Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Every session has three phases: start, work, end.

<!-- BEGIN:REPO:current-state -->
## 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`.

Expand All @@ -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
<!-- END:REPO:current-state -->

<!-- BEGIN:REPO:vision -->
Expand Down Expand Up @@ -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 |
Expand Down
42 changes: 42 additions & 0 deletions .claude/sessions/2026-04-06.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
46 changes: 6 additions & 40 deletions apps/claude-sdk-cli/src/AppLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions apps/claude-sdk-cli/src/StatusState.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
34 changes: 34 additions & 0 deletions apps/claude-sdk-cli/src/renderStatus.ts
Original file line number Diff line number Diff line change
@@ -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;
}
121 changes: 121 additions & 0 deletions apps/claude-sdk-cli/test/StatusState.spec.ts
Original file line number Diff line number Diff line change
@@ -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<StatusState['update']>[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);
});
});
Loading
Loading