detached 8cd8410

Use ASCII ellipses throughout

commit
8cd8410aceca112d3f5cd288aac3f549c1ea5500
author
patriceckhart · · 2026-05-22 15:19 UTC
parent
47257c8
signature
unsigned
refs
tag: v0.1.89
21 files · +87 -54
M README.md +1 -1
@@ -595,7 +595,7 @@ zot can run as a telegram bot so you can DM it from your phone. Two ways to run
595595 Type `/telegram` in the running TUI to open a picker with **connect**, **disconnect**, and **status**. When connected:
596596
597597 - DMs from the paired user become prompts in the **same** session you're typing in, so you can continue a conversation from the terminal on your phone and back again.
598-- Messages you type in the TUI are mirrored into the Telegram thread prefixed `you: …` and the assistant's replies come back prefixed `zot: …`, so the Telegram chat stays a complete record of both sides of the conversation.
598+- Messages you type in the TUI are mirrored into the Telegram thread prefixed `you: ...` and the assistant's replies come back prefixed `zot: ...`, so the Telegram chat stays a complete record of both sides of the conversation.
599599 - Messages sent from Telegram show up as your own bubble in Telegram (no mirror) and the assistant's reply to them comes back bare (no prefix).
600600 - The status bar shows a `- tg -` tag while the bridge is active.
601601 - `/telegram connect` / `/telegram disconnect` / `/telegram status` (or `/tg`) also work as direct commands without the picker.
@@ -595,7 +595,7 @@ zot can run as a telegram bot so you can DM it from your phone. Two ways to run
595 Type `/telegram` in the running TUI to open a picker with **connect**, **disconnect**, and **status**. When connected:595 Type `/telegram` in the running TUI to open a picker with **connect**, **disconnect**, and **status**. When connected:
596 596
597 - DMs from the paired user become prompts in the **same** session you're typing in, so you can continue a conversation from the terminal on your phone and back again.597 - DMs from the paired user become prompts in the **same** session you're typing in, so you can continue a conversation from the terminal on your phone and back again.
598-- Messages you type in the TUI are mirrored into the Telegram thread prefixed `you: …` and the assistant's replies come back prefixed `zot: …`, so the Telegram chat stays a complete record of both sides of the conversation.598+- Messages you type in the TUI are mirrored into the Telegram thread prefixed `you: ...` and the assistant's replies come back prefixed `zot: ...`, so the Telegram chat stays a complete record of both sides of the conversation.
599 - Messages sent from Telegram show up as your own bubble in Telegram (no mirror) and the assistant's reply to them comes back bare (no prefix).599 - Messages sent from Telegram show up as your own bubble in Telegram (no mirror) and the assistant's reply to them comes back bare (no prefix).
600 - The status bar shows a `- tg -` tag while the bridge is active.600 - The status bar shows a `- tg -` tag while the bridge is active.
601 - `/telegram connect` / `/telegram disconnect` / `/telegram status` (or `/tg`) also work as direct commands without the picker.601 - `/telegram connect` / `/telegram disconnect` / `/telegram status` (or `/tg`) also work as direct commands without the picker.
M docs/rpc.md +2 -2
@@ -43,9 +43,9 @@ Every line in either direction is one JSON object terminated by `\n`. Object bou
4343
4444 | `type` | Direction | Description |
4545 |---|---|---|
46-| any command (`prompt`, `abort`, …) | client → server | Request |
46+| any command (`prompt`, `abort`, ...) | client → server | Request |
4747 | `response` | server → client | Reply to one command, correlated by `id` |
48-| any event (`text_delta`, `tool_call`, …) | server → client | Stream notification (no `id`) |
48+| any event (`text_delta`, `tool_call`, ...) | server → client | Stream notification (no `id`) |
4949
5050 ## Commands
5151
@@ -43,9 +43,9 @@ Every line in either direction is one JSON object terminated by `\n`. Object bou
43 43
44 | `type` | Direction | Description |44 | `type` | Direction | Description |
45 |---|---|---|45 |---|---|---|
46-| any command (`prompt`, `abort`, …) | client → server | Request |46+| any command (`prompt`, `abort`, ...) | client → server | Request |
47 | `response` | server → client | Reply to one command, correlated by `id` |47 | `response` | server → client | Reply to one command, correlated by `id` |
48-| any event (`text_delta`, `tool_call`, …) | server → client | Stream notification (no `id`) |48+| any event (`text_delta`, `tool_call`, ...) | server → client | Stream notification (no `id`) |
49 49
50 ## Commands50 ## Commands
51 51
M examples/extensions/scratchpad/README.md +1 -1
@@ -37,7 +37,7 @@ The model also has a `read_notes` tool. Ask it:
3737
3838 > "What did I tell you to remember?"
3939
40-…and it will call the tool and tell you.
40+...and it will call the tool and tell you.
4141
4242 ## Storage
4343
@@ -37,7 +37,7 @@ The model also has a `read_notes` tool. Ask it:
37 37
38 > "What did I tell you to remember?"38 > "What did I tell you to remember?"
39 39
40-…and it will call the tool and tell you.40+...and it will call the tool and tell you.
41 41
42 ## Storage42 ## Storage
43 43
M internal/agent/botcmd.go +3 -3
@@ -427,7 +427,7 @@ func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version st
427427 return s, nil, err
428428 }
429429
430-// maskToken returns "123456:ABC…xyz" so copies of zot telegram-bot status can be
430+// maskToken returns "123456:ABC...xyz" so copies of zot telegram-bot status can be
431431 // pasted into bug reports without leaking the full token.
432432 func maskToken(tok string) string {
433433 if len(tok) <= 10 {
@@ -436,13 +436,13 @@ func maskToken(tok string) string {
436436 // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.
437437 i := strings.IndexByte(tok, ':')
438438 if i < 0 {
439- return tok[:4] + "…" + tok[len(tok)-4:]
439+ return tok[:4] + "..." + tok[len(tok)-4:]
440440 }
441441 body := tok[i+1:]
442442 if len(body) < 8 {
443443 return tok[:i+1] + "<hidden>"
444444 }
445- return tok[:i+1] + body[:3] + "…" + body[len(body)-3:]
445+ return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
446446 }
447447
448448 // _ compile-time hint so the strconv import stays if we later add numeric parsing.
@@ -427,7 +427,7 @@ func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version st
427 return s, nil, err427 return s, nil, err
428 }428 }
429 429
430-// maskToken returns "123456:ABC…xyz" so copies of zot telegram-bot status can be430+// maskToken returns "123456:ABC...xyz" so copies of zot telegram-bot status can be
431 // pasted into bug reports without leaking the full token.431 // pasted into bug reports without leaking the full token.
432 func maskToken(tok string) string {432 func maskToken(tok string) string {
433 if len(tok) <= 10 {433 if len(tok) <= 10 {
@@ -436,13 +436,13 @@ func maskToken(tok string) string {
436 // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.436 // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.
437 i := strings.IndexByte(tok, ':')437 i := strings.IndexByte(tok, ':')
438 if i < 0 {438 if i < 0 {
439- return tok[:4] + "…" + tok[len(tok)-4:]439+ return tok[:4] + "..." + tok[len(tok)-4:]
440 }440 }
441 body := tok[i+1:]441 body := tok[i+1:]
442 if len(body) < 8 {442 if len(body) < 8 {
443 return tok[:i+1] + "<hidden>"443 return tok[:i+1] + "<hidden>"
444 }444 }
445- return tok[:i+1] + body[:3] + "…" + body[len(body)-3:]445+ return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
446 }446 }
447 447
448 // _ compile-time hint so the strconv import stays if we later add numeric parsing.448 // _ compile-time hint so the strconv import stays if we later add numeric parsing.
M internal/agent/modes/confirm_dialog.go +5 -1
@@ -216,7 +216,11 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string {
216216 // Truncate the tail if the line would exceed width; keeps
217217 // the option numbers always visible.
218218 if visibleLen(plain) > width-2 {
219- plain = plain[:width-3] + "\u2026"
219+ if width <= 5 {
220+ plain = "..."[:max(0, width-2)]
221+ } else {
222+ plain = plain[:width-5] + "..."
223+ }
220224 }
221225 if i == cursor {
222226 lines = append(lines, th.PadHighlight(plain, width))
@@ -216,7 +216,11 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string {
216 // Truncate the tail if the line would exceed width; keeps216 // Truncate the tail if the line would exceed width; keeps
217 // the option numbers always visible.217 // the option numbers always visible.
218 if visibleLen(plain) > width-2 {218 if visibleLen(plain) > width-2 {
219- plain = plain[:width-3] + "\u2026"219+ if width <= 5 {
220+ plain = "..."[:max(0, width-2)]
221+ } else {
222+ plain = plain[:width-5] + "..."
223+ }
220 }224 }
221 if i == cursor {225 if i == cursor {
222 lines = append(lines, th.PadHighlight(plain, width))226 lines = append(lines, th.PadHighlight(plain, width))
M internal/agent/modes/help.go +1 -1
@@ -42,7 +42,7 @@ func renderHelpBlock(th tui.Theme, width int) []string {
4242 }
4343
4444 // Label column width uses display cells, not byte length, so
45- // single-cell multibyte runes (← → - …) don't over-count and leave
45+ // single-cell multibyte runes (← → - ...) don't over-count and leave
4646 // a raggedy right edge. `len("alt+← / alt+→")` is 17 bytes but
4747 // only 13 cells; padding off byte length would either overshoot
4848 // (setting labelWidth too high and wasting space on every row)
@@ -42,7 +42,7 @@ func renderHelpBlock(th tui.Theme, width int) []string {
42 }42 }
43 43
44 // Label column width uses display cells, not byte length, so44 // Label column width uses display cells, not byte length, so
45- // single-cell multibyte runes (← → - …) don't over-count and leave45+ // single-cell multibyte runes (← → - ...) don't over-count and leave
46 // a raggedy right edge. `len("alt+← / alt+→")` is 17 bytes but46 // a raggedy right edge. `len("alt+← / alt+→")` is 17 bytes but
47 // only 13 cells; padding off byte length would either overshoot47 // only 13 cells; padding off byte length would either overshoot
48 // (setting labelWidth too high and wasting space on every row)48 // (setting labelWidth too high and wasting space on every row)
M internal/agent/modes/interactive.go +9 -9
@@ -799,7 +799,7 @@ func (i *Interactive) buildChatLocked(cols int) []string {
799799 // narrow terminal.
800800 line := "✓ " + i.statusOK
801801 if cols > 4 && len(line) > cols {
802- line = line[:cols-1] + "…"
802+ line = line[:cols-3] + "..."
803803 }
804804 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
805805 }
@@ -1305,7 +1305,7 @@ func clipBottomClippedImages(lines []string) []string {
13051305 // fixed status bar area. Suppress that image for this frame.
13061306 //
13071307 // When the image lives inside a tool box, the reservation rows
1308- // are wrapped in vertical box edges ("│ … │"); those rows
1308+ // are wrapped in vertical box edges ("│ ... │"); those rows
13091309 // look non-blank under a naive whitespace check but are still
13101310 // reservation rows for this scan, so treat them as blank.
13111311 foundInfo := false
@@ -1329,7 +1329,7 @@ func clipBottomClippedImages(lines []string) []string {
13291329 // stripping ANSI escape sequences, surrounding whitespace, and the
13301330 // vertical box edges drawn by the tool-box renderer. Used by
13311331 // clipBottomClippedImages so an image's reservation rows still count
1332-// as blank when those rows are wrapped in "│ … │" inside a tool box.
1332+// as blank when those rows are wrapped in "│ ... │" inside a tool box.
13331333 func isBoxBlankLine(line string) bool {
13341334 stripped := stripANSIBytes(line)
13351335 stripped = strings.TrimSpace(stripped)
@@ -1338,7 +1338,7 @@ func isBoxBlankLine(line string) bool {
13381338 return stripped == ""
13391339 }
13401340
1341-// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' … final
1341+// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
13421342 // byte) from s without pulling in the regexp package. Mirrors the
13431343 // internal helper in package tui; the duplicated copy avoids exporting
13441344 // it just for one caller.
@@ -1423,10 +1423,10 @@ func truncateLine(s string, n int) string {
14231423 if len(runes) <= n {
14241424 return s
14251425 }
1426- if n <= 1 {
1427- return "…"
1426+ if n <= 3 {
1427+ return strings.Repeat(".", n)
14281428 }
1429- return string(runes[:n-1]) + "…"
1429+ return string(runes[:n-3]) + "..."
14301430 }
14311431
14321432 // ctrlCExitWindow is how long after a ctrl+c press a *second* press
@@ -3626,7 +3626,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
36263626 i.queued = append([]string{prompt}, i.queued...)
36273627 }
36283628 i.statusErr = ""
3629- i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending…"))
3629+ i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
36303630 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
36313631 i.mu.Unlock()
36323632 i.invalidate()
@@ -4042,7 +4042,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
40424042 return
40434043 }
40444044 i.mu.Lock()
4045- i.statusOK = "reloading extensions…"
4045+ i.statusOK = "reloading extensions..."
40464046 i.statusErr = ""
40474047 i.mu.Unlock()
40484048 i.invalidate()
@@ -799,7 +799,7 @@ func (i *Interactive) buildChatLocked(cols int) []string {
799 // narrow terminal.799 // narrow terminal.
800 line := "✓ " + i.statusOK800 line := "✓ " + i.statusOK
801 if cols > 4 && len(line) > cols {801 if cols > 4 && len(line) > cols {
802- line = line[:cols-1] + "…"802+ line = line[:cols-3] + "..."
803 }803 }
804 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")804 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
805 }805 }
@@ -1305,7 +1305,7 @@ func clipBottomClippedImages(lines []string) []string {
1305 // fixed status bar area. Suppress that image for this frame.1305 // fixed status bar area. Suppress that image for this frame.
1306 //1306 //
1307 // When the image lives inside a tool box, the reservation rows1307 // When the image lives inside a tool box, the reservation rows
1308- // are wrapped in vertical box edges ("│ … │"); those rows1308+ // are wrapped in vertical box edges ("│ ... │"); those rows
1309 // look non-blank under a naive whitespace check but are still1309 // look non-blank under a naive whitespace check but are still
1310 // reservation rows for this scan, so treat them as blank.1310 // reservation rows for this scan, so treat them as blank.
1311 foundInfo := false1311 foundInfo := false
@@ -1329,7 +1329,7 @@ func clipBottomClippedImages(lines []string) []string {
1329 // stripping ANSI escape sequences, surrounding whitespace, and the1329 // stripping ANSI escape sequences, surrounding whitespace, and the
1330 // vertical box edges drawn by the tool-box renderer. Used by1330 // vertical box edges drawn by the tool-box renderer. Used by
1331 // clipBottomClippedImages so an image's reservation rows still count1331 // clipBottomClippedImages so an image's reservation rows still count
1332-// as blank when those rows are wrapped in "│ … │" inside a tool box.1332+// as blank when those rows are wrapped in "│ ... │" inside a tool box.
1333 func isBoxBlankLine(line string) bool {1333 func isBoxBlankLine(line string) bool {
1334 stripped := stripANSIBytes(line)1334 stripped := stripANSIBytes(line)
1335 stripped = strings.TrimSpace(stripped)1335 stripped = strings.TrimSpace(stripped)
@@ -1338,7 +1338,7 @@ func isBoxBlankLine(line string) bool {
1338 return stripped == ""1338 return stripped == ""
1339 }1339 }
1340 1340
1341-// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' … final1341+// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
1342 // byte) from s without pulling in the regexp package. Mirrors the1342 // byte) from s without pulling in the regexp package. Mirrors the
1343 // internal helper in package tui; the duplicated copy avoids exporting1343 // internal helper in package tui; the duplicated copy avoids exporting
1344 // it just for one caller.1344 // it just for one caller.
@@ -1423,10 +1423,10 @@ func truncateLine(s string, n int) string {
1423 if len(runes) <= n {1423 if len(runes) <= n {
1424 return s1424 return s
1425 }1425 }
1426- if n <= 1 {1426+ if n <= 3 {
1427- return "…"1427+ return strings.Repeat(".", n)
1428 }1428 }
1429- return string(runes[:n-1]) + "…"1429+ return string(runes[:n-3]) + "..."
1430 }1430 }
1431 1431
1432 // ctrlCExitWindow is how long after a ctrl+c press a *second* press1432 // ctrlCExitWindow is how long after a ctrl+c press a *second* press
@@ -3626,7 +3626,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
3626 i.queued = append([]string{prompt}, i.queued...)3626 i.queued = append([]string{prompt}, i.queued...)
3627 }3627 }
3628 i.statusErr = ""3628 i.statusErr = ""
3629- i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending…"))3629+ i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
3630 i.pendingPostCompactNote = "context auto-compacted; sending your last message"3630 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
3631 i.mu.Unlock()3631 i.mu.Unlock()
3632 i.invalidate()3632 i.invalidate()
@@ -4042,7 +4042,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
4042 return4042 return
4043 }4043 }
4044 i.mu.Lock()4044 i.mu.Lock()
4045- i.statusOK = "reloading extensions…"4045+ i.statusOK = "reloading extensions..."
4046 i.statusErr = ""4046 i.statusErr = ""
4047 i.mu.Unlock()4047 i.mu.Unlock()
4048 i.invalidate()4048 i.invalidate()
M internal/agent/modes/jump_dialog.go +5 -1
@@ -169,7 +169,11 @@ func formatJumpRowPlain(t jumpTarget, maxWidth int) string {
169169 }
170170 preview := t.Preview
171171 if len(preview) > room {
172- preview = preview[:room-1] + "\u2026"
172+ if room <= 3 {
173+ preview = "..."[:room]
174+ } else {
175+ preview = preview[:room-3] + "..."
176+ }
173177 }
174178 return left + preview
175179 }
@@ -169,7 +169,11 @@ func formatJumpRowPlain(t jumpTarget, maxWidth int) string {
169 }169 }
170 preview := t.Preview170 preview := t.Preview
171 if len(preview) > room {171 if len(preview) > room {
172- preview = preview[:room-1] + "\u2026"172+ if room <= 3 {
173+ preview = "..."[:room]
174+ } else {
175+ preview = preview[:room-3] + "..."
176+ }
173 }177 }
174 return left + preview178 return left + preview
175 }179 }
M internal/agent/modes/model_dialog.go +2 -2
@@ -188,10 +188,10 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
188188 }
189189
190190 if start > 0 {
191- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))
191+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start)))
192192 }
193193 if end < len(d.view) {
194- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))
194+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
195195 }
196196
197197 lines = append(lines, frameRule(th, width))
@@ -188,10 +188,10 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
188 }188 }
189 189
190 if start > 0 {190 if start > 0 {
191- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))191+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start)))
192 }192 }
193 if end < len(d.view) {193 if end < len(d.view) {
194- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))194+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
195 }195 }
196 196
197 lines = append(lines, frameRule(th, width))197 lines = append(lines, frameRule(th, width))
M internal/agent/modes/rescue_dialog.go +3 -3
@@ -165,10 +165,10 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string {
165165 }
166166 }
167167 if start > 0 {
168- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))
168+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start)))
169169 }
170170 if end < len(d.view) {
171- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))
171+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
172172 }
173173
174174 lines = append(lines, frameRule(th, width))
@@ -278,7 +278,7 @@ func shortError(msg string) string {
278278 if len(msg) <= max {
279279 return msg
280280 }
281- return msg[:max] + "…"
281+ return msg[:max] + "..."
282282 }
283283
284284 // extractFailedProvider tries to pull the failing provider name out
@@ -165,10 +165,10 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string {
165 }165 }
166 }166 }
167 if start > 0 {167 if start > 0 {
168- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))168+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start)))
169 }169 }
170 if end < len(d.view) {170 if end < len(d.view) {
171- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))171+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
172 }172 }
173 173
174 lines = append(lines, frameRule(th, width))174 lines = append(lines, frameRule(th, width))
@@ -278,7 +278,7 @@ func shortError(msg string) string {
278 if len(msg) <= max {278 if len(msg) <= max {
279 return msg279 return msg
280 }280 }
281- return msg[:max] + "…"281+ return msg[:max] + "..."
282 }282 }
283 283
284 // extractFailedProvider tries to pull the failing provider name out284 // extractFailedProvider tries to pull the failing provider name out
M internal/agent/modes/session_dialog.go +6 -2
@@ -193,13 +193,17 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
193193 }
194194 runes := []rune(summary)
195195 if len(runes) > room {
196- summary = string(runes[:room-1]) + "…"
196+ summary = string(runes[:room-3]) + "..."
197197 }
198198 row := left + summary
199199 // Hard clamp: ensure the full row never exceeds maxWidth.
200200 rowRunes := []rune(row)
201201 if len(rowRunes) > maxWidth {
202- row = string(rowRunes[:maxWidth-1]) + "…"
202+ if maxWidth <= 3 {
203+ row = strings.Repeat(".", maxWidth)
204+ } else {
205+ row = string(rowRunes[:maxWidth-3]) + "..."
206+ }
203207 }
204208 return row
205209 }
@@ -193,13 +193,17 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
193 }193 }
194 runes := []rune(summary)194 runes := []rune(summary)
195 if len(runes) > room {195 if len(runes) > room {
196- summary = string(runes[:room-1]) + "…"196+ summary = string(runes[:room-3]) + "..."
197 }197 }
198 row := left + summary198 row := left + summary
199 // Hard clamp: ensure the full row never exceeds maxWidth.199 // Hard clamp: ensure the full row never exceeds maxWidth.
200 rowRunes := []rune(row)200 rowRunes := []rune(row)
201 if len(rowRunes) > maxWidth {201 if len(rowRunes) > maxWidth {
202- row = string(rowRunes[:maxWidth-1]) + "…"202+ if maxWidth <= 3 {
203+ row = strings.Repeat(".", maxWidth)
204+ } else {
205+ row = string(rowRunes[:maxWidth-3]) + "..."
206+ }
203 }207 }
204 return row208 return row
205 }209 }
M internal/agent/modes/session_tree_dialog.go +1 -1
@@ -149,7 +149,7 @@ func formatTreeRow(n *core.TreeNode) string {
149149 }
150150 }
151151 if len(preview) > 50 {
152- preview = preview[:49] + "\u2026"
152+ preview = preview[:47] + "..."
153153 }
154154 return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount)
155155 }
@@ -149,7 +149,7 @@ func formatTreeRow(n *core.TreeNode) string {
149 }149 }
150 }150 }
151 if len(preview) > 50 {151 if len(preview) > 50 {
152- preview = preview[:49] + "\u2026"152+ preview = preview[:47] + "..."
153 }153 }
154 return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount)154 return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount)
155 }155 }
M internal/agent/modes/skills_dialog.go +8 -4
@@ -173,7 +173,11 @@ func formatSkillRow(s *skills.Skill, maxWidth int) string {
173173 }
174174 desc := s.Description
175175 if len(desc) > room {
176- desc = desc[:room-1] + "\u2026"
176+ if room <= 3 {
177+ desc = strings.Repeat(".", room)
178+ } else {
179+ desc = desc[:room-3] + "..."
180+ }
177181 }
178182 return left + desc + src
179183 }
@@ -185,10 +189,10 @@ func truncateLineSafe(s string, n int) string {
185189 if len(r) <= n {
186190 return s
187191 }
188- if n <= 1 {
189- return "\u2026"
192+ if n <= 3 {
193+ return strings.Repeat(".", n)
190194 }
191- return string(r[:n-1]) + "\u2026"
195+ return string(r[:n-3]) + "..."
192196 }
193197
194198 // visibleWindow centers cursor in a window of size n within total
@@ -173,7 +173,11 @@ func formatSkillRow(s *skills.Skill, maxWidth int) string {
173 }173 }
174 desc := s.Description174 desc := s.Description
175 if len(desc) > room {175 if len(desc) > room {
176- desc = desc[:room-1] + "\u2026"176+ if room <= 3 {
177+ desc = strings.Repeat(".", room)
178+ } else {
179+ desc = desc[:room-3] + "..."
180+ }
177 }181 }
178 return left + desc + src182 return left + desc + src
179 }183 }
@@ -185,10 +189,10 @@ func truncateLineSafe(s string, n int) string {
185 if len(r) <= n {189 if len(r) <= n {
186 return s190 return s
187 }191 }
188- if n <= 1 {192+ if n <= 3 {
189- return "\u2026"193+ return strings.Repeat(".", n)
190 }194 }
191- return string(r[:n-1]) + "\u2026"195+ return string(r[:n-3]) + "..."
192 }196 }
193 197
194 // visibleWindow centers cursor in a window of size n within total198 // visibleWindow centers cursor in a window of size n within total
M internal/agent/modes/swarm_dialog.go +6 -2
@@ -1357,12 +1357,16 @@ func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
13571357 act = r.Task
13581358 }
13591359 if len([]rune(act)) > room {
1360- act = string([]rune(act)[:room-1]) + "…"
1360+ act = string([]rune(act)[:room-3]) + "..."
13611361 }
13621362 row := left + act
13631363 rowRunes := []rune(row)
13641364 if len(rowRunes) > maxWidth {
1365- row = string(rowRunes[:maxWidth-1]) + "…"
1365+ if maxWidth <= 3 {
1366+ row = strings.Repeat(".", maxWidth)
1367+ } else {
1368+ row = string(rowRunes[:maxWidth-3]) + "..."
1369+ }
13661370 }
13671371 return row
13681372 }
@@ -1357,12 +1357,16 @@ func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
1357 act = r.Task1357 act = r.Task
1358 }1358 }
1359 if len([]rune(act)) > room {1359 if len([]rune(act)) > room {
1360- act = string([]rune(act)[:room-1]) + "…"1360+ act = string([]rune(act)[:room-3]) + "..."
1361 }1361 }
1362 row := left + act1362 row := left + act
1363 rowRunes := []rune(row)1363 rowRunes := []rune(row)
1364 if len(rowRunes) > maxWidth {1364 if len(rowRunes) > maxWidth {
1365- row = string(rowRunes[:maxWidth-1]) + "…"1365+ if maxWidth <= 3 {
1366+ row = strings.Repeat(".", maxWidth)
1367+ } else {
1368+ row = string(rowRunes[:maxWidth-3]) + "..."
1369+ }
1366 }1370 }
1367 return row1371 return row
1368 }1372 }
M internal/agent/swarm_agent.go +4 -1
@@ -254,7 +254,10 @@ func truncateForLog(s string, n int) string {
254254 if len(s) <= n {
255255 return s
256256 }
257- return s[:n-1] + "…"
257+ if n <= 3 {
258+ return strings.Repeat(".", n)
259+ }
260+ return s[:n-3] + "..."
258261 }
259262
260263 // _ keeps the provider import used; provider types may surface
@@ -254,7 +254,10 @@ func truncateForLog(s string, n int) string {
254 if len(s) <= n {254 if len(s) <= n {
255 return s255 return s
256 }256 }
257- return s[:n-1] + "…"257+ if n <= 3 {
258+ return strings.Repeat(".", n)
259+ }
260+ return s[:n-3] + "..."
258 }261 }
259 262
260 // _ keeps the provider import used; provider types may surface263 // _ keeps the provider import used; provider types may surface
M internal/agent/updatecmd.go +5 -5
@@ -108,7 +108,7 @@ func runUpdate(version string) error {
108108 ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
109109 defer cancel()
110110
111- fmt.Println("zot update: querying latest release…")
111+ fmt.Println("zot update: querying latest release...")
112112 tag, releaseURL, err := fetchLatestRelease(ctx)
113113 if err != nil {
114114 return fmt.Errorf("query latest release: %w", err)
@@ -147,7 +147,7 @@ func runUpdate(version string) error {
147147 // users can clear /tmp themselves.
148148 defer func() { _ = os.RemoveAll(tmp) }()
149149
150- fmt.Println("zot update: downloading checksums.txt…")
150+ fmt.Println("zot update: downloading checksums.txt...")
151151 sumsPath := filepath.Join(tmp, "checksums.txt")
152152 if err := downloadFile(ctx, sumsURL, sumsPath); err != nil {
153153 return fmt.Errorf("download checksums: %w", err)
@@ -157,13 +157,13 @@ func runUpdate(version string) error {
157157 return err
158158 }
159159
160- fmt.Println("zot update: downloading archive…")
160+ fmt.Println("zot update: downloading archive...")
161161 archivePath := filepath.Join(tmp, assetName)
162162 if err := downloadFile(ctx, assetURL, archivePath); err != nil {
163163 return fmt.Errorf("download archive: %w", err)
164164 }
165165
166- fmt.Println("zot update: verifying checksum…")
166+ fmt.Println("zot update: verifying checksum...")
167167 gotSum, err := sha256File(archivePath)
168168 if err != nil {
169169 return fmt.Errorf("hash archive: %w", err)
@@ -172,7 +172,7 @@ func runUpdate(version string) error {
172172 return fmt.Errorf("checksum mismatch for %s: got %s, want %s", assetName, gotSum, wantSum)
173173 }
174174
175- fmt.Println("zot update: extracting…")
175+ fmt.Println("zot update: extracting...")
176176 extractDir := filepath.Join(tmp, "extracted")
177177 if err := os.MkdirAll(extractDir, 0o755); err != nil {
178178 return fmt.Errorf("mkdir extract: %w", err)
@@ -108,7 +108,7 @@ func runUpdate(version string) error {
108 ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)108 ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
109 defer cancel()109 defer cancel()
110 110
111- fmt.Println("zot update: querying latest release…")111+ fmt.Println("zot update: querying latest release...")
112 tag, releaseURL, err := fetchLatestRelease(ctx)112 tag, releaseURL, err := fetchLatestRelease(ctx)
113 if err != nil {113 if err != nil {
114 return fmt.Errorf("query latest release: %w", err)114 return fmt.Errorf("query latest release: %w", err)
@@ -147,7 +147,7 @@ func runUpdate(version string) error {
147 // users can clear /tmp themselves.147 // users can clear /tmp themselves.
148 defer func() { _ = os.RemoveAll(tmp) }()148 defer func() { _ = os.RemoveAll(tmp) }()
149 149
150- fmt.Println("zot update: downloading checksums.txt…")150+ fmt.Println("zot update: downloading checksums.txt...")
151 sumsPath := filepath.Join(tmp, "checksums.txt")151 sumsPath := filepath.Join(tmp, "checksums.txt")
152 if err := downloadFile(ctx, sumsURL, sumsPath); err != nil {152 if err := downloadFile(ctx, sumsURL, sumsPath); err != nil {
153 return fmt.Errorf("download checksums: %w", err)153 return fmt.Errorf("download checksums: %w", err)
@@ -157,13 +157,13 @@ func runUpdate(version string) error {
157 return err157 return err
158 }158 }
159 159
160- fmt.Println("zot update: downloading archive…")160+ fmt.Println("zot update: downloading archive...")
161 archivePath := filepath.Join(tmp, assetName)161 archivePath := filepath.Join(tmp, assetName)
162 if err := downloadFile(ctx, assetURL, archivePath); err != nil {162 if err := downloadFile(ctx, assetURL, archivePath); err != nil {
163 return fmt.Errorf("download archive: %w", err)163 return fmt.Errorf("download archive: %w", err)
164 }164 }
165 165
166- fmt.Println("zot update: verifying checksum…")166+ fmt.Println("zot update: verifying checksum...")
167 gotSum, err := sha256File(archivePath)167 gotSum, err := sha256File(archivePath)
168 if err != nil {168 if err != nil {
169 return fmt.Errorf("hash archive: %w", err)169 return fmt.Errorf("hash archive: %w", err)
@@ -172,7 +172,7 @@ func runUpdate(version string) error {
172 return fmt.Errorf("checksum mismatch for %s: got %s, want %s", assetName, gotSum, wantSum)172 return fmt.Errorf("checksum mismatch for %s: got %s, want %s", assetName, gotSum, wantSum)
173 }173 }
174 174
175- fmt.Println("zot update: extracting…")175+ fmt.Println("zot update: extracting...")
176 extractDir := filepath.Join(tmp, "extracted")176 extractDir := filepath.Join(tmp, "extracted")
177 if err := os.MkdirAll(extractDir, 0o755); err != nil {177 if err := os.MkdirAll(extractDir, 0o755); err != nil {
178 return fmt.Errorf("mkdir extract: %w", err)178 return fmt.Errorf("mkdir extract: %w", err)
M internal/core/confirm.go +4 -1
@@ -177,5 +177,8 @@ func truncatePreview(s string, n int) string {
177177 if len(s) <= n {
178178 return s
179179 }
180- return s[:n-1] + "\u2026"
180+ if n <= 3 {
181+ return "..."[:n]
182+ }
183+ return s[:n-3] + "..."
181184 }
@@ -177,5 +177,8 @@ func truncatePreview(s string, n int) string {
177 if len(s) <= n {177 if len(s) <= n {
178 return s178 return s
179 }179 }
180- return s[:n-1] + "\u2026"180+ if n <= 3 {
181+ return "..."[:n]
182+ }
183+ return s[:n-3] + "..."
181 }184 }
M internal/core/confirm_test.go +2 -1
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "encoding/json"
5+ "strings"
56 "sync"
67 "testing"
78 )
@@ -180,5 +181,5 @@ func TestBuildPreview(t *testing.T) {
180181 }
181182
182183 func hasEllipsis(s string) bool {
183- return len(s) > 0 && s[len(s)-len("\u2026"):] == "\u2026"
184+ return strings.HasSuffix(s, "...")
184185 }
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5+ "strings"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 )8 )
@@ -180,5 +181,5 @@ func TestBuildPreview(t *testing.T) {
180 }181 }
181 182
182 func hasEllipsis(s string) bool {183 func hasEllipsis(s string) bool {
183- return len(s) > 0 && s[len(s)-len("\u2026"):] == "\u2026"184+ return strings.HasSuffix(s, "...")
184 }185 }
M internal/swarm/swarm.go +4 -1
@@ -509,7 +509,10 @@ func truncate(s string, n int) string {
509509 if len(s) <= n {
510510 return s
511511 }
512- return s[:n-1] + "…"
512+ if n <= 3 {
513+ return strings.Repeat(".", n)
514+ }
515+ return s[:n-3] + "..."
513516 }
514517
515518 func lastN(lines []string, n int) []string {
@@ -509,7 +509,10 @@ func truncate(s string, n int) string {
509 if len(s) <= n {509 if len(s) <= n {
510 return s510 return s
511 }511 }
512- return s[:n-1] + "…"512+ if n <= 3 {
513+ return strings.Repeat(".", n)
514+ }
515+ return s[:n-3] + "..."
513 }516 }
514 517
515 func lastN(lines []string, n int) []string {518 func lastN(lines []string, n int) []string {
M internal/tui/editor.go +1 -1
@@ -1160,7 +1160,7 @@ func max(a, b int) int {
11601160 }
11611161
11621162 // pasteCollapseLineThreshold and pasteCollapseCharThreshold govern
1163-// when a bracketed paste gets collapsed to a [pasted text #N …]
1163+// when a bracketed paste gets collapsed to a [pasted text #N ...]
11641164 // placeholder instead of being inserted inline. Either trigger
11651165 // alone is enough — a 500-line log dump and a 1200-character
11661166 // one-line log entry both bloat the editor in ways the user
@@ -1160,7 +1160,7 @@ func max(a, b int) int {
1160 }1160 }
1161 1161
1162 // pasteCollapseLineThreshold and pasteCollapseCharThreshold govern1162 // pasteCollapseLineThreshold and pasteCollapseCharThreshold govern
1163-// when a bracketed paste gets collapsed to a [pasted text #N …]1163+// when a bracketed paste gets collapsed to a [pasted text #N ...]
1164 // placeholder instead of being inserted inline. Either trigger1164 // placeholder instead of being inserted inline. Either trigger
1165 // alone is enough — a 500-line log dump and a 1200-character1165 // alone is enough — a 500-line log dump and a 1200-character
1166 // one-line log entry both bloat the editor in ways the user1166 // one-line log entry both bloat the editor in ways the user
M internal/tui/view.go +14 -11
@@ -654,7 +654,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
654654 // before the metadata caption) are tagged with the
655655 // imageFootprintSentinel by renderImageBlock. Strip
656656 // the tag, parse the optional width hint, then wrap
657- // the row in the usual │ … │ box edges so the
657+ // the row in the usual │ ... │ box edges so the
658658 // frame stays continuous around the image.
659659 imgCells, stripped := parseImageFootprint(line)
660660 if hasImageEscapeLine(stripped) {
@@ -775,7 +775,7 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
775775 }
776776
777777 // wrapLiveBody returns the streaming body content as a list of
778-// box-side rows: each line wrapped in │ … │ with right padding so
778+// box-side rows: each line wrapped in │ ... │ with right padding so
779779 // the closing edge sits at column width-1. The caller (renderToolCall)
780780 // supplies the surrounding top/bottom edges so the live overlay
781781 // renders as a closed box matching the finalised transcript form.
@@ -848,10 +848,10 @@ func toolBoxTop(th Theme, label string, width int) string {
848848 // terminals (the chat column is usually wide enough).
849849 over := -fill
850850 runes := []rune(label)
851- if over+1 < len(runes) {
852- label = string(runes[:len(runes)-over-1]) + "…"
851+ if over+3 < len(runes) {
852+ label = string(runes[:len(runes)-over-3]) + "..."
853853 } else {
854- label = "…"
854+ label = "..."
855855 }
856856 used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix)
857857 fill = w - used - 1
@@ -906,7 +906,7 @@ func hasImageEscapeLine(s string) bool {
906906 // imageFootprintSentinel marks rows that belong to an inline-image's
907907 // reserved footprint — the escape row plus the blank rows below it
908908 // plus the gap row before the metadata caption. Any consumer that
909-// wraps content in box edges (│ … │) detects the sentinel, strips
909+// wraps content in box edges (│ ... │) detects the sentinel, strips
910910 // it, and emits the row — the image graphics rectangle paints over
911911 // whatever was drawn there. Uses a non-printing C0 control byte so
912912 // it can never appear in normal text or in an ANSI escape sequence
@@ -1124,7 +1124,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
11241124 // colors matching git diff conventions.
11251125 func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string {
11261126 // Legacy path: transcripts saved before we dropped line numbers
1127- // from the read tool still carry " 1\t…" prefixes. Detect and
1127+ // from the read tool still carry " 1\t..." prefixes. Detect and
11281128 // strip them, then fall through to the highlighter.
11291129 if looksLikeNumberedFile(text) {
11301130 return v.renderNumberedFile(text, sourcePath)
@@ -1308,7 +1308,10 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
13081308 // output is unreliable.
13091309 maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */
13101310 if maxCode > 0 && len(code) > maxCode {
1311- trunc := code[:maxCode-1] + "…"
1311+ trunc := strings.Repeat(".", maxCode)
1312+ if maxCode > 3 {
1313+ trunc = code[:maxCode-3] + "..."
1314+ }
13121315 if lang != "" {
13131316 if h := HighlightCode(trunc, lang); len(h) == 1 {
13141317 codeRendered = h[0]
@@ -1379,7 +1382,7 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
13791382 // edge plus a small interior gutter so the image rectangle
13801383 // sits visibly inside the frame instead of kissing the │.
13811384 // The escape row carries a width hint after the sentinel
1382- // ("\x1e<cells>\x1e\u2026") so toolBoxSide knows how many
1385+ // ("\x1e<cells>\x1e...") so toolBoxSide knows how many
13831386 // cells the image occupies and can pad to the right edge.
13841387 widthHint := fmt.Sprintf("%s%d%s", imageFootprintSentinel, actualCells, imageFootprintSentinel)
13851388 out := make([]string, 0, rows+3)
@@ -1680,7 +1683,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st
16801683 continue
16811684 }
16821685 if l == "..." {
1683- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "…"))
1686+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "..."))
16841687 continue
16851688 }
16861689 switch l[0] {
@@ -1899,7 +1902,7 @@ func truncateLines(s string, n int) string {
18991902 if len(lines) <= n {
19001903 return s
19011904 }
1902- return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
1905+ return strings.Join(lines[:n], "\n") + "\n ... (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
19031906 }
19041907
19051908 // renderCompactionBlock renders a compaction summary as a distinct
@@ -654,7 +654,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
654 // before the metadata caption) are tagged with the654 // before the metadata caption) are tagged with the
655 // imageFootprintSentinel by renderImageBlock. Strip655 // imageFootprintSentinel by renderImageBlock. Strip
656 // the tag, parse the optional width hint, then wrap656 // the tag, parse the optional width hint, then wrap
657- // the row in the usual │ … │ box edges so the657+ // the row in the usual │ ... │ box edges so the
658 // frame stays continuous around the image.658 // frame stays continuous around the image.
659 imgCells, stripped := parseImageFootprint(line)659 imgCells, stripped := parseImageFootprint(line)
660 if hasImageEscapeLine(stripped) {660 if hasImageEscapeLine(stripped) {
@@ -775,7 +775,7 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
775 }775 }
776 776
777 // wrapLiveBody returns the streaming body content as a list of777 // wrapLiveBody returns the streaming body content as a list of
778-// box-side rows: each line wrapped in │ … │ with right padding so778+// box-side rows: each line wrapped in │ ... │ with right padding so
779 // the closing edge sits at column width-1. The caller (renderToolCall)779 // the closing edge sits at column width-1. The caller (renderToolCall)
780 // supplies the surrounding top/bottom edges so the live overlay780 // supplies the surrounding top/bottom edges so the live overlay
781 // renders as a closed box matching the finalised transcript form.781 // renders as a closed box matching the finalised transcript form.
@@ -848,10 +848,10 @@ func toolBoxTop(th Theme, label string, width int) string {
848 // terminals (the chat column is usually wide enough).848 // terminals (the chat column is usually wide enough).
849 over := -fill849 over := -fill
850 runes := []rune(label)850 runes := []rune(label)
851- if over+1 < len(runes) {851+ if over+3 < len(runes) {
852- label = string(runes[:len(runes)-over-1]) + "…"852+ label = string(runes[:len(runes)-over-3]) + "..."
853 } else {853 } else {
854- label = "…"854+ label = "..."
855 }855 }
856 used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix)856 used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix)
857 fill = w - used - 1857 fill = w - used - 1
@@ -906,7 +906,7 @@ func hasImageEscapeLine(s string) bool {
906 // imageFootprintSentinel marks rows that belong to an inline-image's906 // imageFootprintSentinel marks rows that belong to an inline-image's
907 // reserved footprint — the escape row plus the blank rows below it907 // reserved footprint — the escape row plus the blank rows below it
908 // plus the gap row before the metadata caption. Any consumer that908 // plus the gap row before the metadata caption. Any consumer that
909-// wraps content in box edges (│ … │) detects the sentinel, strips909+// wraps content in box edges (│ ... │) detects the sentinel, strips
910 // it, and emits the row — the image graphics rectangle paints over910 // it, and emits the row — the image graphics rectangle paints over
911 // whatever was drawn there. Uses a non-printing C0 control byte so911 // whatever was drawn there. Uses a non-printing C0 control byte so
912 // it can never appear in normal text or in an ANSI escape sequence912 // it can never appear in normal text or in an ANSI escape sequence
@@ -1124,7 +1124,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1124 // colors matching git diff conventions.1124 // colors matching git diff conventions.
1125 func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string {1125 func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string {
1126 // Legacy path: transcripts saved before we dropped line numbers1126 // Legacy path: transcripts saved before we dropped line numbers
1127- // from the read tool still carry " 1\t…" prefixes. Detect and1127+ // from the read tool still carry " 1\t..." prefixes. Detect and
1128 // strip them, then fall through to the highlighter.1128 // strip them, then fall through to the highlighter.
1129 if looksLikeNumberedFile(text) {1129 if looksLikeNumberedFile(text) {
1130 return v.renderNumberedFile(text, sourcePath)1130 return v.renderNumberedFile(text, sourcePath)
@@ -1308,7 +1308,10 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
1308 // output is unreliable.1308 // output is unreliable.
1309 maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */1309 maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */
1310 if maxCode > 0 && len(code) > maxCode {1310 if maxCode > 0 && len(code) > maxCode {
1311- trunc := code[:maxCode-1] + "…"1311+ trunc := strings.Repeat(".", maxCode)
1312+ if maxCode > 3 {
1313+ trunc = code[:maxCode-3] + "..."
1314+ }
1312 if lang != "" {1315 if lang != "" {
1313 if h := HighlightCode(trunc, lang); len(h) == 1 {1316 if h := HighlightCode(trunc, lang); len(h) == 1 {
1314 codeRendered = h[0]1317 codeRendered = h[0]
@@ -1379,7 +1382,7 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
1379 // edge plus a small interior gutter so the image rectangle1382 // edge plus a small interior gutter so the image rectangle
1380 // sits visibly inside the frame instead of kissing the │.1383 // sits visibly inside the frame instead of kissing the │.
1381 // The escape row carries a width hint after the sentinel1384 // The escape row carries a width hint after the sentinel
1382- // ("\x1e<cells>\x1e\u2026") so toolBoxSide knows how many1385+ // ("\x1e<cells>\x1e...") so toolBoxSide knows how many
1383 // cells the image occupies and can pad to the right edge.1386 // cells the image occupies and can pad to the right edge.
1384 widthHint := fmt.Sprintf("%s%d%s", imageFootprintSentinel, actualCells, imageFootprintSentinel)1387 widthHint := fmt.Sprintf("%s%d%s", imageFootprintSentinel, actualCells, imageFootprintSentinel)
1385 out := make([]string, 0, rows+3)1388 out := make([]string, 0, rows+3)
@@ -1680,7 +1683,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st
1680 continue1683 continue
1681 }1684 }
1682 if l == "..." {1685 if l == "..." {
1683- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "…"))1686+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "..."))
1684 continue1687 continue
1685 }1688 }
1686 switch l[0] {1689 switch l[0] {
@@ -1899,7 +1902,7 @@ func truncateLines(s string, n int) string {
1899 if len(lines) <= n {1902 if len(lines) <= n {
1900 return s1903 return s
1901 }1904 }
1902- return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"1905+ return strings.Join(lines[:n], "\n") + "\n ... (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
1903 }1906 }
1904 1907
1905 // renderCompactionBlock renders a compaction summary as a distinct1908 // renderCompactionBlock renders a compaction summary as a distinct

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close