@erichll/pi-statusline 0.0.0-stage → 0.1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-10-03
4
+
5
+ - Make bar mode the default and use whole cells to avoid visual gaps from partial glyphs; non-zero usage fills at least one cell.
6
+ - Place context immediately after the model in preset layouts and add a configurable context progress bar (`contextDisplay: "bar"`).
7
+ - Hide the cost segment by default in every preset; it can still be enabled in settings.
8
+ - Use an independent rainbow palette so themes with repeated semantic colors still display distinct thinking-level hues.
9
+ - Use `` as the default model glyph and append the thinking level in parentheses, coloring the entire `(level)` with per-level colors and rainbow high/xhigh/max.
10
+ - Move thinking controls into the model settings; retain existing `thinking` enablement/color settings for compatibility.
11
+ - Move configuration to `extensions/pi-statusline/config.json` under Pi's agent directory.
12
+ - Compact context usage to `used/capacity(percent%)`, removing separator padding.
13
+ - Add `modelDisplay: "last"` to show only the final component of a slash-separated model ID, configurable in the preview panel.
14
+ - Add a native Pi footer with configurable presets.
15
+ - Add configurable segments, Unicode/Nerd Font/ASCII icons, colors and path display.
16
+ - Add a keyboard-driven configuration panel using the same renderer as the live footer.
17
+ - Add asynchronous Git state, native session usage accounting and extension-status display.
18
+ - Handle narrow terminals, session replacement, compaction and non-interactive modes.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,196 @@
1
- # Temporary Holding Version
1
+ # @erichll/pi-statusline
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A configurable footer for Pi 1.0 with Git status, session usage, themes, and live configuration preview. Uses Pi's native footer and leaves the editor intact.
4
+
5
+ ```text
6
+  Model(high) | ◉ [█░░░░░░░░░]11.9% | ▸ pi-packages | ⑂ main ● *2 ↑2
7
+ extension status text, when available
8
+ ```
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ pi install npm:@erichll/pi-statusline
14
+ ```
15
+
16
+ Requires Node.js >=22.19, Pi >=1.0.0, and optionally Git. The package has no
17
+ other runtime dependencies and makes no network requests or credential lookups.
18
+ Only one extension should own Pi's footer, so disable any other footer
19
+ extension in `pi config` before enabling this one.
20
+
21
+ ## Local use
22
+
23
+ From this repository, try the extension for one invocation:
24
+
25
+ ```sh
26
+ npm install
27
+ pi --no-extensions --extension ./packages/pi-statusline/src/index.ts
28
+ ```
29
+
30
+ `--no-extensions` disables other auto-discovered extensions for this invocation.
31
+ To use your other extensions, disable `pi-powerline-footer` in `pi config`
32
+ before starting Pi with `--extension ./packages/pi-statusline/src/index.ts`.
33
+ Only one extension should own the footer. The previous package can remain
34
+ installed for rollback. Loading this repository as a whole also discovers the
35
+ security extensions; use the statusline entrypoint to try it on its own.
36
+
37
+ This checkout is for development only. For a persistent install use the
38
+ published package, `npm:@erichll/pi-statusline`.
39
+
40
+ ## Commands
41
+
42
+ | Command | Behavior |
43
+ | --- | --- |
44
+ | `/statusline` | Open configuration and live preview |
45
+ | `/statusline on` | Enable for this session |
46
+ | `/statusline off` | Restore Pi's native footer for this session |
47
+ | `/statusline reload` | Reload saved configuration, including startup enablement |
48
+
49
+ The panel supports arrow navigation, Space to toggle a segment, Alt+Up/Down to
50
+ reorder it, and Enter to edit its properties. Choose **Save and apply globally**
51
+ to persist the draft. Esc backs out of a property editor or cancels the panel.
52
+ **Restore defaults in preview** changes only the draft until saved.
53
+ Preview uses the real footer renderer and current session data, or clearly
54
+ labelled example data for an empty session. A save error leaves the active
55
+ configuration unchanged and keeps the panel open.
56
+
57
+ ## Configuration
58
+
59
+ Settings live in `extensions/pi-statusline/config.json` under Pi's agent directory,
60
+ normally `~/.pi/agent/extensions/pi-statusline/config.json`;
61
+ `PI_CODING_AGENT_DIR` is respected. The extension
62
+ does not write Pi's general `settings.json`. Configuration is global, with no
63
+ project override or file watcher. After manual changes use `/statusline reload`.
64
+ The configuration directory is created automatically when saving from the panel.
65
+ When upgrading from the previous `~/.pi/agent/statusline.json` location, move the
66
+ existing file to the new path before running `/reload`; the old path is no longer read.
67
+
68
+ ```json
69
+ {
70
+ "version": 1,
71
+ "enabled": true,
72
+ "preset": "cometix",
73
+ "icons": "unicode",
74
+ "separator": "pipe",
75
+ "pathMode": "basename",
76
+ "modelDisplay": "name",
77
+ "contextDisplay": "bar"
78
+ }
79
+ ```
80
+
81
+ - Presets: `cometix` (default), `minimal`, `powerline`. Choosing a preset in the
82
+ panel resets segment order and style overrides. Pi's current light/dark theme
83
+ supplies semantic colors; Powerline backgrounds adapt to its appearance.
84
+ - Icons: `unicode` (default), `nerd` (requires a Nerd Font), `ascii`. ASCII mode
85
+ replaces built-in decorative glyphs, not user-provided names or custom icons.
86
+ Both graphical modes use `` for the model by default; this glyph requires a
87
+ Nerd Font. A custom model icon still overrides the default.
88
+ - Separators: `pipe`, `dot`, `space`, `powerline`. ASCII mode uses plain separators.
89
+ - Paths: `basename`, `abbreviated`, `full` (home directory abbreviated as `~`).
90
+ - Model display: `name` (default, display name with full ID fallback) or `last`
91
+ (the final non-empty `/`-separated component of the **model ID**, even when a
92
+ display name is configured). For example, `provider/a/b/c/d` becomes `d`.
93
+ Set `"modelDisplay": "last"` or select **Model display: last** in the panel.
94
+ Switching presets preserves this preference; restoring defaults resets it.
95
+ - Context display: `bar` (default, `[██░░░░░░░░]20%`) or `text`
96
+ (`12.7k/272k(4.7%)`). Choose **Context display: bar** in the panel or set
97
+ `"contextDisplay": "bar"`. Switching presets preserves this preference.
98
+ The ten-cell bar uses whole blocks to avoid gaps inside partial glyphs. Fill
99
+ rounds to the nearest cell, with at least one filled cell for non-zero usage;
100
+ the percentage retains the more precise value. ASCII mode uses `#` and `-`.
101
+ Unknown usage shows `[??????????]?%`; values above 100% fill
102
+ the bar completely and retain the reported percentage. Both modes use the
103
+ existing context warning colors. Text mode includes token counts and capacity;
104
+ bar mode shows progress and percentage.
105
+ - `segments`: optional ordered array of `{ id, enabled, icon?, color?, iconColor?, background? }`.
106
+ An explicit list is authoritative: omitted segments are disabled but remain
107
+ available in the panel. The `statuses` segment always occupies the second row.
108
+ Preset layouts place context immediately after the model, before directory
109
+ and Git. Explicit saved segment order is respected and can be changed in the panel.
110
+ - Thinking is appended to the model as `(level)`, with no separate icon or
111
+ separator. In the panel, edit **model → Thinking level / Thinking color**.
112
+ The existing `thinking` configuration entry controls suffix enablement and an
113
+ optional color override; its position, icon and background are ignored. It
114
+ does not render on its own when the model is hidden or non-reasoning.
115
+ By default, `off`, `minimal`, `low`, and `medium` use their respective Pi theme
116
+ colors; `high`, `xhigh`, and `max` use a fixed violet/pink/orange/yellow/green/cyan/blue
117
+ palette independent of the Pi theme, distributed across the available characters.
118
+ The color or rainbow covers the entire `(level)`, including parentheses, and
119
+ the model's background covers the whole item.
120
+ - Icon `""` hides a glyph. In the panel, enter `:default` to restore its default.
121
+ Color fields accept `#RRGGBB` or `text`, `accent`, `muted`, `dim`, `success`,
122
+ `warning`, `error`, `border`, `borderAccent`, `borderMuted`, and Pi's
123
+ `thinkingOff/Minimal/Low/Medium/High/Xhigh/Max` tokens. Empty color input in the
124
+ panel restores the default. Explicit colors override automatic warning colors.
125
+
126
+ Available segments:
127
+
128
+ | ID | Data | Default |
129
+ | --- | --- | --- |
130
+ | `model` | Display name/full ID, or final ID component via `modelDisplay` | On |
131
+ | `thinking` | Controls the colored `(level)` suffix inside the model item | On |
132
+ | `context` | Text usage/capacity or a progress bar, followed by percentage | On |
133
+ | `directory` | Current working directory | On |
134
+ | `git` | Branch, clean/dirty/conflict, staged `+`, unstaged `*`, untracked `?`, ahead/behind | On |
135
+ | `cost` | Pi-recorded cumulative USD cost; enable in the panel when wanted | Off |
136
+ | `input`, `output`, `tokens` | Cumulative input, output, or total tokens | Off |
137
+ | `cacheRead`, `cacheWrite` | Cumulative cache token counts | Off |
138
+ | `statuses` | Other extensions' `setStatus()` text, with terminal controls stripped | On |
139
+
140
+ The minimal preset enables directory, Git, context, and extension statuses.
141
+ See [examples/statusline.json](examples/statusline.json) for an explicit layout.
142
+ Unknown versions, duplicate segments, invalid colors and malformed JSON are
143
+ rejected with a notification; reload retains the last working configuration.
144
+ Removing the file restores defaults on reload. Saves use an atomic file replacement.
145
+
146
+ ## Statistics and refresh behavior
147
+
148
+ Context comes from Pi's `getContextUsage()`. It is distinct from cumulative
149
+ token consumption. Unknown values, including immediately after compaction, show
150
+ `?` until Pi can provide an estimate. Yellow begins above 70%, red above 90%.
151
+ Pi 1.0 bases context estimates on persisted messages; in-flight usage updates
152
+ the cumulative token/cost segments while context refreshes after persistence.
153
+
154
+ Session totals include all usage already recorded in the current session file:
155
+ assistant messages, explicit usage entries, tool-result usage, compaction and
156
+ branch summaries, including pre-compaction and abandoned-branch consumption.
157
+ Streaming responses are provisional and counted once when persisted. The total
158
+ token segment sums input, output, cache-read and cache-write, matching Pi's
159
+ native session totals. Costs are **Pi-reported values, not provider invoices**;
160
+ zero means no cost recorded by Pi. No subscription-quota API or child-session
161
+ log is queried, and private `pi-subagents` result details are not parsed.
162
+
163
+ Git queries run asynchronously every two seconds and after tool/branch changes,
164
+ with a one-second timeout and optional index locks disabled. No repository hides
165
+ the segment; failures show `?`, never a false clean state. Large output above
166
+ 8 MiB also falls back to unknown. Worktrees, detached HEAD, unborn branches and
167
+ filenames containing newlines are supported. Disabling the Git segment stops
168
+ its polling.
169
+
170
+ Rendering performs no file reads, Git queries or full-session scans. Streaming
171
+ refresh requests are coalesced to 100 ms. Narrow terminals shorten model/path
172
+ labels, then hide cost, cache/token counters, the thinking suffix, Git and directory in
173
+ that order, retaining model/context where possible. The secondary status row
174
+ is truncated to fit. Reload, session replacement, disabling and shutdown
175
+ release timers and subscriptions. RPC, JSON and print mode do not install UI
176
+ components or start polling.
177
+
178
+ ## Development and rollback
179
+
180
+ ```sh
181
+ npm run check --workspace @erichll/pi-statusline
182
+ npm test --workspace @erichll/pi-statusline
183
+ # Optional: POSIX PTY integration, using an offline simulated model
184
+ npm run smoke --workspace @erichll/pi-statusline
185
+ ```
186
+
187
+ Tests cover rendering and panel interactions, configuration persistence, session
188
+ usage/lifecycle, and real temporary Git repositories. To roll back a persistent
189
+ installation, disable this extension in `pi config`, re-enable the previous
190
+ footer and reload Pi. `/statusline off` restores the native footer, not another
191
+ extension's editor or widgets.
192
+
193
+ The initial implementation is independently written, with design references to
194
+ [pi-powerline-footer](https://github.com/nicobailon/pi-powerline-footer).
195
+
196
+ MIT licensed.
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": 1,
3
+ "enabled": true,
4
+ "preset": "cometix",
5
+ "icons": "unicode",
6
+ "separator": "pipe",
7
+ "pathMode": "basename",
8
+ "modelDisplay": "name",
9
+ "contextDisplay": "bar",
10
+ "segments": [
11
+ { "id": "model", "enabled": true, "color": "accent" },
12
+ { "id": "thinking", "enabled": true },
13
+ { "id": "context", "enabled": true },
14
+ { "id": "directory", "enabled": true },
15
+ { "id": "git", "enabled": true },
16
+ { "id": "cost", "enabled": false },
17
+ { "id": "statuses", "enabled": true }
18
+ ]
19
+ }
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@erichll/pi-statusline",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Configurable Pi statusline with live preview, Git and session usage",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": ["pi-package", "pi-extension", "pi", "statusline"],
8
+ "repository": {"type": "git", "url": "git+https://github.com/erichll/pi-packages.git", "directory": "packages/pi-statusline"},
9
+ "publishConfig": {"access": "public"},
10
+ "engines": {"node": ">=22.19.0"},
11
+ "files": ["src", "README.md", "CHANGELOG.md", "LICENSE", "examples"],
12
+ "exports": {".": "./src/index.ts"},
13
+ "pi": {"extensions": ["./src/index.ts"]},
14
+ "scripts": {
15
+ "check": "tsc -p tsconfig.json --noEmit",
16
+ "test": "node --experimental-strip-types --test test/*.test.ts",
17
+ "smoke": "python3 test/pty-smoke.py"
18
+ },
19
+ "peerDependencies": {
20
+ "@earendil-works/pi-ai": "*",
21
+ "@earendil-works/pi-coding-agent": "*",
22
+ "@earendil-works/pi-tui": "*"
23
+ },
24
+ "devDependencies": {
25
+ "@earendil-works/pi-ai": "^1.0.0",
26
+ "@earendil-works/pi-coding-agent": "^1.0.0",
27
+ "@earendil-works/pi-tui": "^1.0.0",
28
+ "@types/node": "^26.6.4",
29
+ "typescript": "^7.0.2"
30
+ }
31
+ }
package/src/config.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import type { ColorValue, Preset, StatuslineConfig } from "./types.ts";
5
+ import { SEGMENT_IDS } from "./types.ts";
6
+
7
+ // A deliberately small, documented set of Pi semantic foreground colors.
8
+ export const COLORS = ["text", "accent", "muted", "dim", "success", "warning", "error", "border", "borderAccent", "borderMuted", "thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh", "thinkingMax"] as const;
9
+ export function validColor(value: unknown): value is ColorValue {
10
+ return typeof value === "string" && (/^#[\da-f]{6}$/i.test(value) || (COLORS as readonly string[]).includes(value));
11
+ }
12
+ export function presetConfig(preset: Preset = "cometix"): StatuslineConfig {
13
+ const enabled = new Set<string>(preset === "minimal" ? ["directory", "git", "context", "statuses"] : ["model", "thinking", "directory", "git", "context", "statuses"]);
14
+ return {
15
+ version: 1, enabled: true, preset, icons: "unicode",
16
+ separator: preset === "powerline" ? "powerline" : "pipe", pathMode: "basename", modelDisplay: "name", contextDisplay: "bar",
17
+ segments: SEGMENT_IDS.map((id) => ({ id, enabled: enabled.has(id) })),
18
+ runtime: { enabled: true, status: true, tools: true, noContent: true, noContentSeconds: 10, avg: true, cache: true, counts: true, untilCompact: true, retainSummary: true },
19
+ providerMetrics: { phase: true, firstText: true, firstReasoning: true, duration: false, transport: false },
20
+ integrations: { cliproxyapi: "auto" },
21
+ };
22
+ }
23
+ function record(value: unknown): value is Record<string, unknown> {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+ function choice<T extends string>(value: unknown, choices: readonly T[], name: string): T {
27
+ if (typeof value !== "string" || !choices.includes(value as T)) throw new Error(`Invalid ${name}`);
28
+ return value as T;
29
+ }
30
+ function boolean(value: unknown, name: string): boolean {
31
+ if (typeof value !== "boolean") throw new Error(`Invalid ${name}`);
32
+ return value;
33
+ }
34
+ export function parseConfig(raw: unknown): StatuslineConfig {
35
+ if (!record(raw)) throw new Error("Configuration must be an object");
36
+ if (raw.version !== undefined && raw.version !== 1) throw new Error("Unsupported configuration version");
37
+ const config = presetConfig(raw.preset === undefined ? "cometix" : choice(raw.preset, ["cometix", "minimal", "powerline"] as const, "preset"));
38
+ if (raw.enabled !== undefined) config.enabled = boolean(raw.enabled, "enabled");
39
+ if (raw.icons !== undefined) config.icons = choice(raw.icons, ["unicode", "nerd", "ascii"], "icons");
40
+ if (raw.separator !== undefined) config.separator = choice(raw.separator, ["pipe", "dot", "space", "powerline"], "separator");
41
+ if (raw.pathMode !== undefined) config.pathMode = choice(raw.pathMode, ["basename", "abbreviated", "full"], "pathMode");
42
+ if (raw.modelDisplay !== undefined) config.modelDisplay = choice(raw.modelDisplay, ["name", "last"], "modelDisplay");
43
+ if (raw.contextDisplay !== undefined) config.contextDisplay = choice(raw.contextDisplay, ["text", "bar"], "contextDisplay");
44
+ for (const group of ["runtime", "providerMetrics"] as const) {
45
+ if (raw[group] === undefined) continue;
46
+ if (!record(raw[group])) throw new Error(`Invalid ${group}`);
47
+ for (const key of Object.keys(config[group])) {
48
+ const value = raw[group][key];
49
+ if (value === undefined) continue;
50
+ if (key === "noContentSeconds") {
51
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 3600) throw new Error("Invalid runtime.noContentSeconds (1–3600)");
52
+ config.runtime.noContentSeconds = value;
53
+ } else (config[group] as unknown as Record<string, unknown>)[key] = boolean(value, `${group}.${key}`);
54
+ }
55
+ }
56
+ if (raw.integrations !== undefined) {
57
+ if (!record(raw.integrations)) throw new Error("Invalid integrations");
58
+ if (raw.integrations.cliproxyapi !== undefined) config.integrations.cliproxyapi = choice(raw.integrations.cliproxyapi, ["auto", "off", "on"], "integrations.cliproxyapi");
59
+ }
60
+ if (raw.segments !== undefined) {
61
+ if (!Array.isArray(raw.segments)) throw new Error("segments must be an array");
62
+ const seen = new Set<string>();
63
+ config.segments = raw.segments.map((item: unknown) => {
64
+ if (!record(item)) throw new Error("Invalid segment");
65
+ const id = choice(item.id, SEGMENT_IDS, "segment id");
66
+ if (seen.has(id)) throw new Error(`Duplicate segment: ${id}`);
67
+ seen.add(id);
68
+ const result = { id, enabled: item.enabled === undefined ? true : boolean(item.enabled, `${id}.enabled`) } as StatuslineConfig["segments"][number];
69
+ if (item.icon !== undefined) {
70
+ if (typeof item.icon !== "string" || item.icon.length > 32 || /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u.test(item.icon)) throw new Error(`Invalid icon: ${id}`);
71
+ result.icon = item.icon;
72
+ }
73
+ for (const key of ["color", "iconColor", "background"] as const) {
74
+ if (item[key] !== undefined) {
75
+ if (!validColor(item[key])) throw new Error(`Invalid ${id}.${key}`);
76
+ result[key] = item[key];
77
+ }
78
+ }
79
+ return result;
80
+ });
81
+ // An explicit list is authoritative. Omitted segments remain editable but disabled.
82
+ for (const id of SEGMENT_IDS) if (!seen.has(id)) config.segments.push({ id, enabled: false });
83
+ }
84
+ return config;
85
+ }
86
+
87
+ export class ConfigStore {
88
+ readonly file: string;
89
+ private lastWarning = "";
90
+ constructor(file: string) { this.file = file; }
91
+ async load(previous = presetConfig()): Promise<{ config: StatuslineConfig; warning?: string }> {
92
+ try {
93
+ const config = parseConfig(JSON.parse(await readFile(this.file, "utf8")));
94
+ this.lastWarning = "";
95
+ return { config };
96
+ } catch (error) {
97
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
98
+ this.lastWarning = "";
99
+ return { config: presetConfig() };
100
+ }
101
+ const warning = `Could not load ${this.file}: ${error instanceof Error ? error.message : String(error)}`;
102
+ const notify = warning !== this.lastWarning;
103
+ this.lastWarning = warning;
104
+ return { config: previous, ...(notify ? { warning } : {}) };
105
+ }
106
+ }
107
+ async save(config: StatuslineConfig): Promise<void> {
108
+ const validated = parseConfig(config);
109
+ await mkdir(dirname(this.file), { recursive: true });
110
+ const temporary = `${this.file}.${randomUUID()}.tmp`;
111
+ try {
112
+ const handle = await open(temporary, "wx", 0o600);
113
+ try { await handle.writeFile(`${JSON.stringify(validated, null, 2)}\n`); await handle.sync(); }
114
+ finally { await handle.close(); }
115
+ await rename(temporary, this.file);
116
+ this.lastWarning = "";
117
+ } finally {
118
+ await unlink(temporary).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; });
119
+ }
120
+ }
121
+ }
package/src/git.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { GitState } from "./types.ts";
3
+
4
+ export function emptyGit(kind: GitState["kind"] = "unknown"): GitState {
5
+ return { kind, branch: null, detached: false, staged: 0, unstaged: 0, untracked: 0, conflicts: 0, ahead: 0, behind: 0 };
6
+ }
7
+ export function parseGitStatus(output: string): GitState {
8
+ const state = emptyGit("repository");
9
+ let oid = "";
10
+ const records = output.split("\0");
11
+ for (let i = 0; i < records.length; i++) {
12
+ const line = records[i]!;
13
+ if (line.startsWith("# branch.head ")) {
14
+ const name = line.slice(14);
15
+ state.detached = name === "(detached)";
16
+ if (!state.detached) state.branch = name;
17
+ } else if (line.startsWith("# branch.oid ")) oid = line.slice(13);
18
+ else if (line.startsWith("# branch.ab ")) {
19
+ const match = /^# branch.ab \+(\d+) -(\d+)$/.exec(line);
20
+ if (match) { state.ahead = Number(match[1]); state.behind = Number(match[2]); }
21
+ } else if (line.startsWith("? ")) state.untracked++;
22
+ else if (line.startsWith("u ")) state.conflicts++;
23
+ else if (line.startsWith("1 ") || line.startsWith("2 ")) {
24
+ const fields = line.split(" ", 4);
25
+ const xy = fields[1] ?? "..";
26
+ const sub = fields[2] ?? "N...";
27
+ if (xy[0] !== ".") state.staged++;
28
+ if (xy[1] !== "." || (sub.startsWith("S") && sub.slice(1) !== "...")) state.unstaged++;
29
+ if (line.startsWith("2 ")) i++; // Rename source is a separate NUL record, not another status.
30
+ }
31
+ }
32
+ if (state.detached) state.branch = oid && oid !== "(initial)" ? oid.slice(0, 7) : "HEAD";
33
+ return state;
34
+ }
35
+ export type GitReader = (cwd: string, signal: AbortSignal) => Promise<GitState>;
36
+ export const readGit: GitReader = (cwd, signal) => new Promise((resolve) => {
37
+ if (signal.aborted) { resolve(emptyGit()); return; }
38
+ const child = spawn("git", ["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z"], {
39
+ cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
40
+ });
41
+ const chunks: Buffer[] = [];
42
+ let bytes = 0, stderr = "", stopped = false;
43
+ const stop = () => { stopped = true; child.kill("SIGKILL"); };
44
+ const timer = setTimeout(stop, 1000);
45
+ timer.unref();
46
+ signal.addEventListener("abort", stop, { once: true });
47
+ const finish = (state: GitState) => { clearTimeout(timer); signal.removeEventListener("abort", stop); resolve(state); };
48
+ child.stdout.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > 8 * 1024 * 1024) stop(); else chunks.push(chunk); });
49
+ child.stderr.on("data", (chunk: Buffer) => { if (stderr.length < 4096) stderr += chunk.toString(); });
50
+ child.on("error", () => finish(emptyGit()));
51
+ child.on("close", (code) => {
52
+ if (stopped || signal.aborted) finish(emptyGit());
53
+ else if (code === 0) finish(parseGitStatus(Buffer.concat(chunks).toString("utf8")));
54
+ else finish(emptyGit(stderr.includes("not a git repository") ? "none" : "unknown"));
55
+ });
56
+ });
57
+
58
+ /** One query at a time. Disposal cancels work and prevents stale completion callbacks. */
59
+ export class GitPoller {
60
+ private controller: AbortController | undefined;
61
+ private timer: ReturnType<typeof setInterval> | undefined;
62
+ private state = emptyGit();
63
+ private disposed = false;
64
+ private pending = false;
65
+ private lastStart = 0;
66
+ private deferred: ReturnType<typeof setTimeout> | undefined;
67
+ private readonly cwd: string;
68
+ private readonly changed: (state: GitState) => void;
69
+ private readonly reader: GitReader;
70
+ constructor(cwd: string, changed: (state: GitState) => void, reader: GitReader = readGit) {
71
+ this.cwd = cwd; this.changed = changed; this.reader = reader;
72
+ }
73
+ start(): void {
74
+ if (this.timer || this.disposed) return;
75
+ void this.refresh();
76
+ this.timer = setInterval(() => void this.refresh(), 2000);
77
+ this.timer.unref();
78
+ }
79
+ async refresh(): Promise<void> {
80
+ if (this.disposed) return;
81
+ if (this.controller) { this.pending = true; return; }
82
+ const delay = 250 - (Date.now() - this.lastStart);
83
+ if (delay > 0) {
84
+ this.deferred ??= setTimeout(() => { this.deferred = undefined; void this.refresh(); }, delay);
85
+ this.deferred.unref();
86
+ return;
87
+ }
88
+ this.lastStart = Date.now();
89
+ const controller = new AbortController();
90
+ this.controller = controller;
91
+ try {
92
+ const state = await this.reader(this.cwd, controller.signal);
93
+ if (!this.disposed && JSON.stringify(state) !== JSON.stringify(this.state)) { this.state = state; this.changed(state); }
94
+ } catch {
95
+ if (!this.disposed && this.state.kind !== "unknown") { this.state = emptyGit(); this.changed(this.state); }
96
+ } finally {
97
+ this.controller = undefined;
98
+ if (this.pending && !this.disposed) { this.pending = false; void this.refresh(); }
99
+ }
100
+ }
101
+ dispose(): void {
102
+ this.disposed = true;
103
+ clearInterval(this.timer); clearTimeout(this.deferred); this.controller?.abort();
104
+ }
105
+ }
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { join } from "node:path";
2
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { ConfigStore, presetConfig } from "./config.ts";
4
+ import { StatuslinePanel } from "./panel.ts";
5
+ import { sampleSnapshot } from "./render.ts";
6
+ import { FooterSession } from "./session.ts";
7
+
8
+ export default function statusline(pi: ExtensionAPI): void {
9
+ const store = new ConfigStore(join(getAgentDir(), "extensions", "pi-statusline", "config.json"));
10
+ let config = presetConfig();
11
+ let session: FooterSession | undefined;
12
+ let generation = 0;
13
+
14
+ pi.on("session_start", async (_event, ctx) => {
15
+ const token = ++generation;
16
+ session?.dispose(); session = undefined;
17
+ if (ctx.mode !== "tui") return;
18
+ const loaded = await store.load(config);
19
+ if (token !== generation) return;
20
+ config = loaded.config;
21
+ if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
22
+ session = new FooterSession(ctx, config);
23
+ session.refresh(true);
24
+ session.install();
25
+ });
26
+ pi.on("session_shutdown", () => { generation++; session?.dispose(); session = undefined; });
27
+ pi.on("message_start", (event, ctx) => {
28
+ session?.setContext(ctx);
29
+ if (event.message.role === "assistant") session?.setLive(undefined);
30
+ session?.schedule(true);
31
+ });
32
+ pi.on("message_update", (event, ctx) => {
33
+ session?.setContext(ctx);
34
+ if (event.message.role === "assistant") session?.setLive(event.message);
35
+ session?.schedule();
36
+ });
37
+ pi.on("message_end", (event, ctx) => {
38
+ session?.setContext(ctx);
39
+ if (event.message.role === "assistant") session?.setLive(event.message);
40
+ session?.schedule(true);
41
+ });
42
+ const refresh = (_event: unknown, ctx: ExtensionContext) => { session?.setContext(ctx); session?.schedule(true); };
43
+ const resetLive = (event: unknown, ctx: ExtensionContext) => { session?.setLive(undefined); refresh(event, ctx); };
44
+ pi.on("agent_end", resetLive);
45
+ pi.on("turn_end", refresh);
46
+ pi.on("model_select", refresh);
47
+ pi.on("thinking_level_select", refresh);
48
+ pi.on("session_compact", resetLive);
49
+ pi.on("session_compact_failed", refresh);
50
+ pi.on("session_tree", resetLive);
51
+ pi.on("tool_execution_end", (_event, ctx) => { session?.setContext(ctx); session?.requestGit(); session?.schedule(true); });
52
+ pi.on("user_bash", (_event, ctx) => { session?.setContext(ctx); session?.requestGit(); });
53
+
54
+ pi.registerCommand("statusline", {
55
+ description: "Configure the statusline, or on | off | reload",
56
+ handler: async (args, ctx) => {
57
+ if (ctx.mode !== "tui") { ctx.ui.notify("Statusline configuration requires Pi terminal mode.", "info"); return; }
58
+ const token = generation;
59
+ const current = session;
60
+ if (!current) return;
61
+ current.setContext(ctx);
62
+ const command = args.trim();
63
+ if (command === "off") { current.disable(); return; }
64
+ if (command === "on") { current.apply({ ...config, enabled: true }); return; }
65
+ if (command === "reload") {
66
+ const loaded = await store.load(config);
67
+ if (token !== generation) return;
68
+ config = loaded.config;
69
+ if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
70
+ current.apply(config);
71
+ return;
72
+ }
73
+ if (command) { ctx.ui.notify("Usage: /statusline [on|off|reload]", "info"); return; }
74
+ current.refresh(true);
75
+ await ctx.ui.custom<void>((tui, _theme, _keys, done) => new StatuslinePanel({
76
+ config, theme: () => ctx.ui.theme,
77
+ snapshot: () => {
78
+ const example = ctx.sessionManager.getLeafId() === null;
79
+ return { data: example ? sampleSnapshot() : current.snapshot(), example };
80
+ },
81
+ requestRender: () => tui.requestRender(), height: () => tui.terminal.rows,
82
+ done: () => done(),
83
+ save: async (next) => {
84
+ if (token !== generation) throw new Error("Session changed; reopen settings");
85
+ await store.save(next);
86
+ if (token !== generation) return;
87
+ config = next; current.apply(config);
88
+ },
89
+ }));
90
+ },
91
+ });
92
+ }
package/src/panel.ts ADDED
@@ -0,0 +1,187 @@
1
+ import { Input, matchesKey, SelectList, truncateToWidth, type Component, type Focusable, type SelectItem } from "@earendil-works/pi-tui";
2
+ import { COLORS, parseConfig, presetConfig } from "./config.ts";
3
+ import { cleanText, renderFooter, type RenderTheme } from "./render.ts";
4
+ import type { SegmentConfig, StatuslineConfig, StatuslineSnapshot } from "./types.ts";
5
+
6
+ interface PanelOptions {
7
+ config: StatuslineConfig;
8
+ theme: () => RenderTheme;
9
+ snapshot: () => { data: StatuslineSnapshot; example: boolean };
10
+ requestRender: () => void;
11
+ height: () => number;
12
+ save: (config: StatuslineConfig) => Promise<void>;
13
+ done: () => void;
14
+ }
15
+
16
+ /** All edits belong to a draft. Only a successful explicit Save changes the running footer. */
17
+ export class StatuslinePanel implements Component, Focusable {
18
+ private options: PanelOptions;
19
+ private draft: StatuslineConfig;
20
+ private list!: SelectList;
21
+ private editing: SegmentConfig | undefined;
22
+ private input: Input | undefined;
23
+ private inputLabel = "";
24
+ private error = "";
25
+ private saving = false;
26
+ private disposed = false;
27
+ private hasFocus = false;
28
+ private listHeight = 0;
29
+
30
+ constructor(options: PanelOptions) { this.options = options; this.draft = structuredClone(options.config); this.rebuild(); }
31
+ get focused(): boolean { return this.hasFocus; }
32
+ set focused(value: boolean) { this.hasFocus = value; if (this.input) this.input.focused = value; }
33
+ private paint(text: string, color: "text" | "accent" | "muted" | "error" = "text"): string {
34
+ const theme = this.options.theme(); return theme.style(text, { fg: theme.colors[color] });
35
+ }
36
+ private thinkingConfig(): SegmentConfig {
37
+ let thinking = this.draft.segments.find((segment) => segment.id === "thinking");
38
+ if (!thinking) { thinking = { id: "thinking", enabled: false }; this.draft.segments.push(thinking); }
39
+ return thinking;
40
+ }
41
+ private items(): SelectItem[] {
42
+ if (this.editing) {
43
+ const segment = this.editing;
44
+ return [
45
+ { value: "enabled", label: `Enabled: ${segment.enabled ? "yes" : "no"}` },
46
+ ...(["icon", "color", "iconColor", "background"] as const).map((key) => ({ value: key, label: `${key}: ${segment[key] ?? "(default)"}`, description: key === "icon" ? "Empty text hides the icon; :default restores it" : "Semantic color or #RRGGBB; empty restores default" })),
47
+ ...(segment.id === "model" ? [
48
+ { value: "thinking", label: `Thinking level: ${this.thinkingConfig().enabled ? "yes" : "no"}`, description: "Colored level in parentheses after the model name" },
49
+ { value: "thinkingColor", label: `Thinking color: ${this.thinkingConfig().color ?? "automatic"}`, description: "Automatic uses level colors; high/xhigh/max use rainbow" },
50
+ ] : []),
51
+ { value: "back", label: "Back to segments" },
52
+ ];
53
+ }
54
+ return [
55
+ { value: "preset", label: `Preset: ${this.draft.preset}`, description: "Cycles presets and resets segment styles/order" },
56
+ { value: "defaultEnabled", label: `Enable on startup: ${this.draft.enabled ? "yes" : "no"}` },
57
+ { value: "icons", label: `Icons: ${this.draft.icons}` },
58
+ { value: "separator", label: `Separator: ${this.draft.separator}` },
59
+ { value: "pathMode", label: `Path: ${this.draft.pathMode}` },
60
+ { value: "modelDisplay", label: `Model display: ${this.draft.modelDisplay}`, description: "name: display name · last: final model ID component" },
61
+ { value: "contextDisplay", label: `Context display: ${this.draft.contextDisplay}`, description: "text: tokens/capacity(percent) · bar: progress and percent" },
62
+ ...this.draft.segments.filter((segment) => segment.id !== "thinking").map((segment) => ({ value: `segment:${segment.id}`, label: `[${segment.enabled ? "x" : " "}] ${segment.id}`, description: segment.id === "statuses" ? "Secondary row · Space toggles, Enter edits" : "Space toggles · Alt+Up/Down reorders · Enter edits" })),
63
+ { value: "save", label: "Save and apply globally" },
64
+ { value: "reset", label: "Restore defaults in preview" },
65
+ { value: "cancel", label: "Cancel" },
66
+ ];
67
+ }
68
+ private rebuild(selected?: string): void {
69
+ const items = this.items();
70
+ this.listHeight = Math.max(1, this.options.height() - 12);
71
+ this.list = new SelectList(items, this.listHeight, {
72
+ selectedPrefix: (text) => this.paint(text, "accent"), selectedText: (text) => this.paint(text, "accent"),
73
+ description: (text) => this.paint(text, "muted"), scrollInfo: (text) => this.paint(text, "muted"), noMatch: (text) => this.paint(text, "muted"),
74
+ });
75
+ this.list.onSelect = (item) => this.activate(item.value);
76
+ this.list.onCancel = () => this.cancel();
77
+ if (selected) this.list.setSelectedIndex(Math.max(0, items.findIndex((item) => item.value === selected)));
78
+ }
79
+ private cancel(): void {
80
+ if (this.input) { this.input = undefined; this.error = ""; }
81
+ else if (this.editing) { const id = this.editing.id; this.editing = undefined; this.rebuild(`segment:${id}`); }
82
+ else this.options.done();
83
+ }
84
+ private activate(value: string): void {
85
+ this.error = "";
86
+ if (this.editing) {
87
+ if (value === "back") this.cancel();
88
+ else if (value === "enabled") { this.editing.enabled = !this.editing.enabled; this.rebuild(value); }
89
+ else if (value === "thinking") { const thinking = this.thinkingConfig(); thinking.enabled = !thinking.enabled; this.rebuild(value); }
90
+ else this.editProperty(value as "icon" | "color" | "iconColor" | "background" | "thinkingColor");
91
+ return;
92
+ }
93
+ const cycle = <T,>(current: T, options: readonly T[]): T => options[(options.indexOf(current) + 1) % options.length]!;
94
+ switch (value) {
95
+ case "preset": {
96
+ const enabled = this.draft.enabled;
97
+ const modelDisplay = this.draft.modelDisplay;
98
+ const contextDisplay = this.draft.contextDisplay;
99
+ this.draft = presetConfig(cycle(this.draft.preset, ["cometix", "minimal", "powerline"]));
100
+ this.draft.enabled = enabled;
101
+ this.draft.modelDisplay = modelDisplay;
102
+ this.draft.contextDisplay = contextDisplay;
103
+ break;
104
+ }
105
+ case "defaultEnabled": this.draft.enabled = !this.draft.enabled; break;
106
+ case "icons": this.draft.icons = cycle(this.draft.icons, ["unicode", "nerd", "ascii"]); break;
107
+ case "separator": this.draft.separator = cycle(this.draft.separator, ["pipe", "dot", "space", "powerline"]); break;
108
+ case "pathMode": this.draft.pathMode = cycle(this.draft.pathMode, ["basename", "abbreviated", "full"]); break;
109
+ case "modelDisplay": this.draft.modelDisplay = cycle(this.draft.modelDisplay, ["name", "last"]); break;
110
+ case "contextDisplay": this.draft.contextDisplay = cycle(this.draft.contextDisplay, ["text", "bar"]); break;
111
+ case "reset": this.draft = presetConfig(); break;
112
+ case "cancel": this.options.done(); return;
113
+ case "save": void this.save(); return;
114
+ default: this.editing = this.draft.segments.find((segment) => `segment:${segment.id}` === value); break;
115
+ }
116
+ this.rebuild(this.editing ? undefined : value);
117
+ }
118
+ private editProperty(property: "icon" | "color" | "iconColor" | "background" | "thinkingColor"): void {
119
+ const editingId = this.editing!.id;
120
+ const segment = property === "thinkingColor" ? this.thinkingConfig() : this.editing!;
121
+ const key = property === "thinkingColor" ? "color" : property;
122
+ const input = new Input();
123
+ input.setValue(segment[key] ?? (key === "icon" ? ":default" : ""));
124
+ input.focused = this.hasFocus;
125
+ this.input = input;
126
+ this.inputLabel = key === "icon" ? "Icon (empty hides; :default restores)" : `${key} (#RRGGBB or ${COLORS.slice(0, 7).join(", ")}; empty restores)`;
127
+ input.onEscape = () => this.cancel();
128
+ input.onSubmit = (text) => {
129
+ const candidate = structuredClone(this.draft);
130
+ const target = candidate.segments.find((item) => item.id === segment.id)!;
131
+ if ((key === "icon" && text === ":default") || (key !== "icon" && !text.trim())) delete target[key];
132
+ else (target as unknown as Record<string, unknown>)[key] = key === "icon" ? text : text.trim();
133
+ try {
134
+ this.draft = parseConfig(candidate);
135
+ this.editing = this.draft.segments.find((item) => item.id === editingId);
136
+ this.input = undefined; this.error = ""; this.rebuild(property);
137
+ } catch (error) { this.error = error instanceof Error ? error.message : String(error); }
138
+ };
139
+ }
140
+ private async save(): Promise<void> {
141
+ this.saving = true;
142
+ this.options.requestRender();
143
+ try { await this.options.save(structuredClone(this.draft)); if (!this.disposed) this.options.done(); }
144
+ catch (error) { if (!this.disposed) this.error = `Save failed: ${error instanceof Error ? error.message : String(error)}`; }
145
+ finally { this.saving = false; if (!this.disposed) this.options.requestRender(); }
146
+ }
147
+ handleInput(data: string): void {
148
+ if (this.saving || this.disposed) return;
149
+ if (this.input) this.input.handleInput(data);
150
+ else {
151
+ const selected = this.list.getSelectedItem()?.value;
152
+ const segmentIndex = this.draft.segments.findIndex((segment) => `segment:${segment.id}` === selected);
153
+ if (!this.editing && segmentIndex >= 0 && matchesKey(data, "space")) {
154
+ const segment = this.draft.segments[segmentIndex]!; segment.enabled = !segment.enabled; this.rebuild(selected);
155
+ } else if (!this.editing && segmentIndex >= 0 && (matchesKey(data, "alt+up") || matchesKey(data, "alt+down"))) {
156
+ const direction = matchesKey(data, "alt+up") ? -1 : 1;
157
+ let next = segmentIndex + direction;
158
+ while (this.draft.segments[next]?.id === "thinking") next += direction;
159
+ if (next >= 0 && next < this.draft.segments.length) {
160
+ [this.draft.segments[segmentIndex], this.draft.segments[next]] = [this.draft.segments[next]!, this.draft.segments[segmentIndex]!];
161
+ this.rebuild(selected);
162
+ }
163
+ } else this.list.handleInput(data);
164
+ }
165
+ if (!this.disposed) this.options.requestRender();
166
+ }
167
+ invalidate(): void { this.list.invalidate(); this.input?.invalidate(); }
168
+ dispose(): void { this.disposed = true; }
169
+ render(width: number): string[] {
170
+ if (width <= 0) return [];
171
+ if (this.listHeight !== Math.max(1, this.options.height() - 12)) this.rebuild(this.list.getSelectedItem()?.value);
172
+ const { data, example } = this.options.snapshot();
173
+ const preview = renderFooter(width, data, this.draft, this.options.theme());
174
+ const lines = [
175
+ this.paint(`Statusline settings${this.editing ? ` / ${this.editing.id}` : ""}`, "accent"),
176
+ this.paint(example ? "Preview · example data" : "Preview · current session", "muted"),
177
+ ...(preview.length ? preview : [this.paint("(all segments hidden)", "muted")]),
178
+ this.paint("Changes are a draft until Save. Fees are Pi-reported USD.", "muted"),
179
+ "",
180
+ ...(this.input ? [this.paint(this.inputLabel, "accent"), ...this.input.render(width)] : this.list.render(width)),
181
+ "",
182
+ this.paint(this.saving ? "Saving…" : "↑↓ select · Space toggle · Alt+↑↓ reorder · Enter edit · Esc back/cancel", "muted"),
183
+ ...(this.error ? [this.paint(cleanText(this.error), "error")] : []),
184
+ ];
185
+ return lines.map((line) => truncateToWidth(line, width));
186
+ }
187
+ }
package/src/render.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { basename, isAbsolute, relative, sep } from "node:path";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { parseColor, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { cleanText } from "./text.ts";
5
+ export { cleanText } from "./text.ts";
6
+ import type { ColorValue, SegmentConfig, SegmentId, StatuslineConfig, StatuslineSnapshot } from "./types.ts";
7
+ import { emptyGit } from "./git.ts";
8
+ import { emptyUsage } from "./usage.ts";
9
+
10
+ export type RenderTheme = Pick<Theme, "style" | "colors" | "appearance">;
11
+ // Keep rainbow hues independent of semantic theme colors, which can repeat.
12
+ const THINKING_RAINBOW = ["#c084fc", "#f472b6", "#fb923c", "#facc15", "#4ade80", "#22d3ee", "#60a5fa"].map((value) => parseColor(value));
13
+ const ICONS: Record<StatuslineConfig["icons"], Partial<Record<SegmentId, string>>> = {
14
+ unicode: { model: "", directory: "▸", git: "⑂", context: "◉", cost: "$", input: "↑", output: "↓", tokens: "Σ", cacheRead: "R", cacheWrite: "W" },
15
+ nerd: { model: "", directory: "󰉋", git: "", context: "󰆼", cost: "$", input: "↑", output: "↓", tokens: "󰊄", cacheRead: "R", cacheWrite: "W" },
16
+ ascii: { model: "", directory: "", git: "git:", context: "ctx:", cost: "$", input: "in:", output: "out:", tokens: "tok:", cacheRead: "R:", cacheWrite: "W:" },
17
+ };
18
+ const DROP_ORDER: SegmentId[] = ["cost", "cacheWrite", "cacheRead", "tokens", "output", "input", "thinking", "git", "directory"];
19
+ export function formatTokens(value: number): string {
20
+ if (value < 1000) return String(Math.round(value));
21
+ const divisor = value >= 1e6 ? 1e6 : 1000;
22
+ return `${Number((value / divisor).toFixed(1))}${divisor === 1e6 ? "M" : "k"}`;
23
+ }
24
+ function contextBar(percent: number | null, ascii: boolean): string {
25
+ const width = 10;
26
+ if (percent === null || !Number.isFinite(percent)) return `[${"?".repeat(width)}]?%`;
27
+ const fill = Math.max(0, Math.min(100, percent)) / 100 * width;
28
+ // Whole cells avoid the blank right side of fractional glyphs such as ▌.
29
+ // Keep small, non-zero usage visible; the percentage gives the precise value.
30
+ const full = fill > 0 ? Math.max(1, Math.round(fill)) : 0;
31
+ const bar = (ascii ? "#" : "█").repeat(full) + (ascii ? "-" : "░").repeat(width - full);
32
+ return `[${bar}]${Number(percent.toFixed(1))}%`;
33
+ }
34
+ function displayPath(data: StatuslineSnapshot, mode: StatuslineConfig["pathMode"]): string {
35
+ if (mode === "basename") return basename(data.cwd) || data.cwd;
36
+ const rel = data.home ? relative(data.home, data.cwd) : "..";
37
+ const inside = rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
38
+ const full = inside ? (rel ? `~${sep}${rel}` : "~") : data.cwd;
39
+ if (mode !== "abbreviated" || visibleWidth(full) <= 36) return full;
40
+ const parts = full.split(sep);
41
+ return `…${sep}${parts.slice(-2).join(sep)}`;
42
+ }
43
+ function gitText(data: StatuslineSnapshot, ascii: boolean): string | undefined {
44
+ const git = data.git;
45
+ if (git.kind === "none") return undefined;
46
+ if (git.kind === "unknown") return `${git.branch ?? ""} ?`.trim();
47
+ const bits = [git.branch ?? "HEAD"];
48
+ if (git.detached) bits.push(ascii ? "(detached)" : "detached");
49
+ if (git.conflicts) bits.push(`${ascii ? "!" : "⚠"}${git.conflicts}`);
50
+ else bits.push(git.staged + git.unstaged + git.untracked ? (ascii ? "*" : "●") : (ascii ? "ok" : "✓"));
51
+ if (git.staged) bits.push(`+${git.staged}`);
52
+ if (git.unstaged) bits.push(`*${git.unstaged}`);
53
+ if (git.untracked) bits.push(`?${git.untracked}`);
54
+ if (git.ahead) bits.push(`${ascii ? "^" : "↑"}${git.ahead}`);
55
+ if (git.behind) bits.push(`${ascii ? "v" : "↓"}${git.behind}`);
56
+ return bits.join(" ");
57
+ }
58
+ function segmentText(id: SegmentId, data: StatuslineSnapshot, config: StatuslineConfig): string | undefined {
59
+ const usage = data.usage;
60
+ switch (id) {
61
+ case "model": return (config.modelDisplay === "last" ? data.modelId.split("/").filter(Boolean).at(-1) : undefined) || data.model || "No model";
62
+ case "thinking": return undefined; // Its settings now control the model's suffix.
63
+ case "directory": return displayPath(data, config.pathMode);
64
+ case "git": return gitText(data, config.icons === "ascii");
65
+ case "context": {
66
+ const { tokens, percent, contextWindow } = data.context;
67
+ if (config.contextDisplay === "bar") return contextBar(percent, config.icons === "ascii");
68
+ const capacity = contextWindow > 0 ? formatTokens(contextWindow) : "?";
69
+ return `${tokens === null ? "?" : formatTokens(tokens)}/${capacity}(${percent === null ? "?" : Number(percent.toFixed(1))}%)`;
70
+ }
71
+ case "cost": return usage.cost.toFixed(3);
72
+ case "input": return formatTokens(usage.input);
73
+ case "output": return formatTokens(usage.output);
74
+ case "tokens": return formatTokens(usage.input + usage.output + usage.cacheRead + usage.cacheWrite);
75
+ case "cacheRead": return formatTokens(usage.cacheRead);
76
+ case "cacheWrite": return formatTokens(usage.cacheWrite);
77
+ case "statuses": return [...data.statuses.values()].map(cleanText).filter(Boolean).join(" · ") || undefined;
78
+ }
79
+ }
80
+ function defaultColor(id: SegmentId, data: StatuslineSnapshot, config: StatuslineConfig): ColorValue {
81
+ if (id === "context") {
82
+ if ((data.context.percent ?? 0) > 90) return "error";
83
+ if ((data.context.percent ?? 0) > 70) return "warning";
84
+ }
85
+ if (id === "git") {
86
+ if (data.git.conflicts) return "error";
87
+ if (data.git.kind === "unknown" || data.git.staged + data.git.unstaged + data.git.untracked) return "warning";
88
+ return "success";
89
+ }
90
+ if (id === "thinking") {
91
+ const colors: Record<string, ColorValue> = { off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax" };
92
+ return colors[data.thinking] ?? "muted";
93
+ }
94
+ if (config.preset === "minimal") return "muted";
95
+ if (id === "model") return "accent";
96
+ if (id === "directory") return "success";
97
+ if (id === "context") return "#c678dd";
98
+ if (id === "cost") return "text";
99
+ return "muted";
100
+ }
101
+ function color(value: ColorValue, theme: RenderTheme) {
102
+ return value.startsWith("#") ? parseColor(value) : theme.colors[value as Exclude<ColorValue, `#${string}`>];
103
+ }
104
+ interface Part {
105
+ config: SegmentConfig;
106
+ text: string;
107
+ icon: string;
108
+ foreground: ColorValue;
109
+ background?: ColorValue;
110
+ thinking?: { text: string; foreground: ColorValue | "rainbow" };
111
+ }
112
+ function renderPart(part: Part, theme: RenderTheme): string {
113
+ const bg = part.background ? color(part.background, theme) : undefined;
114
+ const style = { fg: color(part.foreground, theme), ...(bg ? { bg } : {}) };
115
+ const icon = part.icon ? theme.style(`${part.icon} `, { ...style, fg: color(part.config.iconColor ?? part.foreground, theme) }) : "";
116
+ const text = theme.style(part.text, style);
117
+ let suffix = "";
118
+ if (part.thinking) {
119
+ const { text: level, foreground } = part.thinking;
120
+ const label = `(${level})`;
121
+ const characters = Array.from(label);
122
+ suffix = foreground === "rainbow"
123
+ ? characters.map((character, i) => theme.style(character, {
124
+ ...style,
125
+ fg: THINKING_RAINBOW[Math.round(i * (THINKING_RAINBOW.length - 1) / Math.max(1, characters.length - 1))]!,
126
+ })).join("")
127
+ : theme.style(label, { ...style, fg: color(foreground, theme) });
128
+ }
129
+ const content = `${icon}${text}${suffix}`;
130
+ return bg ? `${theme.style(" ", style)}${content}${theme.style(" ", style)}` : content;
131
+ }
132
+ function joinParts(parts: Part[], config: StatuslineConfig, theme: RenderTheme): string {
133
+ return parts.map((part, i) => {
134
+ let separator = "";
135
+ const previous = parts[i - 1];
136
+ if (previous) {
137
+ if (config.separator === "powerline" && config.icons !== "ascii") {
138
+ separator = theme.style("", {
139
+ fg: color(previous.background ?? previous.foreground, theme),
140
+ ...(part.background ? { bg: color(part.background, theme) } : {}),
141
+ });
142
+ } else {
143
+ const sep = config.separator === "space" ? " " : config.separator === "dot" && config.icons !== "ascii" ? " · " : " | ";
144
+ separator = theme.style(sep, { fg: theme.colors.dim });
145
+ }
146
+ }
147
+ return separator + renderPart(part, theme);
148
+ }).join("");
149
+ }
150
+
151
+ /** Pure terminal rendering: no disk reads, processes or session traversal. */
152
+ export function renderFooter(width: number, data: StatuslineSnapshot, config: StatuslineConfig, theme: RenderTheme): string[] {
153
+ width = Math.max(0, Math.floor(width));
154
+ if (width === 0) return [];
155
+ const parts: Part[] = [];
156
+ let secondary: Part | undefined;
157
+ const thinking = config.segments.find((segment) => segment.id === "thinking");
158
+ for (const segment of config.segments) {
159
+ if (!segment.enabled) continue;
160
+ const raw = segmentText(segment.id, data, config);
161
+ if (raw === undefined) continue;
162
+ let text = cleanText(raw);
163
+ if (config.icons === "ascii") text = text.replaceAll(" · ", " / ").replaceAll("…", "...");
164
+ const background = segment.background ?? (config.preset === "powerline" ? (theme.appearance === "light" ? (parts.length % 2 ? "#e4e9f2" : "#d6dde9") : (parts.length % 2 ? "#283447" : "#202938")) : undefined);
165
+ const part: Part = { config: segment, text, icon: cleanText(segment.icon ?? ICONS[config.icons][segment.id] ?? ""), foreground: segment.color ?? defaultColor(segment.id, data, config), background };
166
+ if (segment.id === "model" && data.reasoning && thinking?.enabled) {
167
+ part.thinking = {
168
+ text: cleanText(data.thinking || "off"),
169
+ foreground: thinking.color ?? (["high", "xhigh", "max"].includes(data.thinking) ? "rainbow" : defaultColor("thinking", data, config)),
170
+ };
171
+ }
172
+ if (segment.id === "statuses") secondary = part;
173
+ else parts.push(part);
174
+ }
175
+ let line = joinParts(parts, config, theme);
176
+ const ellipsis = config.icons === "ascii" ? "..." : "…";
177
+ if (visibleWidth(line) > width) {
178
+ for (const id of ["directory", "model"] as const) {
179
+ const part = parts.find((p) => p.config.id === id);
180
+ if (part) part.text = truncateToWidth(part.text, Math.max(6, Math.min(24, Math.floor(width / 4))), ellipsis);
181
+ }
182
+ line = joinParts(parts, config, theme);
183
+ for (const id of DROP_ORDER) {
184
+ if (visibleWidth(line) <= width) break;
185
+ if (id === "thinking") {
186
+ const model = parts.find((p) => p.config.id === "model");
187
+ if (model) model.thinking = undefined;
188
+ } else {
189
+ const index = parts.findIndex((p) => p.config.id === id);
190
+ if (index !== -1) parts.splice(index, 1);
191
+ }
192
+ line = joinParts(parts, config, theme);
193
+ }
194
+ }
195
+ const lines: string[] = [];
196
+ if (parts.length) lines.push(truncateToWidth(line, width, ellipsis));
197
+ if (secondary) lines.push(truncateToWidth(renderPart(secondary, theme), width, ellipsis));
198
+ return lines;
199
+ }
200
+
201
+ export function sampleSnapshot(): StatuslineSnapshot {
202
+ return {
203
+ model: "Example model", modelId: "example/provider/model", reasoning: true, thinking: "high", cwd: "/projects/pi-packages", home: "/home/example",
204
+ context: { tokens: 23800, contextWindow: 200000, percent: 11.9 },
205
+ usage: { ...emptyUsage(), input: 32000, output: 5400, cacheRead: 12000, cost: 0.12 },
206
+ git: { ...emptyGit("repository"), branch: "main", unstaged: 2, ahead: 2 },
207
+ statuses: new Map([["example", "Example extension status"]]),
208
+ };
209
+ }
package/src/session.ts ADDED
@@ -0,0 +1,108 @@
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
+
10
+ export class FooterSession {
11
+ private ctx: ExtensionContext;
12
+ private config: StatuslineConfig;
13
+ private usage = new UsageCache();
14
+ private git = emptyGit();
15
+ private provider: ReadonlyFooterDataProvider | undefined;
16
+ private tui: Pick<TUI, "requestRender"> | undefined;
17
+ private poller: GitPoller | undefined;
18
+ private timer: ReturnType<typeof setInterval> | undefined;
19
+ private scheduled: ReturnType<typeof setTimeout> | undefined;
20
+ private unsubscribe: (() => void) | undefined;
21
+ private closed = false;
22
+ private ownsFooter = false;
23
+ private statsKey = "";
24
+ private displayKey = "";
25
+ private context: StatuslineSnapshot["context"] = { tokens: null, contextWindow: 0, percent: null };
26
+ private readonly gitReader: GitReader | undefined;
27
+
28
+ constructor(ctx: ExtensionContext, config: StatuslineConfig, gitReader?: GitReader) {
29
+ this.ctx = ctx; this.config = config; this.gitReader = gitReader;
30
+ }
31
+ get active(): boolean { return this.ownsFooter && !this.closed; }
32
+ setContext(ctx: ExtensionContext): void { this.ctx = ctx; }
33
+ install(): void {
34
+ if (this.closed || this.ctx.mode !== "tui" || !this.config.enabled) return;
35
+ this.ctx.ui.setFooter((tui, _theme, provider) => {
36
+ this.ownsFooter = true; this.tui = tui; this.provider = provider;
37
+ this.poller = new GitPoller(this.ctx.cwd, (git) => { this.git = git; this.repaint(); }, this.gitReader);
38
+ this.unsubscribe = provider.onBranchChange(() => this.requestGit());
39
+ if (this.config.segments.some((segment) => segment.id === "git" && segment.enabled)) this.poller.start();
40
+ this.refresh(true);
41
+ // Covers usage entries appended outside normal message events (e.g. cache warming).
42
+ this.timer = setInterval(() => this.refresh(), 2000);
43
+ this.timer.unref();
44
+ return {
45
+ render: (width: number) => renderFooter(width, this.snapshot(), this.config, this.ctx.ui.theme),
46
+ invalidate: () => {}, // Theme and extension statuses are read fresh on every render.
47
+ dispose: () => this.stopResources(),
48
+ };
49
+ });
50
+ }
51
+ private stopResources(): void {
52
+ clearInterval(this.timer); clearTimeout(this.scheduled);
53
+ this.timer = undefined; this.scheduled = undefined;
54
+ this.poller?.dispose(); this.poller = undefined;
55
+ this.unsubscribe?.(); this.unsubscribe = undefined;
56
+ this.ownsFooter = false; this.tui = undefined; this.provider = undefined;
57
+ }
58
+ disable(): void {
59
+ const restore = this.ownsFooter;
60
+ this.stopResources();
61
+ if (restore && !this.closed) this.ctx.ui.setFooter(undefined);
62
+ }
63
+ dispose(): void { this.stopResources(); this.closed = true; }
64
+ apply(config: StatuslineConfig): void {
65
+ if (this.closed) return;
66
+ this.disable(); this.config = config;
67
+ if (config.enabled) this.install();
68
+ }
69
+ setLive(message: AssistantMessage | undefined): void { this.usage.setLive(message); }
70
+ schedule(immediate = false): void {
71
+ if (!this.active) return;
72
+ if (immediate) { clearTimeout(this.scheduled); this.scheduled = undefined; }
73
+ if (this.scheduled) return;
74
+ // Defer final events too: Pi emits message_end before appending the message.
75
+ this.scheduled = setTimeout(() => { this.scheduled = undefined; this.refresh(); }, immediate ? 0 : 100);
76
+ this.scheduled.unref();
77
+ }
78
+ requestGit(): void {
79
+ if (this.active && this.config.segments.some((segment) => segment.id === "git" && segment.enabled)) void this.poller?.refresh();
80
+ }
81
+ refresh(force = false): void {
82
+ if (this.closed) return;
83
+ const manager = this.ctx.sessionManager;
84
+ const key = `${manager.getSessionId()}:${manager.getLeafId()}:${this.ctx.model?.provider}/${this.ctx.model?.id}/${this.ctx.model?.contextWindow}`;
85
+ if (force || key !== this.statsKey) {
86
+ this.usage.update(manager.getEntries());
87
+ const context = this.ctx.getContextUsage();
88
+ this.context = context ?? { tokens: null, contextWindow: this.ctx.model?.contextWindow ?? 0, percent: null };
89
+ this.statsKey = key;
90
+ }
91
+ this.repaint();
92
+ }
93
+ private repaint(): void {
94
+ if (!this.active) return;
95
+ const snapshot = this.snapshot();
96
+ const key = JSON.stringify({ ...snapshot, statuses: undefined });
97
+ if (key === this.displayKey) return;
98
+ this.displayKey = key; this.tui?.requestRender();
99
+ }
100
+ snapshot(): StatuslineSnapshot {
101
+ return {
102
+ model: this.ctx.model?.name || this.ctx.model?.id || "No model", modelId: this.ctx.model?.id ?? "", reasoning: this.ctx.model?.reasoning ?? false,
103
+ thinking: this.ctx.thinkingLevel ?? "off", cwd: this.ctx.cwd, home: homedir(),
104
+ context: this.context, usage: this.usage.snapshot(), git: this.git,
105
+ statuses: this.provider?.getExtensionStatuses() ?? new Map(),
106
+ };
107
+ }
108
+ }
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,81 @@
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 = "cometix" | "minimal" | "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
+ untilCompact: boolean;
18
+ retainSummary: boolean;
19
+ }
20
+ export interface ProviderMetricsConfig {
21
+ phase: boolean;
22
+ firstText: boolean;
23
+ firstReasoning: boolean;
24
+ duration: boolean;
25
+ transport: boolean;
26
+ }
27
+ export interface SegmentConfig {
28
+ id: SegmentId;
29
+ enabled: boolean;
30
+ icon?: string;
31
+ color?: ColorValue;
32
+ iconColor?: ColorValue;
33
+ background?: ColorValue;
34
+ }
35
+ export interface StatuslineConfig {
36
+ version: 1;
37
+ enabled: boolean;
38
+ preset: Preset;
39
+ icons: IconMode;
40
+ separator: "pipe" | "dot" | "space" | "powerline";
41
+ pathMode: "basename" | "abbreviated" | "full";
42
+ modelDisplay: "name" | "last";
43
+ contextDisplay: "text" | "bar";
44
+ segments: SegmentConfig[];
45
+ runtime: RuntimeConfig;
46
+ providerMetrics: ProviderMetricsConfig;
47
+ integrations: { cliproxyapi: "auto" | "off" | "on" };
48
+ }
49
+ export interface UsageTotals {
50
+ input: number;
51
+ output: number;
52
+ cacheRead: number;
53
+ cacheWrite: number;
54
+ cost: number;
55
+ }
56
+ export interface GitState {
57
+ kind: "repository" | "none" | "unknown";
58
+ branch: string | null;
59
+ detached: boolean;
60
+ staged: number;
61
+ unstaged: number;
62
+ untracked: number;
63
+ conflicts: number;
64
+ ahead: number;
65
+ behind: number;
66
+ }
67
+ export interface StatuslineSnapshot {
68
+ model: string;
69
+ modelId: string;
70
+ reasoning: boolean;
71
+ thinking: string;
72
+ cwd: string;
73
+ home: string;
74
+ context: { tokens: number | null; contextWindow: number; percent: number | null };
75
+ usage: UsageTotals;
76
+ git: GitState;
77
+ statuses: ReadonlyMap<string, string>;
78
+ runtime?: import("./runtime.ts").RuntimeSnapshot;
79
+ compaction?: import("./compaction.ts").CompactionSnapshot;
80
+ telemetry?: import("./receiver.ts").TelemetrySnapshot;
81
+ }
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
+ }