pi-footer-manager 1.0.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 +177 -0
- package/assets/pi-footer-manager.png +0 -0
- package/footer-manager/README.md +125 -0
- package/footer-manager/built-ins.ts +144 -0
- package/footer-manager/index.ts +462 -0
- package/footer-manager/types.ts +53 -0
- package/fragments/context-gauge-text-fragment.ts +57 -0
- package/fragments/footer-timer-fragment.ts +81 -0
- package/fragments/quota-footer-fragment-text.ts +476 -0
- package/fragments/quota-footer-fragment.ts +483 -0
- package/package.json +50 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SettingsManager,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
type ReadonlyFooterDataProvider,
|
|
6
|
+
type Theme,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
9
|
+
import { createBuiltInFragments } from "./built-ins.js";
|
|
10
|
+
import {
|
|
11
|
+
FOOTER_MANAGER_INVALIDATE,
|
|
12
|
+
FOOTER_MANAGER_REGISTER_FRAGMENT,
|
|
13
|
+
FOOTER_MANAGER_UNREGISTER_FRAGMENT,
|
|
14
|
+
type FooterFragmentComponent,
|
|
15
|
+
type FooterFragmentRegistration,
|
|
16
|
+
type FooterLayoutConfig,
|
|
17
|
+
type FooterRegionAlign,
|
|
18
|
+
type FooterRegionWidth,
|
|
19
|
+
type FooterRenderEnv,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
type ValidLayout = Required<Pick<FooterLayoutConfig, "separator" | "rows">>;
|
|
23
|
+
type Region = ValidLayout["rows"][number]["regions"][number];
|
|
24
|
+
type WarnType = "info" | "warning" | "error";
|
|
25
|
+
|
|
26
|
+
type Entry = {
|
|
27
|
+
registration: FooterFragmentRegistration;
|
|
28
|
+
component?: FooterFragmentComponent;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/;
|
|
32
|
+
const UNKNOWN_FRAGMENT_WARNING_DELAY_MS = 3000;
|
|
33
|
+
const DEFAULT_LAYOUT: ValidLayout = {
|
|
34
|
+
separator: " > ",
|
|
35
|
+
rows: [
|
|
36
|
+
{
|
|
37
|
+
regions: [
|
|
38
|
+
{ width: 0.65, align: "left", fragments: ["cwd.full", "git.branch", "context.gauge"] },
|
|
39
|
+
{ width: 0.35, align: "right", fragments: ["model.name", "thinking.level", "statuses"] },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
46
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stripNewlines(text: string): string {
|
|
50
|
+
return text.replace(/[\r\n]+/g, " ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function padToWidth(line: string, width: number): string {
|
|
54
|
+
return line + " ".repeat(Math.max(0, width - visibleWidth(line)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function alignText(text: string, width: number, align: FooterRegionAlign): string {
|
|
58
|
+
if (width <= 0) return "";
|
|
59
|
+
const clipped = truncateToWidth(stripNewlines(text), width);
|
|
60
|
+
const used = visibleWidth(clipped);
|
|
61
|
+
const pad = Math.max(0, width - used);
|
|
62
|
+
if (align === "right") return " ".repeat(pad) + clipped;
|
|
63
|
+
if (align === "center") {
|
|
64
|
+
const left = Math.floor(pad / 2);
|
|
65
|
+
return " ".repeat(left) + clipped + " ".repeat(pad - left);
|
|
66
|
+
}
|
|
67
|
+
return clipped + " ".repeat(pad);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isAutoWidth(width: FooterRegionWidth): width is "auto" {
|
|
71
|
+
return width === "auto";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function numericWidth(width: FooterRegionWidth): number {
|
|
75
|
+
return typeof width === "number" ? width : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function allocateProportional(weights: number[], totalWidth: number): number[] {
|
|
79
|
+
if (totalWidth <= 0) return weights.map(() => 0);
|
|
80
|
+
const sum = weights.reduce((acc, width) => acc + Math.max(0, width), 0);
|
|
81
|
+
if (sum <= 0) return weights.map(() => 0);
|
|
82
|
+
const ideals = weights.map((width) => (Math.max(0, width) / sum) * totalWidth);
|
|
83
|
+
const floors = ideals.map(Math.floor);
|
|
84
|
+
let leftover = totalWidth - floors.reduce((acc, n) => acc + n, 0);
|
|
85
|
+
const order = ideals
|
|
86
|
+
.map((ideal, index) => ({ index, remainder: ideal - Math.floor(ideal) }))
|
|
87
|
+
.sort((a, b) => b.remainder - a.remainder || a.index - b.index);
|
|
88
|
+
for (const item of order) {
|
|
89
|
+
if (leftover <= 0) break;
|
|
90
|
+
floors[item.index]++;
|
|
91
|
+
leftover--;
|
|
92
|
+
}
|
|
93
|
+
return floors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fitRightFirst(requested: number[], totalWidth: number): number[] {
|
|
97
|
+
const widths = requested.map((width) => Math.max(0, Math.floor(width)));
|
|
98
|
+
const target = Math.max(0, totalWidth);
|
|
99
|
+
let overflow = widths.reduce((acc, width) => acc + width, 0) - target;
|
|
100
|
+
for (let index = widths.length - 1; index >= 0 && overflow > 0; index--) {
|
|
101
|
+
const cut = Math.min(widths[index] ?? 0, overflow);
|
|
102
|
+
widths[index] = (widths[index] ?? 0) - cut;
|
|
103
|
+
overflow -= cut;
|
|
104
|
+
}
|
|
105
|
+
const used = widths.reduce((acc, width) => acc + width, 0);
|
|
106
|
+
const extra = target - used;
|
|
107
|
+
if (extra > 0) {
|
|
108
|
+
const index = widths.map((width, index) => ({ width, index })).filter((item) => item.width > 0).at(-1)?.index ?? widths.length - 1;
|
|
109
|
+
if (index >= 0) widths[index] = (widths[index] ?? 0) + extra;
|
|
110
|
+
}
|
|
111
|
+
return widths;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function allocateWidths(regions: Region[], totalWidth: number, measureAuto: (region: Region) => number): number[] {
|
|
115
|
+
if (totalWidth <= 0) return regions.map(() => 0);
|
|
116
|
+
|
|
117
|
+
const hasAuto = regions.some((region) => isAutoWidth(region.width ?? "auto"));
|
|
118
|
+
if (!hasAuto) return allocateProportional(regions.map((region) => numericWidth(region.width ?? "auto")), totalWidth);
|
|
119
|
+
|
|
120
|
+
const fixedWeights = regions.map((region) => (isAutoWidth(region.width ?? "auto") ? 0 : numericWidth(region.width ?? "auto")));
|
|
121
|
+
const fixedSum = fixedWeights.reduce((acc, width) => acc + width, 0);
|
|
122
|
+
const fixedWidths = fixedSum > 1
|
|
123
|
+
? fitRightFirst(fixedWeights.map((width) => Math.ceil(width * totalWidth)), totalWidth)
|
|
124
|
+
: allocateProportional(fixedWeights, Math.min(totalWidth, Math.round(fixedSum * totalWidth)));
|
|
125
|
+
const remaining = Math.max(0, totalWidth - fixedWidths.reduce((acc, width) => acc + width, 0));
|
|
126
|
+
|
|
127
|
+
const autoRequests = regions.map((region) => (isAutoWidth(region.width ?? "auto") ? Math.max(0, Math.ceil(measureAuto(region))) : 0));
|
|
128
|
+
const autoWidths = fitRightFirst(autoRequests, remaining);
|
|
129
|
+
return regions.map((region, index) => (isAutoWidth(region.width ?? "auto") ? autoWidths[index] ?? 0 : fixedWidths[index] ?? 0));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
class FooterManager {
|
|
133
|
+
private ctx?: ExtensionContext;
|
|
134
|
+
private tui?: TUI;
|
|
135
|
+
private theme?: Theme;
|
|
136
|
+
private footerData?: ReadonlyFooterDataProvider;
|
|
137
|
+
private layout: ValidLayout = DEFAULT_LAYOUT;
|
|
138
|
+
private entries = new Map<string, Entry>();
|
|
139
|
+
private persistedRegistrations = new Map<string, FooterFragmentRegistration>();
|
|
140
|
+
private warned = new Set<string>();
|
|
141
|
+
private unknownWarned = new Set<string>();
|
|
142
|
+
private pendingUnknownWarnings = new Set<string>();
|
|
143
|
+
private unsubscribers: Array<() => void> = [];
|
|
144
|
+
private renderQueued = false;
|
|
145
|
+
|
|
146
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
147
|
+
|
|
148
|
+
start(ctx: ExtensionContext): void {
|
|
149
|
+
this.listen();
|
|
150
|
+
this.disposeComponents();
|
|
151
|
+
this.entries.clear();
|
|
152
|
+
this.ctx = ctx;
|
|
153
|
+
this.warned.clear();
|
|
154
|
+
this.unknownWarned.clear();
|
|
155
|
+
this.pendingUnknownWarnings.clear();
|
|
156
|
+
this.loadLayout();
|
|
157
|
+
this.registerBuiltIns();
|
|
158
|
+
for (const registration of this.persistedRegistrations.values()) this.register(registration, false);
|
|
159
|
+
|
|
160
|
+
if (!ctx.hasUI) return;
|
|
161
|
+
|
|
162
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
163
|
+
this.tui = tui;
|
|
164
|
+
this.theme = theme;
|
|
165
|
+
this.footerData = footerData;
|
|
166
|
+
const unsubBranch = footerData.onBranchChange(() => this.invalidate());
|
|
167
|
+
this.reconcileVisibleComponents();
|
|
168
|
+
return {
|
|
169
|
+
invalidate: () => this.invalidate(),
|
|
170
|
+
dispose: () => {
|
|
171
|
+
unsubBranch();
|
|
172
|
+
this.disposeComponents();
|
|
173
|
+
this.tui = undefined;
|
|
174
|
+
this.theme = undefined;
|
|
175
|
+
this.footerData = undefined;
|
|
176
|
+
},
|
|
177
|
+
render: (width: number) => this.render(width),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
listen(): void {
|
|
183
|
+
if (this.unsubscribers.length > 0) return;
|
|
184
|
+
this.unsubscribers.push(
|
|
185
|
+
this.pi.events.on(FOOTER_MANAGER_REGISTER_FRAGMENT, (data) => this.register(data)),
|
|
186
|
+
this.pi.events.on(FOOTER_MANAGER_UNREGISTER_FRAGMENT, (data) => this.unregister(data)),
|
|
187
|
+
this.pi.events.on(FOOTER_MANAGER_INVALIDATE, () => this.invalidate())
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
shutdown(): void {
|
|
192
|
+
for (const unsub of this.unsubscribers.splice(0)) unsub();
|
|
193
|
+
this.disposeComponents();
|
|
194
|
+
this.entries.clear();
|
|
195
|
+
this.ctx?.ui.setFooter(undefined);
|
|
196
|
+
this.ctx = undefined;
|
|
197
|
+
this.tui = undefined;
|
|
198
|
+
this.theme = undefined;
|
|
199
|
+
this.footerData = undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private warn(key: string, message: string, type: WarnType = "warning"): void {
|
|
203
|
+
if (this.warned.has(key)) return;
|
|
204
|
+
this.warned.add(key);
|
|
205
|
+
if (this.ctx?.hasUI) this.ctx.ui.notify(`[footer-manager] ${message}`, type);
|
|
206
|
+
else console.warn(`[footer-manager] ${message}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private loadLayout(): void {
|
|
210
|
+
let source: "project" | "global" | "default" = "default";
|
|
211
|
+
let raw: unknown;
|
|
212
|
+
try {
|
|
213
|
+
const manager = SettingsManager.create((this.ctx as any)?.cwd ?? process.cwd());
|
|
214
|
+
const project = manager.getProjectSettings() as any;
|
|
215
|
+
const global = manager.getGlobalSettings() as any;
|
|
216
|
+
if (Object.prototype.hasOwnProperty.call(project, "footerManager")) {
|
|
217
|
+
source = "project";
|
|
218
|
+
raw = project.footerManager;
|
|
219
|
+
} else if (Object.prototype.hasOwnProperty.call(global, "footerManager")) {
|
|
220
|
+
source = "global";
|
|
221
|
+
raw = global.footerManager;
|
|
222
|
+
}
|
|
223
|
+
for (const error of manager.drainErrors()) this.warn(`settings:${error.scope}`, `could not read ${error.scope} settings: ${error.error.message}`);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this.warn("settings:create", `could not load settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (source === "default") {
|
|
229
|
+
this.layout = DEFAULT_LAYOUT;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const parsed = this.validateSettings(raw, source);
|
|
234
|
+
this.layout = parsed ?? DEFAULT_LAYOUT;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private validateSettings(value: unknown, source: string): ValidLayout | undefined {
|
|
238
|
+
const settings = asObject(value);
|
|
239
|
+
const layout = asObject(settings?.layout);
|
|
240
|
+
if (!settings || !layout) {
|
|
241
|
+
this.warn(`layout:${source}:shape`, `${source} footerManager.layout is missing or invalid; using default layout`);
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
const rowsValue = layout.rows;
|
|
245
|
+
if (!Array.isArray(rowsValue) || rowsValue.length === 0) {
|
|
246
|
+
this.warn(`layout:${source}:rows`, `${source} footerManager.layout.rows must be a non-empty array; using default layout`);
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
const separator = layout.separator === undefined ? DEFAULT_LAYOUT.separator : layout.separator;
|
|
250
|
+
if (typeof separator !== "string") {
|
|
251
|
+
this.warn(`layout:${source}:separator`, `${source} footerManager.layout.separator must be a string; using default layout`);
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const rows: ValidLayout["rows"] = [];
|
|
256
|
+
for (let rowIndex = 0; rowIndex < rowsValue.length; rowIndex++) {
|
|
257
|
+
const row = asObject(rowsValue[rowIndex]);
|
|
258
|
+
const regionsValue = row?.regions;
|
|
259
|
+
if (!row || !Array.isArray(regionsValue)) {
|
|
260
|
+
this.warn(`layout:${source}:row:${rowIndex}`, `${source} footer row ${rowIndex} is invalid; using default layout`);
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
const regions: Region[] = [];
|
|
264
|
+
for (let regionIndex = 0; regionIndex < regionsValue.length; regionIndex++) {
|
|
265
|
+
const region = asObject(regionsValue[regionIndex]);
|
|
266
|
+
const label = typeof region?.name === "string" ? region.name : `${rowIndex}.${regionIndex}`;
|
|
267
|
+
const width: FooterRegionWidth = region?.width === undefined ? "auto" : region.width as FooterRegionWidth;
|
|
268
|
+
const align = region?.align;
|
|
269
|
+
const fragments = region?.fragments;
|
|
270
|
+
if (width !== "auto" && (typeof width !== "number" || !Number.isFinite(width) || width < 0 || width > 1)) {
|
|
271
|
+
this.warn(`layout:${source}:region:${label}:width`, `${source} footer region ${label} has invalid width; expected a number from 0 to 1, "auto", or omitted; using default layout`);
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
if (align !== "left" && align !== "center" && align !== "right") {
|
|
275
|
+
this.warn(`layout:${source}:region:${label}:align`, `${source} footer region ${label} has invalid align; using default layout`);
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
if (!Array.isArray(fragments) || !fragments.every((id) => typeof id === "string")) {
|
|
279
|
+
this.warn(`layout:${source}:region:${label}:fragments`, `${source} footer region ${label} has invalid fragments; using default layout`);
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
regions.push({ name: typeof region?.name === "string" ? region.name : undefined, width, align, fragments });
|
|
283
|
+
}
|
|
284
|
+
const numericSum = regions.reduce((acc, r) => acc + numericWidth(r.width ?? "auto"), 0);
|
|
285
|
+
const hasAuto = regions.some((r) => isAutoWidth(r.width ?? "auto"));
|
|
286
|
+
if (!hasAuto && numericSum <= 0) {
|
|
287
|
+
this.warn(`layout:${source}:row:${rowIndex}:zero`, `${source} footer row ${rowIndex} widths sum to zero; using default layout`);
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
if (!hasAuto && Math.abs(numericSum - 1) > 0.000001) this.warn(`layout:${source}:row:${rowIndex}:normalize`, `${source} footer row ${rowIndex} widths sum to ${numericSum}; normalizing`);
|
|
291
|
+
rows.push({ regions });
|
|
292
|
+
}
|
|
293
|
+
return { separator, rows };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private register(data: unknown, persist = true): void {
|
|
297
|
+
const registration = asObject(data) as FooterFragmentRegistration | undefined;
|
|
298
|
+
if (!registration || typeof registration.id !== "string" || !ID_RE.test(registration.id) || typeof registration.component !== "function") {
|
|
299
|
+
this.warn(`register:invalid:${JSON.stringify(data)?.slice(0, 60)}`, "invalid fragment registration ignored");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (persist) this.persistedRegistrations.set(registration.id, registration);
|
|
303
|
+
const existing = this.entries.get(registration.id);
|
|
304
|
+
if (existing) {
|
|
305
|
+
this.warn(`register:duplicate:${registration.id}`, `duplicate fragment '${registration.id}' registered; using latest`);
|
|
306
|
+
this.disposeEntry(registration.id, existing);
|
|
307
|
+
}
|
|
308
|
+
this.entries.set(registration.id, { registration });
|
|
309
|
+
this.pendingUnknownWarnings.delete(registration.id);
|
|
310
|
+
this.reconcileVisibleComponents();
|
|
311
|
+
this.invalidate();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private unregister(data: unknown): void {
|
|
315
|
+
const id = typeof data === "string" ? data : typeof asObject(data)?.id === "string" ? (asObject(data)!.id as string) : undefined;
|
|
316
|
+
if (!id) return;
|
|
317
|
+
const entry = this.entries.get(id);
|
|
318
|
+
if (!entry) return;
|
|
319
|
+
this.disposeEntry(id, entry);
|
|
320
|
+
this.entries.delete(id);
|
|
321
|
+
this.persistedRegistrations.delete(id);
|
|
322
|
+
this.invalidate();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private registerBuiltIns(): void {
|
|
326
|
+
for (const fragment of createBuiltInFragments({ getSeparator: () => this.layout.separator })) {
|
|
327
|
+
this.register(fragment, false);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private visibleIds(): Set<string> {
|
|
332
|
+
const ids = new Set<string>();
|
|
333
|
+
for (const row of this.layout.rows) {
|
|
334
|
+
for (const region of row.regions) {
|
|
335
|
+
const width = region.width ?? "auto";
|
|
336
|
+
if (!isAutoWidth(width) && width <= 0) continue;
|
|
337
|
+
for (const id of region.fragments) ids.add(id);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return ids;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private reconcileVisibleComponents(): void {
|
|
344
|
+
if (!this.ctx || !this.tui || !this.theme || !this.footerData) return;
|
|
345
|
+
const visible = this.visibleIds();
|
|
346
|
+
for (const [id, entry] of this.entries) {
|
|
347
|
+
if (!visible.has(id)) {
|
|
348
|
+
if (entry.component) this.disposeEntry(id, entry);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (entry.component) continue;
|
|
352
|
+
try {
|
|
353
|
+
entry.component = entry.registration.component({ ctx: this.ctx, tui: this.tui, theme: this.theme, footerData: this.footerData, separator: this.layout.separator, invalidate: () => this.invalidate() });
|
|
354
|
+
} catch (error) {
|
|
355
|
+
this.warn(`factory:${id}`, `fragment '${id}' factory failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private disposeEntry(id: string, entry: Entry): void {
|
|
361
|
+
if (!entry.component) return;
|
|
362
|
+
try { entry.component.dispose?.(); } catch (error) { this.warn(`dispose:${id}`, `fragment '${id}' dispose failed: ${error instanceof Error ? error.message : String(error)}`); }
|
|
363
|
+
entry.component = undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private disposeComponents(): void {
|
|
367
|
+
for (const [id, entry] of this.entries) this.disposeEntry(id, entry);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private invalidate(): void {
|
|
371
|
+
if (!this.tui || this.renderQueued) return;
|
|
372
|
+
this.renderQueued = true;
|
|
373
|
+
setTimeout(() => {
|
|
374
|
+
this.renderQueued = false;
|
|
375
|
+
this.tui?.requestRender();
|
|
376
|
+
}, 0);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private renderFragment(id: string, regionWidth: number): string | undefined {
|
|
380
|
+
const entry = this.entries.get(id);
|
|
381
|
+
if (!entry) {
|
|
382
|
+
if (!this.unknownWarned.has(id) && !this.pendingUnknownWarnings.has(id)) {
|
|
383
|
+
this.pendingUnknownWarnings.add(id);
|
|
384
|
+
setTimeout(() => {
|
|
385
|
+
this.pendingUnknownWarnings.delete(id);
|
|
386
|
+
if (this.entries.has(id) || this.unknownWarned.has(id)) return;
|
|
387
|
+
this.unknownWarned.add(id);
|
|
388
|
+
this.warn(`unknown:${id}`, `unknown fragment '${id}' skipped`);
|
|
389
|
+
}, UNKNOWN_FRAGMENT_WARNING_DELAY_MS);
|
|
390
|
+
}
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
if (!entry.component) this.reconcileVisibleComponents();
|
|
394
|
+
if (!entry.component) return undefined;
|
|
395
|
+
try {
|
|
396
|
+
return stripNewlines(entry.component.render() || "");
|
|
397
|
+
} catch (error) {
|
|
398
|
+
this.warn(`render:${id}`, `fragment '${id}' render failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private renderRegionContent(region: Region, width: number): string {
|
|
404
|
+
if (width <= 0 || region.fragments.length === 0) return "";
|
|
405
|
+
const sep = this.theme?.fg("dim", this.layout.separator) ?? this.layout.separator;
|
|
406
|
+
let parts = region.fragments.map((id: string) => this.renderFragment(id, width)).filter((part): part is string => part !== undefined && visibleWidth(part) > 0);
|
|
407
|
+
while (parts.length > 1 && visibleWidth(parts.join(sep)) > width) {
|
|
408
|
+
parts = region.align === "right" ? parts.slice(1) : parts.slice(0, -1);
|
|
409
|
+
}
|
|
410
|
+
return parts.length === 1 ? truncateToWidth(parts[0], width) : parts.join(sep);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private measureRegion(region: Region, maxWidth: number): number {
|
|
414
|
+
return visibleWidth(this.renderRegionContent(region, maxWidth));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private renderRegion(region: Region, width: number): string {
|
|
418
|
+
return alignText(this.renderRegionContent(region, width), width, region.align);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
render(width: number): string[] {
|
|
422
|
+
const safeWidth = Math.max(0, Math.floor(width));
|
|
423
|
+
if (safeWidth <= 0) return [];
|
|
424
|
+
this.reconcileVisibleComponents();
|
|
425
|
+
const lines: string[] = [];
|
|
426
|
+
for (const row of this.layout.rows) {
|
|
427
|
+
const gapWidth = Math.max(0, row.regions.length - 1);
|
|
428
|
+
const contentWidth = Math.max(0, safeWidth - gapWidth);
|
|
429
|
+
const widths = allocateWidths(row.regions, contentWidth, (region) => this.measureRegion(region, contentWidth));
|
|
430
|
+
const regions = row.regions.map((region: Region, i: number) => this.renderRegion(region, widths[i] ?? 0));
|
|
431
|
+
const hasContent = regions.some((region: string) => visibleWidth(region.trim()) > 0);
|
|
432
|
+
if (!hasContent) continue;
|
|
433
|
+
lines.push(padToWidth(truncateToWidth(regions.join(" "), safeWidth), safeWidth));
|
|
434
|
+
}
|
|
435
|
+
return lines;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export default function (pi: ExtensionAPI) {
|
|
440
|
+
const manager = new FooterManager(pi);
|
|
441
|
+
manager.listen();
|
|
442
|
+
|
|
443
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
444
|
+
manager.start(ctx);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
pi.on("session_shutdown", async () => {
|
|
448
|
+
manager.shutdown();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
pi.on("turn_end", async () => {
|
|
452
|
+
pi.events.emit(FOOTER_MANAGER_INVALIDATE, { id: "footer-manager" });
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
pi.on("model_select", async () => {
|
|
456
|
+
pi.events.emit(FOOTER_MANAGER_INVALIDATE, { id: "footer-manager" });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
pi.on("thinking_level_select", async () => {
|
|
460
|
+
pi.events.emit(FOOTER_MANAGER_INVALIDATE, { id: "footer-manager" });
|
|
461
|
+
});
|
|
462
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionContext,
|
|
3
|
+
ReadonlyFooterDataProvider,
|
|
4
|
+
Theme,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
export const FOOTER_MANAGER_REGISTER_FRAGMENT = "footer-manager:register-fragment";
|
|
9
|
+
export const FOOTER_MANAGER_UNREGISTER_FRAGMENT = "footer-manager:unregister-fragment";
|
|
10
|
+
export const FOOTER_MANAGER_INVALIDATE = "footer-manager:invalidate";
|
|
11
|
+
|
|
12
|
+
export type FooterFragmentRegistration = {
|
|
13
|
+
id: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
component: (env: FooterRenderEnv) => FooterFragmentComponent;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type FooterRenderEnv = {
|
|
19
|
+
ctx: ExtensionContext;
|
|
20
|
+
tui: TUI;
|
|
21
|
+
theme: Theme;
|
|
22
|
+
footerData: ReadonlyFooterDataProvider;
|
|
23
|
+
separator: string;
|
|
24
|
+
invalidate: () => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type FooterFragmentComponent = {
|
|
28
|
+
render(): string;
|
|
29
|
+
dispose?(): void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type FooterRegionAlign = "left" | "center" | "right";
|
|
33
|
+
export type FooterRegionWidth = number | "auto";
|
|
34
|
+
|
|
35
|
+
export type FooterRegionConfig = {
|
|
36
|
+
name?: string;
|
|
37
|
+
width?: FooterRegionWidth;
|
|
38
|
+
align: FooterRegionAlign;
|
|
39
|
+
fragments: string[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type FooterRowConfig = {
|
|
43
|
+
regions: FooterRegionConfig[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type FooterLayoutConfig = {
|
|
47
|
+
separator?: string;
|
|
48
|
+
rows: FooterRowConfig[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type FooterManagerSettings = {
|
|
52
|
+
layout?: FooterLayoutConfig;
|
|
53
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { buildSessionContext, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
FOOTER_MANAGER_REGISTER_FRAGMENT,
|
|
4
|
+
FOOTER_MANAGER_UNREGISTER_FRAGMENT,
|
|
5
|
+
type FooterFragmentRegistration,
|
|
6
|
+
type FooterRenderEnv,
|
|
7
|
+
} from "../footer-manager/types.js";
|
|
8
|
+
|
|
9
|
+
const FRAGMENT_ID = "context.gauge.text";
|
|
10
|
+
|
|
11
|
+
function formatTokens(tokens: number): string {
|
|
12
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return "0";
|
|
13
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
14
|
+
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
|
15
|
+
return `${Math.round(tokens)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getContextInfo(ctx: ExtensionContext): { percentage: number; used?: number; total?: number } {
|
|
19
|
+
try {
|
|
20
|
+
const contextWindow = Number(ctx.model?.contextWindow) || 0;
|
|
21
|
+
if (contextWindow <= 0) return { percentage: 0 };
|
|
22
|
+
const context = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
|
|
23
|
+
const lastAssistant = [...context.messages].reverse().find((m: any) => m.role === "assistant" && m.stopReason !== "aborted") as any;
|
|
24
|
+
const usage = lastAssistant?.usage;
|
|
25
|
+
if (!usage) return { percentage: 0, used: 0, total: contextWindow };
|
|
26
|
+
const used = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
|
27
|
+
return { percentage: (used / contextWindow) * 100, used, total: contextWindow };
|
|
28
|
+
} catch {
|
|
29
|
+
return { percentage: 0 };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderContextText(ctx: ExtensionContext, theme: Theme): string {
|
|
34
|
+
const { percentage, used, total } = getContextInfo(ctx);
|
|
35
|
+
const clamped = Math.max(0, Math.min(100, percentage));
|
|
36
|
+
const color = clamped >= 90 ? "error" : clamped >= 70 ? "warning" : clamped >= 50 ? "accent" : "success";
|
|
37
|
+
const counts = used !== undefined && total ? ` ${formatTokens(used)}/${formatTokens(total)}` : "";
|
|
38
|
+
return `${theme.fg("dim", "ctx ")}${theme.fg(color, `${Math.round(clamped)}%`)}${theme.fg("dim", counts)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function (pi: ExtensionAPI) {
|
|
42
|
+
const registration: FooterFragmentRegistration = {
|
|
43
|
+
id: FRAGMENT_ID,
|
|
44
|
+
label: "Context (text)",
|
|
45
|
+
component: ({ ctx, theme }: FooterRenderEnv) => ({
|
|
46
|
+
render: () => renderContextText(ctx, theme),
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
pi.on("session_start", async () => {
|
|
51
|
+
pi.events.emit(FOOTER_MANAGER_REGISTER_FRAGMENT, registration);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pi.on("session_shutdown", async () => {
|
|
55
|
+
pi.events.emit(FOOTER_MANAGER_UNREGISTER_FRAGMENT, { id: FRAGMENT_ID });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
FOOTER_MANAGER_INVALIDATE,
|
|
4
|
+
FOOTER_MANAGER_REGISTER_FRAGMENT,
|
|
5
|
+
FOOTER_MANAGER_UNREGISTER_FRAGMENT,
|
|
6
|
+
type FooterFragmentRegistration,
|
|
7
|
+
type FooterRenderEnv,
|
|
8
|
+
} from "../footer-manager/types.js";
|
|
9
|
+
|
|
10
|
+
const FRAGMENT_ID = "timer.work";
|
|
11
|
+
|
|
12
|
+
type TimerState =
|
|
13
|
+
| { status: "idle" }
|
|
14
|
+
| { status: "running"; startedAt: number }
|
|
15
|
+
| { status: "done"; elapsedMs: number };
|
|
16
|
+
|
|
17
|
+
function formatDuration(ms: number): string {
|
|
18
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
19
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
20
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
21
|
+
const seconds = totalSeconds % 60;
|
|
22
|
+
|
|
23
|
+
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
|
24
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
25
|
+
return `${seconds}s`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function (pi: ExtensionAPI) {
|
|
29
|
+
let state: TimerState = { status: "idle" };
|
|
30
|
+
|
|
31
|
+
function invalidate() {
|
|
32
|
+
pi.events.emit(FOOTER_MANAGER_INVALIDATE, { id: FRAGMENT_ID });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const registration: FooterFragmentRegistration = {
|
|
36
|
+
id: FRAGMENT_ID,
|
|
37
|
+
label: "Work timer",
|
|
38
|
+
component: (env: FooterRenderEnv) => {
|
|
39
|
+
const interval = setInterval(() => {
|
|
40
|
+
if (state.status === "running") env.invalidate();
|
|
41
|
+
}, 1000);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
render() {
|
|
45
|
+
if (state.status === "idle") return "";
|
|
46
|
+
|
|
47
|
+
if (state.status === "running") {
|
|
48
|
+
return env.theme.fg("accent", "⏱") + env.theme.fg("dim", ` ${formatDuration(Date.now() - state.startedAt)}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return env.theme.fg("success", "✓") + env.theme.fg("dim", ` ${formatDuration(state.elapsedMs)}`);
|
|
52
|
+
},
|
|
53
|
+
dispose() {
|
|
54
|
+
clearInterval(interval);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
pi.on("session_start", async () => {
|
|
61
|
+
state = { status: "idle" };
|
|
62
|
+
pi.events.emit(FOOTER_MANAGER_REGISTER_FRAGMENT, registration);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
pi.on("agent_start", async () => {
|
|
66
|
+
state = { status: "running", startedAt: Date.now() };
|
|
67
|
+
invalidate();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
pi.on("agent_end", async () => {
|
|
71
|
+
if (state.status === "running") {
|
|
72
|
+
state = { status: "done", elapsedMs: Date.now() - state.startedAt };
|
|
73
|
+
invalidate();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
pi.on("session_shutdown", async () => {
|
|
78
|
+
state = { status: "idle" };
|
|
79
|
+
pi.events.emit(FOOTER_MANAGER_UNREGISTER_FRAGMENT, { id: FRAGMENT_ID });
|
|
80
|
+
});
|
|
81
|
+
}
|