@erichll/pi-statusline 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
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
+
20
+ ## 0.1.0 - 2026-10-03
21
+
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.
23
+ - Place context immediately after the model in preset layouts and add a configurable context progress bar (`contextDisplay: "bar"`).
24
+ - Hide the cost segment by default in every preset; it can still be enabled in settings.
25
+ - Use an independent rainbow palette so themes with repeated semantic colors still display distinct thinking-level hues.
26
+ - Use `` as the default model glyph and append the thinking level in parentheses, coloring the entire `(level)` with per-level colors and rainbow high/xhigh/max.
27
+ - Move thinking controls into the model settings; retain existing `thinking` enablement/color settings for compatibility.
28
+ - Move configuration to `extensions/pi-statusline/config.json` under Pi's agent directory.
29
+ - Compact context usage to `used/capacity(percent%)`, removing separator padding.
30
+ - Add `modelDisplay: "last"` to show only the final component of a slash-separated model ID, configurable in the preview panel.
31
+ - Add a native Pi footer with configurable presets.
32
+ - Add configurable segments, Unicode/Nerd Font/ASCII icons, colors and path display.
33
+ - Add a keyboard-driven configuration panel using the same renderer as the live footer.
34
+ - Add asynchronous Git state, native session usage accounting and extension-status display.
35
+ - Handle narrow terminals, session replacement, compaction and non-interactive modes.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,330 @@
1
- # Temporary Holding Version
1
+ # @erichll/pi-statusline
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A configurable footer for Pi 1.0 with Git status, session usage, runtime/tool activity, themes, and live configuration preview. Uses Pi's native footer and leaves the editor intact.
4
+
5
+ ```text
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
+ ```
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ pi install npm:@erichll/pi-statusline
14
+ ```
15
+
16
+ Requires Node.js >=22.19, Pi >=1.0.0, 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
19
+ other runtime dependencies and makes no network requests or credential lookups.
20
+ Only one extension should own Pi's footer, so disable any other footer
21
+ extension in `pi config` before enabling this one.
22
+
23
+ ## Local use
24
+
25
+ From this repository, try the extension for one invocation:
26
+
27
+ ```sh
28
+ npm install
29
+ pi --no-extensions --extension ./packages/pi-statusline/src/index.ts
30
+ ```
31
+
32
+ `--no-extensions` disables other auto-discovered extensions for this invocation.
33
+ To use your other extensions, disable `pi-powerline-footer` in `pi config`
34
+ before starting Pi with `--extension ./packages/pi-statusline/src/index.ts`.
35
+ Only one extension should own the footer. The previous package can remain
36
+ installed for rollback. Loading this repository as a whole also discovers the
37
+ security extensions; use the statusline entrypoint to try it on its own.
38
+
39
+ This checkout is for development only. For a persistent install use the
40
+ published package, `npm:@erichll/pi-statusline`.
41
+
42
+ ## Commands
43
+
44
+ | Command | Behavior |
45
+ | --- | --- |
46
+ | `/statusline` | Open configuration and live preview |
47
+ | `/statusline on` | Enable for this session |
48
+ | `/statusline off` | Restore Pi's native footer for this session |
49
+ | `/statusline reload` | Reload saved configuration, including startup enablement |
50
+
51
+ The panel supports arrow navigation, Space to toggle a segment, Alt+Up/Down to
52
+ reorder it, and Enter to edit its properties. Choose **Save and apply globally**
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.
56
+ **Restore defaults in preview** changes only the draft until saved.
57
+ Preview uses the real footer renderer and current session data, or clearly
58
+ labelled example data for an empty session. A save error leaves the active
59
+ configuration unchanged and keeps the panel open.
60
+
61
+ ## Configuration
62
+
63
+ Settings live in `extensions/pi-statusline/config.json` under Pi's agent directory,
64
+ normally `~/.pi/agent/extensions/pi-statusline/config.json`;
65
+ `PI_CODING_AGENT_DIR` is respected. The extension
66
+ does not write Pi's general `settings.json`. Configuration is global, with no
67
+ project override or file watcher. After manual changes use `/statusline reload`.
68
+ The configuration directory is created automatically when saving from the panel.
69
+ When upgrading from the previous `~/.pi/agent/statusline.json` location, move the
70
+ existing file to the new path before running `/reload`; the old path is no longer read.
71
+
72
+ ```json
73
+ {
74
+ "version": 1,
75
+ "enabled": true,
76
+ "preset": "powerline",
77
+ "icons": "nerd",
78
+ "separator": "powerline",
79
+ "pathMode": "basename",
80
+ "modelDisplay": "name",
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
+ }
143
+ }
144
+ ```
145
+
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
151
+ replaces built-in decorative glyphs, not user-provided names or custom icons.
152
+ Both graphical modes use `` for the model by default; this glyph requires a
153
+ Nerd Font. A custom model icon still overrides the default.
154
+ - Separators: `powerline` (default), `pipe`, `dot`, `space`. ASCII mode uses plain separators.
155
+ - Paths: `basename`, `abbreviated`, `full` (home directory abbreviated as `~`).
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`.
160
+ - Context display: `bar` (default, `[██░░░░░░░░]20%`) or `text`
161
+ (`12.7k/272k(4.7%)`). Choose **Context display: bar** in the panel or set
162
+ `"contextDisplay": "bar"`.
163
+ The ten-cell bar uses whole blocks to avoid gaps inside partial glyphs. Fill
164
+ rounds to the nearest cell, with at least one filled cell for non-zero usage;
165
+ the percentage retains the more precise value. ASCII mode uses `#` and `-`.
166
+ Unknown usage shows `[??????????]?%`; values above 100% fill
167
+ the bar completely and retain the reported percentage. Both modes use the
168
+ existing context warning colors. Text mode includes token counts and capacity;
169
+ bar mode shows progress and percentage.
170
+ - `segments`: optional ordered array of `{ id, enabled, icon?, color?, iconColor?, background? }`.
171
+ An explicit list is authoritative: omitted segments are disabled but remain
172
+ available in the panel. The `statuses` segment always occupies the second row.
173
+ Preset layouts place context immediately after the model, before directory
174
+ and Git. Explicit saved segment order is respected and can be changed in the panel.
175
+ - Thinking is appended to the model as `(level)`, with no separate icon or
176
+ separator. In the panel, edit **model → Thinking level / Thinking color**.
177
+ The existing `thinking` configuration entry controls suffix enablement and an
178
+ optional color override; its position, icon and background are ignored. It
179
+ does not render on its own when the model is hidden or non-reasoning.
180
+ By default, `off`, `minimal`, `low`, and `medium` use their respective Pi theme
181
+ colors; `high`, `xhigh`, and `max` use a fixed violet/pink/orange/yellow/green/cyan/blue
182
+ palette independent of the Pi theme, distributed across the available characters.
183
+ The color or rainbow covers the entire `(level)`, including parentheses, and
184
+ the model's background covers the whole item.
185
+ - Icon `""` hides a glyph. In the panel, enter `:default` to restore its default.
186
+ Color fields accept `#RRGGBB` or `text`, `accent`, `muted`, `dim`, `success`,
187
+ `warning`, `error`, `border`, `borderAccent`, `borderMuted`, and Pi's
188
+ `thinkingOff/Minimal/Low/Medium/High/Xhigh/Max` tokens. Empty color input in the
189
+ panel restores the default. Explicit colors override automatic warning colors.
190
+
191
+ Available segments:
192
+
193
+ | ID | Data | Default |
194
+ | --- | --- | --- |
195
+ | `model` | Display name/full ID, or final ID component via `modelDisplay` | On |
196
+ | `thinking` | Controls the colored `(level)` suffix inside the model item | On |
197
+ | `context` | Text usage/capacity or a progress bar, followed by percentage | On |
198
+ | `directory` | Current working directory | On |
199
+ | `git` | Branch, clean/dirty/conflict, staged `+`, unstaged `*`, untracked `?`, ahead/behind | On |
200
+ | `cost` | Pi-recorded cumulative USD cost; enable in the panel when wanted | Off |
201
+ | `tokens` | Cumulative total tokens | On |
202
+ | `input`, `output` | Cumulative input or output tokens | Off |
203
+ | `cacheRead`, `cacheWrite` | Cumulative cache token counts | Off |
204
+ | `statuses` | Other extensions' `setStatus()` text on the runtime row, with terminal controls stripped | On |
205
+
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.
208
+ See [examples/statusline.json](examples/statusline.json) for an explicit layout.
209
+ Unknown presets or versions, duplicate segments, invalid colors and malformed JSON are
210
+ rejected with a notification; reload retains the last working configuration.
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.
272
+
273
+ ## Statistics and refresh behavior
274
+
275
+ Context comes from Pi's `getContextUsage()`. It is distinct from cumulative
276
+ token consumption. Unknown values, including immediately after compaction, show
277
+ `?` until Pi can provide an estimate. Yellow begins above 70%, red above 90%.
278
+ Pi 1.0 bases context estimates on persisted messages; in-flight usage updates
279
+ the cumulative token/cost segments while context refreshes after persistence.
280
+
281
+ Session totals include all usage already recorded in the current session file:
282
+ assistant messages, explicit usage entries, tool-result usage, compaction and
283
+ branch summaries, including pre-compaction and abandoned-branch consumption.
284
+ Streaming responses are provisional and counted once when persisted. The total
285
+ token segment sums input, output, cache-read and cache-write, matching Pi's
286
+ native session totals. Costs are **Pi-reported values, not provider invoices**;
287
+ zero means no cost recorded by Pi. No subscription-quota API or child-session
288
+ log is queried, and private `pi-subagents` result details are not parsed.
289
+
290
+ Git queries run asynchronously every two seconds and after tool/branch changes,
291
+ with a one-second timeout and optional index locks disabled. No repository hides
292
+ the segment; failures show `?`, never a false clean state. Large output above
293
+ 8 MiB also falls back to unknown. Worktrees, detached HEAD, unborn branches and
294
+ filenames containing newlines are supported. Disabling the Git segment stops
295
+ its polling.
296
+
297
+ Rendering performs no file reads, Git queries or full-session scans. Streaming
298
+ refresh requests are coalesced to 100 ms. Narrow terminals shorten model/path
299
+ labels, then hide cost, cache/token counters, the thinking suffix, Git and directory in
300
+ that order, retaining model/context where possible. The secondary status row
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
304
+ release timers and subscriptions. RPC, JSON and print mode do not install UI
305
+ components or start polling.
306
+
307
+ ## Development and rollback
308
+
309
+ ```sh
310
+ npm run check --workspace @erichll/pi-statusline
311
+ npm test --workspace @erichll/pi-statusline
312
+ # Optional: POSIX PTY integration, using an offline simulated model
313
+ npm run smoke --workspace @erichll/pi-statusline
314
+ ```
315
+
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
323
+ installation, disable this extension in `pi config`, re-enable the previous
324
+ footer and reload Pi. `/statusline off` restores the native footer, not another
325
+ extension's editor or widgets.
326
+
327
+ The initial implementation is independently written, with design references to
328
+ [pi-powerline-footer](https://github.com/nicobailon/pi-powerline-footer).
329
+
330
+ MIT licensed.
@@ -0,0 +1,71 @@
1
+ {
2
+ "version": 1,
3
+ "enabled": true,
4
+ "preset": "powerline",
5
+ "icons": "nerd",
6
+ "separator": "powerline",
7
+ "pathMode": "basename",
8
+ "modelDisplay": "name",
9
+ "contextDisplay": "bar",
10
+ "segments": [
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
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@erichll/pi-statusline",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Configurable Pi statusline with live preview, Git and session usage",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": ["pi-package", "pi-extension", "pi", "statusline"],
8
+ "repository": {"type": "git", "url": "git+https://github.com/erichll/pi-packages.git", "directory": "packages/pi-statusline"},
9
+ "publishConfig": {"access": "public"},
10
+ "engines": {"node": ">=22.19.0"},
11
+ "files": ["src", "README.md", "CHANGELOG.md", "LICENSE", "examples"],
12
+ "exports": {".": "./src/index.ts"},
13
+ "pi": {"extensions": ["./src/index.ts"]},
14
+ "scripts": {
15
+ "check": "tsc -p tsconfig.json --noEmit",
16
+ "test": "node --experimental-strip-types --test test/*.test.ts",
17
+ "smoke": "python3 test/pty-smoke.py"
18
+ },
19
+ "peerDependencies": {
20
+ "@earendil-works/pi-ai": "*",
21
+ "@earendil-works/pi-coding-agent": "*",
22
+ "@earendil-works/pi-tui": "*"
23
+ },
24
+ "devDependencies": {
25
+ "@earendil-works/pi-ai": "^1.0.0",
26
+ "@earendil-works/pi-coding-agent": "^1.0.0",
27
+ "@earendil-works/pi-tui": "^1.0.0",
28
+ "@types/node": "^26.6.4",
29
+ "typescript": "^7.0.2"
30
+ }
31
+ }
@@ -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 ADDED
@@ -0,0 +1,116 @@
1
+ import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import type { ColorValue, StatuslineConfig } from "./types.ts";
5
+ import { SEGMENT_IDS } from "./types.ts";
6
+
7
+ // A deliberately small, documented set of Pi semantic foreground colors.
8
+ export const COLORS = ["text", "accent", "muted", "dim", "success", "warning", "error", "border", "borderAccent", "borderMuted", "thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh", "thinkingMax"] as const;
9
+ export function validColor(value: unknown): value is ColorValue {
10
+ return typeof value === "string" && (/^#[\da-f]{6}$/i.test(value) || (COLORS as readonly string[]).includes(value));
11
+ }
12
+ export function presetConfig(): StatuslineConfig {
13
+ const enabled = new Set<string>(["model", "thinking", "directory", "git", "context", "tokens", "statuses"]);
14
+ return {
15
+ version: 1, enabled: true, preset: "powerline", icons: "nerd",
16
+ separator: "powerline", pathMode: "basename", modelDisplay: "name", contextDisplay: "bar",
17
+ segments: SEGMENT_IDS.map((id) => ({ id, enabled: enabled.has(id) })),
18
+ runtime: { enabled: true, status: true, tools: true, noContent: true, noContentSeconds: 10, avg: true, cache: true, counts: true, retainSummary: true },
19
+ };
20
+ }
21
+ function record(value: unknown): value is Record<string, unknown> {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+ function choice<T extends string>(value: unknown, choices: readonly T[], name: string): T {
25
+ if (typeof value !== "string" || !choices.includes(value as T)) throw new Error(`Invalid ${name}`);
26
+ return value as T;
27
+ }
28
+ function boolean(value: unknown, name: string): boolean {
29
+ if (typeof value !== "boolean") throw new Error(`Invalid ${name}`);
30
+ return value;
31
+ }
32
+ export function parseConfig(raw: unknown): StatuslineConfig {
33
+ if (!record(raw)) throw new Error("Configuration must be an object");
34
+ if (raw.version !== undefined && raw.version !== 1) throw new Error("Unsupported configuration version");
35
+ if (raw.preset !== undefined) choice(raw.preset, ["powerline"] as const, "preset");
36
+ const config = presetConfig();
37
+ if (raw.enabled !== undefined) config.enabled = boolean(raw.enabled, "enabled");
38
+ if (raw.icons !== undefined) config.icons = choice(raw.icons, ["unicode", "nerd", "ascii"], "icons");
39
+ if (raw.separator !== undefined) config.separator = choice(raw.separator, ["pipe", "dot", "space", "powerline"], "separator");
40
+ if (raw.pathMode !== undefined) config.pathMode = choice(raw.pathMode, ["basename", "abbreviated", "full"], "pathMode");
41
+ if (raw.modelDisplay !== undefined) config.modelDisplay = choice(raw.modelDisplay, ["name", "last"], "modelDisplay");
42
+ if (raw.contextDisplay !== undefined) config.contextDisplay = choice(raw.contextDisplay, ["text", "bar"], "contextDisplay");
43
+ for (const group of ["runtime"] as const) {
44
+ if (raw[group] === undefined) continue;
45
+ if (!record(raw[group])) throw new Error(`Invalid ${group}`);
46
+ for (const key of Object.keys(config[group])) {
47
+ const value = raw[group][key];
48
+ if (value === undefined) continue;
49
+ if (key === "noContentSeconds") {
50
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 3600) throw new Error("Invalid runtime.noContentSeconds (1–3600)");
51
+ config.runtime.noContentSeconds = value;
52
+ } else (config[group] as unknown as Record<string, unknown>)[key] = boolean(value, `${group}.${key}`);
53
+ }
54
+ }
55
+ if (raw.segments !== undefined) {
56
+ if (!Array.isArray(raw.segments)) throw new Error("segments must be an array");
57
+ const seen = new Set<string>();
58
+ config.segments = raw.segments.map((item: unknown) => {
59
+ if (!record(item)) throw new Error("Invalid segment");
60
+ const id = choice(item.id, SEGMENT_IDS, "segment id");
61
+ if (seen.has(id)) throw new Error(`Duplicate segment: ${id}`);
62
+ seen.add(id);
63
+ const result = { id, enabled: item.enabled === undefined ? true : boolean(item.enabled, `${id}.enabled`) } as StatuslineConfig["segments"][number];
64
+ if (item.icon !== undefined) {
65
+ if (typeof item.icon !== "string" || item.icon.length > 32 || /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u.test(item.icon)) throw new Error(`Invalid icon: ${id}`);
66
+ result.icon = item.icon;
67
+ }
68
+ for (const key of ["color", "iconColor", "background"] as const) {
69
+ if (item[key] !== undefined) {
70
+ if (!validColor(item[key])) throw new Error(`Invalid ${id}.${key}`);
71
+ result[key] = item[key];
72
+ }
73
+ }
74
+ return result;
75
+ });
76
+ // An explicit list is authoritative. Omitted segments remain editable but disabled.
77
+ for (const id of SEGMENT_IDS) if (!seen.has(id)) config.segments.push({ id, enabled: false });
78
+ }
79
+ return config;
80
+ }
81
+
82
+ export class ConfigStore {
83
+ readonly file: string;
84
+ private lastWarning = "";
85
+ constructor(file: string) { this.file = file; }
86
+ async load(previous = presetConfig()): Promise<{ config: StatuslineConfig; warning?: string }> {
87
+ try {
88
+ const config = parseConfig(JSON.parse(await readFile(this.file, "utf8")));
89
+ this.lastWarning = "";
90
+ return { config };
91
+ } catch (error) {
92
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
93
+ this.lastWarning = "";
94
+ return { config: presetConfig() };
95
+ }
96
+ const warning = `Could not load ${this.file}: ${error instanceof Error ? error.message : String(error)}`;
97
+ const notify = warning !== this.lastWarning;
98
+ this.lastWarning = warning;
99
+ return { config: previous, ...(notify ? { warning } : {}) };
100
+ }
101
+ }
102
+ async save(config: StatuslineConfig): Promise<void> {
103
+ const validated = parseConfig(config);
104
+ await mkdir(dirname(this.file), { recursive: true });
105
+ const temporary = `${this.file}.${randomUUID()}.tmp`;
106
+ try {
107
+ const handle = await open(temporary, "wx", 0o600);
108
+ try { await handle.writeFile(`${JSON.stringify(validated, null, 2)}\n`); await handle.sync(); }
109
+ finally { await handle.close(); }
110
+ await rename(temporary, this.file);
111
+ this.lastWarning = "";
112
+ } finally {
113
+ await unlink(temporary).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; });
114
+ }
115
+ }
116
+ }