From 6d436d62e0288a37856135769860e71df8e3dfc0 Mon Sep 17 00:00:00 2001 From: agentforce314 Date: Sat, 4 Jul 2026 01:10:51 -0700 Subject: [PATCH] =?UTF-8?q?fix(tui):=20inline-mode=20cursor=20drift=20?= =?UTF-8?q?=E2=80=94=20typing=20landed=20one=20row=20below=20the=20input?= =?UTF-8?q?=20box?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a turn whose transcript scrolled past the viewport, typing the next query rendered on the footer row ("? for shortcuts" line) instead of the composer input row, with the stale footer left on screen. Root cause: inline sessions start below pre-existing shell output, so frame rows scroll into scrollback earlier than LogUpdate's height-vs-viewport arithmetic predicts. A repaint of a row in that phantom band emits a cursor-up that clamps at the viewport top; every later relative write — and the parked displayCursor that seeds all future frames' relative moves — lands one row low, permanently (main screen has no per-frame CSI H self-heal). Fix: track the frame-end cursor's physical viewport row across frames (LogUpdate.physCursorRow; LF and auto-wrap pin at the bottom margin) and derive scrollback reachability from it. The 0-seed only ever under-estimates, so the guard is conservative (a relative move can never clamp) and converges to exact at the first bottom-margin pin. Also: never park the declared cursor above the physical viewport top, and re-anchor on clearTerminal / forceRedraw / SIGCONT. Verified: new regression suite drives the real pipeline (React → renderer → log-update → optimizer → terminal writer) against a strict VT emulator across a full turn lifecycle — 8 scenarios including the exact reported repro (fails before, passes after) and a ctrl+L re-anchor pass. Fork suite 118 tests green; tsc + eslint clean; real PTY drive with a live model turn confirms typing lands on the prompt row after an overflowing turn. Co-Authored-By: Claude Fable 5 --- ui-tui/packages/clawcodex-ink/src/ink/ink.tsx | 14 +- .../src/ink/inline-scrollback-drift.test.ts | 542 ++++++++++++++++++ .../clawcodex-ink/src/ink/log-update.ts | 208 +++++-- 3 files changed, 721 insertions(+), 43 deletions(-) create mode 100644 ui-tui/packages/clawcodex-ink/src/ink/inline-scrollback-drift.test.ts diff --git a/ui-tui/packages/clawcodex-ink/src/ink/ink.tsx b/ui-tui/packages/clawcodex-ink/src/ink/ink.tsx index d7734e456..a94aaac89 100644 --- a/ui-tui/packages/clawcodex-ink/src/ink/ink.tsx +++ b/ui-tui/packages/clawcodex-ink/src/ink/ink.tsx @@ -1028,6 +1028,15 @@ export default class Ink { type: 'stdout', content: cursorPosition(row, col) }) + + this.displayCursor = target + } else if (frame.cursor.y - target.y > this.log.physicalCursorRow()) { + // The declared target sits above the physical viewport top (its + // row scrolled off, e.g. a composer taller than the terminal). + // A relative move there would clamp and desync displayCursor + // from the real cursor, poisoning every later frame's relative + // moves. Leave the cursor where it is instead. + this.displayCursor = !hasDiff && parked !== null ? parked : null } else { // After the diff (or preamble), cursor is at frame.cursor. If no // diff AND previously parked, it's still at the old park position @@ -1049,9 +1058,9 @@ export default class Ink { content: cursorMove(dx, dy) }) } - } - this.displayCursor = target + this.displayCursor = target + } } else { // Declaration cleared (input blur, unmount). Restore physical cursor // to frame.cursor before forgetting the park position — otherwise @@ -2409,6 +2418,7 @@ export default class Ink { // cursor, so this is inline-only.) const parkedY = this.displayCursor?.y ?? this.frontFrame.screen.height const below = Math.max(0, this.frontFrame.screen.height - parkedY) + if (below > 0) { writeSync(1, '\r' + '\n'.repeat(below)) } diff --git a/ui-tui/packages/clawcodex-ink/src/ink/inline-scrollback-drift.test.ts b/ui-tui/packages/clawcodex-ink/src/ink/inline-scrollback-drift.test.ts new file mode 100644 index 000000000..f2145caea --- /dev/null +++ b/ui-tui/packages/clawcodex-ink/src/ink/inline-scrollback-drift.test.ts @@ -0,0 +1,542 @@ +import { EventEmitter } from 'events' + +import React from 'react' +import { describe, expect, it } from 'vitest' + +import Box from './components/Box.js' +import ScrollBox from './components/ScrollBox.js' +import Text from './components/Text.js' +import { useDeclaredCursor } from './hooks/use-declared-cursor.js' +import Ink from './ink.js' + +/** + * Regression suite for main-screen (inline mode) scrollback drift — the + * "second-turn typing lands on the footer row" bug. + * + * Root cause: inline sessions start BELOW pre-existing shell output, so + * frame rows scroll into scrollback EARLIER than frame-height arithmetic + * predicts. LogUpdate's old reachability guard (viewportY derived from + * screen height vs viewport height) under-counted the scrolled-off rows; + * when a later frame repainted a row in that phantom band (e.g. transcript + * virtualization swapping rows near the top), the emitted CSI cursor-up + * clamped at the viewport top and every subsequent relative write — plus + * the parked displayCursor that seeds all future frames — landed one row + * too low, permanently. Fixed by tracking the frame-end cursor's physical + * viewport row across frames (LogUpdate.physCursorRow, LF pins at the + * bottom margin) and deriving reachability from it. + * + * Strategy: drive the REAL pipeline (React → renderer → log-update → + * optimizer → writeDiffToTerminal) against a fake TTY, replay the captured + * bytes through a strict VT emulator (LF scrolls at the bottom margin, + * CUU/CUD clamp, pending-wrap semantics), and assert the physical screen + * matches the user-visible contract after every frame of a full turn + * lifecycle: type query 1 → submit → stream past the viewport → turn end + * → idle repaints → type query 2. + */ + +const COLS = 40 +const ROWS = 10 +const PROMPT = 'deepseek > ' +const PROMPT_W = PROMPT.length // 11, same as the real composer prompt +const FOOTER_IDLE = ' ? for shortcuts' +const FOOTER_BUSY = ' esc to interrupt' + +const ESC = '' +const BEL = '' + +// --------------------------------------------------------------------------- +// Strict VT emulator: models exactly the semantics the inline renderer relies +// on. Throws on anything it does not model so nothing slips through unnoticed. +// --------------------------------------------------------------------------- +class Vt { + grid: string[][] = [] + scrollback: string[] = [] + x = 0 + y = 0 + pendingWrap = false + + constructor( + readonly cols: number, + readonly rows: number + ) { + for (let r = 0; r < rows; r++) { + this.grid.push(new Array(cols).fill(' ')) + } + } + + private scroll() { + const top = this.grid.shift()! + this.scrollback.push(top.join('').replace(/\s+$/, '')) + this.grid.push(new Array(this.cols).fill(' ')) + } + + private linefeed() { + this.pendingWrap = false + + if (this.y === this.rows - 1) { + this.scroll() + } else { + this.y++ + } + } + + private putChar(ch: string) { + if (this.pendingWrap) { + this.x = 0 + this.linefeed() + } + + this.grid[this.y]![this.x] = ch + + if (this.x === this.cols - 1) { + this.pendingWrap = true + } else { + this.x++ + } + } + + feed(data: string) { + let i = 0 + + while (i < data.length) { + const ch = data[i]! + + if (ch === ESC) { + const rest = data.slice(i + 1) + + // OSC (hyperlinks etc.) — swallow through BEL or ST + const osc = new RegExp(`^\\]([^${BEL}${ESC}]*)(${BEL}|${ESC}\\\\)`).exec(rest) + + if (osc) { + i += 1 + osc[0].length + + continue + } + + const m = /^\[(\??)([0-9;]*)([A-Za-z@`~])/.exec(rest) + + if (!m) { + throw new Error(`Vt: unhandled escape at ${JSON.stringify(rest.slice(0, 16))}`) + } + + const [all, priv, paramStr, final] = m + const p = paramStr!.length ? paramStr!.split(';').map(s => parseInt(s, 10)) : [] + const n = Math.max(1, p[0] ?? 1) + + if (priv === '?') { + // DEC private modes (cursor show/hide, mouse, paste…) — no cursor motion + i += 1 + all!.length + + continue + } + + switch (final) { + case 'A': // CUU — clamps at top, no scroll + this.pendingWrap = false + this.y = Math.max(0, this.y - n) + + break + + case 'B': // CUD — clamps at bottom, no scroll + this.pendingWrap = false + this.y = Math.min(this.rows - 1, this.y + n) + + break + + case 'C': // CUF + this.pendingWrap = false + this.x = Math.min(this.cols - 1, this.x + n) + + break + + case 'D': // CUB + this.pendingWrap = false + this.x = Math.max(0, this.x - n) + + break + + case 'G': // CHA (1-based) + this.pendingWrap = false + this.x = Math.min(this.cols - 1, Math.max(0, (p[0] ?? 1) - 1)) + + break + case 'H': { + // CUP (1-based row;col) + this.pendingWrap = false + const row = (p[0] ?? 1) - 1 + const col = (p[1] ?? 1) - 1 + this.y = Math.min(this.rows - 1, Math.max(0, row)) + this.x = Math.min(this.cols - 1, Math.max(0, col)) + + break + } + + case 'J': { + // ED + const mode = p[0] ?? 0 + + if (mode === 2) { + for (const row of this.grid) { + row.fill(' ') + } + } else if (mode === 3) { + this.scrollback = [] + } else if (mode === 0) { + this.grid[this.y]!.fill(' ', this.x) + + for (let r = this.y + 1; r < this.rows; r++) { + this.grid[r]!.fill(' ') + } + } else { + throw new Error(`Vt: ED mode ${mode} not modeled`) + } + + break + } + + case 'K': { + // EL + const mode = p[0] ?? 0 + + if (mode === 2) { + this.grid[this.y]!.fill(' ') + } else if (mode === 0) { + this.grid[this.y]!.fill(' ', this.x) + } else { + this.grid[this.y]!.fill(' ', 0, this.x + 1) + } + + break + } + + case 'm': // SGR — styling only + break + + default: + throw new Error(`Vt: unhandled CSI final ${JSON.stringify(final)} in ${JSON.stringify(all)}`) + } + + i += 1 + all!.length + + continue + } + + if (ch === '\r') { + this.x = 0 + this.pendingWrap = false + } else if (ch === '\n') { + this.linefeed() + } else if (ch === '\b') { + this.x = Math.max(0, this.x - 1) + this.pendingWrap = false + } else if (ch === BEL) { + // bell + } else if (ch >= ' ') { + this.putChar(ch) + } else { + throw new Error(`Vt: unhandled control char 0x${ch.charCodeAt(0).toString(16)}`) + } + + i++ + } + } + + row(r: number): string { + return this.grid[r]!.join('').replace(/\s+$/, '') + } + + dump(): string { + return this.grid.map((_, r) => `${String(r).padStart(2)}|${this.row(r)}`).join('\n') + } + + findRow(needle: string): number { + for (let r = 0; r < this.rows; r++) { + if (this.row(r).includes(needle)) { + return r + } + } + + return -1 + } +} + +// --------------------------------------------------------------------------- +// Fake TTY + Ink harness (same pattern as ink-cursor-advance.test.ts) +// --------------------------------------------------------------------------- +class FakeTty extends EventEmitter { + chunks: string[] = [] + columns = COLS + rows = ROWS + isTTY = true + + write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean { + this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + cb?.() + + return true + } +} + +// Mirrors the composer input contract: nativeCursor mode renders the bare +// value (hardware cursor marks the caret) and declares the caret position. +function FakeInput({ value, columns }: { columns: number; value: string }) { + const boxRef = useDeclaredCursor({ active: true, column: value.length, line: 0 }) + + return React.createElement( + Box, + { ref: boxRef, width: columns }, + React.createElement(Text, { wrap: 'wrap' }, value || ' ') + ) +} + +type HarnessState = { + busy: boolean + flash: boolean + lines: string[] + scrollbox: boolean + value: string +} + +// Transcript (plain column or ScrollBox) + busy line + bordered composer with +// an absolutely-positioned right-edge flash slot + busy/idle footer. +function Harness({ busy, flash, lines, scrollbox, value }: HarnessState) { + const transcriptRows = lines.map((l, idx) => React.createElement(Text, { key: `l${idx}` }, l)) + + const transcript = scrollbox + ? React.createElement( + ScrollBox, + { flexDirection: 'column', flexGrow: 1, flexShrink: 1, key: 'transcript', stickyScroll: true }, + React.createElement(Box, { flexDirection: 'column' }, ...transcriptRows) + ) + : React.createElement(Box, { flexDirection: 'column', key: 'transcript' }, ...transcriptRows) + + return React.createElement( + Box, + { flexDirection: 'column' }, + transcript, + busy ? React.createElement(Text, { key: 'busy' }, '* thinking...') : null, + React.createElement( + Box, + { + borderBottom: true, + borderLeft: false, + borderRight: false, + borderStyle: 'round', + borderTop: true, + flexDirection: 'column', + key: 'composer' + }, + React.createElement( + Box, + { key: 'inputRow', position: 'relative' }, + React.createElement(Box, { width: PROMPT_W }, React.createElement(Text, { bold: true }, PROMPT)), + React.createElement(FakeInput, { columns: COLS - PROMPT_W - 2, value }), + flash + ? React.createElement( + Box, + { key: 'heart', position: 'absolute', right: 0 }, + React.createElement(Text, null, '<3') + ) + : null + ) + ), + value === '' ? React.createElement(Text, { key: 'footer' }, busy ? FOOTER_BUSY : FOOTER_IDLE) : null + ) +} + +// --------------------------------------------------------------------------- +// Scenario runner +// --------------------------------------------------------------------------- +type StepOpts = Partial & { + /** Invoke ink.forceRedraw() after rendering this step (the ctrl+L / + * /redraw recovery path — ERASE_SCREEN + CURSOR_HOME + full repaint). */ + forceRedraw?: boolean + label: string +} + +function runScenario(steps: Array, scrollbox: boolean, shellRows = 0) { + const stdout = new FakeTty() + const stdin = new FakeTty() + const stderr = new FakeTty() + + const ink = new Ink({ + exitOnCtrlC: false, + patchConsole: false, + stderr: stderr as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream + }) + + const vt = new Vt(COLS, ROWS) + const frames: Array<{ bytes: string; label: string }> = [] + + // Inline mode starts BELOW pre-existing shell output: the shell prompt, + // the launch command, and entry.tsx's own leading "\n". The Ink frame's + // content row 0 therefore sits at physical row `shellRows`, not 0 — the + // exact situation the renderer's scrollback math must survive. + for (let r = 0; r < shellRows; r++) { + vt.feed(`shell history ${r}\r\n`) + } + + const state: HarnessState = { busy: false, flash: false, lines: [], scrollbox, value: '' } + + const ctx = (label: string) => + `step: ${label}\n--- physical screen ---\n${vt.dump()}\n--- last frames ---\n${frames + .slice(-4) + .map(f => `${f.label}: ${JSON.stringify(f.bytes)}`) + .join('\n')}` + + const assertComposerIntact = (label: string) => { + const promptRow = vt.findRow('deepseek') + expect(promptRow, `prompt row missing\n${ctx(label)}`).toBeGreaterThanOrEqual(0) + + const inputRowText = vt.row(promptRow) + + if (state.value) { + // The typed value must be on the SAME physical row as the prompt. + // (Right-trimmed — trailing-space keystrokes leave no visible cell.) + expect(inputRowText, `value not on the input row\n${ctx(label)}`).toContain( + (PROMPT + state.value).replace(/\s+$/, '') + ) + + // The parked hardware cursor must sit on the input row — this is the + // user-visible caret from the bug screenshot. + expect(vt.y, `hardware cursor not on the input row\n${ctx(label)}`).toBe(promptRow) + + // And the footer must be gone from the whole screen (unmounted). + expect(vt.findRow('? for shortcuts'), `footer visible while typing\n${ctx(label)}`).toBe(-1) + } else if (!state.busy) { + const footerRow = vt.findRow('? for shortcuts') + expect(footerRow, `footer missing when idle\n${ctx(label)}`).toBeGreaterThanOrEqual(0) + // border-bottom row sits between input row and footer + expect(footerRow, `footer not below the input row\n${ctx(label)}`).toBe(promptRow + 2) + } + } + + for (const { assert = true, forceRedraw = false, label, ...patch } of steps) { + Object.assign(state, patch) + const before = stdout.chunks.length + ink.render(React.createElement(Harness, { ...state, lines: [...state.lines] })) + ink.onRender() + + if (forceRedraw) { + ink.forceRedraw() + } + + const bytes = stdout.chunks.slice(before).join('') + frames.push({ bytes, label }) + vt.feed(bytes) + + if (assert) { + assertComposerIntact(label) + } + } + + ink.unmount() +} + +// The full first-turn lifecycle from the bug report: idle → type query 1 → +// submit (input clears, busy mounts) → transcript streams past the viewport +// → turn end (busy unmounts, optional flash/reflow, optional virtualization +// window slide that repaints rows near the top of the frame) → type query 2. +function lifecycleSteps(opts: { + flashOnEnd: boolean + reflowOnEnd: boolean + virtSlideOnIdle?: boolean +}): Array { + const lines: string[] = ['welcome to clawcodex'] + const steps: Array = [] + + steps.push({ label: 'idle-fresh', lines: [...lines] }) + + // Type query 1, one keystroke at a time (footer unmounts on first char) + const q1 = 'what?' + + for (let i = 1; i <= q1.length; i++) { + steps.push({ label: `q1-type-${i}`, value: q1.slice(0, i) }) + } + + // Submit: input clears, user line lands in transcript, busy mounts + lines.push('> what?') + steps.push({ busy: true, label: 'q1-submit', lines: [...lines], value: '' }) + + // Streaming: transcript grows well past the viewport height + for (let batch = 0; batch < 6; batch++) { + for (let k = 0; k < 3; k++) { + lines.push(`assistant output line ${batch}-${k}`) + } + + steps.push({ label: `stream-${batch}`, lines: [...lines] }) + } + + // Turn end: busy unmounts; optionally the streamed tail reflows into its + // final shape (heights change) and the right-edge heart flashes. + if (opts.reflowOnEnd) { + lines.splice(-3, 3, 'final answer line A', 'final answer line B', 'final answer line C', 'final answer line D') + } + + steps.push({ busy: false, flash: opts.flashOnEnd, label: 'turn-end', lines: [...lines] }) + + if (opts.flashOnEnd) { + steps.push({ flash: false, label: 'flash-off' }) + } + + // Transcript virtualization window slide: rows in the middle of the frame + // get swapped for spacer cells after measurement settles. Some of those + // rows sit in the band the renderer believes is still reachable but has + // physically scrolled off (when the frame started below the viewport top). + if (opts.virtSlideOnIdle) { + for (let i = 15; i <= 19 && i < lines.length; i++) { + lines[i] = `virtualized spacer ${i}` + } + + steps.push({ label: 'virt-slide', lines: [...lines] }) + } + + // Type query 2, one keystroke at a time — the bug report's failing moment + const q2 = 'do a' + + for (let i = 1; i <= q2.length; i++) { + steps.push({ label: `q2-type-${i}`, value: q2.slice(0, i) }) + } + + return steps +} + +describe('inline-mode physical screen stays in sync across a full turn', () => { + it('A: plain transcript column', () => { + runScenario(lifecycleSteps({ flashOnEnd: false, reflowOnEnd: false }), false) + }) + + it('B: transcript inside a sticky ScrollBox', () => { + runScenario(lifecycleSteps({ flashOnEnd: false, reflowOnEnd: false }), true) + }) + + it('C: ScrollBox + right-edge flash on turn end', () => { + runScenario(lifecycleSteps({ flashOnEnd: true, reflowOnEnd: false }), true) + }) + + it('D: ScrollBox + flash + streaming tail reflow on turn end', () => { + runScenario(lifecycleSteps({ flashOnEnd: true, reflowOnEnd: true }), true) + }) + + it('E: frame starts below shell output (control — no top-band repaint)', () => { + runScenario(lifecycleSteps({ flashOnEnd: false, reflowOnEnd: false }), false, 3) + }) + + it('F: frame starts below shell output + idle virtualization slide', () => { + runScenario(lifecycleSteps({ flashOnEnd: false, reflowOnEnd: false, virtSlideOnIdle: true }), false, 3) + }) + + it('G: full realism — shell offset + ScrollBox + flash + reflow + virt slide', () => { + runScenario(lifecycleSteps({ flashOnEnd: true, reflowOnEnd: true, virtSlideOnIdle: true }), true, 3) + }) + + it('H: ctrl+L mid-session re-anchors and typing stays correct after it', () => { + const steps = lifecycleSteps({ flashOnEnd: false, reflowOnEnd: false, virtSlideOnIdle: true }) + const at = steps.findIndex(s => s.label === 'virt-slide') + steps.splice(at + 1, 0, { forceRedraw: true, label: 'ctrl-l' }) + + runScenario(steps, false, 3) + }) +}) diff --git a/ui-tui/packages/clawcodex-ink/src/ink/log-update.ts b/ui-tui/packages/clawcodex-ink/src/ink/log-update.ts index a428060b9..7effb3433 100644 --- a/ui-tui/packages/clawcodex-ink/src/ink/log-update.ts +++ b/ui-tui/packages/clawcodex-ink/src/ink/log-update.ts @@ -41,12 +41,49 @@ const NEWLINE = { type: 'stdout', content: '\n' } as const export class LogUpdate { private state: State + /** + * Physical viewport row (0-based) where the frame-end cursor physically + * sits, carried frame to frame (main screen only — alt screen re-anchors + * with CSI H every frame). + * + * Inline sessions start BELOW pre-existing shell output, so the frame's + * top row is NOT at the viewport top and content scrolls into scrollback + * EARLIER than frame-height arithmetic alone can predict. Reachability + * of a row therefore cannot be derived from screen height — it must come + * from where the cursor physically is: rows more than `physCursorRow` + * above the frame-end cursor are in scrollback and cannot be addressed + * (CSI cursor-up clamps at the viewport top; a clamped move desyncs + * every later relative move AND the parked-cursor basis of all future + * frames — the classic "typing lands one row below the input box" bug). + * + * Seeded at 0 — an UNDER-estimate whenever shell output sits above the + * frame. Under-estimation is safe: the scrolled-off guard becomes + * conservative (skips repainting a few more top rows that are actually + * visible until the estimate converges), and it converges to the exact + * value the first time an LF pins at the bottom margin, which any + * viewport-filling frame guarantees. + */ + private physCursorRow = 0 + constructor(private readonly options: Options) { this.state = { previousOutput: '' } } + /** Re-anchor the physical cursor tracker (e.g. after ERASE_SCREEN + + * CURSOR_HOME in forceRedraw, when the cursor is known to be at a + * specific viewport row). */ + resetAnchor(row: number): void { + this.physCursorRow = row + } + + /** Physical viewport row of the frame-end cursor — the basis ink.tsx + * uses to keep its own cursor-park moves inside the viewport. */ + physicalCursorRow(): number { + return this.physCursorRow + } + renderPreviousOutput_DEPRECATED(prevFrame: Frame): Diff { if (!this.options.isTTY) { // Non-TTY output is no longer supported (string output was removed) @@ -59,6 +96,9 @@ export class LogUpdate { // Called when process resumes from suspension (SIGCONT) to prevent clobbering terminal content reset(): void { this.state.previousOutput = '' + // Physical position is unknown after suspension — fall back to the + // conservative under-estimate (see physCursorRow docs). + this.physCursorRow = 0 } private renderFullFrame(frame: Frame): Diff { @@ -149,7 +189,7 @@ export class LogUpdate { next.viewport.height !== prev.viewport.height || (prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width) ) { - return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool) + return this.fullReset(next, 'resize', altScreen) } // DECSTBM scroll optimization: when a ScrollBox's scrollTop changed, @@ -206,10 +246,17 @@ export class LogUpdate { const cursorAtBottom = prev.cursor.y >= prev.screen.height const isGrowing = next.screen.height > prev.screen.height - // When content fills the viewport exactly (height == viewport) and the - // cursor is at the bottom, the cursor-restore LF at the end of the - // previous frame scrolled 1 row into scrollback. Use >= to catch this. - const prevHadScrollback = cursorAtBottom && prev.screen.height >= prev.viewport.height + // Main screen: rows more than physCursorRow above the frame-end cursor + // have scrolled into scrollback and cannot be addressed with relative + // moves (CSI cursor-up clamps at the viewport top). This is EXACT — + // unlike frame-height arithmetic, it stays correct when the frame + // started below pre-existing shell output (inline mode), where content + // scrolls earlier than height-vs-viewport comparison predicts. + const scrolledOffRows = Math.max(0, prev.cursor.y - this.physCursorRow) + + const prevHadScrollback = altScreen + ? cursorAtBottom && prev.screen.height >= prev.viewport.height + : scrolledOffRows > 0 const isShrinking = next.screen.height < prev.screen.height const nextFitsViewport = next.screen.height <= prev.viewport.height @@ -224,7 +271,7 @@ export class LogUpdate { `Full reset (shrink->below): prevHeight=${prev.screen.height}, nextHeight=${next.screen.height}, viewport=${prev.viewport.height}` ) - return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool) + return this.fullReset(next, 'offscreen', altScreen) } if ( @@ -252,7 +299,7 @@ export class LogUpdate { const prevLine = readLine(prev.screen, scrollbackChangeY) const nextLine = readLine(next.screen, scrollbackChangeY) - return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, { + return this.fullReset(next, 'offscreen', altScreen, { triggerY: scrollbackChangeY, prevLine, nextLine @@ -260,7 +307,12 @@ export class LogUpdate { } } - const screen = new VirtualScreen(prev.cursor, next.viewport.width) + const screen = new VirtualScreen( + prev.cursor, + next.viewport.width, + next.viewport.height, + altScreen ? 0 : this.physCursorRow + ) // Treat empty screen as height 1 to avoid spurious adjustments on first render const heightDelta = Math.max(next.screen.height, 1) - Math.max(prev.screen.height, 1) @@ -272,11 +324,14 @@ export class LogUpdate { if (shrinking) { const linesToClear = prev.screen.height - next.screen.height - // eraseLines only works within the viewport - it can't clear scrollback. - // If we need to clear more lines than fit in the viewport, some are in - // scrollback, so we need a full reset. - if (linesToClear > prev.viewport.height) { - return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', this.options.stylePool) + // eraseLines walks upward from the cursor row and can only erase rows + // that are physically on screen — above the viewport top the walk + // clamps. Main screen: the cursor sits at physCursorRow, so at most + // physCursorRow + 1 rows are reachable. If more must go, full reset. + const eraseReach = altScreen ? prev.viewport.height : this.physCursorRow + 1 + + if (linesToClear > eraseReach) { + return this.fullReset(next, 'offscreen', altScreen) } // clear(N) moves cursor UP by N-1 lines and to column 0 @@ -292,17 +347,27 @@ export class LogUpdate { } // viewportY = number of rows in scrollback (not visible on terminal). - // For shrinking: use max(prev, next) because terminal clears don't scroll. - // For growing: use prev state because new rows haven't scrolled old ones yet. - // When prevHadScrollback, add 1 for the cursor-restore LF that scrolled - // an additional row out of view at the end of the previous frame. Without - // this, the diff loop treats that row as reachable — but the cursor clamps - // at viewport top, causing writes to land 1 row off and garbling the output. + // + // Main screen: exactly scrolledOffRows — derived from the tracked + // physical cursor row, which absorbs BOTH content overflow and any + // start offset below pre-existing shell output. The old height-based + // heuristic under-counted in inline sessions, letting the diff loop + // address rows that had physically scrolled away; the resulting + // clamped cursor-up desynced every later write by the clamped amount + // (typing landed one row below the input box). + // + // Alt screen keeps the height arithmetic: its buffer never scrolls and + // prev.cursor is anchored to (0,0), so scrolledOffRows is meaningless + // there. For shrinking: use max(prev, next) because terminal clears + // don't scroll. For growing: use prev state because new rows haven't + // scrolled old ones yet. const cursorRestoreScroll = prevHadScrollback ? 1 : 0 - const viewportY = growing - ? Math.max(0, prev.screen.height - prev.viewport.height + cursorRestoreScroll) - : Math.max(prev.screen.height, next.screen.height) - next.viewport.height + cursorRestoreScroll + const viewportY = altScreen + ? growing + ? Math.max(0, prev.screen.height - prev.viewport.height + cursorRestoreScroll) + : Math.max(prev.screen.height, next.screen.height) - next.viewport.height + cursorRestoreScroll + : scrolledOffRows let currentStyleId = stylePool.none let currentHyperlink: Hyperlink = undefined @@ -383,7 +448,7 @@ export class LogUpdate { }) if (needsFullReset) { - return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, { + return this.fullReset(next, 'offscreen', altScreen, { triggerY: resetTriggerY, prevLine: readLine(prev.screen, resetTriggerY), nextLine: readLine(next.screen, resetTriggerY) @@ -425,7 +490,7 @@ export class LogUpdate { patches[1 + i] = NEWLINE } - return [patches, { dx: -prev.x, dy: rowsToCreate }] + return [patches, { dx: -prev.x, dy: rowsToCreate, lf: rowsToCreate }] } // At or past target row - need to move cursor to correct position @@ -445,6 +510,14 @@ export class LogUpdate { moveCursorTo(screen, next.cursor.x, next.cursor.y) } + // Persist the tracked physical row for the next frame's reachability + // guard. Alt screen skips this: its frames are CSI H-anchored and the + // main-screen anchor must survive alt-screen excursions unchanged + // (DECSET 1049 saves/restores the main-screen cursor). + if (!altScreen) { + this.physCursorRow = screen.phys + } + const elapsed = performance.now() - startTime if (elapsed > 50) { @@ -459,6 +532,29 @@ export class LogUpdate { return scrollPatch.length > 0 ? [...scrollPatch, ...screen.diff] : screen.diff } + + /** + * Full clear + repaint. clearTerminal homes the cursor to the viewport + * top, so the repaint's physical tracking re-anchors from row 0 — this is + * what makes resize/offscreen resets self-healing for the main-screen + * anchor as well. + */ + private fullReset( + frame: Frame, + reason: FlickerReason, + altScreen: boolean, + debug?: { triggerY: number; prevLine: string; nextLine: string } + ): Diff { + // After clearTerminal, cursor is at (0, 0) + const screen = new VirtualScreen({ x: 0, y: 0 }, frame.viewport.width, frame.viewport.height, 0) + renderFrame(screen, frame, this.options.stylePool) + + if (!altScreen) { + this.physCursorRow = screen.phys + } + + return [{ type: 'clearTerminal', reason, debug }, ...screen.diff] + } } function transitionHyperlink(diff: Diff, current: Hyperlink, target: Hyperlink): Hyperlink { @@ -491,19 +587,6 @@ function readLine(screen: Screen, y: number): string { return line.trimEnd() } -function fullResetSequence_CAUSES_FLICKER( - frame: Frame, - reason: FlickerReason, - stylePool: StylePool, - debug?: { triggerY: number; prevLine: string; nextLine: string } -): Diff { - // After clearTerminal, cursor is at (0, 0) - const screen = new VirtualScreen({ x: 0, y: 0 }, frame.viewport.width) - renderFrame(screen, frame, stylePool) - - return [{ type: 'clearTerminal', reason, debug }, ...screen.diff] -} - function renderFrame(screen: VirtualScreen, frame: Frame, stylePool: StylePool): void { renderFrameSlice(screen, frame, 0, frame.screen.height, stylePool) } @@ -546,7 +629,7 @@ function renderFrameSlice( patches[1 + i] = NEWLINE } - return [patches, { dx: -prev.x, dy: rowsToAdvance }] + return [patches, { dx: -prev.x, dy: rowsToAdvance, lf: rowsToAdvance }] }) } @@ -588,7 +671,7 @@ function renderFrameSlice( // CR+LF at end of row — \r resets to column 0, \n moves to next line. // Without \r, the terminal cursor stays at whatever column content ended // (since we skip trailing spaces, this can be mid-row). - screen.txn(prev => [[CARRIAGE_RETURN, NEWLINE], { dx: -prev.x, dy: 1 }]) + screen.txn(prev => [[CARRIAGE_RETURN, NEWLINE], { dx: -prev.x, dy: 1, lf: 1 }]) } // Reset any open style/hyperlink at end of slice @@ -598,7 +681,15 @@ function renderFrameSlice( return screen } -type Delta = { dx: number; dy: number } +type Delta = { + dx: number + dy: number + /** How many of `dy`'s rows are LF-driven ('\n' bytes). LFs differ from + * CSI cursor-down at the bottom margin: the terminal scrolls instead of + * moving the cursor, so the physical row pins at viewport bottom while + * the virtual (content) row keeps counting. */ + lf?: number +} /** * Write a cell with a pre-serialized style transition string (from @@ -658,6 +749,9 @@ function writeCellWithStyleStr(screen: VirtualScreen, cell: Cell, styleStr: stri if (px >= vw) { screen.cursor.x = cellWidth screen.cursor.y++ + // Auto-wrap behaves like an LF: at the bottom margin the terminal + // scrolls and the physical row pins instead of advancing. + screen.physLinefeed() } else { screen.cursor.x = px + cellWidth } @@ -731,12 +825,28 @@ class VirtualScreen { // File-private class — not exposed outside log-update.ts. cursor: Point diff: Diff = [] + /** Physical viewport row (0-based) of the cursor, tracked with terminal + * semantics: LF-driven advances pin at the bottom margin (the terminal + * scrolls instead of moving the cursor down). Relative cursor moves + * translate 1:1 — the reachability guard in render() must keep them + * inside the viewport, so they never clamp. */ + phys: number constructor( origin: Point, - readonly viewportWidth: number + readonly viewportWidth: number, + readonly viewportHeight: number, + physStart: number ) { this.cursor = { ...origin } + this.phys = physStart + } + + /** Advance the physical row by an LF-driven step (pins at the bottom). */ + physLinefeed(): void { + if (this.phys < this.viewportHeight - 1) { + this.phys++ + } } txn(fn: (prev: Point) => [patches: Diff, next: Delta]): void { @@ -748,5 +858,21 @@ class VirtualScreen { this.cursor.x += next.dx this.cursor.y += next.dy + + const lf = next.lf ?? 0 + + for (let i = 0; i < lf; i++) { + this.physLinefeed() + } + + // Non-LF vertical movement (CSI cursor up/down) moves the physical + // cursor exactly dy rows. The scrolled-off-row guard keeps targets + // inside the viewport; clamp defensively so a slipped-through move + // can't corrupt the tracker beyond the frame it happened in. + const moveDy = next.dy - lf + + if (moveDy !== 0) { + this.phys = Math.min(Math.max(this.phys + moveDy, 0), this.viewportHeight - 1) + } } }