diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1b10723..65f1282 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -5,7 +5,7 @@ ## Session Protocol -Every session has three phases. Follow them in order — session start sets up the workspace, work is the development, session end records what happened. Start and end wrap the work so nothing is lost. +Every session has three phases. Follow them in order. Session start sets up the workspace, work is the development, session end records what happened. Start and end wrap the work so nothing is lost. ``` - [ ] Session start @@ -16,20 +16,19 @@ Every session has three phases. Follow them in order — session start sets up t ### Session Start 1. Read this file 2. Find recent session logs: `find .claude/sessions -name '*.md' 2>/dev/null | sort -r | head -5` -3. Read session logs found — understand current state before doing anything +3. Read session logs found. Understand current state before doing anything. 4. Create or switch to the correct branch (if specified in prompt) -5. Build your TODO list using TodoWrite — include all work steps from the prompt, then append `Session end` as the final item -6. Present the TODO list to the user before starting work +5. Build your TODO list using TodoWrite. Include all work steps from the prompt, then append `Session end` as the final item. ### Work -This is where you do the actual development — writing code, fixing bugs, running tests, verifying changes. Each step from the prompt becomes a TODO item. +This is where you do the actual development: writing code, fixing bugs, running tests, verifying changes. Each step from the prompt becomes a TODO item. -- Work incrementally — one task at a time +- Work incrementally, one task at a time - Mark each TODO in-progress before starting, completed immediately after finishing -- If a TODO is dropped, mark it `[-]` with a brief reason — never silently remove a task +- If a TODO is dropped, mark it `[-]` with a brief reason. Never silently remove a task. - Commit with descriptive messages after each meaningful change - If your prompt includes WORK ITEMS, reference them in commit messages (e.g. `#82`, `AB#1234`) -- Be proactive — after completing a step, start the next one. If blocked, say why. +- Be proactive: after completing a step, start the next one. If blocked, say why. Verification (type-check, tests, lint, asking the user to test) is part of your work steps, not session end. Include it where it makes sense for the changes you made. @@ -39,22 +38,27 @@ Session end is bookkeeping. Do not start until all work steps are complete. 1. Write session log to `.claude/sessions/YYYY-MM-DD.md`: ``` - ### HH:MM — [area/task] + ### HH:MM - [area/task] - Did: (1-3 bullets) - Files: (changed files) - - Decisions: (what and why — include dropped tasks and why) + - Decisions: (what and why, include dropped tasks and why) - Next: (what remains / blockers) - - Violations: (any protocol violations, or "None") ``` 2. Update `Current State` below if branch or in-progress work changed 3. Update `Recent Decisions` below if you made an architectural decision -4. Commit — session log and state updates MUST be in this commit -5. Push to remote -6. Create PR (if appropriate) - -**Why push and PR are last:** The session log and state updates are tracked files. They must be committed with the code they describe — one commit, one push, one PR that includes everything. If you push first and write the log after, it either gets left out or requires a second push. +4. If committing: session log and state updates MUST be in the same commit as the code they describe + +## Never Guess + +If you do not have enough information to do something, ask. Do not guess. Do not infer. Do not fill in blanks with what seems reasonable. + +A guessed value compounds through every downstream action. A guessed git identity becomes commits attributed to the wrong person. A guessed config value becomes a runtime error three sessions later. A guessed file path becomes a wasted investigation. Every guess costs time, money, and trust. The damage is not the guess itself: it is everything built on top of it. + +If something is missing, broken, or unclear: stop and ask. A question costs one message. A guess costs everything downstream of it. + + ## Prompt Delivery @@ -67,13 +71,13 @@ Your assignment may have been dispatched from a prompt file in the fleet PM repo | Work suspended, will resume later | `paused` | | All deliverables complete | `completed` | -Only update the `Status` field — do not modify any other frontmatter or prompt content. The PM handles all other prompt tracking. +Only update the `Status` field. Do not modify any other frontmatter or prompt content. The PM handles all other prompt tracking. ## Current State -Branch: `feature/149-alternate-buffer-rendering` -In-progress: PR #151 created. Awaiting CodeQL check and auto-merge. +Branch: `feature/150-in-app-scrollback` +In-progress: #150 complete. PR shellicar/claude-cli#152 open, auto-merge pending (CodeQL running). diff --git a/.claude/sessions/2026-03-30.md b/.claude/sessions/2026-03-30.md index 36ba3e5..566f22c 100644 --- a/.claude/sessions/2026-03-30.md +++ b/.claude/sessions/2026-03-30.md @@ -361,3 +361,497 @@ Pause buffer (`pauseBuffer`) still works correctly. During resize pause, history - Decisions: Branch was already up to date with remote from Phase 2. Loaded git-push skill but no commits needed pushing. - Next: Enable auto-merge on PR. CodeQL check in progress (not a failure). - Violations: None + +### 04:00: scrollback/design (Phase 0) + +- Did: Investigated rendering pipeline for #150 in-app scrollback. Read terminal.ts, Viewport.ts, TerminalRenderer.ts, Layout.ts, ClaudeCli.ts (handleKey, redraw, key routing), input.ts (KeyAction types, translateKey), StatusLineBuilder.ts, Screen.ts, MockScreen.ts, existing tests. Produced 8 design artifacts below. +- Files: (no changes, investigation only) +- Decisions: See Design Artifacts below +- Next: Phase 1 implementation (history visible in alt buffer) + +## Design Artifacts (#150) + +### 1. Two-Region Rendering Model + +**Decision:** Option (b): two separate regions with independent scroll state. History (top), zone (bottom). + +**Rationale:** Option (a) (prepend history to layout buffer, single Viewport) requires disabling cursor chasing in history mode while maintaining correct cursor positioning for the editor. This creates tangled responsibilities in one Viewport. Two regions give clean separation: the history viewport handles scroll/mode, the zone viewport handles cursor chasing. Neither interferes with the other. + +**Data flow:** + +``` +Terminal.renderZone() + // 1. Build zone (same pipeline as current) + buildLayoutInput(columns) -> layout(input) -> result + + // 2. Compute region sizes + zoneHeight = min(result.buffer.length, screenRows) + historyRows = screenRows - zoneHeight + + // 3. History viewport (skip if no display content) + if displayBuffer.length > 0: + wrappedHistory = displayBuffer.flatMap(line => wrapLine(line, columns)) + historyFrame = historyViewport.resolve(wrappedHistory, historyRows) + else: + historyFrame = { rows: [], totalLines: 0, visibleStart: 0 } + + // 4. Zone viewport (available rows = screen minus history) + zoneRows = screenRows - historyFrame.rows.length + zoneFrame = viewport.resolve(result.buffer, zoneRows, cursorRow, cursorCol) + + // 5. Renderer receives both + renderer.render(historyFrame.rows, zoneFrame) +``` + +When `displayBuffer` is empty, `historyFrame.rows` is `[]`, so `zoneRows = screenRows`. The zone gets the full screen, preserving current behavior. When history arrives, the zone slides down and history fills from above. + +**Layout.ts change:** Export `wrapLine` (currently module-private). History lines need the same grapheme-aware, ZWJ-sanitised wrapping that the zone layout uses. This is the minimal Layout change needed. + +### 2. History Viewport Interface + +**Decision:** New `HistoryViewport` class, separate from the existing `Viewport`. The existing Viewport does cursor chasing, which history does not need. A separate class avoids conditional "no cursor" mode in Viewport. + +```typescript +export interface HistoryFrame { + rows: string[]; + totalLines: number; + visibleStart: number; +} + +export class HistoryViewport { + private scrollOffset = 0; + private _mode: 'live' | 'history' = 'live'; + private lastViewportRows = 0; + private lastBufferLength = 0; + + public get mode(): 'live' | 'history' { + return this._mode; + } + + /** + * Resolve the history buffer into a frame for rendering. + * In live mode, auto-follows the bottom. + * In history mode, scrollOffset is pinned. + * Content is bottom-aligned (top-padded) when buffer < viewport rows. + */ + public resolve(buffer: string[], rows: number): HistoryFrame { + this.lastViewportRows = rows; + this.lastBufferLength = buffer.length; + + if (rows <= 0) { + return { rows: [], totalLines: buffer.length, visibleStart: 0 }; + } + + if (buffer.length === 0) { + return { rows: Array(rows).fill(''), totalLines: 0, visibleStart: 0 }; + } + + if (this._mode === 'live') { + this.scrollOffset = Math.max(0, buffer.length - rows); + } else { + // Cap to valid range (buffer may have grown since last scroll) + this.scrollOffset = Math.max( + 0, + Math.min(this.scrollOffset, buffer.length - rows), + ); + } + + const slice = buffer.slice(this.scrollOffset, this.scrollOffset + rows); + + // Top-pad: empty rows first, content at bottom of region. + // This keeps the most recent history adjacent to the zone. + const padding = rows - slice.length; + const result = padding > 0 + ? [...Array(padding).fill(''), ...slice] + : slice; + + return { + rows: result, + totalLines: buffer.length, + visibleStart: this.scrollOffset, + }; + } + + /** + * Scroll up by one page (historyRows). Enters history mode. + * First call snaps scrollOffset to current live position before scrolling. + */ + public pageUp(): void { + if (this.lastViewportRows <= 0 || this.lastBufferLength === 0) return; + if (this._mode === 'live') { + this.scrollOffset = Math.max(0, this.lastBufferLength - this.lastViewportRows); + } + this._mode = 'history'; + this.scrollOffset = Math.max(0, this.scrollOffset - this.lastViewportRows); + } + + /** + * Scroll down by one page. Returns to live if scrolled to bottom. + */ + public pageDown(): void { + if (this._mode !== 'history') return; + const maxOffset = Math.max(0, this.lastBufferLength - this.lastViewportRows); + this.scrollOffset = Math.min(this.scrollOffset + this.lastViewportRows, maxOffset); + if (this.scrollOffset >= maxOffset) { + this._mode = 'live'; + } + } + + /** + * Scroll up by one line. Enters history mode. + */ + public lineUp(): void { + if (this.lastViewportRows <= 0 || this.lastBufferLength === 0) return; + if (this._mode === 'live') { + this.scrollOffset = Math.max(0, this.lastBufferLength - this.lastViewportRows); + } + this._mode = 'history'; + this.scrollOffset = Math.max(0, this.scrollOffset - 1); + } + + /** + * Scroll down by one line. Returns to live if scrolled to bottom. + */ + public lineDown(): void { + if (this._mode !== 'history') return; + const maxOffset = Math.max(0, this.lastBufferLength - this.lastViewportRows); + this.scrollOffset = Math.min(this.scrollOffset + 1, maxOffset); + if (this.scrollOffset >= maxOffset) { + this._mode = 'live'; + } + } + + /** + * Return to live mode. Next resolve() will auto-follow bottom. + */ + public returnToLive(): void { + this._mode = 'live'; + } +} +``` + +**Top-padding rationale:** When history content is shorter than the region, top-padding pushes content to the bottom of the history region, adjacent to the zone below. Without this, 5 lines of history in a 21-row region would display at the top with a 16-row gap above the zone. + +**Cached `lastViewportRows`/`lastBufferLength`:** Scroll methods need these to compute page size and detect scroll-to-bottom. Values come from the most recent `resolve()` call. Staleness (new history lines arriving between renders) is harmless because the next `resolve()` caps `scrollOffset` to the valid range. + +### 3. Zone Height Communication + +**Decision:** Computed in `renderZone()`. No external state needed. + +``` +zoneHeight = min(result.buffer.length, screenRows) +historyRows = screenRows - zoneHeight +``` + +After the history viewport resolves, the zone viewport gets the remaining rows: + +``` +zoneRows = screenRows - historyFrame.rows.length +zoneFrame = viewport.resolve(result.buffer, zoneRows, cursorRow, cursorCol) +``` + +When `displayBuffer` is empty, `historyFrame.rows` is `[]`, so `zoneRows = screenRows`. The zone gets the full screen. + +When the zone layout buffer fills the screen (`result.buffer.length >= screenRows`), `zoneHeight = screenRows`, `historyRows = 0`, history viewport returns `{ rows: [] }`, and the zone gets the full screen. History is not visible but still accumulates in `displayBuffer` for when the zone shrinks. + +No new state, no new interfaces. Everything is computed fresh each render. + +### 4. Mode State Machine + +**Decision:** State lives on `HistoryViewport` (encapsulated with the scroll logic it controls). + +``` +State: 'live' | 'history' +Initial: 'live' + +Transitions: + live -> history : pageUp() or lineUp() called (user presses Page Up or Shift+Up) + history -> live : returnToLive() called (user presses Esc) + history -> live : pageDown() or lineDown() reaches bottom of buffer (auto-return) +``` + +**Queryable via `mode` getter.** ClaudeCli reads this for: +1. Escape key routing: if `term.isHistoryMode`, Esc returns to live instead of aborting query +2. Status line: Terminal reads `historyViewport.mode` to show position indicator + +**Terminal exposes:** + +```typescript +// Terminal class additions +private readonly historyViewport = new HistoryViewport(); + +public scrollHistoryPageUp(): void { + this.historyViewport.pageUp(); +} + +public scrollHistoryPageDown(): void { + this.historyViewport.pageDown(); +} + +public scrollHistoryLineUp(): void { + this.historyViewport.lineUp(); +} + +public scrollHistoryLineDown(): void { + this.historyViewport.lineDown(); +} + +public returnHistoryToLive(): void { + this.historyViewport.returnToLive(); +} + +public get isHistoryMode(): boolean { + return this.historyViewport.mode === 'history'; +} +``` + +ClaudeCli calls these methods, then `scheduleRedraw()`. The next render reflects the updated viewport state. + +### 5. Key Routing Design + +**New KeyAction types:** + +```typescript +// Add to KeyAction union in input.ts +| { type: 'page_up' } +| { type: 'page_down' } +| { type: 'shift+up' } +| { type: 'shift+down' } +``` + +**translateKey additions:** + +```typescript +// Shift modifier handling (before named keys switch) +if (key?.shift && !ctrl) { + switch (name) { + case 'up': + return { type: 'shift+up' }; + case 'down': + return { type: 'shift+down' }; + } +} + +// Named keys (add to existing switch) +case 'pageup': + return { type: 'page_up' }; +case 'pagedown': + return { type: 'page_down' }; +``` + +Node's readline already parses Page Up (CSI 5~) and Page Down (CSI 6~) as `name: 'pageup'` / `name: 'pagedown'`. The Kitty keyboard protocol also sends these as standard CSI sequences. + +**handleKey routing (updated priority order):** + +```typescript +private handleKey(key: KeyAction): void { + // 1. Ctrl+C: always exit + switch (key.type) { + case 'ctrl+c': { ... } + + // 2. History scroll keys: work in ALL states (idle, busy, prompting) + case 'page_up': + this.term.scrollHistoryPageUp(); + this.scheduleRedraw(); + return; + case 'page_down': + this.term.scrollHistoryPageDown(); + this.scheduleRedraw(); + return; + case 'shift+up': + this.term.scrollHistoryLineUp(); + this.scheduleRedraw(); + return; + case 'shift+down': + this.term.scrollHistoryLineDown(); + this.scheduleRedraw(); + return; + + // 3. Ctrl+/: command mode toggle + case 'ctrl+/': ... + + // 4. Escape: history mode first, then command/prompt/abort + case 'escape': + if (this.term.isHistoryMode) { + this.term.returnHistoryToLive(); + this.scheduleRedraw(); + return; + } + // ... existing command mode / prompt / abort handling + } + + // 5-9: rest of existing routing unchanged +} +``` + +**Rationale for early routing:** History scroll keys should work during ALL phases: while waiting for a response, during permission prompts, during ask-user-question dialogs. The user might want to review previous output while deciding on a permission. These keys never conflict with other handlers (no existing use of Page Up/Down or Shift+Up/Down). + +### 6. Position Indicator Design + +**Format:** `[↑ 42/128]` appended to the status line when in history mode. `42` is the first visible line (1-based), `128` is total lines in the wrapped display buffer. + +**No indicator in live mode.** The user already knows they're at the bottom. + +**Integration with `buildStatusLine()`:** + +```typescript +private buildStatusLine(columns: number, allowIdle: boolean): { line: string; screenLines: number } | null { + const b = new StatusLineBuilder(); + // ... existing phase switch (unchanged) ... + + // Append history position indicator if in history mode + if (this.historyViewport.mode === 'history') { + const start = this.lastHistoryFrame.visibleStart + 1; // 1-based + const total = this.lastHistoryFrame.totalLines; + b.ansi(resetStyle); + b.text(` [\u2191 ${start}/${total}]`); + } + + return { line: b.output, screenLines: b.screenLines(columns) }; +} +``` + +`lastHistoryFrame` is cached from the most recent `renderZone()` call. This avoids recomputing the history viewport just for the status line. + +**`lastHistoryFrame` on Terminal:** + +```typescript +private lastHistoryFrame: HistoryFrame = { rows: [], totalLines: 0, visibleStart: 0 }; +``` + +Updated in `renderZone()` after resolving the history viewport. + +### 7. Renderer Interface Changes + +**New signature:** + +```typescript +public render(historyRows: string[], zoneFrame: ViewportResult): void +``` + +**Implementation:** + +```typescript +public render(historyRows: string[], zoneFrame: ViewportResult): void { + // Combine both regions into one buffer + const combined = [...historyRows, ...zoneFrame.rows]; + + // Trim trailing empty rows (same purpose as current: avoid writing + // unnecessary rows at the bottom of the screen) + let trimEnd = combined.length; + while (trimEnd > 1 && combined[trimEnd - 1] === '') { + trimEnd--; + } + const rows = trimEnd === combined.length ? combined : combined.slice(0, trimEnd); + + let out = syncStart + hideCursor; + out += cursorAt(1, 1); + + for (let i = 0; i < rows.length - 1; i++) { + out += '\r' + clearLine + rows[i] + '\n'; + } + + out += clearDown; + + const lastRow = rows[rows.length - 1]; + if (lastRow !== undefined) { + out += '\r' + clearLine + lastRow; + } + + // Cursor position: offset by history rows, then zone-relative position + const cursorAbsRow = historyRows.length + zoneFrame.visibleCursorRow + 1; + out += cursorAt(cursorAbsRow, zoneFrame.visibleCursorCol + 1); + out += showCursor + syncEnd; + + this.screen.write(out); +} +``` + +**Key points:** +- History rows written first (top of screen), zone rows below +- Cursor absolute position = history row count + zone-relative cursor row (1-based) +- Trim applies to the combined buffer. Since the zone always has content (at least 1 editor line), trailing empties from zone padding are trimmed but history padding in the middle is preserved (this is correct: it creates the visual gap between sparse history and the zone) +- clearDown after the last row clears leftover from previous taller frames +- Still fully stateless: no state tracked between renders + +### 8. flushHistory Integration + +**Problem:** `flushHistory()` clears `historyBuffer` after writing to the main buffer. But the history region needs to keep displaying those lines after the flush. + +**Solution:** New `displayBuffer: string[]` alongside existing `historyBuffer`. Both fed by `writeHistory()`. `displayBuffer` persists across flushes for in-app rendering. `historyBuffer` is cleared by `flushHistory()` (unchanged per constraint). + +```typescript +// Terminal class +private historyBuffer: string[] = []; // for flush to main (current, unchanged) +private displayBuffer: string[] = []; // for in-app history rendering (new) +private readonly historyViewport = new HistoryViewport(); +private lastHistoryFrame: HistoryFrame = { rows: [], totalLines: 0, visibleStart: 0 }; + +private writeHistory(line: string): void { + if (this._paused) { + this.pauseBuffer.push(line); + return; + } + if (this.inAltBuffer) { + this.historyBuffer.push(line); // for main buffer flush (existing) + this.displayBuffer.push(line); // for in-app display (new) + this.renderZone(); + } else { + this.screen.write(line + '\n'); + } +} + +// flushHistory() is UNCHANGED. It only touches historyBuffer. +// displayBuffer retains all lines for the history viewport. +``` + +**Consumers:** +- `flushHistory()`: reads and clears `historyBuffer` (writes to main buffer). Unchanged. +- `renderZone()`: wraps `displayBuffer` and feeds to `historyViewport.resolve()`. Persists across flushes. + +**Lifecycle:** `displayBuffer` accumulates for the entire session. No cap (ring buffer is explicitly out of scope per the issue). + +## Concerns + +### 1. Zone position change + +Current behavior: zone content renders at the top of the screen (cursorAt(1,1), write content, clearDown below). With the two-region model and empty history, the zone still renders at the top (historyFrame.rows is empty, combined buffer starts with zone content). When the first history line arrives, the zone slides down. This is a one-time shift that happens early in any interactive session (first log/info/error call while in alt buffer). + +The shift is small for a typical zone (3-4 rows) and happens immediately on first output. Not a concern for UX. + +### 2. History wrapping cost + +`displayBuffer.flatMap(line => wrapLine(line, columns))` runs on every render. For large history buffers (hundreds of lines), this could be expensive. Acceptable for now per the issue's explicit exclusion of ring buffer/max-lines cap. Future optimization: cache wrapped lines, invalidate on column width change. + +### 3. Escape key priority change + +Escape now checks history mode BEFORE command mode exit and query abort. If the user is in history mode AND command mode simultaneously, Escape returns to live mode first, requiring a second Escape to exit command mode. This is correct: each Escape undoes one layer of modal state. + +### 4. No minimum history region + +When the zone fills the screen (many editor lines), historyRows = 0 and history is invisible. Scroll keys are no-ops (lastViewportRows = 0 guard). This matches terminal behavior (alt buffer apps cannot scroll beyond the screen). History remains in displayBuffer and becomes visible when the zone shrinks. + +### 5. wrapLine export from Layout.ts + +The prompt says "Do not touch Layout internals unless required by the two-region rendering model." Exporting the existing `wrapLine` function (adding `export` keyword) is the minimal change required. No logic changes to Layout. + +### 04:25: scrollback/phase1 (Phase 1) + +- Did: Implemented Phase 1 of #150 (history visible in alt buffer). Created HistoryViewport with live/history mode, resolve(), scroll methods, top-padding. Exported wrapLine from Layout.ts. Terminal gains displayBuffer, historyViewport, lastHistoryFrame; writeHistory() pushes to both buffers; renderZone() implements two-region data flow. Renderer.render() updated to accept historyRows + zoneFrame with history-offset cursor positioning. Updated all existing tests (pass [] as historyRows). Added HistoryViewport unit tests and two-region integration tests. 208 tests pass. +- Files: src/HistoryViewport.ts (new), src/Layout.ts, src/terminal.ts, src/TerminalRenderer.ts, test/HistoryViewport.spec.ts (new), test/TerminalRenderer.spec.ts, test/terminal-integration.spec.ts +- Decisions: Followed Phase 0 design exactly. biome reported only unsafe fixes (block statements for guard returns) - fixed manually. +- Next: Phase 2 (history mode and scrolling): key actions (page_up, page_down, shift+up, shift+down), handleKey routing, position indicator in status bar. + +### 04:34: scrollback/phase2 (Phase 2) + +- Did: Implemented Phase 2 of #150 (history mode and scrolling). Added page_up/page_down/shift+up/shift+down to KeyAction union and translateKey (shift modifier check before named keys switch, pageup/pagedown cases). Wired history scroll keys early in handleKey (after ctrl+c, before ctrl+/) so they work in all phases. Escape now checks isHistoryMode before command mode exit and query abort. Added [up N/M] position indicator to buildStatusLine when historyViewport is in history mode. New test/input.spec.ts (7 tests for new key actions). Position indicator data tests added to HistoryViewport.spec.ts. 218 tests pass, type-check and build clean. +- Files: src/input.ts, src/ClaudeCli.ts, src/terminal.ts, test/input.spec.ts (new), test/HistoryViewport.spec.ts +- Decisions: Followed Phase 0 design exactly. Escape priority: history mode takes precedence over command mode exit and query abort (each Escape undoes one modal layer). translateKey shift check placed before named keys switch to avoid falling through to unmodified up/down. + +### 04:40: scrollback/phase3 (Phase 3) + +- Did: Pushed branch, created PR shellicar/claude-cli#152 with Closes #150, enabled auto-merge. +- Files: (none - ship only) +- 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. diff --git a/src/ClaudeCli.ts b/src/ClaudeCli.ts index 2882dbc..6aca687 100644 --- a/src/ClaudeCli.ts +++ b/src/ClaudeCli.ts @@ -514,7 +514,28 @@ export class ClaudeCli { this.commandMode.toggle(); this.scheduleRedraw(); return; + case 'page_up': + this.term.scrollHistoryPageUp(); + this.scheduleRedraw(); + return; + case 'page_down': + this.term.scrollHistoryPageDown(); + this.scheduleRedraw(); + return; + case 'shift+up': + this.term.scrollHistoryLineUp(); + this.scheduleRedraw(); + return; + case 'shift+down': + this.term.scrollHistoryLineDown(); + this.scheduleRedraw(); + return; case 'escape': + if (this.term.isHistoryMode) { + this.term.returnHistoryToLive(); + this.scheduleRedraw(); + return; + } if (this.commandMode.active) { this.commandMode.exit(); this.scheduleRedraw(); diff --git a/src/HistoryViewport.ts b/src/HistoryViewport.ts new file mode 100644 index 0000000..3301685 --- /dev/null +++ b/src/HistoryViewport.ts @@ -0,0 +1,119 @@ +export interface HistoryFrame { + rows: string[]; + totalLines: number; + visibleStart: number; +} + +export class HistoryViewport { + private scrollOffset = 0; + private _mode: 'live' | 'history' = 'live'; + private lastViewportRows = 0; + private lastBufferLength = 0; + + public get mode(): 'live' | 'history' { + return this._mode; + } + + /** + * Resolve the history buffer into a frame for rendering. + * In live mode, auto-follows the bottom. + * In history mode, scrollOffset is pinned. + * Content is bottom-aligned (top-padded) when buffer < viewport rows. + */ + public resolve(buffer: string[], rows: number): HistoryFrame { + this.lastViewportRows = rows; + this.lastBufferLength = buffer.length; + + if (rows <= 0) { + return { rows: [], totalLines: buffer.length, visibleStart: 0 }; + } + + if (buffer.length === 0) { + return { rows: Array(rows).fill(''), totalLines: 0, visibleStart: 0 }; + } + + if (this._mode === 'live') { + this.scrollOffset = Math.max(0, buffer.length - rows); + } else { + // Cap to valid range (buffer may have grown since last scroll) + this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, buffer.length - rows)); + } + + const slice = buffer.slice(this.scrollOffset, this.scrollOffset + rows); + + // Top-pad: empty rows first, content at bottom of region. + // Keeps the most recent history adjacent to the zone. + const padding = rows - slice.length; + const result = padding > 0 ? [...Array(padding).fill(''), ...slice] : slice; + + return { + rows: result, + totalLines: buffer.length, + visibleStart: this.scrollOffset, + }; + } + + /** + * Scroll up by one page (historyRows). Enters history mode. + * First call snaps scrollOffset to current live position before scrolling. + */ + public pageUp(): void { + if (this.lastViewportRows <= 0 || this.lastBufferLength === 0) { + return; + } + if (this._mode === 'live') { + this.scrollOffset = Math.max(0, this.lastBufferLength - this.lastViewportRows); + } + this._mode = 'history'; + this.scrollOffset = Math.max(0, this.scrollOffset - this.lastViewportRows); + } + + /** + * Scroll down by one page. Returns to live if scrolled to bottom. + */ + public pageDown(): void { + if (this._mode !== 'history') { + return; + } + const maxOffset = Math.max(0, this.lastBufferLength - this.lastViewportRows); + this.scrollOffset = Math.min(this.scrollOffset + this.lastViewportRows, maxOffset); + if (this.scrollOffset >= maxOffset) { + this._mode = 'live'; + } + } + + /** + * Scroll up by one line. Enters history mode. + */ + public lineUp(): void { + if (this.lastViewportRows <= 0 || this.lastBufferLength === 0) { + return; + } + if (this._mode === 'live') { + this.scrollOffset = Math.max(0, this.lastBufferLength - this.lastViewportRows); + } + this._mode = 'history'; + this.scrollOffset = Math.max(0, this.scrollOffset - 1); + } + + /** + * Scroll down by one line. Returns to live if scrolled to bottom. + */ + public lineDown(): void { + if (this._mode !== 'history') { + return; + } + const maxOffset = Math.max(0, this.lastBufferLength - this.lastViewportRows); + this.scrollOffset = Math.min(this.scrollOffset + 1, maxOffset); + if (this.scrollOffset >= maxOffset) { + this._mode = 'live'; + } + } + + /** + * Return to live mode. Next resolve() will auto-follow bottom. + */ + public returnToLive(): void { + this._mode = 'live'; + } +} diff --git a/src/Layout.ts b/src/Layout.ts index f286bdf..7c8ae08 100644 --- a/src/Layout.ts +++ b/src/Layout.ts @@ -39,7 +39,7 @@ export interface LayoutResult { * Splits a logical line into visual rows by wrapping at `columns` visual width. * Returns at least one entry (empty string for empty input). */ -function wrapLine(line: string, columns: number): string[] { +export function wrapLine(line: string, columns: number): string[] { const sanitised = sanitiseZwj(line); if (stringWidth(sanitised) <= columns) { return [sanitised]; diff --git a/src/TerminalRenderer.ts b/src/TerminalRenderer.ts index c21453a..9b12862 100644 --- a/src/TerminalRenderer.ts +++ b/src/TerminalRenderer.ts @@ -13,42 +13,37 @@ const syncEnd = '\x1B[?2026l'; export class Renderer { public constructor(private readonly screen: Screen) {} - public render(frame: ViewportResult): void { - // Trim trailing empty rows from the Viewport-padded frame. Padding is correct - // for Viewport's contract but would cause the Renderer to write screenRows rows - // unnecessarily. - let trimEnd = frame.rows.length; - while (trimEnd > 1 && frame.rows[trimEnd - 1] === '') { + public render(historyRows: string[], zoneFrame: ViewportResult): void { + // Combine history (top) and zone (bottom) into one buffer + const combined = [...historyRows, ...zoneFrame.rows]; + + // Trim trailing empty rows to avoid writing unnecessary blank rows + let trimEnd = combined.length; + while (trimEnd > 1 && combined[trimEnd - 1] === '') { trimEnd--; } - const renderFrame = - trimEnd === frame.rows.length - ? frame - : { - rows: frame.rows.slice(0, trimEnd), - visibleCursorRow: Math.min(frame.visibleCursorRow, trimEnd - 1), - visibleCursorCol: frame.visibleCursorCol, - }; + const rows = trimEnd === combined.length ? combined : combined.slice(0, trimEnd); let out = syncStart + hideCursor; out += cursorAt(1, 1); // Always top-left in alt buffer // Write all rows except last, each followed by \n - for (let i = 0; i < renderFrame.rows.length - 1; i++) { - out += '\r' + clearLine + renderFrame.rows[i] + '\n'; + for (let i = 0; i < rows.length - 1; i++) { + out += '\r' + clearLine + rows[i] + '\n'; } // clearDown clears leftover rows from a taller previous frame out += clearDown; // Write last row without \n (no scroll) - const lastRow = renderFrame.rows[renderFrame.rows.length - 1]; + const lastRow = rows[rows.length - 1]; if (lastRow !== undefined) { out += '\r' + clearLine + lastRow; } - // Position cursor at absolute coordinates (1-based) - out += cursorAt(renderFrame.visibleCursorRow + 1, renderFrame.visibleCursorCol + 1); + // Cursor absolute position: history rows + zone-relative cursor row (1-based) + const cursorAbsRow = historyRows.length + zoneFrame.visibleCursorRow + 1; + out += cursorAt(cursorAbsRow, zoneFrame.visibleCursorCol + 1); out += showCursor + syncEnd; this.screen.write(out); diff --git a/src/input.ts b/src/input.ts index 0b18903..0469934 100644 --- a/src/input.ts +++ b/src/input.ts @@ -36,6 +36,10 @@ export type KeyAction = | { type: 'ctrl+d' } | { type: 'ctrl+/' } | { type: 'escape' } + | { type: 'page_up' } + | { type: 'page_down' } + | { type: 'shift+up' } + | { type: 'shift+down' } | { type: 'unknown'; raw: string }; export interface NodeKey { @@ -143,6 +147,16 @@ export function translateKey(ch: string | undefined, key: NodeKey | undefined): } } + // Shift modifier handling (before named keys switch) + if (key?.shift && !ctrl) { + switch (name) { + case 'up': + return { type: 'shift+up' }; + case 'down': + return { type: 'shift+down' }; + } + } + // Named keys (without modifiers) switch (name) { case 'return': @@ -165,6 +179,10 @@ export function translateKey(ch: string | undefined, key: NodeKey | undefined): return { type: 'end' }; case 'escape': return { type: 'escape' }; + case 'pageup': + return { type: 'page_up' }; + case 'pagedown': + return { type: 'page_down' }; } // Ctrl+/ — most terminals send \x1f (ASCII Unit Separator) diff --git a/src/terminal.ts b/src/terminal.ts index 984b575..a5e0151 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -5,8 +5,9 @@ import type { AppState } from './AppState.js'; import type { AttachmentStore } from './AttachmentStore.js'; import type { CommandMode } from './CommandMode.js'; import type { EditorState } from './editor.js'; +import { type HistoryFrame, HistoryViewport } from './HistoryViewport.js'; import type { BuiltComponent, LayoutInput } from './Layout.js'; -import { layout } from './Layout.js'; +import { layout, wrapLine } from './Layout.js'; import { type EditorRender, prepareEditor } from './renderer.js'; import type { Screen } from './Screen.js'; import { StdoutScreen } from './Screen.js'; @@ -34,6 +35,9 @@ export class Terminal { private readonly renderer: Renderer; private inAltBuffer = false; private historyBuffer: string[] = []; + private displayBuffer: string[] = []; + private readonly historyViewport = new HistoryViewport(); + private lastHistoryFrame: HistoryFrame = { rows: [], totalLines: 0, visibleStart: 0 }; public sessionId: string | undefined; public modelOverride: string | undefined; @@ -62,6 +66,30 @@ export class Terminal { this.inAltBuffer = false; } + public get isHistoryMode(): boolean { + return this.historyViewport.mode === 'history'; + } + + public scrollHistoryPageUp(): void { + this.historyViewport.pageUp(); + } + + public scrollHistoryPageDown(): void { + this.historyViewport.pageDown(); + } + + public scrollHistoryLineUp(): void { + this.historyViewport.lineUp(); + } + + public scrollHistoryLineDown(): void { + this.historyViewport.lineDown(); + } + + public returnHistoryToLive(): void { + this.historyViewport.returnToLive(); + } + public get paused(): boolean { return this._paused; } @@ -161,6 +189,14 @@ export class Terminal { break; } } + + if (this.historyViewport.mode === 'history') { + const start = this.lastHistoryFrame.visibleStart + 1; + const total = this.lastHistoryFrame.totalLines; + b.ansi(resetStyle); + b.text(` [\u2191 ${start}/${total}]`); + } + return { line: b.output, screenLines: b.screenLines(columns) }; } @@ -303,11 +339,32 @@ export class Terminal { private renderZone(): void { const columns = this.screen.columns; - const rows = this.screen.rows; + const screenRows = this.screen.rows; + + // 1. Build zone const input = this.buildLayoutInput(columns); const result = layout(input); - const frame = this.viewport.resolve(result.buffer, rows, result.cursorRow, result.cursorCol); - this.renderer.render(frame); + + // 2. Compute region sizes + const zoneHeight = Math.min(result.buffer.length, screenRows); + 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 }; + } + this.lastHistoryFrame = historyFrame; + + // 4. Zone viewport (available rows = screen minus history) + const zoneRows = screenRows - historyFrame.rows.length; + const frame = this.viewport.resolve(result.buffer, zoneRows, result.cursorRow, result.cursorCol); + + // 5. Renderer receives both + this.renderer.render(historyFrame.rows, frame); if (this.cursorHidden) { this.screen.write(hideCursorSeq); } @@ -333,6 +390,7 @@ export class Terminal { 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/HistoryViewport.spec.ts b/test/HistoryViewport.spec.ts new file mode 100644 index 0000000..fb08665 --- /dev/null +++ b/test/HistoryViewport.spec.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { HistoryViewport } from '../src/HistoryViewport.js'; + +function makeBuffer(n: number): string[] { + return Array.from({ length: n }, (_, i) => `line ${i}`); +} + +describe('HistoryViewport', () => { + it('starts in live mode', () => { + const vp = new HistoryViewport(); + expect(vp.mode).toBe('live'); + }); + + it('resolve with rows <= 0 returns empty rows', () => { + const vp = new HistoryViewport(); + const frame = vp.resolve(makeBuffer(10), 0); + expect(frame.rows).toEqual([]); + expect(frame.totalLines).toBe(10); + expect(frame.visibleStart).toBe(0); + }); + + it('resolve with empty buffer returns padded empty rows', () => { + const vp = new HistoryViewport(); + const frame = vp.resolve([], 5); + expect(frame.rows).toHaveLength(5); + expect(frame.rows.every((r) => r === '')).toBe(true); + expect(frame.totalLines).toBe(0); + }); + + it('live mode: auto-follows bottom when buffer > rows', () => { + const vp = new HistoryViewport(); + const buf = makeBuffer(20); + const frame = vp.resolve(buf, 5); + // Should show lines 15-19 + expect(frame.rows[0]).toBe('line 15'); + expect(frame.rows[4]).toBe('line 19'); + expect(frame.visibleStart).toBe(15); + }); + + it('live mode: top-pads when buffer < rows', () => { + const vp = new HistoryViewport(); + const buf = makeBuffer(3); + const frame = vp.resolve(buf, 10); + // 7 empty rows then 3 content rows + expect(frame.rows).toHaveLength(10); + expect(frame.rows[0]).toBe(''); + expect(frame.rows[6]).toBe(''); + expect(frame.rows[7]).toBe('line 0'); + expect(frame.rows[9]).toBe('line 2'); + }); + + it('live mode: new lines added after resolve cause auto-follow on next resolve', () => { + const vp = new HistoryViewport(); + const buf = makeBuffer(5); + vp.resolve(buf, 5); + // Add more lines + buf.push('line 5'); + buf.push('line 6'); + const frame = vp.resolve(buf, 5); + // Should show lines 2-6 + expect(frame.rows[4]).toBe('line 6'); + }); + + it('pageUp enters history mode', () => { + const vp = new HistoryViewport(); + vp.resolve(makeBuffer(20), 5); + vp.pageUp(); + expect(vp.mode).toBe('history'); + }); + + it('pageUp is a no-op when no buffer', () => { + const vp = new HistoryViewport(); + vp.resolve([], 5); + vp.pageUp(); + expect(vp.mode).toBe('live'); + }); + + it('returnToLive switches back to live mode', () => { + const vp = new HistoryViewport(); + vp.resolve(makeBuffer(20), 5); + vp.pageUp(); + vp.returnToLive(); + expect(vp.mode).toBe('live'); + }); + + it('history mode: viewport pinned, new content does not scroll', () => { + const vp = new HistoryViewport(); + const buf = makeBuffer(20); + // Resolve to establish state, then page up + vp.resolve(buf, 5); + vp.pageUp(); + const frame1 = vp.resolve(buf, 5); + const start1 = frame1.visibleStart; + // Add more lines + buf.push('line 20'); + buf.push('line 21'); + const frame2 = vp.resolve(buf, 5); + // Viewport should remain pinned (visibleStart unchanged) + expect(frame2.visibleStart).toBe(start1); + }); + + it('pageDown from bottom returns to live mode', () => { + const vp = new HistoryViewport(); + const buf = makeBuffer(10); + vp.resolve(buf, 5); + vp.pageUp(); // scroll to start + // Page down to bottom + vp.pageDown(); + vp.pageDown(); + vp.pageDown(); + expect(vp.mode).toBe('live'); + }); + + it('lineUp enters history mode', () => { + const vp = new HistoryViewport(); + vp.resolve(makeBuffer(20), 5); + vp.lineUp(); + expect(vp.mode).toBe('history'); + }); + + it('lineDown at bottom returns to live mode', () => { + const vp = new HistoryViewport(); + const buf = makeBuffer(10); + vp.resolve(buf, 5); + vp.lineUp(); + // Scroll back to bottom + for (let i = 0; i < 20; i++) { + vp.lineDown(); + } + expect(vp.mode).toBe('live'); + }); +}); + +describe('Position indicator data', () => { + it('live mode: totalLines and visibleStart reflect current position', () => { + const vp = new HistoryViewport(); + const buf = makeBuffer(20); + const frame = vp.resolve(buf, 5); + expect(frame.totalLines).toBe(20); + // In live mode, auto-follows bottom: visibleStart = 20 - 5 = 15 + expect(frame.visibleStart).toBe(15); + }); + + it('history mode: frame provides correct visibleStart for indicator', () => { + const vp = new HistoryViewport(); + const buf = makeBuffer(20); + vp.resolve(buf, 5); + vp.pageUp(); + const frame = vp.resolve(buf, 5); + // After one page-up from bottom (offset=15), subtract 5 => offset=10 + expect(frame.visibleStart).toBe(10); + expect(frame.totalLines).toBe(20); + expect(vp.mode).toBe('history'); + }); + + it('history mode indicator: visibleStart is 1-based display position', () => { + const vp = new HistoryViewport(); + const buf = makeBuffer(20); + vp.resolve(buf, 5); + vp.pageUp(); + const frame = vp.resolve(buf, 5); + // Status line shows (visibleStart + 1) / totalLines + const displayStart = frame.visibleStart + 1; + expect(displayStart).toBe(11); + expect(frame.totalLines).toBe(20); + }); +}); diff --git a/test/TerminalRenderer.spec.ts b/test/TerminalRenderer.spec.ts index ba7809c..592196b 100644 --- a/test/TerminalRenderer.spec.ts +++ b/test/TerminalRenderer.spec.ts @@ -29,7 +29,7 @@ describe('Renderer', () => { it('render() always starts with cursorAt(1,1)', () => { const { screen, output } = makeScreen(80); const renderer = new Renderer(screen); - renderer.render(makeFrame(['line0', 'line1'], 0, 0)); + renderer.render([], makeFrame(['line0', 'line1'], 0, 0)); const all = output.join(''); expect(all).toContain('\x1B[1;1H'); }); @@ -37,9 +37,9 @@ describe('Renderer', () => { it('second render also starts with cursorAt(1,1) (stateless)', () => { const { screen, output } = makeScreen(80); const renderer = new Renderer(screen); - renderer.render(makeFrame(['a', 'b'], 0, 0)); + renderer.render([], makeFrame(['a', 'b'], 0, 0)); output.length = 0; - renderer.render(makeFrame(['c', 'd'], 0, 0)); + renderer.render([], makeFrame(['c', 'd'], 0, 0)); const all = output.join(''); expect(all).toContain('\x1B[1;1H'); expect(all).not.toContain('\x1B[1A'); // no cursorUp in alt buffer @@ -49,7 +49,7 @@ describe('Renderer', () => { const screen = new MockScreen(80, 10); screen.enterAltBuffer(); const renderer = new Renderer(screen); - renderer.render(makeFrame(['line 0', 'line 1', 'line 2', 'line 3', 'line 4'], 2, 0)); + renderer.render([], makeFrame(['line 0', 'line 1', 'line 2', 'line 3', 'line 4'], 2, 0)); screen.assertNoScrollbackViolations(); }); @@ -58,7 +58,7 @@ describe('Renderer', () => { screen.enterAltBuffer(); const renderer = new Renderer(screen); const rows = Array.from({ length: 10 }, (_, i) => `row ${i}`); - renderer.render(makeFrame(rows, 9, 0)); + renderer.render([], makeFrame(rows, 9, 0)); screen.assertNoScrollbackViolations(); for (let i = 0; i < 10; i++) { expect(screen.getRow(i)).toBe(`row ${i}`); @@ -69,8 +69,8 @@ describe('Renderer', () => { const screen = new MockScreen(80, 10); screen.enterAltBuffer(); const renderer = new Renderer(screen); - renderer.render(makeFrame(['a0', 'a1', 'a2', 'a3', 'a4'], 2, 0)); - renderer.render(makeFrame(['b0', 'b1', 'b2', 'b3', 'b4'], 2, 0)); + renderer.render([], makeFrame(['a0', 'a1', 'a2', 'a3', 'a4'], 2, 0)); + renderer.render([], makeFrame(['b0', 'b1', 'b2', 'b3', 'b4'], 2, 0)); screen.assertNoScrollbackViolations(); expect(screen.getRow(0)).toBe('b0'); expect(screen.getRow(4)).toBe('b4'); @@ -81,13 +81,14 @@ describe('Renderer', () => { screen.enterAltBuffer(); const renderer = new Renderer(screen); renderer.render( + [], makeFrame( Array.from({ length: 8 }, (_, i) => `long${i}`), 4, 0, ), ); - renderer.render(makeFrame(['short0', 'short1', 'short2'], 1, 0)); + renderer.render([], makeFrame(['short0', 'short1', 'short2'], 1, 0)); screen.assertNoScrollbackViolations(); expect(screen.getRow(0)).toBe('short0'); expect(screen.getRow(2)).toBe('short2'); @@ -100,7 +101,7 @@ describe('Renderer', () => { const screen = new MockScreen(80, 10); screen.enterAltBuffer(); const renderer = new Renderer(screen); - renderer.render(makeFrame(['a', 'b', 'c', 'd', 'e'], 3, 15)); + renderer.render([], makeFrame(['a', 'b', 'c', 'd', 'e'], 3, 15)); expect(screen.cursorRow).toBe(3); expect(screen.cursorCol).toBe(15); }); @@ -108,7 +109,7 @@ describe('Renderer', () => { it('cursor positioned via cursorAt not cursorUp+cursorTo', () => { const { screen, output } = makeScreen(80); const renderer = new Renderer(screen); - renderer.render(makeFrame(['row0', 'row1', 'row2'], 2, 5)); + renderer.render([], makeFrame(['row0', 'row1', 'row2'], 2, 5)); const all = output.join(''); // Cursor placed via absolute ESC[row;colH, not cursorUp expect(all).toContain('\x1B[3;6H'); // row 3 (1-based), col 6 (1-based) @@ -178,8 +179,8 @@ describe('MockScreen dual-buffer', () => { const screen = new MockScreen(80, 10); screen.enterAltBuffer(); const renderer = new Renderer(screen); - renderer.render(makeFrame(['z0', 'z1', 'z2'], 0, 0)); - renderer.render(makeFrame(['z3', 'z4', 'z5'], 0, 0)); + renderer.render([], makeFrame(['z0', 'z1', 'z2'], 0, 0)); + renderer.render([], makeFrame(['z3', 'z4', 'z5'], 0, 0)); screen.assertNoScrollbackViolations(); }); }); diff --git a/test/input.spec.ts b/test/input.spec.ts new file mode 100644 index 0000000..00754b5 --- /dev/null +++ b/test/input.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { type NodeKey, translateKey } from '../src/input.js'; + +function key(name: string, opts: Partial = {}): NodeKey { + return { sequence: '', name, ctrl: false, meta: false, shift: false, ...opts }; +} + +describe('translateKey', () => { + it('pageup produces page_up', () => { + expect(translateKey(undefined, key('pageup'))).toEqual({ type: 'page_up' }); + }); + + it('pagedown produces page_down', () => { + expect(translateKey(undefined, key('pagedown'))).toEqual({ type: 'page_down' }); + }); + + it('shift+up produces shift+up', () => { + expect(translateKey(undefined, key('up', { shift: true }))).toEqual({ type: 'shift+up' }); + }); + + it('shift+down produces shift+down', () => { + expect(translateKey(undefined, key('down', { shift: true }))).toEqual({ type: 'shift+down' }); + }); + + it('unmodified up still produces up', () => { + expect(translateKey(undefined, key('up'))).toEqual({ type: 'up' }); + }); + + it('unmodified down still produces down', () => { + expect(translateKey(undefined, key('down'))).toEqual({ type: 'down' }); + }); + + it('ctrl+up does not produce shift+up', () => { + const result = translateKey(undefined, key('up', { ctrl: true })); + expect(result?.type).not.toBe('shift+up'); + }); +}); diff --git a/test/terminal-integration.spec.ts b/test/terminal-integration.spec.ts index 0a4875a..80b5bad 100644 --- a/test/terminal-integration.spec.ts +++ b/test/terminal-integration.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { HistoryViewport } from '../src/HistoryViewport.js'; import type { BuiltComponent, LayoutInput } from '../src/Layout.js'; -import { layout } from '../src/Layout.js'; +import { layout, wrapLine } from '../src/Layout.js'; import type { EditorRender } from '../src/renderer.js'; import { Renderer } from '../src/TerminalRenderer.js'; import { Viewport } from '../src/Viewport.js'; @@ -18,7 +19,7 @@ function makeComponent(rows: string[]): BuiltComponent { function runPipeline(screen: MockScreen, viewport: Viewport, renderer: Renderer, input: LayoutInput): void { const result = layout(input); const frame = viewport.resolve(result.buffer, screen.rows, result.cursorRow, result.cursorCol); - renderer.render(frame); + renderer.render([], frame); } describe('Terminal integration', () => { @@ -83,7 +84,7 @@ describe('Terminal integration', () => { const smallRenderer = new Renderer(smallScreen); const { buffer, cursorRow, cursorCol } = layout(input); const frame = viewport.resolve(buffer, 10, cursorRow, cursorCol); - smallRenderer.render(frame); + smallRenderer.render([], frame); smallScreen.assertNoScrollbackViolations(); expect(frame.visibleCursorRow).toBeGreaterThanOrEqual(0); @@ -208,3 +209,118 @@ describe('History flush', () => { screen.assertNoScrollbackViolations(); }); }); + +describe('Two-region rendering', () => { + it('empty displayBuffer: zone gets full screen', () => { + const screen = new MockScreen(80, 10); + screen.enterAltBuffer(); + const viewport = new Viewport(); + const renderer = new Renderer(screen); + + const input = { + editor: makeEditorRender(3, 1, 0), + status: makeComponent(['status']), + attachments: null, + preview: null, + question: null, + columns: 80, + } satisfies LayoutInput; + + const result = layout(input); + const screenRows = screen.rows; + + // When displayBuffer is empty, Terminal uses short-circuit: historyFrame = { rows: [] } + const historyFrame = { rows: [] as string[], totalLines: 0, visibleStart: 0 }; + 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); + }); + + it('history lines appear above zone content', () => { + 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 = ['history line 0', 'history line 1']; + 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(); + // Zone renders below history rows + expect(zoneFrame.rows.length).toBe(zoneRows); + }); + + it('history viewport auto-follows in live mode', () => { + const historyViewport = new HistoryViewport(); + const buf = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; + const frame1 = historyViewport.resolve(buf, 3); + // Should show last 3 lines + expect(frame1.rows[2]).toBe('g'); + + buf.push('h'); + const frame2 = historyViewport.resolve(buf, 3); + expect(frame2.rows[2]).toBe('h'); + }); + + it('zone height changes do not corrupt history region', () => { + const screen = new MockScreen(80, 10); + screen.enterAltBuffer(); + const historyViewport = new HistoryViewport(); + const viewport = new Viewport(); + const renderer = new Renderer(screen); + + const displayBuffer = ['log line 0', 'log line 1', 'log line 2']; + + function render(editorLines: number) { + const input = { + editor: makeEditorRender(editorLines, 0, 0), + status: null, + attachments: null, + preview: null, + question: null, + columns: 80, + } satisfies LayoutInput; + 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 hFrame = historyViewport.resolve(wrappedHistory, historyRows); + const zoneRows = screenRows - hFrame.rows.length; + const zoneFrame = viewport.resolve(result.buffer, zoneRows, result.cursorRow, result.cursorCol); + renderer.render(hFrame.rows, zoneFrame); + } + + render(1); + screen.assertNoScrollbackViolations(); + render(5); + screen.assertNoScrollbackViolations(); + render(1); + screen.assertNoScrollbackViolations(); + }); +});