Skip to content

fix(tui): wrap status line instead of truncating when too long#3054

Merged
esengine merged 1 commit into
esengine:main-v2from
CnsMaple:fix/status-line-wrap
Jun 11, 2026
Merged

fix(tui): wrap status line instead of truncating when too long#3054
esengine merged 1 commit into
esengine:main-v2from
CnsMaple:fix/status-line-wrap

Conversation

@CnsMaple

@CnsMaple CnsMaple commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Problem

The bottom status/data line (model · git · effort · context · cache · jobs · balance, or custom statusline command output) was truncated with when its content exceeded the terminal width on narrow terminals or with many active tags. Information was silently lost with no way to reveal it.

Solution

Replace truncation with ANSI-aware hard-wrapping so the data line flows onto additional rows instead of being cut off.

Changes

  1. wrapStatusLine (replaces clampStatusLine): uses ansi.Hardwrap to wrap text at word boundaries to the given width instead of truncating with ansi.Truncate(…).

  2. computeStatusLineCount: new method that replicates the data-tag construction from View() and returns the number of terminal rows the status block will occupy after wrapping. Called from Update() so the viewport height is correctly reserved.

  3. statusLineCount field: stored on the model and used by bottomRows() instead of the hardcoded + 2. Falls back to 2 when uninitialized (e.g. in tests).

  4. View(): updated to use wrapStatusLine for both status rows and the working spinner line.

Tests

All existing tests pass. No behavioral change on wide terminals — wrapping only kicks in when the content exceeds width columns.

@github-actions github-actions Bot added the v2 Go rewrite (1.x) — main-v2 branch, active development label Jun 4, 2026
@esengine

esengine commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Good direction — now that the chat view is alt-screen (v.AltScreen = true), wrapping the status line is safe (no scrollback to strand), so dropping the truncate-and-fix-two-rows shape makes sense, and using ansi.Hardwrap is the right call. I built it; it compiles.

Before it can land, computeStatusLineCount needs to match what View() actually renders, or the reserved height drifts from the drawn height on narrow terminals and the bottom rows overlap/clip. Two concrete gaps:

  1. Width mismatch. Update calls computeStatusLineCount(contentW) where contentW = m.width - 1 (scrollbar column), but View() wraps the status block at boxW = m.width. Computing the wrap count at a different width than the render means they disagree by a row right at a wrap boundary. Pass the same width both places.

  2. The first status line isn't counted as wrappable. computeStatusLineCount hardcodes lines := 1 ("always fits one row"), but View() does wrapStatusLine(status, boxW) on the first line too. The mode-tag + state line (e.g. NORMAL · idle (shift+tab to cycle)) does wrap on a narrow terminal, so on a thin window the count is short by a row and the layout breaks.

Fix: count both lines through the same wrapStatusLine(..., width) at the same width View() uses (e.g. strings.Count(wrapStatusLine(status, w), "\n") + strings.Count(wrapStatusLine(dataLine, w), "\n") + 2). And please add a test that drives a narrow width with a long mode line + long data line and asserts computeStatusLineCount equals the actual rendered row count — this is the load-bearing invariant and there's no coverage for it yet. Ping me after and I'll merge.

@SivanCola SivanCola left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this. This is related to #3278 because both changes affect the CLI TUI bottom rail height budget (bottomRows() / View() / composer/status rows). I tested the integration order locally as main-v2 + #3278 + #3054; #3278 applies cleanly, but #3054 conflicts in internal/cli/chat_tui.go, so this needs a rebase after #3278.

One behavioral issue also needs fixing before this can land: View() now wraps the working line, the first status line, and the data/statusline row via wrapStatusLine(...), but bottomRows() only reserves one row for working and computeStatusLineCount() assumes the first status line is always one row. On narrow terminals, long mode/status text, or a running turn with queued feedback, the rendered bottom region can become taller than transcriptHeight() reserved, so the viewport will overlap or clip the bottom rail.

Please rebase on top of the current main-v2 / #3278 shape and make the height accounting use the same wrapped strings that View() renders, including:

  • wrapped working line height when m.state == tuiRunning
  • wrapped first status line height
  • wrapped data/custom statusline height
  • dynamic composer height from #3278 (m.input.Height())

A focused regression test with a narrow width and long status/custom statusline would make this much safer.

@esengine

esengine commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Thanks for this! Since this PR was opened, main-v2 has changed the status line to clamp each row to width independently (a fixed-height approach specifically to avoid wrap-induced resize ghosting). That overlaps with the goal here but takes the opposite approach (wrapping vs clamping), so rebasing surfaces semantic conflicts in chat_tui.go between the two designs. Could you rebase onto the latest main-v2 and reconcile whether wrapping is still wanted over the now-landed clamp approach, and if so how the two should coexist? Thank you!

@esengine

Copy link
Copy Markdown
Owner

@CnsMaple gentle ping — the wrap-instead-of-truncate direction was accepted in review, but the branch has gone stale and now conflicts with main-v2. Could you rebase and address the earlier review notes? Happy to merge once it's green.

The bottom status/data line (model · git · effort · context · cache · jobs ·
balance, or custom statusline) was truncated with an ellipsis when its content
exceeded the terminal width. This lost information with no way to reveal it.

Replace clampStatusLine (ansi.Truncate) with wrapStatusLine (ansi.Hardwrap)
so the data line wraps to additional rows instead of being silently cut.

Add statusLineCount field + computeStatusLineCount() method so bottomRows()
reserves the correct dynamic height, including the working/spinner line when
running and the custom statusline output.

computeStatusLineCount mirrors View()'s construction exactly: same width,
both the mode/state line and the data line are wrapped for counting, and the
mode tag width includes the Padding(0,1) that View() applies — without this
the count drifts from the rendered height by 1 row at certain widths, hiding
the bottom transcript line.

Also fix gitTag() to never truncate itself — it previously took a maxWidth
argument and silently truncated with ansi.Truncate, causing the git tag to
be cut off ('项...字@项目分支') or disappear entirely before wrapStatusLine
had a chance to wrap the whole line. Now gitTag() renders the full identity
and lets wrapStatusLine handle any needed wrapping at word boundaries.
@CnsMaple CnsMaple force-pushed the fix/status-line-wrap branch from c37a7f3 to 959195a Compare June 11, 2026 04:26
@CnsMaple CnsMaple requested a review from esengine as a code owner June 11, 2026 04:26
@github-actions github-actions Bot added the tui Terminal UI / CLI (internal/cli, internal/control) label Jun 11, 2026

@esengine esengine left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the rework — this lands the invariant the first review asked for: computeStatusLineCount mirrors View()'s exact construction, bottomRows() reserves the real wrapped height, and the tests pin transcriptHeight + bottomRows == height across widths including the CJK boundary sweep. The wrap-vs-clamp reconciliation is also sound: the ghosting that motivated clamping was a scrollback artifact, and the status block now lives in the alt-screen view where there is no scrollback to strand — the comment says exactly that.

Two notes for a follow-up, neither blocking:

  1. Update() now recomputes statusLineCount twice back to back (once with contentW, immediately overwritten with cm.width) — merge artifact; the first call is a dead store and should go.
  2. computeStatusLineCount mirrors ~80 lines of View(); longer term I'd like the line builders extracted and shared so the two can't drift. Your width-sweep tests are the guard until then.

Thanks for sticking with this through three rounds — merging.

@esengine esengine merged commit 31c4207 into esengine:main-v2 Jun 11, 2026
13 checks passed
@CnsMaple CnsMaple deleted the fix/status-line-wrap branch June 12, 2026 02:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tui Terminal UI / CLI (internal/cli, internal/control) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants