diff --git a/.claude/testament/2026-04-12.md b/.claude/testament/2026-04-12.md new file mode 100644 index 0000000..e2eb7ac --- /dev/null +++ b/.claude/testament/2026-04-12.md @@ -0,0 +1,19 @@ +# 09:20 + +## Phase 1 complete: Extract StatusState to constructor injection + +Branch: `feature/extract-status-state` + +### Verification results + +- **Tests**: 427 passed (18 files) -- all clean +- **Type-check**: Clean (ran `tsc -p tsconfig.check.json --noEmit` directly to bypass turbo cache) +- **Biome**: `pnpm ci:fix` fixed formatting in our 4 files. Exit code 1 due to a pre-existing unsafe lint error in `packages/claude-core/src/reflow.ts` (control character in regex) -- not our change, not fixable with safe auto-fix. + +### One thing to know for Phase 2 + +`pnpm ci:fix` also formatted two files outside our scope: +- `packages/claude-sdk-tools/src/Find/Find.ts` +- `packages/claude-sdk-tools/test/Find.spec.ts` + +These are pre-existing formatting issues that biome auto-fixed as a side effect. When staging for commit, only stage the four files listed in the prompt -- leave those two out. diff --git a/apps/claude-sdk-cli/src/AppLayout.ts b/apps/claude-sdk-cli/src/AppLayout.ts index 1395caa..e5d4090 100644 --- a/apps/claude-sdk-cli/src/AppLayout.ts +++ b/apps/claude-sdk-cli/src/AppLayout.ts @@ -5,7 +5,6 @@ import type { KeyAction } from '@shellicar/claude-core/input'; import { sanitiseLoneSurrogates } from '@shellicar/claude-core/sanitise'; import type { Screen } from '@shellicar/claude-core/screen'; import { StdoutScreen } from '@shellicar/claude-core/screen'; -import type { SdkMessageUsage } from '@shellicar/claude-sdk'; import { readClipboardPath, readClipboardText } from './clipboard.js'; import { logger } from './logger.js'; import { buildSubmitText } from './model/buildSubmitText.js'; @@ -13,7 +12,7 @@ import { CommandModeState } from './model/CommandModeState.js'; import type { Block, BlockType } from './model/ConversationState.js'; import { ConversationState } from './model/ConversationState.js'; import { EditorState } from './model/EditorState.js'; -import { StatusState } from './model/StatusState.js'; +import type { StatusState } from './model/StatusState.js'; import type { PendingTool } from './model/ToolApprovalState.js'; import { ToolApprovalState } from './model/ToolApprovalState.js'; import { renderCommandMode } from './view/renderCommandMode.js'; @@ -55,9 +54,10 @@ export class AppLayout implements Disposable { #editorResolve: ((value: string) => void) | null = null; #cancelFn: (() => void) | null = null; - #statusState = new StatusState(); + readonly #statusState: StatusState; - public constructor() { + public constructor(statusState: StatusState) { + this.#statusState = statusState; this.#screen = new StdoutScreen(); this.#cleanupResize = this.#screen.onResize(() => { this.#resizing = true; @@ -152,11 +152,6 @@ export class AppLayout implements Disposable { this.#cancelFn = fn; } - public setModel(model: string): void { - this.#statusState.setModel(model); - this.render(); - } - /** * Append text to the most recent sealed block of the given type. * Used for retroactive annotations (e.g. adding turn cost to the tools block after @@ -179,11 +174,6 @@ export class AppLayout implements Disposable { } } - public updateUsage(msg: SdkMessageUsage): void { - this.#statusState.update(msg); - this.render(); - } - /** Enter editor mode and wait for the user to submit input via Ctrl+Enter. */ public waitForInput(): Promise { this.#mode = 'editor'; diff --git a/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts b/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts index 776840f..715118a 100644 --- a/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts +++ b/apps/claude-sdk-cli/src/controller/AgentMessageHandler.ts @@ -4,6 +4,7 @@ import { CacheTtl, calculateCost, type DurableConfig, type SdkMessage, type SdkM import type { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; import type { AppLayout, PendingTool } from '../AppLayout.js'; import type { logger } from '../logger.js'; +import type { StatusState } from '../model/StatusState.js'; import { getPermission, PermissionAction } from '../permissions.js'; // ---- helpers (moved from runAgent.ts) ------------------------------------ @@ -76,6 +77,7 @@ export interface AgentMessageHandlerOptions { port: MessagePort; cwd: string; store: RefStore; + statusState: StatusState; } // ---- class --------------------------------------------------------------- @@ -97,6 +99,7 @@ export class AgentMessageHandler { #store: RefStore; #lastUsage: SdkMessageUsage | null = null; #usageBeforeTools: SdkMessageUsage | null = null; + #statusState: StatusState; public constructor(layout: AppLayout, log: typeof logger, opts: AgentMessageHandlerOptions) { this.#layout = layout; @@ -105,6 +108,7 @@ export class AgentMessageHandler { this.#port = opts.port; this.#cwd = opts.cwd; this.#store = opts.store; + this.#statusState = opts.statusState; } public handle(msg: SdkMessage): void { @@ -173,7 +177,7 @@ export class AgentMessageHandler { this.#usageBeforeTools = null; } this.#lastUsage = msg; - this.#layout.updateUsage(msg); + this.#statusState.update(msg); break; } case 'done': diff --git a/apps/claude-sdk-cli/src/entry/main.ts b/apps/claude-sdk-cli/src/entry/main.ts index 423806e..3645590 100644 --- a/apps/claude-sdk-cli/src/entry/main.ts +++ b/apps/claude-sdk-cli/src/entry/main.ts @@ -27,6 +27,7 @@ import { AgentMessageHandler } from '../controller/AgentMessageHandler.js'; import { GitStateMonitor } from '../GitStateMonitor.js'; import { printUsage, printVersion, printVersionInfo, startupBannerText } from '../help.js'; import { logger } from '../logger.js'; +import { StatusState } from '../model/StatusState.js'; import { ReadLine } from '../ReadLine.js'; import { replayHistory } from '../replayHistory.js'; import { runAgent } from '../runAgent.js'; @@ -100,13 +101,15 @@ const main = async () => { }; using rl = new ReadLine(); - const layout = new AppLayout(); + const statusState = new StatusState(); + const layout = new AppLayout(statusState); let turnInProgress = false; const watcher = new SdkConfigWatcher((config) => { logger.info('config reloaded', { model: config.model }); if (!turnInProgress) { - layout.setModel(config.model); + statusState.setModel(config.model); + layout.render(); } }); @@ -200,6 +203,7 @@ const main = async () => { port: channel.consumerPort, cwd, store, + statusState, }); channel.consumerPort.on('message', (msg: SdkMessage) => { handler.handle(msg); @@ -220,7 +224,8 @@ const main = async () => { } layout.showStartupBanner(startupBannerText()); - layout.setModel(watcher.config.model); + statusState.setModel(watcher.config.model); + layout.render(); // --- Main loop --- @@ -238,7 +243,8 @@ const main = async () => { const abortController = new AbortController(); currentAbortController = abortController; - layout.setModel(watcher.config.model); + statusState.setModel(watcher.config.model); + layout.render(); turnInProgress = true; const gitDelta = await gitMonitor.getDelta(); await runAgent(queryRunner, prompt, layout, channel.consumerPort, transformToolResult, abortController, gitDelta); @@ -246,7 +252,8 @@ const main = async () => { turnInProgress = false; currentAbortController = null; - layout.setModel(watcher.config.model); + statusState.setModel(watcher.config.model); + layout.render(); saveHistory(conversation, HISTORY_FILE); } }; diff --git a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts index 73999de..ae0ee88 100644 --- a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts +++ b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import type { AppLayout } from '../src/AppLayout.js'; import { AgentMessageHandler, type AgentMessageHandlerOptions } from '../src/controller/AgentMessageHandler.js'; import { logger } from '../src/logger.js'; +import { StatusState } from '../src/model/StatusState.js'; // --------------------------------------------------------------------------- // Helpers @@ -15,7 +16,6 @@ function makeLayout() { transitionBlock: vi.fn(), appendStreaming: vi.fn(), appendToLastSealed: vi.fn(), - updateUsage: vi.fn(), addPendingTool: vi.fn(), removePendingTool: vi.fn(), requestApproval: vi.fn().mockResolvedValue(true), @@ -32,12 +32,13 @@ function makeConfig(overrides: Partial = {}): DurableConfig { }; } -function makeOpts(overrides: { config?: Partial; cwd?: string; store?: AgentMessageHandlerOptions['store'] } = {}): AgentMessageHandlerOptions { +function makeOpts(overrides: { config?: Partial; cwd?: string; store?: AgentMessageHandlerOptions['store']; statusState?: StatusState } = {}): AgentMessageHandlerOptions { return { config: makeConfig(overrides.config), port: new MessageChannel().port2, cwd: overrides.cwd ?? '/test', store: overrides.store ?? ({ get: vi.fn(), getHint: vi.fn() } as unknown as AgentMessageHandlerOptions['store']), + statusState: overrides.statusState ?? new StatusState(), }; } @@ -339,11 +340,10 @@ function makeUsage(inputTokens: number): { type: 'message_usage'; inputTokens: n describe('AgentMessageHandler — message_usage without prior tools', () => { it('calls updateUsage', () => { + const statusState = new StatusState(); const layout = makeLayout(); - makeHandler(layout).handle(makeUsage(1000)); - const expected = 1; - const actual = vi.mocked(layout.updateUsage).mock.calls.length; - expect(actual).toBe(expected); + makeHandler(layout, { statusState }).handle(makeUsage(1000)); + expect(statusState.totalInputTokens).toBe(1000); }); it('does not annotate when no tool batch is open', () => {