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 | |||
| 595 | 595 | Type `/telegram` in the running TUI to open a picker with **connect**, **disconnect**, and **status**. When connected: | |
| 596 | 596 | ||
| 597 | 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 | 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 | 600 | - The status bar shows a `- tg -` tag while the bridge is active. | |
| 601 | 601 | - `/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 | |||
| 43 | 43 | ||
| 44 | 44 | | `type` | Direction | Description | | |
| 45 | 45 | |---|---|---| | |
| 46 | - | | any command (`prompt`, `abort`, …) | client → server | Request | | |
| 46 | + | | any command (`prompt`, `abort`, ...) | client → server | Request | | |
| 47 | 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 | 50 | ## Commands | |
| 51 | 51 | ||
| @@ -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 | ## Commands | 50 | ## 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: | |||
| 37 | 37 | ||
| 38 | 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 | 42 | ## Storage | |
| 43 | 43 | ||
| @@ -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 | ## Storage | 42 | ## 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 | |||
| 427 | 427 | return s, nil, err | |
| 428 | 428 | } | |
| 429 | 429 | ||
| 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 | |
| 431 | 431 | // pasted into bug reports without leaking the full token. | |
| 432 | 432 | func maskToken(tok string) string { | |
| 433 | 433 | if len(tok) <= 10 { | |
| @@ -436,13 +436,13 @@ func maskToken(tok string) string { | |||
| 436 | 436 | // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body. | |
| 437 | 437 | i := strings.IndexByte(tok, ':') | |
| 438 | 438 | if i < 0 { | |
| 439 | - | return tok[:4] + "…" + tok[len(tok)-4:] | |
| 439 | + | return tok[:4] + "..." + tok[len(tok)-4:] | |
| 440 | 440 | } | |
| 441 | 441 | body := tok[i+1:] | |
| 442 | 442 | if len(body) < 8 { | |
| 443 | 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 | 448 | // _ 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, err | 427 | return s, nil, err | ||
| 428 | } | 428 | } | ||
| 429 | 429 | ||||
| 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 |
| 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 { | |||
| 216 | 216 | // Truncate the tail if the line would exceed width; keeps | |
| 217 | 217 | // the option numbers always visible. | |
| 218 | 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 | 225 | if i == cursor { | |
| 222 | 226 | 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; keeps | 216 | // 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 { | |||
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | // 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 | |
| 46 | 46 | // a raggedy right edge. `len("alt+← / alt+→")` is 17 bytes but | |
| 47 | 47 | // only 13 cells; padding off byte length would either overshoot | |
| 48 | 48 | // (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, so | 44 | // 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 |
| 46 | // a raggedy right edge. `len("alt+← / alt+→")` is 17 bytes but | 46 | // a raggedy right edge. `len("alt+← / alt+→")` is 17 bytes but | ||
| 47 | // only 13 cells; padding off byte length would either overshoot | 47 | // 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 { | |||
| 799 | 799 | // narrow terminal. | |
| 800 | 800 | line := "✓ " + i.statusOK | |
| 801 | 801 | if cols > 4 && len(line) > cols { | |
| 802 | - | line = line[:cols-1] + "…" | |
| 802 | + | line = line[:cols-3] + "..." | |
| 803 | 803 | } | |
| 804 | 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 | 1305 | // fixed status bar area. Suppress that image for this frame. | |
| 1306 | 1306 | // | |
| 1307 | 1307 | // 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 | |
| 1309 | 1309 | // look non-blank under a naive whitespace check but are still | |
| 1310 | 1310 | // reservation rows for this scan, so treat them as blank. | |
| 1311 | 1311 | foundInfo := false | |
| @@ -1329,7 +1329,7 @@ func clipBottomClippedImages(lines []string) []string { | |||
| 1329 | 1329 | // stripping ANSI escape sequences, surrounding whitespace, and the | |
| 1330 | 1330 | // vertical box edges drawn by the tool-box renderer. Used by | |
| 1331 | 1331 | // 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 | 1333 | func isBoxBlankLine(line string) bool { | |
| 1334 | 1334 | stripped := stripANSIBytes(line) | |
| 1335 | 1335 | stripped = strings.TrimSpace(stripped) | |
| @@ -1338,7 +1338,7 @@ func isBoxBlankLine(line string) bool { | |||
| 1338 | 1338 | return stripped == "" | |
| 1339 | 1339 | } | |
| 1340 | 1340 | ||
| 1341 | - | // stripANSIBytes removes ANSI CSI escape sequences (ESC '[' … final | |
| 1341 | + | // stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final | |
| 1342 | 1342 | // byte) from s without pulling in the regexp package. Mirrors the | |
| 1343 | 1343 | // internal helper in package tui; the duplicated copy avoids exporting | |
| 1344 | 1344 | // it just for one caller. | |
| @@ -1423,10 +1423,10 @@ func truncateLine(s string, n int) string { | |||
| 1423 | 1423 | if len(runes) <= n { | |
| 1424 | 1424 | return s | |
| 1425 | 1425 | } | |
| 1426 | - | if n <= 1 { | |
| 1427 | - | return "…" | |
| 1426 | + | if n <= 3 { | |
| 1427 | + | return strings.Repeat(".", n) | |
| 1428 | 1428 | } | |
| 1429 | - | return string(runes[:n-1]) + "…" | |
| 1429 | + | return string(runes[:n-3]) + "..." | |
| 1430 | 1430 | } | |
| 1431 | 1431 | ||
| 1432 | 1432 | // 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 | 3626 | i.queued = append([]string{prompt}, i.queued...) | |
| 3627 | 3627 | } | |
| 3628 | 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 | 3630 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | |
| 3631 | 3631 | i.mu.Unlock() | |
| 3632 | 3632 | i.invalidate() | |
| @@ -4042,7 +4042,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) { | |||
| 4042 | 4042 | return | |
| 4043 | 4043 | } | |
| 4044 | 4044 | i.mu.Lock() | |
| 4045 | - | i.statusOK = "reloading extensions…" | |
| 4045 | + | i.statusOK = "reloading extensions..." | |
| 4046 | 4046 | i.statusErr = "" | |
| 4047 | 4047 | i.mu.Unlock() | |
| 4048 | 4048 | i.invalidate() | |
| @@ -799,7 +799,7 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 799 | // narrow terminal. | 799 | // narrow terminal. | ||
| 800 | line := "✓ " + i.statusOK | 800 | 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 rows | 1307 | // 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 |
| 1309 | // look non-blank under a naive whitespace check but are still | 1309 | // 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 := false | 1311 | foundInfo := false | ||
| @@ -1329,7 +1329,7 @@ func clipBottomClippedImages(lines []string) []string { | |||||
| 1329 | // stripping ANSI escape sequences, surrounding whitespace, and the | 1329 | // stripping ANSI escape sequences, surrounding whitespace, and the | ||
| 1330 | // vertical box edges drawn by the tool-box renderer. Used by | 1330 | // vertical box edges drawn by the tool-box renderer. Used by | ||
| 1331 | // clipBottomClippedImages so an image's reservation rows still count | 1331 | // 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 '[' … final | 1341 | + | // stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final |
| 1342 | // byte) from s without pulling in the regexp package. Mirrors the | 1342 | // byte) from s without pulling in the regexp package. Mirrors the | ||
| 1343 | // internal helper in package tui; the duplicated copy avoids exporting | 1343 | // 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 s | 1424 | 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* press | 1432 | // 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 | return | 4042 | 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 { | |||
| 169 | 169 | } | |
| 170 | 170 | preview := t.Preview | |
| 171 | 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 | 178 | return left + preview | |
| 175 | 179 | } | |
| @@ -169,7 +169,11 @@ func formatJumpRowPlain(t jumpTarget, maxWidth int) string { | |||||
| 169 | } | 169 | } | ||
| 170 | preview := t.Preview | 170 | 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 + preview | 178 | 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 { | |||
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | 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 | 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 | 197 | 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 { | |||
| 165 | 165 | } | |
| 166 | 166 | } | |
| 167 | 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 | 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 | 174 | lines = append(lines, frameRule(th, width)) | |
| @@ -278,7 +278,7 @@ func shortError(msg string) string { | |||
| 278 | 278 | if len(msg) <= max { | |
| 279 | 279 | return msg | |
| 280 | 280 | } | |
| 281 | - | return msg[:max] + "…" | |
| 281 | + | return msg[:max] + "..." | |
| 282 | 282 | } | |
| 283 | 283 | ||
| 284 | 284 | // 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 msg | 279 | 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 out | 284 | // 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 { | |||
| 193 | 193 | } | |
| 194 | 194 | runes := []rune(summary) | |
| 195 | 195 | if len(runes) > room { | |
| 196 | - | summary = string(runes[:room-1]) + "…" | |
| 196 | + | summary = string(runes[:room-3]) + "..." | |
| 197 | 197 | } | |
| 198 | 198 | row := left + summary | |
| 199 | 199 | // Hard clamp: ensure the full row never exceeds maxWidth. | |
| 200 | 200 | rowRunes := []rune(row) | |
| 201 | 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 | 208 | return row | |
| 205 | 209 | } | |
| @@ -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 + summary | 198 | 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 row | 208 | return row | ||
| 205 | } | 209 | } | ||
M internal/agent/modes/session_tree_dialog.go +1 -1
| @@ -149,7 +149,7 @@ func formatTreeRow(n *core.TreeNode) string { | |||
| 149 | 149 | } | |
| 150 | 150 | } | |
| 151 | 151 | if len(preview) > 50 { | |
| 152 | - | preview = preview[:49] + "\u2026" | |
| 152 | + | preview = preview[:47] + "..." | |
| 153 | 153 | } | |
| 154 | 154 | return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount) | |
| 155 | 155 | } | |
| @@ -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 { | |||
| 173 | 173 | } | |
| 174 | 174 | desc := s.Description | |
| 175 | 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 | 182 | return left + desc + src | |
| 179 | 183 | } | |
| @@ -185,10 +189,10 @@ func truncateLineSafe(s string, n int) string { | |||
| 185 | 189 | if len(r) <= n { | |
| 186 | 190 | return s | |
| 187 | 191 | } | |
| 188 | - | if n <= 1 { | |
| 189 | - | return "\u2026" | |
| 192 | + | if n <= 3 { | |
| 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 | 198 | // 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.Description | 174 | 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 + src | 182 | 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 s | 190 | 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 total | 198 | // 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 { | |||
| 1357 | 1357 | act = r.Task | |
| 1358 | 1358 | } | |
| 1359 | 1359 | if len([]rune(act)) > room { | |
| 1360 | - | act = string([]rune(act)[:room-1]) + "…" | |
| 1360 | + | act = string([]rune(act)[:room-3]) + "..." | |
| 1361 | 1361 | } | |
| 1362 | 1362 | row := left + act | |
| 1363 | 1363 | rowRunes := []rune(row) | |
| 1364 | 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 | 1371 | return row | |
| 1368 | 1372 | } | |
| @@ -1357,12 +1357,16 @@ func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { | |||||
| 1357 | act = r.Task | 1357 | 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 + act | 1362 | 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 row | 1371 | return row | ||
| 1368 | } | 1372 | } | ||
M internal/agent/swarm_agent.go +4 -1
| @@ -254,7 +254,10 @@ func truncateForLog(s string, n int) string { | |||
| 254 | 254 | if len(s) <= n { | |
| 255 | 255 | 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 | 263 | // _ 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 s | 255 | 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 surface | 263 | // _ 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 { | |||
| 108 | 108 | ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) | |
| 109 | 109 | defer cancel() | |
| 110 | 110 | ||
| 111 | - | fmt.Println("zot update: querying latest release…") | |
| 111 | + | fmt.Println("zot update: querying latest release...") | |
| 112 | 112 | tag, releaseURL, err := fetchLatestRelease(ctx) | |
| 113 | 113 | if err != nil { | |
| 114 | 114 | return fmt.Errorf("query latest release: %w", err) | |
| @@ -147,7 +147,7 @@ func runUpdate(version string) error { | |||
| 147 | 147 | // users can clear /tmp themselves. | |
| 148 | 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 | 151 | sumsPath := filepath.Join(tmp, "checksums.txt") | |
| 152 | 152 | if err := downloadFile(ctx, sumsURL, sumsPath); err != nil { | |
| 153 | 153 | return fmt.Errorf("download checksums: %w", err) | |
| @@ -157,13 +157,13 @@ func runUpdate(version string) error { | |||
| 157 | 157 | return err | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | - | fmt.Println("zot update: downloading archive…") | |
| 160 | + | fmt.Println("zot update: downloading archive...") | |
| 161 | 161 | archivePath := filepath.Join(tmp, assetName) | |
| 162 | 162 | if err := downloadFile(ctx, assetURL, archivePath); err != nil { | |
| 163 | 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 | 167 | gotSum, err := sha256File(archivePath) | |
| 168 | 168 | if err != nil { | |
| 169 | 169 | return fmt.Errorf("hash archive: %w", err) | |
| @@ -172,7 +172,7 @@ func runUpdate(version string) error { | |||
| 172 | 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 | 176 | extractDir := filepath.Join(tmp, "extracted") | |
| 177 | 177 | if err := os.MkdirAll(extractDir, 0o755); err != nil { | |
| 178 | 178 | 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 err | 157 | 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 { | |||
| 177 | 177 | if len(s) <= n { | |
| 178 | 178 | 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 | } | |
| @@ -177,5 +177,8 @@ func truncatePreview(s string, n int) string { | |||||
| 177 | if len(s) <= n { | 177 | if len(s) <= n { | ||
| 178 | return s | 178 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | + | "strings" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ) | |
| @@ -180,5 +181,5 @@ func TestBuildPreview(t *testing.T) { | |||
| 180 | 181 | } | |
| 181 | 182 | ||
| 182 | 183 | func hasEllipsis(s string) bool { | |
| 183 | - | return len(s) > 0 && s[len(s)-len("\u2026"):] == "\u2026" | |
| 184 | + | return strings.HasSuffix(s, "...") | |
| 184 | 185 | } | |
| @@ -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 { | |||
| 509 | 509 | if len(s) <= n { | |
| 510 | 510 | 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 | 518 | 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 s | 510 | 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 { | |||
| 1160 | 1160 | } | |
| 1161 | 1161 | ||
| 1162 | 1162 | // 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 | 1164 | // placeholder instead of being inserted inline. Either trigger | |
| 1165 | 1165 | // alone is enough — a 500-line log dump and a 1200-character | |
| 1166 | 1166 | // 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 govern | 1162 | // 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 trigger | 1164 | // placeholder instead of being inserted inline. Either trigger | ||
| 1165 | // alone is enough — a 500-line log dump and a 1200-character | 1165 | // alone is enough — a 500-line log dump and a 1200-character | ||
| 1166 | // one-line log entry both bloat the editor in ways the user | 1166 | // 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 | |||
| 654 | 654 | // before the metadata caption) are tagged with the | |
| 655 | 655 | // imageFootprintSentinel by renderImageBlock. Strip | |
| 656 | 656 | // 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 | |
| 658 | 658 | // frame stays continuous around the image. | |
| 659 | 659 | imgCells, stripped := parseImageFootprint(line) | |
| 660 | 660 | if hasImageEscapeLine(stripped) { | |
| @@ -775,7 +775,7 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||
| 775 | 775 | } | |
| 776 | 776 | ||
| 777 | 777 | // 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 | |
| 779 | 779 | // the closing edge sits at column width-1. The caller (renderToolCall) | |
| 780 | 780 | // supplies the surrounding top/bottom edges so the live overlay | |
| 781 | 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 | 848 | // terminals (the chat column is usually wide enough). | |
| 849 | 849 | over := -fill | |
| 850 | 850 | 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]) + "..." | |
| 853 | 853 | } else { | |
| 854 | - | label = "…" | |
| 854 | + | label = "..." | |
| 855 | 855 | } | |
| 856 | 856 | used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix) | |
| 857 | 857 | fill = w - used - 1 | |
| @@ -906,7 +906,7 @@ func hasImageEscapeLine(s string) bool { | |||
| 906 | 906 | // imageFootprintSentinel marks rows that belong to an inline-image's | |
| 907 | 907 | // reserved footprint — the escape row plus the blank rows below it | |
| 908 | 908 | // 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 | |
| 910 | 910 | // it, and emits the row — the image graphics rectangle paints over | |
| 911 | 911 | // whatever was drawn there. Uses a non-printing C0 control byte so | |
| 912 | 912 | // 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 | 1124 | // colors matching git diff conventions. | |
| 1125 | 1125 | func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string { | |
| 1126 | 1126 | // 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 | |
| 1128 | 1128 | // strip them, then fall through to the highlighter. | |
| 1129 | 1129 | if looksLikeNumberedFile(text) { | |
| 1130 | 1130 | return v.renderNumberedFile(text, sourcePath) | |
| @@ -1308,7 +1308,10 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt | |||
| 1308 | 1308 | // output is unreliable. | |
| 1309 | 1309 | maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */ | |
| 1310 | 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 | 1315 | if lang != "" { | |
| 1313 | 1316 | if h := HighlightCode(trunc, lang); len(h) == 1 { | |
| 1314 | 1317 | codeRendered = h[0] | |
| @@ -1379,7 +1382,7 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||
| 1379 | 1382 | // edge plus a small interior gutter so the image rectangle | |
| 1380 | 1383 | // sits visibly inside the frame instead of kissing the │. | |
| 1381 | 1384 | // 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 | |
| 1383 | 1386 | // cells the image occupies and can pad to the right edge. | |
| 1384 | 1387 | widthHint := fmt.Sprintf("%s%d%s", imageFootprintSentinel, actualCells, imageFootprintSentinel) | |
| 1385 | 1388 | out := make([]string, 0, rows+3) | |
| @@ -1680,7 +1683,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st | |||
| 1680 | 1683 | continue | |
| 1681 | 1684 | } | |
| 1682 | 1685 | if l == "..." { | |
| 1683 | - | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "…")) | |
| 1686 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "...")) | |
| 1684 | 1687 | continue | |
| 1685 | 1688 | } | |
| 1686 | 1689 | switch l[0] { | |
| @@ -1899,7 +1902,7 @@ func truncateLines(s string, n int) string { | |||
| 1899 | 1902 | if len(lines) <= n { | |
| 1900 | 1903 | 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 | 1908 | // 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 the | 654 | // before the metadata caption) are tagged with the | ||
| 655 | // imageFootprintSentinel by renderImageBlock. Strip | 655 | // imageFootprintSentinel by renderImageBlock. Strip | ||
| 656 | // the tag, parse the optional width hint, then wrap | 656 | // 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 |
| 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 of | 777 | // 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 |
| 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 overlay | 780 | // 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 := -fill | 849 | 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 - 1 | 857 | fill = w - used - 1 | ||
| @@ -906,7 +906,7 @@ func hasImageEscapeLine(s string) bool { | |||||
| 906 | // imageFootprintSentinel marks rows that belong to an inline-image's | 906 | // imageFootprintSentinel marks rows that belong to an inline-image's | ||
| 907 | // reserved footprint — the escape row plus the blank rows below it | 907 | // reserved footprint — the escape row plus the blank rows below it | ||
| 908 | // plus the gap row before the metadata caption. Any consumer that | 908 | // 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 |
| 910 | // it, and emits the row — the image graphics rectangle paints over | 910 | // it, and emits the row — the image graphics rectangle paints over | ||
| 911 | // whatever was drawn there. Uses a non-printing C0 control byte so | 911 | // 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 sequence | 912 | // 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 numbers | 1126 | // 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 |
| 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 rectangle | 1382 | // 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 sentinel | 1384 | // 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 |
| 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 | continue | 1683 | 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 | continue | 1687 | 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 s | 1903 | 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 distinct | 1908 | // renderCompactionBlock renders a compaction summary as a distinct | ||