@pi-vault/pi-status 0.1.0 → 0.2.1

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/src/index.ts CHANGED
@@ -2,20 +2,13 @@ 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
- import {
12
- loadConfig,
13
- 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";
5
+ import { loadConfig, saveConfigToSettings } from "./core/config.ts";
6
+ import { buildSnapshot } from "./core/snapshot.ts";
7
+ import { createUsageRuntime } from "./core/usage-runtime.ts";
8
+ import type { PiStatusConfig } from "./shared/types.ts";
9
+ import { createStatusLineEditor } from "./tui/editor.ts";
10
+ import { buildFooterLine } from "./tui/render.ts";
11
+ import { fromPiTheme, noTheme, type StatusLineTheme } from "./tui/theme.ts";
19
12
 
20
13
  type FooterComponent = {
21
14
  render: (width: number) => string[];
@@ -35,85 +28,55 @@ type FooterFactory = (
35
28
  footerData: FooterDataLike,
36
29
  ) => FooterComponent;
37
30
 
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.
31
+ type RuntimeState = {
32
+ config: PiStatusConfig;
33
+ ctx: ExtensionContext | undefined;
34
+ requestRender: (() => void) | undefined;
35
+ gitBranch: string | null;
36
+ extensionStatuses: Map<string, string>;
37
+ };
38
+
39
+ function createRuntimeState(): RuntimeState {
40
+ return {
41
+ config: loadConfig().config,
42
+ ctx: undefined,
43
+ requestRender: undefined,
44
+ gitBranch: null,
45
+ extensionStatuses: new Map(),
46
+ };
47
+ }
48
+
40
49
  const EMPTY_FOOTER_FACTORY: FooterFactory = () => ({
41
- render(_width: number): string[] {
50
+ render(): string[] {
42
51
  return [];
43
52
  },
44
53
  invalidate(): void {},
45
54
  dispose(): void {},
46
55
  });
47
56
 
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
57
  function isLiveTheme(value: unknown): boolean {
54
58
  if (!value || typeof value !== "object") return false;
55
59
  const candidate = value as { fg?: unknown; bold?: unknown };
56
- return typeof candidate.fg === "function" && typeof candidate.bold === "function";
57
- }
58
-
59
- function aggregateBranchTotals(ctx: ExtensionContext): {
60
- input: number;
61
- output: number;
62
- totalTokens: number;
63
- } {
64
- const totals = { input: 0, output: 0, totalTokens: 0 };
65
- const branch = ctx.sessionManager.getBranch() as unknown[];
66
-
67
- for (const entry of branch ?? []) {
68
- if (!entry || typeof entry !== "object") continue;
69
- const type = (entry as { type?: unknown }).type;
70
- if (type !== "message") continue;
71
- const message = (
72
- entry as {
73
- message?: {
74
- role?: unknown;
75
- usage?: { input?: number; output?: number; totalTokens?: number };
76
- };
77
- }
78
- ).message;
79
- if (message?.role !== "assistant") continue;
80
- const usage = message.usage;
81
- if (!usage) continue;
82
- if (typeof usage.input === "number") totals.input += usage.input;
83
- if (typeof usage.output === "number") totals.output += usage.output;
84
- if (typeof usage.totalTokens === "number")
85
- totals.totalTokens += usage.totalTokens;
86
- }
87
-
88
- return totals;
60
+ return (
61
+ typeof candidate.fg === "function" && typeof candidate.bold === "function"
62
+ );
89
63
  }
90
64
 
91
65
  export default function createExtension(pi: ExtensionAPI): void {
92
- let runtimeConfig: PiStatusConfig = loadConfig().config;
93
- let currentCtx: ExtensionContext | undefined;
94
- let requestRender: (() => void) | undefined;
95
- let usageState: UsageCoreState | undefined;
96
- let lastGitBranch: string | null = null;
97
- let lastExtensionStatuses = new Map<string, string>();
66
+ const state = createRuntimeState();
98
67
 
99
- function refreshRuntimeConfig(cwd?: string): void {
100
- runtimeConfig = loadConfig(cwd ? { cwd } : undefined).config;
101
- }
68
+ const usageRuntime = createUsageRuntime(pi);
102
69
 
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?.();
70
+ function refreshRuntimeConfig(cwd?: string): void {
71
+ state.config = loadConfig(cwd ? { cwd } : undefined).config;
110
72
  }
111
73
 
112
74
  function installFooter(ctx: ExtensionContext): void {
113
75
  if (!ctx.hasUI) return;
114
76
 
115
77
  const factory: FooterFactory = (tui, theme, footerData) => {
116
- requestRender = () => tui.requestRender?.();
78
+ state.requestRender = () => tui.requestRender?.();
79
+ usageRuntime.setOnChange(state.requestRender);
117
80
  const unsubscribe = footerData.onBranchChange?.(() =>
118
81
  tui.requestRender?.(),
119
82
  );
@@ -121,42 +84,41 @@ export default function createExtension(pi: ExtensionAPI): void {
121
84
  return {
122
85
  dispose() {
123
86
  unsubscribe?.();
124
- if (requestRender === tui.requestRender) {
125
- requestRender = undefined;
126
- }
87
+ if (state.requestRender === tui.requestRender)
88
+ state.requestRender = undefined;
89
+ usageRuntime.setOnChange(state.requestRender);
127
90
  },
128
91
  invalidate() {
129
- requestRender?.();
92
+ state.requestRender?.();
130
93
  },
131
94
  render(width: number) {
132
- const activeCtx = currentCtx ?? ctx;
133
- lastGitBranch = footerData.getGitBranch();
134
- lastExtensionStatuses = new Map(
95
+ const activeCtx = state.ctx ?? ctx;
96
+ state.gitBranch = footerData.getGitBranch();
97
+ state.extensionStatuses = new Map(
135
98
  footerData.getExtensionStatuses().entries(),
136
99
  );
100
+ const snapshot = buildSnapshot({
101
+ model: activeCtx.model,
102
+ cwd: activeCtx.cwd,
103
+ thinkingLevel: String(pi.getThinkingLevel()),
104
+ gitBranch: state.gitBranch,
105
+ isIdle: activeCtx.isIdle(),
106
+ hasPendingMessages: activeCtx.hasPendingMessages(),
107
+ contextUsage: activeCtx.getContextUsage(),
108
+ branch: activeCtx.sessionManager.getBranch() as unknown[],
109
+ sessionId: activeCtx.sessionManager.getSessionId(),
110
+ usageState: usageRuntime.getState(),
111
+ extensionStatuses: state.extensionStatuses,
112
+ });
137
113
  const line = buildFooterLine(
138
114
  {
139
- model: activeCtx.model,
140
- cwd: activeCtx.cwd,
141
- thinkingLevel: String(pi.getThinkingLevel()),
142
- gitBranch: lastGitBranch,
143
- runState: !activeCtx.isIdle()
144
- ? "busy"
145
- : activeCtx.hasPendingMessages()
146
- ? "queued"
147
- : "idle",
148
- contextUsage: activeCtx.getContextUsage(),
149
- branchTotals: aggregateBranchTotals(activeCtx),
150
- sessionId: activeCtx.sessionManager.getSessionId(),
151
- usageState,
152
- extensionStatuses: lastExtensionStatuses,
153
- filter: runtimeConfig.filter,
154
- segments: runtimeConfig.segments,
115
+ ...snapshot,
116
+ filter: state.config.filter,
117
+ segments: state.config.segments,
155
118
  },
156
119
  theme,
157
120
  width,
158
121
  );
159
-
160
122
  return [line];
161
123
  },
162
124
  };
@@ -166,37 +128,15 @@ export default function createExtension(pi: ExtensionAPI): void {
166
128
  }
167
129
 
168
130
  function installEmptyFooter(ctx: ExtensionContext): void {
169
- if (!ctx.hasUI) return;
170
- ctx.ui.setFooter(EMPTY_FOOTER_FACTORY as never);
131
+ if (ctx.hasUI) ctx.ui.setFooter(EMPTY_FOOTER_FACTORY as never);
171
132
  }
172
133
 
173
134
  function refresh(ctx: ExtensionContext): void {
174
- currentCtx = ctx;
135
+ state.ctx = ctx;
175
136
  refreshRuntimeConfig(ctx.cwd);
176
- requestRender?.();
137
+ state.requestRender?.();
177
138
  }
178
139
 
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
140
  pi.registerCommand("statusline", {
201
141
  description: "Configure statusline segments and extension-status filters",
202
142
  handler: async (_args, ctx) => {
@@ -205,7 +145,7 @@ export default function createExtension(pi: ExtensionAPI): void {
205
145
  return;
206
146
  }
207
147
 
208
- const discovered = [...lastExtensionStatuses.keys()].sort((a, b) =>
148
+ const discovered = [...state.extensionStatuses.keys()].sort((a, b) =>
209
149
  a.localeCompare(b),
210
150
  );
211
151
 
@@ -214,32 +154,31 @@ export default function createExtension(pi: ExtensionAPI): void {
214
154
  installEmptyFooter(ctx);
215
155
  result = await ctx.ui.custom<PiStatusConfig | null>(
216
156
  (tui, theme, _keys, done) => {
217
- const activeCtx = currentCtx ?? ctx;
218
- const menuTheme: StatuslineMenuTheme = isLiveTheme(theme)
157
+ const activeCtx = state.ctx ?? ctx;
158
+ const menuTheme: StatusLineTheme = isLiveTheme(theme)
219
159
  ? fromPiTheme(theme)
220
160
  : noTheme;
221
- return createStatuslineEditor({
222
- config: runtimeConfig,
161
+ const snapshot = buildSnapshot({
162
+ model: activeCtx.model,
163
+ cwd: activeCtx.cwd,
164
+ thinkingLevel: String(pi.getThinkingLevel()),
165
+ gitBranch: state.gitBranch,
166
+ isIdle: activeCtx.isIdle(),
167
+ hasPendingMessages: activeCtx.hasPendingMessages(),
168
+ contextUsage: activeCtx.getContextUsage(),
169
+ branch: activeCtx.sessionManager.getBranch() as unknown[],
170
+ sessionId: activeCtx.sessionManager.getSessionId(),
171
+ usageState: usageRuntime.getState(),
172
+ extensionStatuses: state.extensionStatuses,
173
+ });
174
+ return createStatusLineEditor({
175
+ config: state.config,
223
176
  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
- },
177
+ previewInput: snapshot,
240
178
  theme: menuTheme,
241
179
  done,
242
180
  requestRender: () => tui.requestRender?.(),
181
+ usageAvailable: usageRuntime.getAvailable(),
243
182
  });
244
183
  },
245
184
  );
@@ -251,8 +190,8 @@ export default function createExtension(pi: ExtensionAPI): void {
251
190
 
252
191
  try {
253
192
  saveConfigToSettings(result, { cwd: ctx.cwd });
254
- runtimeConfig = result;
255
- requestRender?.();
193
+ state.config = result;
194
+ state.requestRender?.();
256
195
  } catch (error) {
257
196
  const message =
258
197
  error instanceof Error
@@ -264,14 +203,15 @@ export default function createExtension(pi: ExtensionAPI): void {
264
203
  });
265
204
 
266
205
  pi.on("session_start", (_event, ctx) => {
206
+ usageRuntime.requestCurrent();
267
207
  refreshRuntimeConfig(ctx.cwd);
268
- currentCtx = ctx;
208
+ state.ctx = ctx;
269
209
  installFooter(ctx);
270
210
  });
271
211
 
272
212
  pi.on("session_tree", (_event, ctx) => {
273
213
  refreshRuntimeConfig(ctx.cwd);
274
- currentCtx = ctx;
214
+ state.ctx = ctx;
275
215
  installFooter(ctx);
276
216
  });
277
217
 
@@ -284,10 +224,9 @@ export default function createExtension(pi: ExtensionAPI): void {
284
224
  });
285
225
 
286
226
  pi.on("session_shutdown", (_event, ctx) => {
287
- currentCtx = undefined;
288
- requestRender = undefined;
289
- unsubscribeUsageReady();
290
- unsubscribeUsageUpdate();
227
+ state.ctx = undefined;
228
+ state.requestRender = undefined;
229
+ usageRuntime.setOnChange(undefined);
291
230
  if (ctx.hasUI) ctx.ui.setFooter(undefined);
292
231
  });
293
232
  }
@@ -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
+ }