From c020b8469e880bb73d6b638a1d7086db7d1b6d9a Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 20:16:04 +1000 Subject: [PATCH 1/2] Extract EditorState from AppLayout (step 3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the three editor fields out of AppLayout into a dedicated class: - #editorLines -> EditorState.#lines (exposed via .lines getter) - #cursorLine -> EditorState.#cursorLine (getter + setter) - #cursorCol -> EditorState.#cursorCol (getter + setter) AppLayout holds this.#editorState and accesses state through it. Two reset sites (completeTurn, waitForInput) replaced by reset(). Key handling and rendering stay in AppLayout for now. The lines array is intentionally mutable from AppLayout — direct index assignment and splice still happen there until key handling moves in 3b. The setter pattern (cursorLine++, cursorLine = n) works correctly through the getter/setter pair. TypeScript caught every missed reference at compile time. --- apps/claude-sdk-cli/src/AppLayout.ts | 199 ++++++++++++------------- apps/claude-sdk-cli/src/EditorState.ts | 50 +++++++ 2 files changed, 147 insertions(+), 102 deletions(-) create mode 100644 apps/claude-sdk-cli/src/EditorState.ts 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; + } +} From 96f84b5ec5337732e44fe84b6ce46e2f30d9bd76 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 20:27:14 +1000 Subject: [PATCH 2/2] =?UTF-8?q?Write=20session=20log=20continuation=20for?= =?UTF-8?q?=202026-04-06=20(steps=201a=E2=80=933a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/sessions/2026-04-06.md | 107 +++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) 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