@neilurk12/pi-clean-footer 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/src/index.ts ADDED
@@ -0,0 +1,595 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+ import { execFile } from "node:child_process";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { promisify } from "node:util";
12
+
13
+ const execFileAsync = promisify(execFile);
14
+ const DEFAULT_GIT_REFRESH_DEBOUNCE_MS = 500;
15
+
16
+ type Theme = ExtensionContext["ui"]["theme"];
17
+
18
+ type CleanFooterConfig = {
19
+ enabled?: boolean;
20
+ showGit?: boolean;
21
+ showTokens?: boolean;
22
+ showCache?: boolean;
23
+ showContext?: boolean;
24
+ showDirectory?: boolean;
25
+ showEffort?: boolean;
26
+ gitRefreshDebounceMs?: number;
27
+ contextWarningPercent?: number;
28
+ contextDangerPercent?: number;
29
+ modelAliases?: Record<string, string>;
30
+ colors?: Partial<ColorConfig>;
31
+ };
32
+
33
+ type ResolvedConfig = Required<
34
+ Omit<CleanFooterConfig, "modelAliases" | "colors">
35
+ > & {
36
+ modelAliases: Record<string, string>;
37
+ colors: ColorConfig;
38
+ };
39
+
40
+ type ColorConfig = {
41
+ model: string;
42
+ directory: string;
43
+ git: string;
44
+ gitDirty: string;
45
+ contextNormal: string;
46
+ contextWarning: string;
47
+ contextDanger: string;
48
+ tokens: string;
49
+ separator: string;
50
+ };
51
+
52
+ type GitState = {
53
+ inRepo: boolean;
54
+ branch?: string;
55
+ dirtyCount: number;
56
+ };
57
+
58
+ type Totals = {
59
+ input: number;
60
+ output: number;
61
+ cacheRead: number;
62
+ cacheWrite: number;
63
+ };
64
+
65
+ type FooterRuntime = {
66
+ enabled: boolean;
67
+ git: GitState;
68
+ thinkingLevel?: string;
69
+ refreshTimer?: ReturnType<typeof setTimeout>;
70
+ requestRender?: () => void;
71
+ config: ResolvedConfig;
72
+ configPaths: { global: string; project: string };
73
+ loadedConfigPaths: string[];
74
+ configError?: string;
75
+ };
76
+
77
+ const defaultConfig: ResolvedConfig = {
78
+ enabled: true,
79
+ showGit: true,
80
+ showTokens: true,
81
+ showCache: true,
82
+ showContext: true,
83
+ showDirectory: true,
84
+ showEffort: true,
85
+ gitRefreshDebounceMs: DEFAULT_GIT_REFRESH_DEBOUNCE_MS,
86
+ contextWarningPercent: 70,
87
+ contextDangerPercent: 85,
88
+ modelAliases: {},
89
+ colors: {
90
+ model: "accent",
91
+ directory: "dim",
92
+ git: "success",
93
+ gitDirty: "warning",
94
+ contextNormal: "success",
95
+ contextWarning: "warning",
96
+ contextDanger: "error",
97
+ tokens: "muted",
98
+ separator: "dim",
99
+ },
100
+ };
101
+
102
+ const runtime: FooterRuntime = {
103
+ enabled: true,
104
+ git: { inRepo: false, dirtyCount: 0 },
105
+ config: defaultConfig,
106
+ configPaths: {
107
+ global: path.join(os.homedir(), ".pi", "agent", "clean-footer.json"),
108
+ project: path.join(process.cwd(), ".pi", "clean-footer.json"),
109
+ },
110
+ loadedConfigPaths: [],
111
+ };
112
+
113
+ export default function (pi: ExtensionAPI) {
114
+ pi.registerCommand("footer", {
115
+ description: "Toggle, refresh, or configure the clean footer",
116
+ handler: async (args, ctx) => {
117
+ const command = args.trim();
118
+
119
+ if (command === "refresh") {
120
+ await refreshGit(ctx, true);
121
+ runtime.requestRender?.();
122
+ if (ctx.hasUI) ctx.ui.notify("Footer refreshed", "info");
123
+ return;
124
+ }
125
+
126
+ if (command === "reload") {
127
+ loadConfig(ctx.cwd);
128
+ runtime.enabled = runtime.config.enabled;
129
+ if (ctx.hasUI && runtime.enabled) installFooter(ctx);
130
+ if (ctx.hasUI && !runtime.enabled) ctx.ui.setFooter(undefined);
131
+ runtime.requestRender?.();
132
+ notifyConfigStatus(ctx);
133
+ return;
134
+ }
135
+
136
+ if (command === "config") {
137
+ showConfig(ctx);
138
+ return;
139
+ }
140
+
141
+ runtime.enabled = !runtime.enabled;
142
+ if (!ctx.hasUI) return;
143
+
144
+ if (runtime.enabled) {
145
+ runtime.thinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel?.());
146
+ installFooter(ctx);
147
+ await refreshGit(ctx, true);
148
+ ctx.ui.notify("Clean footer enabled", "info");
149
+ } else {
150
+ clearScheduledRefresh();
151
+ ctx.ui.setFooter(undefined);
152
+ ctx.ui.notify("Default footer restored", "info");
153
+ }
154
+ },
155
+ });
156
+
157
+ pi.on("session_start", async (_event, ctx) => {
158
+ loadConfig(ctx.cwd);
159
+ runtime.enabled = runtime.config.enabled;
160
+ runtime.thinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel?.());
161
+ if (!ctx.hasUI || !runtime.enabled) return;
162
+ installFooter(ctx);
163
+ await refreshGit(ctx, true);
164
+ if (runtime.configError) notifyConfigStatus(ctx);
165
+ });
166
+
167
+ pi.on("session_shutdown", async (_event, ctx) => {
168
+ clearScheduledRefresh();
169
+ runtime.requestRender = undefined;
170
+ if (ctx.hasUI) ctx.ui.setFooter(undefined);
171
+ });
172
+
173
+ pi.on("thinking_level_select", (event) => {
174
+ runtime.thinkingLevel = normalizeThinkingLevel(event.level);
175
+ runtime.requestRender?.();
176
+ });
177
+
178
+ pi.on("model_select", () => {
179
+ runtime.requestRender?.();
180
+ });
181
+
182
+ pi.on("message_end", (event) => {
183
+ if (event.message.role === "assistant") runtime.requestRender?.();
184
+ });
185
+
186
+ pi.on("tool_execution_end", (event, ctx) => {
187
+ if (["bash", "edit", "write"].includes(event.toolName))
188
+ scheduleGitRefresh(ctx);
189
+ runtime.requestRender?.();
190
+ });
191
+
192
+ pi.on("user_bash", (_event, ctx) => {
193
+ scheduleGitRefresh(ctx);
194
+ });
195
+ }
196
+
197
+ function installFooter(ctx: ExtensionContext) {
198
+ if (!ctx.hasUI) return;
199
+
200
+ ctx.ui.setFooter((tui, theme) => {
201
+ runtime.requestRender = () => tui.requestRender();
202
+
203
+ return {
204
+ invalidate() {},
205
+ render(width: number): string[] {
206
+ const cfg = runtime.config;
207
+ const modelSegment = formatModelSegment(ctx, theme);
208
+ const dirSegment = cfg.showDirectory
209
+ ? color(theme, cfg.colors.directory, path.basename(ctx.cwd))
210
+ : undefined;
211
+ const gitSegment = cfg.showGit ? formatGitSegment(theme) : undefined;
212
+ const ctxSegment = cfg.showContext
213
+ ? formatContextSegment(ctx, theme)
214
+ : undefined;
215
+ const totals = getTotals(ctx);
216
+ const separator = color(theme, cfg.colors.separator, " | ");
217
+
218
+ const leftFull = [modelSegment, dirSegment, gitSegment]
219
+ .filter(Boolean)
220
+ .join(separator);
221
+ const leftMin = modelSegment;
222
+
223
+ if (width >= 100) {
224
+ return [
225
+ joinLeftRight(
226
+ leftFull,
227
+ joinRightSegments(
228
+ theme,
229
+ ctxSegment,
230
+ tokenSegment(theme, totals, "full"),
231
+ ),
232
+ width,
233
+ ),
234
+ ];
235
+ }
236
+
237
+ if (width >= 80) {
238
+ return [
239
+ joinLeftRight(
240
+ leftFull,
241
+ joinRightSegments(
242
+ theme,
243
+ ctxSegment,
244
+ tokenSegment(theme, totals, "no-cache"),
245
+ ),
246
+ width,
247
+ ),
248
+ ];
249
+ }
250
+
251
+ if (width >= 60) {
252
+ return [
253
+ joinLeftRight(
254
+ leftFull,
255
+ joinRightSegments(
256
+ theme,
257
+ ctxSegment,
258
+ tokenSegment(theme, totals, "total-only"),
259
+ ),
260
+ width,
261
+ ),
262
+ ];
263
+ }
264
+
265
+ if (width >= 40)
266
+ return [joinLeftRight(leftFull, ctxSegment ?? "", width)];
267
+ return [joinLeftRight(leftMin, ctxSegment ?? "", width)];
268
+ },
269
+ };
270
+ });
271
+ }
272
+
273
+ function loadConfig(cwd: string) {
274
+ const configPaths = {
275
+ global: path.join(os.homedir(), ".pi", "agent", "clean-footer.json"),
276
+ project: path.join(cwd, ".pi", "clean-footer.json"),
277
+ };
278
+
279
+ const loaded: string[] = [];
280
+ let merged: CleanFooterConfig = {};
281
+ let error: string | undefined;
282
+
283
+ for (const configPath of [configPaths.global, configPaths.project]) {
284
+ if (!existsSync(configPath)) continue;
285
+ try {
286
+ const parsed = JSON.parse(
287
+ readFileSync(configPath, "utf8"),
288
+ ) as CleanFooterConfig;
289
+ merged = mergeConfig(merged, parsed);
290
+ loaded.push(configPath);
291
+ } catch (err) {
292
+ error = `${configPath}: ${err instanceof Error ? err.message : String(err)}`;
293
+ }
294
+ }
295
+
296
+ runtime.configPaths = configPaths;
297
+ runtime.loadedConfigPaths = loaded;
298
+ runtime.configError = error;
299
+ runtime.config = resolveConfig(merged);
300
+ }
301
+
302
+ function mergeConfig(
303
+ base: CleanFooterConfig,
304
+ override: CleanFooterConfig,
305
+ ): CleanFooterConfig {
306
+ return {
307
+ ...base,
308
+ ...override,
309
+ modelAliases: {
310
+ ...(base.modelAliases ?? {}),
311
+ ...(override.modelAliases ?? {}),
312
+ },
313
+ colors: {
314
+ ...(base.colors ?? {}),
315
+ ...(override.colors ?? {}),
316
+ },
317
+ };
318
+ }
319
+
320
+ function resolveConfig(config: CleanFooterConfig): ResolvedConfig {
321
+ return {
322
+ ...defaultConfig,
323
+ ...config,
324
+ gitRefreshDebounceMs: positiveNumber(
325
+ config.gitRefreshDebounceMs,
326
+ defaultConfig.gitRefreshDebounceMs,
327
+ ),
328
+ contextWarningPercent: percentNumber(
329
+ config.contextWarningPercent,
330
+ defaultConfig.contextWarningPercent,
331
+ ),
332
+ contextDangerPercent: percentNumber(
333
+ config.contextDangerPercent,
334
+ defaultConfig.contextDangerPercent,
335
+ ),
336
+ modelAliases: {
337
+ ...defaultConfig.modelAliases,
338
+ ...(config.modelAliases ?? {}),
339
+ },
340
+ colors: { ...defaultConfig.colors, ...(config.colors ?? {}) },
341
+ };
342
+ }
343
+
344
+ function positiveNumber(value: unknown, fallback: number): number {
345
+ return typeof value === "number" && Number.isFinite(value) && value > 0
346
+ ? value
347
+ : fallback;
348
+ }
349
+
350
+ function percentNumber(value: unknown, fallback: number): number {
351
+ return typeof value === "number" &&
352
+ Number.isFinite(value) &&
353
+ value >= 0 &&
354
+ value <= 100
355
+ ? value
356
+ : fallback;
357
+ }
358
+
359
+ function notifyConfigStatus(ctx: ExtensionContext) {
360
+ if (!ctx.hasUI) return;
361
+ if (runtime.configError) {
362
+ ctx.ui.notify(`Clean footer config error: ${runtime.configError}`, "error");
363
+ return;
364
+ }
365
+ ctx.ui.notify("Clean footer config loaded", "info");
366
+ }
367
+
368
+ function showConfig(ctx: ExtensionContext) {
369
+ if (!ctx.hasUI) return;
370
+ const loaded = runtime.loadedConfigPaths.length
371
+ ? runtime.loadedConfigPaths.join("\n")
372
+ : "none";
373
+ ctx.ui.notify(
374
+ [
375
+ "Clean footer config",
376
+ `global: ${runtime.configPaths.global}`,
377
+ `project: ${runtime.configPaths.project}`,
378
+ `loaded:\n${loaded}`,
379
+ runtime.configError ? `error: ${runtime.configError}` : "error: none",
380
+ `resolved: ${JSON.stringify(runtime.config)}`,
381
+ ].join("\n"),
382
+ "info",
383
+ );
384
+ }
385
+
386
+ function formatModelSegment(ctx: ExtensionContext, theme: Theme): string {
387
+ const modelId = ctx.model?.id ?? "no-model";
388
+ const model = formatModelName(modelId);
389
+ const effort =
390
+ runtime.config.showEffort && runtime.thinkingLevel
391
+ ? ` • ${runtime.thinkingLevel}`
392
+ : "";
393
+ return color(theme, runtime.config.colors.model, `${model}${effort}`);
394
+ }
395
+
396
+ function formatModelName(modelId: string): string {
397
+ const aliases = runtime.config.modelAliases;
398
+ if (aliases[modelId]) return aliases[modelId];
399
+
400
+ const lower = modelId.toLowerCase();
401
+ const withoutProvider = lower.includes("/") ? lower.split("/").pop()! : lower;
402
+ if (aliases[withoutProvider]) return aliases[withoutProvider];
403
+
404
+ if (
405
+ withoutProvider.includes("claude") &&
406
+ withoutProvider.includes("sonnet")
407
+ ) {
408
+ if (withoutProvider.includes("4-5") || withoutProvider.includes("4.5"))
409
+ return "sonnet-4.5";
410
+ if (withoutProvider.includes("4")) return "sonnet-4";
411
+ return "sonnet";
412
+ }
413
+
414
+ if (withoutProvider.includes("claude") && withoutProvider.includes("opus"))
415
+ return "opus";
416
+ if (withoutProvider.includes("claude") && withoutProvider.includes("haiku"))
417
+ return "haiku";
418
+
419
+ const gpt5 = withoutProvider.match(/gpt-5(?:[.-][a-z0-9]+)*/);
420
+ if (gpt5) return gpt5[0];
421
+
422
+ const gpt4 = withoutProvider.match(/gpt-4(?:[.-][a-z0-9]+)*/);
423
+ if (gpt4) return gpt4[0];
424
+
425
+ const gemini = withoutProvider.match(/gemini-[a-z0-9.-]+/);
426
+ if (gemini) return gemini[0].replace(/-preview.*/, "");
427
+
428
+ return withoutProvider.length > 24
429
+ ? `${withoutProvider.slice(0, 21)}…`
430
+ : withoutProvider;
431
+ }
432
+
433
+ function normalizeThinkingLevel(level: unknown): string | undefined {
434
+ if (typeof level !== "string") return undefined;
435
+
436
+ const normalized = level.toLowerCase();
437
+ if (normalized === "medium") return "med";
438
+ if (
439
+ normalized === "extra-high" ||
440
+ normalized === "extra_high" ||
441
+ normalized === "x-high"
442
+ )
443
+ return "xhigh";
444
+ if (["low", "med", "high", "xhigh"].includes(normalized)) return normalized;
445
+
446
+ return undefined;
447
+ }
448
+
449
+ function getTotals(ctx: ExtensionContext): Totals {
450
+ const totals: Totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
451
+
452
+ for (const entry of ctx.sessionManager.getBranch()) {
453
+ if (entry.type !== "message" || entry.message.role !== "assistant")
454
+ continue;
455
+
456
+ const message = entry.message as AssistantMessage;
457
+ totals.input += message.usage?.input ?? 0;
458
+ totals.output += message.usage?.output ?? 0;
459
+ totals.cacheRead += message.usage?.cacheRead ?? 0;
460
+ totals.cacheWrite += message.usage?.cacheWrite ?? 0;
461
+ }
462
+
463
+ return totals;
464
+ }
465
+
466
+ function tokenSegment(
467
+ theme: Theme,
468
+ totals: Totals,
469
+ mode: "full" | "no-cache" | "total-only",
470
+ ): string | undefined {
471
+ if (!runtime.config.showTokens) return undefined;
472
+ const effectiveMode = runtime.config.showCache
473
+ ? mode
474
+ : mode === "full"
475
+ ? "no-cache"
476
+ : mode;
477
+ return color(
478
+ theme,
479
+ runtime.config.colors.tokens,
480
+ formatTokenSegment(totals, effectiveMode),
481
+ );
482
+ }
483
+
484
+ function formatTokenSegment(
485
+ totals: Totals,
486
+ mode: "full" | "no-cache" | "total-only",
487
+ ): string {
488
+ const total = totals.input + totals.output;
489
+ if (mode === "total-only") return `Σ${formatCount(total)}`;
490
+
491
+ const base = `↑${formatCount(totals.input)} ↓${formatCount(totals.output)} Σ${formatCount(total)}`;
492
+ if (mode === "no-cache") return base;
493
+
494
+ return `${base} ↯${formatCount(totals.cacheRead)} ↥${formatCount(totals.cacheWrite)}`;
495
+ }
496
+
497
+ function formatContextSegment(ctx: ExtensionContext, theme: Theme): string {
498
+ const usage = ctx.getContextUsage?.();
499
+ const used = usage?.tokens ?? 0;
500
+ const max = ctx.model?.contextWindow;
501
+ const text = `ctx ${formatCount(used)}/${max ? formatCount(max) : "--"}`;
502
+
503
+ if (!max || max <= 0) return color(theme, "dim", text);
504
+
505
+ const percent = (used / max) * 100;
506
+ if (percent >= runtime.config.contextDangerPercent)
507
+ return color(theme, runtime.config.colors.contextDanger, text);
508
+ if (percent >= runtime.config.contextWarningPercent)
509
+ return color(theme, runtime.config.colors.contextWarning, text);
510
+ return color(theme, runtime.config.colors.contextNormal, text);
511
+ }
512
+
513
+ function formatGitSegment(theme: Theme): string | undefined {
514
+ if (!runtime.git.inRepo || !runtime.git.branch) return undefined;
515
+
516
+ const branch = color(theme, runtime.config.colors.git, runtime.git.branch);
517
+ if (runtime.git.dirtyCount <= 0) return branch;
518
+
519
+ return `${branch} ${color(theme, runtime.config.colors.gitDirty, `●${runtime.git.dirtyCount}`)}`;
520
+ }
521
+
522
+ async function refreshGit(ctx: ExtensionContext, immediate = false) {
523
+ if (!runtime.config.showGit) {
524
+ runtime.git = { inRepo: false, dirtyCount: 0 };
525
+ return;
526
+ }
527
+
528
+ try {
529
+ const [branchResult, statusResult] = await Promise.all([
530
+ execFileAsync("git", ["branch", "--show-current"], {
531
+ cwd: ctx.cwd,
532
+ timeout: 2_000,
533
+ }),
534
+ execFileAsync("git", ["status", "--porcelain"], {
535
+ cwd: ctx.cwd,
536
+ timeout: 2_000,
537
+ }),
538
+ ]);
539
+
540
+ const branch = branchResult.stdout.trim() || "detached";
541
+ const dirtyCount = statusResult.stdout.split("\n").filter(Boolean).length;
542
+ runtime.git = { inRepo: true, branch, dirtyCount };
543
+ } catch {
544
+ runtime.git = { inRepo: false, dirtyCount: 0 };
545
+ }
546
+
547
+ if (immediate) runtime.requestRender?.();
548
+ }
549
+
550
+ function scheduleGitRefresh(ctx: ExtensionContext) {
551
+ clearScheduledRefresh();
552
+ runtime.refreshTimer = setTimeout(() => {
553
+ runtime.refreshTimer = undefined;
554
+ void refreshGit(ctx, true);
555
+ }, runtime.config.gitRefreshDebounceMs);
556
+ }
557
+
558
+ function clearScheduledRefresh() {
559
+ if (runtime.refreshTimer) clearTimeout(runtime.refreshTimer);
560
+ runtime.refreshTimer = undefined;
561
+ }
562
+
563
+ function joinRightSegments(
564
+ theme: Theme,
565
+ ...segments: Array<string | undefined>
566
+ ): string {
567
+ return segments
568
+ .filter(Boolean)
569
+ .join(color(theme, runtime.config.colors.separator, " | "));
570
+ }
571
+
572
+ function joinLeftRight(left: string, right: string, width: number): string {
573
+ if (!right) return truncateToWidth(left, width);
574
+ if (!left) return truncateToWidth(right, width);
575
+
576
+ const gap = width - visibleWidth(left) - visibleWidth(right);
577
+ if (gap >= 1) return truncateToWidth(left + " ".repeat(gap) + right, width);
578
+
579
+ const half = Math.max(1, Math.floor((width - 1) / 2));
580
+ return (
581
+ truncateToWidth(left, half) + " " + truncateToWidth(right, width - half - 1)
582
+ );
583
+ }
584
+
585
+ function color(theme: Theme, colorName: string, text: string): string {
586
+ return theme.fg(colorName as never, text);
587
+ }
588
+
589
+ function formatCount(value: number): string {
590
+ if (!Number.isFinite(value) || value <= 0) return "0";
591
+ if (value < 1_000) return `${Math.round(value)}`;
592
+ if (value < 1_000_000)
593
+ return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)}k`;
594
+ return `${(value / 1_000_000).toFixed(1)}m`;
595
+ }
package/tok ADDED
@@ -0,0 +1 @@
1
+ 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