diff --git a/README.md b/README.md index e0b56d6..b25844e 100644 --- a/README.md +++ b/README.md @@ -595,7 +595,7 @@ zot can run as a telegram bot so you can DM it from your phone. Two ways to run Type `/telegram` in the running TUI to open a picker with **connect**, **disconnect**, and **status**. When connected: - 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. -- 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. +- 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. - 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). - The status bar shows a `- tg -` tag while the bridge is active. - `/telegram connect` / `/telegram disconnect` / `/telegram status` (or `/tg`) also work as direct commands without the picker. diff --git a/docs/rpc.md b/docs/rpc.md index d7fc355..c421077 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -43,9 +43,9 @@ Every line in either direction is one JSON object terminated by `\n`. Object bou | `type` | Direction | Description | |---|---|---| -| any command (`prompt`, `abort`, …) | client → server | Request | +| any command (`prompt`, `abort`, ...) | client → server | Request | | `response` | server → client | Reply to one command, correlated by `id` | -| any event (`text_delta`, `tool_call`, …) | server → client | Stream notification (no `id`) | +| any event (`text_delta`, `tool_call`, ...) | server → client | Stream notification (no `id`) | ## Commands diff --git a/examples/extensions/scratchpad/README.md b/examples/extensions/scratchpad/README.md index f21d019..1dc7560 100644 --- a/examples/extensions/scratchpad/README.md +++ b/examples/extensions/scratchpad/README.md @@ -37,7 +37,7 @@ The model also has a `read_notes` tool. Ask it: > "What did I tell you to remember?" -…and it will call the tool and tell you. +...and it will call the tool and tell you. ## Storage diff --git a/internal/agent/botcmd.go b/internal/agent/botcmd.go index 5e4f88d..4a554c2 100644 --- a/internal/agent/botcmd.go +++ b/internal/agent/botcmd.go @@ -427,7 +427,7 @@ func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version st return s, nil, err } -// maskToken returns "123456:ABC…xyz" so copies of zot telegram-bot status can be +// maskToken returns "123456:ABC...xyz" so copies of zot telegram-bot status can be // pasted into bug reports without leaking the full token. func maskToken(tok string) string { if len(tok) <= 10 { @@ -436,13 +436,13 @@ func maskToken(tok string) string { // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body. i := strings.IndexByte(tok, ':') if i < 0 { - return tok[:4] + "…" + tok[len(tok)-4:] + return tok[:4] + "..." + tok[len(tok)-4:] } body := tok[i+1:] if len(body) < 8 { return tok[:i+1] + "" } - return tok[:i+1] + body[:3] + "…" + body[len(body)-3:] + return tok[:i+1] + body[:3] + "..." + body[len(body)-3:] } // _ compile-time hint so the strconv import stays if we later add numeric parsing. diff --git a/internal/agent/modes/confirm_dialog.go b/internal/agent/modes/confirm_dialog.go index 17ab34e..17fc8cd 100644 --- a/internal/agent/modes/confirm_dialog.go +++ b/internal/agent/modes/confirm_dialog.go @@ -216,7 +216,11 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string { // Truncate the tail if the line would exceed width; keeps // the option numbers always visible. if visibleLen(plain) > width-2 { - plain = plain[:width-3] + "\u2026" + if width <= 5 { + plain = "..."[:max(0, width-2)] + } else { + plain = plain[:width-5] + "..." + } } if i == cursor { lines = append(lines, th.PadHighlight(plain, width)) diff --git a/internal/agent/modes/help.go b/internal/agent/modes/help.go index 3f36f04..0e4c8f2 100644 --- a/internal/agent/modes/help.go +++ b/internal/agent/modes/help.go @@ -42,7 +42,7 @@ func renderHelpBlock(th tui.Theme, width int) []string { } // Label column width uses display cells, not byte length, so - // single-cell multibyte runes (← → - …) don't over-count and leave + // single-cell multibyte runes (← → - ...) don't over-count and leave // a raggedy right edge. `len("alt+← / alt+→")` is 17 bytes but // only 13 cells; padding off byte length would either overshoot // (setting labelWidth too high and wasting space on every row) diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 8530ea1..36332a9 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -799,7 +799,7 @@ func (i *Interactive) buildChatLocked(cols int) []string { // narrow terminal. line := "✓ " + i.statusOK if cols > 4 && len(line) > cols { - line = line[:cols-1] + "…" + line = line[:cols-3] + "..." } chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "") } @@ -1305,7 +1305,7 @@ func clipBottomClippedImages(lines []string) []string { // fixed status bar area. Suppress that image for this frame. // // When the image lives inside a tool box, the reservation rows - // are wrapped in vertical box edges ("│ … │"); those rows + // are wrapped in vertical box edges ("│ ... │"); those rows // look non-blank under a naive whitespace check but are still // reservation rows for this scan, so treat them as blank. foundInfo := false @@ -1329,7 +1329,7 @@ func clipBottomClippedImages(lines []string) []string { // stripping ANSI escape sequences, surrounding whitespace, and the // vertical box edges drawn by the tool-box renderer. Used by // clipBottomClippedImages so an image's reservation rows still count -// as blank when those rows are wrapped in "│ … │" inside a tool box. +// as blank when those rows are wrapped in "│ ... │" inside a tool box. func isBoxBlankLine(line string) bool { stripped := stripANSIBytes(line) stripped = strings.TrimSpace(stripped) @@ -1338,7 +1338,7 @@ func isBoxBlankLine(line string) bool { return stripped == "" } -// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' … final +// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final // byte) from s without pulling in the regexp package. Mirrors the // internal helper in package tui; the duplicated copy avoids exporting // it just for one caller. @@ -1423,10 +1423,10 @@ func truncateLine(s string, n int) string { if len(runes) <= n { return s } - if n <= 1 { - return "…" + if n <= 3 { + return strings.Repeat(".", n) } - return string(runes[:n-1]) + "…" + return string(runes[:n-3]) + "..." } // 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, i.queued = append([]string{prompt}, i.queued...) } i.statusErr = "" - i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending…")) + i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) i.pendingPostCompactNote = "context auto-compacted; sending your last message" i.mu.Unlock() i.invalidate() @@ -4042,7 +4042,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) { return } i.mu.Lock() - i.statusOK = "reloading extensions…" + i.statusOK = "reloading extensions..." i.statusErr = "" i.mu.Unlock() i.invalidate() diff --git a/internal/agent/modes/jump_dialog.go b/internal/agent/modes/jump_dialog.go index 0f5256f..2559e8e 100644 --- a/internal/agent/modes/jump_dialog.go +++ b/internal/agent/modes/jump_dialog.go @@ -169,7 +169,11 @@ func formatJumpRowPlain(t jumpTarget, maxWidth int) string { } preview := t.Preview if len(preview) > room { - preview = preview[:room-1] + "\u2026" + if room <= 3 { + preview = "..."[:room] + } else { + preview = preview[:room-3] + "..." + } } return left + preview } diff --git a/internal/agent/modes/model_dialog.go b/internal/agent/modes/model_dialog.go index 7ab0c99..1294cb9 100644 --- a/internal/agent/modes/model_dialog.go +++ b/internal/agent/modes/model_dialog.go @@ -188,10 +188,10 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { } if start > 0 { - lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start))) + lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start))) } if end < len(d.view) { - lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end))) + lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end))) } lines = append(lines, frameRule(th, width)) diff --git a/internal/agent/modes/rescue_dialog.go b/internal/agent/modes/rescue_dialog.go index 2e1dc7e..6a9bf1b 100644 --- a/internal/agent/modes/rescue_dialog.go +++ b/internal/agent/modes/rescue_dialog.go @@ -165,10 +165,10 @@ func (d *rescueDialog) Render(th tui.Theme, width int) []string { } } if start > 0 { - lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start))) + lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start))) } if end < len(d.view) { - lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end))) + lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end))) } lines = append(lines, frameRule(th, width)) @@ -278,7 +278,7 @@ func shortError(msg string) string { if len(msg) <= max { return msg } - return msg[:max] + "…" + return msg[:max] + "..." } // extractFailedProvider tries to pull the failing provider name out diff --git a/internal/agent/modes/session_dialog.go b/internal/agent/modes/session_dialog.go index feacd06..b660369 100644 --- a/internal/agent/modes/session_dialog.go +++ b/internal/agent/modes/session_dialog.go @@ -193,13 +193,17 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { } runes := []rune(summary) if len(runes) > room { - summary = string(runes[:room-1]) + "…" + summary = string(runes[:room-3]) + "..." } row := left + summary // Hard clamp: ensure the full row never exceeds maxWidth. rowRunes := []rune(row) if len(rowRunes) > maxWidth { - row = string(rowRunes[:maxWidth-1]) + "…" + if maxWidth <= 3 { + row = strings.Repeat(".", maxWidth) + } else { + row = string(rowRunes[:maxWidth-3]) + "..." + } } return row } diff --git a/internal/agent/modes/session_tree_dialog.go b/internal/agent/modes/session_tree_dialog.go index 98f9072..7f2b07d 100644 --- a/internal/agent/modes/session_tree_dialog.go +++ b/internal/agent/modes/session_tree_dialog.go @@ -149,7 +149,7 @@ func formatTreeRow(n *core.TreeNode) string { } } if len(preview) > 50 { - preview = preview[:49] + "\u2026" + preview = preview[:47] + "..." } return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount) } diff --git a/internal/agent/modes/skills_dialog.go b/internal/agent/modes/skills_dialog.go index de2c856..d64a245 100644 --- a/internal/agent/modes/skills_dialog.go +++ b/internal/agent/modes/skills_dialog.go @@ -173,7 +173,11 @@ func formatSkillRow(s *skills.Skill, maxWidth int) string { } desc := s.Description if len(desc) > room { - desc = desc[:room-1] + "\u2026" + if room <= 3 { + desc = strings.Repeat(".", room) + } else { + desc = desc[:room-3] + "..." + } } return left + desc + src } @@ -185,10 +189,10 @@ func truncateLineSafe(s string, n int) string { if len(r) <= n { return s } - if n <= 1 { - return "\u2026" + if n <= 3 { + return strings.Repeat(".", n) } - return string(r[:n-1]) + "\u2026" + return string(r[:n-3]) + "..." } // visibleWindow centers cursor in a window of size n within total diff --git a/internal/agent/modes/swarm_dialog.go b/internal/agent/modes/swarm_dialog.go index 9d461fb..14034c0 100644 --- a/internal/agent/modes/swarm_dialog.go +++ b/internal/agent/modes/swarm_dialog.go @@ -1357,12 +1357,16 @@ func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { act = r.Task } if len([]rune(act)) > room { - act = string([]rune(act)[:room-1]) + "…" + act = string([]rune(act)[:room-3]) + "..." } row := left + act rowRunes := []rune(row) if len(rowRunes) > maxWidth { - row = string(rowRunes[:maxWidth-1]) + "…" + if maxWidth <= 3 { + row = strings.Repeat(".", maxWidth) + } else { + row = string(rowRunes[:maxWidth-3]) + "..." + } } return row } diff --git a/internal/agent/swarm_agent.go b/internal/agent/swarm_agent.go index 957ca06..661ded6 100644 --- a/internal/agent/swarm_agent.go +++ b/internal/agent/swarm_agent.go @@ -254,7 +254,10 @@ func truncateForLog(s string, n int) string { if len(s) <= n { return s } - return s[:n-1] + "…" + if n <= 3 { + return strings.Repeat(".", n) + } + return s[:n-3] + "..." } // _ keeps the provider import used; provider types may surface diff --git a/internal/agent/updatecmd.go b/internal/agent/updatecmd.go index 0da9e4a..a445fc3 100644 --- a/internal/agent/updatecmd.go +++ b/internal/agent/updatecmd.go @@ -108,7 +108,7 @@ func runUpdate(version string) error { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() - fmt.Println("zot update: querying latest release…") + fmt.Println("zot update: querying latest release...") tag, releaseURL, err := fetchLatestRelease(ctx) if err != nil { return fmt.Errorf("query latest release: %w", err) @@ -147,7 +147,7 @@ func runUpdate(version string) error { // users can clear /tmp themselves. defer func() { _ = os.RemoveAll(tmp) }() - fmt.Println("zot update: downloading checksums.txt…") + fmt.Println("zot update: downloading checksums.txt...") sumsPath := filepath.Join(tmp, "checksums.txt") if err := downloadFile(ctx, sumsURL, sumsPath); err != nil { return fmt.Errorf("download checksums: %w", err) @@ -157,13 +157,13 @@ func runUpdate(version string) error { return err } - fmt.Println("zot update: downloading archive…") + fmt.Println("zot update: downloading archive...") archivePath := filepath.Join(tmp, assetName) if err := downloadFile(ctx, assetURL, archivePath); err != nil { return fmt.Errorf("download archive: %w", err) } - fmt.Println("zot update: verifying checksum…") + fmt.Println("zot update: verifying checksum...") gotSum, err := sha256File(archivePath) if err != nil { return fmt.Errorf("hash archive: %w", err) @@ -172,7 +172,7 @@ func runUpdate(version string) error { return fmt.Errorf("checksum mismatch for %s: got %s, want %s", assetName, gotSum, wantSum) } - fmt.Println("zot update: extracting…") + fmt.Println("zot update: extracting...") extractDir := filepath.Join(tmp, "extracted") if err := os.MkdirAll(extractDir, 0o755); err != nil { return fmt.Errorf("mkdir extract: %w", err) diff --git a/internal/core/confirm.go b/internal/core/confirm.go index cfe6729..24e7bca 100644 --- a/internal/core/confirm.go +++ b/internal/core/confirm.go @@ -177,5 +177,8 @@ func truncatePreview(s string, n int) string { if len(s) <= n { return s } - return s[:n-1] + "\u2026" + if n <= 3 { + return "..."[:n] + } + return s[:n-3] + "..." } diff --git a/internal/core/confirm_test.go b/internal/core/confirm_test.go index 7ec6855..5d4348d 100644 --- a/internal/core/confirm_test.go +++ b/internal/core/confirm_test.go @@ -2,6 +2,7 @@ package core import ( "encoding/json" + "strings" "sync" "testing" ) @@ -180,5 +181,5 @@ func TestBuildPreview(t *testing.T) { } func hasEllipsis(s string) bool { - return len(s) > 0 && s[len(s)-len("\u2026"):] == "\u2026" + return strings.HasSuffix(s, "...") } diff --git a/internal/swarm/swarm.go b/internal/swarm/swarm.go index 42ccb0e..0f17da0 100644 --- a/internal/swarm/swarm.go +++ b/internal/swarm/swarm.go @@ -509,7 +509,10 @@ func truncate(s string, n int) string { if len(s) <= n { return s } - return s[:n-1] + "…" + if n <= 3 { + return strings.Repeat(".", n) + } + return s[:n-3] + "..." } func lastN(lines []string, n int) []string { diff --git a/internal/tui/editor.go b/internal/tui/editor.go index 8cfd00f..a0a08d9 100644 --- a/internal/tui/editor.go +++ b/internal/tui/editor.go @@ -1160,7 +1160,7 @@ func max(a, b int) int { } // pasteCollapseLineThreshold and pasteCollapseCharThreshold govern -// when a bracketed paste gets collapsed to a [pasted text #N …] +// when a bracketed paste gets collapsed to a [pasted text #N ...] // placeholder instead of being inserted inline. Either trigger // alone is enough — a 500-line log dump and a 1200-character // one-line log entry both bloat the editor in ways the user diff --git a/internal/tui/view.go b/internal/tui/view.go index 0ac73df..68d37ea 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -654,7 +654,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str // before the metadata caption) are tagged with the // imageFootprintSentinel by renderImageBlock. Strip // the tag, parse the optional width hint, then wrap - // the row in the usual │ … │ box edges so the + // the row in the usual │ ... │ box edges so the // frame stays continuous around the image. imgCells, stripped := parseImageFootprint(line) if hasImageEscapeLine(stripped) { @@ -775,7 +775,7 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { } // wrapLiveBody returns the streaming body content as a list of -// box-side rows: each line wrapped in │ … │ with right padding so +// box-side rows: each line wrapped in │ ... │ with right padding so // the closing edge sits at column width-1. The caller (renderToolCall) // supplies the surrounding top/bottom edges so the live overlay // renders as a closed box matching the finalised transcript form. @@ -848,10 +848,10 @@ func toolBoxTop(th Theme, label string, width int) string { // terminals (the chat column is usually wide enough). over := -fill runes := []rune(label) - if over+1 < len(runes) { - label = string(runes[:len(runes)-over-1]) + "…" + if over+3 < len(runes) { + label = string(runes[:len(runes)-over-3]) + "..." } else { - label = "…" + label = "..." } used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix) fill = w - used - 1 @@ -906,7 +906,7 @@ func hasImageEscapeLine(s string) bool { // imageFootprintSentinel marks rows that belong to an inline-image's // reserved footprint — the escape row plus the blank rows below it // plus the gap row before the metadata caption. Any consumer that -// wraps content in box edges (│ … │) detects the sentinel, strips +// wraps content in box edges (│ ... │) detects the sentinel, strips // it, and emits the row — the image graphics rectangle paints over // whatever was drawn there. Uses a non-printing C0 control byte so // 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 { // colors matching git diff conventions. func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string { // Legacy path: transcripts saved before we dropped line numbers - // from the read tool still carry " 1\t…" prefixes. Detect and + // from the read tool still carry " 1\t..." prefixes. Detect and // strip them, then fall through to the highlighter. if looksLikeNumberedFile(text) { return v.renderNumberedFile(text, sourcePath) @@ -1308,7 +1308,10 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt // output is unreliable. maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */ if maxCode > 0 && len(code) > maxCode { - trunc := code[:maxCode-1] + "…" + trunc := strings.Repeat(".", maxCode) + if maxCode > 3 { + trunc = code[:maxCode-3] + "..." + } if lang != "" { if h := HighlightCode(trunc, lang); len(h) == 1 { codeRendered = h[0] @@ -1379,7 +1382,7 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { // edge plus a small interior gutter so the image rectangle // sits visibly inside the frame instead of kissing the │. // The escape row carries a width hint after the sentinel - // ("\x1e\x1e\u2026") so toolBoxSide knows how many + // ("\x1e\x1e...") so toolBoxSide knows how many // cells the image occupies and can pad to the right edge. widthHint := fmt.Sprintf("%s%d%s", imageFootprintSentinel, actualCells, imageFootprintSentinel) out := make([]string, 0, rows+3) @@ -1680,7 +1683,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st continue } if l == "..." { - out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "…")) + out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "...")) continue } switch l[0] { @@ -1899,7 +1902,7 @@ func truncateLines(s string, n int) string { if len(lines) <= n { return s } - return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)" + return strings.Join(lines[:n], "\n") + "\n ... (" + fmt.Sprintf("%d", len(lines)-n) + " more)" } // renderCompactionBlock renders a compaction summary as a distinct