diff --git a/.claude/sessions/2026-04-06.md b/.claude/sessions/2026-04-06.md index b106ed7..787b7ba 100644 --- a/.claude/sessions/2026-04-06.md +++ b/.claude/sessions/2026-04-06.md @@ -166,3 +166,110 @@ Full plan at `.claude/plans/architecture-refactor.md`. Key points: - Existing `ConversationHistory.spec.ts` tests become the spec for `Conversation` - Rewrite tests to follow convention, then migrate implementation - `AgentRun` receives `Conversation`; `AnthropicAgent` creates both + + +--- + +## Session continuation 3 (same day, later still) + +This continuation picks up from compact. The previous session had just planned step 1a as the next move. We worked through steps 1a → 3a over several PRs, with one architecture plan revision along the way. + +--- + +### 1. Step 1a — `Conversation` / `ConversationStore` split (PR #183) + +Already completed before context compacted, but recorded here for continuity. Split `packages/claude-sdk/src/private/ConversationHistory.ts` into: +- `Conversation.ts` — pure data: stores `HistoryItem[]`, merge logic, compaction, no file I/O +- `ConversationStore.ts` — I/O wrapper: reads/writes `history.jsonl`, holds a `Conversation` instance + +`AgentRun` receives a `Conversation`; `AnthropicAgent` creates both `ConversationStore` and extracts the inner `Conversation` to pass down. Tests migrated to `Conversation.spec.ts` following the one-expect-per-it convention. + +--- + +### 2. Five banana pillars restoration (PR #185) + +Philosophy document `.claude/five-banana-pillars.md` was lost at some point after commit `b67b1f6`. Content recovered from git history and restored. No code changes. + +--- + +### 3. Step 1b — Replay conversation history into TUI on startup (PR #186) + +New file: `apps/claude-sdk-cli/src/replayHistory.ts` — `replayHistory(messages, emit)` converts `BetaMessageParam[]` into the `StreamEvent` shapes that `AppLayout` already handles, replaying them into the conversation view so prior turns are visible when the CLI starts. + +`runAgent.ts` calls `replayHistory` after constructing the agent and before entering the input loop. The history is read via `agent.getHistory()`. + +Effect: starting the CLI mid-conversation now shows the previous exchange in the TUI rather than a blank screen. + +--- + +### 4. Step 2 — `RequestBuilder` extraction from `AgentRun` (PR #187) + +New file: `packages/claude-sdk/src/private/RequestBuilder.ts` — pure function `buildRequest(conversation, options)` that assembles the Anthropic API request body from current history and run options. No I/O, no side effects. + +`AgentRun` now delegates request construction to `RequestBuilder`. The split makes request logic independently testable and separates "what to send" from "how to run the loop". + +--- + +### 5. Architecture plan revision — MVVM without data bindings (PR #188) + +The original plan used a component-based approach. After reading the code more carefully, this was revised to a cleaner MVVM model with three explicit layers: + +- **State** — pure data and transitions (`EditorState`, `ConversationState`, etc.). No rendering, no I/O. +- **Renderer** — pure functions `(state, cols) → string[]`. Know column width but not row position. Testable with plain string assertions. +- **ScreenCoordinator** — owns the physical screen. Handles events (keypress, stream data, resize). After each event: updates relevant state, calls all renderers, allocates rows, assembles and writes output. Single trigger for re-renders. + +Key properties this design gives us: +- Reflow lands cleanly: state untouched on resize, renderers re-called with new `cols`, coordinator re-allocates rows +- State machines testable without any ANSI knowledge +- Renderers testable with plain string assertions +- Cross-component layout constraints (tool widget expands → conversation shrinks) live in coordinator — one place +- Pull-based and explicit: no data bindings, no reactive subscriptions + +The distinction between Renderer and Coordinator: Renderer knows *what* is on screen (content, colours, wrapping) and takes `cols`. Coordinator knows *where* it goes (row position, cursor placement) and takes both `cols` and `rows`. + +Plan file updated at `.claude/plans/architecture-refactor.md`. + +--- + +### 6. Step 3a — `EditorState` extracted from `AppLayout` (PR #189, pending merge) + +New file: `apps/claude-sdk-cli/src/EditorState.ts` + +```typescript +export class EditorState { + #lines: string[] = ['']; + #cursorLine = 0; + #cursorCol = 0; + + public get lines(): string[] // live internal array — intentionally mutable until 3b + public get cursorLine(): number + public set cursorLine(n: number) + public get cursorCol(): number + public set cursorCol(n: number) + public get text(): string // lines.join('\n') + public reset(): void // lines=[''], cursor at 0,0 +} +``` + +`AppLayout` changes: +- `#editorLines`, `#cursorLine`, `#cursorCol` fields removed +- `#editorState = new EditorState()` added +- Two reset sites (`completeTurn`, `waitForInput`) replaced with `this.#editorState.reset()` +- All field accesses updated to go through `#editorState` + +Key handling and rendering still live in `AppLayout` — behaviour is identical. The `lines` getter intentionally returns the live internal array because `AppLayout` still mutates it via index assignment and `splice`. This is documented as temporary until step 3b. + +BananaBot approved. PR #189 open, auto-merge set to squash. + +--- + +### State at compact + +- Branch: `feature/editor-state`, one commit ahead of main (`c020b84`) +- PR #189 open, approved, awaiting merge +- **Next: step 3b** — move key handling into `EditorState.handleKey(key: KeyAction)` + - `handleKey` returns `boolean` (true = handled, false = AppLayout handles it) + - `#wordStartLeft` / `#wordEndRight` helpers move into `EditorState` + - `ctrl+enter` stays in `AppLayout` (involves attachments and `editorResolve`, not pure state) + - AppLayout key dispatch becomes: delegate to `handleKey`, schedule render, then handle `ctrl+enter` separately + - Tests: pure state machine → valuable unit tests for backspace, enter, ctrl+left, ctrl+backspace, delete, up/down clamping diff --git a/apps/claude-sdk-cli/src/AppLayout.ts b/apps/claude-sdk-cli/src/AppLayout.ts index 9b1ecd0..9472cac 100644 --- a/apps/claude-sdk-cli/src/AppLayout.ts +++ b/apps/claude-sdk-cli/src/AppLayout.ts @@ -11,6 +11,7 @@ import type { SdkMessageUsage } from '@shellicar/claude-sdk'; import { highlight } from 'cli-highlight'; import { AttachmentStore } from './AttachmentStore.js'; import { readClipboardPath, readClipboardText } from './clipboard.js'; +import { EditorState } from './EditorState.js'; import { logger } from './logger.js'; export type PendingTool = { @@ -130,9 +131,7 @@ export class AppLayout implements Disposable { #sealedBlocks: Block[] = []; #flushedCount = 0; #activeBlock: Block | null = null; - #editorLines: string[] = ['']; - #cursorLine = 0; - #cursorCol = 0; + #editorState = new EditorState(); #renderPending = false; #pendingTools: PendingTool[] = []; @@ -234,9 +233,7 @@ export class AppLayout implements Disposable { this.#commandMode = false; this.#previewMode = false; this.#attachments.clear(); - this.#editorLines = ['']; - this.#cursorLine = 0; - this.#cursorCol = 0; + this.#editorState.reset(); this.#flushToScroll(); this.render(); } @@ -305,9 +302,7 @@ export class AppLayout implements Disposable { /** Enter editor mode and wait for the user to submit input via Ctrl+Enter. */ public waitForInput(): Promise { this.#mode = 'editor'; - this.#editorLines = ['']; - this.#cursorLine = 0; - this.#cursorCol = 0; + this.#editorState.reset(); this.#toolExpanded = false; this.render(); return new Promise((resolve) => { @@ -432,18 +427,18 @@ export class AppLayout implements Disposable { switch (key.type) { case 'enter': { // Split current line at cursor - const cur = this.#editorLines[this.#cursorLine] ?? ''; - const before = cur.slice(0, this.#cursorCol); - const after = cur.slice(this.#cursorCol); - this.#editorLines[this.#cursorLine] = before; - this.#editorLines.splice(this.#cursorLine + 1, 0, after); - this.#cursorLine++; - this.#cursorCol = 0; + const cur = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + const before = cur.slice(0, this.#editorState.cursorCol); + const after = cur.slice(this.#editorState.cursorCol); + this.#editorState.lines[this.#editorState.cursorLine] = before; + this.#editorState.lines.splice(this.#editorState.cursorLine + 1, 0, after); + this.#editorState.cursorLine++; + this.#editorState.cursorCol = 0; this.#scheduleRender(); break; } case 'ctrl+enter': { - const text = this.#editorLines.join('\n').trim(); + const text = this.#editorState.lines.join('\n').trim(); if (!text && !this.#attachments.hasAttachments) { break; } @@ -485,169 +480,169 @@ export class AppLayout implements Disposable { break; } case 'backspace': { - if (this.#cursorCol > 0) { - const line = this.#editorLines[this.#cursorLine] ?? ''; - this.#editorLines[this.#cursorLine] = line.slice(0, this.#cursorCol - 1) + line.slice(this.#cursorCol); - this.#cursorCol--; - } else if (this.#cursorLine > 0) { + if (this.#editorState.cursorCol > 0) { + const line = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + this.#editorState.lines[this.#editorState.cursorLine] = line.slice(0, this.#editorState.cursorCol - 1) + line.slice(this.#editorState.cursorCol); + this.#editorState.cursorCol--; + } else if (this.#editorState.cursorLine > 0) { // Join with previous line - const prev = this.#editorLines[this.#cursorLine - 1] ?? ''; - const curr = this.#editorLines[this.#cursorLine] ?? ''; - this.#editorLines.splice(this.#cursorLine, 1); - this.#cursorLine--; - this.#cursorCol = prev.length; - this.#editorLines[this.#cursorLine] = prev + curr; + const prev = this.#editorState.lines[this.#editorState.cursorLine - 1] ?? ''; + const curr = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + this.#editorState.lines.splice(this.#editorState.cursorLine, 1); + this.#editorState.cursorLine--; + this.#editorState.cursorCol = prev.length; + this.#editorState.lines[this.#editorState.cursorLine] = prev + curr; } this.#scheduleRender(); break; } case 'delete': { - const line = this.#editorLines[this.#cursorLine] ?? ''; - if (this.#cursorCol < line.length) { - this.#editorLines[this.#cursorLine] = line.slice(0, this.#cursorCol) + line.slice(this.#cursorCol + 1); - } else if (this.#cursorLine < this.#editorLines.length - 1) { + const line = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + if (this.#editorState.cursorCol < line.length) { + this.#editorState.lines[this.#editorState.cursorLine] = line.slice(0, this.#editorState.cursorCol) + line.slice(this.#editorState.cursorCol + 1); + } else if (this.#editorState.cursorLine < this.#editorState.lines.length - 1) { // Join with next line - const next = this.#editorLines[this.#cursorLine + 1] ?? ''; - this.#editorLines.splice(this.#cursorLine + 1, 1); - this.#editorLines[this.#cursorLine] = line + next; + const next = this.#editorState.lines[this.#editorState.cursorLine + 1] ?? ''; + this.#editorState.lines.splice(this.#editorState.cursorLine + 1, 1); + this.#editorState.lines[this.#editorState.cursorLine] = line + next; } this.#scheduleRender(); break; } case 'ctrl+backspace': { - if (this.#cursorCol === 0) { + if (this.#editorState.cursorCol === 0) { // At start of line: cross the newline boundary, same as plain backspace - if (this.#cursorLine > 0) { - const prev = this.#editorLines[this.#cursorLine - 1] ?? ''; - const curr = this.#editorLines[this.#cursorLine] ?? ''; - this.#editorLines.splice(this.#cursorLine, 1); - this.#cursorLine--; - this.#cursorCol = prev.length; - this.#editorLines[this.#cursorLine] = prev + curr; + if (this.#editorState.cursorLine > 0) { + const prev = this.#editorState.lines[this.#editorState.cursorLine - 1] ?? ''; + const curr = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + this.#editorState.lines.splice(this.#editorState.cursorLine, 1); + this.#editorState.cursorLine--; + this.#editorState.cursorCol = prev.length; + this.#editorState.lines[this.#editorState.cursorLine] = prev + curr; } } else { - const line = this.#editorLines[this.#cursorLine] ?? ''; - const newCol = this.#wordStartLeft(line, this.#cursorCol); - this.#editorLines[this.#cursorLine] = line.slice(0, newCol) + line.slice(this.#cursorCol); - this.#cursorCol = newCol; + const line = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + const newCol = this.#wordStartLeft(line, this.#editorState.cursorCol); + this.#editorState.lines[this.#editorState.cursorLine] = line.slice(0, newCol) + line.slice(this.#editorState.cursorCol); + this.#editorState.cursorCol = newCol; } this.#scheduleRender(); break; } case 'ctrl+delete': { - const line = this.#editorLines[this.#cursorLine] ?? ''; - if (this.#cursorCol === line.length) { + const line = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + if (this.#editorState.cursorCol === line.length) { // At EOL: cross the newline boundary, same as plain delete - if (this.#cursorLine < this.#editorLines.length - 1) { - const next = this.#editorLines[this.#cursorLine + 1] ?? ''; - this.#editorLines.splice(this.#cursorLine + 1, 1); - this.#editorLines[this.#cursorLine] = line + next; + if (this.#editorState.cursorLine < this.#editorState.lines.length - 1) { + const next = this.#editorState.lines[this.#editorState.cursorLine + 1] ?? ''; + this.#editorState.lines.splice(this.#editorState.cursorLine + 1, 1); + this.#editorState.lines[this.#editorState.cursorLine] = line + next; } } else { - const newCol = this.#wordEndRight(line, this.#cursorCol); - this.#editorLines[this.#cursorLine] = line.slice(0, this.#cursorCol) + line.slice(newCol); + const newCol = this.#wordEndRight(line, this.#editorState.cursorCol); + this.#editorState.lines[this.#editorState.cursorLine] = line.slice(0, this.#editorState.cursorCol) + line.slice(newCol); } this.#scheduleRender(); break; } case 'ctrl+k': { - const line = this.#editorLines[this.#cursorLine] ?? ''; - if (this.#cursorCol < line.length) { + const line = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + if (this.#editorState.cursorCol < line.length) { // Kill to end of line - this.#editorLines[this.#cursorLine] = line.slice(0, this.#cursorCol); - } else if (this.#cursorLine < this.#editorLines.length - 1) { + this.#editorState.lines[this.#editorState.cursorLine] = line.slice(0, this.#editorState.cursorCol); + } else if (this.#editorState.cursorLine < this.#editorState.lines.length - 1) { // At EOL: join with next line - const next = this.#editorLines[this.#cursorLine + 1] ?? ''; - this.#editorLines.splice(this.#cursorLine + 1, 1); - this.#editorLines[this.#cursorLine] = line + next; + const next = this.#editorState.lines[this.#editorState.cursorLine + 1] ?? ''; + this.#editorState.lines.splice(this.#editorState.cursorLine + 1, 1); + this.#editorState.lines[this.#editorState.cursorLine] = line + next; } this.#scheduleRender(); break; } case 'ctrl+u': { - const line = this.#editorLines[this.#cursorLine] ?? ''; - this.#editorLines[this.#cursorLine] = line.slice(this.#cursorCol); - this.#cursorCol = 0; + const line = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + this.#editorState.lines[this.#editorState.cursorLine] = line.slice(this.#editorState.cursorCol); + this.#editorState.cursorCol = 0; this.#scheduleRender(); break; } case 'left': { - if (this.#cursorCol > 0) { - this.#cursorCol--; - } else if (this.#cursorLine > 0) { - this.#cursorLine--; - this.#cursorCol = (this.#editorLines[this.#cursorLine] ?? '').length; + if (this.#editorState.cursorCol > 0) { + this.#editorState.cursorCol--; + } else if (this.#editorState.cursorLine > 0) { + this.#editorState.cursorLine--; + this.#editorState.cursorCol = (this.#editorState.lines[this.#editorState.cursorLine] ?? '').length; } this.#scheduleRender(); break; } case 'right': { - const line = this.#editorLines[this.#cursorLine] ?? ''; - if (this.#cursorCol < line.length) { - this.#cursorCol++; - } else if (this.#cursorLine < this.#editorLines.length - 1) { - this.#cursorLine++; - this.#cursorCol = 0; + const line = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + if (this.#editorState.cursorCol < line.length) { + this.#editorState.cursorCol++; + } else if (this.#editorState.cursorLine < this.#editorState.lines.length - 1) { + this.#editorState.cursorLine++; + this.#editorState.cursorCol = 0; } this.#scheduleRender(); break; } case 'up': { - if (this.#cursorLine > 0) { - this.#cursorLine--; - const newLine = this.#editorLines[this.#cursorLine] ?? ''; - this.#cursorCol = Math.min(this.#cursorCol, newLine.length); + if (this.#editorState.cursorLine > 0) { + this.#editorState.cursorLine--; + const newLine = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + this.#editorState.cursorCol = Math.min(this.#editorState.cursorCol, newLine.length); } this.#scheduleRender(); break; } case 'down': { - if (this.#cursorLine < this.#editorLines.length - 1) { - this.#cursorLine++; - const newLine = this.#editorLines[this.#cursorLine] ?? ''; - this.#cursorCol = Math.min(this.#cursorCol, newLine.length); + if (this.#editorState.cursorLine < this.#editorState.lines.length - 1) { + this.#editorState.cursorLine++; + const newLine = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + this.#editorState.cursorCol = Math.min(this.#editorState.cursorCol, newLine.length); } this.#scheduleRender(); break; } case 'home': { - this.#cursorCol = 0; + this.#editorState.cursorCol = 0; this.#scheduleRender(); break; } case 'end': { - this.#cursorCol = (this.#editorLines[this.#cursorLine] ?? '').length; + this.#editorState.cursorCol = (this.#editorState.lines[this.#editorState.cursorLine] ?? '').length; this.#scheduleRender(); break; } case 'ctrl+home': { - this.#cursorLine = 0; - this.#cursorCol = 0; + this.#editorState.cursorLine = 0; + this.#editorState.cursorCol = 0; this.#scheduleRender(); break; } case 'ctrl+end': { - this.#cursorLine = this.#editorLines.length - 1; - this.#cursorCol = (this.#editorLines[this.#cursorLine] ?? '').length; + this.#editorState.cursorLine = this.#editorState.lines.length - 1; + this.#editorState.cursorCol = (this.#editorState.lines[this.#editorState.cursorLine] ?? '').length; this.#scheduleRender(); break; } case 'ctrl+left': { - const line = this.#editorLines[this.#cursorLine] ?? ''; - this.#cursorCol = this.#wordStartLeft(line, this.#cursorCol); + const line = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + this.#editorState.cursorCol = this.#wordStartLeft(line, this.#editorState.cursorCol); this.#scheduleRender(); break; } case 'ctrl+right': { - const line = this.#editorLines[this.#cursorLine] ?? ''; - this.#cursorCol = this.#wordEndRight(line, this.#cursorCol); + const line = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + this.#editorState.cursorCol = this.#wordEndRight(line, this.#editorState.cursorCol); this.#scheduleRender(); break; } case 'char': { - const line = this.#editorLines[this.#cursorLine] ?? ''; - this.#editorLines[this.#cursorLine] = line.slice(0, this.#cursorCol) + key.value + line.slice(this.#cursorCol); - this.#cursorCol += key.value.length; + const line = this.#editorState.lines[this.#editorState.cursorLine] ?? ''; + this.#editorState.lines[this.#editorState.cursorLine] = line.slice(0, this.#editorState.cursorCol) + key.value + line.slice(this.#editorState.cursorCol); + this.#editorState.cursorCol += key.value.length; this.#scheduleRender(); break; } @@ -741,14 +736,14 @@ export class AppLayout implements Disposable { if (this.#mode === 'editor') { allContent.push(buildDivider(BLOCK_PLAIN.prompt ?? 'prompt', cols)); allContent.push(''); - for (let i = 0; i < this.#editorLines.length; i++) { + for (let i = 0; i < this.#editorState.lines.length; i++) { const pfx = i === 0 ? EDITOR_PROMPT : CONTENT_INDENT; - const line = this.#editorLines[i] ?? ''; - if (i === this.#cursorLine) { + const line = this.#editorState.lines[i] ?? ''; + if (i === this.#editorState.cursorLine) { // Render the character *under* the cursor in reverse-video (no text displacement). // At EOL there is no character, so use a space as the cursor block. - const charUnder = line[this.#cursorCol] ?? ' '; - const withCursor = `${line.slice(0, this.#cursorCol)}${INVERSE_ON}${charUnder}${INVERSE_OFF}${line.slice(this.#cursorCol + 1)}`; + const charUnder = line[this.#editorState.cursorCol] ?? ' '; + const withCursor = `${line.slice(0, this.#editorState.cursorCol)}${INVERSE_ON}${charUnder}${INVERSE_OFF}${line.slice(this.#editorState.cursorCol + 1)}`; allContent.push(...wrapLine(pfx + withCursor, cols)); } else { allContent.push(...wrapLine(pfx + line, cols)); diff --git a/apps/claude-sdk-cli/src/EditorState.ts b/apps/claude-sdk-cli/src/EditorState.ts new file mode 100644 index 0000000..b3c7edf --- /dev/null +++ b/apps/claude-sdk-cli/src/EditorState.ts @@ -0,0 +1,50 @@ +/** + * Pure editor state — lines of text and cursor position. + * No rendering, no key handling, no I/O. + * + * AppLayout holds an instance and reads from it directly. Key handling + * will move here in step 3b; rendering will be extracted in step 3c. + * Until 3b lands, AppLayout mutates the lines array and cursor position + * directly through the exposed getters/setters. + */ +export class EditorState { + #lines: string[] = ['']; + #cursorLine = 0; + #cursorCol = 0; + + /** + * The lines array. Direct mutation (index assignment, splice) is + * intentional here — key handling still lives in AppLayout until step 3b. + */ + public get lines(): string[] { + return this.#lines; + } + + public get cursorLine(): number { + return this.#cursorLine; + } + + public set cursorLine(n: number) { + this.#cursorLine = n; + } + + public get cursorCol(): number { + return this.#cursorCol; + } + + public set cursorCol(n: number) { + this.#cursorCol = n; + } + + /** Full text content — all lines joined by newline. */ + public get text(): string { + return this.#lines.join('\n'); + } + + /** Reset to a single empty line with cursor at the origin. */ + public reset(): void { + this.#lines = ['']; + this.#cursorLine = 0; + this.#cursorCol = 0; + } +}