@caph42/pi-ccline 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,374 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ import { clearBinaryCache, probeVersion, resolveBinary, spawnCcline, type ResolvedBinary } from "./binary.ts";
5
+ import { loadConfig, saveConfig, type PiCclineConfig } from "./config.ts";
6
+ import { buildCclineInput, collectSnapshot } from "./input.ts";
7
+ import { RefreshScheduler, RefreshTicker } from "./refresh.ts";
8
+ import { extensionStatusLine, loadingNotice, missingBinaryNotice, normalizeFrame, noticeFrame, withStaleMarker } from "./render.ts";
9
+
10
+ /** Structural subsets so the footer does not depend on pi-tui internals. */
11
+ type TuiLike = { requestRender(force?: boolean): void };
12
+ type ThemeLike = { fg(color: string, text: string): string };
13
+ type FooterDataLike = {
14
+ onBranchChange(callback: () => void): () => void;
15
+ /** Present on pi >= 1.4; absent on older footers, hence optional. */
16
+ getExtensionStatuses?(): ReadonlyMap<string, string>;
17
+ };
18
+
19
+ const DEFAULT_WIDTH = 120;
20
+
21
+ /**
22
+ * Best-effort initial width so the very first refresh wraps like the footer
23
+ * will. render(width) corrects it once pi paints the first frame.
24
+ */
25
+ function initialWidth(): number {
26
+ try {
27
+ const columns = process.stdout.columns;
28
+ if (typeof columns === "number" && Number.isFinite(columns) && columns > 0) return Math.floor(columns);
29
+ } catch {
30
+ // not a terminal
31
+ }
32
+ return DEFAULT_WIDTH;
33
+ }
34
+
35
+ function firstLine(text: string): string {
36
+ return (text.split("\n")[0] ?? "").trim().slice(0, 160);
37
+ }
38
+
39
+ /**
40
+ * Owns one footer installation: the cached frame, the single-flight refresh
41
+ * scheduler and the in-flight child process.
42
+ */
43
+ export class CclineFooter {
44
+ private readonly ctx: ExtensionContext;
45
+ private readonly config: PiCclineConfig;
46
+ private readonly scheduler: RefreshScheduler;
47
+ /** Only runs while the agent is active so an idle session spawns nothing. */
48
+ private readonly ticker: RefreshTicker;
49
+ private controller: AbortController | null = null;
50
+ /** Bumped on dispose and on every refresh so late results are dropped. */
51
+ private generation = 0;
52
+ private frame: string[] | null = null;
53
+ private stale = false;
54
+ private lastError: string | null = null;
55
+ private width = initialWidth();
56
+ private theme: ThemeLike | null = null;
57
+ private footerData: FooterDataLike | null = null;
58
+ private requestRenderFn: (() => void) | null = null;
59
+ private branchUnsubscribe: (() => void) | null = null;
60
+ private disposed = false;
61
+
62
+ readonly stats = {
63
+ refreshes: 0,
64
+ lastRefreshAt: null as number | null,
65
+ lastDurationMs: null as number | null,
66
+ };
67
+ resolved: ResolvedBinary = { path: null, source: "missing" };
68
+
69
+ constructor(ctx: ExtensionContext, config: PiCclineConfig) {
70
+ this.ctx = ctx;
71
+ this.config = config;
72
+ this.scheduler = new RefreshScheduler({
73
+ throttleMs: config.refreshThrottleMs,
74
+ task: () => this.refresh(),
75
+ });
76
+ this.ticker = new RefreshTicker({
77
+ intervalMs: config.liveRefreshMs,
78
+ onTick: () => this.requestRefresh(),
79
+ });
80
+ }
81
+
82
+ /** Called by pi with the live TUI, theme and footer data. */
83
+ attach(tui: TuiLike, theme: ThemeLike, footerData: FooterDataLike) {
84
+ this.theme = theme;
85
+ this.footerData = footerData;
86
+ this.requestRenderFn = () => tui.requestRender();
87
+ try {
88
+ this.branchUnsubscribe = footerData.onBranchChange(() => {
89
+ this.requestRefresh();
90
+ tui.requestRender();
91
+ });
92
+ } catch {
93
+ this.branchUnsubscribe = null;
94
+ }
95
+ this.requestRefresh();
96
+ // The footer can be installed (or re-enabled) in the middle of a run, when
97
+ // no agent_start will arrive until the next one. Start ticking right away.
98
+ if (!this.agentIsIdle()) this.startLive();
99
+
100
+ return {
101
+ dispose: () => this.dispose(),
102
+ invalidate: () => this.requestRefresh(),
103
+ render: (width: number) => this.render(width),
104
+ };
105
+ }
106
+
107
+ /** Coalesced, throttled refresh request. Never starts work synchronously. */
108
+ requestRefresh(): void {
109
+ if (this.disposed) return;
110
+ this.scheduler.request();
111
+ }
112
+
113
+ /**
114
+ * Start ticking so time-based segments (elapsed duration) stay live.
115
+ * Called when an agent run starts; idempotent.
116
+ */
117
+ startLive(): void {
118
+ if (this.disposed) return;
119
+ this.ticker.start();
120
+ }
121
+
122
+ stopLive(): void {
123
+ this.ticker.stop();
124
+ }
125
+
126
+ /** Idle unless pi says otherwise; an unreadable state must not start a timer. */
127
+ private agentIsIdle(): boolean {
128
+ try {
129
+ return this.ctx.isIdle();
130
+ } catch {
131
+ return true;
132
+ }
133
+ }
134
+
135
+ get error(): string | null {
136
+ return this.lastError;
137
+ }
138
+
139
+ get hasFrame(): boolean {
140
+ return this.frame !== null;
141
+ }
142
+
143
+ private render(width: number): string[] {
144
+ const requested = Math.floor(width);
145
+ const safeWidth = Math.max(1, requested || this.width);
146
+ // The width is only known during a render pass. Record it and let the
147
+ // scheduler wrap it into a later refresh; never spawn from here.
148
+ if (requested > 0 && requested !== this.width) {
149
+ this.width = requested;
150
+ this.requestRefresh();
151
+ }
152
+
153
+ const frame = this.frame;
154
+ const lines = frame
155
+ ? (() => {
156
+ const fitted = frame.map((line) => (visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth) : line));
157
+ return this.stale && this.theme ? withStaleMarker(fitted, safeWidth, this.theme.fg("dim", " ·stale")) : fitted;
158
+ })()
159
+ : loadingNotice(safeWidth);
160
+
161
+ // Read live: pi repaints on setStatus, so no subscription is needed and a
162
+ // status change shows up on the next frame even while ccline is throttled.
163
+ const statuses = (() => {
164
+ try {
165
+ return this.footerData?.getExtensionStatuses?.() ?? null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ })();
170
+ const status = extensionStatusLine(statuses, safeWidth, this.theme ? this.theme.fg("dim", "…") : "…");
171
+ return status ? [...lines, status] : lines;
172
+ }
173
+
174
+ private applyFrame(lines: string[], stale: boolean, error: string | null): void {
175
+ this.frame = lines;
176
+ this.stale = stale;
177
+ this.lastError = error;
178
+ this.requestRenderFn?.();
179
+ }
180
+
181
+ private async refresh(): Promise<void> {
182
+ if (this.disposed) return;
183
+ const generation = ++this.generation;
184
+ const startedAt = Date.now();
185
+ const binary = resolveBinary(this.config.binary);
186
+ this.resolved = binary;
187
+
188
+ if (!binary.path) {
189
+ this.applyFrame(missingBinaryNotice(this.width), false, "ccline binary not found");
190
+ return;
191
+ }
192
+
193
+ this.controller?.abort();
194
+ const controller = new AbortController();
195
+ this.controller = controller;
196
+
197
+ const snapshot = collectSnapshot(this.ctx, Date.now());
198
+ const payload = JSON.stringify(buildCclineInput(snapshot, { showProvider: this.config.showProvider }));
199
+
200
+ const result = await spawnCcline({
201
+ binary: binary.path,
202
+ payload,
203
+ timeoutMs: this.config.timeoutMs,
204
+ columns: this.width,
205
+ env: this.config.env,
206
+ signal: controller.signal,
207
+ });
208
+
209
+ // A newer refresh or a dispose superseded this run; drop its result.
210
+ if (this.disposed || generation !== this.generation) return;
211
+ this.controller = null;
212
+ this.stats.lastRefreshAt = Date.now();
213
+ this.stats.lastDurationMs = Date.now() - startedAt;
214
+
215
+ if (result.ok) {
216
+ const lines = normalizeFrame(result.stdout, this.width);
217
+ if (lines.length > 0) {
218
+ this.stats.refreshes += 1;
219
+ this.applyFrame(lines, false, null);
220
+ return;
221
+ }
222
+ }
223
+
224
+ const reason = result.aborted
225
+ ? "aborted"
226
+ : result.timedOut
227
+ ? `timed out after ${this.config.timeoutMs}ms`
228
+ : result.error
229
+ ? result.error
230
+ : `exited with code ${result.code}${result.stderr.trim() ? `: ${firstLine(result.stderr)}` : ""}`;
231
+
232
+ if (this.frame) {
233
+ // Keep the last good frame and only mark it stale.
234
+ this.applyFrame(this.frame, true, reason);
235
+ } else {
236
+ this.applyFrame(noticeFrame(`ccline failed: ${reason}`, this.width), false, reason);
237
+ }
238
+ }
239
+
240
+ dispose(): void {
241
+ if (this.disposed) return;
242
+ this.disposed = true;
243
+ this.generation += 1;
244
+ this.ticker.stop();
245
+ this.scheduler.dispose();
246
+ this.controller?.abort();
247
+ this.controller = null;
248
+ try {
249
+ this.branchUnsubscribe?.();
250
+ } catch {
251
+ // ignore
252
+ }
253
+ this.branchUnsubscribe = null;
254
+ this.footerData = null;
255
+ this.requestRenderFn = null;
256
+ }
257
+ }
258
+
259
+ /** Optional seams so the extension lifecycle can be tested without a real session. */
260
+ export interface PiCclineDeps {
261
+ loadConfig?: () => PiCclineConfig;
262
+ }
263
+
264
+ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
265
+ let footer: CclineFooter | null = null;
266
+ const readConfig = deps.loadConfig ?? (() => loadConfig());
267
+
268
+ const teardown = () => {
269
+ footer?.dispose();
270
+ footer = null;
271
+ };
272
+
273
+ /** Install the footer for the current session. Returns false when it does not apply. */
274
+ const install = (ctx: ExtensionContext): boolean => {
275
+ teardown();
276
+ if (ctx.mode !== "tui" || !ctx.hasUI) return false;
277
+ const config = readConfig();
278
+ if (!config.enabled) return false;
279
+ const next = new CclineFooter(ctx, config);
280
+ ctx.ui.setFooter((tui, theme, footerData) => next.attach(tui, theme, footerData));
281
+ footer = next;
282
+ return true;
283
+ };
284
+
285
+ const requestRefresh = () => footer?.requestRefresh();
286
+
287
+ // Processes, timers and the footer only start from a session, never from the
288
+ // extension factory (some invocations load extensions without a session).
289
+ pi.on("session_start", (_event, ctx) => {
290
+ install(ctx);
291
+ });
292
+ pi.on("turn_start", () => {
293
+ requestRefresh();
294
+ // Covers a footer installed while a run was already in progress.
295
+ footer?.startLive();
296
+ });
297
+ pi.on("turn_end", () => requestRefresh());
298
+ // An agent run can contain many turns and can sit inside one turn for a long
299
+ // time, so events alone leave the elapsed segment frozen. Tick while the run
300
+ // is active and stop again when it settles.
301
+ pi.on("agent_start", () => footer?.startLive());
302
+ pi.on("agent_end", () => {
303
+ requestRefresh();
304
+ footer?.stopLive();
305
+ });
306
+ pi.on("agent_settled", () => footer?.stopLive());
307
+ pi.on("model_select", () => requestRefresh());
308
+ pi.on("thinking_level_select", () => requestRefresh());
309
+ pi.on("session_info_changed", () => requestRefresh());
310
+ pi.on("session_shutdown", () => teardown());
311
+
312
+ pi.registerCommand("pi-ccline", {
313
+ description: "ccline footer: on | off | status | path",
314
+ handler: async (args, ctx) => {
315
+ const subcommand = (args ?? "").trim().split(/\s+/)[0]?.toLowerCase() ?? "";
316
+
317
+ if (subcommand === "on") {
318
+ if (!saveConfig({ ...loadConfig(), enabled: true })) {
319
+ ctx.ui.notify("pi-ccline: could not write config.json", "warning");
320
+ }
321
+ clearBinaryCache();
322
+ if (install(ctx)) ctx.ui.notify("pi-ccline footer enabled", "info");
323
+ else ctx.ui.notify("pi-ccline: footer needs interactive (tui) mode", "warning");
324
+ return;
325
+ }
326
+
327
+ if (subcommand === "off") {
328
+ saveConfig({ ...loadConfig(), enabled: false });
329
+ teardown();
330
+ if (ctx.hasUI) ctx.ui.setFooter(undefined);
331
+ ctx.ui.notify("pi-ccline footer disabled", "info");
332
+ return;
333
+ }
334
+
335
+ if (subcommand === "path") {
336
+ const config = loadConfig();
337
+ const binary = resolveBinary(config.binary);
338
+ if (!binary.path) {
339
+ ctx.ui.notify(
340
+ "ccline not found\nlooked at: config.binary, ~/.claude/ccline/ccline, PATH\ninstall: npm i -g @cometix/ccline",
341
+ "warning",
342
+ );
343
+ return;
344
+ }
345
+ const version = await probeVersion(binary.path, config.timeoutMs);
346
+ ctx.ui.notify(`${binary.path}\nsource: ${binary.source}\nversion: ${version ?? "unknown"}`, "info");
347
+ return;
348
+ }
349
+
350
+ if (subcommand !== "" && subcommand !== "status") {
351
+ ctx.ui.notify("usage: /pi-ccline on | off | status | path", "warning");
352
+ return;
353
+ }
354
+
355
+ const config = loadConfig();
356
+ const binary = footer?.resolved ?? resolveBinary(config.binary);
357
+ const lines = [
358
+ `enabled: ${config.enabled ? "yes" : "no"} · mode: ${ctx.mode}`,
359
+ `binary: ${binary.path ?? "not found"}${binary.path ? ` (${binary.source})` : ""}`,
360
+ `timeout: ${config.timeoutMs}ms · throttle: ${config.refreshThrottleMs}ms`,
361
+ `live: ${config.liveRefreshMs > 0 ? `${config.liveRefreshMs}ms while the agent runs` : "off"}`,
362
+ ];
363
+ if (footer) {
364
+ const { refreshes, lastRefreshAt, lastDurationMs } = footer.stats;
365
+ lines.push(`refreshes: ${refreshes} · last: ${lastRefreshAt === null ? "never" : `${Math.round((Date.now() - lastRefreshAt) / 1000)}s ago`}`);
366
+ lines.push(`duration: ${lastDurationMs === null ? "n/a" : `${lastDurationMs}ms`} · frame: ${footer.hasFrame ? "cached" : "none"}`);
367
+ lines.push(`state: ${footer.error ? `stale — ${footer.error}` : "ok"}`);
368
+ } else {
369
+ lines.push("state: footer not installed");
370
+ }
371
+ ctx.ui.notify(lines.join("\n"), footer?.error ? "warning" : "info");
372
+ },
373
+ });
374
+ }
@@ -0,0 +1,207 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { buildCclineInput, collectSnapshot, type BranchEntryLike, type PiSnapshot, type SnapshotContextLike } from "./input.ts";
5
+
6
+ function snapshot(overrides: Partial<PiSnapshot> = {}): PiSnapshot {
7
+ return {
8
+ cwd: "/work/project",
9
+ model: { id: "gpt-5.6-codex", name: "GPT-5.6 Codex", contextWindow: 272_000 },
10
+ thinkingLevel: "high",
11
+ contextUsage: { tokens: 42_000, contextWindow: 272_000, percent: 15.4 },
12
+ totals: { costUsd: 1.2345, durationMs: 1_830_000 },
13
+ session: { id: "sess-1", name: "demo" },
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ test("maps a full snapshot to the ccline schema", () => {
19
+ assert.deepEqual(buildCclineInput(snapshot()), {
20
+ cwd: "/work/project",
21
+ workspace: { current_dir: "/work/project" },
22
+ model: { id: "gpt-5.6-codex", display_name: "GPT-5.6 Codex" },
23
+ effort: { level: "high" },
24
+ context_window: {
25
+ context_window_size: 272_000,
26
+ current_usage: { input_tokens: 42_000 },
27
+ used_percentage: 15.4,
28
+ },
29
+ cost: { total_cost_usd: 1.2345, total_duration_ms: 1_830_000 },
30
+ session_id: "sess-1",
31
+ session_name: "demo",
32
+ });
33
+ });
34
+
35
+ test("never sends version, which ccline 2.0.1 rejects, and never sends rate_limits", () => {
36
+ const input = buildCclineInput(snapshot());
37
+ assert.equal("version" in input, false);
38
+ assert.equal("rate_limits" in input, false);
39
+ });
40
+
41
+ test("omits model and effort when pi has none", () => {
42
+ const input = buildCclineInput(snapshot({ model: undefined, thinkingLevel: undefined }));
43
+ assert.equal("model" in input, false);
44
+ assert.equal("effort" in input, false);
45
+ // Context usage still carries its own window size without a model.
46
+ const contextWindow = input.context_window as Record<string, unknown>;
47
+ assert.equal(contextWindow.context_window_size, 272_000);
48
+ // cwd and session data do not depend on the model.
49
+ assert.deepEqual(input.workspace, { current_dir: "/work/project" });
50
+ assert.equal(input.session_id, "sess-1");
51
+ });
52
+
53
+ test("ignores an unknown thinking level instead of guessing", () => {
54
+ const input = buildCclineInput(snapshot({ thinkingLevel: "off" }));
55
+ assert.equal("effort" in input, false);
56
+ });
57
+
58
+ test("omits cost when no assistant usage was recorded", () => {
59
+ const input = buildCclineInput(snapshot({ totals: undefined }));
60
+ assert.equal("cost" in input, false);
61
+ assert.equal("context_window" in input, true);
62
+ const contextWindow = input.context_window as Record<string, unknown>;
63
+ assert.equal(contextWindow.used_percentage, 15.4);
64
+ });
65
+
66
+ test("sends only the live context, never cumulative token totals", () => {
67
+ // ccline prefers total_input_tokens over current_usage, which would show a
68
+ // token count that disagrees with used_percentage.
69
+ const contextWindow = buildCclineInput(snapshot()).context_window as Record<string, unknown>;
70
+ assert.equal("total_input_tokens" in contextWindow, false);
71
+ assert.equal("total_output_tokens" in contextWindow, false);
72
+ assert.deepEqual(contextWindow.current_usage, { input_tokens: 42_000 });
73
+ });
74
+
75
+ test("keeps a partial context window when usage is unknown", () => {
76
+ const input = buildCclineInput(snapshot({ contextUsage: { tokens: null, contextWindow: 272_000, percent: null } }));
77
+ const contextWindow = input.context_window as Record<string, unknown>;
78
+ assert.equal(contextWindow.context_window_size, 272_000);
79
+ assert.equal("current_usage" in contextWindow, false);
80
+ assert.equal("used_percentage" in contextWindow, false);
81
+ });
82
+
83
+ test("falls back to the model context window and drops the session when unset", () => {
84
+ const input = buildCclineInput(snapshot({ contextUsage: undefined, session: {} }));
85
+ const contextWindow = input.context_window as Record<string, unknown>;
86
+ assert.equal(contextWindow.context_window_size, 272_000);
87
+ assert.equal("session_id" in input, false);
88
+ assert.equal("session_name" in input, false);
89
+ });
90
+
91
+ test("uses the model id as display_name when no name is available", () => {
92
+ const input = buildCclineInput(snapshot({ model: { id: "some-model" } }));
93
+ assert.deepEqual(input.model, { id: "some-model", display_name: "some-model" });
94
+ });
95
+
96
+ test("appends the provider to the model name", () => {
97
+ const input = buildCclineInput(snapshot({ model: { id: "claude-sonnet-4-5", name: "Sonnet 4.5", provider: "anthropic" } }));
98
+ assert.deepEqual(input.model, { id: "claude-sonnet-4-5", display_name: "Sonnet 4.5 (anthropic)" });
99
+ });
100
+
101
+ test("appends the provider to an id-only model and skips a duplicate suffix", () => {
102
+ const idOnly = buildCclineInput(snapshot({ model: { id: "gpt-5.1", provider: "openai-codex" } }));
103
+ assert.deepEqual(idOnly.model, { id: "gpt-5.1", display_name: "gpt-5.1 (openai-codex)" });
104
+
105
+ const already = buildCclineInput(snapshot({ model: { id: "m", name: "Sonnet 4.5 (anthropic)", provider: "anthropic" } }));
106
+ assert.deepEqual(already.model, { id: "m", display_name: "Sonnet 4.5 (anthropic)" });
107
+ });
108
+
109
+ test("showProvider false or an unknown provider keeps the bare name", () => {
110
+ const model = { id: "m", name: "Sonnet 4.5", provider: "anthropic" };
111
+ assert.deepEqual(buildCclineInput(snapshot({ model }), { showProvider: false }).model, {
112
+ id: "m",
113
+ display_name: "Sonnet 4.5",
114
+ });
115
+ assert.deepEqual(buildCclineInput(snapshot({ model: { id: "m", name: "Sonnet 4.5", provider: " " } })).model, {
116
+ id: "m",
117
+ display_name: "Sonnet 4.5",
118
+ });
119
+ });
120
+
121
+ test("omits blank strings instead of sending empty values", () => {
122
+ const input = buildCclineInput(snapshot({ cwd: " ", session: { id: " ", name: "" } }));
123
+ assert.equal("cwd" in input, false);
124
+ assert.equal("workspace" in input, false);
125
+ assert.equal("session_id" in input, false);
126
+ });
127
+
128
+ function entry(partial: BranchEntityInput): BranchEntryLike {
129
+ return partial as BranchEntryLike;
130
+ }
131
+
132
+ type BranchEntityInput = {
133
+ type?: string;
134
+ timestamp?: string;
135
+ message?: { role?: string; usage?: Record<string, unknown> };
136
+ };
137
+
138
+ function fakeContext(branch: BranchEntryLike[], overrides: Partial<SnapshotContextLike> = {}): SnapshotContextLike {
139
+ return {
140
+ cwd: "/work/project",
141
+ model: { id: "m", name: "M", contextWindow: 200_000 },
142
+ thinkingLevel: "low",
143
+ getContextUsage: () => ({ tokens: 1000, contextWindow: 200_000, percent: 0.5 }),
144
+ sessionManager: {
145
+ getBranch: () => branch,
146
+ getSessionId: () => "sess-9",
147
+ getSessionName: () => "named",
148
+ },
149
+ ...overrides,
150
+ };
151
+ }
152
+
153
+ test("collectSnapshot sums only assistant usage on the branch", () => {
154
+ const branch: BranchEntryLike[] = [
155
+ entry({ type: "message", timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user" } }),
156
+ entry({
157
+ type: "message",
158
+ timestamp: "2026-01-01T00:00:10.000Z",
159
+ message: {
160
+ role: "assistant",
161
+ usage: { input: 100, output: 20, cacheRead: 30, cacheWrite: 5, cost: { total: 0.5 } },
162
+ },
163
+ }),
164
+ entry({
165
+ type: "message",
166
+ timestamp: "2026-01-01T00:00:20.000Z",
167
+ message: {
168
+ role: "assistant",
169
+ usage: { input: 200, output: 40, cacheRead: 0, cacheWrite: 0, cost: { total: 0.25 } },
170
+ },
171
+ }),
172
+ entry({ type: "custom", timestamp: "2026-01-01T00:00:30.000Z" }),
173
+ ];
174
+
175
+ const snapshotValue = collectSnapshot(fakeContext(branch), Date.parse("2026-01-01T00:10:00.000Z"));
176
+ assert.deepEqual(snapshotValue.totals, {
177
+ costUsd: 0.75,
178
+ durationMs: 600_000,
179
+ });
180
+ assert.equal(snapshotValue.session?.id, "sess-9");
181
+ assert.equal(snapshotValue.session?.name, "named");
182
+ assert.equal(snapshotValue.contextUsage?.tokens, 1000);
183
+ });
184
+
185
+ test("collectSnapshot reports no totals for an empty branch", () => {
186
+ const snapshotValue = collectSnapshot(fakeContext([]), Date.now());
187
+ assert.equal(snapshotValue.totals, undefined);
188
+ assert.equal(snapshotValue.session?.name, "named");
189
+ });
190
+
191
+ test("collectSnapshot carries the provider from ctx.model", () => {
192
+ const ctx = fakeContext([], { model: { id: "m", name: "M", provider: "anthropic", contextWindow: 200_000 } });
193
+ const snapshotValue = collectSnapshot(ctx, Date.now());
194
+ assert.equal(snapshotValue.model?.provider, "anthropic");
195
+ assert.equal((buildCclineInput(snapshotValue).model as Record<string, string>).display_name, "M (anthropic)");
196
+ });
197
+
198
+ test("collectSnapshot survives throwing context accessors", () => {
199
+ const ctx = fakeContext([], {
200
+ getContextUsage: () => {
201
+ throw new Error("boom");
202
+ },
203
+ });
204
+ const snapshotValue = collectSnapshot(ctx, Date.now());
205
+ assert.equal(snapshotValue.contextUsage, undefined);
206
+ assert.equal(buildCclineInput(snapshotValue).context_window !== undefined, true);
207
+ });