From 60a4e648e5358a3f72caab7a57732f49fd8400c2 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 30 Mar 2026 05:16:05 +1100 Subject: [PATCH 1/2] Anchor zone to bottom from startup (#153) Two fixes so the zone renders at the bottom of the screen on launch: 1. Remove empty-displayBuffer short-circuit in renderZone(): always resolve through HistoryViewport so padding rows fill the history region even when displayBuffer is empty. The viewport already returns Array(rows).fill('') for an empty buffer, so trailing-trim never removes padding (zone content is always at the bottom). 2. Always push to displayBuffer in writeHistory() regardless of inAltBuffer. Startup messages (version, session info) are written before enterAltBuffer() and previously bypassed displayBuffer entirely. With this fix the first renderZone() call after entering alt buffer sees the startup messages in displayBuffer and the history viewport displays them above the zone. Closes #153 --- src/terminal.ts | 11 ++----- test/terminal-integration.spec.ts | 51 +++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/terminal.ts b/src/terminal.ts index a5e0151..a1ce621 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -350,13 +350,8 @@ export class Terminal { const historyRows = screenRows - zoneHeight; // 3. History viewport - let historyFrame: HistoryFrame; - if (this.displayBuffer.length > 0) { - const wrappedHistory = this.displayBuffer.flatMap((line) => wrapLine(line, columns)); - historyFrame = this.historyViewport.resolve(wrappedHistory, historyRows); - } else { - historyFrame = { rows: [], totalLines: 0, visibleStart: 0 }; - } + const wrappedHistory = this.displayBuffer.flatMap((line) => wrapLine(line, columns)); + const historyFrame = this.historyViewport.resolve(wrappedHistory, historyRows); this.lastHistoryFrame = historyFrame; // 4. Zone viewport (available rows = screen minus history) @@ -387,10 +382,10 @@ export class Terminal { this.pauseBuffer.push(line); return; } + this.displayBuffer.push(line); if (this.inAltBuffer) { // Accumulate in memory; re-render zone (status may have changed) this.historyBuffer.push(line); - this.displayBuffer.push(line); this.renderZone(); } else { // Main buffer (startup messages, post-exit): write directly diff --git a/test/terminal-integration.spec.ts b/test/terminal-integration.spec.ts index 80b5bad..54be1f2 100644 --- a/test/terminal-integration.spec.ts +++ b/test/terminal-integration.spec.ts @@ -211,9 +211,10 @@ describe('History flush', () => { }); describe('Two-region rendering', () => { - it('empty displayBuffer: zone gets full screen', () => { + it('empty displayBuffer: zone anchored to bottom with padding rows above', () => { const screen = new MockScreen(80, 10); screen.enterAltBuffer(); + const historyViewport = new HistoryViewport(); const viewport = new Viewport(); const renderer = new Renderer(screen); @@ -228,18 +229,56 @@ describe('Two-region rendering', () => { const result = layout(input); const screenRows = screen.rows; + const zoneHeight = Math.min(result.buffer.length, screenRows); + const historyRows = screenRows - zoneHeight; - // When displayBuffer is empty, Terminal uses short-circuit: historyFrame = { rows: [] } - const historyFrame = { rows: [] as string[], totalLines: 0, visibleStart: 0 }; + // Empty displayBuffer resolves through viewport (no short-circuit) + const historyFrame = historyViewport.resolve([], historyRows); + const zoneRows = screenRows - historyFrame.rows.length; + const zoneFrame = viewport.resolve(result.buffer, zoneRows, result.cursorRow, result.cursorCol); + + renderer.render(historyFrame.rows, zoneFrame); + + screen.assertNoScrollbackViolations(); + // History region is all padding rows (empty strings) + expect(historyFrame.rows.length).toBe(historyRows); + expect(historyFrame.rows.every((r) => r === '')).toBe(true); + // Zone gets correct number of rows, anchored to bottom + expect(zoneFrame.rows.length).toBe(zoneRows); + }); + + it('startup messages in displayBuffer visible in history region on first render', () => { + const screen = new MockScreen(80, 10); + screen.enterAltBuffer(); + const historyViewport = new HistoryViewport(); + const viewport = new Viewport(); + const renderer = new Renderer(screen); + + const input = { + editor: makeEditorRender(2, 0, 0), + status: makeComponent(['status']), + attachments: null, + preview: null, + question: null, + columns: 80, + } satisfies LayoutInput; + + const displayBuffer = ['v1.0.0', 'Session: abc123']; + const result = layout(input); + const screenRows = screen.rows; + const zoneHeight = Math.min(result.buffer.length, screenRows); + const historyRows = screenRows - zoneHeight; + const wrappedHistory = displayBuffer.flatMap((line) => wrapLine(line, 80)); + const historyFrame = historyViewport.resolve(wrappedHistory, historyRows); const zoneRows = screenRows - historyFrame.rows.length; const zoneFrame = viewport.resolve(result.buffer, zoneRows, result.cursorRow, result.cursorCol); renderer.render(historyFrame.rows, zoneFrame); screen.assertNoScrollbackViolations(); - // empty displayBuffer: zone gets the full screen - expect(historyFrame.rows).toEqual([]); - expect(zoneFrame.rows.length).toBe(screenRows); + // Startup messages visible in history region (bottom-aligned) + expect(historyFrame.rows).toContain('v1.0.0'); + expect(historyFrame.rows).toContain('Session: abc123'); }); it('history lines appear above zone content', () => { From a9a2fe5bac3b019eeaebcfc4d8196e63d857c768 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 30 Mar 2026 05:18:44 +1100 Subject: [PATCH 2/2] Session log: zone-anchor-bottom phase 1 --- .claude/CLAUDE.md | 4 +- .claude/sessions/2026-03-30.md | 123 +++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 65f1282..280b334 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -76,8 +76,8 @@ Only update the `Status` field. Do not modify any other frontmatter or prompt co ## Current State -Branch: `feature/150-in-app-scrollback` -In-progress: #150 complete. PR shellicar/claude-cli#152 open, auto-merge pending (CodeQL running). +Branch: `feature/153-zone-anchor-bottom` +In-progress: #153 Phase 1 complete. PR #156 open, auto-merge enabled. diff --git a/.claude/sessions/2026-03-30.md b/.claude/sessions/2026-03-30.md index 566f22c..ff40d26 100644 --- a/.claude/sessions/2026-03-30.md +++ b/.claude/sessions/2026-03-30.md @@ -855,3 +855,126 @@ The prompt says "Do not touch Layout internals unless required by the two-region - Decisions: No code changes. Auto-merge enabled. Build and verify-signatures checks passed; CodeQL in progress. - Next: #150 complete. PR auto-merges when CodeQL passes. - Next: Phase 3 (ship): push branch, create PR with Closes #150. + +### 05:04: zone-anchor-bottom/design (Phase 0) + +- Did: Investigated rendering pipeline for #153 (zone anchor to bottom from startup). Identified two root causes and designed minimal fixes. Produced design artifacts below. +- Files: (no changes, investigation only) +- Decisions: See Design Artifacts below +- Next: Phase 1 implementation + +## Design Artifacts (#153) + +### 1. renderZone() changes: remove empty-displayBuffer short-circuit + +**Current code** (terminal.ts ~line 354-359): + +```typescript +if (this.displayBuffer.length > 0) { + const wrappedHistory = this.displayBuffer.flatMap((line) => wrapLine(line, columns)); + historyFrame = this.historyViewport.resolve(wrappedHistory, historyRows); +} else { + historyFrame = { rows: [], totalLines: 0, visibleStart: 0 }; +} +``` + +**Problem:** When `displayBuffer` is empty, `historyFrame.rows` is `[]` (zero rows). The zone gets `screenRows - 0 = screenRows` and renders at the top. + +**Fix:** Always resolve through the history viewport. Remove the guard entirely: + +```typescript +const wrappedHistory = this.displayBuffer.flatMap((line) => wrapLine(line, columns)); +historyFrame = this.historyViewport.resolve(wrappedHistory, historyRows); +``` + +**Why this works:** `HistoryViewport.resolve([], historyRows)` returns `{ rows: Array(historyRows).fill(''), ... }`. These padding rows occupy the top of the screen. `zoneRows = screenRows - historyRows = zoneHeight`. The zone renders at the bottom. + +**Trailing trim check:** The renderer trims trailing empty rows from the combined `[...historyRows, ...zoneRows]` buffer. History padding is at the TOP. Zone content (editor + status) is at the BOTTOM and is never empty (at least 1 editor row). So the last row is always zone content, and no history padding is trimmed. Verified for: empty displayBuffer, zone smaller than screen, zone filling entire screen. + +### 2. writeHistory() changes: always push to displayBuffer + +**Current code** (terminal.ts ~line 385-399): + +```typescript +private writeHistory(line: string): void { + if (this._paused) { + this.pauseBuffer.push(line); + return; + } + if (this.inAltBuffer) { + this.historyBuffer.push(line); + this.displayBuffer.push(line); + this.renderZone(); + } else { + this.screen.write(line + '\n'); + } +} +``` + +**Problem:** Startup messages (written before `enterAltBuffer()`) go to `screen.write()` only, never to `displayBuffer`. When alt buffer starts and the first render happens, `displayBuffer` is empty. + +**Fix:** Always push to `displayBuffer` regardless of `inAltBuffer`: + +```typescript +private writeHistory(line: string): void { + if (this._paused) { + this.pauseBuffer.push(line); + return; + } + this.displayBuffer.push(line); + if (this.inAltBuffer) { + this.historyBuffer.push(line); + this.renderZone(); + } else { + this.screen.write(line + '\n'); + } +} +``` + +**Why only displayBuffer, not historyBuffer:** `historyBuffer` is used by `flushHistory()` to write lines to main buffer. Startup messages are already in main buffer (via `screen.write()`). Adding them to `historyBuffer` would cause double-writing on flush. + +### 3. No other code paths affected + +- **flushHistory()**: Unchanged. Only reads/clears `historyBuffer`. `displayBuffer` persists across flushes. +- **TerminalRenderer.render()**: Unchanged. Receives padding rows from history, zone content below. +- **HistoryViewport**: Unchanged. Already handles empty buffer with padding. +- **ClaudeCli.start()**: Unchanged. Startup sequence (messages, showSkills, enterAltBuffer, redraw) still works. First `redraw()` now sees startup messages in `displayBuffer` and history viewport returns padding + content rows that push the zone to the bottom. +- **showSkills()**: Called at line 1049 before `enterAltBuffer()`. Skills log line goes through `writeHistory()`, so with the fix it reaches `displayBuffer` too. Correct and consistent. + +## Concerns + +### 1. Startup messages appear in both buffers + +With the fix, startup messages appear in: +- Main buffer (via `screen.write()`) visible in terminal scrollback +- Alt buffer history region (via `displayBuffer`) visible while using the app + +This is correct. Main buffer has them for post-exit review. Alt buffer history has them for in-app scrollback. Omitting either would lose accessibility in one context. + +### 2. Pause buffer interaction + +When `_paused` is true, lines go to `pauseBuffer` without touching `displayBuffer`. On unpause, `flushPauseBuffer()` calls `writeHistory()` for each line, which pushes to `displayBuffer`. Paused lines still reach `displayBuffer` eventually. Startup messages are never paused (pause is only set during resize debounce, which happens after startup). + +### 3. Zone fills entire screen + +When the zone has >= screenRows rows: `zoneHeight = screenRows`, `historyRows = 0`. `resolve(wrappedHistory, 0)` returns `{ rows: [], ... }` (the `rows <= 0` guard). Zone gets full screen. History is invisible but still accumulates in `displayBuffer` for when the zone shrinks. Same behavior as before. + +### 4. Test impact + +One existing test needs updating: + +- **`empty displayBuffer: zone gets full screen`** (terminal-integration.spec.ts line 213-243): Currently tests the short-circuit path where `historyFrame.rows = []` and zone gets `screenRows`. After the fix, empty displayBuffer resolves through the viewport, producing padding rows. The zone gets `zoneHeight` rows (not `screenRows`), anchored at the bottom. Test should be rewritten to verify bottom-anchoring: history region filled with padding, zone at bottom of screen. + +New tests needed: +- Zone anchored to bottom with empty displayBuffer (padding rows at top, zone content at bottom) +- Startup messages visible in displayBuffer after pre-alt-buffer writes (simulate `writeHistory` calls before `inAltBuffer = true`, verify they appear in displayBuffer) +- Existing behavior preserved when history has content (already tested, should pass without changes) + +--- + +### 05:15: zone-anchor-bottom/phase1 (Phase 1) + +- Did: Implemented both renderZone() and writeHistory() fixes per Phase 0 design. Updated tests: rewrote empty-displayBuffer test, added startup messages test. 219 tests pass. +- Files: src/terminal.ts, test/terminal-integration.spec.ts +- Decisions: Followed Phase 0 design exactly. Two minimal changes: remove the if/else short-circuit in renderZone() (3 lines replaced by 2), always push to displayBuffer before the inAltBuffer branch in writeHistory(). +- Next: PR #156 open, auto-merge enabled. CodeQL and CI in progress.