@erichll/pi-statusline 0.1.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-10-04
4
+
5
+ - Use the same muted color for No content and Avg; default modelDisplay to name while preserving explicit last preferences.
6
+ - Remove Minimal and the preset selector; support only Powerline, without migration for retired preset values.
7
+ - Make Powerline the default with Nerd icons, Powerline separators, model display name, basename paths, context bar and total tokens enabled; ship the layout as the example configuration.
8
+ - Omit the startup/idle footer label and its separator.
9
+ - Lead the second row with one Elapsed duration; reuse explicit `tps` Elapsed display text without an extra Running/Done duration.
10
+ - Track monotonic runs through retries, tools, UI waits and compaction until `agent_settled`; retain the final run's footer summary.
11
+ - Add ID-based concurrent/nested tool activity, UI-wait precedence and configurable non-empty-content observation warnings (ten seconds by default).
12
+ - Add assistant-only per-run usage, whole-run average output rate and cache-read ratio without changing cumulative session totals.
13
+ - Add compaction activity/result and whole-session successful compaction counts.
14
+ - Remove the Until compact metric, its runtime setting and compaction-capacity calculation.
15
+ - Add runtime settings with draft preview, numeric validation and backward-compatible version-1 partial configuration.
16
+ - Keep statistics limited to existing provider statuses and Pi public events/usage; do not expose unsupported request-level metrics or require provider changes. Ignore and drop obsolete experimental request/integration configuration on save.
17
+ - Remove the read-only run details command/panel; retain footer statistics, configuration and live preview.
18
+ - Expand lifecycle/configuration/render tests and isolated offline PTY smoke across regular/fullscreen layouts.
19
+
3
20
  ## 0.1.0 - 2026-10-03
4
21
 
5
22
  - Make bar mode the default and use whole cells to avoid visual gaps from partial glyphs; non-zero usage fills at least one cell.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # @erichll/pi-statusline
2
2
 
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.
3
+ A configurable footer for Pi 1.0 with Git status, session usage, runtime/tool activity, themes, and live configuration preview. Uses Pi's native footer and leaves the editor intact.
4
4
 
5
5
  ```text
6
- Model(high) | ◉ [█░░░░░░░░░]11.9% | ▸ pi-packages | ⑂ main ● *2 ↑2
7
- extension status text, when available
6
+ Example model(high) 󰆼 [█░░░░░░░░░]11.9% 󰉋 pi-packages main ● *2 ↑2  󰊄 49.4k
7
+ Elapsed 5m 42s · Avg 45 tok/s · Cache 86% · Turns 3 · Compactions 1
8
8
  ```
9
9
 
10
10
  ## Install
@@ -13,7 +13,9 @@ extension status text, when available
13
13
  pi install npm:@erichll/pi-statusline
14
14
  ```
15
15
 
16
- Requires Node.js >=22.19, Pi >=1.0.0, and optionally Git. The package has no
16
+ Requires Node.js >=22.19, Pi >=1.0.0, a Nerd Font for the default Powerline layout,
17
+ and optionally Git. Switch icons/separators in the panel for a font-independent
18
+ layout. The package has no
17
19
  other runtime dependencies and makes no network requests or credential lookups.
18
20
  Only one extension should own Pi's footer, so disable any other footer
19
21
  extension in `pi config` before enabling this one.
@@ -49,6 +51,8 @@ published package, `npm:@erichll/pi-statusline`.
49
51
  The panel supports arrow navigation, Space to toggle a segment, Alt+Up/Down to
50
52
  reorder it, and Enter to edit its properties. Choose **Save and apply globally**
51
53
  to persist the draft. Esc backs out of a property editor or cancels the panel.
54
+ **Runtime options** edits the same draft; Space toggles booleans and Enter edits
55
+ the no-content threshold.
52
56
  **Restore defaults in preview** changes only the draft until saved.
53
57
  Preview uses the real footer renderer and current session data, or clearly
54
58
  labelled example data for an empty session. A save error leaves the active
@@ -69,32 +73,93 @@ existing file to the new path before running `/reload`; the old path is no longe
69
73
  {
70
74
  "version": 1,
71
75
  "enabled": true,
72
- "preset": "cometix",
73
- "icons": "unicode",
74
- "separator": "pipe",
76
+ "preset": "powerline",
77
+ "icons": "nerd",
78
+ "separator": "powerline",
75
79
  "pathMode": "basename",
76
80
  "modelDisplay": "name",
77
- "contextDisplay": "bar"
81
+ "contextDisplay": "bar",
82
+ "segments": [
83
+ {
84
+ "id": "model",
85
+ "enabled": true
86
+ },
87
+ {
88
+ "id": "thinking",
89
+ "enabled": true
90
+ },
91
+ {
92
+ "id": "context",
93
+ "enabled": true
94
+ },
95
+ {
96
+ "id": "directory",
97
+ "enabled": true
98
+ },
99
+ {
100
+ "id": "git",
101
+ "enabled": true
102
+ },
103
+ {
104
+ "id": "cost",
105
+ "enabled": false
106
+ },
107
+ {
108
+ "id": "input",
109
+ "enabled": false
110
+ },
111
+ {
112
+ "id": "output",
113
+ "enabled": false
114
+ },
115
+ {
116
+ "id": "tokens",
117
+ "enabled": true
118
+ },
119
+ {
120
+ "id": "cacheRead",
121
+ "enabled": false
122
+ },
123
+ {
124
+ "id": "cacheWrite",
125
+ "enabled": false
126
+ },
127
+ {
128
+ "id": "statuses",
129
+ "enabled": true
130
+ }
131
+ ],
132
+ "runtime": {
133
+ "enabled": true,
134
+ "status": true,
135
+ "tools": true,
136
+ "noContent": true,
137
+ "noContentSeconds": 10,
138
+ "avg": true,
139
+ "cache": true,
140
+ "counts": true,
141
+ "retainSummary": true
142
+ }
78
143
  }
79
144
  ```
80
145
 
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
146
+ - Layout: `powerline` is the only preset; there is no preset selector.
147
+ Customize segments and styles directly, or restore defaults in preview.
148
+ Pi's current light/dark theme supplies semantic colors; Powerline backgrounds
149
+ adapt to its appearance.
150
+ - Icons: `nerd` (default; requires a Nerd Font), `unicode`, `ascii`. ASCII mode
85
151
  replaces built-in decorative glyphs, not user-provided names or custom icons.
86
152
  Both graphical modes use `` for the model by default; this glyph requires a
87
153
  Nerd Font. A custom model icon still overrides the default.
88
- - Separators: `pipe`, `dot`, `space`, `powerline`. ASCII mode uses plain separators.
154
+ - Separators: `powerline` (default), `pipe`, `dot`, `space`. ASCII mode uses plain separators.
89
155
  - 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.
156
+ - Model display: `name` (default) uses the display name with full ID fallback.
157
+ Choose `last` for the final non-empty `/`-separated component of the **model ID**,
158
+ even when a display name is configured. For example, `provider/a/b/c/d` becomes `d`.
159
+ Restoring defaults resets this preference to `name`.
95
160
  - Context display: `bar` (default, `[██░░░░░░░░]20%`) or `text`
96
161
  (`12.7k/272k(4.7%)`). Choose **Context display: bar** in the panel or set
97
- `"contextDisplay": "bar"`. Switching presets preserves this preference.
162
+ `"contextDisplay": "bar"`.
98
163
  The ten-cell bar uses whole blocks to avoid gaps inside partial glyphs. Fill
99
164
  rounds to the nearest cell, with at least one filled cell for non-zero usage;
100
165
  the percentage retains the more precise value. ASCII mode uses `#` and `-`.
@@ -133,15 +198,77 @@ Available segments:
133
198
  | `directory` | Current working directory | On |
134
199
  | `git` | Branch, clean/dirty/conflict, staged `+`, unstaged `*`, untracked `?`, ahead/behind | On |
135
200
  | `cost` | Pi-recorded cumulative USD cost; enable in the panel when wanted | Off |
136
- | `input`, `output`, `tokens` | Cumulative input, output, or total tokens | Off |
201
+ | `tokens` | Cumulative total tokens | On |
202
+ | `input`, `output` | Cumulative input or output tokens | Off |
137
203
  | `cacheRead`, `cacheWrite` | Cumulative cache token counts | Off |
138
- | `statuses` | Other extensions' `setStatus()` text, with terminal controls stripped | On |
204
+ | `statuses` | Other extensions' `setStatus()` text on the runtime row, with terminal controls stripped | On |
139
205
 
140
- The minimal preset enables directory, Git, context, and extension statuses.
206
+ The default Powerline preset enables model/thinking, context, directory, Git, total
207
+ tokens and extension statuses. Cost, input/output and cache token counters stay off.
141
208
  See [examples/statusline.json](examples/statusline.json) for an explicit layout.
142
- Unknown versions, duplicate segments, invalid colors and malformed JSON are
209
+ Unknown presets or versions, duplicate segments, invalid colors and malformed JSON are
143
210
  rejected with a notification; reload retains the last working configuration.
144
211
  Removing the file restores defaults on reload. Saves use an atomic file replacement.
212
+ Obsolete `runtime.untilCompact`, `providerMetrics` and `integrations` keys are
213
+ ignored and dropped on the next save; they do not require a configuration migration.
214
+
215
+ ## Runtime row and compaction
216
+
217
+ Runtime metrics use Pi's public events and reported usage, without modifying the
218
+ provider. `Elapsed`
219
+ leads the second row; no additional `Running 342s` or `Done 342s` duration is
220
+ shown. A run starts at the first `agent_start` and ends at `agent_settled`, not
221
+ `agent_end`. Retries, tools, UI waits and compaction do not reset its monotonic
222
+ clock. `retainSummary` keeps the last run's footer summary until the next run
223
+ (disable it to hide the run summary at settlement). The idle
224
+ footer has no Idle label or leading separator.
225
+
226
+ - `Turns` counts Pi `turn_start` events, not upstream requests. Internal retries
227
+ may make more than one request in a turn; no request count is inferred from
228
+ turn events. Compaction does not increase the model-turn count.
229
+ - Tool activity is keyed by tool-call ID, including concurrent/nested calls and
230
+ updates. It shows `Tool bash 26s` or `Tools 2 active`; tools cannot decide the
231
+ model run's outcome. Pi UI prompts take precedence with `Awaiting input`.
232
+ - `No content 10s` means **no non-empty text, reasoning or tool-call delta has
233
+ been observed** for ten seconds. It uses the same muted color as Avg.
234
+ It is not a server timeout or request phase.
235
+ Tools, compaction and UI waits suppress it. The integer threshold accepts
236
+ 1–3600 seconds. Empty deltas and usage-only updates do not reset it.
237
+ - Per-run usage includes only actual assistant messages. Streaming counters are
238
+ replaced, not repeatedly added. `Avg = output tokens / total run seconds`,
239
+ including tools, UI waits, retries and compaction; it is not inference speed.
240
+ `Cache = cacheRead / (input + cacheRead + cacheWrite)`. Missing usage/invalid
241
+ denominators are unavailable, not made-up zeroes. A genuinely reported zero
242
+ remains zero. These values never replace cumulative session totals.
243
+ - `Compacting… 4s` suppresses rate/cache noise while
244
+ compaction is active. Success restores the base row; failure/cancellation can
245
+ show `Compaction failed` / `Compaction cancelled` while no model run is active,
246
+ until subsequent activity.
247
+ - `Compactions` counts unique successful entries across the **whole session file**,
248
+ including other branches, not merely the selected tree path.
249
+ `runtime.enabled: false` hides the statusline's runtime metrics. Individual
250
+ options hide their corresponding items; `status` controls the local Elapsed
251
+ and compaction-result labels. The `statuses` segment independently controls other
252
+ extensions' text. If its `tps` entry is precisely a plain `Elapsed …` duration,
253
+ that display text is moved to the front and replaces the local Elapsed item.
254
+ All other statuses remain opaque text: they are never parsed into counters or
255
+ provider metrics. The provider's Elapsed may use its own pause
256
+ semantics; Avg always uses the independent monotonic run clock.
257
+
258
+ Runtime summaries are in-memory since extension load; reload/new session resets
259
+ them, unlike persisted usage/compaction totals.
260
+
261
+ ## Existing provider statuses
262
+
263
+ The CLIProxyAPI provider already publishes an Elapsed footer status through
264
+ `setStatus("tps", ...)` and a TPS/token-summary notification at settlement. The
265
+ statusline can reuse the explicit Elapsed text; it does not parse the notification.
266
+ Its own Avg and Cache come from Pi-reported assistant usage and the local run clock.
267
+
268
+ No provider changes, adapter handshake or private network instrumentation are
269
+ required. Request counts, first-text/reasoning request latency, per-request duration,
270
+ transport reporting and retry associations are not exposed by this package. The
271
+ provider may implement transports/retries internally without exposing these metrics.
145
272
 
146
273
  ## Statistics and refresh behavior
147
274
 
@@ -171,7 +298,9 @@ Rendering performs no file reads, Git queries or full-session scans. Streaming
171
298
  refresh requests are coalesced to 100 ms. Narrow terminals shorten model/path
172
299
  labels, then hide cost, cache/token counters, the thinking suffix, Git and directory in
173
300
  that order, retaining model/context where possible. The secondary status row
174
- is truncated to fit. Reload, session replacement, disabling and shutdown
301
+ drops extension text and cache/rate first, preserving
302
+ leading Elapsed and compaction status where possible, then truncates by terminal
303
+ cell width. Reload, session replacement, disabling and shutdown
175
304
  release timers and subscriptions. RPC, JSON and print mode do not install UI
176
305
  components or start polling.
177
306
 
@@ -184,8 +313,13 @@ npm test --workspace @erichll/pi-statusline
184
313
  npm run smoke --workspace @erichll/pi-statusline
185
314
  ```
186
315
 
187
- Tests cover rendering and panel interactions, configuration persistence, session
188
- usage/lifecycle, and real temporary Git repositories. To roll back a persistent
316
+ Tests cover rendering and panel interactions, configuration persistence, runtime
317
+ and extension-event wiring, compaction, session usage and real temporary Git
318
+ repositories. The POSIX PTY smoke runs **regular and fullscreen**, with isolated
319
+ agent/project directories and an entirely offline provider. It covers streaming, nested tools/UI waits,
320
+ no-content/cancellation, failed/successful compaction, model/theme changes,
321
+ 40-column layout, draft cancel/save, off/on, reload and new session. It writes
322
+ `/tmp/pi-statusline-pty-<layout>.log` for diagnosis. To roll back a persistent
189
323
  installation, disable this extension in `pi config`, re-enable the previous
190
324
  footer and reload Pi. `/statusline off` restores the native footer, not another
191
325
  extension's editor or widgets.
@@ -1,19 +1,71 @@
1
1
  {
2
2
  "version": 1,
3
3
  "enabled": true,
4
- "preset": "cometix",
5
- "icons": "unicode",
6
- "separator": "pipe",
4
+ "preset": "powerline",
5
+ "icons": "nerd",
6
+ "separator": "powerline",
7
7
  "pathMode": "basename",
8
8
  "modelDisplay": "name",
9
9
  "contextDisplay": "bar",
10
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
- ]
11
+ {
12
+ "id": "model",
13
+ "enabled": true
14
+ },
15
+ {
16
+ "id": "thinking",
17
+ "enabled": true
18
+ },
19
+ {
20
+ "id": "context",
21
+ "enabled": true
22
+ },
23
+ {
24
+ "id": "directory",
25
+ "enabled": true
26
+ },
27
+ {
28
+ "id": "git",
29
+ "enabled": true
30
+ },
31
+ {
32
+ "id": "cost",
33
+ "enabled": false
34
+ },
35
+ {
36
+ "id": "input",
37
+ "enabled": false
38
+ },
39
+ {
40
+ "id": "output",
41
+ "enabled": false
42
+ },
43
+ {
44
+ "id": "tokens",
45
+ "enabled": true
46
+ },
47
+ {
48
+ "id": "cacheRead",
49
+ "enabled": false
50
+ },
51
+ {
52
+ "id": "cacheWrite",
53
+ "enabled": false
54
+ },
55
+ {
56
+ "id": "statuses",
57
+ "enabled": true
58
+ }
59
+ ],
60
+ "runtime": {
61
+ "enabled": true,
62
+ "status": true,
63
+ "tools": true,
64
+ "noContent": true,
65
+ "noContentSeconds": 10,
66
+ "avg": true,
67
+ "cache": true,
68
+ "counts": true,
69
+ "retainSummary": true
70
+ }
19
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-statusline",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Configurable Pi statusline with live preview, Git and session usage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,32 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import type { Clock } from "./runtime.ts";
3
+ import { safeReason } from "./text.ts";
4
+
5
+ export interface CompactionSnapshot {
6
+ active: boolean;
7
+ elapsedMs: number;
8
+ count: number;
9
+ last?: { status: "success" | "failed" | "cancelled"; reason?: string };
10
+ showFailure: boolean;
11
+ }
12
+ export class CompactionState {
13
+ private clock: Clock;
14
+ private started: number | null = null;
15
+ private ids = new Set<string>();
16
+ private last: CompactionSnapshot["last"];
17
+ private showFailure = false;
18
+ constructor(clock: Clock = () => performance.now()) { this.clock = clock; }
19
+ start(): void { if (this.started === null) this.started = this.clock(); this.showFailure = false; }
20
+ finish(status: "success" | "failed" | "cancelled", id?: string, reason?: string): void {
21
+ this.started = null; if (status === "success" && id) this.ids.add(id);
22
+ this.last = { status, ...(reason ? { reason: safeReason(reason) } : {}) }; this.showFailure = status !== "success";
23
+ }
24
+ activity(): void { this.showFailure = false; }
25
+ update(entries: readonly SessionEntry[]): void {
26
+ // getEntries contains the entire file, including compactions on other branches.
27
+ this.ids = new Set(entries.filter((entry) => entry.type === "compaction").map((entry) => entry.id));
28
+ }
29
+ snapshot(): CompactionSnapshot {
30
+ return { active: this.started !== null, elapsedMs: this.started === null ? 0 : Math.max(0, this.clock() - this.started), count: this.ids.size, last: this.last, showFailure: this.showFailure };
31
+ }
32
+ }
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import type { ColorValue, Preset, StatuslineConfig } from "./types.ts";
4
+ import type { ColorValue, StatuslineConfig } from "./types.ts";
5
5
  import { SEGMENT_IDS } from "./types.ts";
6
6
 
7
7
  // A deliberately small, documented set of Pi semantic foreground colors.
@@ -9,15 +9,13 @@ export const COLORS = ["text", "accent", "muted", "dim", "success", "warning", "
9
9
  export function validColor(value: unknown): value is ColorValue {
10
10
  return typeof value === "string" && (/^#[\da-f]{6}$/i.test(value) || (COLORS as readonly string[]).includes(value));
11
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"]);
12
+ export function presetConfig(): StatuslineConfig {
13
+ const enabled = new Set<string>(["model", "thinking", "directory", "git", "context", "tokens", "statuses"]);
14
14
  return {
15
- version: 1, enabled: true, preset, icons: "unicode",
16
- separator: preset === "powerline" ? "powerline" : "pipe", pathMode: "basename", modelDisplay: "name", contextDisplay: "bar",
15
+ version: 1, enabled: true, preset: "powerline", icons: "nerd",
16
+ separator: "powerline", pathMode: "basename", modelDisplay: "name", contextDisplay: "bar",
17
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" },
18
+ runtime: { enabled: true, status: true, tools: true, noContent: true, noContentSeconds: 10, avg: true, cache: true, counts: true, retainSummary: true },
21
19
  };
22
20
  }
23
21
  function record(value: unknown): value is Record<string, unknown> {
@@ -34,14 +32,15 @@ function boolean(value: unknown, name: string): boolean {
34
32
  export function parseConfig(raw: unknown): StatuslineConfig {
35
33
  if (!record(raw)) throw new Error("Configuration must be an object");
36
34
  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"));
35
+ if (raw.preset !== undefined) choice(raw.preset, ["powerline"] as const, "preset");
36
+ const config = presetConfig();
38
37
  if (raw.enabled !== undefined) config.enabled = boolean(raw.enabled, "enabled");
39
38
  if (raw.icons !== undefined) config.icons = choice(raw.icons, ["unicode", "nerd", "ascii"], "icons");
40
39
  if (raw.separator !== undefined) config.separator = choice(raw.separator, ["pipe", "dot", "space", "powerline"], "separator");
41
40
  if (raw.pathMode !== undefined) config.pathMode = choice(raw.pathMode, ["basename", "abbreviated", "full"], "pathMode");
42
41
  if (raw.modelDisplay !== undefined) config.modelDisplay = choice(raw.modelDisplay, ["name", "last"], "modelDisplay");
43
42
  if (raw.contextDisplay !== undefined) config.contextDisplay = choice(raw.contextDisplay, ["text", "bar"], "contextDisplay");
44
- for (const group of ["runtime", "providerMetrics"] as const) {
43
+ for (const group of ["runtime"] as const) {
45
44
  if (raw[group] === undefined) continue;
46
45
  if (!record(raw[group])) throw new Error(`Invalid ${group}`);
47
46
  for (const key of Object.keys(config[group])) {
@@ -53,10 +52,6 @@ export function parseConfig(raw: unknown): StatuslineConfig {
53
52
  } else (config[group] as unknown as Record<string, unknown>)[key] = boolean(value, `${group}.${key}`);
54
53
  }
55
54
  }
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
55
  if (raw.segments !== undefined) {
61
56
  if (!Array.isArray(raw.segments)) throw new Error("segments must be an array");
62
57
  const seen = new Set<string>();
package/src/index.ts CHANGED
@@ -24,31 +24,45 @@ export default function statusline(pi: ExtensionAPI): void {
24
24
  session.install();
25
25
  });
26
26
  pi.on("session_shutdown", () => { generation++; session?.dispose(); session = undefined; });
27
+ pi.on("agent_start", (_event, ctx) => { session?.setContext(ctx); session?.startRun(); session?.schedule(true); });
28
+ pi.on("turn_start", (_event, ctx) => { session?.setContext(ctx); session?.startTurn(); session?.schedule(true); });
29
+ pi.on("agent_before_settle", (event) => { session?.runtime.beforeSettle(event.outcome); });
30
+ pi.on("agent_settled", (_event, ctx) => { session?.setContext(ctx); session?.settle(); session?.schedule(true); });
31
+ pi.on("ui_prompt_start", () => { session?.runtime.uiStart(); session?.schedule(true); });
32
+ pi.on("ui_prompt_end", () => { session?.runtime.uiEnd(); session?.schedule(true); });
33
+ pi.on("tool_execution_start", (event, ctx) => { session?.setContext(ctx); session?.runtime.toolStart(event.toolCallId, event.toolName); session?.schedule(true); });
34
+ pi.on("session_before_compact", (_event, ctx) => {
35
+ session?.setContext(ctx); session?.compaction.start(); session?.runtime.compaction(true); session?.schedule(true);
36
+ });
27
37
  pi.on("message_start", (event, ctx) => {
28
38
  session?.setContext(ctx);
29
- if (event.message.role === "assistant") session?.setLive(undefined);
39
+ if (event.message.role === "assistant") { session?.setLive(undefined); session?.runtime.messageStart(event.message); }
30
40
  session?.schedule(true);
31
41
  });
32
42
  pi.on("message_update", (event, ctx) => {
33
43
  session?.setContext(ctx);
34
- if (event.message.role === "assistant") session?.setLive(event.message);
44
+ if (event.message.role === "assistant") { session?.setLive(event.message); session?.runtime.stream(event.assistantMessageEvent); session?.runtime.message(event.message); }
35
45
  session?.schedule();
36
46
  });
37
47
  pi.on("message_end", (event, ctx) => {
38
48
  session?.setContext(ctx);
39
- if (event.message.role === "assistant") session?.setLive(event.message);
49
+ if (event.message.role === "assistant") { session?.setLive(event.message); session?.runtime.message(event.message, true); }
40
50
  session?.schedule(true);
41
51
  });
42
52
  const refresh = (_event: unknown, ctx: ExtensionContext) => { session?.setContext(ctx); session?.schedule(true); };
43
53
  const resetLive = (event: unknown, ctx: ExtensionContext) => { session?.setLive(undefined); refresh(event, ctx); };
44
54
  pi.on("agent_end", resetLive);
45
- pi.on("turn_end", refresh);
55
+ pi.on("turn_end", (event, ctx) => { if (event.message.role === "assistant") session?.runtime.message(event.message, true); session?.runtime.turnEnd(); refresh(event, ctx); });
46
56
  pi.on("model_select", refresh);
47
57
  pi.on("thinking_level_select", refresh);
48
- pi.on("session_compact", resetLive);
49
- pi.on("session_compact_failed", refresh);
58
+ pi.on("session_compact", (event, ctx) => {
59
+ session?.compaction.finish("success", event.compactionEntry.id); session?.runtime.compaction(false); resetLive(event, ctx);
60
+ });
61
+ pi.on("session_compact_failed", (event, ctx) => {
62
+ session?.compaction.finish(event.aborted ? "cancelled" : "failed", undefined, event.errorMessage); session?.runtime.compaction(false); refresh(event, ctx);
63
+ });
50
64
  pi.on("session_tree", resetLive);
51
- pi.on("tool_execution_end", (_event, ctx) => { session?.setContext(ctx); session?.requestGit(); session?.schedule(true); });
65
+ pi.on("tool_execution_end", (event, ctx) => { session?.setContext(ctx); session?.runtime.toolEnd(event.toolCallId); session?.requestGit(); session?.schedule(true); });
52
66
  pi.on("user_bash", (_event, ctx) => { session?.setContext(ctx); session?.requestGit(); });
53
67
 
54
68
  pi.registerCommand("statusline", {
@@ -67,7 +81,9 @@ export default function statusline(pi: ExtensionAPI): void {
67
81
  if (token !== generation) return;
68
82
  config = loaded.config;
69
83
  if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
70
- current.apply(config);
84
+ current.dispose();
85
+ session = new FooterSession(ctx, config);
86
+ session.refresh(true); session.install();
71
87
  return;
72
88
  }
73
89
  if (command) { ctx.ui.notify("Usage: /statusline [on|off|reload]", "info"); return; }
package/src/panel.ts CHANGED
@@ -26,6 +26,7 @@ export class StatuslinePanel implements Component, Focusable {
26
26
  private disposed = false;
27
27
  private hasFocus = false;
28
28
  private listHeight = 0;
29
+ private section: "runtime" | undefined;
29
30
 
30
31
  constructor(options: PanelOptions) { this.options = options; this.draft = structuredClone(options.config); this.rebuild(); }
31
32
  get focused(): boolean { return this.hasFocus; }
@@ -39,6 +40,10 @@ export class StatuslinePanel implements Component, Focusable {
39
40
  return thinking;
40
41
  }
41
42
  private items(): SelectItem[] {
43
+ if (this.section) {
44
+ const labels: Record<string, string> = { enabled: "Runtime metrics", status: "Elapsed / compaction status", tools: "Tool activity", noContent: "No content warning", noContentSeconds: "No content threshold (seconds)", avg: "Average output speed", cache: "Cache read ratio", counts: "Turns and compactions", retainSummary: "Retain last run summary" };
45
+ return [...Object.entries(this.draft[this.section]).map(([key, value]) => ({ value: `option:${key}`, label: `${labels[key] ?? key}: ${value}` })), { value: "back", label: "Back to settings" }];
46
+ }
42
47
  if (this.editing) {
43
48
  const segment = this.editing;
44
49
  return [
@@ -52,7 +57,6 @@ export class StatuslinePanel implements Component, Focusable {
52
57
  ];
53
58
  }
54
59
  return [
55
- { value: "preset", label: `Preset: ${this.draft.preset}`, description: "Cycles presets and resets segment styles/order" },
56
60
  { value: "defaultEnabled", label: `Enable on startup: ${this.draft.enabled ? "yes" : "no"}` },
57
61
  { value: "icons", label: `Icons: ${this.draft.icons}` },
58
62
  { value: "separator", label: `Separator: ${this.draft.separator}` },
@@ -60,6 +64,7 @@ export class StatuslinePanel implements Component, Focusable {
60
64
  { value: "modelDisplay", label: `Model display: ${this.draft.modelDisplay}`, description: "name: display name · last: final model ID component" },
61
65
  { value: "contextDisplay", label: `Context display: ${this.draft.contextDisplay}`, description: "text: tokens/capacity(percent) · bar: progress and percent" },
62
66
  ...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" })),
67
+ { value: "runtime", label: "Runtime options" },
63
68
  { value: "save", label: "Save and apply globally" },
64
69
  { value: "reset", label: "Restore defaults in preview" },
65
70
  { value: "cancel", label: "Cancel" },
@@ -78,11 +83,29 @@ export class StatuslinePanel implements Component, Focusable {
78
83
  }
79
84
  private cancel(): void {
80
85
  if (this.input) { this.input = undefined; this.error = ""; }
86
+ else if (this.section) { const section = this.section; this.section = undefined; this.rebuild(section); }
81
87
  else if (this.editing) { const id = this.editing.id; this.editing = undefined; this.rebuild(`segment:${id}`); }
82
88
  else this.options.done();
83
89
  }
84
90
  private activate(value: string): void {
85
91
  this.error = "";
92
+ if (this.section) {
93
+ if (value === "back") { this.cancel(); return; }
94
+ const key = value.slice("option:".length);
95
+ if (key === "noContentSeconds") {
96
+ this.input = new Input(); this.input.focused = this.hasFocus; this.input.setValue(String(this.draft.runtime.noContentSeconds));
97
+ this.inputLabel = "No content threshold (1–3600 seconds)";
98
+ this.input.onEscape = () => this.cancel();
99
+ this.input.onSubmit = (text) => {
100
+ try { this.draft = parseConfig({ ...this.draft, runtime: { ...this.draft.runtime, noContentSeconds: Number(text) } }); this.input = undefined; this.error = ""; this.rebuild(value); }
101
+ catch (error) { this.error = error instanceof Error ? error.message : String(error); }
102
+ };
103
+ } else {
104
+ const group = this.draft[this.section] as unknown as Record<string, unknown>;
105
+ if (typeof group[key] === "boolean") group[key] = !group[key];
106
+ }
107
+ this.rebuild(value); return;
108
+ }
86
109
  if (this.editing) {
87
110
  if (value === "back") this.cancel();
88
111
  else if (value === "enabled") { this.editing.enabled = !this.editing.enabled; this.rebuild(value); }
@@ -92,22 +115,13 @@ export class StatuslinePanel implements Component, Focusable {
92
115
  }
93
116
  const cycle = <T,>(current: T, options: readonly T[]): T => options[(options.indexOf(current) + 1) % options.length]!;
94
117
  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
118
  case "defaultEnabled": this.draft.enabled = !this.draft.enabled; break;
106
119
  case "icons": this.draft.icons = cycle(this.draft.icons, ["unicode", "nerd", "ascii"]); break;
107
120
  case "separator": this.draft.separator = cycle(this.draft.separator, ["pipe", "dot", "space", "powerline"]); break;
108
121
  case "pathMode": this.draft.pathMode = cycle(this.draft.pathMode, ["basename", "abbreviated", "full"]); break;
109
122
  case "modelDisplay": this.draft.modelDisplay = cycle(this.draft.modelDisplay, ["name", "last"]); break;
110
123
  case "contextDisplay": this.draft.contextDisplay = cycle(this.draft.contextDisplay, ["text", "bar"]); break;
124
+ case "runtime": this.section = value; this.rebuild(); return;
111
125
  case "reset": this.draft = presetConfig(); break;
112
126
  case "cancel": this.options.done(); return;
113
127
  case "save": void this.save(); return;
@@ -160,7 +174,8 @@ export class StatuslinePanel implements Component, Focusable {
160
174
  [this.draft.segments[segmentIndex], this.draft.segments[next]] = [this.draft.segments[next]!, this.draft.segments[segmentIndex]!];
161
175
  this.rebuild(selected);
162
176
  }
163
- } else this.list.handleInput(data);
177
+ } else if (this.section && matchesKey(data, "space") && selected?.startsWith("option:")) this.activate(selected);
178
+ else this.list.handleInput(data);
164
179
  }
165
180
  if (!this.disposed) this.options.requestRender();
166
181
  }
@@ -172,7 +187,7 @@ export class StatuslinePanel implements Component, Focusable {
172
187
  const { data, example } = this.options.snapshot();
173
188
  const preview = renderFooter(width, data, this.draft, this.options.theme());
174
189
  const lines = [
175
- this.paint(`Statusline settings${this.editing ? ` / ${this.editing.id}` : ""}`, "accent"),
190
+ this.paint(`Statusline settings${this.editing ? ` / ${this.editing.id}` : this.section ? ` / ${this.section}` : ""}`, "accent"),
176
191
  this.paint(example ? "Preview · example data" : "Preview · current session", "muted"),
177
192
  ...(preview.length ? preview : [this.paint("(all segments hidden)", "muted")]),
178
193
  this.paint("Changes are a draft until Save. Fees are Pi-reported USD.", "muted"),
package/src/render.ts CHANGED
@@ -6,6 +6,7 @@ export { cleanText } from "./text.ts";
6
6
  import type { ColorValue, SegmentConfig, SegmentId, StatuslineConfig, StatuslineSnapshot } from "./types.ts";
7
7
  import { emptyGit } from "./git.ts";
8
8
  import { emptyUsage } from "./usage.ts";
9
+ import { renderRuntime } from "./runtime-render.ts";
9
10
 
10
11
  export type RenderTheme = Pick<Theme, "style" | "colors" | "appearance">;
11
12
  // Keep rainbow hues independent of semantic theme colors, which can repeat.
@@ -77,7 +78,7 @@ function segmentText(id: SegmentId, data: StatuslineSnapshot, config: Statusline
77
78
  case "statuses": return [...data.statuses.values()].map(cleanText).filter(Boolean).join(" · ") || undefined;
78
79
  }
79
80
  }
80
- function defaultColor(id: SegmentId, data: StatuslineSnapshot, config: StatuslineConfig): ColorValue {
81
+ function defaultColor(id: SegmentId, data: StatuslineSnapshot): ColorValue {
81
82
  if (id === "context") {
82
83
  if ((data.context.percent ?? 0) > 90) return "error";
83
84
  if ((data.context.percent ?? 0) > 70) return "warning";
@@ -91,7 +92,6 @@ function defaultColor(id: SegmentId, data: StatuslineSnapshot, config: Statuslin
91
92
  const colors: Record<string, ColorValue> = { off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax" };
92
93
  return colors[data.thinking] ?? "muted";
93
94
  }
94
- if (config.preset === "minimal") return "muted";
95
95
  if (id === "model") return "accent";
96
96
  if (id === "directory") return "success";
97
97
  if (id === "context") return "#c678dd";
@@ -154,19 +154,23 @@ export function renderFooter(width: number, data: StatuslineSnapshot, config: St
154
154
  if (width === 0) return [];
155
155
  const parts: Part[] = [];
156
156
  let secondary: Part | undefined;
157
+ // CLIProxyAPI's published `tps` status is display text only.
158
+ const tps = cleanText(data.statuses.get("tps") ?? "");
159
+ const elapsedStatus = config.segments.some((s) => s.id === "statuses" && s.enabled) && /^Elapsed \d+[dhms](?: \d+[hms])*$/.test(tps) ? tps : undefined;
160
+ const statuses = elapsedStatus ? new Map([...data.statuses].filter(([key]) => key !== "tps")) : data.statuses;
157
161
  const thinking = config.segments.find((segment) => segment.id === "thinking");
158
162
  for (const segment of config.segments) {
159
163
  if (!segment.enabled) continue;
160
- const raw = segmentText(segment.id, data, config);
164
+ const raw = segmentText(segment.id, segment.id === "statuses" ? { ...data, statuses } : data, config);
161
165
  if (raw === undefined) continue;
162
166
  let text = cleanText(raw);
163
167
  if (config.icons === "ascii") text = text.replaceAll(" · ", " / ").replaceAll("…", "...");
164
168
  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 };
169
+ const part: Part = { config: segment, text, icon: cleanText(segment.icon ?? ICONS[config.icons][segment.id] ?? ""), foreground: segment.color ?? defaultColor(segment.id, data), background };
166
170
  if (segment.id === "model" && data.reasoning && thinking?.enabled) {
167
171
  part.thinking = {
168
172
  text: cleanText(data.thinking || "off"),
169
- foreground: thinking.color ?? (["high", "xhigh", "max"].includes(data.thinking) ? "rainbow" : defaultColor("thinking", data, config)),
173
+ foreground: thinking.color ?? (["high", "xhigh", "max"].includes(data.thinking) ? "rainbow" : defaultColor("thinking", data)),
170
174
  };
171
175
  }
172
176
  if (segment.id === "statuses") secondary = part;
@@ -194,7 +198,8 @@ export function renderFooter(width: number, data: StatuslineSnapshot, config: St
194
198
  }
195
199
  const lines: string[] = [];
196
200
  if (parts.length) lines.push(truncateToWidth(line, width, ellipsis));
197
- if (secondary) lines.push(truncateToWidth(renderPart(secondary, theme), width, ellipsis));
201
+ const secondLine = renderRuntime(width, data, config, theme, secondary ? renderPart(secondary, theme) : undefined, elapsedStatus);
202
+ if (secondLine) lines.push(secondLine);
198
203
  return lines;
199
204
  }
200
205
 
@@ -205,5 +210,7 @@ export function sampleSnapshot(): StatuslineSnapshot {
205
210
  usage: { ...emptyUsage(), input: 32000, output: 5400, cacheRead: 12000, cost: 0.12 },
206
211
  git: { ...emptyGit("repository"), branch: "main", unstaged: 2, ahead: 2 },
207
212
  statuses: new Map([["example", "Example extension status"]]),
213
+ runtime: { runId: "example", status: "Running", elapsedMs: 28000, turns: 3, toolCalls: 2, tools: [], awaitingInput: false, noContentMs: 0, usage: { ...emptyUsage(), output: 1260 }, avg: 45, cache: 0.86 },
214
+ compaction: { active: false, elapsedMs: 0, count: 1, showFailure: false },
208
215
  };
209
216
  }
@@ -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 CHANGED
@@ -6,8 +6,12 @@ import { emptyGit, GitPoller, type GitReader } from "./git.ts";
6
6
  import { renderFooter } from "./render.ts";
7
7
  import type { StatuslineConfig, StatuslineSnapshot } from "./types.ts";
8
8
  import { UsageCache } from "./usage.ts";
9
+ import { RuntimeState } from "./runtime.ts";
10
+ import { CompactionState } from "./compaction.ts";
9
11
 
10
12
  export class FooterSession {
13
+ readonly runtime = new RuntimeState();
14
+ readonly compaction = new CompactionState();
11
15
  private ctx: ExtensionContext;
12
16
  private config: StatuslineConfig;
13
17
  private usage = new UsageCache();
@@ -24,22 +28,43 @@ export class FooterSession {
24
28
  private displayKey = "";
25
29
  private context: StatuslineSnapshot["context"] = { tokens: null, contextWindow: 0, percent: null };
26
30
  private readonly gitReader: GitReader | undefined;
31
+ private signal: AbortSignal | undefined;
32
+ private readonly onAbort = () => { this.runtime.observeAbort(); this.schedule(true); };
27
33
 
28
34
  constructor(ctx: ExtensionContext, config: StatuslineConfig, gitReader?: GitReader) {
29
35
  this.ctx = ctx; this.config = config; this.gitReader = gitReader;
30
36
  }
31
37
  get active(): boolean { return this.ownsFooter && !this.closed; }
32
- setContext(ctx: ExtensionContext): void { this.ctx = ctx; }
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(); }
33
57
  install(): void {
34
58
  if (this.closed || this.ctx.mode !== "tui" || !this.config.enabled) return;
35
59
  this.ctx.ui.setFooter((tui, _theme, provider) => {
36
60
  this.ownsFooter = true; this.tui = tui; this.provider = provider;
61
+ this.watchSignal();
37
62
  this.poller = new GitPoller(this.ctx.cwd, (git) => { this.git = git; this.repaint(); }, this.gitReader);
38
63
  this.unsubscribe = provider.onBranchChange(() => this.requestGit());
39
64
  if (this.config.segments.some((segment) => segment.id === "git" && segment.enabled)) this.poller.start();
40
65
  this.refresh(true);
41
66
  // Covers usage entries appended outside normal message events (e.g. cache warming).
42
- this.timer = setInterval(() => this.refresh(), 2000);
67
+ this.timer = setInterval(() => this.refresh(), 1000);
43
68
  this.timer.unref();
44
69
  return {
45
70
  render: (width: number) => renderFooter(width, this.snapshot(), this.config, this.ctx.ui.theme),
@@ -49,6 +74,7 @@ export class FooterSession {
49
74
  });
50
75
  }
51
76
  private stopResources(): void {
77
+ this.clearSignal();
52
78
  clearInterval(this.timer); clearTimeout(this.scheduled);
53
79
  this.timer = undefined; this.scheduled = undefined;
54
80
  this.poller?.dispose(); this.poller = undefined;
@@ -60,7 +86,7 @@ export class FooterSession {
60
86
  this.stopResources();
61
87
  if (restore && !this.closed) this.ctx.ui.setFooter(undefined);
62
88
  }
63
- dispose(): void { this.stopResources(); this.closed = true; }
89
+ dispose(): void { this.stopResources(); this.clearSignal(); this.closed = true; }
64
90
  apply(config: StatuslineConfig): void {
65
91
  if (this.closed) return;
66
92
  this.disable(); this.config = config;
@@ -83,11 +109,13 @@ export class FooterSession {
83
109
  const manager = this.ctx.sessionManager;
84
110
  const key = `${manager.getSessionId()}:${manager.getLeafId()}:${this.ctx.model?.provider}/${this.ctx.model?.id}/${this.ctx.model?.contextWindow}`;
85
111
  if (force || key !== this.statsKey) {
86
- this.usage.update(manager.getEntries());
112
+ const entries = manager.getEntries();
113
+ this.usage.update(entries);
114
+ this.compaction.update(entries);
87
115
  const context = this.ctx.getContextUsage();
88
116
  this.context = context ?? { tokens: null, contextWindow: this.ctx.model?.contextWindow ?? 0, percent: null };
89
- this.statsKey = key;
90
117
  }
118
+ this.statsKey = key;
91
119
  this.repaint();
92
120
  }
93
121
  private repaint(): void {
@@ -103,6 +131,7 @@ export class FooterSession {
103
131
  thinking: this.ctx.thinkingLevel ?? "off", cwd: this.ctx.cwd, home: homedir(),
104
132
  context: this.context, usage: this.usage.snapshot(), git: this.git,
105
133
  statuses: this.provider?.getExtensionStatuses() ?? new Map(),
134
+ runtime: this.runtime.snapshot(this.config.runtime.retainSummary), compaction: this.compaction.snapshot(),
106
135
  };
107
136
  }
108
137
  }
package/src/types.ts CHANGED
@@ -3,7 +3,7 @@ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
3
3
  export const SEGMENT_IDS = ["model", "thinking", "context", "directory", "git", "cost", "input", "output", "tokens", "cacheRead", "cacheWrite", "statuses"] as const;
4
4
  export type SegmentId = typeof SEGMENT_IDS[number];
5
5
  export type ColorValue = ThemeColor | `#${string}`;
6
- export type Preset = "cometix" | "minimal" | "powerline";
6
+ export type Preset = "powerline";
7
7
  export type IconMode = "unicode" | "nerd" | "ascii";
8
8
  export interface RuntimeConfig {
9
9
  enabled: boolean;
@@ -14,16 +14,8 @@ export interface RuntimeConfig {
14
14
  avg: boolean;
15
15
  cache: boolean;
16
16
  counts: boolean;
17
- untilCompact: boolean;
18
17
  retainSummary: boolean;
19
18
  }
20
- export interface ProviderMetricsConfig {
21
- phase: boolean;
22
- firstText: boolean;
23
- firstReasoning: boolean;
24
- duration: boolean;
25
- transport: boolean;
26
- }
27
19
  export interface SegmentConfig {
28
20
  id: SegmentId;
29
21
  enabled: boolean;
@@ -43,8 +35,6 @@ export interface StatuslineConfig {
43
35
  contextDisplay: "text" | "bar";
44
36
  segments: SegmentConfig[];
45
37
  runtime: RuntimeConfig;
46
- providerMetrics: ProviderMetricsConfig;
47
- integrations: { cliproxyapi: "auto" | "off" | "on" };
48
38
  }
49
39
  export interface UsageTotals {
50
40
  input: number;
@@ -77,5 +67,4 @@ export interface StatuslineSnapshot {
77
67
  statuses: ReadonlyMap<string, string>;
78
68
  runtime?: import("./runtime.ts").RuntimeSnapshot;
79
69
  compaction?: import("./compaction.ts").CompactionSnapshot;
80
- telemetry?: import("./receiver.ts").TelemetrySnapshot;
81
70
  }