@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/CHANGELOG.md +44 -0
- package/README.md +54 -20
- package/docs/assets/statusline-configuration.png +0 -0
- package/docs/assets/statusline-ui.png +0 -0
- package/package.json +8 -7
- package/src/{config.ts → core/config.ts} +23 -63
- package/src/core/snapshot.ts +75 -0
- package/src/core/usage-runtime.ts +62 -0
- package/src/index.ts +89 -150
- package/src/shared/types.ts +63 -0
- package/src/{ui/statusline-editor.ts → tui/editor.ts} +91 -114
- package/src/{render.ts → tui/render.ts} +25 -82
- package/src/tui/theme.ts +35 -0
- package/src/ui/statusline-theme.ts +0 -67
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from "
|
|
10
|
-
import
|
|
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
|
-
|
|
39
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
runtimeConfig = loadConfig(cwd ? { cwd } : undefined).config;
|
|
101
|
-
}
|
|
68
|
+
const usageRuntime = createUsageRuntime(pi);
|
|
102
69
|
|
|
103
|
-
function
|
|
104
|
-
|
|
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 =
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 (
|
|
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
|
-
|
|
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 = [...
|
|
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 =
|
|
218
|
-
const menuTheme:
|
|
157
|
+
const activeCtx = state.ctx ?? ctx;
|
|
158
|
+
const menuTheme: StatusLineTheme = isLiveTheme(theme)
|
|
219
159
|
? fromPiTheme(theme)
|
|
220
160
|
: noTheme;
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
requestRender = undefined;
|
|
289
|
-
|
|
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
|
+
}
|