diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ef1beee..b8aa7cd 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -66,7 +66,7 @@ Every session has three phases: start, work, end. ## Current State -Branch: `feature/agent-message-handler-stateful` — PR #193 open (step 4b), auto-merge set. +Branch: `feature/status-state` — PR #194 open (step 5a), auto-merge set. Active development is in **`apps/claude-sdk-cli/`** — a TUI terminal app built on `@shellicar/claude-sdk`. @@ -81,12 +81,13 @@ Follows a State / Renderer / ScreenCoordinator (MVVM) pattern. Each substep ship - **3b** `EditorState.handleKey` — all editor key transitions moved out of `AppLayout` — PR #190 - **3c** `renderEditor(state, cols): string[]` pure renderer extracted — PR #191 - **4a** `AgentMessageHandler` stateless cases extracted from `runAgent.ts` — PR #192 -- **4b** `AgentMessageHandler` stateful cases moved in (`message_usage`, `tool_approval_request`, `tool_error`) — PR #193 (pending merge) +- **4b** `AgentMessageHandler` stateful cases moved in (`message_usage`, `tool_approval_request`, `tool_error`) — PR #193 +- **5a** `StatusState` + `renderStatus(state, cols): string` extracted — PR #194 (pending merge) -**Next: step 5a** — extract `StatusState` + `renderStatus` from `AppLayout` -- Move the 5 token/cost accumulators to `StatusState` -- Move status line render logic to `renderStatus(state, cols): string` -- `AppLayout` holds `this.#statusState`, calls `renderStatus` in its render pass +**Next: step 5b** — extract `ConversationState` + `renderConversation` from `AppLayout` +- Move sealed blocks, active block, flush count, `transitionBlock`, `appendStreaming`, `completeStreaming`, `appendToLastSealed` to `ConversationState` +- Move render logic to `renderConversation(state, cols, availableRows): string[]` +- Largest extraction so far — flush-to-scroll and block rendering are the complex parts @@ -141,6 +142,8 @@ Full detail: `.claude/five-banana-pillars.md` | `clipboard.ts` | `readClipboardText()`; three-stage `readClipboardPath()` (pbpaste → VS Code code/file-list JXA → osascript furl); `looksLikePath`; `sanitiseFurlResult` | | `EditorState.ts` | Pure editor state + `handleKey(key): boolean` transitions. No rendering, no I/O. | | `renderEditor.ts` | Pure `renderEditor(state: EditorState, cols: number): string[]` renderer. | +| `StatusState.ts` | Token/cost accumulators: 7 fields, single `update(msg)` method. Pure state. | +| `renderStatus.ts` | Pure `renderStatus(state: StatusState, cols: number): string` renderer. | | `AgentMessageHandler.ts` | Maps all `SdkMessage` events → layout calls / state mutations. Extracted from `runAgent.ts`. | | `runAgent.ts` | Wires agent to layout: sets up tools, beta flags, constructs handler, wires `port.on` | | `permissions.ts` | Tool auto-approve/deny rules | diff --git a/.claude/sessions/2026-04-06.md b/.claude/sessions/2026-04-06.md index 04b8466..5783acc 100644 --- a/.claude/sessions/2026-04-06.md +++ b/.claude/sessions/2026-04-06.md @@ -416,3 +416,45 @@ All 167 tests pass. Manual testing confirmed: tool approval flow, delta annotati - Move status line render logic to `renderStatus(state, cols): string` - `AppLayout` holds `this.#statusState`, calls `renderStatus` in its render pass - Tests: given a usage sequence, assert state totals and render output + + +--- + +## Session continuation 7 (same day, even later still) + +### Step 5a — `StatusState` + `renderStatus` (PR #194) + +**New `StatusState.ts`** — pure state, no I/O: +- 7 private fields: `totalInputTokens`, `totalCacheCreationTokens`, `totalCacheReadTokens`, `totalOutputTokens`, `totalCostUsd`, `lastContextUsed`, `contextWindow` +- All exposed via `public get` (enforced read-only from outside) +- Single `public update(msg: SdkMessageUsage)` method: accumulates the 5 running totals, overwrites the 2 last-value fields (`lastContextUsed`, `contextWindow` are not accumulated — they reflect the most recent message) + +**New `renderStatus.ts`** — pure `(state, cols): string`: +- Moves `#buildStatusLine` logic verbatim from `AppLayout` +- `formatTokens` helper moves here (was module-level in `AppLayout`, only used by this function) +- Returns `''` when no usage recorded +- Otherwise builds the full status line: in/out tokens, optional cache creation/read, cost, optional context% + +**`AppLayout` changes:** +- Imports `StatusState`, `renderStatus` +- Replaces 7 private fields with `#statusState = new StatusState()` +- `updateUsage` becomes: `this.#statusState.update(msg); this.render()` +- `#buildStatusLine` becomes: `return renderStatus(this.#statusState, cols)` +- `formatTokens` removed (moved to `renderStatus.ts`) + +**Tests:** 190 total (23 new) +- `StatusState.spec.ts` (11): initial zeros, accumulation of each counter, last-value semantics (second `update` overwrites `lastContextUsed` and `contextWindow` rather than adding) +- `renderStatus.spec.ts` (12): empty on no usage, labels present, conditional cache sections, context% conditional on `contextWindow > 0`, `formatTokens` formatting via rendered output + +**Lesson from tooling:** `PreviewEdit` with multiple line-number edits uses chained coordinates — each edit's line numbers are relative to the result of the previous edit, not the original file. This means if edit #1 adds 2 lines and edit #2 deletes 6, edit #3 must account for the net -4 shift. The safe alternative is `replace_text` which matches by string content and is coordinate-independent. Used `replace_text` for all 5 edits in `AppLayout.ts`. + +--- + +### State at end of session + +- Branch: `feature/status-state`, PR #194 open, auto-merge set +- **Next: step 5b** — extract `ConversationState` + `renderConversation` from `AppLayout` + - Move sealed blocks, active block, flush count, `transitionBlock`, `appendStreaming`, `completeStreaming`, `appendToLastSealed` to `ConversationState` + - Move render logic to `renderConversation(state, cols, availableRows): string[]` + - Largest extraction so far — the flush-to-scroll mechanism and block rendering are the complex parts + - Risk: medium. Visible immediately if wrong (content disappears or renders incorrectly) diff --git a/apps/claude-sdk-cli/src/AppLayout.ts b/apps/claude-sdk-cli/src/AppLayout.ts index d16b741..64e730e 100644 --- a/apps/claude-sdk-cli/src/AppLayout.ts +++ b/apps/claude-sdk-cli/src/AppLayout.ts @@ -14,6 +14,8 @@ import { readClipboardPath, readClipboardText } from './clipboard.js'; import { EditorState } from './EditorState.js'; import { logger } from './logger.js'; import { renderEditor } from './renderEditor.js'; +import { renderStatus } from './renderStatus.js'; +import { StatusState } from './StatusState.js'; export type PendingTool = { requestId: string; @@ -96,13 +98,6 @@ function renderBlockContent(content: string, cols: number): string[] { return result; } -function formatTokens(n: number): string { - if (n >= 1000) { - return `${(n / 1000).toFixed(1)}k`; - } - return String(n); -} - function buildDivider(displayLabel: string | null, cols: number): string { if (!displayLabel) { return DIM + FILL.repeat(cols) + RESET; @@ -146,13 +141,7 @@ export class AppLayout implements Disposable { #pendingApprovals: Array<(approved: boolean) => void> = []; #cancelFn: (() => void) | null = null; - #totalInputTokens = 0; - #totalCacheCreationTokens = 0; - #totalCacheReadTokens = 0; - #totalOutputTokens = 0; - #totalCostUsd = 0; - #lastContextUsed = 0; - #contextWindow = 0; + #statusState = new StatusState(); public constructor() { this.#screen = new StdoutScreen(); @@ -289,13 +278,7 @@ export class AppLayout implements Disposable { } public updateUsage(msg: SdkMessageUsage): void { - this.#totalInputTokens += msg.inputTokens; - this.#totalCacheCreationTokens += msg.cacheCreationTokens; - this.#totalCacheReadTokens += msg.cacheReadTokens; - this.#totalOutputTokens += msg.outputTokens; - this.#totalCostUsd += msg.costUsd; - this.#lastContextUsed = msg.inputTokens + msg.cacheCreationTokens + msg.cacheReadTokens; - this.#contextWindow = msg.contextWindow; + this.#statusState.update(msg); this.render(); } @@ -694,25 +677,8 @@ export class AppLayout implements Disposable { return b.output; } - #buildStatusLine(_cols: number): string { - if (this.#totalInputTokens === 0 && this.#totalOutputTokens === 0 && this.#totalCacheCreationTokens === 0) { - return ''; - } - const b = new StatusLineBuilder(); - b.text(` in: ${formatTokens(this.#totalInputTokens)}`); - if (this.#totalCacheCreationTokens > 0) { - b.text(` ↑${formatTokens(this.#totalCacheCreationTokens)}`); - } - if (this.#totalCacheReadTokens > 0) { - b.text(` ↓${formatTokens(this.#totalCacheReadTokens)}`); - } - b.text(` out: ${formatTokens(this.#totalOutputTokens)}`); - b.text(` $${this.#totalCostUsd.toFixed(4)}`); - if (this.#contextWindow > 0) { - const pct = ((this.#lastContextUsed / this.#contextWindow) * 100).toFixed(1); - b.text(` ctx: ${formatTokens(this.#lastContextUsed)}/${formatTokens(this.#contextWindow)} (${pct}%)`); - } - return b.output; + #buildStatusLine(cols: number): string { + return renderStatus(this.#statusState, cols); } #buildApprovalRow(_cols: number): string { diff --git a/apps/claude-sdk-cli/src/StatusState.ts b/apps/claude-sdk-cli/src/StatusState.ts new file mode 100644 index 0000000..1b99c32 --- /dev/null +++ b/apps/claude-sdk-cli/src/StatusState.ts @@ -0,0 +1,47 @@ +import type { SdkMessageUsage } from '@shellicar/claude-sdk'; + +/** + * Accumulates token usage across all turns in a session. + * Pure state: no rendering, no I/O. + */ +export class StatusState { + #totalInputTokens = 0; + #totalCacheCreationTokens = 0; + #totalCacheReadTokens = 0; + #totalOutputTokens = 0; + #totalCostUsd = 0; + #lastContextUsed = 0; + #contextWindow = 0; + + public get totalInputTokens(): number { + return this.#totalInputTokens; + } + public get totalCacheCreationTokens(): number { + return this.#totalCacheCreationTokens; + } + public get totalCacheReadTokens(): number { + return this.#totalCacheReadTokens; + } + public get totalOutputTokens(): number { + return this.#totalOutputTokens; + } + public get totalCostUsd(): number { + return this.#totalCostUsd; + } + public get lastContextUsed(): number { + return this.#lastContextUsed; + } + public get contextWindow(): number { + return this.#contextWindow; + } + + public update(msg: SdkMessageUsage): void { + this.#totalInputTokens += msg.inputTokens; + this.#totalCacheCreationTokens += msg.cacheCreationTokens; + this.#totalCacheReadTokens += msg.cacheReadTokens; + this.#totalOutputTokens += msg.outputTokens; + this.#totalCostUsd += msg.costUsd; + this.#lastContextUsed = msg.inputTokens + msg.cacheCreationTokens + msg.cacheReadTokens; + this.#contextWindow = msg.contextWindow; + } +} diff --git a/apps/claude-sdk-cli/src/renderStatus.ts b/apps/claude-sdk-cli/src/renderStatus.ts new file mode 100644 index 0000000..7b1910b --- /dev/null +++ b/apps/claude-sdk-cli/src/renderStatus.ts @@ -0,0 +1,34 @@ +import { StatusLineBuilder } from '@shellicar/claude-core/status-line'; +import type { StatusState } from './StatusState.js'; + +function formatTokens(n: number): string { + if (n >= 1000) { + return `${(n / 1000).toFixed(1)}k`; + } + return String(n); +} + +/** + * Pure renderer: given the current status state, produce a single status line string. + * Returns an empty string if no usage has been recorded yet. + */ +export function renderStatus(state: StatusState, _cols: number): string { + if (state.totalInputTokens === 0 && state.totalOutputTokens === 0 && state.totalCacheCreationTokens === 0) { + return ''; + } + const b = new StatusLineBuilder(); + b.text(` in: ${formatTokens(state.totalInputTokens)}`); + if (state.totalCacheCreationTokens > 0) { + b.text(` \u2191${formatTokens(state.totalCacheCreationTokens)}`); + } + if (state.totalCacheReadTokens > 0) { + b.text(` \u2193${formatTokens(state.totalCacheReadTokens)}`); + } + b.text(` out: ${formatTokens(state.totalOutputTokens)}`); + b.text(` $${state.totalCostUsd.toFixed(4)}`); + if (state.contextWindow > 0) { + const pct = ((state.lastContextUsed / state.contextWindow) * 100).toFixed(1); + b.text(` ctx: ${formatTokens(state.lastContextUsed)}/${formatTokens(state.contextWindow)} (${pct}%)`); + } + return b.output; +} diff --git a/apps/claude-sdk-cli/test/StatusState.spec.ts b/apps/claude-sdk-cli/test/StatusState.spec.ts new file mode 100644 index 0000000..40dff54 --- /dev/null +++ b/apps/claude-sdk-cli/test/StatusState.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { StatusState } from '../src/StatusState.js'; + +function makeUsage(inputTokens: number, opts: { cacheCreation?: number; cacheRead?: number; output?: number; cost?: number; contextWindow?: number } = {}): Parameters[0] { + return { + type: 'message_usage', + inputTokens, + cacheCreationTokens: opts.cacheCreation ?? 0, + cacheReadTokens: opts.cacheRead ?? 0, + outputTokens: opts.output ?? 100, + costUsd: opts.cost ?? 0.001, + contextWindow: opts.contextWindow ?? 200_000, + }; +} + +// --------------------------------------------------------------------------- +// Initial state +// --------------------------------------------------------------------------- + +describe('StatusState — initial state', () => { + it('totalInputTokens starts at zero', () => { + const expected = 0; + const actual = new StatusState().totalInputTokens; + expect(actual).toBe(expected); + }); + + it('totalCostUsd starts at zero', () => { + const expected = 0; + const actual = new StatusState().totalCostUsd; + expect(actual).toBe(expected); + }); + + it('contextWindow starts at zero', () => { + const expected = 0; + const actual = new StatusState().contextWindow; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// update() — accumulation +// --------------------------------------------------------------------------- + +describe('StatusState — update accumulates tokens', () => { + it('accumulates inputTokens across updates', () => { + const state = new StatusState(); + state.update(makeUsage(1000)); + state.update(makeUsage(500)); + const expected = 1500; + const actual = state.totalInputTokens; + expect(actual).toBe(expected); + }); + + it('accumulates cacheCreationTokens', () => { + const state = new StatusState(); + state.update(makeUsage(0, { cacheCreation: 200 })); + state.update(makeUsage(0, { cacheCreation: 300 })); + const expected = 500; + const actual = state.totalCacheCreationTokens; + expect(actual).toBe(expected); + }); + + it('accumulates cacheReadTokens', () => { + const state = new StatusState(); + state.update(makeUsage(0, { cacheRead: 400 })); + state.update(makeUsage(0, { cacheRead: 100 })); + const expected = 500; + const actual = state.totalCacheReadTokens; + expect(actual).toBe(expected); + }); + + it('accumulates outputTokens', () => { + const state = new StatusState(); + state.update(makeUsage(0, { output: 300 })); + state.update(makeUsage(0, { output: 200 })); + const expected = 500; + const actual = state.totalOutputTokens; + expect(actual).toBe(expected); + }); + + it('accumulates costUsd', () => { + const state = new StatusState(); + state.update(makeUsage(0, { cost: 0.001 })); + state.update(makeUsage(0, { cost: 0.002 })); + const expected = 0.003; + const actual = Number(state.totalCostUsd.toFixed(3)); + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// update() — last-value fields (not accumulated) +// --------------------------------------------------------------------------- + +describe('StatusState — update overwrites lastContextUsed and contextWindow', () => { + it('lastContextUsed is sum of input+cacheCreate+cacheRead from last update', () => { + const state = new StatusState(); + state.update(makeUsage(1000, { cacheCreation: 200, cacheRead: 300 })); + const expected = 1500; + const actual = state.lastContextUsed; + expect(actual).toBe(expected); + }); + + it('lastContextUsed is overwritten (not accumulated) on second update', () => { + const state = new StatusState(); + state.update(makeUsage(1000)); + state.update(makeUsage(500)); + const expected = 500; + const actual = state.lastContextUsed; + expect(actual).toBe(expected); + }); + + it('contextWindow is overwritten on second update', () => { + const state = new StatusState(); + state.update(makeUsage(0, { contextWindow: 100_000 })); + state.update(makeUsage(0, { contextWindow: 200_000 })); + const expected = 200_000; + const actual = state.contextWindow; + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/renderStatus.spec.ts b/apps/claude-sdk-cli/test/renderStatus.spec.ts new file mode 100644 index 0000000..b9ddba6 --- /dev/null +++ b/apps/claude-sdk-cli/test/renderStatus.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { renderStatus } from '../src/renderStatus.js'; +import { StatusState } from '../src/StatusState.js'; + +function makeState(inputTokens: number, opts: { cacheCreation?: number; cacheRead?: number; output?: number; cost?: number; contextWindow?: number } = {}): StatusState { + const state = new StatusState(); + state.update({ + type: 'message_usage', + inputTokens, + cacheCreationTokens: opts.cacheCreation ?? 0, + cacheReadTokens: opts.cacheRead ?? 0, + outputTokens: opts.output ?? 100, + costUsd: opts.cost ?? 0.001, + contextWindow: opts.contextWindow ?? 200_000, + }); + return state; +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +describe('renderStatus — empty state', () => { + it('returns empty string when no usage recorded', () => { + const expected = ''; + const actual = renderStatus(new StatusState(), 120); + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Content +// --------------------------------------------------------------------------- + +describe('renderStatus — content', () => { + it('includes "in:" label', () => { + const expected = true; + const actual = renderStatus(makeState(1000), 120).includes('in:'); + expect(actual).toBe(expected); + }); + + it('includes "out:" label', () => { + const expected = true; + const actual = renderStatus(makeState(1000), 120).includes('out:'); + expect(actual).toBe(expected); + }); + + it('includes cost with $ prefix', () => { + const expected = true; + const actual = renderStatus(makeState(1000, { cost: 0.0042 }), 120).includes('$0.0042'); + expect(actual).toBe(expected); + }); + + it('shows cache creation with up-arrow when non-zero', () => { + const expected = true; + const actual = renderStatus(makeState(1000, { cacheCreation: 500 }), 120).includes('\u2191'); + expect(actual).toBe(expected); + }); + + it('omits cache creation when zero', () => { + const expected = false; + const actual = renderStatus(makeState(1000, { cacheCreation: 0 }), 120).includes('\u2191'); + expect(actual).toBe(expected); + }); + + it('shows cache read with down-arrow when non-zero', () => { + const expected = true; + const actual = renderStatus(makeState(1000, { cacheRead: 300 }), 120).includes('\u2193'); + expect(actual).toBe(expected); + }); + + it('omits cache read when zero', () => { + const expected = false; + const actual = renderStatus(makeState(1000, { cacheRead: 0 }), 120).includes('\u2193'); + expect(actual).toBe(expected); + }); + + it('includes context percentage when contextWindow > 0', () => { + const expected = true; + const actual = renderStatus(makeState(100_000, { contextWindow: 200_000 }), 120).includes('ctx:'); + expect(actual).toBe(expected); + }); + + it('omits context when contextWindow is zero', () => { + const state = new StatusState(); + // Use a raw update that leaves contextWindow at 0 + state.update({ type: 'message_usage', inputTokens: 1000, cacheCreationTokens: 0, cacheReadTokens: 0, outputTokens: 100, costUsd: 0.001, contextWindow: 0 }); + const expected = false; + const actual = renderStatus(state, 120).includes('ctx:'); + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// formatTokens behaviour (via rendered output) +// --------------------------------------------------------------------------- + +describe('renderStatus — token formatting', () => { + it('formats tokens below 1000 as plain number', () => { + const expected = true; + const actual = renderStatus(makeState(500), 120).includes('500'); + expect(actual).toBe(expected); + }); + + it('formats tokens >= 1000 with k suffix', () => { + const expected = true; + const actual = renderStatus(makeState(2500), 120).includes('2.5k'); + expect(actual).toBe(expected); + }); +});