Skip to content

fix(tui): flush logical lines to scrollback so resize reflow stops splitting words and dropping indent#665

Draft
griffinwork40 wants to merge 2 commits into
mainfrom
afk/tui-axis2-stage3
Draft

fix(tui): flush logical lines to scrollback so resize reflow stops splitting words and dropping indent#665
griffinwork40 wants to merge 2 commits into
mainfrom
afk/tui-axis2-stage3

Conversation

@griffinwork40

Copy link
Copy Markdown
Owner

Problem

Narrowing the terminal pane after long output has scrolled into native scrollback makes the terminal re-wrap that stored scrollback at the new width — splitting words mid-character (missingmiss + ing) and dropping continuation-line indentation to column 0.

Root cause

AFK hard-wraps committed output to physical rows for exact live-cursor frame math, then archives those pre-wrapped physical rows into native scrollback with a raw newline (terminal-compositor.committed-band-commit.ts). Once bytes are in native scrollback the terminal owns them, so the in-memory reflow fix (band-reflow.ts, PR #386 / f75d8a4) cannot reach them. On a narrowing resize, the terminal's DECAWM autowrap hard-wraps each stored row again → mid-word splits + lost indent. band-reflow.ts:10-22 and docs/tui-resize-reflow.md:129-142 document this as a known out-of-scope gap.

Fix

Retain per-row logical provenance (committedBandMeta / BandRowMeta) and, at the band-hold archive site, emit whole soft-wrappable logical lines via buildScrollbackArchiveEscape instead of pre-wrapped physical rows. Native scrollback then holds one reflow-clean logical line per logical line, which any emulator — and tmux copy-mode (-J) — rewraps cleanly on resize.

Gates (rebased onto current main, zero conflicts)

  • pnpm lint — 0 errors
  • logical-flush test — 24/24
  • compositor suite — 381/381
  • PTY suite (pnpm test:pty) — 8/8 (incl. the 3 axis-2 resize guards)

MERGE GATE — human real-terminal validation required (do NOT merge until checked)

PTY tests replay an xterm emulator and cannot reproduce terminal-only DECAWM deferred-wrap artifacts; prior fixes in this path shipped green and broke on real terminals.

  • iTerm2, Terminal.app, and Ghostty
  • Long single line (~150+ cols, no spaces) scrolled into scrollback
  • Narrow pane (120 to 68): reflows as one continuous line, no mid-word splits
  • Widen pane back (48 to 110): rejoins cleanly, no stranded fragments
  • Markdown table under a collapsed "thinking" overlay: header appears once, no void above the frame
  • Native scrollback: each committed line appears once, in order
  • tmux copy-mode (-J): unbroken logical line
  • Continuation-row indentation preserved after reflow

Draft opened by afk. Branch rebases cleanly onto main (verified); rebase or "Update branch" before merge.

…uards (#540, #541)

Extend the node-pty scrollback harness (issue #541) with a mid-stream width
resize so the width-resize scrollback-fragmentation class (#540 axis-2) is
reproducible in CI, and add guards that lock the bug until the Stage-3
logical-line flush fixes it. Read-only diagnosis only; no src/ changes.

Harness (tests/pty/):
- constants.ts: PTY_RESIZE marker protocol (buildResizeMarker/findResizeMarker).
  An APC marker a scenario emits at the point it wants the geometry changed,
  carrying <cols>x<rows>; xterm ignores APC so it never perturbs the buffer.
- harness.ts: IPtyProcess.resize(); runScenarioInPty detects the first marker,
  calls child.resize (SIGWINCH), records its byte offset, and on replay splits
  the captured stream: construct emulator @ old geometry, write pre-bytes,
  emulator.resize() to reflow, write post-bytes -- faithful to a real terminal
  reflowing scrollback on resize. Also exposes per-line isWrapped.
- scenarios.ts: requestResize() driver helper (emit marker, await own 'resize');
  PtyExpect.logicalSpan asserts soft-wrap rejoinability (tmux -J equivalent) via
  isWrapped counts, plus minSpanRows to prove a resize actually fired.

Scenarios (existing 5 unchanged -- the resize is marker-driven, no API change):
- width-resize-reflow-sanity (GREEN, stable): a line that fits wide re-wraps to
  2 rows when narrowed -- certifies the resize handshake actually fires.
- width-resize-fragment-narrow / -widen (GREEN now = RED guards): a logical line
  committed at one width, flushed to scrollback as hard rows, shows >=2
  non-wrapped rows after a resize (not tmux -J-rejoinable). When PR 2 (#540
  Stage 3 logical flush) lands, this drops to 1 and the assertion FAILS -- the
  signal to flip minNonWrappedRows -> maxNonWrappedRows: 1.

Gates: pnpm test:pty 8/8 green; pnpm lint (tsc --noEmit) clean.
…ize fragmentation (#540)

Axis 2 of the resize-wrapping bug: committed content was hard-wrapped to the
width-at-commit and flushed into native scrollback as hard-newline rows, so a
later width change re-fragmented scrolled-off content (the reported screenshot).
Terminals only reflow their own soft-wraps, never app hard-newlines.

Fix: the end-of-turn / disarm flush now archives the pending committed band as
soft-wrappable LOGICAL lines instead of pre-hard-wrapped physical rows, so the
terminal owns wrapping and reflows scrollback cleanly on any width change
(docs/tui-resize-reflow.md "sound pattern (a)").

Mechanism -- retain, do NOT reconstruct (the flat committedBand: string[] loses
logical boundaries, and reflowBandSplit re-wraps each row independently, so they
cannot be recovered by re-joining rows):
- Add committedBandMeta: BandRowMeta[], per-physical-row logical provenance
  index-aligned 1:1 with committedBand, maintained across commit / reflow /
  eviction / repin.
- flushPendingCommittedBand: scrollbackFlushLines maps the pending physical rows
  back to logical lines (reading the full band+meta so a logical line STRADDLING
  the pending/painted boundary emits only its pending rows -- no scrollback
  duplication); buildScrollbackArchiveEscape writes each line at the bottom
  margin with autowrap ON + trailing newline, so the terminal wraps it and
  scrolls it into history.
- The LIVE band keeps hard-wrapped physical rows for exact row math (unchanged);
  only the scrollback archival path emits logical lines.

Flips PR 1's two RED guards (width-resize-fragment-narrow/-widen) to assert the
rejoined property (maxNonWrappedRows: 1) -- they pass because the behavior
changed, not because the assertions were weakened.

Stacked on #621 (base afk/tmux-test-wrapping-fix).

Gates: pnpm lint clean; pnpm test:pty 8/8; headless compositor 363/363;
pnpm test:coverage green.

NOT verified: the Stage-4 human real-terminal matrix (iTerm2 / Terminal.app /
Ghostty) -- REQUIRED before merge; prior fixes in this code shipped green and
broke in reality.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant