@aemonge-dev/pi-stardeck 0.0.0-stage → 0.5.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -2
  3. package/demo/pi-stardeck.tape +59 -0
  4. package/docs/architecture.md +103 -0
  5. package/docs/assets/pi-stardeck.gif +0 -0
  6. package/docs/development.md +128 -0
  7. package/licenses/README.md +17 -0
  8. package/licenses/pi-fancy-footer-LICENSE +21 -0
  9. package/licenses/pi-vim-LICENSE +21 -0
  10. package/package.json +81 -4
  11. package/packages/pi-fancy-footer-full-palette/LICENSE +21 -0
  12. package/packages/pi-fancy-footer-full-palette/LOCAL-CHANGES.md +41 -0
  13. package/packages/pi-fancy-footer-full-palette/README.md +468 -0
  14. package/packages/pi-fancy-footer-full-palette/package.json +52 -0
  15. package/packages/pi-fancy-footer-full-palette/src/api.ts +204 -0
  16. package/packages/pi-fancy-footer-full-palette/src/config-editor.ts +634 -0
  17. package/packages/pi-fancy-footer-full-palette/src/config.ts +852 -0
  18. package/packages/pi-fancy-footer-full-palette/src/data-widgets.ts +259 -0
  19. package/packages/pi-fancy-footer-full-palette/src/git.ts +130 -0
  20. package/packages/pi-fancy-footer-full-palette/src/index.ts +479 -0
  21. package/packages/pi-fancy-footer-full-palette/src/layout.ts +309 -0
  22. package/packages/pi-fancy-footer-full-palette/src/provider-status.ts +1466 -0
  23. package/packages/pi-fancy-footer-full-palette/src/render.ts +1061 -0
  24. package/packages/pi-fancy-footer-full-palette/src/shared.ts +903 -0
  25. package/packages/pi-stardeck/README.md +219 -0
  26. package/packages/pi-stardeck/index.ts +691 -0
  27. package/packages/pi-stardeck/package.json +32 -0
  28. package/packages/pi-stardeck/src/activity-age.ts +184 -0
  29. package/packages/pi-stardeck/src/capabilities.ts +105 -0
  30. package/packages/pi-stardeck/src/deck.ts +807 -0
  31. package/packages/pi-stardeck/src/gauge.ts +1323 -0
  32. package/packages/pi-stardeck/src/git-refresh.ts +5 -0
  33. package/packages/pi-stardeck/src/process-resources.ts +428 -0
  34. package/packages/pi-stardeck/src/title-state.ts +195 -0
  35. package/packages/pi-stardeck/src/title.ts +443 -0
  36. package/packages/pi-vim-top-border/LICENSE +21 -0
  37. package/packages/pi-vim-top-border/LOCAL-CHANGES.md +71 -0
  38. package/packages/pi-vim-top-border/README.md +577 -0
  39. package/packages/pi-vim-top-border/clipboard-mirror.ts +451 -0
  40. package/packages/pi-vim-top-border/clipboard-policy.ts +23 -0
  41. package/packages/pi-vim-top-border/cursor-shape.ts +131 -0
  42. package/packages/pi-vim-top-border/index.ts +4195 -0
  43. package/packages/pi-vim-top-border/input-keys.ts +49 -0
  44. package/packages/pi-vim-top-border/mode-change-command.ts +120 -0
  45. package/packages/pi-vim-top-border/mode-colors.ts +97 -0
  46. package/packages/pi-vim-top-border/mode-label.ts +62 -0
  47. package/packages/pi-vim-top-border/motions.ts +297 -0
  48. package/packages/pi-vim-top-border/package.json +47 -0
  49. package/packages/pi-vim-top-border/prompt-external-editor.ts +152 -0
  50. package/packages/pi-vim-top-border/prompt-rail.ts +334 -0
  51. package/packages/pi-vim-top-border/prompt-status.ts +131 -0
  52. package/packages/pi-vim-top-border/settings.ts +287 -0
  53. package/packages/pi-vim-top-border/text-objects.ts +389 -0
  54. package/packages/pi-vim-top-border/types.ts +51 -0
  55. package/packages/pi-vim-top-border/visual.ts +65 -0
  56. package/packages/pi-vim-top-border/word-boundary-cache.ts +196 -0
  57. package/packages/status/README.md +398 -0
  58. package/packages/status/index.ts +1717 -0
  59. package/packages/status/package.json +33 -0
  60. package/packages/status/src/active-work.ts +235 -0
  61. package/packages/status/src/config.ts +218 -0
  62. package/packages/status/src/diagnostics.ts +238 -0
  63. package/packages/status/src/goal-block.ts +377 -0
  64. package/packages/status/src/goal.ts +170 -0
  65. package/packages/status/src/ledger.ts +101 -0
  66. package/packages/status/src/openspec.ts +394 -0
  67. package/packages/status/src/orchestration.ts +237 -0
  68. package/packages/status/src/publisher.ts +1294 -0
  69. package/packages/status/src/reducer.ts +190 -0
  70. package/packages/status/src/runtime-runs.ts +267 -0
  71. package/packages/status/src/state.ts +178 -0
  72. package/packages/status/src/title-activity.ts +224 -0
  73. package/packages/status/src/title-state.ts +218 -0
  74. package/packages/status/src/turn-cost.ts +63 -0
  75. package/packages/status/src/types.ts +228 -0
  76. package/src/deck-surface.ts +46 -0
  77. package/src/health.ts +228 -0
  78. package/src/index.ts +28 -0
  79. package/src/pi-status.ts +334 -0
@@ -0,0 +1,903 @@
1
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
2
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
+
4
+ export type ThinkingLevel = ModelThinkingLevel;
5
+
6
+ export const FOOTER_ICON_FAMILIES = ["nerd", "emoji", "unicode", "ascii"] as const;
7
+
8
+ export type FooterIconFamily = (typeof FOOTER_ICON_FAMILIES)[number];
9
+
10
+ export const STATUSLINE_SYMBOLS = {
11
+ nerd: {
12
+ thinking: "󰧑",
13
+ model: "󰚩",
14
+ provider: "󰅟",
15
+ path: "",
16
+ branch: "",
17
+ commit: "",
18
+ providerStatus: "󰓅",
19
+ contextBarMarker: "󰾆",
20
+ contextCapacityMarker: "",
21
+ gitAhead: "",
22
+ gitBehind: "",
23
+ gitDiverged: "",
24
+ diffAdded: "↗",
25
+ diffRemoved: "↘",
26
+ currency: "󰜦",
27
+ cacheRead: "󰇚",
28
+ cacheWrite: "󰕒",
29
+ cacheHitRate: "󰍛",
30
+ },
31
+ emoji: {
32
+ thinking: "🧠",
33
+ model: "🤖",
34
+ provider: "☁️",
35
+ path: "📁",
36
+ branch: "🌿",
37
+ commit: "🔖",
38
+ providerStatus: "📊",
39
+ contextBarMarker: "🔋",
40
+ contextCapacityMarker: "💾",
41
+ gitAhead: "🔼",
42
+ gitBehind: "🔽",
43
+ gitDiverged: "🔀",
44
+ diffAdded: "➕",
45
+ diffRemoved: "➖",
46
+ currency: "💲",
47
+ cacheRead: "📥",
48
+ cacheWrite: "📤",
49
+ cacheHitRate: "🎯",
50
+ },
51
+ unicode: {
52
+ thinking: "✦",
53
+ model: "◉",
54
+ provider: "☁",
55
+ path: "⌂",
56
+ branch: "⎇",
57
+ commit: "#",
58
+ providerStatus: "%",
59
+ contextBarMarker: "◧",
60
+ contextCapacityMarker: "□",
61
+ gitAhead: "↑",
62
+ gitBehind: "↓",
63
+ gitDiverged: "↕",
64
+ diffAdded: "+",
65
+ diffRemoved: "−",
66
+ currency: "$",
67
+ cacheRead: "↧",
68
+ cacheWrite: "↥",
69
+ cacheHitRate: "◎",
70
+ },
71
+ ascii: {
72
+ thinking: "?",
73
+ model: "%",
74
+ provider: "&",
75
+ path: "/",
76
+ branch: "*",
77
+ commit: "#",
78
+ providerStatus: "%",
79
+ contextBarMarker: "|",
80
+ contextCapacityMarker: "[]",
81
+ gitAhead: "^",
82
+ gitBehind: "_",
83
+ gitDiverged: "<>",
84
+ diffAdded: "+",
85
+ diffRemoved: "-",
86
+ currency: "$",
87
+ cacheRead: "R",
88
+ cacheWrite: "W",
89
+ cacheHitRate: "H",
90
+ },
91
+ } as const;
92
+
93
+ export type StatuslineSymbols = (typeof STATUSLINE_SYMBOLS)[FooterIconFamily];
94
+
95
+ // ── Gauge styles ───────────────────────────────────────────────────────
96
+
97
+ export interface GaugeStyleDef {
98
+ readonly label: string;
99
+ readonly filled: string;
100
+ readonly empty: string;
101
+ }
102
+
103
+ export const GAUGE_STYLES = [
104
+ { label: "blocks", filled: "■", empty: "□" },
105
+ { label: "lines", filled: "━", empty: "─" },
106
+ { label: "circles", filled: "●", empty: "○" },
107
+ { label: "parallelograms", filled: "▰", empty: "▱" },
108
+ { label: "diamonds", filled: "◆", empty: "◇" },
109
+ { label: "bars", filled: "█", empty: "░" },
110
+ { label: "stars", filled: "★", empty: "☆" },
111
+ { label: "specks", filled: "•", empty: "◦" },
112
+ ] as const satisfies readonly GaugeStyleDef[];
113
+
114
+ export type GaugeStyleId = (typeof GAUGE_STYLES)[number]["label"];
115
+
116
+ export const GAUGE_STYLE_IDS = GAUGE_STYLES.map(
117
+ (s) => s.label,
118
+ ) as readonly GaugeStyleId[];
119
+
120
+ export const DEFAULT_GAUGE_STYLE: GaugeStyleId = "blocks";
121
+
122
+ export function isGaugeStyleId(value: unknown): value is GaugeStyleId {
123
+ return (
124
+ typeof value === "string" && (GAUGE_STYLE_IDS as readonly string[]).includes(value)
125
+ );
126
+ }
127
+
128
+ export function getGaugeStyle(id: GaugeStyleId): GaugeStyleDef {
129
+ return GAUGE_STYLES.find((s) => s.label === id) ?? GAUGE_STYLES[0];
130
+ }
131
+
132
+ export type GaugeSeverity = "success" | "warning" | "error";
133
+
134
+ export interface GaugeSegment {
135
+ filledGlyphs: string;
136
+ emptyGlyphs: string;
137
+ percentText: string;
138
+ color: GaugeSeverity;
139
+ }
140
+
141
+ export function gaugeSeverity(leftPercent: number): GaugeSeverity {
142
+ if (leftPercent < 25) return "error";
143
+ if (leftPercent < 60) return "warning";
144
+ return "success";
145
+ }
146
+
147
+ // Mini gauge over a resource with `usedPercent` consumed. Filled cells and
148
+ // the percent label always grow from the left with consumption, while the
149
+ // color reflects how close the resource is to exhaustion. The gauge never
150
+ // reads completely full or empty unless it truly is.
151
+ export function buildGauge(
152
+ usedPercent: number,
153
+ style: GaugeStyleDef,
154
+ cells: number,
155
+ ): GaugeSegment {
156
+ const used = Math.max(0, Math.min(100, usedPercent));
157
+ const n = Math.max(1, Math.floor(cells));
158
+ let filledCells = Math.round((used / 100) * n);
159
+ if (used > 0 && filledCells === 0) filledCells = 1;
160
+ if (used < 100 && filledCells === n) filledCells = n - 1;
161
+ return {
162
+ filledGlyphs: style.filled.repeat(filledCells),
163
+ emptyGlyphs: style.empty.repeat(n - filledCells),
164
+ percentText: formatGaugePercent(used),
165
+ color: gaugeSeverity(100 - used),
166
+ };
167
+ }
168
+
169
+ export function displayedGaugePercent(value: number): number {
170
+ return Math.round(value * 10) / 10;
171
+ }
172
+
173
+ export function formatGaugePercent(value: number): string {
174
+ const displayed = displayedGaugePercent(value);
175
+ return Number.isInteger(displayed) ? `${displayed}%` : `${displayed.toFixed(1)}%`;
176
+ }
177
+
178
+ // Compact token counts with SI-style units: 246, 1.2k, 246k, 1M, 1.2M, 12M.
179
+ // Unlike pi's own footer, round values drop the trailing ".0".
180
+ export function formatTokens(count: number): string {
181
+ const compact = (value: number, unit: string): string =>
182
+ `${value.toFixed(1).replace(/\.0$/, "")}${unit}`;
183
+ if (count < 1000) return `${count}`;
184
+ if (count < 10_000) return compact(count / 1000, "k");
185
+ if (count < 999_500) return `${Math.round(count / 1000)}k`;
186
+ if (count < 10_000_000) return compact(count / 1_000_000, "M");
187
+ return `${Math.round(count / 1_000_000)}M`;
188
+ }
189
+
190
+ export const GIT_REFRESH_MS = 5000;
191
+ export const MIN_FOOTER_REFRESH_MS = 250;
192
+ export const MAX_FOOTER_REFRESH_MS = 60_000;
193
+ export const MAX_WIDGET_ROW = 12;
194
+ export const MAX_WIDGET_POSITION = 64;
195
+ export const MAX_WIDGET_MIN_WIDTH = 120;
196
+ export const FOOTER_CONFIG_FILE = "fancy-footer.json";
197
+
198
+ export const MIN_PROVIDER_STATUS_REFRESH_MS = 10_000;
199
+ export const MAX_PROVIDER_STATUS_REFRESH_MS = 3_600_000;
200
+ export const MIN_PROVIDER_STATUS_CACHE_TTL_MS = 0;
201
+ export const MAX_PROVIDER_STATUS_CACHE_TTL_MS = 3_600_000;
202
+
203
+ export const MIN_GAUGE_WIDTH = 3;
204
+ export const MAX_GAUGE_WIDTH = 40;
205
+ export const DEFAULT_GAUGE_WIDTH = 5;
206
+
207
+ export interface GaugeColorsSnapshot {
208
+ ok: FooterWidgetColor;
209
+ warning: FooterWidgetColor;
210
+ error: FooterWidgetColor;
211
+ }
212
+
213
+ export const DEFAULT_GAUGE_COLORS: GaugeColorsSnapshot = {
214
+ ok: "accent",
215
+ warning: "warning",
216
+ error: "error",
217
+ };
218
+
219
+ export function gaugeColorFor(
220
+ severity: GaugeSeverity,
221
+ colors: GaugeColorsSnapshot,
222
+ ): FooterWidgetColor {
223
+ return severity === "success" ? colors.ok : colors[severity];
224
+ }
225
+
226
+ export interface UsageSnapshot {
227
+ input: number;
228
+ cacheRead: number;
229
+ cacheWrite: number;
230
+ cost: number;
231
+ }
232
+
233
+ export interface SessionUsageMetrics {
234
+ latest: UsageSnapshot | undefined;
235
+ totalCost: number;
236
+ totalCacheRead: number;
237
+ totalCacheWrite: number;
238
+ }
239
+
240
+ export interface GitCounts {
241
+ staged: number;
242
+ modified: number;
243
+ untracked: number;
244
+ ahead: number;
245
+ behind: number;
246
+ }
247
+
248
+ export interface ProviderStatusWindow {
249
+ label: string;
250
+ leftPercent: number;
251
+ usedPercent: number;
252
+ resetAt?: number;
253
+ /** The cached window rolled over, but its current usage is not yet known. */
254
+ usageUnknown?: true;
255
+ }
256
+
257
+ export type ProviderStatusState = "ok" | "warning" | "error" | "unavailable";
258
+
259
+ /**
260
+ * A quota window that only applies while a specific model is in use, such as
261
+ * Anthropic's weekly Fable cap. Scoped windows are collected for every model
262
+ * the provider reports and replace the same-labeled account-wide window once
263
+ * the footer knows which model is active.
264
+ */
265
+ export interface ProviderStatusScopedWindow extends ProviderStatusWindow {
266
+ /** Model name the limit is scoped to, as reported by the provider. */
267
+ model: string;
268
+ }
269
+
270
+ export interface ProviderStatusSnapshot {
271
+ provider: string;
272
+ source: "api" | "headers" | "cache";
273
+ fetchedAt: string;
274
+ state: ProviderStatusState;
275
+ primary?: ProviderStatusWindow;
276
+ secondary?: ProviderStatusWindow;
277
+ scoped?: ProviderStatusScopedWindow[];
278
+ credits?: string;
279
+ url?: string;
280
+ error?: string;
281
+ }
282
+
283
+ export interface GitInfo {
284
+ repository?: string;
285
+ branch: string;
286
+ commit: string;
287
+ added: number;
288
+ removed: number;
289
+ counts: GitCounts;
290
+ }
291
+
292
+ export const EMPTY_GIT_INFO: GitInfo = {
293
+ branch: "",
294
+ commit: "",
295
+ added: 0,
296
+ removed: 0,
297
+ counts: {
298
+ staged: 0,
299
+ modified: 0,
300
+ untracked: 0,
301
+ ahead: 0,
302
+ behind: 0,
303
+ },
304
+ };
305
+
306
+ export type FooterWidgetAlign = "left" | "middle" | "right";
307
+ export type FooterWidgetFill = "none" | "grow";
308
+
309
+ export const FOOTER_WIDGET_COLORS = [
310
+ "text",
311
+ "accent",
312
+ "muted",
313
+ "dim",
314
+ "searchMatchText",
315
+ "success",
316
+ "error",
317
+ "warning",
318
+ "border",
319
+ "borderAccent",
320
+ "borderMuted",
321
+ "userMessageText",
322
+ "customMessageText",
323
+ "customMessageLabel",
324
+ "toolTitle",
325
+ "toolOutput",
326
+ "toolDiffAdded",
327
+ "toolDiffRemoved",
328
+ "toolDiffContext",
329
+ "mdHeading",
330
+ "mdLink",
331
+ "mdLinkUrl",
332
+ "mdCode",
333
+ "mdCodeBlock",
334
+ "mdCodeBlockBorder",
335
+ "mdQuote",
336
+ "mdQuoteBorder",
337
+ "mdHr",
338
+ "mdListBullet",
339
+ "syntaxComment",
340
+ "syntaxKeyword",
341
+ "syntaxFunction",
342
+ "syntaxVariable",
343
+ "syntaxString",
344
+ "syntaxNumber",
345
+ "syntaxType",
346
+ "syntaxOperator",
347
+ "syntaxPunctuation",
348
+ "thinkingOff",
349
+ "thinkingMinimal",
350
+ "thinkingLow",
351
+ "thinkingMedium",
352
+ "thinkingHigh",
353
+ "thinkingXhigh",
354
+ "thinkingMax",
355
+ "bashMode",
356
+ ] as const;
357
+
358
+ export type FooterWidgetColor = (typeof FOOTER_WIDGET_COLORS)[number];
359
+
360
+ export const FOOTER_WIDGET_IDS = [
361
+ "provider",
362
+ "model",
363
+ "thinking",
364
+ "context-capacity",
365
+ "context-bar",
366
+ "total-cost",
367
+ "cache-read",
368
+ "cache-write",
369
+ "cache-hit-rate",
370
+ "location",
371
+ "branch",
372
+ "commit",
373
+ "provider-status",
374
+ "diff-added",
375
+ "diff-removed",
376
+ "git-status",
377
+ ] as const;
378
+
379
+ export type BuiltInFooterWidgetId = (typeof FOOTER_WIDGET_IDS)[number];
380
+ export type FooterWidgetId = string;
381
+
382
+ export const FOOTER_REFRESH_OPTIONS = [
383
+ 250, 500, 1000, 2000, 3000, 5000, 10000,
384
+ ] as const;
385
+ export const FOOTER_ROW_OPTIONS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const;
386
+ export const FOOTER_POSITION_OPTIONS = [
387
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
388
+ ] as const;
389
+ export const FOOTER_MIN_WIDTH_OPTIONS = [
390
+ 0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32,
391
+ ] as const;
392
+ export const GAUGE_WIDTH_OPTIONS = [3, 4, 5, 6, 8, 10, 12, 16, 20] as const;
393
+
394
+ export type FooterWidgetState = "default" | "enabled" | "disabled";
395
+ export type FooterWidgetIconMode = "default" | "hide";
396
+
397
+ export interface FooterWidgetLocation {
398
+ row: number;
399
+ position: number;
400
+ }
401
+
402
+ export interface FooterWidgetIcon {
403
+ text: string;
404
+ color: FooterWidgetColor;
405
+ }
406
+
407
+ export interface FooterMetrics {
408
+ provider: string;
409
+ model: string;
410
+ thinking: string;
411
+ totalTokens: number;
412
+ usedTokensForBar: number;
413
+ totalCost: number;
414
+ totalCacheRead: number;
415
+ totalCacheWrite: number;
416
+ cacheHitRatePercent: number | undefined;
417
+ locationText: string;
418
+ branch: string;
419
+ commit: string;
420
+ added: number;
421
+ removed: number;
422
+ gitStatusSymbol: string;
423
+ gitStatusText: string;
424
+ }
425
+
426
+ export interface WidgetRenderContext {
427
+ width: number;
428
+ nowMs: number;
429
+ theme: Theme;
430
+ ctx: ExtensionContext;
431
+ gaugeWidth: number;
432
+ gaugeColors: GaugeColorsSnapshot;
433
+ metrics: FooterMetrics;
434
+ providerStatuses: readonly ProviderStatusSnapshot[];
435
+ providerStatusConfig: Pick<
436
+ ProviderStatusConfigSnapshot,
437
+ "display" | "showCredits" | "showReset" | "resetMinUsedPercent"
438
+ >;
439
+ defaultIconColor: FooterWidgetColor;
440
+ defaultTextColor: FooterWidgetColor;
441
+ }
442
+
443
+ export type FooterWidgetSize = number | ((ctx: WidgetRenderContext) => number);
444
+
445
+ export interface FooterWidget {
446
+ id: FooterWidgetId;
447
+ location: FooterWidgetLocation;
448
+ align: FooterWidgetAlign;
449
+ fill?: FooterWidgetFill;
450
+ /** False for widgets that stay hidden unless a config override enables them. */
451
+ defaultEnabled?: boolean;
452
+ minWidth?: FooterWidgetSize;
453
+ icon?: FooterWidgetIcon;
454
+ /** Optional HTTP(S) destination for the complete rendered widget. */
455
+ href?: string;
456
+ preferredIconColor?: FooterWidgetColor;
457
+ textColor?: FooterWidgetColor;
458
+ preferredTextColor?: FooterWidgetColor;
459
+ styled?: boolean;
460
+ /** False when enabling the widget must preserve its conditional visibility. */
461
+ forceVisibleWhenEnabled?: boolean;
462
+ visible?: (ctx: WidgetRenderContext) => boolean;
463
+ renderText: (ctx: WidgetRenderContext, availableWidth?: number) => string;
464
+ }
465
+
466
+ export interface PreparedWidget {
467
+ widget: FooterWidget;
468
+ fill: FooterWidgetFill;
469
+ minWidth: number;
470
+ fixedText: string;
471
+ fixedWidth: number;
472
+ }
473
+
474
+ export interface PreparedWidgetGroup {
475
+ widgets: PreparedWidget[];
476
+ minWidth: number;
477
+ }
478
+
479
+ export interface FooterWidgetConfigOverride {
480
+ enabled?: boolean;
481
+ row?: number;
482
+ position?: number;
483
+ align?: FooterWidgetAlign;
484
+ fill?: FooterWidgetFill;
485
+ minWidth?: number;
486
+ icon?: FooterWidgetIconMode;
487
+ iconColor?: FooterWidgetColor;
488
+ textColor?: FooterWidgetColor;
489
+ }
490
+
491
+ export interface FooterConfigSnapshot {
492
+ refreshMs: number;
493
+ iconFamily: FooterIconFamily;
494
+ gaugeStyle: GaugeStyleId;
495
+ gaugeWidth: number;
496
+ gaugeColors: GaugeColorsSnapshot;
497
+ defaultTextColor: FooterWidgetColor;
498
+ defaultIconColor: FooterWidgetColor;
499
+ providerStatus: ProviderStatusConfigSnapshot;
500
+ widgets: Partial<Record<BuiltInFooterWidgetId, FooterWidgetConfigOverride>>;
501
+ extensionWidgets: Record<string, FooterWidgetConfigOverride>;
502
+ }
503
+
504
+ export const PROVIDER_STATUS_PROVIDER_IDS = [
505
+ "openai-codex",
506
+ "anthropic",
507
+ "zai",
508
+ ] as const;
509
+
510
+ export type ProviderStatusProviderId = (typeof PROVIDER_STATUS_PROVIDER_IDS)[number];
511
+
512
+ export const PROVIDER_STATUS_DISPLAYS = ["gauge", "text", "percent"] as const;
513
+
514
+ export type ProviderStatusDisplay = (typeof PROVIDER_STATUS_DISPLAYS)[number];
515
+
516
+ export const PROVIDER_STATUS_RESET_MODES = ["off", "primary", "all"] as const;
517
+
518
+ export type ProviderStatusResetMode = (typeof PROVIDER_STATUS_RESET_MODES)[number];
519
+
520
+ export interface ProviderStatusConfigSnapshot {
521
+ refreshMs: number;
522
+ cacheTtlMs: number;
523
+ providers: readonly string[];
524
+ display: ProviderStatusDisplay;
525
+ showCredits: boolean;
526
+ showReset: ProviderStatusResetMode;
527
+ /** Inclusive used-percentage threshold for displaying reset countdowns. */
528
+ resetMinUsedPercent: number;
529
+ }
530
+
531
+ export const DEFAULT_PROVIDER_STATUS_CONFIG: ProviderStatusConfigSnapshot = {
532
+ refreshMs: 60_000,
533
+ cacheTtlMs: 60_000,
534
+ providers: ["openai-codex", "anthropic", "zai"],
535
+ display: "gauge",
536
+ showCredits: false,
537
+ showReset: "all",
538
+ resetMinUsedPercent: 75,
539
+ };
540
+
541
+ export const DEFAULT_FOOTER_CONFIG: FooterConfigSnapshot = {
542
+ refreshMs: GIT_REFRESH_MS,
543
+ iconFamily: "nerd",
544
+ gaugeStyle: DEFAULT_GAUGE_STYLE,
545
+ gaugeWidth: DEFAULT_GAUGE_WIDTH,
546
+ gaugeColors: DEFAULT_GAUGE_COLORS,
547
+ defaultTextColor: "dim",
548
+ defaultIconColor: "text",
549
+ providerStatus: DEFAULT_PROVIDER_STATUS_CONFIG,
550
+ widgets: {},
551
+ extensionWidgets: {},
552
+ };
553
+
554
+ export interface FooterWidgetEditorDefaults {
555
+ row: number;
556
+ position: number;
557
+ align: FooterWidgetAlign;
558
+ fill: FooterWidgetFill;
559
+ minWidth?: number;
560
+ /** Set to false for widgets that start on the bench until enabled. */
561
+ enabled?: boolean;
562
+ }
563
+
564
+ export interface FooterWidgetMeta {
565
+ defaults: FooterWidgetEditorDefaults;
566
+ /** Compact label for the config editor when the full id doesn't fit. */
567
+ shortLabel: string;
568
+ description: string;
569
+ symbolKey: keyof StatuslineSymbols;
570
+ hasFooterIcon?: boolean;
571
+ }
572
+
573
+ export const FOOTER_WIDGET_META: Record<BuiltInFooterWidgetId, FooterWidgetMeta> = {
574
+ provider: {
575
+ shortLabel: "provider",
576
+ defaults: {
577
+ row: 1,
578
+ position: 5,
579
+ align: "right",
580
+ fill: "none",
581
+ enabled: false,
582
+ },
583
+ description: "Shows the provider behind the active model.",
584
+ symbolKey: "provider",
585
+ },
586
+ model: {
587
+ shortLabel: "model",
588
+ defaults: { row: 1, position: 6, align: "right", fill: "none" },
589
+ description: "Shows the active model.",
590
+ symbolKey: "model",
591
+ },
592
+ thinking: {
593
+ shortLabel: "think",
594
+ defaults: { row: 1, position: 7, align: "right", fill: "none" },
595
+ description: "Shows the current thinking level.",
596
+ symbolKey: "thinking",
597
+ },
598
+ "context-capacity": {
599
+ shortLabel: "capacity",
600
+ defaults: {
601
+ row: 0,
602
+ position: 1,
603
+ align: "left",
604
+ fill: "none",
605
+ enabled: false,
606
+ },
607
+ description: "Shows the total context window size.",
608
+ symbolKey: "contextCapacityMarker",
609
+ },
610
+ "context-bar": {
611
+ shortLabel: "ctx-bar",
612
+ defaults: { row: 0, position: 0, align: "left", fill: "none" },
613
+ description:
614
+ "Shows a mini gauge of used context. Set fill to grow for a full-width bar.",
615
+ symbolKey: "contextBarMarker",
616
+ },
617
+ "total-cost": {
618
+ shortLabel: "cost",
619
+ defaults: { row: 0, position: 3, align: "right", fill: "none" },
620
+ description: "Shows the total session cost.",
621
+ symbolKey: "currency",
622
+ },
623
+ "cache-read": {
624
+ shortLabel: "cache-r",
625
+ defaults: { row: 0, position: 0, align: "right", fill: "none" },
626
+ description: "Shows cumulative cache-read tokens for the session.",
627
+ symbolKey: "cacheRead",
628
+ },
629
+ "cache-write": {
630
+ shortLabel: "cache-w",
631
+ defaults: { row: 0, position: 1, align: "right", fill: "none" },
632
+ description: "Shows cumulative cache-write tokens for the session.",
633
+ symbolKey: "cacheWrite",
634
+ },
635
+ "cache-hit-rate": {
636
+ shortLabel: "cache-hit",
637
+ defaults: { row: 0, position: 2, align: "right", fill: "none" },
638
+ description: "Shows the latest turn's prompt-cache hit rate.",
639
+ symbolKey: "cacheHitRate",
640
+ },
641
+ location: {
642
+ shortLabel: "loc",
643
+ defaults: { row: 1, position: 0, align: "left", fill: "none" },
644
+ description: "Shows the current path.",
645
+ symbolKey: "path",
646
+ },
647
+ branch: {
648
+ shortLabel: "branch",
649
+ defaults: { row: 1, position: 1, align: "left", fill: "none" },
650
+ description: "Shows the current Git branch.",
651
+ symbolKey: "branch",
652
+ },
653
+ commit: {
654
+ shortLabel: "commit",
655
+ defaults: {
656
+ row: 1,
657
+ position: 2,
658
+ align: "left",
659
+ fill: "none",
660
+ enabled: false,
661
+ },
662
+ description: "Shows the short Git commit SHA.",
663
+ symbolKey: "commit",
664
+ },
665
+ "provider-status": {
666
+ shortLabel: "quota",
667
+ defaults: { row: 0, position: 1, align: "left", fill: "none" },
668
+ description: "Shows quota status for configured providers.",
669
+ symbolKey: "providerStatus",
670
+ },
671
+ "diff-added": {
672
+ shortLabel: "added",
673
+ defaults: { row: 1, position: 6, align: "left", fill: "none" },
674
+ description: "Shows added lines in your working tree.",
675
+ symbolKey: "diffAdded",
676
+ },
677
+ "diff-removed": {
678
+ shortLabel: "removed",
679
+ defaults: { row: 1, position: 7, align: "left", fill: "none" },
680
+ description: "Shows removed lines in your working tree.",
681
+ symbolKey: "diffRemoved",
682
+ },
683
+ "git-status": {
684
+ shortLabel: "git",
685
+ defaults: { row: 1, position: 8, align: "left", fill: "none" },
686
+ description: "Shows whether your branch is ahead, behind, or diverged.",
687
+ symbolKey: "gitDiverged",
688
+ hasFooterIcon: false,
689
+ },
690
+ };
691
+
692
+ export function toNumber(value: unknown): number {
693
+ const n = Number(value);
694
+ return Number.isFinite(n) ? n : 0;
695
+ }
696
+
697
+ export function clampInt(value: number, min: number, max: number): number {
698
+ return Math.max(min, Math.min(max, Math.floor(value)));
699
+ }
700
+
701
+ export function isFooterWidgetColor(value: unknown): value is FooterWidgetColor {
702
+ return (
703
+ typeof value === "string" &&
704
+ (FOOTER_WIDGET_COLORS as readonly string[]).includes(value)
705
+ );
706
+ }
707
+
708
+ export function isFooterIconFamily(value: unknown): value is FooterIconFamily {
709
+ return (
710
+ typeof value === "string" &&
711
+ (FOOTER_ICON_FAMILIES as readonly string[]).includes(value)
712
+ );
713
+ }
714
+
715
+ export function getStatuslineSymbols(iconFamily: FooterIconFamily): StatuslineSymbols {
716
+ return STATUSLINE_SYMBOLS[iconFamily];
717
+ }
718
+
719
+ export function isFooterWidgetAlign(value: unknown): value is FooterWidgetAlign {
720
+ return value === "left" || value === "middle" || value === "right";
721
+ }
722
+
723
+ export function isFooterWidgetFill(value: unknown): value is FooterWidgetFill {
724
+ return value === "none" || value === "grow";
725
+ }
726
+
727
+ export function isFooterWidgetId(value: string): value is BuiltInFooterWidgetId {
728
+ return (FOOTER_WIDGET_IDS as readonly string[]).includes(value);
729
+ }
730
+
731
+ export function toBoundedNonNegativeInt(
732
+ value: unknown,
733
+ max: number,
734
+ ): number | undefined {
735
+ const n = Number(value);
736
+ if (!Number.isFinite(n)) return undefined;
737
+ return clampInt(n, 0, max);
738
+ }
739
+
740
+ export function normalizePath(path: string): string {
741
+ const home = process.env.HOME || process.env.USERPROFILE || "";
742
+ if (!home) return path;
743
+ if (path === home) return "~";
744
+ if (path.startsWith(`${home}/`) || path.startsWith(`${home}\\`)) {
745
+ return `~${path.slice(home.length)}`;
746
+ }
747
+ return path;
748
+ }
749
+
750
+ export function normalizeModel(model: string): string {
751
+ const trimmed = model.trim();
752
+ if (!trimmed) return "Claude";
753
+ return trimmed.replace(/^Claude\s+/i, "") || "Claude";
754
+ }
755
+
756
+ export function getThinkingLevelFromEntries(
757
+ entries: ReadonlyArray<{
758
+ type: string;
759
+ thinkingLevel?: string;
760
+ }>,
761
+ fallbackLevel: ThinkingLevel,
762
+ ): string {
763
+ for (let i = entries.length - 1; i >= 0; i--) {
764
+ const entry = entries[i];
765
+ if (entry.type !== "thinking_level_change") continue;
766
+ return entry.thinkingLevel ?? fallbackLevel;
767
+ }
768
+
769
+ return fallbackLevel;
770
+ }
771
+
772
+ export function formatThinkingLevel(level: string): string {
773
+ if (level === "off") return "";
774
+ return level;
775
+ }
776
+
777
+ export function formatTerminalHyperlink(url: string, text: string): string {
778
+ if (!url) return text;
779
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
780
+ }
781
+
782
+ export function closeOpenTerminalHyperlinks(text: string, suffix = ""): string {
783
+ let open = 0;
784
+
785
+ for (let i = 0; i < text.length; i++) {
786
+ if (!text.startsWith("\x1b]8;;", i)) continue;
787
+
788
+ const valueStart = i + "\x1b]8;;".length;
789
+ let end = valueStart;
790
+ let terminatorLength = 0;
791
+
792
+ while (end < text.length) {
793
+ if (text[end] === "\x07") {
794
+ terminatorLength = 1;
795
+ break;
796
+ }
797
+ if (text[end] === "\x1b" && text[end + 1] === "\\") {
798
+ terminatorLength = 2;
799
+ break;
800
+ }
801
+ end += 1;
802
+ }
803
+
804
+ if (terminatorLength === 0) break;
805
+
806
+ const value = text.slice(valueStart, end);
807
+ open += value ? 1 : -1;
808
+ open = Math.max(0, open);
809
+ i = end + terminatorLength - 1;
810
+ }
811
+
812
+ if (open === 0) return text;
813
+
814
+ const closeSequence = "\x1b]8;;\x07".repeat(open);
815
+ if (!suffix) return `${text}${closeSequence}`;
816
+
817
+ const suffixStart = text.lastIndexOf(suffix);
818
+ if (suffixStart < 0) return `${text}${closeSequence}`;
819
+ return `${text.slice(0, suffixStart)}${closeSequence}${text.slice(suffixStart)}`;
820
+ }
821
+
822
+ export function parseNumstat(output: string): {
823
+ added: number;
824
+ removed: number;
825
+ } {
826
+ let added = 0;
827
+ let removed = 0;
828
+
829
+ for (const line of output.split(/\r?\n/)) {
830
+ if (!line.trim()) continue;
831
+ const parts = line.split("\t");
832
+ if (parts.length < 2) continue;
833
+ const a = parts[0] || "";
834
+ const b = parts[1] || "";
835
+ if (a === "-" || b === "-") continue;
836
+ added += toNumber(a);
837
+ removed += toNumber(b);
838
+ }
839
+
840
+ return { added, removed };
841
+ }
842
+
843
+ export function getWidgetSettingIcon(
844
+ widgetId: BuiltInFooterWidgetId,
845
+ iconFamily: FooterIconFamily,
846
+ ): string {
847
+ return getStatuslineSymbols(iconFamily)[FOOTER_WIDGET_META[widgetId].symbolKey];
848
+ }
849
+
850
+ export function getDefaultWidgetIcon(
851
+ widgetId: string,
852
+ iconFamily: FooterIconFamily,
853
+ ): FooterWidgetIcon | undefined {
854
+ if (!isFooterWidgetId(widgetId)) return undefined;
855
+ const meta = FOOTER_WIDGET_META[widgetId];
856
+ if (meta.hasFooterIcon === false) return undefined;
857
+ return { text: getWidgetSettingIcon(widgetId, iconFamily), color: "text" };
858
+ }
859
+
860
+ export function widgetSummary(
861
+ config: FooterConfigSnapshot,
862
+ widgetId: BuiltInFooterWidgetId,
863
+ ): string {
864
+ const meta = FOOTER_WIDGET_META[widgetId];
865
+ const defaults = meta.defaults;
866
+ const hasBuiltInIcon = meta.hasFooterIcon !== false;
867
+ const hasConfigurableIconColor = hasBuiltInIcon;
868
+
869
+ const override = config.widgets[widgetId];
870
+ if (!override) return "default";
871
+
872
+ const parts: string[] = [];
873
+
874
+ if (override.enabled === true) parts.push("on");
875
+ if (override.enabled === false && (defaults.enabled ?? true)) parts.push("off");
876
+
877
+ if (override.row !== undefined && override.row !== defaults.row)
878
+ parts.push(`row:${override.row}`);
879
+ if (override.position !== undefined && override.position !== defaults.position)
880
+ parts.push(`pos:${override.position}`);
881
+ if (override.align !== undefined && override.align !== defaults.align)
882
+ parts.push(`align:${override.align}`);
883
+
884
+ if (hasBuiltInIcon && override.icon === "hide") parts.push("icon:hidden");
885
+ if (
886
+ hasConfigurableIconColor &&
887
+ override.iconColor !== undefined &&
888
+ override.iconColor !== config.defaultIconColor
889
+ ) {
890
+ parts.push(`icon:${override.iconColor}`);
891
+ }
892
+ if (
893
+ override.textColor !== undefined &&
894
+ override.textColor !== config.defaultTextColor
895
+ ) {
896
+ parts.push(`text:${override.textColor}`);
897
+ }
898
+ if (override.fill !== undefined && override.fill !== defaults.fill)
899
+ parts.push(`fill:${override.fill}`);
900
+ if (override.minWidth !== undefined) parts.push(`width:${override.minWidth}`);
901
+
902
+ return parts.length > 0 ? parts.join(" ") : "default";
903
+ }