@pi-vault/pi-status 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/%40pi-vault%2Fpi-status)](https://www.npmjs.com/package/@pi-vault/pi-status)
4
4
  [![Quality](https://github.com/pi-vault/pi-status/actions/workflows/quality.yml/badge.svg?branch=master)](https://github.com/pi-vault/pi-status/actions/workflows/quality.yml)
5
- [![Node >= 22.12](https://img.shields.io/badge/node-%3E%3D22.12-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
5
+ [![Node >= 22.19](https://img.shields.io/badge/node-%3E%3D22.19-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE)
7
7
 
8
8
  Replace Pi's default footer with a cleaner Codex-style status line that stays out of the way but keeps the useful bits visible.
@@ -11,20 +11,36 @@ Default status line:
11
11
 
12
12
  `model-with-reasoning · current-dir`
13
13
 
14
- > **Status:** Early-adopter release (`v0.1.0`). It is ready to use, but the available segments and configuration details may still change before `v1.0`.
15
-
16
14
  ## Install
17
15
 
16
+ Install `pi-status`:
17
+
18
18
  ```bash
19
19
  pi install npm:@pi-vault/pi-status
20
20
  ```
21
21
 
22
+ Optional: install `pi-usage` if you want `/usage` plus the usage-backed footer segments:
23
+
24
+ ```bash
25
+ pi install npm:@pi-vault/pi-usage
26
+ ```
27
+
22
28
  Then reload Pi:
23
29
 
24
30
  ```bash
25
31
  /reload
26
32
  ```
27
33
 
34
+ ## Screenshots
35
+
36
+ Default status line rendering:
37
+
38
+ ![Status line UI](docs/assets/statusline-ui.png)
39
+
40
+ Interactive configuration editor (`/statusline`):
41
+
42
+ ![Status line configuration](docs/assets/statusline-configuration.png)
43
+
28
44
  ## Use
29
45
 
30
46
  Once installed, the footer updates automatically.
@@ -59,7 +75,7 @@ You can build your footer from these items:
59
75
  - `weekly-limit`
60
76
  - `extension-statuses`
61
77
 
62
- `five-hour-limit` and `weekly-limit` come from [`@pi-vault/pi-usage`](https://www.npmjs.com/package/@pi-vault/pi-usage) and only appear when that usage data is available.
78
+ `five-hour-limit` and `weekly-limit` require standalone [`@pi-vault/pi-usage`](https://www.npmjs.com/package/@pi-vault/pi-usage). When `pi-usage` is not installed or has not responded yet, those items are hidden from `/statusline` and omitted from the footer.
63
79
 
64
80
  ## Common Setups
65
81
 
@@ -77,7 +93,7 @@ model · run-state · git-branch · context-used · context-remaining · session
77
93
 
78
94
  ## Compatibility
79
95
 
80
- - Node.js `>=22.12`
96
+ - Node.js `>=22.19`
81
97
  - Pi host environment with `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui`
82
98
  - Tested in this repo against `@earendil-works/pi-coding-agent@0.78.x` and `@earendil-works/pi-tui@0.78.x`
83
99
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-vault/pi-status",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension that replaces the default status with a Codex-like status",
6
6
  "author": "Lanh Hoang <lanhhoang@users.noreply.github.com>",
@@ -29,19 +29,19 @@
29
29
  },
30
30
  "pi": {
31
31
  "extensions": [
32
- "./src/index.ts",
33
- "node_modules/@pi-vault/pi-usage/src/index.ts"
32
+ "./src/index.ts"
34
33
  ]
35
34
  },
36
35
  "engines": {
37
- "node": ">=22.12"
36
+ "node": ">=22.19"
38
37
  },
39
38
  "files": [
40
39
  "src",
40
+ "docs/assets",
41
41
  "README.md"
42
42
  ],
43
43
  "dependencies": {
44
- "@pi-vault/pi-usage": "^0.1.1"
44
+ "@pi-vault/pi-usage": "^0.2.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@earendil-works/pi-coding-agent": "*",
@@ -11,14 +11,11 @@ import { homedir } from "node:os";
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  import {
13
13
  DEFAULT_SEGMENTS,
14
+ isKnownSegment,
15
+ type PiStatusConfig,
14
16
  type StatusFilter,
15
17
  type StatusLineSegmentId,
16
- } from "./render.ts";
17
-
18
- export type PiStatusConfig = {
19
- segments: StatusLineSegmentId[];
20
- filter: StatusFilter;
21
- };
18
+ } from "../shared/types.ts";
22
19
 
23
20
  export type ConfigLoadResult = {
24
21
  config: PiStatusConfig;
@@ -30,25 +27,6 @@ export const DEFAULT_CONFIG: PiStatusConfig = {
30
27
  filter: { mode: "all", hidden: [] },
31
28
  };
32
29
 
33
- const KNOWN_SEGMENTS = new Set<StatusLineSegmentId>([
34
- "model",
35
- "model-with-reasoning",
36
- "project-name",
37
- "current-dir",
38
- "git-branch",
39
- "run-state",
40
- "context-remaining",
41
- "context-used",
42
- "context-window-size",
43
- "used-tokens",
44
- "total-input-tokens",
45
- "total-output-tokens",
46
- "session-id",
47
- "five-hour-limit",
48
- "weekly-limit",
49
- "extension-statuses",
50
- ]);
51
-
52
30
  function cloneDefaultConfig(): PiStatusConfig {
53
31
  return {
54
32
  segments: [...DEFAULT_CONFIG.segments],
@@ -76,11 +54,10 @@ export function normalizeSegments(input: unknown): StatusLineSegmentId[] {
76
54
 
77
55
  for (const value of input) {
78
56
  if (typeof value !== "string") continue;
79
- if (!KNOWN_SEGMENTS.has(value as StatusLineSegmentId)) continue;
80
- const id = value as StatusLineSegmentId;
81
- if (seen.has(id)) continue;
82
- seen.add(id);
83
- out.push(id);
57
+ if (!isKnownSegment(value)) continue;
58
+ if (seen.has(value)) continue;
59
+ seen.add(value);
60
+ out.push(value);
84
61
  }
85
62
 
86
63
  return out;
@@ -103,8 +80,9 @@ function normalizeFilterValues(input: unknown): string[] {
103
80
  }
104
81
 
105
82
  export function normalizeStatusFilter(input: unknown): StatusFilter {
106
- if (!input || typeof input !== "object" || Array.isArray(input))
83
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
107
84
  return { mode: "all", hidden: [] };
85
+ }
108
86
  const mode = (input as { mode?: unknown }).mode;
109
87
 
110
88
  if (mode === "all") {
@@ -127,8 +105,9 @@ export function normalizeStatusFilter(input: unknown): StatusFilter {
127
105
  function readJsonObject(path: string): Record<string, unknown> | null {
128
106
  try {
129
107
  const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
130
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
108
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
131
109
  return null;
110
+ }
132
111
  return parsed as Record<string, unknown>;
133
112
  } catch {
134
113
  return null;
@@ -141,24 +120,17 @@ type SettingsFileState =
141
120
  | { exists: true; malformed: true };
142
121
 
143
122
  function readSettingsFileState(path: string): SettingsFileState {
144
- if (!existsSync(path)) {
145
- return { exists: false, value: {} };
146
- }
147
-
123
+ if (!existsSync(path)) return { exists: false, value: {} };
148
124
  const parsed = readJsonObject(path);
149
- if (parsed) {
150
- return { exists: true, value: parsed };
151
- }
152
-
125
+ if (parsed) return { exists: true, value: parsed };
153
126
  return { exists: true, malformed: true };
154
127
  }
155
128
 
156
129
  function normalizePiStatus(input: unknown): PiStatusConfig {
157
- if (!input || typeof input !== "object" || Array.isArray(input))
130
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
158
131
  return cloneDefaultConfig();
159
- const segments = normalizeSegments(
160
- (input as { segments?: unknown }).segments,
161
- );
132
+ }
133
+ const segments = normalizeSegments((input as { segments?: unknown }).segments);
162
134
  const filter = normalizeStatusFilter((input as { filter?: unknown }).filter);
163
135
  return {
164
136
  segments: segments.length > 0 ? segments : [...DEFAULT_SEGMENTS],
@@ -167,18 +139,12 @@ function normalizePiStatus(input: unknown): PiStatusConfig {
167
139
  }
168
140
 
169
141
  function mergePiStatus(globalValue: unknown, projectValue: unknown): unknown {
170
- if (
171
- !globalValue ||
172
- typeof globalValue !== "object" ||
173
- Array.isArray(globalValue)
174
- )
142
+ if (!globalValue || typeof globalValue !== "object" || Array.isArray(globalValue)) {
175
143
  return projectValue ?? globalValue;
176
- if (
177
- !projectValue ||
178
- typeof projectValue !== "object" ||
179
- Array.isArray(projectValue)
180
- )
144
+ }
145
+ if (!projectValue || typeof projectValue !== "object" || Array.isArray(projectValue)) {
181
146
  return globalValue;
147
+ }
182
148
  const g = globalValue as Record<string, unknown>;
183
149
  const p = projectValue as Record<string, unknown>;
184
150
  const merged: Record<string, unknown> = { ...g, ...p };
@@ -186,12 +152,8 @@ function mergePiStatus(globalValue: unknown, projectValue: unknown): unknown {
186
152
  const gFilter = g.filter;
187
153
  const pFilter = p.filter;
188
154
  if (
189
- gFilter &&
190
- typeof gFilter === "object" &&
191
- !Array.isArray(gFilter) &&
192
- pFilter &&
193
- typeof pFilter === "object" &&
194
- !Array.isArray(pFilter)
155
+ gFilter && typeof gFilter === "object" && !Array.isArray(gFilter) &&
156
+ pFilter && typeof pFilter === "object" && !Array.isArray(pFilter)
195
157
  ) {
196
158
  merged.filter = {
197
159
  ...(gFilter as Record<string, unknown>),
@@ -240,9 +202,7 @@ export function saveConfigToSettings(
240
202
 
241
203
  const targetState = readSettingsFileState(path);
242
204
  if ("malformed" in targetState) {
243
- throw new Error(
244
- `Refusing to write malformed or non-object settings file: ${path}`,
245
- );
205
+ throw new Error(`Refusing to write malformed or non-object settings file: ${path}`);
246
206
  }
247
207
 
248
208
  const base = targetState.value;
@@ -0,0 +1,62 @@
1
+ import {
2
+ USAGE_CORE_READY_EVENT,
3
+ USAGE_CORE_REQUEST_EVENT,
4
+ USAGE_CORE_UPDATE_CURRENT_EVENT,
5
+ } from "@pi-vault/pi-usage/events";
6
+ import type { UsageCoreState } from "@pi-vault/pi-usage/types";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+
9
+ function isUsageCoreState(value: unknown): value is UsageCoreState {
10
+ return Boolean(value && typeof value === "object");
11
+ }
12
+
13
+ export function createUsageRuntime(pi: ExtensionAPI) {
14
+ let available = false;
15
+ let state: UsageCoreState | undefined;
16
+ let onChange: (() => void) | undefined;
17
+
18
+ const acceptPayload = (payload: unknown): void => {
19
+ if (!payload || typeof payload !== "object") return;
20
+ const maybe = payload as { state?: unknown };
21
+ const next = maybe.state ?? payload;
22
+ if (!isUsageCoreState(next)) return;
23
+ state = next;
24
+ available = true;
25
+ onChange?.();
26
+ };
27
+
28
+ const requestCurrent = (): void => {
29
+ pi.events.emit(USAGE_CORE_REQUEST_EVENT, {
30
+ type: "current",
31
+ reply(payload: unknown) {
32
+ acceptPayload(payload);
33
+ },
34
+ });
35
+ };
36
+
37
+ const unsubscribeReady = pi.events.on(USAGE_CORE_READY_EVENT, acceptPayload);
38
+ const unsubscribeUpdate = pi.events.on(
39
+ USAGE_CORE_UPDATE_CURRENT_EVENT,
40
+ acceptPayload,
41
+ );
42
+
43
+ requestCurrent();
44
+
45
+ return {
46
+ getAvailable(): boolean {
47
+ return available;
48
+ },
49
+ getState(): UsageCoreState | undefined {
50
+ return state;
51
+ },
52
+ setOnChange(listener: (() => void) | undefined): void {
53
+ onChange = listener;
54
+ },
55
+ requestCurrent,
56
+ dispose(): void {
57
+ onChange = undefined;
58
+ unsubscribeReady();
59
+ unsubscribeUpdate();
60
+ },
61
+ };
62
+ }
package/src/index.ts CHANGED
@@ -2,20 +2,15 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import {
6
- USAGE_CORE_READY_EVENT,
7
- USAGE_CORE_REQUEST_EVENT,
8
- USAGE_CORE_UPDATE_CURRENT_EVENT,
9
- } from "@pi-vault/pi-usage/events";
10
- import type { UsageCoreState } from "@pi-vault/pi-usage/types";
11
5
  import {
12
6
  loadConfig,
13
7
  saveConfigToSettings,
14
- type PiStatusConfig,
15
- } from "./config.ts";
16
- import { buildFooterLine } from "./render.ts";
17
- import { createStatuslineEditor } from "./ui/statusline-editor.ts";
18
- import { fromPiTheme, noTheme, type StatuslineMenuTheme } from "./ui/statusline-theme.ts";
8
+ } from "./core/config.ts";
9
+ import { createUsageRuntime } from "./core/usage-runtime.ts";
10
+ import type { PiStatusConfig } from "./shared/types.ts";
11
+ import { createStatusLineEditor } from "./tui/editor.ts";
12
+ import { buildFooterLine } from "./tui/render.ts";
13
+ import { fromPiTheme, noTheme, type StatusLineTheme } from "./tui/theme.ts";
19
14
 
20
15
  type FooterComponent = {
21
16
  render: (width: number) => string[];
@@ -35,21 +30,14 @@ type FooterFactory = (
35
30
  footerData: FooterDataLike,
36
31
  ) => FooterComponent;
37
32
 
38
- // Used only to suppress the live footer while a custom UI (e.g. /statusline)
39
- // is open. Renders no lines and is a no-op otherwise.
40
33
  const EMPTY_FOOTER_FACTORY: FooterFactory = () => ({
41
- render(_width: number): string[] {
34
+ render(): string[] {
42
35
  return [];
43
36
  },
44
37
  invalidate(): void {},
45
38
  dispose(): void {},
46
39
  });
47
40
 
48
- // `ctx.ui.custom(...)` is expected to hand us a live Pi theme, but the
49
- // runtime contract is loose and the theme may be missing in some test or
50
- // boot contexts. We only adapt it when both `fg` and `bold` are callable so
51
- // the editor always gets a well-formed `StatuslineMenuTheme` and never has
52
- // to defend against partial themes at render time.
53
41
  function isLiveTheme(value: unknown): boolean {
54
42
  if (!value || typeof value !== "object") return false;
55
43
  const candidate = value as { fg?: unknown; bold?: unknown };
@@ -66,8 +54,7 @@ function aggregateBranchTotals(ctx: ExtensionContext): {
66
54
 
67
55
  for (const entry of branch ?? []) {
68
56
  if (!entry || typeof entry !== "object") continue;
69
- const type = (entry as { type?: unknown }).type;
70
- if (type !== "message") continue;
57
+ if ((entry as { type?: unknown }).type !== "message") continue;
71
58
  const message = (
72
59
  entry as {
73
60
  message?: {
@@ -81,8 +68,7 @@ function aggregateBranchTotals(ctx: ExtensionContext): {
81
68
  if (!usage) continue;
82
69
  if (typeof usage.input === "number") totals.input += usage.input;
83
70
  if (typeof usage.output === "number") totals.output += usage.output;
84
- if (typeof usage.totalTokens === "number")
85
- totals.totalTokens += usage.totalTokens;
71
+ if (typeof usage.totalTokens === "number") totals.totalTokens += usage.totalTokens;
86
72
  }
87
73
 
88
74
  return totals;
@@ -92,38 +78,28 @@ export default function createExtension(pi: ExtensionAPI): void {
92
78
  let runtimeConfig: PiStatusConfig = loadConfig().config;
93
79
  let currentCtx: ExtensionContext | undefined;
94
80
  let requestRender: (() => void) | undefined;
95
- let usageState: UsageCoreState | undefined;
96
81
  let lastGitBranch: string | null = null;
97
82
  let lastExtensionStatuses = new Map<string, string>();
98
83
 
84
+ const usageRuntime = createUsageRuntime(pi);
85
+
99
86
  function refreshRuntimeConfig(cwd?: string): void {
100
87
  runtimeConfig = loadConfig(cwd ? { cwd } : undefined).config;
101
88
  }
102
89
 
103
- function acceptUsageState(payload: unknown): void {
104
- if (!payload || typeof payload !== "object") return;
105
- const maybe = payload as { state?: unknown };
106
- const next =
107
- maybe.state && typeof maybe.state === "object" ? maybe.state : payload;
108
- usageState = next as UsageCoreState;
109
- requestRender?.();
110
- }
111
-
112
90
  function installFooter(ctx: ExtensionContext): void {
113
91
  if (!ctx.hasUI) return;
114
92
 
115
93
  const factory: FooterFactory = (tui, theme, footerData) => {
116
94
  requestRender = () => tui.requestRender?.();
117
- const unsubscribe = footerData.onBranchChange?.(() =>
118
- tui.requestRender?.(),
119
- );
95
+ usageRuntime.setOnChange(requestRender);
96
+ const unsubscribe = footerData.onBranchChange?.(() => tui.requestRender?.());
120
97
 
121
98
  return {
122
99
  dispose() {
123
100
  unsubscribe?.();
124
- if (requestRender === tui.requestRender) {
125
- requestRender = undefined;
126
- }
101
+ if (requestRender === tui.requestRender) requestRender = undefined;
102
+ usageRuntime.setOnChange(requestRender);
127
103
  },
128
104
  invalidate() {
129
105
  requestRender?.();
@@ -131,9 +107,7 @@ export default function createExtension(pi: ExtensionAPI): void {
131
107
  render(width: number) {
132
108
  const activeCtx = currentCtx ?? ctx;
133
109
  lastGitBranch = footerData.getGitBranch();
134
- lastExtensionStatuses = new Map(
135
- footerData.getExtensionStatuses().entries(),
136
- );
110
+ lastExtensionStatuses = new Map(footerData.getExtensionStatuses().entries());
137
111
  const line = buildFooterLine(
138
112
  {
139
113
  model: activeCtx.model,
@@ -148,7 +122,7 @@ export default function createExtension(pi: ExtensionAPI): void {
148
122
  contextUsage: activeCtx.getContextUsage(),
149
123
  branchTotals: aggregateBranchTotals(activeCtx),
150
124
  sessionId: activeCtx.sessionManager.getSessionId(),
151
- usageState,
125
+ usageState: usageRuntime.getState(),
152
126
  extensionStatuses: lastExtensionStatuses,
153
127
  filter: runtimeConfig.filter,
154
128
  segments: runtimeConfig.segments,
@@ -156,7 +130,6 @@ export default function createExtension(pi: ExtensionAPI): void {
156
130
  theme,
157
131
  width,
158
132
  );
159
-
160
133
  return [line];
161
134
  },
162
135
  };
@@ -166,8 +139,7 @@ export default function createExtension(pi: ExtensionAPI): void {
166
139
  }
167
140
 
168
141
  function installEmptyFooter(ctx: ExtensionContext): void {
169
- if (!ctx.hasUI) return;
170
- ctx.ui.setFooter(EMPTY_FOOTER_FACTORY as never);
142
+ if (ctx.hasUI) ctx.ui.setFooter(EMPTY_FOOTER_FACTORY as never);
171
143
  }
172
144
 
173
145
  function refresh(ctx: ExtensionContext): void {
@@ -176,27 +148,6 @@ export default function createExtension(pi: ExtensionAPI): void {
176
148
  requestRender?.();
177
149
  }
178
150
 
179
- const unsubscribeUsageReady = pi.events.on(
180
- USAGE_CORE_READY_EVENT,
181
- (payload: unknown) => {
182
- acceptUsageState(payload);
183
- },
184
- );
185
-
186
- const unsubscribeUsageUpdate = pi.events.on(
187
- USAGE_CORE_UPDATE_CURRENT_EVENT,
188
- (payload: unknown) => {
189
- acceptUsageState(payload);
190
- },
191
- );
192
-
193
- pi.events.emit(USAGE_CORE_REQUEST_EVENT, {
194
- type: "current",
195
- reply(payload: unknown) {
196
- acceptUsageState(payload);
197
- },
198
- });
199
-
200
151
  pi.registerCommand("statusline", {
201
152
  description: "Configure statusline segments and extension-status filters",
202
153
  handler: async (_args, ctx) => {
@@ -205,44 +156,39 @@ export default function createExtension(pi: ExtensionAPI): void {
205
156
  return;
206
157
  }
207
158
 
208
- const discovered = [...lastExtensionStatuses.keys()].sort((a, b) =>
209
- a.localeCompare(b),
210
- );
159
+ const discovered = [...lastExtensionStatuses.keys()].sort((a, b) => a.localeCompare(b));
211
160
 
212
161
  let result: PiStatusConfig | null = null;
213
162
  try {
214
163
  installEmptyFooter(ctx);
215
- result = await ctx.ui.custom<PiStatusConfig | null>(
216
- (tui, theme, _keys, done) => {
217
- const activeCtx = currentCtx ?? ctx;
218
- const menuTheme: StatuslineMenuTheme = isLiveTheme(theme)
219
- ? fromPiTheme(theme)
220
- : noTheme;
221
- return createStatuslineEditor({
222
- config: runtimeConfig,
223
- discoveredStatuses: discovered,
224
- previewInput: {
225
- model: activeCtx.model,
226
- cwd: activeCtx.cwd,
227
- thinkingLevel: String(pi.getThinkingLevel()),
228
- gitBranch: lastGitBranch,
229
- runState: !activeCtx.isIdle()
230
- ? "busy"
231
- : activeCtx.hasPendingMessages()
232
- ? "queued"
233
- : "idle",
234
- contextUsage: activeCtx.getContextUsage(),
235
- branchTotals: aggregateBranchTotals(activeCtx),
236
- sessionId: activeCtx.sessionManager.getSessionId(),
237
- usageState,
238
- extensionStatuses: lastExtensionStatuses,
239
- },
240
- theme: menuTheme,
241
- done,
242
- requestRender: () => tui.requestRender?.(),
243
- });
244
- },
245
- );
164
+ result = await ctx.ui.custom<PiStatusConfig | null>((tui, theme, _keys, done) => {
165
+ const activeCtx = currentCtx ?? ctx;
166
+ const menuTheme: StatusLineTheme = isLiveTheme(theme) ? fromPiTheme(theme) : noTheme;
167
+ return createStatusLineEditor({
168
+ config: runtimeConfig,
169
+ discoveredStatuses: discovered,
170
+ previewInput: {
171
+ model: activeCtx.model,
172
+ cwd: activeCtx.cwd,
173
+ thinkingLevel: String(pi.getThinkingLevel()),
174
+ gitBranch: lastGitBranch,
175
+ runState: !activeCtx.isIdle()
176
+ ? "busy"
177
+ : activeCtx.hasPendingMessages()
178
+ ? "queued"
179
+ : "idle",
180
+ contextUsage: activeCtx.getContextUsage(),
181
+ branchTotals: aggregateBranchTotals(activeCtx),
182
+ sessionId: activeCtx.sessionManager.getSessionId(),
183
+ usageState: usageRuntime.getState(),
184
+ extensionStatuses: lastExtensionStatuses,
185
+ },
186
+ theme: menuTheme,
187
+ done,
188
+ requestRender: () => tui.requestRender?.(),
189
+ usageAvailable: usageRuntime.getAvailable(),
190
+ });
191
+ });
246
192
  } finally {
247
193
  installFooter(ctx);
248
194
  }
@@ -254,16 +200,14 @@ export default function createExtension(pi: ExtensionAPI): void {
254
200
  runtimeConfig = result;
255
201
  requestRender?.();
256
202
  } catch (error) {
257
- const message =
258
- error instanceof Error
259
- ? error.message
260
- : "Failed to save statusline settings";
203
+ const message = error instanceof Error ? error.message : "Failed to save statusline settings";
261
204
  ctx.ui.notify(message, "warning");
262
205
  }
263
206
  },
264
207
  });
265
208
 
266
209
  pi.on("session_start", (_event, ctx) => {
210
+ usageRuntime.requestCurrent();
267
211
  refreshRuntimeConfig(ctx.cwd);
268
212
  currentCtx = ctx;
269
213
  installFooter(ctx);
@@ -286,8 +230,7 @@ export default function createExtension(pi: ExtensionAPI): void {
286
230
  pi.on("session_shutdown", (_event, ctx) => {
287
231
  currentCtx = undefined;
288
232
  requestRender = undefined;
289
- unsubscribeUsageReady();
290
- unsubscribeUsageUpdate();
233
+ usageRuntime.setOnChange(undefined);
291
234
  if (ctx.hasUI) ctx.ui.setFooter(undefined);
292
235
  });
293
236
  }
@@ -0,0 +1,63 @@
1
+ export type StatusLineSegmentId =
2
+ | "model"
3
+ | "model-with-reasoning"
4
+ | "project-name"
5
+ | "current-dir"
6
+ | "git-branch"
7
+ | "run-state"
8
+ | "context-remaining"
9
+ | "context-used"
10
+ | "context-window-size"
11
+ | "used-tokens"
12
+ | "total-input-tokens"
13
+ | "total-output-tokens"
14
+ | "session-id"
15
+ | "five-hour-limit"
16
+ | "weekly-limit"
17
+ | "extension-statuses";
18
+
19
+ export type StatusFilter =
20
+ | { mode: "all"; hidden: string[] }
21
+ | { mode: "only"; shown: string[] };
22
+
23
+ export type PiStatusConfig = {
24
+ segments: StatusLineSegmentId[];
25
+ filter: StatusFilter;
26
+ };
27
+
28
+ export const KNOWN_SEGMENTS: readonly StatusLineSegmentId[] = [
29
+ "model",
30
+ "model-with-reasoning",
31
+ "project-name",
32
+ "current-dir",
33
+ "git-branch",
34
+ "run-state",
35
+ "context-remaining",
36
+ "context-used",
37
+ "context-window-size",
38
+ "used-tokens",
39
+ "total-input-tokens",
40
+ "total-output-tokens",
41
+ "session-id",
42
+ "five-hour-limit",
43
+ "weekly-limit",
44
+ "extension-statuses",
45
+ ] as const;
46
+
47
+ export const DEFAULT_SEGMENTS: readonly StatusLineSegmentId[] = [
48
+ "model-with-reasoning",
49
+ "current-dir",
50
+ ] as const;
51
+
52
+ export const USAGE_SEGMENTS = new Set<StatusLineSegmentId>([
53
+ "five-hour-limit",
54
+ "weekly-limit",
55
+ ]);
56
+
57
+ export function isKnownSegment(value: string): value is StatusLineSegmentId {
58
+ return (KNOWN_SEGMENTS as readonly string[]).includes(value);
59
+ }
60
+
61
+ export function isUsageSegment(id: StatusLineSegmentId): boolean {
62
+ return USAGE_SEGMENTS.has(id);
63
+ }