@erichll/pi-statusline 0.1.0 → 0.3.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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-10-04
4
+
5
+ - Add `spacing` (0–3 blank lines, default 1) to control the gap between the input box and the statusline; configurable in the panel and persisted in the configuration.
6
+ - Hide the `Compactions` count while it is zero, including the first active compaction.
7
+
8
+ ## 0.2.0 - 2026-10-04
9
+
10
+ - Use the same muted color for No content and Avg; default modelDisplay to name while preserving explicit last preferences.
11
+ - Remove Minimal and the preset selector; support only Powerline, without migration for retired preset values.
12
+ - 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.
13
+ - Omit the startup/idle footer label and its separator.
14
+ - Lead the second row with one Elapsed duration; reuse explicit `tps` Elapsed display text without an extra Running/Done duration.
15
+ - Track monotonic runs through retries, tools, UI waits and compaction until `agent_settled`; retain the final run's footer summary.
16
+ - Add ID-based concurrent/nested tool activity, UI-wait precedence and configurable non-empty-content observation warnings (ten seconds by default).
17
+ - Add assistant-only per-run usage, whole-run average output rate and cache-read ratio without changing cumulative session totals.
18
+ - Add compaction activity/result and whole-session successful compaction counts.
19
+ - Remove the Until compact metric, its runtime setting and compaction-capacity calculation.
20
+ - Add runtime settings with draft preview, numeric validation and backward-compatible version-1 partial configuration.
21
+ - 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.
22
+ - Remove the read-only run details command/panel; retain footer statistics, configuration and live preview.
23
+ - Expand lifecycle/configuration/render tests and isolated offline PTY smoke across regular/fullscreen layouts.
24
+
3
25
  ## 0.1.0 - 2026-10-03
4
26
 
5
27
  - 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,94 @@ 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
+ "spacing": 1,
83
+ "segments": [
84
+ {
85
+ "id": "model",
86
+ "enabled": true
87
+ },
88
+ {
89
+ "id": "thinking",
90
+ "enabled": true
91
+ },
92
+ {
93
+ "id": "context",
94
+ "enabled": true
95
+ },
96
+ {
97
+ "id": "directory",
98
+ "enabled": true
99
+ },
100
+ {
101
+ "id": "git",
102
+ "enabled": true
103
+ },
104
+ {
105
+ "id": "cost",
106
+ "enabled": false
107
+ },
108
+ {
109
+ "id": "input",
110
+ "enabled": false
111
+ },
112
+ {
113
+ "id": "output",
114
+ "enabled": false
115
+ },
116
+ {
117
+ "id": "tokens",
118
+ "enabled": true
119
+ },
120
+ {
121
+ "id": "cacheRead",
122
+ "enabled": false
123
+ },
124
+ {
125
+ "id": "cacheWrite",
126
+ "enabled": false
127
+ },
128
+ {
129
+ "id": "statuses",
130
+ "enabled": true
131
+ }
132
+ ],
133
+ "runtime": {
134
+ "enabled": true,
135
+ "status": true,
136
+ "tools": true,
137
+ "noContent": true,
138
+ "noContentSeconds": 10,
139
+ "avg": true,
140
+ "cache": true,
141
+ "counts": true,
142
+ "retainSummary": true
143
+ }
78
144
  }
79
145
  ```
80
146
 
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
147
+ - Layout: `powerline` is the only preset; there is no preset selector.
148
+ Customize segments and styles directly, or restore defaults in preview.
149
+ Pi's current light/dark theme supplies semantic colors; Powerline backgrounds
150
+ adapt to its appearance.
151
+ - Icons: `nerd` (default; requires a Nerd Font), `unicode`, `ascii`. ASCII mode
85
152
  replaces built-in decorative glyphs, not user-provided names or custom icons.
86
153
  Both graphical modes use `` for the model by default; this glyph requires a
87
154
  Nerd Font. A custom model icon still overrides the default.
88
- - Separators: `pipe`, `dot`, `space`, `powerline`. ASCII mode uses plain separators.
155
+ - Separators: `powerline` (default), `pipe`, `dot`, `space`. ASCII mode uses plain separators.
89
156
  - 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.
157
+ - Model display: `name` (default) uses the display name with full ID fallback.
158
+ Choose `last` for the final non-empty `/`-separated component of the **model ID**,
159
+ even when a display name is configured. For example, `provider/a/b/c/d` becomes `d`.
160
+ Restoring defaults resets this preference to `name`.
95
161
  - Context display: `bar` (default, `[██░░░░░░░░]20%`) or `text`
96
162
  (`12.7k/272k(4.7%)`). Choose **Context display: bar** in the panel or set
97
- `"contextDisplay": "bar"`. Switching presets preserves this preference.
163
+ `"contextDisplay": "bar"`.
98
164
  The ten-cell bar uses whole blocks to avoid gaps inside partial glyphs. Fill
99
165
  rounds to the nearest cell, with at least one filled cell for non-zero usage;
100
166
  the percentage retains the more precise value. ASCII mode uses `#` and `-`.
@@ -102,6 +168,11 @@ existing file to the new path before running `/reload`; the old path is no longe
102
168
  the bar completely and retain the reported percentage. Both modes use the
103
169
  existing context warning colors. Text mode includes token counts and capacity;
104
170
  bar mode shows progress and percentage.
171
+ - Spacing: `1` (default), `0`, `2` or `3` blank lines above the statusline.
172
+ Pi renders the footer directly under the editor, so this is the only supported
173
+ way to add breathing room between the input box and the statusline; the editor's
174
+ own bottom border cannot be removed. Choose **Spacing above statusline** in the
175
+ panel or set `"spacing": 0` for a flush footer.
105
176
  - `segments`: optional ordered array of `{ id, enabled, icon?, color?, iconColor?, background? }`.
106
177
  An explicit list is authoritative: omitted segments are disabled but remain
107
178
  available in the panel. The `statuses` segment always occupies the second row.
@@ -133,15 +204,78 @@ Available segments:
133
204
  | `directory` | Current working directory | On |
134
205
  | `git` | Branch, clean/dirty/conflict, staged `+`, unstaged `*`, untracked `?`, ahead/behind | On |
135
206
  | `cost` | Pi-recorded cumulative USD cost; enable in the panel when wanted | Off |
136
- | `input`, `output`, `tokens` | Cumulative input, output, or total tokens | Off |
207
+ | `tokens` | Cumulative total tokens | On |
208
+ | `input`, `output` | Cumulative input or output tokens | Off |
137
209
  | `cacheRead`, `cacheWrite` | Cumulative cache token counts | Off |
138
- | `statuses` | Other extensions' `setStatus()` text, with terminal controls stripped | On |
210
+ | `statuses` | Other extensions' `setStatus()` text on the runtime row, with terminal controls stripped | On |
139
211
 
140
- The minimal preset enables directory, Git, context, and extension statuses.
212
+ The default Powerline preset enables model/thinking, context, directory, Git, total
213
+ tokens and extension statuses. Cost, input/output and cache token counters stay off.
141
214
  See [examples/statusline.json](examples/statusline.json) for an explicit layout.
142
- Unknown versions, duplicate segments, invalid colors and malformed JSON are
215
+ Unknown presets or versions, duplicate segments, invalid colors and malformed JSON are
143
216
  rejected with a notification; reload retains the last working configuration.
144
217
  Removing the file restores defaults on reload. Saves use an atomic file replacement.
218
+ Obsolete `runtime.untilCompact`, `providerMetrics` and `integrations` keys are
219
+ ignored and dropped on the next save; they do not require a configuration migration.
220
+
221
+ ## Runtime row and compaction
222
+
223
+ Runtime metrics use Pi's public events and reported usage, without modifying the
224
+ provider. `Elapsed`
225
+ leads the second row; no additional `Running 342s` or `Done 342s` duration is
226
+ shown. A run starts at the first `agent_start` and ends at `agent_settled`, not
227
+ `agent_end`. Retries, tools, UI waits and compaction do not reset its monotonic
228
+ clock. `retainSummary` keeps the last run's footer summary until the next run
229
+ (disable it to hide the run summary at settlement). The idle
230
+ footer has no Idle label or leading separator.
231
+
232
+ - `Turns` counts Pi `turn_start` events, not upstream requests. Internal retries
233
+ may make more than one request in a turn; no request count is inferred from
234
+ turn events. Compaction does not increase the model-turn count.
235
+ - Tool activity is keyed by tool-call ID, including concurrent/nested calls and
236
+ updates. It shows `Tool bash 26s` or `Tools 2 active`; tools cannot decide the
237
+ model run's outcome. Pi UI prompts take precedence with `Awaiting input`.
238
+ - `No content 10s` means **no non-empty text, reasoning or tool-call delta has
239
+ been observed** for ten seconds. It uses the same muted color as Avg.
240
+ It is not a server timeout or request phase.
241
+ Tools, compaction and UI waits suppress it. The integer threshold accepts
242
+ 1–3600 seconds. Empty deltas and usage-only updates do not reset it.
243
+ - Per-run usage includes only actual assistant messages. Streaming counters are
244
+ replaced, not repeatedly added. `Avg = output tokens / total run seconds`,
245
+ including tools, UI waits, retries and compaction; it is not inference speed.
246
+ `Cache = cacheRead / (input + cacheRead + cacheWrite)`. Missing usage/invalid
247
+ denominators are unavailable, not made-up zeroes. A genuinely reported zero
248
+ remains zero. These values never replace cumulative session totals.
249
+ - `Compacting… 4s` suppresses rate/cache noise while
250
+ compaction is active. Success restores the base row; failure/cancellation can
251
+ show `Compaction failed` / `Compaction cancelled` while no model run is active,
252
+ until subsequent activity.
253
+ - `Compactions` counts unique successful entries across the **whole session file**,
254
+ including other branches, not merely the selected tree path. It is hidden
255
+ while the count is zero, including during the first active compaction.
256
+ `runtime.enabled: false` hides the statusline's runtime metrics. Individual
257
+ options hide their corresponding items; `status` controls the local Elapsed
258
+ and compaction-result labels. The `statuses` segment independently controls other
259
+ extensions' text. If its `tps` entry is precisely a plain `Elapsed …` duration,
260
+ that display text is moved to the front and replaces the local Elapsed item.
261
+ All other statuses remain opaque text: they are never parsed into counters or
262
+ provider metrics. The provider's Elapsed may use its own pause
263
+ semantics; Avg always uses the independent monotonic run clock.
264
+
265
+ Runtime summaries are in-memory since extension load; reload/new session resets
266
+ them, unlike persisted usage/compaction totals.
267
+
268
+ ## Existing provider statuses
269
+
270
+ The CLIProxyAPI provider already publishes an Elapsed footer status through
271
+ `setStatus("tps", ...)` and a TPS/token-summary notification at settlement. The
272
+ statusline can reuse the explicit Elapsed text; it does not parse the notification.
273
+ Its own Avg and Cache come from Pi-reported assistant usage and the local run clock.
274
+
275
+ No provider changes, adapter handshake or private network instrumentation are
276
+ required. Request counts, first-text/reasoning request latency, per-request duration,
277
+ transport reporting and retry associations are not exposed by this package. The
278
+ provider may implement transports/retries internally without exposing these metrics.
145
279
 
146
280
  ## Statistics and refresh behavior
147
281
 
@@ -171,7 +305,9 @@ Rendering performs no file reads, Git queries or full-session scans. Streaming
171
305
  refresh requests are coalesced to 100 ms. Narrow terminals shorten model/path
172
306
  labels, then hide cost, cache/token counters, the thinking suffix, Git and directory in
173
307
  that order, retaining model/context where possible. The secondary status row
174
- is truncated to fit. Reload, session replacement, disabling and shutdown
308
+ drops extension text and cache/rate first, preserving
309
+ leading Elapsed and compaction status where possible, then truncates by terminal
310
+ cell width. Reload, session replacement, disabling and shutdown
175
311
  release timers and subscriptions. RPC, JSON and print mode do not install UI
176
312
  components or start polling.
177
313
 
@@ -184,8 +320,13 @@ npm test --workspace @erichll/pi-statusline
184
320
  npm run smoke --workspace @erichll/pi-statusline
185
321
  ```
186
322
 
187
- Tests cover rendering and panel interactions, configuration persistence, session
188
- usage/lifecycle, and real temporary Git repositories. To roll back a persistent
323
+ Tests cover rendering and panel interactions, configuration persistence, runtime
324
+ and extension-event wiring, compaction, session usage and real temporary Git
325
+ repositories. The POSIX PTY smoke runs **regular and fullscreen**, with isolated
326
+ agent/project directories and an entirely offline provider. It covers streaming, nested tools/UI waits,
327
+ no-content/cancellation, failed/successful compaction, model/theme changes,
328
+ 40-column layout, draft cancel/save, off/on, reload and new session. It writes
329
+ `/tmp/pi-statusline-pty-<layout>.log` for diagnosis. To roll back a persistent
189
330
  installation, disable this extension in `pi config`, re-enable the previous
190
331
  footer and reload Pi. `/statusline off` restores the native footer, not another
191
332
  extension's editor or widgets.
@@ -1,19 +1,72 @@
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
+ "spacing": 1,
10
11
  "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
- ]
12
+ {
13
+ "id": "model",
14
+ "enabled": true
15
+ },
16
+ {
17
+ "id": "thinking",
18
+ "enabled": true
19
+ },
20
+ {
21
+ "id": "context",
22
+ "enabled": true
23
+ },
24
+ {
25
+ "id": "directory",
26
+ "enabled": true
27
+ },
28
+ {
29
+ "id": "git",
30
+ "enabled": true
31
+ },
32
+ {
33
+ "id": "cost",
34
+ "enabled": false
35
+ },
36
+ {
37
+ "id": "input",
38
+ "enabled": false
39
+ },
40
+ {
41
+ "id": "output",
42
+ "enabled": false
43
+ },
44
+ {
45
+ "id": "tokens",
46
+ "enabled": true
47
+ },
48
+ {
49
+ "id": "cacheRead",
50
+ "enabled": false
51
+ },
52
+ {
53
+ "id": "cacheWrite",
54
+ "enabled": false
55
+ },
56
+ {
57
+ "id": "statuses",
58
+ "enabled": true
59
+ }
60
+ ],
61
+ "runtime": {
62
+ "enabled": true,
63
+ "status": true,
64
+ "tools": true,
65
+ "noContent": true,
66
+ "noContentSeconds": 10,
67
+ "avg": true,
68
+ "cache": true,
69
+ "counts": true,
70
+ "retainSummary": true
71
+ }
19
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-statusline",
3
- "version": "0.1.0",
3
+ "version": "0.3.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", spacing: 1,
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> {
@@ -31,17 +29,23 @@ function boolean(value: unknown, name: string): boolean {
31
29
  if (typeof value !== "boolean") throw new Error(`Invalid ${name}`);
32
30
  return value;
33
31
  }
32
+ function integer(value: unknown, min: number, max: number, name: string): number {
33
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new Error(`Invalid ${name} (${min}–${max})`);
34
+ return value;
35
+ }
34
36
  export function parseConfig(raw: unknown): StatuslineConfig {
35
37
  if (!record(raw)) throw new Error("Configuration must be an object");
36
38
  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"));
39
+ if (raw.preset !== undefined) choice(raw.preset, ["powerline"] as const, "preset");
40
+ const config = presetConfig();
38
41
  if (raw.enabled !== undefined) config.enabled = boolean(raw.enabled, "enabled");
39
42
  if (raw.icons !== undefined) config.icons = choice(raw.icons, ["unicode", "nerd", "ascii"], "icons");
40
43
  if (raw.separator !== undefined) config.separator = choice(raw.separator, ["pipe", "dot", "space", "powerline"], "separator");
41
44
  if (raw.pathMode !== undefined) config.pathMode = choice(raw.pathMode, ["basename", "abbreviated", "full"], "pathMode");
42
45
  if (raw.modelDisplay !== undefined) config.modelDisplay = choice(raw.modelDisplay, ["name", "last"], "modelDisplay");
43
46
  if (raw.contextDisplay !== undefined) config.contextDisplay = choice(raw.contextDisplay, ["text", "bar"], "contextDisplay");
44
- for (const group of ["runtime", "providerMetrics"] as const) {
47
+ if (raw.spacing !== undefined) config.spacing = integer(raw.spacing, 0, 3, "spacing");
48
+ for (const group of ["runtime"] as const) {
45
49
  if (raw[group] === undefined) continue;
46
50
  if (!record(raw[group])) throw new Error(`Invalid ${group}`);
47
51
  for (const key of Object.keys(config[group])) {
@@ -53,10 +57,6 @@ export function parseConfig(raw: unknown): StatuslineConfig {
53
57
  } else (config[group] as unknown as Record<string, unknown>)[key] = boolean(value, `${group}.${key}`);
54
58
  }
55
59
  }
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
60
  if (raw.segments !== undefined) {
61
61
  if (!Array.isArray(raw.segments)) throw new Error("segments must be an array");
62
62
  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
@@ -1,6 +1,6 @@
1
1
  import { Input, matchesKey, SelectList, truncateToWidth, type Component, type Focusable, type SelectItem } from "@earendil-works/pi-tui";
2
2
  import { COLORS, parseConfig, presetConfig } from "./config.ts";
3
- import { cleanText, renderFooter, type RenderTheme } from "./render.ts";
3
+ import { cleanText, renderFooter, withSpacing, type RenderTheme } from "./render.ts";
4
4
  import type { SegmentConfig, StatuslineConfig, StatuslineSnapshot } from "./types.ts";
5
5
 
6
6
  interface PanelOptions {
@@ -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,14 +57,15 @@ 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}` },
59
63
  { value: "pathMode", label: `Path: ${this.draft.pathMode}` },
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" },
66
+ { value: "spacing", label: `Spacing above statusline: ${this.draft.spacing}`, description: "Blank lines between the input box and the statusline (0–3)" },
62
67
  ...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" })),
68
+ { value: "runtime", label: "Runtime options" },
63
69
  { value: "save", label: "Save and apply globally" },
64
70
  { value: "reset", label: "Restore defaults in preview" },
65
71
  { value: "cancel", label: "Cancel" },
@@ -78,11 +84,29 @@ export class StatuslinePanel implements Component, Focusable {
78
84
  }
79
85
  private cancel(): void {
80
86
  if (this.input) { this.input = undefined; this.error = ""; }
87
+ else if (this.section) { const section = this.section; this.section = undefined; this.rebuild(section); }
81
88
  else if (this.editing) { const id = this.editing.id; this.editing = undefined; this.rebuild(`segment:${id}`); }
82
89
  else this.options.done();
83
90
  }
84
91
  private activate(value: string): void {
85
92
  this.error = "";
93
+ if (this.section) {
94
+ if (value === "back") { this.cancel(); return; }
95
+ const key = value.slice("option:".length);
96
+ if (key === "noContentSeconds") {
97
+ this.input = new Input(); this.input.focused = this.hasFocus; this.input.setValue(String(this.draft.runtime.noContentSeconds));
98
+ this.inputLabel = "No content threshold (1–3600 seconds)";
99
+ this.input.onEscape = () => this.cancel();
100
+ this.input.onSubmit = (text) => {
101
+ try { this.draft = parseConfig({ ...this.draft, runtime: { ...this.draft.runtime, noContentSeconds: Number(text) } }); this.input = undefined; this.error = ""; this.rebuild(value); }
102
+ catch (error) { this.error = error instanceof Error ? error.message : String(error); }
103
+ };
104
+ } else {
105
+ const group = this.draft[this.section] as unknown as Record<string, unknown>;
106
+ if (typeof group[key] === "boolean") group[key] = !group[key];
107
+ }
108
+ this.rebuild(value); return;
109
+ }
86
110
  if (this.editing) {
87
111
  if (value === "back") this.cancel();
88
112
  else if (value === "enabled") { this.editing.enabled = !this.editing.enabled; this.rebuild(value); }
@@ -92,22 +116,14 @@ export class StatuslinePanel implements Component, Focusable {
92
116
  }
93
117
  const cycle = <T,>(current: T, options: readonly T[]): T => options[(options.indexOf(current) + 1) % options.length]!;
94
118
  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
119
  case "defaultEnabled": this.draft.enabled = !this.draft.enabled; break;
106
120
  case "icons": this.draft.icons = cycle(this.draft.icons, ["unicode", "nerd", "ascii"]); break;
107
121
  case "separator": this.draft.separator = cycle(this.draft.separator, ["pipe", "dot", "space", "powerline"]); break;
108
122
  case "pathMode": this.draft.pathMode = cycle(this.draft.pathMode, ["basename", "abbreviated", "full"]); break;
109
123
  case "modelDisplay": this.draft.modelDisplay = cycle(this.draft.modelDisplay, ["name", "last"]); break;
110
124
  case "contextDisplay": this.draft.contextDisplay = cycle(this.draft.contextDisplay, ["text", "bar"]); break;
125
+ case "spacing": this.draft.spacing = cycle(this.draft.spacing, [0, 1, 2, 3]); break;
126
+ case "runtime": this.section = value; this.rebuild(); return;
111
127
  case "reset": this.draft = presetConfig(); break;
112
128
  case "cancel": this.options.done(); return;
113
129
  case "save": void this.save(); return;
@@ -160,7 +176,8 @@ export class StatuslinePanel implements Component, Focusable {
160
176
  [this.draft.segments[segmentIndex], this.draft.segments[next]] = [this.draft.segments[next]!, this.draft.segments[segmentIndex]!];
161
177
  this.rebuild(selected);
162
178
  }
163
- } else this.list.handleInput(data);
179
+ } else if (this.section && matchesKey(data, "space") && selected?.startsWith("option:")) this.activate(selected);
180
+ else this.list.handleInput(data);
164
181
  }
165
182
  if (!this.disposed) this.options.requestRender();
166
183
  }
@@ -170,9 +187,9 @@ export class StatuslinePanel implements Component, Focusable {
170
187
  if (width <= 0) return [];
171
188
  if (this.listHeight !== Math.max(1, this.options.height() - 12)) this.rebuild(this.list.getSelectedItem()?.value);
172
189
  const { data, example } = this.options.snapshot();
173
- const preview = renderFooter(width, data, this.draft, this.options.theme());
190
+ const preview = withSpacing(renderFooter(width, data, this.draft, this.options.theme()), this.draft.spacing);
174
191
  const lines = [
175
- this.paint(`Statusline settings${this.editing ? ` / ${this.editing.id}` : ""}`, "accent"),
192
+ this.paint(`Statusline settings${this.editing ? ` / ${this.editing.id}` : this.section ? ` / ${this.section}` : ""}`, "accent"),
176
193
  this.paint(example ? "Preview · example data" : "Preview · current session", "muted"),
177
194
  ...(preview.length ? preview : [this.paint("(all segments hidden)", "muted")]),
178
195
  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,10 +198,21 @@ 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
 
206
+ /**
207
+ * Pi renders the footer directly under the editor, so leading blank lines are the only
208
+ * extension-side way to adjust the gap between the input box and the statusline.
209
+ */
210
+ export function withSpacing(lines: string[], spacing: number): string[] {
211
+ const count = Number.isFinite(spacing) ? Math.max(0, Math.floor(spacing)) : 0;
212
+ if (lines.length === 0 || count === 0) return lines;
213
+ return [...Array<string>(count).fill(""), ...lines];
214
+ }
215
+
201
216
  export function sampleSnapshot(): StatuslineSnapshot {
202
217
  return {
203
218
  model: "Example model", modelId: "example/provider/model", reasoning: true, thinking: "high", cwd: "/projects/pi-packages", home: "/home/example",
@@ -205,5 +220,7 @@ export function sampleSnapshot(): StatuslineSnapshot {
205
220
  usage: { ...emptyUsage(), input: 32000, output: 5400, cacheRead: 12000, cost: 0.12 },
206
221
  git: { ...emptyGit("repository"), branch: "main", unstaged: 2, ahead: 2 },
207
222
  statuses: new Map([["example", "Example extension status"]]),
223
+ 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 },
224
+ compaction: { active: false, elapsedMs: 0, count: 1, showFailure: false },
208
225
  };
209
226
  }
@@ -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 && compact.count > 0) 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 && compact.count > 0) 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
@@ -3,11 +3,15 @@ import type { AssistantMessage } from "@earendil-works/pi-ai";
3
3
  import type { ExtensionContext, ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent";
4
4
  import type { TUI } from "@earendil-works/pi-tui";
5
5
  import { emptyGit, GitPoller, type GitReader } from "./git.ts";
6
- import { renderFooter } from "./render.ts";
6
+ import { renderFooter, withSpacing } 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,31 +28,53 @@ 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
- render: (width: number) => renderFooter(width, this.snapshot(), this.config, this.ctx.ui.theme),
70
+ render: (width: number) => withSpacing(renderFooter(width, this.snapshot(), this.config, this.ctx.ui.theme), this.config.spacing),
46
71
  invalidate: () => {}, // Theme and extension statuses are read fresh on every render.
47
72
  dispose: () => this.stopResources(),
48
73
  };
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;
@@ -41,10 +33,10 @@ export interface StatuslineConfig {
41
33
  pathMode: "basename" | "abbreviated" | "full";
42
34
  modelDisplay: "name" | "last";
43
35
  contextDisplay: "text" | "bar";
36
+ /** Blank lines rendered above the statusline, between the editor and the footer (0–3). */
37
+ spacing: number;
44
38
  segments: SegmentConfig[];
45
39
  runtime: RuntimeConfig;
46
- providerMetrics: ProviderMetricsConfig;
47
- integrations: { cliproxyapi: "auto" | "off" | "on" };
48
40
  }
49
41
  export interface UsageTotals {
50
42
  input: number;
@@ -77,5 +69,4 @@ export interface StatuslineSnapshot {
77
69
  statuses: ReadonlyMap<string, string>;
78
70
  runtime?: import("./runtime.ts").RuntimeSnapshot;
79
71
  compaction?: import("./compaction.ts").CompactionSnapshot;
80
- telemetry?: import("./receiver.ts").TelemetrySnapshot;
81
72
  }