fix(tui): ANSI-aware viewport clamp so styled transcript lines don't truncate - #1410
Conversation
Behavioral test added: viewport.View() must not drop visible content
when a line contains real ANSI escape sequences (glamour output under
a real color profile). The current clamp in model.go slices by rune
count, which counts escape bytes toward the width budget and cuts
mid-escape, dropping trailing visible text.
Test runner output (expected: all failing):
=== RUN TestTUI1409_ANSIStyledLineNotTruncatedByRuneCount
ansi_clamp_test.go:36: visible content within the width-40 budget was dropped by the clamp; view:
"\n\n\n\n • Created \x1b[48;2;40;40;40m calc.go \x1b[0"
--- FAIL: TestTUI1409_ANSIStyledLineNotTruncatedByRuneCount (0.00s)
FAIL
This test will pass after the implementation in the next commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
Implementation for the test added in 5a0ff54. The viewport horizontal clamp in model.go sliced each visible line by rune count. Under a real color profile, glamour emits ANSI escape sequences whose bytes count as runes but occupy zero terminal cells, so the rune-count budget was exhausted early and the slice cut mid- escape, dropping trailing visible text (issue #1409). Under the ascii test profile there are no escapes, so rune count equals cell width and the bug was invisible to the existing suite. Fix: replace the rune slice with github.com/charmbracelet/x/ansi's Truncate(line, m.width, ""), which is ANSI-aware, keeps escape sequences intact, and is a no-op when the line already fits. github.com/charmbracelet/x/ansi v0.11.6 was already an indirect dependency; `go mod tidy` after adding the import moved it (and two other already-directly-imported packages, github.com/creack/pty and github.com/coder/acp-go-sdk, previously mislabeled) to the direct require block. No dependency versions changed (go.sum is unchanged). Test runner output (expected: all passing): === RUN TestTUI1409_ANSIStyledLineNotTruncatedByRuneCount --- PASS: TestTUI1409_ANSIStyledLineNotTruncatedByRuneCount (0.00s) PASS ok go-agent-harness/cmd/harnesscli/tui/components/viewport (cached) Full cmd/harnesscli/tui/... suite passes under -race; go vet clean; go build ./cmd/... ./internal/... clean (unrelated pre-existing benchmarks/terminal_bench/reference_solutions fixtures already fail `go build ./...` on origin/main and are out of scope). Behavioral tests covered: viewport clamp no longer drops content past an ANSI escape sequence. Files changed: cmd/harnesscli/tui/components/viewport/model.go, go.mod Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
Regression test added that would fail if the fix in 0dfa54a is reverted: TestTUI1409_ModelViewPreservesANSIStyledTranscriptLine constructs a full tui.Model (New + tea.WindowSizeMsg), injects the same ANSI-styled transcript line used by the viewport-level test into the model's embedded viewport, and asserts the fully assembled Model.View() frame (header/separators/viewport/input/status bar) — not just the isolated viewport package — preserves visible content and respects the line width. Verified by hand-reverting the model.go clamp and confirming this test fails the same way ("Add" dropped) before restoring the fix. messagebubble/glamour could not be made to emit real ANSI escapes in this test process: even forcing lipgloss.SetColorProfile(termenv.TrueColor) left RenderMarkdown output escape-free (verified experimentally), because messagebubble's own stdoutIsTerminal probe is a syscall check on os.Stdout's fd, independent of lipgloss's global profile, and always resolves to the escape-free "notty" glamour style outside a real terminal. That probe lives in messagebubble, outside this fix's scope (cmd/harnesscli/tui/components/viewport, go.mod/go.sum, cmd/harnesscli/tui), so the regression test injects a realistic pre-rendered ANSI-styled line directly into m.vp rather than routing it through glamour. Also adds the docs/logs/engineering-log.md entry (symptom, cause, fix). Full test suite output: ok go-agent-harness/cmd/harnesscli/tui 46.592s ok go-agent-harness/cmd/harnesscli/tui/components/configpanel 0.658s ok go-agent-harness/cmd/harnesscli/tui/components/contextgrid 0.877s ok go-agent-harness/cmd/harnesscli/tui/components/costdisplay 0.532s ok go-agent-harness/cmd/harnesscli/tui/components/diffview 0.768s ok go-agent-harness/cmd/harnesscli/tui/components/helpdialog 2.057s ok go-agent-harness/cmd/harnesscli/tui/components/inputarea 1.924s ok go-agent-harness/cmd/harnesscli/tui/components/interruptui 2.261s ok go-agent-harness/cmd/harnesscli/tui/components/layout 1.811s ok go-agent-harness/cmd/harnesscli/tui/components/messagebubble 1.179s ok go-agent-harness/cmd/harnesscli/tui/components/modelswitcher 2.400s ok go-agent-harness/cmd/harnesscli/tui/components/permissionspanel 2.156s ok go-agent-harness/cmd/harnesscli/tui/components/profilepicker 1.702s ok go-agent-harness/cmd/harnesscli/tui/components/sessionpicker 1.373s ok go-agent-harness/cmd/harnesscli/tui/components/slashcomplete 1.256s ok go-agent-harness/cmd/harnesscli/tui/components/spinner 1.041s ok go-agent-harness/cmd/harnesscli/tui/components/statspanel 1.947s ok go-agent-harness/cmd/harnesscli/tui/components/statusbar 1.933s ok go-agent-harness/cmd/harnesscli/tui/components/streamrenderer 1.937s ok go-agent-harness/cmd/harnesscli/tui/components/taskspanel 1.939s ok go-agent-harness/cmd/harnesscli/tui/components/themepicker 1.889s ok go-agent-harness/cmd/harnesscli/tui/components/thinkingbar 1.858s ok go-agent-harness/cmd/harnesscli/tui/components/tooluse 1.922s ok go-agent-harness/cmd/harnesscli/tui/components/transcriptexport 1.897s ok go-agent-harness/cmd/harnesscli/tui/components/undopicker 1.760s ok go-agent-harness/cmd/harnesscli/tui/components/viewport 1.765s ok go-agent-harness/cmd/harnesscli/tui/plugin 1.830s ok go-agent-harness/cmd/harnesscli/tui/testhelpers 2.903s Full cmd/harnesscli/tui/... suite also verified clean under -race. Regression scenarios covered: - Naive rune-count clamp reintroduced in viewport.Model.View() (direct, package-level). - Naive rune-count clamp reintroduced anywhere in the assembled Model.View() render path (integration-level, via the embedded m.vp). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Live verification (coordinator): harnessd fake provider streaming the markdown answer, harnesscli built from this branch, tmux TERM=xterm-256color, measured 120-column pane, captured 8 s after completion. Before (installed main binary, same daemon): |
Closes #1409
Summary
The transcript viewport's horizontal clamp (
viewport.Model.View(),cmd/harnesscli/tui/components/viewport/model.go) sliced each visible line by rune count. Under a real color profile, glamour's ANSI escape bytes count as runes but occupy zero terminal cells, so the rune budget was exhausted early and the slice cut mid-escape, dropping trailing visible text (e.g. "with an Add function" silently disappearing from a bullet item). Fixed by replacing the rune slice withgithub.com/charmbracelet/x/ansi'sTruncate(line, m.width, ""), which is ANSI-aware and a no-op when the line already fits.Scope and issue reconciliation
The issue's original body hypothesized a different cause (streaming re-render line-count accounting flipping plain↔glamour via
ReplaceTailLines/activeAssistantLineCount). That hypothesis was superseded by the issue's own last comment ("Root cause found"), which is the confirmed diagnosis this PR implements: the viewport's rune-count horizontal clamp. I posted an issue comment reconciling the original body against the confirmed cause and updated definition-of-done items before opening this PR (#1409 (comment)).In scope (implemented): the viewport horizontal clamp in
model.go.Out of scope (not touched, per the confirmed diagnosis and the task boundary):
renderActiveAssistantBubble/activeAssistantLineCount/ReplaceTailLinesstreaming re-render accounting,looksLikeMarkdown, bubble width (already fixed in #1407), ctrl+o precedence. If a future report shows truncation specifically during active streaming (not the final rendered transcript), that accounting path needs its own investigation — this PR does not claim to have fixed it.Impact analysis reconciliation
Only one call site clamps lines in the viewport package (
grep -n "runes\[:m.width\]" cmd/harnesscli/tui/components/viewport/model.go— a single match, now replaced). No other file in the tree does a rune-count line clamp against a viewport width (messagebubble/assistant.go'sfitLinealready clamps bylipgloss.Width, not rune count — a different, already-ANSI-aware helper, untouched). Everything downstream ofviewport.Model.View()(the fulltui.Model.View()assembly) passes the string through unmodified (strings.Join(sections, "\n")), so no other render layer needed changes.Architecture and duplication check
Searched for any other ANSI-aware truncation helper already in the tree before reaching for a new dependency:
messagebubble/assistant.go'sfitLinedoes its own O(n²) rune-drop loop keyed offlipgloss.Width— width-aware but not escape-sequence-aware by construction (a general algorithm, not a dedicated primitive) and it's a different package with a different call site; reusing it here would mean exporting it and adding a cross-package dependency for a one-line clamp.github.com/charmbracelet/x/ansiwas already an indirect dependency of this project (pulled in transitively by the existing charm ecosystem deps already used for TUI rendering), and itsTruncatefunction is exactly the ANSI-aware primitive this call site needs, so this uses the already-present dependency rather than adding a new one or hand-rolling an ANSI parser.Test-first evidence
Red command:
go test ./cmd/harnesscli/tui/components/viewport/... -run TestTUI1409 -vObserved failure (before the fix, commit
5a0ff54a):Why this proves the missing/incorrect behavior: the fixture line has visible width 42 and 62 runes (verified both invariants at the top of the test); the naive
runes[:40]clamp cuts inside the\x1b[48;2;40;40;40mescape sequence itself, producing an unterminated escape and dropping every visible character after it — including the "Add" marker the test asserts on. This is not an import/compile error; the test built and ran, and failed for exactly the behavior described in the issue's root-cause comment.Green command:
go test ./cmd/harnesscli/tui/components/viewport/... -run TestTUI1409 -v(after the fix, commit0dfa54aa):Regression/characterization evidence: added
TestTUI1409_ModelViewPreservesANSIStyledTranscriptLineincmd/harnesscli/tui(commit5e061320), which drives the same fixture through the fully assembledtui.Model(constructed viaNew+ a realtea.WindowSizeMsg) rather than the isolated viewport package. Manually reverted just themodel.goclamp hunk and re-ran this test to confirm it fails the same way (marker dropped) before restoring the fix — this proves the regression test actually exercises the fixed code path rather than being a tautology.Verification evidence
go test ./cmd/harnesscli/tui/components/viewport/... -run TestTUI1409 -v— FAIL (pre-fix), shown above.go test ./cmd/harnesscli/tui/... -race— PASS, all 28 sub-packages ok (cmd/harnesscli/tuiitself 46-50s, all components green).go vet ./cmd/harnesscli/tui/...— clean, no output.go build ./cmd/... ./internal/...— clean. (go build ./...alone fails on pre-existing, unrelatedbenchmarks/terminal_bench/reference_solutions/*fixture directories that are intentionally not standalonemainpackages — confirmed pre-existing by stashing this branch's changes and re-running the same build against unmodifiedorigin/main; same failures, unrelated to this change.)TestTUI1409_ModelViewPreservesANSIStyledTranscriptLine— PASS post-fix, confirmed FAIL when themodel.goclamp is manually reverted (see Test-first evidence).messagebubble/glamour could not be forced to emit real escapes in this test process even withlipgloss.SetColorProfile(termenv.TrueColor)forced (verified experimentally:RenderMarkdownoutput had zero escape bytes), becausemessagebubble's ownstdoutIsTerminalprobe is a syscall-level check onos.Stdout's fd, independent of lipgloss's global profile, and always resolves to the escape-freenottyglamour style outside a real terminal. That probe lives inmessagebubble, outside this fix's file scope. State this as unverified rather than claiming a live-capture proof I did not perform.Rollout and rollback
TUI-only, client-side rendering change; no server/API/schema/persistence surface. No migration, no data change, no feature flag. Rollback is reverting this PR (single-purpose commit range, no other work mixed in). No observability changes needed — this fixes a rendering bug with no operational signal to monitor beyond "does the transcript truncate," which is exactly what the added tests assert.
Documentation
Added a
docs/logs/engineering-log.mdentry (symptom, cause, fix, verification) dated 2026-09-06 under "Issue #1409 viewport horizontal clamp truncated ANSI-styled transcript lines." No public API/route/CLI/env-var surface changed, so no other docs needed updates. Did not add adocs/logs/INDEX.mdentry — recent entries in the same file (2026-09-05/06, e.g. issues #1395, #1397, #1399) also did not get INDEX.md entries, so this follows current practice rather than deviating from it.Contract checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5