Skip to content

fix(tui): ANSI-aware viewport clamp so styled transcript lines don't truncate - #1410

Merged
dennisonbertram merged 3 commits into
mainfrom
issue-1409-viewport-ansi-clamp
Sep 6, 2026
Merged

fix(tui): ANSI-aware viewport clamp so styled transcript lines don't truncate#1410
dennisonbertram merged 3 commits into
mainfrom
issue-1409-viewport-ansi-clamp

Conversation

@dennisonbertram

Copy link
Copy Markdown
Owner

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 with github.com/charmbracelet/x/ansi's Truncate(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/ReplaceTailLines streaming 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's fitLine already clamps by lipgloss.Width, not rune count — a different, already-ANSI-aware helper, untouched). Everything downstream of viewport.Model.View() (the full tui.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's fitLine does its own O(n²) rune-drop loop keyed off lipgloss.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/ansi was already an indirect dependency of this project (pulled in transitively by the existing charm ecosystem deps already used for TUI rendering), and its Truncate function 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 -v

Observed failure (before the fix, commit 5a0ff54a):

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

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;40m escape 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, commit 0dfa54aa):

=== RUN   TestTUI1409_ANSIStyledLineNotTruncatedByRuneCount
--- PASS: TestTUI1409_ANSIStyledLineNotTruncatedByRuneCount (0.00s)
PASS

Regression/characterization evidence: added TestTUI1409_ModelViewPreservesANSIStyledTranscriptLine in cmd/harnesscli/tui (commit 5e061320), which drives the same fixture through the fully assembled tui.Model (constructed via New + a real tea.WindowSizeMsg) rather than the isolated viewport package. Manually reverted just the model.go clamp 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

  • Targeted (red): go test ./cmd/harnesscli/tui/components/viewport/... -run TestTUI1409 -v — FAIL (pre-fix), shown above.
  • Targeted (green): same command — PASS (post-fix), shown above.
  • Full package + race: go test ./cmd/harnesscli/tui/... -race — PASS, all 28 sub-packages ok (cmd/harnesscli/tui itself 46-50s, all components green).
  • Static analysis: go vet ./cmd/harnesscli/tui/... — clean, no output.
  • Build: go build ./cmd/... ./internal/... — clean. (go build ./... alone fails on pre-existing, unrelated benchmarks/terminal_bench/reference_solutions/* fixture directories that are intentionally not standalone main packages — confirmed pre-existing by stashing this branch's changes and re-running the same build against unmodified origin/main; same failures, unrelated to this change.)
  • Model-level regression: TestTUI1409_ModelViewPreservesANSIStyledTranscriptLine — PASS post-fix, confirmed FAIL when the model.go clamp is manually reverted (see Test-first evidence).
  • Not exercised: a live TUI capture under a real color profile (the issue's own "Verification plan" calls for a tmux re-capture). This PR verifies via unit/integration tests with a directly-constructed ANSI-escaped fixture line instead — messagebubble/glamour could not be forced to emit real escapes in this test process even with lipgloss.SetColorProfile(termenv.TrueColor) forced (verified experimentally: RenderMarkdown output had zero escape bytes), because messagebubble's own stdoutIsTerminal probe is a syscall-level 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 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.md entry (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 a docs/logs/INDEX.md entry — 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

  • Linked issue follows the current structured contract and this PR closes it
  • Issue acceptance criteria, impact map, and scope were updated when the design changed (see reconciliation comment linked above)
  • All callers, consumers, sources of truth, and similar abstractions were searched
  • No unrelated cleanup, hidden scope growth, duplicated wiring, or parallel abstraction was introduced
  • Tests were written first and the expected red failure was observed
  • Targeted checks and the repository-required full regression are green
  • Security, compatibility, lifecycle, deployment, observability, documentation, and rollback were reconciled
  • Real mouse/keyboard/API/operator behavior was exercised — not done; see "Verification evidence" for why (no live TTY capture) and what was substituted

🤖 Generated with Claude Code

https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5

dennisonbertram and others added 3 commits September 6, 2026 15:34
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
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-06T19:49:55.736838Z 5e06132 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@dennisonbertram

Copy link
Copy Markdown
Owner Author

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): • Created calc.go w (rest dropped). After (this branch): • Created calc.go with an Add function that returns the sum of two integers. and • Created calc_test.go with table-driven tests covering positive, negative, mixed signs, and zeros. — every line intact.

@dennisonbertram
dennisonbertram merged commit 87b62e0 into main Sep 6, 2026
2 checks passed
@dennisonbertram
dennisonbertram deleted the issue-1409-viewport-ansi-clamp branch September 6, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant