diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 280b334..b37e4d6 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/153-zone-anchor-bottom` -In-progress: #153 Phase 1 complete. PR #156 open, auto-merge enabled. +Branch: `feature/154-155-scrollback-ux` +In-progress: PR shellicar/claude-cli#157 open, auto-merge enabled. Awaiting CI and merge. diff --git a/.claude/sessions/2026-03-30.md b/.claude/sessions/2026-03-30.md index ff40d26..f7fcce1 100644 --- a/.claude/sessions/2026-03-30.md +++ b/.claude/sessions/2026-03-30.md @@ -1,3 +1,17 @@ +### 05:47 - scrollback/ux Phase 2 (#154, #155) + +- Did: Pushed branch, created PR shellicar/claude-cli#157 with auto-merge enabled +- Files: (no code changes) +- Decisions: Used milestone "1.0" (existing open milestone) +- Next: Awaiting CI and auto-merge + +### 05:37 - scrollback/ux (#154, #155) + +- Did: Implemented history mode zone collapse (editor hidden, zone = 1 indicator row); changed indicator from `[↑ start/total]` to `[↑ start-end/total]` with correct range; fixed resolve order so indicator reflects current scroll position on first render; added 6 tests +- Files: `src/terminal.ts`, `test/terminal-integration.spec.ts` +- Decisions: History mode resolves history viewport first (before building zone) to eliminate one-frame lag on indicator; `screenRows - 1` reserved for history gives full-screen effect with indicator still visible at bottom +- Next: Phase 2 (push branch + PR) + ### 02:55: rendering/alt-buffer-design (Phase 0) - Did: Investigated entire rendering pipeline for #149 alternate buffer rendering. Produced design artifacts for Phase 1/2 implementation. diff --git a/src/terminal.ts b/src/terminal.ts index a1ce621..fb0cebe 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -192,9 +192,10 @@ export class Terminal { if (this.historyViewport.mode === 'history') { const start = this.lastHistoryFrame.visibleStart + 1; + const end = Math.min(this.lastHistoryFrame.visibleStart + this.lastHistoryFrame.rows.length, this.lastHistoryFrame.totalLines); const total = this.lastHistoryFrame.totalLines; b.ansi(resetStyle); - b.text(` [\u2191 ${start}/${total}]`); + b.text(` [\u2191 ${start}-${end}/${total}]`); } return { line: b.output, screenLines: b.screenLines(columns) }; @@ -340,7 +341,27 @@ export class Terminal { private renderZone(): void { const columns = this.screen.columns; const screenRows = this.screen.rows; + const wrappedHistory = this.displayBuffer.flatMap((line) => wrapLine(line, columns)); + if (this.historyViewport.mode === 'history') { + // History mode: give history all but 1 row, zone collapses to just the indicator. + // Resolve history first so the indicator reflects the current scroll position. + const historyRowCount = Math.max(0, screenRows - 1); + const historyFrame = this.historyViewport.resolve(wrappedHistory, historyRowCount); + this.lastHistoryFrame = historyFrame; + + const statusResult = this.buildStatusLine(columns, true); + const zoneBuffer = statusResult ? [statusResult.line] : []; + const zoneRows = screenRows - historyFrame.rows.length; + const frame = this.viewport.resolve(zoneBuffer, zoneRows, 0, 0); + this.renderer.render(historyFrame.rows, frame); + if (this.cursorHidden) { + this.screen.write(hideCursorSeq); + } + return; + } + + // Live mode: full zone (editor + status + attachments). // 1. Build zone const input = this.buildLayoutInput(columns); const result = layout(input); @@ -350,7 +371,6 @@ export class Terminal { const historyRows = screenRows - zoneHeight; // 3. History viewport - const wrappedHistory = this.displayBuffer.flatMap((line) => wrapLine(line, columns)); const historyFrame = this.historyViewport.resolve(wrappedHistory, historyRows); this.lastHistoryFrame = historyFrame; diff --git a/test/terminal-integration.spec.ts b/test/terminal-integration.spec.ts index 54be1f2..1c09521 100644 --- a/test/terminal-integration.spec.ts +++ b/test/terminal-integration.spec.ts @@ -326,6 +326,91 @@ describe('Two-region rendering', () => { expect(frame2.rows[2]).toBe('h'); }); + it('history mode: history region gets screenRows-1 rows, zone gets 1', () => { + const screenRows = 10; + const historyViewport = new HistoryViewport(); + const buffer = Array.from({ length: 28 }, (_, i) => `line ${i}`); + + // Initialize in live mode so pageUp has lastViewportRows + historyViewport.resolve(buffer, screenRows - 1); + historyViewport.pageUp(); + + const historyFrame = historyViewport.resolve(buffer, screenRows - 1); + const zoneRows = screenRows - historyFrame.rows.length; + + expect(historyFrame.rows.length).toBe(screenRows - 1); + expect(zoneRows).toBe(1); + }); + + it('history mode: returnToLive restores live mode', () => { + const historyViewport = new HistoryViewport(); + const buffer = Array.from({ length: 28 }, (_, i) => `line ${i}`); + + historyViewport.resolve(buffer, 9); + historyViewport.pageUp(); + expect(historyViewport.mode).toBe('history'); + + historyViewport.returnToLive(); + expect(historyViewport.mode).toBe('live'); + }); + + it('indicator range: correct start-end for buffer larger than viewport', () => { + const historyViewport = new HistoryViewport(); + const buffer = Array.from({ length: 52 }, (_, i) => `line ${i}`); + const historyRows = 24; + + // Initialize and enter history mode + historyViewport.resolve(buffer, historyRows); + historyViewport.pageUp(); + const frame = historyViewport.resolve(buffer, historyRows); + + const start = frame.visibleStart + 1; + const end = Math.min(frame.visibleStart + frame.rows.length, frame.totalLines); + + expect(start).toBeGreaterThanOrEqual(1); + expect(end).toBeGreaterThanOrEqual(start); + expect(end - start + 1).toBe(historyRows); + expect(end).toBeLessThanOrEqual(frame.totalLines); + }); + + it('indicator range: short buffer end is capped at totalLines not viewport rows', () => { + const historyViewport = new HistoryViewport(); + const buffer = Array.from({ length: 10 }, (_, i) => `line ${i}`); + const historyRows = 24; + + const frame = historyViewport.resolve(buffer, historyRows); + + const start = frame.visibleStart + 1; + const end = Math.min(frame.visibleStart + frame.rows.length, frame.totalLines); + + expect(start).toBe(1); + expect(end).toBe(10); + expect(frame.totalLines).toBe(10); + }); + + it('history mode: no scrollback violations with collapsed zone', () => { + const screenRows = 10; + const screen = new MockScreen(80, screenRows); + screen.enterAltBuffer(); + const historyViewport = new HistoryViewport(); + const viewport = new Viewport(); + const renderer = new Renderer(screen); + + const buffer = Array.from({ length: 28 }, (_, i) => `line ${i}`); + + historyViewport.resolve(buffer, screenRows - 1); + historyViewport.pageUp(); + + const historyFrame = historyViewport.resolve(buffer, screenRows - 1); + const zoneRows = screenRows - historyFrame.rows.length; + const zoneBuffer = ['[\u2191 1-9/28]']; + const frame = viewport.resolve(zoneBuffer, zoneRows, 0, 0); + + renderer.render(historyFrame.rows, frame); + + screen.assertNoScrollbackViolations(); + }); + it('zone height changes do not corrupt history region', () => { const screen = new MockScreen(80, 10); screen.enterAltBuffer();