[Bug]: spinner truncates by rune count but budgets by display width, so wide labels destroy the cancel hint

#1432 · closed · 0 comments

View on GitHub ↗

dennisonbertram

### Work type Bug / regression ### Observed behavior `shortenLabel` (`cmd/harnesscli/tui/components/spinner/model.go`) is supposed to guarantee that the `(esc to interrupt)` hint survives at narrow widths, sacrificing the label instead. For labels containing double-width characters it does the opposite — the label is left too long and the hint is destroyed: ``` width=40 "· Waiting for 模型模型模型… (esc to inte" width=40 "· Running 🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀… (esc to " width=50 "· Waiting for 模型模型模型模型模型模型模型模型 (es" ``` The rendered line never exceeds the terminal width — the final `MaxWidth` clamp sees to that — so this is not an overflow. It is the clamp cutting the hint off because the label was not shortened enough, which is precisely the outcome `shortenLabel` was written to prevent. ASCII labels are unaffected: `"· Waiting for gpt-4.… (esc to interrupt)"` at width 40 is correct. ### Expected behavior The cancel hint survives at any width where it can fit at all, regardless of the label's script. The label yields first, and yields by display columns rather than by character count. Invariant: after `shortenLabel`, `lipgloss.Width(glyph + " " + label + " " + CancelHint) <= width` whenever the glyph and hint themselves fit. ### Reproduction ```go m := New(0).Start().SetAction("Waiting for 模型模型模型模型模型模型模型模型") m.View(40) // "· Waiting for 模型模型模型… (esc to inte" <- hint truncated ``` Reproduction rate: 100% for any label whose display width exceeds its rune count by enough to consume the hint's columns. ### User and operational impact Affected users: anyone whose model ID, provider name, or tool name contains double-width characters, and anyone on a narrow terminal — the hint is the only part of that line telling them how to stop a run. Severity: low in practice today, since current model and tool identifiers are ASCII. It is a latent correctness bug that becomes user-visible the moment a non-ASCII identifier appears, and the code and its tests both currently claim a guarantee that does not hold. The sharper point: the guarantee is asserted in a comment and pinned by tests that only use ASCII, so the codebase says something false about itself. ### Suspected seam and search evidence `cmd/harnesscli/tui/components/spinner/model.go`, `shortenLabel`. It mixes two different units: - The budget is computed in display columns: `width - lipgloss.Width(glyph) - lipgloss.Width(CancelHint) - 2`. - The truncation is applied in runes: `runes := []rune(label); label = string(runes[:budget-1]) + "…"`. For ASCII the two coincide, which is why every existing test passes. For a double-width rune each character consumes two columns while counting as one rune, so the truncated label is up to twice its budget in columns. The line then exceeds `width`, and the `MaxWidth` clamp at the end of `View` truncates from the right — taking the hint. Callers searched: `shortenLabel` is called only from `View` in the same file, guarded by `lipgloss.Width(base) > width`. Existing tests that should have caught it and did not: `TestSpinnerKeepsCancelHintAtNarrowWidth` and `TestSpinnerHintSurvivesEvenWhenLabelCannotFit` (`truthful_label_test.go`). Both use ASCII-only labels, so both pass while the guarantee is broken. Provenance: found by an external untrusted reviewer (`gpt-6-astra` via the Surplus proxy) reading only the packed source with no tools, then confirmed locally by rendering the cases above. Worth recording — it is the first defect that review path has caught, and it was in code merged the same day. ### Blast-radius impact map Callers and data flow: `shortenLabel` and `View` in the spinner package. Nothing else calls it. Config/env/defaults: none. API/CLI/wire formats/tools: none. No exported signature changes. Persistence/schema/cache: none. Concurrency/lifecycle: none. `Model` stays an immutable value. Security/auth/permissions/privacy: none. TUI/web/macOS/other clients: TUI only. Provider/model/tool catalog: the labels this renders come from model IDs (`Waiting for <model>`) and tool names (`Running <tool>`). Both are catalog-derived, so a non-ASCII entry in either is the trigger. Worth noting the catalog is third-party data and not under our control. Deployment/observability/runbooks: none. Compatibility: rendering only. Existing tests/fixtures: the two hint tests above gain wide-character cases; the committed snapshots are ASCII and unaffected. Documentation: `docs/logs/engineering-log.md`. ### Regression test first Extend `cmd/harnesscli/tui/components/spinner/truthful_label_test.go`: 1. `TestSpinnerKeepsCancelHintWithWideRunes` — table over a CJK label, an emoji label, and an ASCII control, at widths 40 and 50. Assert the full `CancelHint` is present and `lipgloss.Width(view) <= width`. Red output expected, before the fix, for the CJK case at width 40: the view contains `(esc to inte` rather than the whole hint. Why it proves the bug: it asserts the hint's survival — the actual guarantee — rather than the line's width, which is already satisfied by the clamp and is why the current tests pass. False-positive controls: keep the ASCII case in the same table, so a fix that over-truncates every label (satisfying "hint present" by making labels uselessly short) shows up as a visibly degraded ASCII rendering. Keep `TestSpinnerHintSurvivesEvenWhenLabelCannotFit` green. ### Fix boundaries In scope: make the truncation measure display columns rather than runes, accumulating `lipgloss.Width` per rune until the budget is reached; the ellipsis's own width must be counted. Out of scope: - The `MaxWidth` clamp, which is a correct last-resort guard. - The label ladder (#1415) and the pulse cadence (#1420). - Grapheme-cluster handling (combining marks, ZWJ emoji sequences). A rune-by-rune width accumulation is a strict improvement over rune counting; full grapheme segmentation is a larger change and should be its own ticket if a real case appears. ### Diagnostic and observability evidence Before and after, render the three cases at widths 40 and 50 and compare the rendered strings and `lipgloss.Width` of each. The signal is the presence of the complete `(esc to interrupt)` substring. ### Verification plan - Red: run the new test before the fix; record the truncated-hint output. - Green: same test after. - Targeted: `go test ./cmd/harnesscli/tui/... -race`. - Full regression: `go test ./cmd/... ./internal/...`. - Real path: render all three label kinds at 40 and 50 columns and read the output, since this is a rendering guarantee that no assertion fully captures — the point is that a human can still see how to cancel. ### Rollout and rollback Single PR to `main`; picked up on the next `scripts/install.sh`. No migration or persisted state. Rollback is reverting the commit. ### Documentation and handoff `docs/logs/engineering-log.md`: the defect, its cause (display columns versus rune count), and the note that it was found by the untrusted external reviewer and confirmed locally — including that our own tests passed throughout because they were ASCII-only. ### Definition of done - [ ] Wide-rune test written first and observed failing on the hint assertion - [ ] Truncation measures display columns, ellipsis width included - [ ] Hint survives for CJK, emoji, and ASCII labels at 40 and 50 columns - [ ] ASCII rendering unchanged - [ ] `go test ./cmd/harnesscli/tui/... -race` green; full regression green - [ ] Rendered output inspected, not only asserted - [ ] Engineering log records the provenance

Comments