@oh-my-pi/pi-coding-agent 6.9.0 → 7.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.
Files changed (143) hide show
  1. package/CHANGELOG.md +173 -51
  2. package/examples/sdk/04-skills.ts +1 -1
  3. package/package.json +6 -5
  4. package/src/cli/stats-cli.ts +191 -0
  5. package/src/core/agent-session.ts +214 -4
  6. package/src/core/auth-storage.ts +524 -202
  7. package/src/core/bash-executor.ts +1 -1
  8. package/src/core/extensions/index.ts +2 -0
  9. package/src/core/extensions/runner.ts +31 -0
  10. package/src/core/extensions/types.ts +24 -0
  11. package/src/core/messages.ts +48 -0
  12. package/src/core/model-registry.ts +7 -0
  13. package/src/core/python-executor.ts +29 -8
  14. package/src/core/python-gateway-coordinator.ts +55 -1
  15. package/src/core/python-prelude.py +201 -8
  16. package/src/core/session-manager.ts +10 -1
  17. package/src/core/tools/bash.ts +5 -7
  18. package/src/core/tools/find.ts +18 -5
  19. package/src/core/tools/index.ts +1 -1
  20. package/src/core/tools/lsp/index.ts +13 -2
  21. package/src/core/tools/patch/applicator.ts +115 -17
  22. package/src/core/tools/patch/index.ts +1 -1
  23. package/src/core/tools/patch/normalize.ts +185 -10
  24. package/src/core/tools/python.ts +445 -86
  25. package/src/core/tools/read.ts +4 -4
  26. package/src/core/tools/task/executor.ts +2 -6
  27. package/src/core/tools/task/index.ts +30 -12
  28. package/src/core/tools/task/render.ts +163 -30
  29. package/src/core/tools/task/template.ts +37 -0
  30. package/src/core/tools/task/types.ts +6 -2
  31. package/src/core/tools/task/worker.ts +1 -1
  32. package/src/index.ts +2 -0
  33. package/src/main.ts +12 -0
  34. package/src/modes/interactive/components/python-execution.ts +180 -0
  35. package/src/modes/interactive/components/welcome.ts +1 -0
  36. package/src/modes/interactive/controllers/command-controller.ts +395 -0
  37. package/src/modes/interactive/controllers/input-controller.ts +83 -8
  38. package/src/modes/interactive/interactive-mode.ts +16 -1
  39. package/src/modes/interactive/theme/dark.json +2 -9
  40. package/src/modes/interactive/theme/defaults/alabaster.json +2 -8
  41. package/src/modes/interactive/theme/defaults/amethyst.json +2 -9
  42. package/src/modes/interactive/theme/defaults/anthracite.json +2 -9
  43. package/src/modes/interactive/theme/defaults/basalt.json +89 -88
  44. package/src/modes/interactive/theme/defaults/birch.json +2 -8
  45. package/src/modes/interactive/theme/defaults/dark-abyss.json +2 -8
  46. package/src/modes/interactive/theme/defaults/dark-arctic.json +2 -9
  47. package/src/modes/interactive/theme/defaults/dark-aurora.json +3 -2
  48. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +2 -1
  49. package/src/modes/interactive/theme/defaults/dark-cavern.json +2 -8
  50. package/src/modes/interactive/theme/defaults/dark-copper.json +3 -2
  51. package/src/modes/interactive/theme/defaults/dark-cosmos.json +2 -8
  52. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +2 -9
  53. package/src/modes/interactive/theme/defaults/dark-dracula.json +2 -9
  54. package/src/modes/interactive/theme/defaults/dark-eclipse.json +2 -8
  55. package/src/modes/interactive/theme/defaults/dark-ember.json +3 -2
  56. package/src/modes/interactive/theme/defaults/dark-equinox.json +2 -8
  57. package/src/modes/interactive/theme/defaults/dark-forest.json +2 -9
  58. package/src/modes/interactive/theme/defaults/dark-github.json +2 -9
  59. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +2 -9
  60. package/src/modes/interactive/theme/defaults/dark-lavender.json +3 -2
  61. package/src/modes/interactive/theme/defaults/dark-lunar.json +2 -8
  62. package/src/modes/interactive/theme/defaults/dark-midnight.json +3 -2
  63. package/src/modes/interactive/theme/defaults/dark-monochrome.json +2 -9
  64. package/src/modes/interactive/theme/defaults/dark-monokai.json +2 -9
  65. package/src/modes/interactive/theme/defaults/dark-nebula.json +2 -8
  66. package/src/modes/interactive/theme/defaults/dark-nord.json +2 -9
  67. package/src/modes/interactive/theme/defaults/dark-ocean.json +2 -9
  68. package/src/modes/interactive/theme/defaults/dark-one.json +2 -9
  69. package/src/modes/interactive/theme/defaults/dark-rainforest.json +2 -8
  70. package/src/modes/interactive/theme/defaults/dark-reef.json +2 -8
  71. package/src/modes/interactive/theme/defaults/dark-retro.json +2 -9
  72. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +2 -1
  73. package/src/modes/interactive/theme/defaults/dark-sakura.json +3 -2
  74. package/src/modes/interactive/theme/defaults/dark-slate.json +3 -2
  75. package/src/modes/interactive/theme/defaults/dark-solarized.json +2 -1
  76. package/src/modes/interactive/theme/defaults/dark-solstice.json +2 -8
  77. package/src/modes/interactive/theme/defaults/dark-starfall.json +2 -8
  78. package/src/modes/interactive/theme/defaults/dark-sunset.json +2 -9
  79. package/src/modes/interactive/theme/defaults/dark-swamp.json +2 -8
  80. package/src/modes/interactive/theme/defaults/dark-synthwave.json +2 -1
  81. package/src/modes/interactive/theme/defaults/dark-taiga.json +2 -8
  82. package/src/modes/interactive/theme/defaults/dark-terminal.json +3 -2
  83. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +2 -9
  84. package/src/modes/interactive/theme/defaults/dark-tundra.json +2 -8
  85. package/src/modes/interactive/theme/defaults/dark-twilight.json +2 -8
  86. package/src/modes/interactive/theme/defaults/dark-volcanic.json +2 -8
  87. package/src/modes/interactive/theme/defaults/graphite.json +2 -9
  88. package/src/modes/interactive/theme/defaults/light-arctic.json +2 -1
  89. package/src/modes/interactive/theme/defaults/light-aurora-day.json +2 -8
  90. package/src/modes/interactive/theme/defaults/light-canyon.json +2 -8
  91. package/src/modes/interactive/theme/defaults/light-catppuccin.json +2 -1
  92. package/src/modes/interactive/theme/defaults/light-cirrus.json +2 -8
  93. package/src/modes/interactive/theme/defaults/light-coral.json +3 -2
  94. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +2 -9
  95. package/src/modes/interactive/theme/defaults/light-dawn.json +2 -8
  96. package/src/modes/interactive/theme/defaults/light-dunes.json +2 -8
  97. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +3 -2
  98. package/src/modes/interactive/theme/defaults/light-forest.json +2 -9
  99. package/src/modes/interactive/theme/defaults/light-frost.json +3 -2
  100. package/src/modes/interactive/theme/defaults/light-github.json +2 -1
  101. package/src/modes/interactive/theme/defaults/light-glacier.json +2 -8
  102. package/src/modes/interactive/theme/defaults/light-gruvbox.json +2 -9
  103. package/src/modes/interactive/theme/defaults/light-haze.json +2 -8
  104. package/src/modes/interactive/theme/defaults/light-honeycomb.json +3 -2
  105. package/src/modes/interactive/theme/defaults/light-lagoon.json +2 -8
  106. package/src/modes/interactive/theme/defaults/light-lavender.json +3 -2
  107. package/src/modes/interactive/theme/defaults/light-meadow.json +2 -8
  108. package/src/modes/interactive/theme/defaults/light-mint.json +3 -2
  109. package/src/modes/interactive/theme/defaults/light-monochrome.json +2 -1
  110. package/src/modes/interactive/theme/defaults/light-ocean.json +2 -9
  111. package/src/modes/interactive/theme/defaults/light-one.json +2 -8
  112. package/src/modes/interactive/theme/defaults/light-opal.json +2 -8
  113. package/src/modes/interactive/theme/defaults/light-orchard.json +2 -8
  114. package/src/modes/interactive/theme/defaults/light-paper.json +3 -2
  115. package/src/modes/interactive/theme/defaults/light-prism.json +2 -8
  116. package/src/modes/interactive/theme/defaults/light-retro.json +2 -9
  117. package/src/modes/interactive/theme/defaults/light-sand.json +3 -2
  118. package/src/modes/interactive/theme/defaults/light-savanna.json +2 -8
  119. package/src/modes/interactive/theme/defaults/light-solarized.json +2 -1
  120. package/src/modes/interactive/theme/defaults/light-soleil.json +2 -8
  121. package/src/modes/interactive/theme/defaults/light-sunset.json +2 -9
  122. package/src/modes/interactive/theme/defaults/light-synthwave.json +2 -9
  123. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +2 -9
  124. package/src/modes/interactive/theme/defaults/light-wetland.json +2 -8
  125. package/src/modes/interactive/theme/defaults/light-zenith.json +2 -8
  126. package/src/modes/interactive/theme/defaults/limestone.json +2 -8
  127. package/src/modes/interactive/theme/defaults/mahogany.json +2 -9
  128. package/src/modes/interactive/theme/defaults/marble.json +2 -8
  129. package/src/modes/interactive/theme/defaults/obsidian.json +89 -88
  130. package/src/modes/interactive/theme/defaults/onyx.json +89 -88
  131. package/src/modes/interactive/theme/defaults/pearl.json +2 -8
  132. package/src/modes/interactive/theme/defaults/porcelain.json +89 -88
  133. package/src/modes/interactive/theme/defaults/quartz.json +2 -8
  134. package/src/modes/interactive/theme/defaults/sandstone.json +2 -8
  135. package/src/modes/interactive/theme/defaults/titanium.json +88 -87
  136. package/src/modes/interactive/theme/light.json +2 -8
  137. package/src/modes/interactive/theme/theme-schema.json +5 -0
  138. package/src/modes/interactive/theme/theme.ts +7 -0
  139. package/src/modes/interactive/types.ts +7 -1
  140. package/src/modes/interactive/utils/ui-helpers.ts +20 -0
  141. package/src/prompts/system/system-prompt.md +88 -78
  142. package/src/prompts/tools/python.md +39 -2
  143. package/src/prompts/tools/task.md +8 -13
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Component for displaying user-initiated Python execution with streaming output.
3
+ * Shares the same kernel session as the agent's Python tool.
4
+ */
5
+
6
+ import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
7
+ import stripAnsi from "strip-ansi";
8
+ import {
9
+ DEFAULT_MAX_BYTES,
10
+ DEFAULT_MAX_LINES,
11
+ type TruncationResult,
12
+ truncateTail,
13
+ } from "../../../core/tools/truncate";
14
+ import { getSymbolTheme, highlightCode, theme } from "../theme/theme";
15
+ import { DynamicBorder } from "./dynamic-border";
16
+ import { truncateToVisualLines } from "./visual-truncate";
17
+
18
+ const PREVIEW_LINES = 20;
19
+
20
+ export class PythonExecutionComponent extends Container {
21
+ private code: string;
22
+ private outputLines: string[] = [];
23
+ private status: "running" | "complete" | "cancelled" | "error" = "running";
24
+ private exitCode: number | undefined = undefined;
25
+ private loader: Loader;
26
+ private truncationResult?: TruncationResult;
27
+ private fullOutputPath?: string;
28
+ private expanded = false;
29
+ private contentContainer: Container;
30
+ private excludeFromContext: boolean;
31
+
32
+ private formatHeader(colorKey: "dim" | "pythonMode"): Text {
33
+ const prompt = theme.fg(colorKey, theme.bold(">>>"));
34
+ const continuation = theme.fg(colorKey, " ");
35
+ const codeLines = highlightCode(this.code, "python");
36
+ const headerLines = codeLines.map((line, index) =>
37
+ index === 0 ? `${prompt} ${line}` : `${continuation}${line}`,
38
+ );
39
+ return new Text(headerLines.join("\n"), 1, 0);
40
+ }
41
+
42
+ constructor(code: string, ui: TUI, excludeFromContext = false) {
43
+ super();
44
+ this.code = code;
45
+ this.excludeFromContext = excludeFromContext;
46
+
47
+ const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
48
+ const borderColor = (str: string) => theme.fg(colorKey, str);
49
+
50
+ this.addChild(new Spacer(1));
51
+ this.addChild(new DynamicBorder(borderColor));
52
+
53
+ this.contentContainer = new Container();
54
+ this.addChild(this.contentContainer);
55
+ this.contentContainer.addChild(this.formatHeader(colorKey));
56
+
57
+ this.loader = new Loader(
58
+ ui,
59
+ (spinner) => theme.fg(colorKey, spinner),
60
+ (text) => theme.fg("muted", text),
61
+ `Running${theme.format.ellipsis} (esc to cancel)`,
62
+ getSymbolTheme().spinnerFrames,
63
+ );
64
+ this.contentContainer.addChild(this.loader);
65
+
66
+ this.addChild(new DynamicBorder(borderColor));
67
+ }
68
+
69
+ setExpanded(expanded: boolean): void {
70
+ this.expanded = expanded;
71
+ this.updateDisplay();
72
+ }
73
+
74
+ override invalidate(): void {
75
+ super.invalidate();
76
+ this.updateDisplay();
77
+ }
78
+
79
+ appendOutput(chunk: string): void {
80
+ const clean = stripAnsi(chunk).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
81
+
82
+ const newLines = clean.split("\n");
83
+ if (this.outputLines.length > 0 && newLines.length > 0) {
84
+ this.outputLines[this.outputLines.length - 1] += newLines[0];
85
+ this.outputLines.push(...newLines.slice(1));
86
+ } else {
87
+ this.outputLines.push(...newLines);
88
+ }
89
+
90
+ this.updateDisplay();
91
+ }
92
+
93
+ setComplete(
94
+ exitCode: number | undefined,
95
+ cancelled: boolean,
96
+ truncationResult?: TruncationResult,
97
+ fullOutputPath?: string,
98
+ ): void {
99
+ this.exitCode = exitCode;
100
+ this.status = cancelled
101
+ ? "cancelled"
102
+ : exitCode !== 0 && exitCode !== undefined && exitCode !== null
103
+ ? "error"
104
+ : "complete";
105
+ this.truncationResult = truncationResult;
106
+ this.fullOutputPath = fullOutputPath;
107
+
108
+ this.loader.stop();
109
+ this.updateDisplay();
110
+ }
111
+
112
+ private updateDisplay(): void {
113
+ const fullOutput = this.outputLines.join("\n");
114
+ const contextTruncation = truncateTail(fullOutput, {
115
+ maxLines: DEFAULT_MAX_LINES,
116
+ maxBytes: DEFAULT_MAX_BYTES,
117
+ });
118
+
119
+ const availableLines = contextTruncation.content ? contextTruncation.content.split("\n") : [];
120
+ const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
121
+ const hiddenLineCount = availableLines.length - previewLogicalLines.length;
122
+
123
+ this.contentContainer.clear();
124
+
125
+ const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
126
+ this.contentContainer.addChild(this.formatHeader(colorKey));
127
+
128
+ if (availableLines.length > 0) {
129
+ if (this.expanded) {
130
+ const displayText = availableLines.map((line) => theme.fg("muted", line)).join("\n");
131
+ this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
132
+ } else {
133
+ const styledOutput = previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n");
134
+ const previewText = `\n${styledOutput}`;
135
+ this.contentContainer.addChild({
136
+ render: (width: number) => {
137
+ const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
138
+ return visualLines;
139
+ },
140
+ invalidate: () => {},
141
+ });
142
+ }
143
+ }
144
+
145
+ if (this.status === "running") {
146
+ this.contentContainer.addChild(this.loader);
147
+ } else {
148
+ const statusParts: string[] = [];
149
+
150
+ if (hiddenLineCount > 0) {
151
+ statusParts.push(
152
+ theme.fg("dim", `${theme.format.ellipsis} ${hiddenLineCount} more lines (ctrl+o to expand)`),
153
+ );
154
+ }
155
+
156
+ if (this.status === "cancelled") {
157
+ statusParts.push(theme.fg("warning", "(cancelled)"));
158
+ } else if (this.status === "error") {
159
+ statusParts.push(theme.fg("error", `(exit ${this.exitCode})`));
160
+ }
161
+
162
+ const wasTruncated = this.truncationResult?.truncated || contextTruncation.truncated;
163
+ if (wasTruncated && this.fullOutputPath) {
164
+ statusParts.push(theme.fg("warning", `Output truncated. Full output: ${this.fullOutputPath}`));
165
+ }
166
+
167
+ if (statusParts.length > 0) {
168
+ this.contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
169
+ }
170
+ }
171
+ }
172
+
173
+ getOutput(): string {
174
+ return this.outputLines.join("\n");
175
+ }
176
+
177
+ getCode(): string {
178
+ return this.code;
179
+ }
180
+ }
@@ -117,6 +117,7 @@ export class WelcomeComponent implements Component {
117
117
  ` ${theme.fg("dim", "?")}${theme.fg("muted", " for keyboard shortcuts")}`,
118
118
  ` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
119
119
  ` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
120
+ ` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
120
121
  separator,
121
122
  ` ${theme.bold(theme.fg("accent", "LSP Servers"))}`,
122
123
  ...lspLines,
@@ -1,6 +1,7 @@
1
1
  import { mkdir, rm } from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
4
5
  import { Loader, Markdown, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
5
6
  import { $ } from "bun";
6
7
  import { nanoid } from "nanoid";
@@ -16,6 +17,7 @@ import { ArminComponent } from "../components/armin";
16
17
  import { BashExecutionComponent } from "../components/bash-execution";
17
18
  import { BorderedLoader } from "../components/bordered-loader";
18
19
  import { DynamicBorder } from "../components/dynamic-border";
20
+ import { PythonExecutionComponent } from "../components/python-execution";
19
21
  import { getMarkdownTheme, getSymbolTheme, theme } from "../theme/theme";
20
22
  import type { InteractiveModeContext } from "../types";
21
23
 
@@ -283,6 +285,33 @@ export class CommandController {
283
285
  this.ctx.ui.requestRender();
284
286
  }
285
287
 
288
+ async handleUsageCommand(reports?: UsageReport[] | null): Promise<void> {
289
+ let usageReports = reports ?? null;
290
+ if (!usageReports) {
291
+ const provider = this.ctx.session as { fetchUsageReports?: () => Promise<UsageReport[] | null> };
292
+ if (!provider.fetchUsageReports) {
293
+ this.ctx.showWarning("Usage reporting is not configured for this session.");
294
+ return;
295
+ }
296
+ try {
297
+ usageReports = await provider.fetchUsageReports();
298
+ } catch (error) {
299
+ this.ctx.showError(`Failed to fetch usage data: ${error instanceof Error ? error.message : String(error)}`);
300
+ return;
301
+ }
302
+ }
303
+
304
+ if (!usageReports || usageReports.length === 0) {
305
+ this.ctx.showWarning("No usage data available.");
306
+ return;
307
+ }
308
+
309
+ const output = renderUsageReports(usageReports, theme, Date.now());
310
+ this.ctx.chatContainer.addChild(new Spacer(1));
311
+ this.ctx.chatContainer.addChild(new Text(output, 1, 0));
312
+ this.ctx.ui.requestRender();
313
+ }
314
+
286
315
  handleChangelogCommand(): void {
287
316
  const changelogPath = getChangelogPath();
288
317
  const allEntries = parseChangelog(changelogPath);
@@ -344,6 +373,8 @@ export class CommandController {
344
373
  | \`/\` | Slash commands |
345
374
  | \`!\` | Run bash command |
346
375
  | \`!!\` | Run bash command (excluded from context) |
376
+ | \`$\` | Run Python in shared kernel |
377
+ | \`$$\` | Run Python (excluded from context) |
347
378
  `;
348
379
  this.ctx.chatContainer.addChild(new Spacer(1));
349
380
  this.ctx.chatContainer.addChild(new DynamicBorder());
@@ -471,6 +502,49 @@ export class CommandController {
471
502
  this.ctx.ui.requestRender();
472
503
  }
473
504
 
505
+ async handlePythonCommand(code: string, excludeFromContext = false): Promise<void> {
506
+ const isDeferred = this.ctx.session.isStreaming;
507
+ this.ctx.pythonComponent = new PythonExecutionComponent(code, this.ctx.ui, excludeFromContext);
508
+
509
+ if (isDeferred) {
510
+ this.ctx.pendingMessagesContainer.addChild(this.ctx.pythonComponent);
511
+ this.ctx.pendingPythonComponents.push(this.ctx.pythonComponent);
512
+ } else {
513
+ this.ctx.chatContainer.addChild(this.ctx.pythonComponent);
514
+ }
515
+ this.ctx.ui.requestRender();
516
+
517
+ try {
518
+ const result = await this.ctx.session.executePython(
519
+ code,
520
+ (chunk) => {
521
+ if (this.ctx.pythonComponent) {
522
+ this.ctx.pythonComponent.appendOutput(chunk);
523
+ this.ctx.ui.requestRender();
524
+ }
525
+ },
526
+ { excludeFromContext },
527
+ );
528
+
529
+ if (this.ctx.pythonComponent) {
530
+ this.ctx.pythonComponent.setComplete(
531
+ result.exitCode,
532
+ result.cancelled,
533
+ result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined,
534
+ result.fullOutputPath,
535
+ );
536
+ }
537
+ } catch (error) {
538
+ if (this.ctx.pythonComponent) {
539
+ this.ctx.pythonComponent.setComplete(undefined, false);
540
+ }
541
+ this.ctx.showError(`Python execution failed: ${error instanceof Error ? error.message : "Unknown error"}`);
542
+ }
543
+
544
+ this.ctx.pythonComponent = undefined;
545
+ this.ctx.ui.requestRender();
546
+ }
547
+
474
548
  async handleCompactCommand(customInstructions?: string): Promise<void> {
475
549
  const entries = this.ctx.sessionManager.getEntries();
476
550
  const messageCount = entries.filter((e) => e.type === "message").length;
@@ -552,3 +626,324 @@ export class CommandController {
552
626
  await this.ctx.flushCompactionQueue({ willRetry: false });
553
627
  }
554
628
  }
629
+
630
+ const BAR_WIDTH = 24;
631
+ const COLUMN_WIDTH = BAR_WIDTH + 2;
632
+
633
+ function formatProviderName(provider: string): string {
634
+ return provider
635
+ .split(/[-_]/g)
636
+ .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : ""))
637
+ .join(" ");
638
+ }
639
+
640
+ function formatNumber(value: number, maxFractionDigits = 1): string {
641
+ return new Intl.NumberFormat("en-US", { maximumFractionDigits: maxFractionDigits }).format(value);
642
+ }
643
+
644
+ function formatUsedAccounts(value: number): string {
645
+ return `${value.toFixed(2)} used`;
646
+ }
647
+
648
+ function formatDuration(ms: number): string {
649
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
650
+ const minutes = Math.floor(totalSeconds / 60);
651
+ const seconds = totalSeconds % 60;
652
+ const hours = Math.floor(minutes / 60);
653
+ const mins = minutes % 60;
654
+ const days = Math.floor(hours / 24);
655
+ const hrs = hours % 24;
656
+ if (days > 0) return `${days}d ${hrs}h`;
657
+ if (hours > 0) return `${hours}h ${mins}m`;
658
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
659
+ return `${seconds}s`;
660
+ }
661
+
662
+ function formatDurationShort(ms: number): string {
663
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
664
+ const minutes = Math.floor(totalSeconds / 60);
665
+ const hours = Math.floor(minutes / 60);
666
+ const mins = minutes % 60;
667
+ const days = Math.floor(hours / 24);
668
+ const hrs = hours % 24;
669
+ if (days > 0) return `${days}d${hrs > 0 ? ` ${hrs}h` : ""}`;
670
+ if (hours > 0) return `${hours}h${mins > 0 ? ` ${mins}m` : ""}`;
671
+ if (minutes > 0) return `${minutes}m`;
672
+ return `${totalSeconds}s`;
673
+ }
674
+
675
+ function resolveFraction(limit: UsageLimit): number | undefined {
676
+ const amount = limit.amount;
677
+ if (amount.usedFraction !== undefined) return amount.usedFraction;
678
+ if (amount.used !== undefined && amount.limit !== undefined && amount.limit > 0) {
679
+ return amount.used / amount.limit;
680
+ }
681
+ if (amount.unit === "percent" && amount.used !== undefined) {
682
+ return amount.used / 100;
683
+ }
684
+ return undefined;
685
+ }
686
+
687
+ function resolveProviderUsageTotal(reports: UsageReport[]): number {
688
+ return reports
689
+ .flatMap((report) => report.limits)
690
+ .map((limit) => resolveFraction(limit) ?? 0)
691
+ .reduce((sum, value) => sum + value, 0);
692
+ }
693
+
694
+ function formatLimitTitle(limit: UsageLimit): string {
695
+ const tier = limit.scope.tier;
696
+ if (tier && !limit.label.toLowerCase().includes(tier.toLowerCase())) {
697
+ return `${limit.label} (${tier})`;
698
+ }
699
+ return limit.label;
700
+ }
701
+
702
+ function formatWindowSuffix(label: string, windowLabel: string, uiTheme: typeof theme): string {
703
+ const normalizedLabel = label.toLowerCase();
704
+ const normalizedWindow = windowLabel.toLowerCase();
705
+ if (normalizedWindow === "quota window") return "";
706
+ if (normalizedLabel.includes(normalizedWindow)) return "";
707
+ return uiTheme.fg("dim", `(${windowLabel})`);
708
+ }
709
+
710
+ function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: number): string {
711
+ const email = (report.metadata?.email as string | undefined) ?? limit.scope.accountId;
712
+ if (email) return email;
713
+ const accountId = (report.metadata?.accountId as string | undefined) ?? limit.scope.accountId;
714
+ if (accountId) return accountId;
715
+ return `account ${index + 1}`;
716
+ }
717
+
718
+ function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined {
719
+ if (limit.window?.resetInMs !== undefined) {
720
+ return formatDurationShort(limit.window.resetInMs);
721
+ }
722
+ if (limit.window?.resetsAt !== undefined) {
723
+ return formatDurationShort(limit.window.resetsAt - nowMs);
724
+ }
725
+ return undefined;
726
+ }
727
+
728
+ function formatAccountHeader(limit: UsageLimit, report: UsageReport, index: number, nowMs: number): string {
729
+ const label = formatAccountLabel(limit, report, index);
730
+ const reset = formatResetShort(limit, nowMs);
731
+ if (!reset) return label;
732
+ return `${label} (${reset})`;
733
+ }
734
+
735
+ function padColumn(text: string, width: number): string {
736
+ const visible = visibleWidth(text);
737
+ if (visible >= width) return text;
738
+ return `${text}${" ".repeat(width - visible)}`;
739
+ }
740
+
741
+ function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] {
742
+ const hasOk = limits.some((limit) => limit.status === "ok");
743
+ const hasWarning = limits.some((limit) => limit.status === "warning");
744
+ const hasExhausted = limits.some((limit) => limit.status === "exhausted");
745
+ if (!hasOk && !hasWarning && !hasExhausted) return "unknown";
746
+ if (hasOk) {
747
+ return hasWarning || hasExhausted ? "warning" : "ok";
748
+ }
749
+ if (hasWarning) return "warning";
750
+ return "exhausted";
751
+ }
752
+
753
+ function isZeroUsage(limit: UsageLimit): boolean {
754
+ const amount = limit.amount;
755
+ if (amount.usedFraction !== undefined) return amount.usedFraction <= 0;
756
+ if (amount.used !== undefined) return amount.used <= 0;
757
+ if (amount.unit === "percent" && amount.used !== undefined) return amount.used <= 0;
758
+ if (amount.remainingFraction !== undefined) return amount.remainingFraction >= 1;
759
+ return false;
760
+ }
761
+
762
+ function isZeroUsageGroup(limits: UsageLimit[]): boolean {
763
+ return limits.length > 0 && limits.every((limit) => isZeroUsage(limit));
764
+ }
765
+
766
+ function formatAggregateAmount(limits: UsageLimit[]): string {
767
+ const fractions = limits
768
+ .map((limit) => resolveFraction(limit))
769
+ .filter((value): value is number => value !== undefined);
770
+ if (fractions.length === limits.length && fractions.length > 0) {
771
+ const sum = fractions.reduce((total, value) => total + value, 0);
772
+ const usedPct = Math.max(sum * 100, 0);
773
+ const remainingPct = Math.max(0, limits.length * 100 - usedPct);
774
+ const avgRemaining = limits.length > 0 ? remainingPct / limits.length : remainingPct;
775
+ return `${formatUsedAccounts(sum)} (${formatNumber(avgRemaining)}% left)`;
776
+ }
777
+
778
+ const amounts = limits
779
+ .map((limit) => limit.amount)
780
+ .filter((amount) => amount.used !== undefined && amount.limit !== undefined && amount.limit > 0);
781
+ if (amounts.length === limits.length && amounts.length > 0) {
782
+ const totalUsed = amounts.reduce((sum, amount) => sum + (amount.used ?? 0), 0);
783
+ const totalLimit = amounts.reduce((sum, amount) => sum + (amount.limit ?? 0), 0);
784
+ const usedPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
785
+ const remainingPct = Math.max(0, 100 - usedPct);
786
+ const usedAccounts = totalLimit > 0 ? (usedPct / 100) * limits.length : 0;
787
+ return `${formatUsedAccounts(usedAccounts)} (${formatNumber(remainingPct)}% left)`;
788
+ }
789
+
790
+ return `Accounts: ${limits.length}`;
791
+ }
792
+
793
+ function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
794
+ const resets = limits
795
+ .map((limit) => limit.window?.resetInMs ?? undefined)
796
+ .filter((value): value is number => value !== undefined && Number.isFinite(value) && value > 0);
797
+ if (resets.length === 0) {
798
+ const absolute = limits
799
+ .map((limit) => limit.window?.resetsAt)
800
+ .filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs);
801
+ if (absolute.length === 0) return null;
802
+ const earliest = Math.min(...absolute);
803
+ return `resets at ${new Date(earliest).toLocaleString()}`;
804
+ }
805
+ const minReset = Math.min(...resets);
806
+ const maxReset = Math.max(...resets);
807
+ if (maxReset - minReset > 60_000) {
808
+ return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
809
+ }
810
+ return `resets in ${formatDuration(minReset)}`;
811
+ }
812
+
813
+ function resolveStatusIcon(status: UsageLimit["status"], uiTheme: typeof theme): string {
814
+ if (status === "exhausted") return uiTheme.fg("error", uiTheme.status.error);
815
+ if (status === "warning") return uiTheme.fg("warning", uiTheme.status.warning);
816
+ if (status === "ok") return uiTheme.fg("success", uiTheme.status.success);
817
+ return uiTheme.fg("dim", uiTheme.status.pending);
818
+ }
819
+
820
+ function resolveStatusColor(status: UsageLimit["status"]): "success" | "warning" | "error" | "dim" {
821
+ if (status === "exhausted") return "error";
822
+ if (status === "warning") return "warning";
823
+ if (status === "ok") return "success";
824
+ return "dim";
825
+ }
826
+
827
+ function renderUsageBar(limit: UsageLimit, uiTheme: typeof theme): string {
828
+ const fraction = resolveFraction(limit);
829
+ if (fraction === undefined) {
830
+ return uiTheme.fg("dim", `[${"·".repeat(BAR_WIDTH)}]`);
831
+ }
832
+ const clamped = Math.min(Math.max(fraction, 0), 1);
833
+ const filled = Math.round(clamped * BAR_WIDTH);
834
+ const filledBar = "█".repeat(filled);
835
+ const emptyBar = "░".repeat(Math.max(0, BAR_WIDTH - filled));
836
+ const color = resolveStatusColor(limit.status);
837
+ return `${uiTheme.fg("dim", "[")}${uiTheme.fg(color, filledBar)}${uiTheme.fg("dim", emptyBar)}${uiTheme.fg("dim", "]")}`;
838
+ }
839
+
840
+ function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs: number): string {
841
+ const lines: string[] = [];
842
+ const latestFetchedAt = Math.max(...reports.map((report) => report.fetchedAt ?? 0));
843
+ const headerSuffix = latestFetchedAt ? ` (${formatDuration(nowMs - latestFetchedAt)} ago)` : "";
844
+ lines.push(uiTheme.bold(uiTheme.fg("accent", `Usage${headerSuffix}`)));
845
+ const grouped = new Map<string, UsageReport[]>();
846
+ for (const report of reports) {
847
+ const list = grouped.get(report.provider) ?? [];
848
+ list.push(report);
849
+ grouped.set(report.provider, list);
850
+ }
851
+ const providerEntries = Array.from(grouped.entries())
852
+ .map(([provider, providerReports]) => ({
853
+ provider,
854
+ providerReports,
855
+ totalUsage: resolveProviderUsageTotal(providerReports),
856
+ }))
857
+ .sort((a, b) => {
858
+ if (a.totalUsage !== b.totalUsage) return a.totalUsage - b.totalUsage;
859
+ return a.provider.localeCompare(b.provider);
860
+ });
861
+
862
+ for (const { provider, providerReports } of providerEntries) {
863
+ lines.push("");
864
+ const providerName = formatProviderName(provider);
865
+
866
+ const limitGroups = new Map<
867
+ string,
868
+ { label: string; windowLabel: string; limits: UsageLimit[]; reports: UsageReport[] }
869
+ >();
870
+ for (const report of providerReports) {
871
+ for (const limit of report.limits) {
872
+ const windowId = limit.window?.id ?? limit.scope.windowId ?? "default";
873
+ const key = `${formatLimitTitle(limit)}|${windowId}`;
874
+ const windowLabel = limit.window?.label ?? windowId;
875
+ const entry = limitGroups.get(key) ?? {
876
+ label: formatLimitTitle(limit),
877
+ windowLabel,
878
+ limits: [],
879
+ reports: [],
880
+ };
881
+ entry.limits.push(limit);
882
+ entry.reports.push(report);
883
+ limitGroups.set(key, entry);
884
+ }
885
+ }
886
+
887
+ const providerAllZero = isZeroUsageGroup(Array.from(limitGroups.values()).flatMap((group) => group.limits));
888
+ if (providerAllZero) {
889
+ const providerTitle = `${resolveStatusIcon("ok", uiTheme)} ${uiTheme.fg("accent", `${providerName} (0%)`)}`;
890
+ lines.push(uiTheme.bold(providerTitle));
891
+ continue;
892
+ }
893
+
894
+ lines.push(uiTheme.bold(uiTheme.fg("accent", providerName)));
895
+
896
+ for (const group of limitGroups.values()) {
897
+ const entries = group.limits.map((limit, index) => ({
898
+ limit,
899
+ report: group.reports[index],
900
+ fraction: resolveFraction(limit),
901
+ index,
902
+ }));
903
+ entries.sort((a, b) => {
904
+ const aFraction = a.fraction ?? -1;
905
+ const bFraction = b.fraction ?? -1;
906
+ if (aFraction !== bFraction) return bFraction - aFraction;
907
+ return a.index - b.index;
908
+ });
909
+ const sortedLimits = entries.map((entry) => entry.limit);
910
+ const sortedReports = entries.map((entry) => entry.report);
911
+
912
+ const status = resolveAggregateStatus(sortedLimits);
913
+ const statusIcon = resolveStatusIcon(status, uiTheme);
914
+ if (isZeroUsageGroup(sortedLimits)) {
915
+ const resetText = resolveResetRange(sortedLimits, nowMs);
916
+ const resetSuffix = resetText ? ` | ${resetText}` : "";
917
+ const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme);
918
+ lines.push(
919
+ `${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix} ${uiTheme.fg(
920
+ "dim",
921
+ `0%${resetSuffix}`,
922
+ )}`.trim(),
923
+ );
924
+ continue;
925
+ }
926
+
927
+ const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme);
928
+ lines.push(`${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix}`.trim());
929
+ const accountLabels = sortedLimits.map((limit, index) =>
930
+ padColumn(formatAccountHeader(limit, sortedReports[index], index, nowMs), COLUMN_WIDTH),
931
+ );
932
+ lines.push(` ${accountLabels.join(" ")}`.trimEnd());
933
+ const bars = sortedLimits.map((limit) => padColumn(renderUsageBar(limit, uiTheme), COLUMN_WIDTH));
934
+ lines.push(` ${bars.join(" ")} ${formatAggregateAmount(sortedLimits)}`.trimEnd());
935
+ const resetText = sortedLimits.length <= 1 ? resolveResetRange(sortedLimits, nowMs) : null;
936
+ if (resetText) {
937
+ lines.push(` ${uiTheme.fg("dim", resetText)}`.trimEnd());
938
+ }
939
+ const notes = sortedLimits.flatMap((limit) => limit.notes ?? []);
940
+ if (notes.length > 0) {
941
+ lines.push(` ${uiTheme.fg("dim", notes.join(" • "))}`.trimEnd());
942
+ }
943
+ }
944
+
945
+ // No per-provider footer; global header shows last check.
946
+ }
947
+
948
+ return lines.join("\n");
949
+ }