From af119e6fcd456c1deafefafd4d1c95d5d17a2928 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 30 Mar 2026 07:11:16 +1100 Subject: [PATCH 1/3] Fix rendering duplication, overlap, and input leaks (#161) Embedded newlines in displayBuffer entries broke row structure because wrapLine splits by visual width but not on \n, causing the renderer to advance mid-row and shift all subsequent rows down. Split on \n in writeHistory() so each segment is its own buffer entry. Editor accepted keystrokes during history mode because handleKey() had no guard for this.term.isHistoryMode before the editor block. History navigation keys already returned early; all other keys now return early too when in history mode. AppState changed handler called both this.term.refresh() and this.redraw(), each triggering renderZone(). The first render used stale editorContent from the previous frame. Removing refresh() leaves only redraw(), which calls renderEditor() then renderZone() in the correct order. --- src/ClaudeCli.ts | 6 ++++- src/terminal.ts | 18 +++++++++++--- test/terminal.spec.ts | 56 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 test/terminal.spec.ts diff --git a/src/ClaudeCli.ts b/src/ClaudeCli.ts index 6aca687..21d11ea 100644 --- a/src/ClaudeCli.ts +++ b/src/ClaudeCli.ts @@ -643,6 +643,11 @@ export class ClaudeCli { return; } + // Block editor input during history mode; navigation keys are handled above. + if (this.term.isHistoryMode) { + return; + } + switch (key.type) { case 'ctrl+d': { if (this.prompts.isOtherMode) { @@ -1030,7 +1035,6 @@ export class ClaudeCli { }, 300); }); this.appState.on('changed', () => { - this.term.refresh(); this.redraw(); }); diff --git a/src/terminal.ts b/src/terminal.ts index fb0cebe..8a4946c 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -70,6 +70,11 @@ export class Terminal { return this.historyViewport.mode === 'history'; } + /** Exposed for testing only. Do not use in production code. */ + public get testDisplayBuffer(): readonly string[] { + return this.displayBuffer; + } + public scrollHistoryPageUp(): void { this.historyViewport.pageUp(); } @@ -398,14 +403,21 @@ export class Terminal { } private writeHistory(line: string): void { + const segments = line.split('\n'); if (this._paused) { - this.pauseBuffer.push(line); + for (const segment of segments) { + this.pauseBuffer.push(segment); + } return; } - this.displayBuffer.push(line); + for (const segment of segments) { + this.displayBuffer.push(segment); + } if (this.inAltBuffer) { // Accumulate in memory; re-render zone (status may have changed) - this.historyBuffer.push(line); + for (const segment of segments) { + this.historyBuffer.push(segment); + } this.renderZone(); } else { // Main buffer (startup messages, post-exit): write directly diff --git a/test/terminal.spec.ts b/test/terminal.spec.ts new file mode 100644 index 0000000..0682815 --- /dev/null +++ b/test/terminal.spec.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AppState } from '../src/AppState.js'; +import { AttachmentStore } from '../src/AttachmentStore.js'; +import { CommandMode } from '../src/CommandMode.js'; +import { Terminal } from '../src/terminal.js'; + +function makeTerminal(): Terminal { + return new Terminal(new AppState(), null, new AttachmentStore(), new CommandMode()); +} + +describe('Terminal.writeHistory newline splitting', () => { + beforeEach(() => { + vi.spyOn(process.stdout, 'write').mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('single-line input produces one displayBuffer entry', () => { + const term = makeTerminal(); + term.info('hello'); + expect(term.testDisplayBuffer).toEqual(['hello']); + }); + + it('multi-line input produces separate displayBuffer entries per line', () => { + const term = makeTerminal(); + term.info('line one\nline two'); + expect(term.testDisplayBuffer).toEqual(['line one', 'line two']); + }); + + it('three-line input produces three displayBuffer entries', () => { + const term = makeTerminal(); + term.info('a\nb\nc'); + expect(term.testDisplayBuffer).toEqual(['a', 'b', 'c']); + }); + + it('consecutive newlines preserve empty entries', () => { + const term = makeTerminal(); + term.info('before\n\nafter'); + expect(term.testDisplayBuffer).toEqual(['before', '', 'after']); + }); + + it('trailing newline produces a trailing empty entry', () => { + const term = makeTerminal(); + term.info('line\n'); + expect(term.testDisplayBuffer).toEqual(['line', '']); + }); + + it('multiple info calls accumulate correctly', () => { + const term = makeTerminal(); + term.info('first'); + term.info('second\nthird'); + expect(term.testDisplayBuffer).toEqual(['first', 'second', 'third']); + }); +}); From eff57b9517bce293ee7f84d50f9c84dd2b63b6f5 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 30 Mar 2026 07:12:31 +1100 Subject: [PATCH 2/3] chore: session log and state update for #161 Phase 1 --- .claude/CLAUDE.md | 4 ++-- .claude/sessions/2026-03-30.md | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3871c92..6c38e64 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/version-1.0.0-alpha.72` -In-progress: PR shellicar/claude-cli#160 open, auto-merge enabled. Awaiting CodeQL and merge. Phase 2 (github-release) to follow after merge. +Branch: `fix/161-rendering-duplication` +In-progress: Phase 1 complete (3 rendering fixes committed). Phase 2 (push + PR) pending. diff --git a/.claude/sessions/2026-03-30.md b/.claude/sessions/2026-03-30.md index 4d090d1..3880bcb 100644 --- a/.claude/sessions/2026-03-30.md +++ b/.claude/sessions/2026-03-30.md @@ -1,3 +1,24 @@ +### 07:01 - fix/rendering-duplication Phase 1 (#161) + +- Did: Fixed three rendering bugs: (1) split embedded newlines in `Terminal.writeHistory()` so each segment is its own `displayBuffer` / `historyBuffer` entry; (2) added `this.term.isHistoryMode` guard before the editor block in `handleKey()` to block editor input during history mode; (3) removed redundant `this.term.refresh()` from the `appState.on('changed')` handler, leaving only `this.redraw()`. Added 6 tests in `test/terminal.spec.ts` covering multi-line split and edge cases. Added `testDisplayBuffer` getter to `Terminal` for test access. All 230 tests pass. +- Files: `src/terminal.ts`, `src/ClaudeCli.ts`, `test/terminal.spec.ts` +- Decisions: `testDisplayBuffer` getter added to Terminal to expose private `displayBuffer` for tests without changing production access patterns. Tests mock `process.stdout.write` to suppress noise from the non-alt-buffer screen write path. History mode guard test skipped (not testable without a ClaudeCli harness, per prompt qualifier). +- Next: Phase 2 (push branch + PR with Closes #161) + +### 06:50 - investigation/rendering-duplication (#161) + +- Did: Investigated rendering duplication symptoms. Read all core rendering files (TerminalRenderer, terminal, Layout, Viewport, Screen, HistoryViewport, ClaudeCli, AppState, renderer). Identified 9 bugs across the rendering pipeline. Wrote investigation report to fleet investigations directory. +- Files: (no code changes, investigation only) +- Decisions: Primary bug is embedded newlines in displayBuffer entries breaking the renderer's row structure. Secondary bugs: editor accepting input during history mode, double render on AppState change, ANSI state leak in wrapLine, prepareEditor column mismatch, flushHistory without sync protocol. +- Next: Fix implementation per suggested order in report (Bug 1 embedded newlines first, Bug 2 history input guard, Bug 3 double render) + +### 06:23 - release/publish (1.0.0-alpha.72) + +- Did: Published GitHub release 1.0.0-alpha.72 (pre-release); npm-publish workflow succeeded; @shellicar/claude-cli@1.0.0-alpha.72 live on npm +- Files: (no code changes) +- Decisions: Auto-generated notes accepted; milestone 1.0 left open (pre-release series) +- Next: Nothing remaining for this release + ### 06:12 - release/version-bump (1.0.0-alpha.72) - Did: Bumped version to 1.0.0-alpha.72, updated CHANGELOG.md with 12 commits of rendering pipeline and scrollback changes, created PR shellicar/claude-cli#160 with auto-merge enabled From 1d0109c611cfbe0bbc31170c1b4e4bffa98f6577 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 30 Mar 2026 07:20:03 +1100 Subject: [PATCH 3/3] chore: session log and state update for #161 Phase 2 --- .claude/CLAUDE.md | 4 ++-- .claude/sessions/2026-03-30.md | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6c38e64..53f0adc 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: `fix/161-rendering-duplication` -In-progress: Phase 1 complete (3 rendering fixes committed). Phase 2 (push + PR) pending. +Branch: `main` +In-progress: PR shellicar/claude-cli#162 open, auto-merge enabled. Awaiting CodeQL and merge. diff --git a/.claude/sessions/2026-03-30.md b/.claude/sessions/2026-03-30.md index 3880bcb..52f26df 100644 --- a/.claude/sessions/2026-03-30.md +++ b/.claude/sessions/2026-03-30.md @@ -1,3 +1,10 @@ +### 07:15 - fix/rendering-duplication Phase 2 (#161) + +- Did: Pushed `fix/161-rendering-duplication` branch; created PR shellicar/claude-cli#162 with `Closes #161`, milestone `1.0`, label `bug`, reviewer `bananabot9000`, auto-merge enabled. Build and signature checks passed; CodeQL in progress. +- Files: (no code changes) +- Decisions: None. +- Next: Nothing. PR will auto-merge when CodeQL passes. + ### 07:01 - fix/rendering-duplication Phase 1 (#161) - Did: Fixed three rendering bugs: (1) split embedded newlines in `Terminal.writeHistory()` so each segment is its own `displayBuffer` / `historyBuffer` entry; (2) added `this.term.isHistoryMode` guard before the editor block in `handleKey()` to block editor input during history mode; (3) removed redundant `this.term.refresh()` from the `appState.on('changed')` handler, leaving only `this.redraw()`. Added 6 tests in `test/terminal.spec.ts` covering multi-line split and edge cases. Added `testDisplayBuffer` getter to `Terminal` for test access. All 230 tests pass.