@pi-vault/pi-status 0.1.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/LICENSE +21 -0
- package/README.md +95 -0
- package/package.json +58 -0
- package/src/config.ts +272 -0
- package/src/index.ts +293 -0
- package/src/render.ts +343 -0
- package/src/ui/statusline-editor.ts +589 -0
- package/src/ui/statusline-theme.ts +67 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
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";
|
|
19
|
+
|
|
20
|
+
type FooterComponent = {
|
|
21
|
+
render: (width: number) => string[];
|
|
22
|
+
invalidate: () => void;
|
|
23
|
+
dispose?: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type FooterDataLike = {
|
|
27
|
+
getGitBranch: () => string | null;
|
|
28
|
+
getExtensionStatuses: () => ReadonlyMap<string, string>;
|
|
29
|
+
onBranchChange?: (listener: () => void) => (() => void) | undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type FooterFactory = (
|
|
33
|
+
tui: { requestRender?: () => void },
|
|
34
|
+
theme: { fg: (color: string, text: string) => string },
|
|
35
|
+
footerData: FooterDataLike,
|
|
36
|
+
) => FooterComponent;
|
|
37
|
+
|
|
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
|
+
const EMPTY_FOOTER_FACTORY: FooterFactory = () => ({
|
|
41
|
+
render(_width: number): string[] {
|
|
42
|
+
return [];
|
|
43
|
+
},
|
|
44
|
+
invalidate(): void {},
|
|
45
|
+
dispose(): void {},
|
|
46
|
+
});
|
|
47
|
+
|
|
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
|
+
function isLiveTheme(value: unknown): boolean {
|
|
54
|
+
if (!value || typeof value !== "object") return false;
|
|
55
|
+
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;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
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>();
|
|
98
|
+
|
|
99
|
+
function refreshRuntimeConfig(cwd?: string): void {
|
|
100
|
+
runtimeConfig = loadConfig(cwd ? { cwd } : undefined).config;
|
|
101
|
+
}
|
|
102
|
+
|
|
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
|
+
function installFooter(ctx: ExtensionContext): void {
|
|
113
|
+
if (!ctx.hasUI) return;
|
|
114
|
+
|
|
115
|
+
const factory: FooterFactory = (tui, theme, footerData) => {
|
|
116
|
+
requestRender = () => tui.requestRender?.();
|
|
117
|
+
const unsubscribe = footerData.onBranchChange?.(() =>
|
|
118
|
+
tui.requestRender?.(),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
dispose() {
|
|
123
|
+
unsubscribe?.();
|
|
124
|
+
if (requestRender === tui.requestRender) {
|
|
125
|
+
requestRender = undefined;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
invalidate() {
|
|
129
|
+
requestRender?.();
|
|
130
|
+
},
|
|
131
|
+
render(width: number) {
|
|
132
|
+
const activeCtx = currentCtx ?? ctx;
|
|
133
|
+
lastGitBranch = footerData.getGitBranch();
|
|
134
|
+
lastExtensionStatuses = new Map(
|
|
135
|
+
footerData.getExtensionStatuses().entries(),
|
|
136
|
+
);
|
|
137
|
+
const line = buildFooterLine(
|
|
138
|
+
{
|
|
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,
|
|
155
|
+
},
|
|
156
|
+
theme,
|
|
157
|
+
width,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return [line];
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
ctx.ui.setFooter(factory as never);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function installEmptyFooter(ctx: ExtensionContext): void {
|
|
169
|
+
if (!ctx.hasUI) return;
|
|
170
|
+
ctx.ui.setFooter(EMPTY_FOOTER_FACTORY as never);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function refresh(ctx: ExtensionContext): void {
|
|
174
|
+
currentCtx = ctx;
|
|
175
|
+
refreshRuntimeConfig(ctx.cwd);
|
|
176
|
+
requestRender?.();
|
|
177
|
+
}
|
|
178
|
+
|
|
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
|
+
pi.registerCommand("statusline", {
|
|
201
|
+
description: "Configure statusline segments and extension-status filters",
|
|
202
|
+
handler: async (_args, ctx) => {
|
|
203
|
+
if (!ctx.hasUI) {
|
|
204
|
+
ctx.ui.notify("/statusline requires interactive UI", "warning");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const discovered = [...lastExtensionStatuses.keys()].sort((a, b) =>
|
|
209
|
+
a.localeCompare(b),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
let result: PiStatusConfig | null = null;
|
|
213
|
+
try {
|
|
214
|
+
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
|
+
);
|
|
246
|
+
} finally {
|
|
247
|
+
installFooter(ctx);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!result) return;
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
saveConfigToSettings(result, { cwd: ctx.cwd });
|
|
254
|
+
runtimeConfig = result;
|
|
255
|
+
requestRender?.();
|
|
256
|
+
} catch (error) {
|
|
257
|
+
const message =
|
|
258
|
+
error instanceof Error
|
|
259
|
+
? error.message
|
|
260
|
+
: "Failed to save statusline settings";
|
|
261
|
+
ctx.ui.notify(message, "warning");
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
pi.on("session_start", (_event, ctx) => {
|
|
267
|
+
refreshRuntimeConfig(ctx.cwd);
|
|
268
|
+
currentCtx = ctx;
|
|
269
|
+
installFooter(ctx);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
273
|
+
refreshRuntimeConfig(ctx.cwd);
|
|
274
|
+
currentCtx = ctx;
|
|
275
|
+
installFooter(ctx);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
pi.on("model_select", (_event, ctx) => {
|
|
279
|
+
refresh(ctx);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
pi.on("thinking_level_select", (_event, ctx) => {
|
|
283
|
+
refresh(ctx);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
287
|
+
currentCtx = undefined;
|
|
288
|
+
requestRender = undefined;
|
|
289
|
+
unsubscribeUsageReady();
|
|
290
|
+
unsubscribeUsageUpdate();
|
|
291
|
+
if (ctx.hasUI) ctx.ui.setFooter(undefined);
|
|
292
|
+
});
|
|
293
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
// Color roles the live `buildFooterLine` rendering actually depends on. Kept
|
|
7
|
+
// narrow on purpose: it only covers the segment colors and the "dim"
|
|
8
|
+
// separator, not the full Pi `ThemeColor` union. `StatuslineMenuTheme` is
|
|
9
|
+
// built as a superset of this so the same adapted theme can also feed the
|
|
10
|
+
// bottom preview line.
|
|
11
|
+
export type FooterRenderColor =
|
|
12
|
+
| "accent"
|
|
13
|
+
| "dim"
|
|
14
|
+
| "success"
|
|
15
|
+
| "warning"
|
|
16
|
+
| "error";
|
|
17
|
+
|
|
18
|
+
export type ThemeLike = {
|
|
19
|
+
fg: (color: FooterRenderColor, text: string) => string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ModelLike = {
|
|
23
|
+
id?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
reasoning?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type 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
|
+
|
|
46
|
+
export type RunState = "busy" | "queued" | "idle";
|
|
47
|
+
|
|
48
|
+
export type StatusFilter =
|
|
49
|
+
| { mode: "all"; hidden: string[] }
|
|
50
|
+
| { mode: "only"; shown: string[] };
|
|
51
|
+
|
|
52
|
+
export type FooterRenderInput = {
|
|
53
|
+
model?: ModelLike;
|
|
54
|
+
cwd: string;
|
|
55
|
+
thinkingLevel: string;
|
|
56
|
+
gitBranch?: string | null;
|
|
57
|
+
runState: RunState;
|
|
58
|
+
contextUsage?: {
|
|
59
|
+
tokens?: number | null;
|
|
60
|
+
contextWindow?: number;
|
|
61
|
+
percent?: number | null;
|
|
62
|
+
};
|
|
63
|
+
branchTotals?: { input: number; output: number; totalTokens: number };
|
|
64
|
+
sessionId?: string;
|
|
65
|
+
usageState?: {
|
|
66
|
+
compatibility?: {
|
|
67
|
+
currentLiveProviderSnapshot?: {
|
|
68
|
+
providerId?: string;
|
|
69
|
+
windows: Array<{
|
|
70
|
+
key?: string;
|
|
71
|
+
label?: string;
|
|
72
|
+
usedPercent?: number;
|
|
73
|
+
unavailableReason?: string | null;
|
|
74
|
+
}>;
|
|
75
|
+
} | null;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
extensionStatuses?: ReadonlyMap<string, string>;
|
|
79
|
+
filter: StatusFilter;
|
|
80
|
+
segments: StatusLineSegmentId[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const DEFAULT_SEGMENTS: StatusLineSegmentId[] = [
|
|
84
|
+
"model-with-reasoning",
|
|
85
|
+
"current-dir",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
export function normalizeThinkingLevel(level: string): string {
|
|
89
|
+
switch (level) {
|
|
90
|
+
case "minimal":
|
|
91
|
+
return "min";
|
|
92
|
+
case "medium":
|
|
93
|
+
return "med";
|
|
94
|
+
default:
|
|
95
|
+
return level;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function formatCompactNumber(value: number): string {
|
|
100
|
+
if (value < 1000) return String(Math.trunc(value));
|
|
101
|
+
const unit = value >= 1_000_000 ? "M" : "k";
|
|
102
|
+
const divisor = unit === "M" ? 1_000_000 : 1_000;
|
|
103
|
+
const short = (value / divisor).toFixed(1).replace(/\.0$/, "");
|
|
104
|
+
return `${short}${unit}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatModelWithReasoning(
|
|
108
|
+
model: ModelLike | undefined,
|
|
109
|
+
thinkingLevel: string,
|
|
110
|
+
): string | null {
|
|
111
|
+
const base = model?.name ?? model?.id;
|
|
112
|
+
if (!base) return null;
|
|
113
|
+
if (!model?.reasoning) return base;
|
|
114
|
+
return `${base} [${normalizeThinkingLevel(thinkingLevel)}]`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function abbreviateHomeDir(cwd: string, home = homedir()): string {
|
|
118
|
+
if (!home) return cwd;
|
|
119
|
+
if (cwd === home) return "~";
|
|
120
|
+
if (cwd.startsWith(`${home}/`)) return `~${cwd.slice(home.length)}`;
|
|
121
|
+
return cwd;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function findProjectRootLabel(cwd: string): string | null {
|
|
125
|
+
let current = cwd;
|
|
126
|
+
while (true) {
|
|
127
|
+
if (
|
|
128
|
+
existsSync(join(current, ".git")) ||
|
|
129
|
+
existsSync(join(current, ".pi/settings.json"))
|
|
130
|
+
) {
|
|
131
|
+
const base = basename(current);
|
|
132
|
+
return base || current;
|
|
133
|
+
}
|
|
134
|
+
const parent = dirname(current);
|
|
135
|
+
if (parent === current) return null;
|
|
136
|
+
current = parent;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function contextColor(
|
|
141
|
+
percent: number | null | undefined,
|
|
142
|
+
): "success" | "warning" | "error" | "dim" {
|
|
143
|
+
if (percent === undefined || percent === null) return "dim";
|
|
144
|
+
if (percent < 70) return "success";
|
|
145
|
+
if (percent < 90) return "warning";
|
|
146
|
+
return "error";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getRateWindow(
|
|
150
|
+
input: FooterRenderInput,
|
|
151
|
+
key: "fiveHour" | "weekly",
|
|
152
|
+
): { usedPercent: number } | null {
|
|
153
|
+
const snapshot = input.usageState?.compatibility?.currentLiveProviderSnapshot;
|
|
154
|
+
if (snapshot?.providerId !== "openai-codex") return null;
|
|
155
|
+
const window = snapshot.windows.find((item) => item.key === key);
|
|
156
|
+
if (
|
|
157
|
+
!window ||
|
|
158
|
+
typeof window.usedPercent !== "number" ||
|
|
159
|
+
window.unavailableReason
|
|
160
|
+
)
|
|
161
|
+
return null;
|
|
162
|
+
return { usedPercent: window.usedPercent };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function rateColor(usedPercent: number): "success" | "warning" | "error" {
|
|
166
|
+
if (usedPercent < 70) return "success";
|
|
167
|
+
if (usedPercent < 90) return "warning";
|
|
168
|
+
return "error";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const ANSI_PREFIX = `${String.fromCharCode(27)}[`;
|
|
172
|
+
|
|
173
|
+
function hasAnsi(value: string): boolean {
|
|
174
|
+
return value.includes(ANSI_PREFIX);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeFilterList(input: string[]): string[] {
|
|
178
|
+
const out: string[] = [];
|
|
179
|
+
const seen = new Set<string>();
|
|
180
|
+
for (const item of input) {
|
|
181
|
+
if (seen.has(item)) continue;
|
|
182
|
+
seen.add(item);
|
|
183
|
+
out.push(item);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatExtensionStatuses(
|
|
189
|
+
input: FooterRenderInput,
|
|
190
|
+
theme: ThemeLike,
|
|
191
|
+
): string | null {
|
|
192
|
+
const entries = [...(input.extensionStatuses?.entries() ?? [])].sort(
|
|
193
|
+
([a], [b]) => a.localeCompare(b),
|
|
194
|
+
);
|
|
195
|
+
if (entries.length === 0) return null;
|
|
196
|
+
|
|
197
|
+
const filter = input.filter;
|
|
198
|
+
const blocked =
|
|
199
|
+
filter.mode === "all"
|
|
200
|
+
? new Set(normalizeFilterList(filter.hidden))
|
|
201
|
+
: undefined;
|
|
202
|
+
const allowed =
|
|
203
|
+
filter.mode === "only"
|
|
204
|
+
? new Set(normalizeFilterList(filter.shown))
|
|
205
|
+
: undefined;
|
|
206
|
+
const visible =
|
|
207
|
+
filter.mode === "all"
|
|
208
|
+
? entries.filter(([key]) => !blocked?.has(key))
|
|
209
|
+
: entries.filter(([key]) => allowed?.has(key));
|
|
210
|
+
|
|
211
|
+
const parts = visible.slice(0, 5).map(([key, value]) => {
|
|
212
|
+
const trimmed = hasAnsi(value)
|
|
213
|
+
? value
|
|
214
|
+
: value.replace(
|
|
215
|
+
new RegExp(
|
|
216
|
+
`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s*[:=-]\\s*|\\s+)`,
|
|
217
|
+
"i",
|
|
218
|
+
),
|
|
219
|
+
"",
|
|
220
|
+
);
|
|
221
|
+
return truncateToWidth(trimmed, 18, "...");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (parts.length === 0) return null;
|
|
225
|
+
return parts.join(theme.fg("dim", " | "));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatSegment(
|
|
229
|
+
id: StatusLineSegmentId,
|
|
230
|
+
input: FooterRenderInput,
|
|
231
|
+
theme: ThemeLike,
|
|
232
|
+
): [text: string, color: FooterRenderColor | null] | null {
|
|
233
|
+
switch (id) {
|
|
234
|
+
case "model": {
|
|
235
|
+
const value = input.model?.name ?? input.model?.id;
|
|
236
|
+
return value ? [value, "accent"] : null;
|
|
237
|
+
}
|
|
238
|
+
case "model-with-reasoning": {
|
|
239
|
+
const value = formatModelWithReasoning(input.model, input.thinkingLevel);
|
|
240
|
+
return value ? [value, "accent"] : null;
|
|
241
|
+
}
|
|
242
|
+
case "current-dir": {
|
|
243
|
+
const value = abbreviateHomeDir(input.cwd);
|
|
244
|
+
return value ? [value, "success"] : null;
|
|
245
|
+
}
|
|
246
|
+
case "project-name": {
|
|
247
|
+
const value = findProjectRootLabel(input.cwd);
|
|
248
|
+
return value ? [value, "success"] : null;
|
|
249
|
+
}
|
|
250
|
+
case "git-branch":
|
|
251
|
+
return input.gitBranch ? [input.gitBranch, "warning"] : null;
|
|
252
|
+
case "run-state":
|
|
253
|
+
return [input.runState, input.runState === "idle" ? "dim" : "accent"];
|
|
254
|
+
case "context-used": {
|
|
255
|
+
const percent = input.contextUsage?.percent;
|
|
256
|
+
return percent === undefined || percent === null
|
|
257
|
+
? null
|
|
258
|
+
: [`${Math.round(percent)}% ctx`, contextColor(percent)];
|
|
259
|
+
}
|
|
260
|
+
case "context-remaining": {
|
|
261
|
+
const total = input.contextUsage?.tokens;
|
|
262
|
+
const window = input.contextUsage?.contextWindow;
|
|
263
|
+
const percent = input.contextUsage?.percent;
|
|
264
|
+
if (
|
|
265
|
+
total === undefined ||
|
|
266
|
+
total === null ||
|
|
267
|
+
window === undefined ||
|
|
268
|
+
percent === undefined ||
|
|
269
|
+
percent === null
|
|
270
|
+
) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
const remaining = Math.max(0, window - total);
|
|
274
|
+
return [`${formatCompactNumber(remaining)} left`, contextColor(percent)];
|
|
275
|
+
}
|
|
276
|
+
case "context-window-size": {
|
|
277
|
+
const value = input.contextUsage?.contextWindow;
|
|
278
|
+
return value === undefined
|
|
279
|
+
? null
|
|
280
|
+
: [`${formatCompactNumber(value)} ctx`, "dim"];
|
|
281
|
+
}
|
|
282
|
+
case "used-tokens": {
|
|
283
|
+
const value = input.branchTotals?.totalTokens;
|
|
284
|
+
return value === undefined
|
|
285
|
+
? null
|
|
286
|
+
: [`${formatCompactNumber(value)} tok`, "dim"];
|
|
287
|
+
}
|
|
288
|
+
case "total-input-tokens": {
|
|
289
|
+
const value = input.branchTotals?.input;
|
|
290
|
+
return value === undefined
|
|
291
|
+
? null
|
|
292
|
+
: [`↑${formatCompactNumber(value)}`, "dim"];
|
|
293
|
+
}
|
|
294
|
+
case "total-output-tokens": {
|
|
295
|
+
const value = input.branchTotals?.output;
|
|
296
|
+
return value === undefined
|
|
297
|
+
? null
|
|
298
|
+
: [`↓${formatCompactNumber(value)}`, "dim"];
|
|
299
|
+
}
|
|
300
|
+
case "session-id":
|
|
301
|
+
return input.sessionId
|
|
302
|
+
? [`sid ${input.sessionId.slice(0, 8)}`, "dim"]
|
|
303
|
+
: null;
|
|
304
|
+
case "five-hour-limit": {
|
|
305
|
+
const window = getRateWindow(input, "fiveHour");
|
|
306
|
+
if (!window) return null;
|
|
307
|
+
const remaining = Math.min(
|
|
308
|
+
100,
|
|
309
|
+
Math.max(0, 100 - Math.round(window.usedPercent)),
|
|
310
|
+
);
|
|
311
|
+
return [`5h ${remaining}% left`, rateColor(window.usedPercent)];
|
|
312
|
+
}
|
|
313
|
+
case "weekly-limit": {
|
|
314
|
+
const window = getRateWindow(input, "weekly");
|
|
315
|
+
if (!window) return null;
|
|
316
|
+
const remaining = Math.min(
|
|
317
|
+
100,
|
|
318
|
+
Math.max(0, 100 - Math.round(window.usedPercent)),
|
|
319
|
+
);
|
|
320
|
+
return [`wk ${remaining}% left`, rateColor(window.usedPercent)];
|
|
321
|
+
}
|
|
322
|
+
case "extension-statuses": {
|
|
323
|
+
const value = formatExtensionStatuses(input, theme);
|
|
324
|
+
return value ? [value, null] : null;
|
|
325
|
+
}
|
|
326
|
+
default:
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function buildFooterLine(
|
|
332
|
+
input: FooterRenderInput,
|
|
333
|
+
theme: ThemeLike,
|
|
334
|
+
width: number,
|
|
335
|
+
): string {
|
|
336
|
+
const parts = input.segments
|
|
337
|
+
.map((id) => formatSegment(id, input, theme))
|
|
338
|
+
.filter((x): x is [string, FooterRenderColor | null] => x !== null)
|
|
339
|
+
.map(([text, color]) => (color ? theme.fg(color, text) : text));
|
|
340
|
+
|
|
341
|
+
const line = parts.join(theme.fg("dim", " · "));
|
|
342
|
+
return truncateToWidth(line, width);
|
|
343
|
+
}
|