Symptoms
When typing into the editor until the content is taller than the terminal viewport, then scrolling back up in the terminal, the scrollback history is corrupted. This happens every time without exception -- it is not a race condition or edge case.
Steps to reproduce:
- Open the CLI
- Type or paste enough text that the editor area exceeds the terminal height
- Scroll up in the terminal (mouse wheel or scrollbar)
- Observe: scrollback history is garbled/corrupted
This is distinct from the cursor positioning issues fixed in #133/#144 and the viewport overflow addressed in #145. Those fixed the cursor and height budget -- the scrollback corruption remains.
Root cause: The current rendering model writes content that can exceed the terminal viewport. Once content pushes past the top of the screen, it enters scrollback, and the clear/redraw cycle cannot recover it. Every incremental fix moves the corruption to a different edge case. This is a rendering architecture redesign, not a bug fix.
Guidance: Terminal Rendering Redesign
The One Invariant
The renderer never writes more rows than screen.rows.
Everything else follows from this.
Architecture
Three layers, strict separation:
Layout --> string[] (unbounded content buffer)
Viewport --> string[] (sliced to screen.rows)
Renderer --> screen.write() (puts bytes on screen)
Layer 1: Layout
Builds the logical content buffer -- an unbounded string[] where each entry is one visual row (already wrapped to screen.columns).
Layout is pure: takes inputs, returns an array. No side effects, no screen access.
interface BuiltComponent {
rows: string[];
height: number;
}
interface LayoutInput {
editor: EditorRender;
status: BuiltComponent | null;
attachments: BuiltComponent | null;
preview: BuiltComponent | null;
question: BuiltComponent | null;
columns: number;
}
interface LayoutResult {
buffer: string[]; // all visual rows, unbounded
cursorRow: number; // cursor position in buffer (0-based)
cursorCol: number; // cursor column (visual width)
editorStartRow: number; // where editor begins in buffer
}
function layout(input: LayoutInput): LayoutResult;
Order in the buffer (top to bottom):
- Question lines (if any)
- Status line
- Attachment line
- Preview lines
- Editor lines
The buffer can be 5 rows or 500 rows. Layout doesn't care about the terminal.
Layer 2: Viewport
Selects which slice of the buffer is visible. Owns scrollOffset.
interface ViewportResult {
rows: string[]; // exactly screenRows entries (padded with '' if short)
visibleCursorRow: number; // cursorRow - scrollOffset
visibleCursorCol: number; // unchanged from layout
}
Cursor chasing rules:
- If
cursorRow < scrollOffset --> scroll up: scrollOffset = cursorRow
- If
cursorRow >= scrollOffset + screenRows --> scroll down: scrollOffset = cursorRow - screenRows + 1
- Otherwise: don't move scrollOffset
Resize: scrollOffset = min(scrollOffset, max(0, buffer.length - screenRows)). Then cursor chasing applies.
Layer 3: Renderer
Takes ViewportResult, writes it to the Screen. This is the only layer that calls screen.write().
Render cycle:
screen.write(syncStart)
screen.write(hideCursor)
- Cursor-up
zoneHeight rows (go to top of zone)
- For each row in
frame.rows: \r + clearLine + write row content + \n (except last)
- Clear any remaining lines if new frame is shorter than previous
- Cursor-up to
visibleCursorRow
- Cursor-to
visibleCursorCol
screen.write(showCursor)
screen.write(syncEnd)
zoneHeight = frame.rows.length
writeHistory:
- Cursor-up
zoneHeight (top of zone)
- Write
\r + line + \n (pushes zone down, line enters scrollback naturally)
- Re-render the zone (steps above)
The zone is always at the bottom. History is always above it. They never overlap.
Screen Interface
All output goes through an abstract Screen interface, never directly to process.stdout:
interface Screen {
readonly rows: number;
readonly columns: number;
write(data: string): void;
onResize(cb: (columns: number, rows: number) => void): () => void;
}
StdoutScreen -- production, wraps process.stdout
MockScreen -- test, in-memory cell grid with ANSI interpreter. Tracks scrollbackViolations -- incremented any time a write would push past the bottom row
ANSI Primitives
The renderer uses a fixed set of escape sequences, abstracted behind named functions. No raw \x1b[ in the rendering code:
cursorUp(n), cursorTo(col) -- positioning
clearLine(), clearToEndOfLine() -- clearing
syncStart(), syncEnd() -- synchronized update (atomic render, no flicker)
hideCursor(), showCursor() -- prevent cursor flash during render
What Changes
Deleted / Replaced:
clearStickyZone() -- gone. Renderer always knows zone height, cursor-ups by that amount
buildSticky() -- replaced by Layout (pure function returns data, doesn't write)
stickyLineCount / cursorLinesFromBottom tracking -- replaced by zoneHeight and viewport cursor position
- Direct
process.stdout.write() calls -- replaced by screen.write()
Kept (moved to Layout as pure builders):
buildStatusLine() -- returns BuiltComponent
buildAttachmentLine() -- returns BuiltComponent
buildPreviewLines() -- returns BuiltComponent
prepareEditor() -- already pure (in renderer.ts)
Kept (unchanged):
writeHistory() concept, paused / pauseBuffer, log() / info() / error(), beep()
Test Strategy
Using MockScreen:
test('editor taller than terminal does not corrupt scrollback', () => {
const screen = new MockScreen(80, 10);
// Layout with 50 editor lines, resolve viewport, render
screen.assertNoScrollbackViolations();
});
test('resize shrinks viewport without corruption', () => {
const screen = new MockScreen(80, 24);
// Render at 24 rows, resize to 10, re-render
screen.assertNoScrollbackViolations();
});
test('cursor chasing keeps cursor in viewport', () => {
// cursor on row 45 of 50-line buffer, viewport 10 rows
expect(frame.visibleCursorRow).toBeGreaterThanOrEqual(0);
expect(frame.visibleCursorRow).toBeLessThan(10);
});
test('writeHistory does not affect zone height', () => {
// render, note zoneHeight, writeHistory, assert zoneHeight unchanged
screen.assertNoScrollbackViolations();
});
What This Does NOT Cover
- Diffing / optimisation (write only changed cells) -- not needed, add later if ever
- Alternate screen buffer -- not needed (inline rendering is correct for this use case)
- Line wrapping logic -- Layout's job, same string-width logic as today
- Editor text manipulation -- unchanged, still in editor.ts
Summary
| Concern |
Old (terminal.ts) |
New |
| What to show |
buildSticky() mixes content + output |
Layout: pure function, returns string[] |
| What's visible |
Implicit (write everything) |
Viewport: scrollOffset + slice |
| How to write |
process.stdout + relative cursor math |
Renderer: zone-aware, uses Screen interface |
| Scrollback safety |
Guaranteed corruption when content > viewport |
Structural: never exceed screen.rows |
| Testable |
No (coupled to stdout) |
Yes (MockScreen with scrollback assertions) |
Symptoms
When typing into the editor until the content is taller than the terminal viewport, then scrolling back up in the terminal, the scrollback history is corrupted. This happens every time without exception -- it is not a race condition or edge case.
Steps to reproduce:
This is distinct from the cursor positioning issues fixed in #133/#144 and the viewport overflow addressed in #145. Those fixed the cursor and height budget -- the scrollback corruption remains.
Root cause: The current rendering model writes content that can exceed the terminal viewport. Once content pushes past the top of the screen, it enters scrollback, and the clear/redraw cycle cannot recover it. Every incremental fix moves the corruption to a different edge case. This is a rendering architecture redesign, not a bug fix.
Guidance: Terminal Rendering Redesign
The One Invariant
The renderer never writes more rows than
screen.rows.Everything else follows from this.
Architecture
Three layers, strict separation:
Layer 1: Layout
Builds the logical content buffer -- an unbounded
string[]where each entry is one visual row (already wrapped toscreen.columns).Layout is pure: takes inputs, returns an array. No side effects, no screen access.
Order in the buffer (top to bottom):
The buffer can be 5 rows or 500 rows. Layout doesn't care about the terminal.
Layer 2: Viewport
Selects which slice of the buffer is visible. Owns
scrollOffset.Cursor chasing rules:
cursorRow < scrollOffset--> scroll up:scrollOffset = cursorRowcursorRow >= scrollOffset + screenRows--> scroll down:scrollOffset = cursorRow - screenRows + 1Resize:
scrollOffset = min(scrollOffset, max(0, buffer.length - screenRows)). Then cursor chasing applies.Layer 3: Renderer
Takes
ViewportResult, writes it to theScreen. This is the only layer that callsscreen.write().Render cycle:
screen.write(syncStart)screen.write(hideCursor)zoneHeightrows (go to top of zone)frame.rows:\r+ clearLine + write row content +\n(except last)visibleCursorRowvisibleCursorColscreen.write(showCursor)screen.write(syncEnd)zoneHeight = frame.rows.lengthwriteHistory:
zoneHeight(top of zone)\r+ line +\n(pushes zone down, line enters scrollback naturally)The zone is always at the bottom. History is always above it. They never overlap.
Screen Interface
All output goes through an abstract
Screeninterface, never directly toprocess.stdout:StdoutScreen-- production, wrapsprocess.stdoutMockScreen-- test, in-memory cell grid with ANSI interpreter. TracksscrollbackViolations-- incremented any time a write would push past the bottom rowANSI Primitives
The renderer uses a fixed set of escape sequences, abstracted behind named functions. No raw
\x1b[in the rendering code:cursorUp(n),cursorTo(col)-- positioningclearLine(),clearToEndOfLine()-- clearingsyncStart(),syncEnd()-- synchronized update (atomic render, no flicker)hideCursor(),showCursor()-- prevent cursor flash during renderWhat Changes
Deleted / Replaced:
clearStickyZone()-- gone. Renderer always knows zone height, cursor-ups by that amountbuildSticky()-- replaced by Layout (pure function returns data, doesn't write)stickyLineCount/cursorLinesFromBottomtracking -- replaced byzoneHeightand viewport cursor positionprocess.stdout.write()calls -- replaced byscreen.write()Kept (moved to Layout as pure builders):
buildStatusLine()-- returnsBuiltComponentbuildAttachmentLine()-- returnsBuiltComponentbuildPreviewLines()-- returnsBuiltComponentprepareEditor()-- already pure (in renderer.ts)Kept (unchanged):
writeHistory()concept,paused/pauseBuffer,log()/info()/error(),beep()Test Strategy
Using
MockScreen:What This Does NOT Cover
Summary
buildSticky()mixes content + outputstring[]scrollOffset+ sliceprocess.stdout+ relative cursor mathscreen.rows