Use ASCII ellipses throughout

This commit is contained in:
patriceckhart 2026-05-22 17:19:29 +02:00
parent 47257c8a54
commit 8cd8410ace
21 changed files with 87 additions and 54 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -427,7 +427,7 @@ func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version st
return s, nil, err
}
// maskToken returns "123456:ABCxyz" 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] + "<hidden>"
}
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.

View file

@ -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))

View file

@ -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)

View file

@ -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()

View file

@ -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
}

View file

@ -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))

View file

@ -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

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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)

View file

@ -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] + "..."
}

View file

@ -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, "...")
}

View file

@ -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 {

View file

@ -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

View file

@ -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<cells>\x1e\u2026") so toolBoxSide knows how many
// ("\x1e<cells>\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