Skip to content

Commit e8e2cea

Browse files
elirantutiaclaude
andcommitted
fix context meter missing from the status rail on resumed sessions
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BBQLPrB6RrXuAoLUWt4b3
1 parent eddee65 commit e8e2cea

3 files changed

Lines changed: 119 additions & 28 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ CLI-specific behavior is encapsulated behind a `CliProvider` interface (`src/mai
6868
- **Renders must not touch pane DOM they don't need to.** `renderLayout()` runs on every `session-changed`/`layout-changed`, and `appendChild` on a node that is *already* a child is a remove-and-reinsert: it blurs whatever is focused inside (the Cmd+F find bar is appended *into* `.terminal-pane`, see `search-bar.ts`) and collapses an in-progress selection. So every `attach*ToContainer` no-ops when `element.parentElement === container`; DOM order among the panes laid out together is corrected separately by `ensurePaneOrder` (split-layout) via the DOM-free `isInRelativeOrder` in `components/pane-order.ts` — *relative* order, because hidden panes stay in the container interleaved with the visible ones — and `renderSwarmMode` reuses its `.swarm-grid-wrapper` instead of rebuilding it. Focus is guarded one level up, not by sniffing the DOM: `setFocused` cannot tell the find bar from the terminal (it is inside the pane), so `focusPane` gates the call on the DOM-free `shouldFocusPane` (`components/pane-focus.ts`) — a render that does not change the focused pane never calls `setFocused` at all, unless focus is sitting on nothing. `fitTerminal` skips `pty.resize` when the `cols`x`rows` memo is unchanged (a redundant resize makes the CLI redraw under a selection, and xterm does `clearSelection()` on a row-count change); `spawnTerminal` clears that memo and re-fits **after** `pty.create` resolves, because a fit racing the un-awaited spawn is dropped by `resizePty` for a session it has not registered yet (Copilot awaits a hook install first) and the PTY would stay at the 120×30 spawn default. Feeding this loop, `updateSessionCliId` (`state.ts`) early-returns on an unchanged id and the statusLine's `write_if_changed` writes `.sessionid`/`.name` only on change — the statusLine fires on every render, and each write used to cost a persist plus a full re-render.
6969
- `session-activity.ts` — Tracks working/waiting/idle status with debounced transitions
7070
- **Hook → session-state contract** — see `HOOKS.md` for the full map; it is verified against a specific Claude Code version and must be re-checked against https://code.claude.com/docs/en/hooks whenever hook handling changes. Three points are load-bearing and easy to get wrong. (1) **`PostToolUse` fires only on success**; a tool that ran and failed fires `PostToolUseFailure` (`error`, `is_interrupt`, `duration_ms`), and a call rejected before execution fires neither. Treating any non-empty `tool_response` as a failure — which this repo did until the events were re-verified — feeds every successful tool call into `missing-tool-detector.ts`. (2) **A `Stop` is not always a completion**: the main agent fires one every time it pauses on parallel subagents. `stop_status_writer.py` resolves it from the payload's `background_tasks` array, holding `working` only for `subagent`/`teammate`/`workflow` entries; only a *non-empty* array is authoritative — an empty one is not proof of an idle session, because the CLI filters that array on an `isBackgrounded` flag freshly-dispatched subagents don't carry yet — so empty *and* absent both fall through to the legacy `<sid>.subagents` counter. `session_crons` is never consulted or a `/loop` session would never complete. (3) **Every field name in `INSPECTOR_FIELDS` (`claude-cli.ts`) must exist in a documented per-event schema, and be read by something.** A compile-time assertion against `keyof InspectorEvent` catches the internal half of that drift; the external half — whether Claude Code actually sends the field — no type system can check, and an invented name silently renders a blank timeline row forever (how `config_key`/`question`/`answer` survived for months). Beware nested keys when reading the docs: the Elicitation example's `requested_schema.properties.username.type` scans like top-level `type`/`username` fields, and both were added on a previous pass and were dead on arrival. Generated Python embeds values via `pyLiteral` (`shared/python.ts`), never a raw `r'…'` literal, which breaks on an apostrophe in the path.
71-
- `session-cost.ts` — Structured cost tracking via Claude CLI status line (`statusLine` setting), with regex fallback for older CLI versions. Provides per-session and aggregate cost data (USD, tokens, cache, duration)
71+
- `session-cost.ts` — Structured cost tracking via Claude CLI status line (`statusLine` setting), with regex fallback for older CLI versions. Provides per-session and aggregate cost data (USD, tokens, cache, duration). Both this module and `session-context.ts` feed the terminal pane's **status rail** (right cluster = `profile · model · $cost | in/out`, left cluster = the context meter) off the *same* `session:costData` payload — Claude's statusLine writes `{cost, context_window, model}` into one `<sid>.cost` file; there is no `.context` file. Two things make the rail fragile on restore, and both are load-bearing. (1) `setCostData`/`setContextData` **dedupe on unchanged values** (the statusLine rewrites `.cost` on every render, and each event costs a persist + re-render), and `restoreCost`/`restoreContext` seed those maps **silently** from the persisted `SessionRecord.cost`/`.contextWindow` at load. A resumed session reports exactly the context that was persisted at quit, so the first post-resume payload is deduped away and `onContextChange` never fires — cost only self-heals because *its* dedupe set includes the ticking `totalDurationMs`. (2) The rail's left cluster is built lazily inside `updateContextDisplay` and hidden by CSS `.context-indicator:empty`, so "no event" renders as "no meter", not as a stale one. `createTerminalPane` therefore **pulls both clusters from `getCost`/`getContext` right after `instances.set`** — the general invariant being that *a store with a silent `restore*` seeder must be pulled by any consumer that builds its DOM lazily; a push-only consumer is correct only if its DOM provably predates every event.* (Every other consumer of these two stores — board cards, the sessions widget, `refreshProfileLabels` — already pulls, so the pane rail was the lone exception.) The cost pull is unconditional so that `updateCostDisplay` stays the **single renderer** of `.cost-display`, painting the `$0.0000` placeholder itself when cost is null; the pane constructor only toggles `.hidden` off the provider capabilities. `ArchivedSession` persists `cost` but no `contextWindow`, so resume-from-history still starts both empty
7272
- **Session auto-naming** — `session-title.ts` adopts the CLI's own session title as the tab name, so users don't have to rename manually. The title arrives as the **`session_name` field of Claude's statusLine stdin payload** (the custom name from `--name` / `/rename` when one exists, otherwise Claude's AI-generated topic title; the key is absent until the session has one). Hook payloads carry no title — verified against the 2.1.237 binary, whose base hook schema is `{session_id, transcript_path, cwd, prompt_id, permission_mode, agent_id, agent_type, effort}` — so the statusLine is the only live source. `buildStatusLinePython` in `src/main/hook-status.ts` builds one body used on every platform; `installStatusLineScript` writes it to `~/.vibeyard/run/statusline.py` and the `.sh`/`.cmd` wrapper invokes it **by path**, never inlined into the shell command (see the `hook-commands.ts` module docstring for why inlining Python here is fragile). It writes `<STATUS_DIR>/<sid>.name` as `{"name": "…", "session_id": "…"}`, keyed on `CLAUDE_IDE_SESSION_ID`; the watcher forwards it as `session:sessionName` → `applyCliSessionName`, which skips sessions with `userRenamed` (a Vibeyard rename is sticky and always wins), drops a title whose `session_id` no longer matches `session.cliSessionId` (a stale `.name` survives `/clear` until the next render, and `resyncAllSessions` replays every file on window activate), and is gated on `preferences.autoTitleEnabled`. Three script details are load-bearing: it writes **only on change** (statusLine fires on every render, and each write costs a persist + full tab-bar re-render) — which is why flipping `autoTitleEnabled` back on sends `session:resyncStatus` to replay every status file, since an unchanged `.name` produces no further fs event and `app.on('activate')` is effectively macOS-only; it uses `json.dumps` so a CJK/emoji title is ASCII on disk (a raw write hits Windows' locale codepage, raises `UnicodeEncodeError`, and would take the `.cost` write down with it); and `STATUS_DIR` is embedded via `JSON.stringify`, whose escapes are all valid Python ones — a raw `r'…'` literal breaks on an apostrophe in the path (a Windows user named O'Brien), and a `SyntaxError` there silently kills cost, context, sessionid and name at once. `.name` is a **provider-facing channel**, not Claude-private — any provider can write that file the way `codex-session-watcher.ts` already writes `.sessionid` without hooks; today only Claude does, so non-Claude sessions no longer auto-name (this replaced an approach that scraped Claude's box-drawing chrome out of raw PTY bytes). Default names come from `src/renderer/state/session-naming.ts`: `nextNumberFor(key, project)` counts the highest existing number across sessions *and* history for **every locale's template** of that i18n key, with `nextSessionNumber` / `defaultSessionName` as the `Session {num}` wrappers and the MCP inspector tabs numbering independently off the same helper. This replaces three separate `length + 1` formulas, which reissued a number already on screen after a middle tab was closed.
7373
- `components/active-sessions-panel.ts` — Global, cross-project **Active Sessions** list rendered into `#sidebar-active-sessions` (a persistent sidebar block above Discussions). `selectActiveSessions(projects, statusOf, activeStatuses)` is a DOM-free selector that collects open CLI sessions (`isCliSession`) across `appState.projects` whose live `getStatus()` is in the configured set, ordered by `STATUS_PRIORITY` (from `project-status.ts`, now exported) then project name. `initActiveSessions()` subscribes to `session-activity.onChange` + the relevant `appState` events and re-renders; rows route clicks through `setActiveProject` + `setActiveSession`. Visibility is gated by `preferences.sidebarViews.activeSessions` **and** requires more than one project (with a single project the rows just duplicate its own tab bar); the "active" status set comes from `preferences.activeSessionStatuses` (`{ working, waiting, input, completed }`, default working/input/completed via `resolveActiveStatuses`). Both preferences are edited in the Preferences → Appearance section. Styles live in `styles/sidebar.css`; status dots reuse the existing `.project-status.<status>` classes.
7474
- `components/git-panel.ts` — Git changes for the **active project**, rendered inside its sidebar card as a `git` panel-toggle tab (a third `ProjectPanel` alongside `history`/`files` in `sidebar.ts`), not a standalone bottom panel. The "Git" tab carries a `.project-action-badge` count of total changes (badge + click-to-open); `Cmd/Ctrl+Shift+G` (`toggleGitPanel`, exported from `sidebar.ts`) toggles it. `mountGitPanel(project, container)` reparents a single persistent node into `.project-panel-git` so file rows/scroll survive sidebar re-renders; `closeGitPanel()` detaches it. The shared `loadFiles`/worktree-selector logic is unchanged; `sidebarViews.gitPanel` now gates the tab button. There is no `#git-panel` node in `index.html`.

‎src/renderer/components/terminal-pane.test.ts‎

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { ContextWindowInfo, CostInfo } from '../../shared/types.js';
23

34
const providerCaps = new Map([
45
['claude', { costTracking: true, contextWindow: true, pendingPromptTrigger: 'startup-arg' }],
@@ -84,6 +85,7 @@ vi.mock('../session-cost.js', () => ({
8485

8586
vi.mock('../session-context.js', () => ({
8687
removeSession: vi.fn(),
88+
getContext: vi.fn(() => null),
8789
getContextSeverity: vi.fn((pct: number) => (pct >= 90 ? 'critical' : pct >= 70 ? 'warning' : '')),
8890
}));
8991

@@ -668,8 +670,14 @@ describe('profile label in status-line cost string', () => {
668670
return { id, name, providerId, configDir: `/cfg/${id}`, managed: true, createdAt: 0 };
669671
}
670672

671-
function costText(instance: any) {
672-
return instance.element.querySelector('.cost-display')!.textContent as string;
673+
// The cost cluster is span-composed, and FakeElement.textContent does not aggregate
674+
// children — assert the segments, not the container's text.
675+
function costParts(instance: any) {
676+
const cd = instance.element.querySelector('.cost-display')!;
677+
return {
678+
pill: cd.querySelector('.ssl-pill')?.textContent ?? null,
679+
cost: cd.querySelector('.ssl-cost')?.textContent ?? null,
680+
};
673681
}
674682

675683
// createTerminalPane(sessionId, projectPath, cliSessionId, isResume, args, providerId, projectId?, envVars?, configDir?)
@@ -684,7 +692,7 @@ describe('profile label in status-line cost string', () => {
684692

685693
const instance = makePane(createTerminalPane, 'pb-1', 'claude', '/cfg/work');
686694

687-
expect(costText(instance)).toBe('$0.0000');
695+
expect(costParts(instance)).toEqual({ pill: null, cost: '$0.0000' });
688696
});
689697

690698
it('prefixes the cost string with the profile matching the spawned config dir', async () => {
@@ -694,7 +702,7 @@ describe('profile label in status-line cost string', () => {
694702

695703
const instance = makePane(createTerminalPane, 'pb-2', 'claude', '/cfg/personal');
696704

697-
expect(costText(instance)).toBe('Personal · $0.0000');
705+
expect(costParts(instance)).toEqual({ pill: 'Personal', cost: '$0.0000' });
698706
});
699707

700708
it('labels a session on the base config dir (no configDir) as "Default"', async () => {
@@ -704,7 +712,7 @@ describe('profile label in status-line cost string', () => {
704712

705713
const instance = makePane(createTerminalPane, 'pb-3', 'claude', undefined); // base ~/.claude
706714

707-
expect(costText(instance)).toBe('Default · $0.0000');
715+
expect(costParts(instance)).toEqual({ pill: 'Default', cost: '$0.0000' });
708716
});
709717

710718
it('folds the profile in front of the model name once cost data arrives', async () => {
@@ -771,7 +779,7 @@ describe('profile label in status-line cost string', () => {
771779

772780
const instance = makePane(createTerminalPane, 'pb-4', 'claude', '/cfg/work');
773781

774-
expect(costText(instance)).toBe('$0.0000');
782+
expect(costParts(instance)).toEqual({ pill: null, cost: '$0.0000' });
775783
});
776784

777785
it('refreshProfileLabels re-renders the prefix after a second profile is added', async () => {
@@ -780,7 +788,7 @@ describe('profile label in status-line cost string', () => {
780788
appState.profiles.push(makeProfile('work', 'Work'));
781789

782790
const instance = makePane(createTerminalPane, 'pb-5', 'claude', '/cfg/work');
783-
expect(costText(instance)).toBe('$0.0000');
791+
expect(costParts(instance)).toEqual({ pill: null, cost: '$0.0000' });
784792

785793
appState.profiles.push(makeProfile('personal', 'Personal'));
786794
refreshProfileLabels();
@@ -849,3 +857,85 @@ describe('injectPromptIntoRunningSession', () => {
849857
expect(mockPtyWrite).toHaveBeenNthCalledWith(2, 'inj-3', '\r');
850858
});
851859
});
860+
861+
describe('status rail is primed from restored cost/context', () => {
862+
beforeEach(() => {
863+
vi.resetModules();
864+
vi.clearAllMocks();
865+
vi.useFakeTimers();
866+
867+
vi.stubGlobal('document', new FakeDocument());
868+
vi.stubGlobal('window', makeWindowStub());
869+
vi.stubGlobal('navigator', { platform: 'MacIntel', clipboard: { writeText: mockClipboardWrite } });
870+
});
871+
872+
const q = (root: unknown, selector: string): FakeElement =>
873+
(root as FakeElement).querySelector(selector) as FakeElement;
874+
875+
async function primeStores(cost: CostInfo | null = null, context: ContextWindowInfo | null = null): Promise<void> {
876+
const { getCost } = await import('../session-cost.js');
877+
const { getContext } = await import('../session-context.js');
878+
vi.mocked(getCost).mockReturnValue(cost);
879+
vi.mocked(getContext).mockReturnValue(context);
880+
}
881+
882+
// The meter has to be right the moment the pane exists: a resumed session reports the
883+
// context that was already persisted, so `setContextData` dedupes it away and no
884+
// change event ever arrives to build it.
885+
it.each([
886+
[14, 'ok'],
887+
[95, 'crit'],
888+
])('paints the restored context meter and its severity state (%i%%)', async (pct, state) => {
889+
const totalTokens = pct * 10_000;
890+
await primeStores(null, { totalTokens, contextWindowSize: 1_000_000, usedPercentage: pct });
891+
const { createTerminalPane } = await import('./terminal-pane.js');
892+
893+
const instance = createTerminalPane(`restored-${pct}`, '/project', 'cli-1', true, '', 'claude');
894+
895+
const indicator = q(instance.element, '.context-indicator');
896+
expect(q(indicator, '.ssl-meter-fill').style.width).toBe(`${pct}%`);
897+
expect(q(indicator, '.ssl-pct').textContent).toBe(`${pct}%`);
898+
expect(q(indicator, '.ssl-tok').textContent).toBe(String(totalTokens));
899+
expect(q(instance.element, '.session-status-bar').dataset.state).toBe(state);
900+
});
901+
902+
it('renders restored cost instead of the $0.0000 placeholder', async () => {
903+
await primeStores({
904+
totalCostUsd: 4.5619,
905+
totalInputTokens: 150_458,
906+
totalOutputTokens: 1352,
907+
cacheReadTokens: 149_745,
908+
cacheCreationTokens: 711,
909+
totalDurationMs: 298_140_212,
910+
totalApiDurationMs: 782_216,
911+
model: 'Opus 5',
912+
});
913+
const { createTerminalPane } = await import('./terminal-pane.js');
914+
915+
const instance = createTerminalPane('restored-cost', '/project', 'cli-3', true, '', 'claude');
916+
917+
const costDisplay = q(instance.element, '.cost-display');
918+
expect(q(costDisplay, '.ssl-model').textContent).toBe('Opus 5');
919+
expect(q(costDisplay, '.ssl-cost').textContent).toBe('$4.5619');
920+
expect(q(costDisplay, '.ssl-io').textContent).toBe('150458 in / 1352 out');
921+
});
922+
923+
it('leaves the context indicator empty for a brand-new session with nothing restored', async () => {
924+
await primeStores();
925+
const { createTerminalPane } = await import('./terminal-pane.js');
926+
927+
const instance = createTerminalPane('fresh-1', '/project', null, false, '', 'claude');
928+
929+
expect(q(instance.element, '.context-indicator').children.length).toBe(0);
930+
expect(q(instance.element, '.ssl-cost').textContent).toBe('$0.0000');
931+
});
932+
933+
it('does not paint a meter for a provider without context-window support', async () => {
934+
await primeStores(null, { totalTokens: 10_000, contextWindowSize: 200_000, usedPercentage: 5 });
935+
const { createTerminalPane } = await import('./terminal-pane.js');
936+
937+
const instance = createTerminalPane('restored-codex', '/project', 'cli-4', true, '', 'codex');
938+
939+
expect(q(instance.element, '.context-indicator').children.length).toBe(0);
940+
});
941+
});

0 commit comments

Comments
 (0)