@erichll/pi-statusline 0.0.0-stage → 0.2.0

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,62 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { RenderTheme } from "./render.ts";
3
+ import { cleanText } from "./text.ts";
4
+ import type { StatuslineConfig, StatuslineSnapshot } from "./types.ts";
5
+
6
+ export function seconds(ms: number): string { return `${Math.floor(ms / 1000)}s`; }
7
+ export function elapsed(ms: number): string {
8
+ let remaining = Math.max(0, Math.floor(ms / 1000));
9
+ const parts: string[] = [];
10
+ for (const [unit, size] of [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]] as const) {
11
+ const value = Math.floor(remaining / size); remaining %= size;
12
+ if (value || parts.length || unit === "s") parts.push(`${value}${unit}`);
13
+ }
14
+ return parts.join(" ");
15
+ }
16
+ interface Part { id: string; text: string; color?: "text" | "muted" | "warning" | "error" | "success"; styled?: string }
17
+
18
+ /** Layout consumes snapshots only; width decisions use terminal cells, including ANSI. */
19
+ export function renderRuntime(width: number, data: StatuslineSnapshot, config: StatuslineConfig, theme: RenderTheme, extensionStatus?: string, elapsedStatus?: string): string | undefined {
20
+ const parts: Part[] = [];
21
+ const runtime = data.runtime, compact = data.compaction;
22
+ const enabled = config.runtime.enabled;
23
+ const add = (id: string, text: string, color?: Part["color"]) => parts.push({ id, text, color });
24
+ if (elapsedStatus) add("elapsed", elapsedStatus);
25
+ else if (enabled && config.runtime.status && runtime?.runId) add("elapsed", `Elapsed ${elapsed(runtime.elapsedMs)}`);
26
+ if (enabled && compact?.active) {
27
+ add("status", `${config.icons === "ascii" ? "Compacting..." : "Compacting…"} ${seconds(compact.elapsedMs)}`, "warning");
28
+ if (config.runtime.counts) add("compactions", `Compactions ${compact.count}`);
29
+ } else {
30
+ if (enabled && runtime) {
31
+ if (config.runtime.status) {
32
+ const failure = compact?.showFailure && runtime.status !== "Running" ? `Compaction ${compact.last?.status}` : undefined;
33
+ if (failure) add("status", failure, "error");
34
+ }
35
+ let busy = false;
36
+ if (runtime.awaitingInput) { add("activity", "Awaiting input", "warning"); busy = true; }
37
+ else if (config.runtime.tools && runtime.tools.length) {
38
+ add("activity", runtime.tools.length > 1 ? `Tools ${runtime.tools.length} active` : `Tool ${cleanText(runtime.tools[0]!.name).slice(0, 40)} ${seconds(runtime.tools[0]!.elapsedMs)}`);
39
+ busy = true;
40
+ } else if (config.runtime.noContent && runtime.noContentMs !== null && runtime.noContentMs >= config.runtime.noContentSeconds * 1000) {
41
+ add("activity", `No content ${seconds(runtime.noContentMs)}`); busy = true;
42
+ }
43
+ if (!busy) {
44
+ if (config.runtime.avg && runtime.avg !== null) add("avg", `Avg ${Math.round(runtime.avg)} tok/s`);
45
+ if (config.runtime.cache && runtime.cache !== null) add("cache", `Cache ${Math.round(runtime.cache * 100)}%`);
46
+ }
47
+ if (config.runtime.counts && runtime.runId) add("turns", `Turns ${runtime.turns}`);
48
+ }
49
+ if (enabled && config.runtime.counts && compact) add("compactions", `Compactions ${compact.count}`);
50
+ if (extensionStatus) parts.push({ id: "statuses", text: extensionStatus, styled: extensionStatus });
51
+ }
52
+ const separator = config.icons === "ascii" ? " | " : " · ";
53
+ const draw = () => parts.map((part) => part.styled ?? theme.style(part.text, { fg: theme.colors[part.color ?? "muted"] })).join(theme.style(separator, { fg: theme.colors.dim }));
54
+ let line = draw();
55
+ for (const id of ["statuses", "cache", "avg", "compactions", "turns", "activity"]) {
56
+ if (visibleWidth(line) <= width) break;
57
+ const index = parts.findIndex((part) => part.id === id);
58
+ if (index !== -1) parts.splice(index, 1);
59
+ line = draw();
60
+ }
61
+ return parts.length ? truncateToWidth(line, width, config.icons === "ascii" ? "..." : "…") : undefined;
62
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { AssistantMessage, AssistantMessageEvent } from "@earendil-works/pi-ai";
3
+ import type { AgentActivityOutcome } from "@earendil-works/pi-coding-agent";
4
+ import { cleanText } from "./text.ts";
5
+ import { emptyUsage, readUsage } from "./usage.ts";
6
+ import type { UsageTotals } from "./types.ts";
7
+
8
+ export type Clock = () => number;
9
+ export interface RuntimeSnapshot {
10
+ runId: string | null;
11
+ status: "Idle" | "Running" | "Done" | "Cancelled" | "Failed";
12
+ elapsedMs: number;
13
+ turns: number;
14
+ toolCalls: number;
15
+ tools: { name: string; elapsedMs: number }[];
16
+ awaitingInput: boolean;
17
+ noContentMs: number | null;
18
+ usage: UsageTotals | null;
19
+ avg: number | null;
20
+ cache: number | null;
21
+ }
22
+
23
+ /** A run survives agent_end, retries and boundary continuations until agent_settled. */
24
+ export class RuntimeState {
25
+ private clock: Clock;
26
+ private startMs = 0;
27
+ private endMs = 0;
28
+ private runId: string | null = null;
29
+ private state: RuntimeSnapshot["status"] = "Idle";
30
+ private outcome: AgentActivityOutcome = "completed";
31
+ private aborted = false;
32
+ private turns = 0;
33
+ private tools = new Map<string, { name: string; start: number }>();
34
+ private seenTools = new Set<string>();
35
+ private prompts = 0;
36
+ private modelActive = false;
37
+ private compacting = false;
38
+ private lastContent = 0;
39
+ private messages = new Map<string, UsageTotals>();
40
+ private activeMessage: string | undefined;
41
+ private messageAliases = new Map<string, string>();
42
+ private sequence = 0;
43
+ constructor(clock: Clock = () => performance.now()) { this.clock = clock; }
44
+ get running(): boolean { return this.state === "Running"; }
45
+ get id(): string | null { return this.runId; }
46
+ reset(): void {
47
+ this.runId = null; this.state = "Idle"; this.startMs = 0; this.endMs = 0;
48
+ this.outcome = "completed"; this.aborted = false; this.turns = 0; this.tools.clear(); this.seenTools.clear();
49
+ this.prompts = 0; this.modelActive = false; this.compacting = false;
50
+ this.messages.clear(); this.messageAliases.clear(); this.activeMessage = undefined; this.sequence = 0;
51
+ }
52
+ start(): boolean {
53
+ if (this.running) return false;
54
+ const prompts = this.prompts;
55
+ this.reset(); this.prompts = prompts;
56
+ this.runId = randomUUID(); this.startMs = this.clock(); this.lastContent = this.startMs; this.state = "Running";
57
+ return true;
58
+ }
59
+ beforeSettle(outcome: AgentActivityOutcome): void { if (this.running && !this.aborted) this.outcome = outcome; }
60
+ observeAbort(): void { if (this.running) { this.aborted = true; this.outcome = "aborted"; } }
61
+ settle(): void {
62
+ if (!this.running) return;
63
+ this.endMs = this.clock();
64
+ this.state = { completed: "Done", aborted: "Cancelled", error: "Failed" }[this.outcome] as RuntimeSnapshot["status"];
65
+ this.modelActive = false; this.tools.clear(); this.activeMessage = undefined;
66
+ }
67
+ turnStart(): void {
68
+ if (!this.running) return;
69
+ // A continuation/retry supersedes the previous attempt's observed outcome.
70
+ this.outcome = "completed"; this.aborted = false;
71
+ this.turns++; this.modelActive = true; this.lastContent = this.clock(); this.activeMessage = undefined;
72
+ }
73
+ turnEnd(): void { this.modelActive = false; }
74
+ content(): void { if (this.running && !this.compacting) this.lastContent = this.clock(); }
75
+ stream(event: AssistantMessageEvent): void {
76
+ if ((event.type === "text_delta" || event.type === "thinking_delta" || event.type === "toolcall_delta") && event.delta.length > 0) this.content();
77
+ // A tool name/ID in the initial block is content even before JSON deltas arrive.
78
+ if (event.type === "toolcall_start") {
79
+ const block = event.partial.content[event.contentIndex];
80
+ if (block?.type === "toolCall" && (block.name || block.id)) this.content();
81
+ }
82
+ }
83
+ private alias(message: AssistantMessage): string { return `${message.provider}/${message.model}/${message.timestamp}`; }
84
+ messageStart(message: AssistantMessage): void {
85
+ if (!this.running || this.compacting) return;
86
+ this.activeMessage = String(++this.sequence);
87
+ this.messageAliases.set(this.alias(message), this.activeMessage);
88
+ }
89
+ message(message: AssistantMessage, final = false): void {
90
+ if (!this.running || this.compacting) return;
91
+ const alias = this.alias(message);
92
+ const responseAlias = message.responseId ? `${message.provider}/${message.model}/response:${message.responseId}` : undefined;
93
+ const key = (responseAlias ? this.messageAliases.get(responseAlias) : undefined) ?? this.messageAliases.get(alias) ?? this.activeMessage ?? String(++this.sequence);
94
+ this.messageAliases.set(alias, key);
95
+ if (responseAlias) this.messageAliases.set(responseAlias, key);
96
+ // Missing usage stays unavailable. Zeroes explicitly supplied by Pi are valid.
97
+ if (message.usage && [message.usage.input, message.usage.output, message.usage.cacheRead, message.usage.cacheWrite].every((n) => Number.isFinite(n) && n >= 0)) {
98
+ this.messages.set(key, readUsage(message.usage));
99
+ }
100
+ if (final) {
101
+ this.modelActive = false;
102
+ if (!this.aborted) this.outcome = message.stopReason === "aborted" ? "aborted" : message.stopReason === "error" ? "error" : "completed";
103
+ }
104
+ }
105
+ toolStart(id: string, name: string): void {
106
+ if (!this.running || this.seenTools.has(id)) return;
107
+ this.seenTools.add(id); this.tools.set(id, { name: cleanText(name).slice(0, 40) || "unknown", start: this.clock() });
108
+ }
109
+ toolEnd(id: string): void { this.tools.delete(id); }
110
+ uiStart(): void { this.prompts++; }
111
+ uiEnd(): void { this.prompts = Math.max(0, this.prompts - 1); this.lastContent = this.clock(); }
112
+ compaction(active: boolean): void { this.compacting = active; this.lastContent = this.clock(); }
113
+ snapshot(retainSummary = true): RuntimeSnapshot {
114
+ const now = this.clock();
115
+ const hidden = !retainSummary && !this.running;
116
+ const elapsedMs = hidden || !this.runId ? 0 : Math.max(0, (this.running ? now : this.endMs) - this.startMs);
117
+ const usage = !hidden && this.messages.size ? emptyUsage() : null;
118
+ if (usage) for (const contribution of this.messages.values()) for (const key of Object.keys(usage) as (keyof UsageTotals)[]) usage[key] += contribution[key];
119
+ const denominator = usage ? usage.input + usage.cacheRead + usage.cacheWrite : 0;
120
+ return {
121
+ runId: hidden ? null : this.runId, status: hidden ? "Idle" : this.state, elapsedMs,
122
+ turns: hidden ? 0 : this.turns, toolCalls: hidden ? 0 : this.seenTools.size,
123
+ tools: [...this.tools.values()].map((tool) => ({ name: tool.name, elapsedMs: Math.max(0, now - tool.start) })),
124
+ awaitingInput: this.prompts > 0,
125
+ noContentMs: this.running && this.modelActive && !this.tools.size && !this.prompts && !this.compacting ? Math.max(0, now - this.lastContent) : null,
126
+ usage, avg: usage && elapsedMs > 0 ? usage.output / (elapsedMs / 1000) : null,
127
+ cache: usage && denominator > 0 ? usage.cacheRead / denominator : null,
128
+ };
129
+ }
130
+ }
package/src/session.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { homedir } from "node:os";
2
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
3
+ import type { ExtensionContext, ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent";
4
+ import type { TUI } from "@earendil-works/pi-tui";
5
+ import { emptyGit, GitPoller, type GitReader } from "./git.ts";
6
+ import { renderFooter } from "./render.ts";
7
+ import type { StatuslineConfig, StatuslineSnapshot } from "./types.ts";
8
+ import { UsageCache } from "./usage.ts";
9
+ import { RuntimeState } from "./runtime.ts";
10
+ import { CompactionState } from "./compaction.ts";
11
+
12
+ export class FooterSession {
13
+ readonly runtime = new RuntimeState();
14
+ readonly compaction = new CompactionState();
15
+ private ctx: ExtensionContext;
16
+ private config: StatuslineConfig;
17
+ private usage = new UsageCache();
18
+ private git = emptyGit();
19
+ private provider: ReadonlyFooterDataProvider | undefined;
20
+ private tui: Pick<TUI, "requestRender"> | undefined;
21
+ private poller: GitPoller | undefined;
22
+ private timer: ReturnType<typeof setInterval> | undefined;
23
+ private scheduled: ReturnType<typeof setTimeout> | undefined;
24
+ private unsubscribe: (() => void) | undefined;
25
+ private closed = false;
26
+ private ownsFooter = false;
27
+ private statsKey = "";
28
+ private displayKey = "";
29
+ private context: StatuslineSnapshot["context"] = { tokens: null, contextWindow: 0, percent: null };
30
+ private readonly gitReader: GitReader | undefined;
31
+ private signal: AbortSignal | undefined;
32
+ private readonly onAbort = () => { this.runtime.observeAbort(); this.schedule(true); };
33
+
34
+ constructor(ctx: ExtensionContext, config: StatuslineConfig, gitReader?: GitReader) {
35
+ this.ctx = ctx; this.config = config; this.gitReader = gitReader;
36
+ }
37
+ get active(): boolean { return this.ownsFooter && !this.closed; }
38
+ setContext(ctx: ExtensionContext): void { this.ctx = ctx; this.watchSignal(); }
39
+ private watchSignal(): void {
40
+ if (this.closed || !this.ownsFooter || !this.runtime.running) return;
41
+ const signal = this.ctx.signal;
42
+ if (!signal || signal === this.signal) return;
43
+ this.clearSignal(); this.signal = signal;
44
+ if (signal.aborted) this.onAbort();
45
+ else signal.addEventListener("abort", this.onAbort, { once: true });
46
+ }
47
+ private clearSignal(): void { this.signal?.removeEventListener("abort", this.onAbort); this.signal = undefined; }
48
+ startRun(): void {
49
+ this.compaction.activity();
50
+ this.runtime.start();
51
+ this.watchSignal();
52
+ }
53
+ startTurn(): void {
54
+ this.compaction.activity(); this.runtime.turnStart(); this.watchSignal();
55
+ }
56
+ settle(): void { this.runtime.settle(); this.clearSignal(); }
57
+ install(): void {
58
+ if (this.closed || this.ctx.mode !== "tui" || !this.config.enabled) return;
59
+ this.ctx.ui.setFooter((tui, _theme, provider) => {
60
+ this.ownsFooter = true; this.tui = tui; this.provider = provider;
61
+ this.watchSignal();
62
+ this.poller = new GitPoller(this.ctx.cwd, (git) => { this.git = git; this.repaint(); }, this.gitReader);
63
+ this.unsubscribe = provider.onBranchChange(() => this.requestGit());
64
+ if (this.config.segments.some((segment) => segment.id === "git" && segment.enabled)) this.poller.start();
65
+ this.refresh(true);
66
+ // Covers usage entries appended outside normal message events (e.g. cache warming).
67
+ this.timer = setInterval(() => this.refresh(), 1000);
68
+ this.timer.unref();
69
+ return {
70
+ render: (width: number) => renderFooter(width, this.snapshot(), this.config, this.ctx.ui.theme),
71
+ invalidate: () => {}, // Theme and extension statuses are read fresh on every render.
72
+ dispose: () => this.stopResources(),
73
+ };
74
+ });
75
+ }
76
+ private stopResources(): void {
77
+ this.clearSignal();
78
+ clearInterval(this.timer); clearTimeout(this.scheduled);
79
+ this.timer = undefined; this.scheduled = undefined;
80
+ this.poller?.dispose(); this.poller = undefined;
81
+ this.unsubscribe?.(); this.unsubscribe = undefined;
82
+ this.ownsFooter = false; this.tui = undefined; this.provider = undefined;
83
+ }
84
+ disable(): void {
85
+ const restore = this.ownsFooter;
86
+ this.stopResources();
87
+ if (restore && !this.closed) this.ctx.ui.setFooter(undefined);
88
+ }
89
+ dispose(): void { this.stopResources(); this.clearSignal(); this.closed = true; }
90
+ apply(config: StatuslineConfig): void {
91
+ if (this.closed) return;
92
+ this.disable(); this.config = config;
93
+ if (config.enabled) this.install();
94
+ }
95
+ setLive(message: AssistantMessage | undefined): void { this.usage.setLive(message); }
96
+ schedule(immediate = false): void {
97
+ if (!this.active) return;
98
+ if (immediate) { clearTimeout(this.scheduled); this.scheduled = undefined; }
99
+ if (this.scheduled) return;
100
+ // Defer final events too: Pi emits message_end before appending the message.
101
+ this.scheduled = setTimeout(() => { this.scheduled = undefined; this.refresh(); }, immediate ? 0 : 100);
102
+ this.scheduled.unref();
103
+ }
104
+ requestGit(): void {
105
+ if (this.active && this.config.segments.some((segment) => segment.id === "git" && segment.enabled)) void this.poller?.refresh();
106
+ }
107
+ refresh(force = false): void {
108
+ if (this.closed) return;
109
+ const manager = this.ctx.sessionManager;
110
+ const key = `${manager.getSessionId()}:${manager.getLeafId()}:${this.ctx.model?.provider}/${this.ctx.model?.id}/${this.ctx.model?.contextWindow}`;
111
+ if (force || key !== this.statsKey) {
112
+ const entries = manager.getEntries();
113
+ this.usage.update(entries);
114
+ this.compaction.update(entries);
115
+ const context = this.ctx.getContextUsage();
116
+ this.context = context ?? { tokens: null, contextWindow: this.ctx.model?.contextWindow ?? 0, percent: null };
117
+ }
118
+ this.statsKey = key;
119
+ this.repaint();
120
+ }
121
+ private repaint(): void {
122
+ if (!this.active) return;
123
+ const snapshot = this.snapshot();
124
+ const key = JSON.stringify({ ...snapshot, statuses: undefined });
125
+ if (key === this.displayKey) return;
126
+ this.displayKey = key; this.tui?.requestRender();
127
+ }
128
+ snapshot(): StatuslineSnapshot {
129
+ return {
130
+ model: this.ctx.model?.name || this.ctx.model?.id || "No model", modelId: this.ctx.model?.id ?? "", reasoning: this.ctx.model?.reasoning ?? false,
131
+ thinking: this.ctx.thinkingLevel ?? "off", cwd: this.ctx.cwd, home: homedir(),
132
+ context: this.context, usage: this.usage.snapshot(), git: this.git,
133
+ statuses: this.provider?.getExtensionStatuses() ?? new Map(),
134
+ runtime: this.runtime.snapshot(this.config.runtime.retainSummary), compaction: this.compaction.snapshot(),
135
+ };
136
+ }
137
+ }
package/src/text.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
2
+
3
+ export function cleanText(text: string): string {
4
+ return stripTerminalSequences(text.replace(/[\r\n\t]/g, " ")).replace(/[\p{Cc}\u202a-\u202e\u2066-\u2069]/gu, "").trim();
5
+ }
6
+ /** Details never echo URLs, credentials or unbounded provider errors. */
7
+ export function safeReason(text: string): string {
8
+ return cleanText(text).replace(/https?:\/\/\S+/gi, "[URL]")
9
+ .replace(/\b(Bearer|Basic)\s+\S+/gi, "$1 [redacted]")
10
+ .replace(/\b(api[-_]?key|token|authorization|password|secret)\s*[:=]\s*\S+/gi, "$1=[redacted]").slice(0, 180);
11
+ }
package/src/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const SEGMENT_IDS = ["model", "thinking", "context", "directory", "git", "cost", "input", "output", "tokens", "cacheRead", "cacheWrite", "statuses"] as const;
4
+ export type SegmentId = typeof SEGMENT_IDS[number];
5
+ export type ColorValue = ThemeColor | `#${string}`;
6
+ export type Preset = "powerline";
7
+ export type IconMode = "unicode" | "nerd" | "ascii";
8
+ export interface RuntimeConfig {
9
+ enabled: boolean;
10
+ status: boolean;
11
+ tools: boolean;
12
+ noContent: boolean;
13
+ noContentSeconds: number;
14
+ avg: boolean;
15
+ cache: boolean;
16
+ counts: boolean;
17
+ retainSummary: boolean;
18
+ }
19
+ export interface SegmentConfig {
20
+ id: SegmentId;
21
+ enabled: boolean;
22
+ icon?: string;
23
+ color?: ColorValue;
24
+ iconColor?: ColorValue;
25
+ background?: ColorValue;
26
+ }
27
+ export interface StatuslineConfig {
28
+ version: 1;
29
+ enabled: boolean;
30
+ preset: Preset;
31
+ icons: IconMode;
32
+ separator: "pipe" | "dot" | "space" | "powerline";
33
+ pathMode: "basename" | "abbreviated" | "full";
34
+ modelDisplay: "name" | "last";
35
+ contextDisplay: "text" | "bar";
36
+ segments: SegmentConfig[];
37
+ runtime: RuntimeConfig;
38
+ }
39
+ export interface UsageTotals {
40
+ input: number;
41
+ output: number;
42
+ cacheRead: number;
43
+ cacheWrite: number;
44
+ cost: number;
45
+ }
46
+ export interface GitState {
47
+ kind: "repository" | "none" | "unknown";
48
+ branch: string | null;
49
+ detached: boolean;
50
+ staged: number;
51
+ unstaged: number;
52
+ untracked: number;
53
+ conflicts: number;
54
+ ahead: number;
55
+ behind: number;
56
+ }
57
+ export interface StatuslineSnapshot {
58
+ model: string;
59
+ modelId: string;
60
+ reasoning: boolean;
61
+ thinking: string;
62
+ cwd: string;
63
+ home: string;
64
+ context: { tokens: number | null; contextWindow: number; percent: number | null };
65
+ usage: UsageTotals;
66
+ git: GitState;
67
+ statuses: ReadonlyMap<string, string>;
68
+ runtime?: import("./runtime.ts").RuntimeSnapshot;
69
+ compaction?: import("./compaction.ts").CompactionSnapshot;
70
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import type { UsageTotals } from "./types.ts";
4
+
5
+ export function emptyUsage(): UsageTotals { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; }
6
+ const finite = (value: unknown): number => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
7
+ export function readUsage(value: unknown): UsageTotals {
8
+ if (!value || typeof value !== "object") return emptyUsage();
9
+ const usage = value as Record<string, unknown>;
10
+ const cost = usage.cost && typeof usage.cost === "object" ? (usage.cost as Record<string, unknown>).total : 0;
11
+ return { input: finite(usage.input), output: finite(usage.output), cacheRead: finite(usage.cacheRead), cacheWrite: finite(usage.cacheWrite), cost: finite(cost) };
12
+ }
13
+ export function entryUsage(entry: SessionEntry): UsageTotals {
14
+ if (entry.type === "usage" || entry.type === "compaction" || entry.type === "branch_summary") return readUsage(entry.usage);
15
+ if (entry.type === "message" && (entry.message.role === "assistant" || entry.message.role === "toolResult")) return readUsage(entry.message.usage);
16
+ return emptyUsage();
17
+ }
18
+ function add(target: UsageTotals, value: UsageTotals, sign = 1): void {
19
+ for (const key of Object.keys(target) as (keyof UsageTotals)[]) target[key] += sign * value[key];
20
+ }
21
+ function messageKey(message: AssistantMessage): string { return `${message.provider}/${message.model}/${message.timestamp}`; }
22
+
23
+ /** Persisted totals plus one replaceable in-flight response, never summed per streaming event. */
24
+ export class UsageCache {
25
+ private totals = emptyUsage();
26
+ private contributions = new Map<string, UsageTotals>();
27
+ private persistedMessages = new Set<string>();
28
+ private count = 0;
29
+ private live: AssistantMessage | undefined;
30
+ reset(): void {
31
+ this.totals = emptyUsage(); this.contributions.clear(); this.persistedMessages.clear(); this.count = 0; this.live = undefined;
32
+ }
33
+ update(entries: readonly SessionEntry[], rebuild = false): void {
34
+ if (rebuild || entries.length < this.count) this.reset();
35
+ // SessionManager appends immutable records; revisit the previous tail as a defensive measure.
36
+ for (const entry of entries.slice(Math.max(0, this.count - 1))) {
37
+ const previous = this.contributions.get(entry.id);
38
+ if (previous) add(this.totals, previous, -1);
39
+ const usage = entryUsage(entry);
40
+ add(this.totals, usage);
41
+ this.contributions.set(entry.id, usage);
42
+ if (entry.type === "message" && entry.message.role === "assistant") this.persistedMessages.add(messageKey(entry.message));
43
+ }
44
+ this.count = entries.length;
45
+ }
46
+ setLive(message: AssistantMessage | undefined): void { this.live = message; }
47
+ snapshot(): UsageTotals {
48
+ const total = { ...this.totals };
49
+ if (this.live && !this.persistedMessages.has(messageKey(this.live))) add(total, readUsage(this.live.usage));
50
+ return total;
51
+ }
52
+ }