@diegopetrucci/pi-extensions 0.1.73 → 0.1.76

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 (93) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +9 -4
  3. package/extensions/agent-workflow-audit/index.ts +1398 -1009
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/README.md +3 -1
  6. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  7. package/extensions/annotate-git-diff/git.ts +853 -767
  8. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  9. package/extensions/annotate-git-diff/index.ts +712 -671
  10. package/extensions/annotate-git-diff/package.json +1 -1
  11. package/extensions/annotate-git-diff/prompt.ts +45 -45
  12. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  13. package/extensions/annotate-git-diff/review-server.ts +337 -311
  14. package/extensions/annotate-git-diff/types.ts +132 -132
  15. package/extensions/annotate-git-diff/ui.ts +27 -27
  16. package/extensions/annotate-git-diff/watch.ts +74 -74
  17. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  18. package/extensions/annotate-git-diff/web/index.html +5 -6
  19. package/extensions/annotate-last-message/README.md +1 -1
  20. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  21. package/extensions/annotate-last-message/index.ts +210 -206
  22. package/extensions/annotate-last-message/package.json +1 -1
  23. package/extensions/annotate-last-message/prompt.ts +87 -66
  24. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  25. package/extensions/annotate-last-message/session.ts +94 -89
  26. package/extensions/annotate-last-message/theme.ts +199 -193
  27. package/extensions/annotate-last-message/types.ts +23 -23
  28. package/extensions/annotate-last-message/ui.ts +27 -25
  29. package/extensions/annotate-last-message/web/app.js +4 -7
  30. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  31. package/extensions/brrr/README.md +1 -1
  32. package/extensions/brrr/index.ts +213 -191
  33. package/extensions/brrr/package.json +1 -1
  34. package/extensions/claude-fast/README.md +5 -79
  35. package/extensions/claude-fast/index.ts +31 -289
  36. package/extensions/claude-fast/package.json +2 -3
  37. package/extensions/code-reviewer/README.md +1 -1
  38. package/extensions/code-reviewer/index.ts +1598 -1350
  39. package/extensions/code-reviewer/package.json +1 -1
  40. package/extensions/confirm-destructive/README.md +1 -1
  41. package/extensions/confirm-destructive/index.ts +55 -51
  42. package/extensions/confirm-destructive/package.json +1 -1
  43. package/extensions/context-cap/index.ts +312 -304
  44. package/extensions/context-cap/package.json +1 -1
  45. package/extensions/context-inspector/index.ts +1211 -1064
  46. package/extensions/context-inspector/package.json +1 -1
  47. package/extensions/contrarian/index.ts +1854 -1735
  48. package/extensions/contrarian/package.json +1 -1
  49. package/extensions/dirty-repo-guard/index.ts +41 -41
  50. package/extensions/dynamic-context-pruning/README.md +6 -38
  51. package/extensions/dynamic-context-pruning/index.ts +2124 -1980
  52. package/extensions/dynamic-context-pruning/package.json +1 -1
  53. package/extensions/fast/README.md +71 -21
  54. package/extensions/fast/index.ts +513 -313
  55. package/extensions/fast/package.json +1 -1
  56. package/extensions/git-footer/README.md +1 -1
  57. package/extensions/git-footer/index.ts +458 -411
  58. package/extensions/git-footer/package.json +2 -3
  59. package/extensions/gnosis/index.ts +180 -130
  60. package/extensions/gnosis/package.json +1 -1
  61. package/extensions/inline-bash/index.ts +88 -88
  62. package/extensions/inline-bash/package.json +1 -1
  63. package/extensions/librarian/index.ts +1669 -1419
  64. package/extensions/librarian/package.json +1 -1
  65. package/extensions/minimal-footer/index.ts +994 -1012
  66. package/extensions/minimal-footer/openai-usage.ts +195 -190
  67. package/extensions/notify/index.ts +222 -230
  68. package/extensions/notify/package.json +1 -1
  69. package/extensions/openai-fast/README.md +5 -73
  70. package/extensions/openai-fast/index.ts +32 -272
  71. package/extensions/openai-fast/package.json +2 -3
  72. package/extensions/oracle/index.ts +1731 -1625
  73. package/extensions/oracle/package.json +1 -1
  74. package/extensions/permission-gate/index.ts +658 -608
  75. package/extensions/permission-gate/package.json +1 -1
  76. package/extensions/permission-gate/powershell-safety.ts +840 -793
  77. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  78. package/extensions/project-mcp-json/README.md +199 -0
  79. package/extensions/project-mcp-json/config.ts +564 -0
  80. package/extensions/project-mcp-json/index.ts +119 -0
  81. package/extensions/project-mcp-json/package.json +38 -0
  82. package/extensions/quiet-tools/index.ts +282 -264
  83. package/extensions/review/README.md +2 -2
  84. package/extensions/review/index.ts +1745 -1872
  85. package/extensions/review/package.json +1 -1
  86. package/extensions/todo/index.ts +284 -265
  87. package/extensions/triage-comments/README.md +2 -2
  88. package/extensions/triage-comments/index.ts +2294 -1882
  89. package/extensions/triage-comments/package.json +1 -1
  90. package/package.json +6 -3
  91. package/assets/social-preview.png +0 -0
  92. package/extensions/claude-fast/claude-fast.example.json +0 -4
  93. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -2,69 +2,62 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import {
5
- CONFIG_DIR_NAME,
6
- getAgentDir,
7
- type ExtensionAPI,
8
- type ExtensionContext,
5
+ CONFIG_DIR_NAME,
6
+ getAgentDir,
7
+ type ExtensionAPI,
8
+ type ExtensionContext,
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import {
12
- fetchOpenAICodexUsage,
13
- formatUsageStatus,
14
- formatUsageSummary,
15
- getOpenAICodexAccountId,
16
- isOpenAICodexProvider,
17
- isOpenAIProvider,
18
- type UsageAvailability,
19
- type UsageSnapshot,
12
+ fetchOpenAICodexUsage,
13
+ formatUsageStatus,
14
+ formatUsageSummary,
15
+ getOpenAICodexAccountId,
16
+ isOpenAICodexProvider,
17
+ isOpenAIProvider,
18
+ type UsageAvailability,
19
+ type UsageSnapshot,
20
20
  } from "./openai-usage.ts";
21
21
 
22
-
23
22
  const DEFAULT_CONFIG: MinimalFooterConfig = {
24
- context: {
25
- showPercent: true,
26
- dumbZone: {
27
- enabled: true,
28
- thresholdTokens: 200_000,
29
- label: "DUMB ZONE",
30
- color: "error",
31
- },
32
- },
33
- codexUsage: {
34
- enabled: true,
35
- cacheTtlMs: 5 * 60 * 1000,
36
- requestTimeoutMs: 10 * 1000,
37
- windows: {
38
- primary: {
39
- enabled: true,
40
- label: "auto",
41
- },
42
- secondary: {
43
- enabled: true,
44
- label: "auto",
45
- },
46
- },
47
- },
48
- experimentalMarker: {
49
- enabled: true,
50
- label: "xp",
51
- color: "warning",
52
- },
53
- gitStatus: {
54
- enabled: true,
55
- refreshIntervalMs: 8_000,
56
- gitTimeoutMs: 1_500,
57
- ghTimeoutMs: 3_000,
58
- },
23
+ context: {
24
+ showPercent: true,
25
+ dumbZone: {
26
+ enabled: true,
27
+ thresholdTokens: 200_000,
28
+ label: "DUMB ZONE",
29
+ color: "error",
30
+ },
31
+ },
32
+ codexUsage: {
33
+ enabled: true,
34
+ cacheTtlMs: 5 * 60 * 1000,
35
+ requestTimeoutMs: 10 * 1000,
36
+ windows: {
37
+ primary: {
38
+ enabled: true,
39
+ label: "auto",
40
+ },
41
+ secondary: {
42
+ enabled: true,
43
+ label: "auto",
44
+ },
45
+ },
46
+ },
47
+ experimentalMarker: {
48
+ enabled: true,
49
+ label: "xp",
50
+ color: "warning",
51
+ },
52
+ gitStatus: {
53
+ enabled: true,
54
+ refreshIntervalMs: 8_000,
55
+ gitTimeoutMs: 1_500,
56
+ ghTimeoutMs: 3_000,
57
+ },
59
58
  };
60
59
 
61
- const DUMB_ZONE_COLORS = new Set<DumbZoneColor>([
62
- "error",
63
- "warning",
64
- "accent",
65
- "text",
66
- "dim",
67
- ]);
60
+ const DUMB_ZONE_COLORS = new Set<DumbZoneColor>(["error", "warning", "accent", "text", "dim"]);
68
61
 
69
62
  // Node treats delays above this value as 1ms. Clamp only the identity-check
70
63
  // schedule; the configured cache TTL remains the source of truth for lifecycle
@@ -73,808 +66,798 @@ const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647;
73
66
  const ZERO_CACHE_IDENTITY_CHECK_INTERVAL_MS = 5 * 60 * 1000;
74
67
 
75
68
  type RecursivePartial<T> = {
76
- [P in keyof T]?: T[P] extends object ? RecursivePartial<T[P]> : T[P];
69
+ [P in keyof T]?: T[P] extends object ? RecursivePartial<T[P]> : T[P];
77
70
  };
78
71
 
79
72
  type DumbZoneColor = "error" | "warning" | "accent" | "text" | "dim";
80
73
 
81
74
  interface MinimalFooterConfig {
82
- context: {
83
- showPercent: boolean;
84
- dumbZone: {
85
- enabled: boolean;
86
- thresholdTokens: number;
87
- label: string;
88
- color: DumbZoneColor;
89
- };
90
- };
91
- codexUsage: {
92
- enabled: boolean;
93
- cacheTtlMs: number;
94
- requestTimeoutMs: number;
95
- windows: {
96
- primary: {
97
- enabled: boolean;
98
- label: string;
99
- };
100
- secondary: {
101
- enabled: boolean;
102
- label: string;
103
- };
104
- };
105
- };
106
- experimentalMarker: {
107
- enabled: boolean;
108
- label: string;
109
- color: DumbZoneColor;
110
- };
111
- gitStatus: {
112
- enabled: boolean;
113
- refreshIntervalMs: number;
114
- gitTimeoutMs: number;
115
- ghTimeoutMs: number;
116
- };
75
+ context: {
76
+ showPercent: boolean;
77
+ dumbZone: {
78
+ enabled: boolean;
79
+ thresholdTokens: number;
80
+ label: string;
81
+ color: DumbZoneColor;
82
+ };
83
+ };
84
+ codexUsage: {
85
+ enabled: boolean;
86
+ cacheTtlMs: number;
87
+ requestTimeoutMs: number;
88
+ windows: {
89
+ primary: {
90
+ enabled: boolean;
91
+ label: string;
92
+ };
93
+ secondary: {
94
+ enabled: boolean;
95
+ label: string;
96
+ };
97
+ };
98
+ };
99
+ experimentalMarker: {
100
+ enabled: boolean;
101
+ label: string;
102
+ color: DumbZoneColor;
103
+ };
104
+ gitStatus: {
105
+ enabled: boolean;
106
+ refreshIntervalMs: number;
107
+ gitTimeoutMs: number;
108
+ ghTimeoutMs: number;
109
+ };
117
110
  }
118
111
 
119
112
  type UsageIdentity = {
120
- renderKey: string;
121
- usageKey: string;
122
- oauth: boolean;
123
- accountId?: string;
113
+ renderKey: string;
114
+ usageKey: string;
115
+ oauth: boolean;
116
+ accountId?: string;
124
117
  };
125
118
 
126
119
  type UsageSessionState = {
127
- config: MinimalFooterConfig;
128
- snapshot?: UsageSnapshot;
129
- lastFetchedAt?: number;
130
- loading: boolean;
131
- error?: string;
132
- availability?: UsageAvailability;
133
- usageIdentity?: UsageIdentity;
134
- requestVersion: number;
135
- disposed: boolean;
136
- inflight?: Promise<void>;
137
- requestRender?: () => void;
138
- usageRefreshTimer?: TimerHandle;
139
- gitCache?: GitFooterCache;
120
+ config: MinimalFooterConfig;
121
+ snapshot?: UsageSnapshot;
122
+ lastFetchedAt?: number;
123
+ loading: boolean;
124
+ error?: string;
125
+ availability?: UsageAvailability;
126
+ usageIdentity?: UsageIdentity;
127
+ requestVersion: number;
128
+ disposed: boolean;
129
+ inflight?: Promise<void>;
130
+ requestRender?: () => void;
131
+ usageRefreshTimer?: TimerHandle;
132
+ gitCache?: GitFooterCache;
140
133
  };
141
134
 
142
135
  type ProjectConfigContext = {
143
- cwd: string;
144
- isProjectTrusted?: () => boolean;
136
+ cwd: string;
137
+ isProjectTrusted?: () => boolean;
145
138
  };
146
139
 
147
140
  type GitStatusSnapshot = {
148
- branch?: string;
149
- staged: number;
150
- unstaged: number;
151
- untracked: number;
152
- conflict: number;
153
- ahead: number;
154
- behind: number;
141
+ branch?: string;
142
+ staged: number;
143
+ unstaged: number;
144
+ untracked: number;
145
+ conflict: number;
146
+ ahead: number;
147
+ behind: number;
155
148
  };
156
149
 
157
150
  type PullRequestSnapshot = {
158
- number?: number | string;
159
- state?: string;
160
- isDraft?: boolean;
161
- url?: string;
162
- title?: string;
151
+ number?: number | string;
152
+ state?: string;
153
+ isDraft?: boolean;
154
+ url?: string;
155
+ title?: string;
163
156
  };
164
157
 
165
158
  type CommandResult = {
166
- stdout: string;
167
- stderr: string;
168
- exitCode: number | null;
159
+ stdout: string;
160
+ stderr: string;
161
+ exitCode: number | null;
169
162
  };
170
163
 
171
164
  type CommandRunner = (
172
- command: string,
173
- args: readonly string[],
174
- options: { cwd: string; signal: AbortSignal },
165
+ command: string,
166
+ args: readonly string[],
167
+ options: { cwd: string; signal: AbortSignal },
175
168
  ) => Promise<CommandResult>;
176
169
 
177
170
  type RunCommandSafelyResult =
178
- | { kind: "ok"; result: CommandResult }
179
- | { kind: "transient" }
180
- | { kind: "unavailable" };
171
+ | { kind: "ok"; result: CommandResult }
172
+ | { kind: "transient" }
173
+ | { kind: "unavailable" };
181
174
 
182
175
  type TimerHandle = unknown;
183
176
 
184
177
  type Clock = {
185
- setInterval(callback: () => void, ms: number): TimerHandle;
186
- clearInterval(handle: TimerHandle): void;
178
+ setInterval(callback: () => void, ms: number): TimerHandle;
179
+ clearInterval(handle: TimerHandle): void;
187
180
  };
188
181
 
189
182
  type GitFooterCacheOptions = {
190
- cwd: () => string;
191
- canRun?: () => boolean;
192
- runner?: CommandRunner;
193
- clock?: Clock;
194
- refreshIntervalMs?: number;
195
- gitTimeoutMs?: number;
196
- ghTimeoutMs?: number;
197
- onChange?: () => void;
183
+ cwd: () => string;
184
+ canRun?: () => boolean;
185
+ runner?: CommandRunner;
186
+ clock?: Clock;
187
+ refreshIntervalMs?: number;
188
+ gitTimeoutMs?: number;
189
+ ghTimeoutMs?: number;
190
+ onChange?: () => void;
198
191
  };
199
192
 
200
193
  const BRANCH_HEAD_PREFIX = "# branch.head ";
201
194
  const BRANCH_AB_PREFIX = "# branch.ab ";
202
195
  const STATUS_SEPARATOR = " • ";
203
196
  const GIT_STATUS_ARGS = [
204
- "--no-optional-locks",
205
- "-c",
206
- "core.fsmonitor=false",
207
- "status",
208
- "--porcelain=v2",
209
- "--branch",
197
+ "--no-optional-locks",
198
+ "-c",
199
+ "core.fsmonitor=false",
200
+ "status",
201
+ "--porcelain=v2",
202
+ "--branch",
210
203
  ] as const;
211
204
  const GH_PR_VIEW_ARGS = ["pr", "view", "--json", "number,state,isDraft,url,title"] as const;
212
205
 
213
206
  function readConfigFile(path: string): RecursivePartial<MinimalFooterConfig> {
214
- if (!existsSync(path)) return {};
215
-
216
- try {
217
- return JSON.parse(readFileSync(path, "utf-8")) as RecursivePartial<MinimalFooterConfig>;
218
- } catch (error) {
219
- console.error(`Warning: Could not parse ${path}: ${error}`);
220
- return {};
221
- }
207
+ if (!existsSync(path)) return {};
208
+
209
+ try {
210
+ return JSON.parse(readFileSync(path, "utf-8")) as RecursivePartial<MinimalFooterConfig>;
211
+ } catch (error) {
212
+ console.error(`Warning: Could not parse ${path}: ${error}`);
213
+ return {};
214
+ }
222
215
  }
223
216
 
224
217
  function mergeConfig(
225
- base: MinimalFooterConfig,
226
- overrides: RecursivePartial<MinimalFooterConfig>,
218
+ base: MinimalFooterConfig,
219
+ overrides: RecursivePartial<MinimalFooterConfig>,
227
220
  ): MinimalFooterConfig {
228
- const context = overrides.context;
229
- const dumbZone = context?.dumbZone;
230
- const codexUsage = overrides.codexUsage;
231
- const primaryWindow = codexUsage?.windows?.primary;
232
- const secondaryWindow = codexUsage?.windows?.secondary;
233
- const experimentalMarker = overrides.experimentalMarker;
234
- const gitStatus = overrides.gitStatus;
235
-
236
- return {
237
- context: {
238
- showPercent: normalizeBoolean(context?.showPercent, base.context.showPercent),
239
- dumbZone: {
240
- enabled: normalizeBoolean(dumbZone?.enabled, base.context.dumbZone.enabled),
241
- thresholdTokens: normalizeNonNegativeNumber(
242
- dumbZone?.thresholdTokens,
243
- base.context.dumbZone.thresholdTokens,
244
- ),
245
- label: normalizeLabel(dumbZone?.label, base.context.dumbZone.label),
246
- color: normalizeDumbZoneColor(dumbZone?.color, base.context.dumbZone.color),
247
- },
248
- },
249
- codexUsage: {
250
- enabled: normalizeBoolean(codexUsage?.enabled, base.codexUsage.enabled),
251
- cacheTtlMs: normalizeNonNegativeNumber(
252
- codexUsage?.cacheTtlMs,
253
- base.codexUsage.cacheTtlMs,
254
- ),
255
- requestTimeoutMs: normalizePositiveNumber(
256
- codexUsage?.requestTimeoutMs,
257
- base.codexUsage.requestTimeoutMs,
258
- ),
259
- windows: {
260
- primary: {
261
- enabled: normalizeBoolean(
262
- primaryWindow?.enabled,
263
- base.codexUsage.windows.primary.enabled,
264
- ),
265
- label: normalizeLabel(primaryWindow?.label, base.codexUsage.windows.primary.label),
266
- },
267
- secondary: {
268
- enabled: normalizeBoolean(
269
- secondaryWindow?.enabled,
270
- base.codexUsage.windows.secondary.enabled,
271
- ),
272
- label: normalizeLabel(secondaryWindow?.label, base.codexUsage.windows.secondary.label),
273
- },
274
- },
275
- },
276
- experimentalMarker: {
277
- enabled: normalizeBoolean(
278
- experimentalMarker?.enabled,
279
- base.experimentalMarker.enabled,
280
- ),
281
- label: normalizeLabel(experimentalMarker?.label, base.experimentalMarker.label),
282
- color: normalizeDumbZoneColor(
283
- experimentalMarker?.color,
284
- base.experimentalMarker.color,
285
- ),
286
- },
287
- gitStatus: {
288
- enabled: normalizeBoolean(gitStatus?.enabled, base.gitStatus.enabled),
289
- refreshIntervalMs: normalizePositiveNumber(
290
- gitStatus?.refreshIntervalMs,
291
- base.gitStatus.refreshIntervalMs,
292
- ),
293
- gitTimeoutMs: normalizePositiveNumber(
294
- gitStatus?.gitTimeoutMs,
295
- base.gitStatus.gitTimeoutMs,
296
- ),
297
- ghTimeoutMs: normalizePositiveNumber(
298
- gitStatus?.ghTimeoutMs,
299
- base.gitStatus.ghTimeoutMs,
300
- ),
301
- },
302
- };
221
+ const context = overrides.context;
222
+ const dumbZone = context?.dumbZone;
223
+ const codexUsage = overrides.codexUsage;
224
+ const primaryWindow = codexUsage?.windows?.primary;
225
+ const secondaryWindow = codexUsage?.windows?.secondary;
226
+ const experimentalMarker = overrides.experimentalMarker;
227
+ const gitStatus = overrides.gitStatus;
228
+
229
+ return {
230
+ context: {
231
+ showPercent: normalizeBoolean(context?.showPercent, base.context.showPercent),
232
+ dumbZone: {
233
+ enabled: normalizeBoolean(dumbZone?.enabled, base.context.dumbZone.enabled),
234
+ thresholdTokens: normalizeNonNegativeNumber(
235
+ dumbZone?.thresholdTokens,
236
+ base.context.dumbZone.thresholdTokens,
237
+ ),
238
+ label: normalizeLabel(dumbZone?.label, base.context.dumbZone.label),
239
+ color: normalizeDumbZoneColor(dumbZone?.color, base.context.dumbZone.color),
240
+ },
241
+ },
242
+ codexUsage: {
243
+ enabled: normalizeBoolean(codexUsage?.enabled, base.codexUsage.enabled),
244
+ cacheTtlMs: normalizeNonNegativeNumber(codexUsage?.cacheTtlMs, base.codexUsage.cacheTtlMs),
245
+ requestTimeoutMs: normalizePositiveNumber(
246
+ codexUsage?.requestTimeoutMs,
247
+ base.codexUsage.requestTimeoutMs,
248
+ ),
249
+ windows: {
250
+ primary: {
251
+ enabled: normalizeBoolean(
252
+ primaryWindow?.enabled,
253
+ base.codexUsage.windows.primary.enabled,
254
+ ),
255
+ label: normalizeLabel(primaryWindow?.label, base.codexUsage.windows.primary.label),
256
+ },
257
+ secondary: {
258
+ enabled: normalizeBoolean(
259
+ secondaryWindow?.enabled,
260
+ base.codexUsage.windows.secondary.enabled,
261
+ ),
262
+ label: normalizeLabel(secondaryWindow?.label, base.codexUsage.windows.secondary.label),
263
+ },
264
+ },
265
+ },
266
+ experimentalMarker: {
267
+ enabled: normalizeBoolean(experimentalMarker?.enabled, base.experimentalMarker.enabled),
268
+ label: normalizeLabel(experimentalMarker?.label, base.experimentalMarker.label),
269
+ color: normalizeDumbZoneColor(experimentalMarker?.color, base.experimentalMarker.color),
270
+ },
271
+ gitStatus: {
272
+ enabled: normalizeBoolean(gitStatus?.enabled, base.gitStatus.enabled),
273
+ refreshIntervalMs: normalizePositiveNumber(
274
+ gitStatus?.refreshIntervalMs,
275
+ base.gitStatus.refreshIntervalMs,
276
+ ),
277
+ gitTimeoutMs: normalizePositiveNumber(gitStatus?.gitTimeoutMs, base.gitStatus.gitTimeoutMs),
278
+ ghTimeoutMs: normalizePositiveNumber(gitStatus?.ghTimeoutMs, base.gitStatus.ghTimeoutMs),
279
+ },
280
+ };
303
281
  }
304
282
 
305
283
  function normalizeBoolean(value: unknown, fallback: boolean): boolean {
306
- return typeof value === "boolean" ? value : fallback;
284
+ return typeof value === "boolean" ? value : fallback;
307
285
  }
308
286
 
309
287
  function normalizeNonNegativeNumber(value: unknown, fallback: number): number {
310
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
288
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
311
289
  }
312
290
 
313
291
  function normalizePositiveNumber(value: unknown, fallback: number): number {
314
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
292
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
315
293
  }
316
294
 
317
295
  function normalizeLabel(value: unknown, fallback: string): string {
318
- return typeof value === "string" && value.trim() ? value.trim() : fallback;
296
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
319
297
  }
320
298
 
321
299
  function normalizeDumbZoneColor(value: unknown, fallback: DumbZoneColor): DumbZoneColor {
322
- return DUMB_ZONE_COLORS.has(value as DumbZoneColor) ? (value as DumbZoneColor) : fallback;
300
+ return DUMB_ZONE_COLORS.has(value as DumbZoneColor) ? (value as DumbZoneColor) : fallback;
323
301
  }
324
302
 
325
303
  function canReadProjectConfig(ctx: ProjectConfigContext): boolean {
326
- return typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted();
304
+ return typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted();
327
305
  }
328
306
 
329
307
  function findProjectConfigPath(cwd: string): string {
330
- let current = cwd;
331
- while (true) {
332
- const candidate = join(current, CONFIG_DIR_NAME, "minimal-footer.json");
333
- if (existsSync(candidate)) return candidate;
334
-
335
- const parent = dirname(current);
336
- if (parent === current) return join(cwd, CONFIG_DIR_NAME, "minimal-footer.json");
337
- current = parent;
338
- }
308
+ let current = cwd;
309
+ while (true) {
310
+ const candidate = join(current, CONFIG_DIR_NAME, "minimal-footer.json");
311
+ if (existsSync(candidate)) return candidate;
312
+
313
+ const parent = dirname(current);
314
+ if (parent === current) return join(cwd, CONFIG_DIR_NAME, "minimal-footer.json");
315
+ current = parent;
316
+ }
339
317
  }
340
318
 
341
319
  function loadConfig(ctx: ProjectConfigContext): MinimalFooterConfig {
342
- const globalConfig = readConfigFile(join(getAgentDir(), "extensions", "minimal-footer.json"));
343
- const projectConfig = canReadProjectConfig(ctx) ? readConfigFile(findProjectConfigPath(ctx.cwd)) : {};
344
- return mergeConfig(mergeConfig(DEFAULT_CONFIG, globalConfig), projectConfig);
320
+ const globalConfig = readConfigFile(join(getAgentDir(), "extensions", "minimal-footer.json"));
321
+ const projectConfig = canReadProjectConfig(ctx)
322
+ ? readConfigFile(findProjectConfigPath(ctx.cwd))
323
+ : {};
324
+ return mergeConfig(mergeConfig(DEFAULT_CONFIG, globalConfig), projectConfig);
345
325
  }
346
326
 
347
327
  function shouldShowCodexUsage(config: MinimalFooterConfig): boolean {
348
- return (
349
- config.codexUsage.enabled &&
350
- (config.codexUsage.windows.primary.enabled || config.codexUsage.windows.secondary.enabled)
351
- );
328
+ return (
329
+ config.codexUsage.enabled &&
330
+ (config.codexUsage.windows.primary.enabled || config.codexUsage.windows.secondary.enabled)
331
+ );
352
332
  }
353
333
 
354
334
  function shouldShowExperimentalMarker(config: MinimalFooterConfig): boolean {
355
- return config.experimentalMarker.enabled && process.env.PI_EXPERIMENTAL === "1";
335
+ return config.experimentalMarker.enabled && process.env.PI_EXPERIMENTAL === "1";
356
336
  }
357
337
 
358
338
  function shouldShowGitStatus(config: MinimalFooterConfig): boolean {
359
- return config.gitStatus.enabled;
339
+ return config.gitStatus.enabled;
360
340
  }
361
341
 
362
342
  function canRunProjectGit(ctx: ProjectConfigContext): boolean {
363
- return canReadProjectConfig(ctx);
343
+ return canReadProjectConfig(ctx);
364
344
  }
365
345
 
366
346
  function createEmptyGitStatus(): GitStatusSnapshot {
367
- return {
368
- branch: undefined,
369
- staged: 0,
370
- unstaged: 0,
371
- untracked: 0,
372
- conflict: 0,
373
- ahead: 0,
374
- behind: 0,
375
- };
347
+ return {
348
+ branch: undefined,
349
+ staged: 0,
350
+ unstaged: 0,
351
+ untracked: 0,
352
+ conflict: 0,
353
+ ahead: 0,
354
+ behind: 0,
355
+ };
376
356
  }
377
357
 
378
358
  function positiveCount(value: number): number {
379
- return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
359
+ return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
380
360
  }
381
361
 
382
362
  function addTrackedStatusCounts(status: GitStatusSnapshot, xy: string): void {
383
- if (xy.length !== 2) return;
384
- if (xy[0] !== ".") status.staged += 1;
385
- if (xy[1] !== ".") status.unstaged += 1;
363
+ if (xy.length !== 2) return;
364
+ if (xy[0] !== ".") status.staged += 1;
365
+ if (xy[1] !== ".") status.unstaged += 1;
386
366
  }
387
367
 
388
368
  function parseBranchAheadBehind(line: string, status: GitStatusSnapshot): void {
389
- const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
390
- if (!match) return;
391
- status.ahead = Number.parseInt(match[1]!, 10);
392
- status.behind = Number.parseInt(match[2]!, 10);
369
+ const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
370
+ if (!match) return;
371
+ status.ahead = Number.parseInt(match[1]!, 10);
372
+ status.behind = Number.parseInt(match[2]!, 10);
393
373
  }
394
374
 
395
375
  function normalizeBranchHead(value: string): string {
396
- const branch = value.trim();
397
- return branch === "(detached)" ? "detached" : branch;
376
+ const branch = value.trim();
377
+ return branch === "(detached)" ? "detached" : branch;
398
378
  }
399
379
 
400
380
  function parseGitStatusPorcelainV2(output: string): GitStatusSnapshot {
401
- const status = createEmptyGitStatus();
381
+ const status = createEmptyGitStatus();
402
382
 
403
- for (const rawLine of output.split("\n")) {
404
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
405
- if (!line) continue;
383
+ for (const rawLine of output.split("\n")) {
384
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
385
+ if (!line) continue;
406
386
 
407
- if (line.startsWith(BRANCH_HEAD_PREFIX)) {
408
- status.branch = normalizeBranchHead(line.slice(BRANCH_HEAD_PREFIX.length)) || undefined;
409
- continue;
410
- }
387
+ if (line.startsWith(BRANCH_HEAD_PREFIX)) {
388
+ status.branch = normalizeBranchHead(line.slice(BRANCH_HEAD_PREFIX.length)) || undefined;
389
+ continue;
390
+ }
411
391
 
412
- if (line.startsWith(BRANCH_AB_PREFIX)) {
413
- parseBranchAheadBehind(line, status);
414
- continue;
415
- }
392
+ if (line.startsWith(BRANCH_AB_PREFIX)) {
393
+ parseBranchAheadBehind(line, status);
394
+ continue;
395
+ }
416
396
 
417
- if (line.startsWith("1 ") || line.startsWith("2 ")) {
418
- addTrackedStatusCounts(status, line.slice(2, 4));
419
- continue;
420
- }
397
+ if (line.startsWith("1 ") || line.startsWith("2 ")) {
398
+ addTrackedStatusCounts(status, line.slice(2, 4));
399
+ continue;
400
+ }
421
401
 
422
- if (line.startsWith("u ")) {
423
- status.conflict += 1;
424
- continue;
425
- }
402
+ if (line.startsWith("u ")) {
403
+ status.conflict += 1;
404
+ continue;
405
+ }
426
406
 
427
- if (line.startsWith("? ")) status.untracked += 1;
428
- }
407
+ if (line.startsWith("? ")) status.untracked += 1;
408
+ }
429
409
 
430
- return status;
410
+ return status;
431
411
  }
432
412
 
433
413
  function formatGitStatusFooterSegment(status: GitStatusSnapshot | undefined): string | undefined {
434
- if (!status) return undefined;
435
-
436
- const parts: string[] = [];
437
- const indicators: Array<[string, number]> = [
438
- ["!", positiveCount(status.conflict)],
439
- ["+", positiveCount(status.staged)],
440
- ["~", positiveCount(status.unstaged)],
441
- ["?", positiveCount(status.untracked)],
442
- ["↑", positiveCount(status.ahead)],
443
- ["↓", positiveCount(status.behind)],
444
- ];
445
-
446
- for (const [prefix, count] of indicators) {
447
- if (count > 0) parts.push(`${prefix}${count}`);
448
- }
449
-
450
- return parts.length > 0 ? parts.join(" ") : undefined;
414
+ if (!status) return undefined;
415
+
416
+ const parts: string[] = [];
417
+ const indicators: Array<[string, number]> = [
418
+ ["!", positiveCount(status.conflict)],
419
+ ["+", positiveCount(status.staged)],
420
+ ["~", positiveCount(status.unstaged)],
421
+ ["?", positiveCount(status.untracked)],
422
+ ["↑", positiveCount(status.ahead)],
423
+ ["↓", positiveCount(status.behind)],
424
+ ];
425
+
426
+ for (const [prefix, count] of indicators) {
427
+ if (count > 0) parts.push(`${prefix}${count}`);
428
+ }
429
+
430
+ return parts.length > 0 ? parts.join(" ") : undefined;
451
431
  }
452
432
 
453
- function formatPullRequestFooterSegment(pullRequest: PullRequestSnapshot | undefined): string | undefined {
454
- const value = pullRequest?.number;
455
- if (Number.isSafeInteger(value) && Number(value) > 0) return `PR #${value}`;
456
- if (typeof value === "string") {
457
- const trimmed = value.trim();
458
- if (/^[1-9]\d*$/.test(trimmed)) return `PR #${trimmed}`;
459
- }
460
- return undefined;
433
+ function formatPullRequestFooterSegment(
434
+ pullRequest: PullRequestSnapshot | undefined,
435
+ ): string | undefined {
436
+ const value = pullRequest?.number;
437
+ if (Number.isSafeInteger(value) && Number(value) > 0) return `PR #${value}`;
438
+ if (typeof value === "string") {
439
+ const trimmed = value.trim();
440
+ if (/^[1-9]\d*$/.test(trimmed)) return `PR #${trimmed}`;
441
+ }
442
+ return undefined;
461
443
  }
462
444
 
463
445
  function formatGitFooterStatus(
464
- status: GitStatusSnapshot | undefined,
465
- pullRequest: PullRequestSnapshot | undefined,
446
+ status: GitStatusSnapshot | undefined,
447
+ pullRequest: PullRequestSnapshot | undefined,
466
448
  ): string | undefined {
467
- const parts = [
468
- formatGitStatusFooterSegment(status),
469
- formatPullRequestFooterSegment(pullRequest),
470
- ].filter((part): part is string => !!part);
471
- return parts.length > 0 ? parts.join(STATUS_SEPARATOR) : undefined;
449
+ const parts = [
450
+ formatGitStatusFooterSegment(status),
451
+ formatPullRequestFooterSegment(pullRequest),
452
+ ].filter((part): part is string => !!part);
453
+ return parts.length > 0 ? parts.join(STATUS_SEPARATOR) : undefined;
472
454
  }
473
455
 
474
456
  function parsePullRequestJson(stdout: string): PullRequestSnapshot | undefined {
475
- const trimmed = stdout.trim();
476
- if (!trimmed) return undefined;
477
- let parsed: unknown;
478
- try {
479
- parsed = JSON.parse(trimmed);
480
- } catch {
481
- return undefined;
482
- }
483
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
484
-
485
- const record = parsed as Record<string, unknown>;
486
- const snapshot: PullRequestSnapshot = {};
487
- if (typeof record.number === "number" || typeof record.number === "string") {
488
- snapshot.number = record.number;
489
- }
490
- if (typeof record.state === "string") snapshot.state = record.state;
491
- if (typeof record.isDraft === "boolean") snapshot.isDraft = record.isDraft;
492
- if (typeof record.url === "string") snapshot.url = record.url;
493
- if (typeof record.title === "string") snapshot.title = record.title;
494
- return snapshot;
457
+ const trimmed = stdout.trim();
458
+ if (!trimmed) return undefined;
459
+ let parsed: unknown;
460
+ try {
461
+ parsed = JSON.parse(trimmed);
462
+ } catch {
463
+ return undefined;
464
+ }
465
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
466
+
467
+ const record = parsed as Record<string, unknown>;
468
+ const snapshot: PullRequestSnapshot = {};
469
+ if (typeof record.number === "number" || typeof record.number === "string") {
470
+ snapshot.number = record.number;
471
+ }
472
+ if (typeof record.state === "string") snapshot.state = record.state;
473
+ if (typeof record.isDraft === "boolean") snapshot.isDraft = record.isDraft;
474
+ if (typeof record.url === "string") snapshot.url = record.url;
475
+ if (typeof record.title === "string") snapshot.title = record.title;
476
+ return snapshot;
495
477
  }
496
478
 
497
479
  function gitStatusSnapshotsEqual(
498
- left: GitStatusSnapshot | undefined,
499
- right: GitStatusSnapshot | undefined,
480
+ left: GitStatusSnapshot | undefined,
481
+ right: GitStatusSnapshot | undefined,
500
482
  ): boolean {
501
- return (
502
- left?.branch === right?.branch
503
- && left?.staged === right?.staged
504
- && left?.unstaged === right?.unstaged
505
- && left?.untracked === right?.untracked
506
- && left?.conflict === right?.conflict
507
- && left?.ahead === right?.ahead
508
- && left?.behind === right?.behind
509
- );
483
+ return (
484
+ left?.branch === right?.branch &&
485
+ left?.staged === right?.staged &&
486
+ left?.unstaged === right?.unstaged &&
487
+ left?.untracked === right?.untracked &&
488
+ left?.conflict === right?.conflict &&
489
+ left?.ahead === right?.ahead &&
490
+ left?.behind === right?.behind
491
+ );
510
492
  }
511
493
 
512
494
  function pullRequestSnapshotsEqual(
513
- left: PullRequestSnapshot | undefined,
514
- right: PullRequestSnapshot | undefined,
495
+ left: PullRequestSnapshot | undefined,
496
+ right: PullRequestSnapshot | undefined,
515
497
  ): boolean {
516
- return (
517
- left?.number === right?.number
518
- && left?.state === right?.state
519
- && left?.isDraft === right?.isDraft
520
- && left?.url === right?.url
521
- && left?.title === right?.title
522
- );
498
+ return (
499
+ left?.number === right?.number &&
500
+ left?.state === right?.state &&
501
+ left?.isDraft === right?.isDraft &&
502
+ left?.url === right?.url &&
503
+ left?.title === right?.title
504
+ );
523
505
  }
524
506
 
525
507
  function defaultRunner(
526
- command: string,
527
- args: readonly string[],
528
- options: { cwd: string; signal: AbortSignal },
508
+ command: string,
509
+ args: readonly string[],
510
+ options: { cwd: string; signal: AbortSignal },
529
511
  ): Promise<CommandResult> {
530
- return new Promise((resolve, reject) => {
531
- let child;
532
- try {
533
- child = spawn(command, [...args], {
534
- cwd: options.cwd,
535
- stdio: ["ignore", "pipe", "pipe"],
536
- windowsHide: true,
537
- });
538
- } catch (error) {
539
- reject(error);
540
- return;
541
- }
542
-
543
- let stdout = "";
544
- let stderr = "";
545
- let settled = false;
546
-
547
- const finish = (result: CommandResult | Error) => {
548
- if (settled) return;
549
- settled = true;
550
- options.signal.removeEventListener("abort", onAbort);
551
- if (result instanceof Error) reject(result);
552
- else resolve(result);
553
- };
554
-
555
- const onAbort = () => {
556
- try {
557
- child.kill("SIGTERM");
558
- } catch {
559
- // Ignore: process may already be gone.
560
- }
561
- finish(new Error("aborted"));
562
- };
563
-
564
- child.stdout?.on("data", (chunk: Buffer | string) => {
565
- stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
566
- });
567
- child.stderr?.on("data", (chunk: Buffer | string) => {
568
- stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
569
- });
570
- child.on("error", finish);
571
- child.on("close", (code) => finish({ stdout, stderr, exitCode: code }));
572
-
573
- if (options.signal.aborted) {
574
- onAbort();
575
- return;
576
- }
577
- options.signal.addEventListener("abort", onAbort, { once: true });
578
- });
512
+ return new Promise((resolve, reject) => {
513
+ let child;
514
+ try {
515
+ child = spawn(command, [...args], {
516
+ cwd: options.cwd,
517
+ stdio: ["ignore", "pipe", "pipe"],
518
+ windowsHide: true,
519
+ });
520
+ } catch (error) {
521
+ reject(error);
522
+ return;
523
+ }
524
+
525
+ let stdout = "";
526
+ let stderr = "";
527
+ let settled = false;
528
+
529
+ const finish = (result: CommandResult | Error) => {
530
+ if (settled) return;
531
+ settled = true;
532
+ options.signal.removeEventListener("abort", onAbort);
533
+ if (result instanceof Error) reject(result);
534
+ else resolve(result);
535
+ };
536
+
537
+ const onAbort = () => {
538
+ try {
539
+ child.kill("SIGTERM");
540
+ } catch {
541
+ // Ignore: process may already be gone.
542
+ }
543
+ finish(new Error("aborted"));
544
+ };
545
+
546
+ child.stdout?.on("data", (chunk: Buffer | string) => {
547
+ stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
548
+ });
549
+ child.stderr?.on("data", (chunk: Buffer | string) => {
550
+ stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
551
+ });
552
+ child.on("error", finish);
553
+ child.on("close", (code) => finish({ stdout, stderr, exitCode: code }));
554
+
555
+ if (options.signal.aborted) {
556
+ onAbort();
557
+ return;
558
+ }
559
+ options.signal.addEventListener("abort", onAbort, { once: true });
560
+ });
579
561
  }
580
562
 
581
563
  function defaultClock(): Clock {
582
- return {
583
- setInterval(callback, ms) {
584
- const handle = setInterval(callback, ms);
585
- (handle as { unref?: () => void }).unref?.();
586
- return handle;
587
- },
588
- clearInterval(handle) {
589
- clearInterval(handle as ReturnType<typeof setInterval>);
590
- },
591
- };
564
+ return {
565
+ setInterval(callback, ms) {
566
+ const handle = setInterval(callback, ms);
567
+ (handle as { unref?: () => void }).unref?.();
568
+ return handle;
569
+ },
570
+ clearInterval(handle) {
571
+ clearInterval(handle as ReturnType<typeof setInterval>);
572
+ },
573
+ };
592
574
  }
593
575
 
594
576
  function isCommandUnavailableError(error: unknown): boolean {
595
- return !!error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT";
577
+ return !!error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT";
596
578
  }
597
579
 
598
580
  class GitFooterCache {
599
- private readonly cwd: () => string;
600
- private readonly canRun: () => boolean;
601
- private readonly runner: CommandRunner;
602
- private readonly clock: Clock;
603
- private readonly refreshIntervalMs: number;
604
- private readonly gitTimeoutMs: number;
605
- private readonly ghTimeoutMs: number;
606
- private readonly onChange: (() => void) | undefined;
607
-
608
- private intervalHandle: TimerHandle | undefined;
609
- private readonly inflightControllers = new Set<AbortController>();
610
- private disposed = false;
611
- private refreshInFlight: Promise<void> | undefined;
612
- private statusSnapshot: GitStatusSnapshot | undefined;
613
- private pullRequestSnapshot: PullRequestSnapshot | undefined;
614
- private lastSeenBranch: string | undefined;
615
-
616
- constructor(options: GitFooterCacheOptions) {
617
- this.cwd = options.cwd;
618
- this.canRun = options.canRun ?? (() => true);
619
- this.runner = options.runner ?? defaultRunner;
620
- this.clock = options.clock ?? defaultClock();
621
- this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_CONFIG.gitStatus.refreshIntervalMs;
622
- this.gitTimeoutMs = options.gitTimeoutMs ?? DEFAULT_CONFIG.gitStatus.gitTimeoutMs;
623
- this.ghTimeoutMs = options.ghTimeoutMs ?? DEFAULT_CONFIG.gitStatus.ghTimeoutMs;
624
- this.onChange = options.onChange;
625
-
626
- this.intervalHandle = this.clock.setInterval(() => {
627
- void this.refresh();
628
- }, this.refreshIntervalMs);
629
- void this.refresh();
630
- }
631
-
632
- getStatusSnapshot(): GitStatusSnapshot | undefined {
633
- return this.statusSnapshot;
634
- }
635
-
636
- getPullRequestSnapshot(): PullRequestSnapshot | undefined {
637
- return this.pullRequestSnapshot;
638
- }
639
-
640
- refresh(): Promise<void> {
641
- if (this.disposed) return Promise.resolve();
642
- if (!this.canRun()) {
643
- const previousStatusSnapshot = this.statusSnapshot;
644
- const previousPullRequestSnapshot = this.pullRequestSnapshot;
645
- this.statusSnapshot = undefined;
646
- this.pullRequestSnapshot = undefined;
647
- this.lastSeenBranch = undefined;
648
- this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
649
- return Promise.resolve();
650
- }
651
- if (this.refreshInFlight) return this.refreshInFlight;
652
- const run = this.runRefresh()
653
- .finally(() => {
654
- this.refreshInFlight = undefined;
655
- })
656
- .catch(() => undefined);
657
- this.refreshInFlight = run;
658
- return run;
659
- }
660
-
661
- private async runRefresh(): Promise<void> {
662
- const previousStatusSnapshot = this.statusSnapshot;
663
- const previousPullRequestSnapshot = this.pullRequestSnapshot;
664
-
665
- const result = await this.fetchGitStatus();
666
- if (this.disposed) return;
667
- if (result.kind === "transient") return;
668
- if (result.kind === "not-a-repo" || result.kind === "unavailable") {
669
- this.statusSnapshot = undefined;
670
- this.pullRequestSnapshot = undefined;
671
- this.lastSeenBranch = undefined;
672
- this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
673
- return;
674
- }
675
-
676
- const status = result.status;
677
- this.statusSnapshot = status;
678
-
679
- const branch = typeof status.branch === "string" ? status.branch : undefined;
680
- const isValidBranch = !!branch && branch !== "detached";
681
- const branchChanged = branch !== this.lastSeenBranch;
682
-
683
- if (branchChanged) this.pullRequestSnapshot = undefined;
684
- this.lastSeenBranch = branch;
685
-
686
- if (!isValidBranch) {
687
- this.pullRequestSnapshot = undefined;
688
- this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
689
- return;
690
- }
691
-
692
- const pr = await this.fetchPullRequest();
693
- if (this.disposed) return;
694
- if (pr.kind === "ok") this.pullRequestSnapshot = pr.pullRequest;
695
- else if (pr.kind === "not-found" || pr.kind === "unavailable") this.pullRequestSnapshot = undefined;
696
-
697
- this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
698
- }
699
-
700
- private emitChangeIfSnapshotsChanged(
701
- previousStatusSnapshot: GitStatusSnapshot | undefined,
702
- previousPullRequestSnapshot: PullRequestSnapshot | undefined,
703
- ): void {
704
- if (this.disposed) return;
705
- if (
706
- gitStatusSnapshotsEqual(previousStatusSnapshot, this.statusSnapshot)
707
- && pullRequestSnapshotsEqual(previousPullRequestSnapshot, this.pullRequestSnapshot)
708
- ) {
709
- return;
710
- }
711
- try {
712
- this.onChange?.();
713
- } catch {
714
- // Rendering hooks should not break refreshes.
715
- }
716
- }
717
-
718
- private async fetchGitStatus(): Promise<
719
- | { kind: "ok"; status: GitStatusSnapshot }
720
- | { kind: "not-a-repo" }
721
- | { kind: "transient" }
722
- | { kind: "unavailable" }
723
- > {
724
- const result = await this.runCommandSafely("git", GIT_STATUS_ARGS, this.gitTimeoutMs);
725
- if (result.kind !== "ok") return result;
726
- if (result.result.exitCode !== 0) return { kind: "not-a-repo" };
727
- return { kind: "ok", status: parseGitStatusPorcelainV2(result.result.stdout) };
728
- }
729
-
730
- private async fetchPullRequest(): Promise<
731
- | { kind: "ok"; pullRequest: PullRequestSnapshot | undefined }
732
- | { kind: "not-found" }
733
- | { kind: "transient" }
734
- | { kind: "unavailable" }
735
- > {
736
- const result = await this.runCommandSafely("gh", GH_PR_VIEW_ARGS, this.ghTimeoutMs);
737
- if (result.kind !== "ok") return result;
738
- if (result.result.exitCode !== 0) return { kind: "not-found" };
739
- return { kind: "ok", pullRequest: parsePullRequestJson(result.result.stdout) };
740
- }
741
-
742
- private async runCommandSafely(
743
- command: string,
744
- args: readonly string[],
745
- timeoutMs: number,
746
- ): Promise<RunCommandSafelyResult> {
747
- if (this.disposed) return { kind: "transient" };
748
- const controller = new AbortController();
749
- this.inflightControllers.add(controller);
750
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
751
- try {
752
- const result = await this.runner(command, args, { cwd: this.cwd(), signal: controller.signal });
753
- return { kind: "ok", result };
754
- } catch (error) {
755
- return isCommandUnavailableError(error) ? { kind: "unavailable" } : { kind: "transient" };
756
- } finally {
757
- clearTimeout(timeoutId);
758
- this.inflightControllers.delete(controller);
759
- }
760
- }
761
-
762
- dispose(): void {
763
- if (this.disposed) return;
764
- this.disposed = true;
765
- if (this.intervalHandle !== undefined) {
766
- this.clock.clearInterval(this.intervalHandle);
767
- this.intervalHandle = undefined;
768
- }
769
- for (const controller of this.inflightControllers) controller.abort();
770
- this.inflightControllers.clear();
771
- }
581
+ private readonly cwd: () => string;
582
+ private readonly canRun: () => boolean;
583
+ private readonly runner: CommandRunner;
584
+ private readonly clock: Clock;
585
+ private readonly refreshIntervalMs: number;
586
+ private readonly gitTimeoutMs: number;
587
+ private readonly ghTimeoutMs: number;
588
+ private readonly onChange: (() => void) | undefined;
589
+
590
+ private intervalHandle: TimerHandle | undefined;
591
+ private readonly inflightControllers = new Set<AbortController>();
592
+ private disposed = false;
593
+ private refreshInFlight: Promise<void> | undefined;
594
+ private statusSnapshot: GitStatusSnapshot | undefined;
595
+ private pullRequestSnapshot: PullRequestSnapshot | undefined;
596
+ private lastSeenBranch: string | undefined;
597
+
598
+ constructor(options: GitFooterCacheOptions) {
599
+ this.cwd = options.cwd;
600
+ this.canRun = options.canRun ?? (() => true);
601
+ this.runner = options.runner ?? defaultRunner;
602
+ this.clock = options.clock ?? defaultClock();
603
+ this.refreshIntervalMs =
604
+ options.refreshIntervalMs ?? DEFAULT_CONFIG.gitStatus.refreshIntervalMs;
605
+ this.gitTimeoutMs = options.gitTimeoutMs ?? DEFAULT_CONFIG.gitStatus.gitTimeoutMs;
606
+ this.ghTimeoutMs = options.ghTimeoutMs ?? DEFAULT_CONFIG.gitStatus.ghTimeoutMs;
607
+ this.onChange = options.onChange;
608
+
609
+ this.intervalHandle = this.clock.setInterval(() => {
610
+ void this.refresh();
611
+ }, this.refreshIntervalMs);
612
+ void this.refresh();
613
+ }
614
+
615
+ getStatusSnapshot(): GitStatusSnapshot | undefined {
616
+ return this.statusSnapshot;
617
+ }
618
+
619
+ getPullRequestSnapshot(): PullRequestSnapshot | undefined {
620
+ return this.pullRequestSnapshot;
621
+ }
622
+
623
+ refresh(): Promise<void> {
624
+ if (this.disposed) return Promise.resolve();
625
+ if (!this.canRun()) {
626
+ const previousStatusSnapshot = this.statusSnapshot;
627
+ const previousPullRequestSnapshot = this.pullRequestSnapshot;
628
+ this.statusSnapshot = undefined;
629
+ this.pullRequestSnapshot = undefined;
630
+ this.lastSeenBranch = undefined;
631
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
632
+ return Promise.resolve();
633
+ }
634
+ if (this.refreshInFlight) return this.refreshInFlight;
635
+ const run = this.runRefresh()
636
+ .finally(() => {
637
+ this.refreshInFlight = undefined;
638
+ })
639
+ .catch(() => undefined);
640
+ this.refreshInFlight = run;
641
+ return run;
642
+ }
643
+
644
+ private async runRefresh(): Promise<void> {
645
+ const previousStatusSnapshot = this.statusSnapshot;
646
+ const previousPullRequestSnapshot = this.pullRequestSnapshot;
647
+
648
+ const result = await this.fetchGitStatus();
649
+ if (this.disposed) return;
650
+ if (result.kind === "transient") return;
651
+ if (result.kind === "not-a-repo" || result.kind === "unavailable") {
652
+ this.statusSnapshot = undefined;
653
+ this.pullRequestSnapshot = undefined;
654
+ this.lastSeenBranch = undefined;
655
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
656
+ return;
657
+ }
658
+
659
+ const status = result.status;
660
+ this.statusSnapshot = status;
661
+
662
+ const branch = typeof status.branch === "string" ? status.branch : undefined;
663
+ const isValidBranch = !!branch && branch !== "detached";
664
+ const branchChanged = branch !== this.lastSeenBranch;
665
+
666
+ if (branchChanged) this.pullRequestSnapshot = undefined;
667
+ this.lastSeenBranch = branch;
668
+
669
+ if (!isValidBranch) {
670
+ this.pullRequestSnapshot = undefined;
671
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
672
+ return;
673
+ }
674
+
675
+ const pr = await this.fetchPullRequest();
676
+ if (this.disposed) return;
677
+ if (pr.kind === "ok") this.pullRequestSnapshot = pr.pullRequest;
678
+ else if (pr.kind === "not-found" || pr.kind === "unavailable")
679
+ this.pullRequestSnapshot = undefined;
680
+
681
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
682
+ }
683
+
684
+ private emitChangeIfSnapshotsChanged(
685
+ previousStatusSnapshot: GitStatusSnapshot | undefined,
686
+ previousPullRequestSnapshot: PullRequestSnapshot | undefined,
687
+ ): void {
688
+ if (this.disposed) return;
689
+ if (
690
+ gitStatusSnapshotsEqual(previousStatusSnapshot, this.statusSnapshot) &&
691
+ pullRequestSnapshotsEqual(previousPullRequestSnapshot, this.pullRequestSnapshot)
692
+ ) {
693
+ return;
694
+ }
695
+ try {
696
+ this.onChange?.();
697
+ } catch {
698
+ // Rendering hooks should not break refreshes.
699
+ }
700
+ }
701
+
702
+ private async fetchGitStatus(): Promise<
703
+ | { kind: "ok"; status: GitStatusSnapshot }
704
+ | { kind: "not-a-repo" }
705
+ | { kind: "transient" }
706
+ | { kind: "unavailable" }
707
+ > {
708
+ const result = await this.runCommandSafely("git", GIT_STATUS_ARGS, this.gitTimeoutMs);
709
+ if (result.kind !== "ok") return result;
710
+ if (result.result.exitCode !== 0) return { kind: "not-a-repo" };
711
+ return { kind: "ok", status: parseGitStatusPorcelainV2(result.result.stdout) };
712
+ }
713
+
714
+ private async fetchPullRequest(): Promise<
715
+ | { kind: "ok"; pullRequest: PullRequestSnapshot | undefined }
716
+ | { kind: "not-found" }
717
+ | { kind: "transient" }
718
+ | { kind: "unavailable" }
719
+ > {
720
+ const result = await this.runCommandSafely("gh", GH_PR_VIEW_ARGS, this.ghTimeoutMs);
721
+ if (result.kind !== "ok") return result;
722
+ if (result.result.exitCode !== 0) return { kind: "not-found" };
723
+ return { kind: "ok", pullRequest: parsePullRequestJson(result.result.stdout) };
724
+ }
725
+
726
+ private async runCommandSafely(
727
+ command: string,
728
+ args: readonly string[],
729
+ timeoutMs: number,
730
+ ): Promise<RunCommandSafelyResult> {
731
+ if (this.disposed) return { kind: "transient" };
732
+ const controller = new AbortController();
733
+ this.inflightControllers.add(controller);
734
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
735
+ try {
736
+ const result = await this.runner(command, args, {
737
+ cwd: this.cwd(),
738
+ signal: controller.signal,
739
+ });
740
+ return { kind: "ok", result };
741
+ } catch (error) {
742
+ return isCommandUnavailableError(error) ? { kind: "unavailable" } : { kind: "transient" };
743
+ } finally {
744
+ clearTimeout(timeoutId);
745
+ this.inflightControllers.delete(controller);
746
+ }
747
+ }
748
+
749
+ dispose(): void {
750
+ if (this.disposed) return;
751
+ this.disposed = true;
752
+ if (this.intervalHandle !== undefined) {
753
+ this.clock.clearInterval(this.intervalHandle);
754
+ this.intervalHandle = undefined;
755
+ }
756
+ for (const controller of this.inflightControllers) controller.abort();
757
+ this.inflightControllers.clear();
758
+ }
772
759
  }
773
760
 
774
761
  function renderFooterLines(options: {
775
- width: number;
776
- cwd: string;
777
- config: MinimalFooterConfig;
778
- branch: string;
779
- gitStatus?: string;
780
- contextUsage?: { percent?: number | null; tokens?: number | null };
781
- modelId?: string;
782
- modelProvider?: string;
783
- thinkingLevel: string;
784
- theme: { fg(color: DumbZoneColor | "dim", text: string): string };
785
- usageSnapshot?: UsageSnapshot;
786
- usageStatus?: UsageAvailability;
762
+ width: number;
763
+ cwd: string;
764
+ config: MinimalFooterConfig;
765
+ branch: string;
766
+ gitStatus?: string;
767
+ contextUsage?: { percent?: number | null; tokens?: number | null };
768
+ modelId?: string;
769
+ modelProvider?: string;
770
+ thinkingLevel: string;
771
+ theme: { fg(color: DumbZoneColor | "dim", text: string): string };
772
+ usageSnapshot?: UsageSnapshot;
773
+ usageStatus?: UsageAvailability;
787
774
  }): string[] {
788
- const repo = basename(options.cwd);
789
- const branchText = options.gitStatus
790
- ? [options.branch, options.gitStatus].filter(Boolean).join(" · ")
791
- : options.branch;
792
-
793
- const context = options.contextUsage?.percent == null
794
- ? "?"
795
- : `${options.contextUsage.percent.toFixed(1)}%`;
796
- const dumbZone = options.config.context.dumbZone;
797
- const inDumbZone = dumbZone.enabled
798
- && (options.contextUsage?.tokens ?? 0) > dumbZone.thresholdTokens;
799
- const usageEnabled = shouldShowCodexUsage(options.config);
800
- const usageSummary = usageEnabled && isOpenAICodexProvider(options.modelProvider)
801
- ? formatUsageSummary(options.usageSnapshot, options.config.codexUsage.windows)
802
- : undefined;
803
- const usageStatus = usageEnabled
804
- && (isOpenAICodexProvider(options.modelProvider) || isOpenAIProvider(options.modelProvider))
805
- ? formatUsageStatus(options.usageStatus)
806
- : undefined;
807
-
808
- const model = options.modelId ?? "no-model";
809
- const modelText = options.thinkingLevel === "off"
810
- ? model
811
- : `${model} ${options.thinkingLevel}`;
812
-
813
- const branchStyled = options.theme.fg("dim", branchText);
814
- const repoStyled = options.theme.fg("dim", repo);
815
- const contextParts: string[] = [];
816
- if (options.config.context.showPercent) contextParts.push(options.theme.fg("dim", context));
817
- if (inDumbZone) contextParts.push(options.theme.fg(dumbZone.color, dumbZone.label));
818
- if (usageSummary) contextParts.push(options.theme.fg("dim", usageSummary));
819
- else if (usageStatus) contextParts.push(options.theme.fg("dim", usageStatus));
820
- if (shouldShowExperimentalMarker(options.config)) {
821
- const marker = options.config.experimentalMarker;
822
- contextParts.push(options.theme.fg(marker.color, marker.label));
823
- }
824
- const contextStyled = contextParts.join(options.theme.fg("dim", " · "));
825
- const modelStyled = options.theme.fg("dim", modelText);
826
-
827
- const renderSplitLine = (left: string, right: string): string => {
828
- const gap = " ".repeat(Math.max(2, options.width - visibleWidth(left) - visibleWidth(right)));
829
- return truncateToWidth(left + gap + right, options.width);
830
- };
831
-
832
- const line1Fits = visibleWidth(branchStyled) + visibleWidth(repoStyled) + 2 <= options.width;
833
- const line2Fits = visibleWidth(contextStyled) + visibleWidth(modelStyled) + 2 <= options.width;
834
-
835
- if (line1Fits && line2Fits) {
836
- return [
837
- renderSplitLine(branchStyled, repoStyled),
838
- renderSplitLine(contextStyled, modelStyled),
839
- ];
840
- }
841
-
842
- return [
843
- truncateToWidth(branchStyled, options.width),
844
- truncateToWidth(repoStyled, options.width),
845
- truncateToWidth(contextStyled, options.width),
846
- truncateToWidth(modelStyled, options.width),
847
- ];
775
+ const repo = basename(options.cwd);
776
+ const branchText = options.gitStatus
777
+ ? [options.branch, options.gitStatus].filter(Boolean).join(" · ")
778
+ : options.branch;
779
+
780
+ const context =
781
+ options.contextUsage?.percent == null ? "?" : `${options.contextUsage.percent.toFixed(1)}%`;
782
+ const dumbZone = options.config.context.dumbZone;
783
+ const inDumbZone =
784
+ dumbZone.enabled && (options.contextUsage?.tokens ?? 0) > dumbZone.thresholdTokens;
785
+ const usageEnabled = shouldShowCodexUsage(options.config);
786
+ const usageSummary =
787
+ usageEnabled && isOpenAICodexProvider(options.modelProvider)
788
+ ? formatUsageSummary(options.usageSnapshot, options.config.codexUsage.windows)
789
+ : undefined;
790
+ const usageStatus =
791
+ usageEnabled &&
792
+ (isOpenAICodexProvider(options.modelProvider) || isOpenAIProvider(options.modelProvider))
793
+ ? formatUsageStatus(options.usageStatus)
794
+ : undefined;
795
+
796
+ const model = options.modelId ?? "no-model";
797
+ const modelText = options.thinkingLevel === "off" ? model : `${model} ${options.thinkingLevel}`;
798
+
799
+ const branchStyled = options.theme.fg("dim", branchText);
800
+ const repoStyled = options.theme.fg("dim", repo);
801
+ const contextParts: string[] = [];
802
+ if (options.config.context.showPercent) contextParts.push(options.theme.fg("dim", context));
803
+ if (inDumbZone) contextParts.push(options.theme.fg(dumbZone.color, dumbZone.label));
804
+ if (usageSummary) contextParts.push(options.theme.fg("dim", usageSummary));
805
+ else if (usageStatus) contextParts.push(options.theme.fg("dim", usageStatus));
806
+ if (shouldShowExperimentalMarker(options.config)) {
807
+ const marker = options.config.experimentalMarker;
808
+ contextParts.push(options.theme.fg(marker.color, marker.label));
809
+ }
810
+ const contextStyled = contextParts.join(options.theme.fg("dim", " · "));
811
+ const modelStyled = options.theme.fg("dim", modelText);
812
+
813
+ const renderSplitLine = (left: string, right: string): string => {
814
+ const gap = " ".repeat(Math.max(2, options.width - visibleWidth(left) - visibleWidth(right)));
815
+ return truncateToWidth(left + gap + right, options.width);
816
+ };
817
+
818
+ const line1Fits = visibleWidth(branchStyled) + visibleWidth(repoStyled) + 2 <= options.width;
819
+ const line2Fits = visibleWidth(contextStyled) + visibleWidth(modelStyled) + 2 <= options.width;
820
+
821
+ if (line1Fits && line2Fits) {
822
+ return [renderSplitLine(branchStyled, repoStyled), renderSplitLine(contextStyled, modelStyled)];
823
+ }
824
+
825
+ return [
826
+ truncateToWidth(branchStyled, options.width),
827
+ truncateToWidth(repoStyled, options.width),
828
+ truncateToWidth(contextStyled, options.width),
829
+ truncateToWidth(modelStyled, options.width),
830
+ ];
848
831
  }
849
832
 
850
833
  function clearUsageState(state: UsageSessionState): void {
851
- state.snapshot = undefined;
852
- state.lastFetchedAt = undefined;
853
- state.loading = false;
854
- state.error = undefined;
855
- state.availability = undefined;
834
+ state.snapshot = undefined;
835
+ state.lastFetchedAt = undefined;
836
+ state.loading = false;
837
+ state.error = undefined;
838
+ state.availability = undefined;
856
839
  }
857
840
 
858
841
  function isModelUsingOAuth(ctx: ExtensionContext): boolean {
859
- if (!ctx.model) return false;
860
- try {
861
- return ctx.modelRegistry.isUsingOAuth(ctx.model);
862
- } catch {
863
- return false;
864
- }
842
+ if (!ctx.model) return false;
843
+ try {
844
+ return ctx.modelRegistry.isUsingOAuth(ctx.model);
845
+ } catch {
846
+ return false;
847
+ }
865
848
  }
866
849
 
867
850
  function buildUsageRenderKey(
868
- model: NonNullable<ExtensionContext["model"]>,
869
- oauth: boolean,
851
+ model: NonNullable<ExtensionContext["model"]>,
852
+ oauth: boolean,
870
853
  ): string {
871
- return [model.provider, model.api, model.id, oauth ? "oauth" : "api-key"].join("/");
854
+ return [model.provider, model.api, model.id, oauth ? "oauth" : "api-key"].join("/");
872
855
  }
873
856
 
874
857
  function getUsageRenderKey(ctx: ExtensionContext): string | undefined {
875
- const model = ctx.model;
876
- if (!model) return undefined;
877
- return buildUsageRenderKey(model, isModelUsingOAuth(ctx));
858
+ const model = ctx.model;
859
+ if (!model) return undefined;
860
+ return buildUsageRenderKey(model, isModelUsingOAuth(ctx));
878
861
  }
879
862
 
880
863
  /**
@@ -883,351 +866,350 @@ function getUsageRenderKey(ctx: ExtensionContext): string | undefined {
883
866
  * auth.json or otherwise reloads account metadata.
884
867
  */
885
868
  function getUsageIdentity(ctx: ExtensionContext): UsageIdentity | undefined {
886
- const model = ctx.model;
887
- if (!model) return undefined;
888
-
889
- const oauth = isModelUsingOAuth(ctx);
890
- const renderKey = buildUsageRenderKey(model, oauth);
891
- const accountId = isOpenAICodexProvider(model.provider) && oauth
892
- ? getOpenAICodexAccountId(ctx.modelRegistry)
893
- : undefined;
894
- return {
895
- renderKey,
896
- usageKey: [renderKey, accountId ?? "account-unknown"].join("/"),
897
- oauth,
898
- accountId,
899
- };
869
+ const model = ctx.model;
870
+ if (!model) return undefined;
871
+
872
+ const oauth = isModelUsingOAuth(ctx);
873
+ const renderKey = buildUsageRenderKey(model, oauth);
874
+ const accountId =
875
+ isOpenAICodexProvider(model.provider) && oauth
876
+ ? getOpenAICodexAccountId(ctx.modelRegistry)
877
+ : undefined;
878
+ return {
879
+ renderKey,
880
+ usageKey: [renderKey, accountId ?? "account-unknown"].join("/"),
881
+ oauth,
882
+ accountId,
883
+ };
900
884
  }
901
885
 
902
886
  type UsageMode = "codex" | UsageAvailability;
903
887
 
904
888
  function getUsageMode(
905
- ctx: ExtensionContext,
906
- config: MinimalFooterConfig,
907
- identity?: UsageIdentity,
889
+ ctx: ExtensionContext,
890
+ config: MinimalFooterConfig,
891
+ identity?: UsageIdentity,
908
892
  ): UsageMode | undefined {
909
- if (!shouldShowCodexUsage(config)) return undefined;
910
- const oauth = identity?.oauth ?? isModelUsingOAuth(ctx);
911
- if (isOpenAICodexProvider(ctx.model?.provider)) {
912
- return oauth ? "codex" : "unavailable";
913
- }
914
- if (isOpenAIProvider(ctx.model?.provider) && oauth) {
915
- // Pi's new OpenAI ChatGPT OAuth flow targets api.openai.com. There is
916
- // no documented account-usage contract for that token, so never reuse
917
- // the legacy openai-codex WHAM request here.
918
- return "unsupported";
919
- }
920
- return undefined;
893
+ if (!shouldShowCodexUsage(config)) return undefined;
894
+ const oauth = identity?.oauth ?? isModelUsingOAuth(ctx);
895
+ if (isOpenAICodexProvider(ctx.model?.provider)) {
896
+ return oauth ? "codex" : "unavailable";
897
+ }
898
+ if (isOpenAIProvider(ctx.model?.provider) && oauth) {
899
+ // Pi's new OpenAI ChatGPT OAuth flow targets api.openai.com. There is
900
+ // no documented account-usage contract for that token, so never reuse
901
+ // the legacy openai-codex WHAM request here.
902
+ return "unsupported";
903
+ }
904
+ return undefined;
921
905
  }
922
906
 
923
- function invalidateUsageState(
924
- state: UsageSessionState,
925
- usageIdentity?: UsageIdentity,
926
- ): void {
927
- state.requestVersion += 1;
928
- state.snapshot = undefined;
929
- state.lastFetchedAt = undefined;
930
- state.loading = false;
931
- state.error = undefined;
932
- state.availability = undefined;
933
- state.usageIdentity = usageIdentity;
934
- // The previous promise may still be resolving. Clearing the reference lets
935
- // a newly selected provider/account start its own request; requestVersion
936
- // prevents the old promise from repainting this state when it settles.
937
- state.inflight = undefined;
907
+ function invalidateUsageState(state: UsageSessionState, usageIdentity?: UsageIdentity): void {
908
+ state.requestVersion += 1;
909
+ state.snapshot = undefined;
910
+ state.lastFetchedAt = undefined;
911
+ state.loading = false;
912
+ state.error = undefined;
913
+ state.availability = undefined;
914
+ state.usageIdentity = usageIdentity;
915
+ // The previous promise may still be resolving. Clearing the reference lets
916
+ // a newly selected provider/account start its own request; requestVersion
917
+ // prevents the old promise from repainting this state when it settles.
918
+ state.inflight = undefined;
938
919
  }
939
920
 
940
921
  function usageRequestStillCurrent(
941
- ctx: ExtensionContext,
942
- state: UsageSessionState,
943
- requestVersion: number,
944
- requestUsageKey: string | undefined,
922
+ ctx: ExtensionContext,
923
+ state: UsageSessionState,
924
+ requestVersion: number,
925
+ requestUsageKey: string | undefined,
945
926
  ): boolean {
946
- if (
947
- state.disposed
948
- || state.requestVersion !== requestVersion
949
- || state.usageIdentity?.usageKey !== requestUsageKey
950
- ) {
951
- return false;
952
- }
953
-
954
- // Account-only login changes do not emit an extension event in Pi 0.99.
955
- // Re-read the lifecycle identity before accepting a response, but never
956
- // perform this check from render().
957
- const currentIdentity = getUsageIdentity(ctx);
958
- if (currentIdentity?.usageKey === requestUsageKey) return true;
959
-
960
- invalidateUsageState(state, currentIdentity);
961
- state.requestRender?.();
962
- void refreshUsageIfNeeded(ctx, state, true, currentIdentity);
963
- return false;
927
+ if (
928
+ state.disposed ||
929
+ state.requestVersion !== requestVersion ||
930
+ state.usageIdentity?.usageKey !== requestUsageKey
931
+ ) {
932
+ return false;
933
+ }
934
+
935
+ // Account-only login changes do not emit an extension event in Pi 0.99.
936
+ // Re-read the lifecycle identity before accepting a response, but never
937
+ // perform this check from render().
938
+ const currentIdentity = getUsageIdentity(ctx);
939
+ if (currentIdentity?.usageKey === requestUsageKey) return true;
940
+
941
+ invalidateUsageState(state, currentIdentity);
942
+ state.requestRender?.();
943
+ void refreshUsageIfNeeded(ctx, state, true, currentIdentity);
944
+ return false;
964
945
  }
965
946
 
966
947
  async function refreshUsageIfNeeded(
967
- ctx: ExtensionContext,
968
- state: UsageSessionState,
969
- force = false,
970
- usageIdentity?: UsageIdentity,
948
+ ctx: ExtensionContext,
949
+ state: UsageSessionState,
950
+ force = false,
951
+ usageIdentity?: UsageIdentity,
971
952
  ): Promise<void> {
972
- if (state.disposed) return;
973
-
974
- const resolvedUsageIdentity = usageIdentity ?? getUsageIdentity(ctx);
975
- const config = state.config;
976
- if (state.usageIdentity?.usageKey !== resolvedUsageIdentity?.usageKey) {
977
- invalidateUsageState(state, resolvedUsageIdentity);
978
- state.requestRender?.();
979
- }
980
-
981
- const mode = getUsageMode(ctx, config, resolvedUsageIdentity);
982
- if (!mode) {
983
- if (state.snapshot || state.availability || state.loading || state.error) {
984
- clearUsageState(state);
985
- state.requestRender?.();
986
- }
987
- return;
988
- }
989
-
990
- if (mode !== "codex") {
991
- const status: UsageAvailability = mode;
992
- if (!force && state.availability === status && state.lastFetchedAt !== undefined) return;
993
- clearUsageState(state);
994
- state.availability = status;
995
- state.lastFetchedAt = Date.now();
996
- state.requestRender?.();
997
- return;
998
- }
999
-
1000
- const now = Date.now();
1001
- if (
1002
- !force &&
1003
- state.lastFetchedAt !== undefined &&
1004
- now - state.lastFetchedAt < config.codexUsage.cacheTtlMs
1005
- ) {
1006
- return;
1007
- }
1008
-
1009
- if (state.inflight) return state.inflight;
1010
-
1011
- const requestVersion = state.requestVersion;
1012
- const requestUsageKey = resolvedUsageIdentity?.usageKey;
1013
- state.loading = true;
1014
- state.availability = undefined;
1015
- state.requestRender?.();
1016
- const request = (async () => {
1017
- try {
1018
- const snapshot = await fetchOpenAICodexUsage(ctx.modelRegistry, {
1019
- timeoutMs: config.codexUsage.requestTimeoutMs,
1020
- // Passing the lifecycle-resolved identity avoids a second account
1021
- // lookup and binds the response to the account that was requested.
1022
- accountId: resolvedUsageIdentity?.accountId,
1023
- });
1024
- if (!usageRequestStillCurrent(ctx, state, requestVersion, requestUsageKey)) return;
1025
- if (snapshot) {
1026
- state.snapshot = snapshot;
1027
- state.lastFetchedAt = snapshot.fetchedAt;
1028
- state.error = undefined;
1029
- } else {
1030
- state.snapshot = undefined;
1031
- state.lastFetchedAt = Date.now();
1032
- state.error = undefined;
1033
- state.availability = "unavailable";
1034
- }
1035
- } catch (error) {
1036
- if (!usageRequestStillCurrent(ctx, state, requestVersion, requestUsageKey)) return;
1037
- state.snapshot = undefined;
1038
- state.lastFetchedAt = Date.now();
1039
- state.availability = "unavailable";
1040
- state.error = error instanceof Error ? error.message : String(error);
1041
- } finally {
1042
- if (state.disposed || state.requestVersion !== requestVersion || state.usageIdentity?.usageKey !== requestUsageKey) return;
1043
- state.loading = false;
1044
- state.inflight = undefined;
1045
- state.requestRender?.();
1046
- }
1047
- })();
1048
-
1049
- state.inflight = request;
1050
- return request;
953
+ if (state.disposed) return;
954
+
955
+ const resolvedUsageIdentity = usageIdentity ?? getUsageIdentity(ctx);
956
+ const config = state.config;
957
+ if (state.usageIdentity?.usageKey !== resolvedUsageIdentity?.usageKey) {
958
+ invalidateUsageState(state, resolvedUsageIdentity);
959
+ state.requestRender?.();
960
+ }
961
+
962
+ const mode = getUsageMode(ctx, config, resolvedUsageIdentity);
963
+ if (!mode) {
964
+ if (state.snapshot || state.availability || state.loading || state.error) {
965
+ clearUsageState(state);
966
+ state.requestRender?.();
967
+ }
968
+ return;
969
+ }
970
+
971
+ if (mode !== "codex") {
972
+ const status: UsageAvailability = mode;
973
+ if (!force && state.availability === status && state.lastFetchedAt !== undefined) return;
974
+ clearUsageState(state);
975
+ state.availability = status;
976
+ state.lastFetchedAt = Date.now();
977
+ state.requestRender?.();
978
+ return;
979
+ }
980
+
981
+ const now = Date.now();
982
+ if (
983
+ !force &&
984
+ state.lastFetchedAt !== undefined &&
985
+ now - state.lastFetchedAt < config.codexUsage.cacheTtlMs
986
+ ) {
987
+ return;
988
+ }
989
+
990
+ if (state.inflight) return state.inflight;
991
+
992
+ const requestVersion = state.requestVersion;
993
+ const requestUsageKey = resolvedUsageIdentity?.usageKey;
994
+ state.loading = true;
995
+ state.availability = undefined;
996
+ state.requestRender?.();
997
+ const request = (async () => {
998
+ try {
999
+ const snapshot = await fetchOpenAICodexUsage(ctx.modelRegistry, {
1000
+ timeoutMs: config.codexUsage.requestTimeoutMs,
1001
+ // Passing the lifecycle-resolved identity avoids a second account
1002
+ // lookup and binds the response to the account that was requested.
1003
+ accountId: resolvedUsageIdentity?.accountId,
1004
+ });
1005
+ if (!usageRequestStillCurrent(ctx, state, requestVersion, requestUsageKey)) return;
1006
+ if (snapshot) {
1007
+ state.snapshot = snapshot;
1008
+ state.lastFetchedAt = snapshot.fetchedAt;
1009
+ state.error = undefined;
1010
+ } else {
1011
+ state.snapshot = undefined;
1012
+ state.lastFetchedAt = Date.now();
1013
+ state.error = undefined;
1014
+ state.availability = "unavailable";
1015
+ }
1016
+ } catch (error) {
1017
+ if (!usageRequestStillCurrent(ctx, state, requestVersion, requestUsageKey)) return;
1018
+ state.snapshot = undefined;
1019
+ state.lastFetchedAt = Date.now();
1020
+ state.availability = "unavailable";
1021
+ state.error = error instanceof Error ? error.message : String(error);
1022
+ } finally {
1023
+ if (
1024
+ state.disposed ||
1025
+ state.requestVersion !== requestVersion ||
1026
+ state.usageIdentity?.usageKey !== requestUsageKey
1027
+ )
1028
+ return;
1029
+ state.loading = false;
1030
+ state.inflight = undefined;
1031
+ state.requestRender?.();
1032
+ }
1033
+ })();
1034
+
1035
+ state.inflight = request;
1036
+ return request;
1051
1037
  }
1052
1038
 
1053
1039
  function checkUsageIdentity(ctx: ExtensionContext, state: UsageSessionState): void {
1054
- if (state.disposed) return;
1055
-
1056
- const currentIdentity = getUsageIdentity(ctx);
1057
- if (state.usageIdentity?.usageKey === currentIdentity?.usageKey) return;
1058
-
1059
- // The timer is deliberately identity-only. It performs no WHAM request for
1060
- // an unchanged account; an identity change uses the existing account-bound
1061
- // refresh path so token/header validation and late-result isolation remain in
1062
- // one place.
1063
- invalidateUsageState(state, currentIdentity);
1064
- state.requestRender?.();
1065
- void refreshUsageIfNeeded(ctx, state, true, currentIdentity);
1040
+ if (state.disposed) return;
1041
+
1042
+ const currentIdentity = getUsageIdentity(ctx);
1043
+ if (state.usageIdentity?.usageKey === currentIdentity?.usageKey) return;
1044
+
1045
+ // The timer is deliberately identity-only. It performs no WHAM request for
1046
+ // an unchanged account; an identity change uses the existing account-bound
1047
+ // refresh path so token/header validation and late-result isolation remain in
1048
+ // one place.
1049
+ invalidateUsageState(state, currentIdentity);
1050
+ state.requestRender?.();
1051
+ void refreshUsageIfNeeded(ctx, state, true, currentIdentity);
1066
1052
  }
1067
1053
 
1068
1054
  function startUsageRefreshTimer(ctx: ExtensionContext, state: UsageSessionState): void {
1069
- const configuredIntervalMs = state.config.codexUsage.cacheTtlMs;
1070
- if (
1071
- state.usageRefreshTimer !== undefined
1072
- || !shouldShowCodexUsage(state.config)
1073
- ) {
1074
- return;
1075
- }
1076
-
1077
- const requestedIntervalMs = configuredIntervalMs === 0
1078
- ? ZERO_CACHE_IDENTITY_CHECK_INTERVAL_MS
1079
- : configuredIntervalMs;
1080
- const intervalMs = Math.min(requestedIntervalMs, MAX_NODE_TIMER_DELAY_MS);
1081
- const handle = setInterval(() => checkUsageIdentity(ctx, state), intervalMs);
1082
- (handle as { unref?: () => void }).unref?.();
1083
- state.usageRefreshTimer = handle;
1055
+ const configuredIntervalMs = state.config.codexUsage.cacheTtlMs;
1056
+ if (state.usageRefreshTimer !== undefined || !shouldShowCodexUsage(state.config)) {
1057
+ return;
1058
+ }
1059
+
1060
+ const requestedIntervalMs =
1061
+ configuredIntervalMs === 0 ? ZERO_CACHE_IDENTITY_CHECK_INTERVAL_MS : configuredIntervalMs;
1062
+ const intervalMs = Math.min(requestedIntervalMs, MAX_NODE_TIMER_DELAY_MS);
1063
+ const handle = setInterval(() => checkUsageIdentity(ctx, state), intervalMs);
1064
+ (handle as { unref?: () => void }).unref?.();
1065
+ state.usageRefreshTimer = handle;
1084
1066
  }
1085
1067
 
1086
1068
  function disposeUsageRefreshTimer(state: UsageSessionState): void {
1087
- if (state.usageRefreshTimer === undefined) return;
1088
- clearInterval(state.usageRefreshTimer as ReturnType<typeof setInterval>);
1089
- state.usageRefreshTimer = undefined;
1069
+ if (state.usageRefreshTimer === undefined) return;
1070
+ clearInterval(state.usageRefreshTimer as ReturnType<typeof setInterval>);
1071
+ state.usageRefreshTimer = undefined;
1090
1072
  }
1091
1073
 
1092
1074
  export default function (pi: ExtensionAPI) {
1093
- const states = new WeakMap<object, UsageSessionState>();
1094
-
1095
- function disposeGitCache(state: UsageSessionState): void {
1096
- state.gitCache?.dispose();
1097
- state.gitCache = undefined;
1098
- }
1099
-
1100
- function disposeState(state: UsageSessionState): void {
1101
- state.disposed = true;
1102
- state.requestVersion += 1;
1103
- state.snapshot = undefined;
1104
- state.lastFetchedAt = undefined;
1105
- state.loading = false;
1106
- state.error = undefined;
1107
- state.availability = undefined;
1108
- state.usageIdentity = undefined;
1109
- state.inflight = undefined;
1110
- state.requestRender = undefined;
1111
- disposeUsageRefreshTimer(state);
1112
- disposeGitCache(state);
1113
- }
1114
-
1115
- function ensureGitCache(ctx: ExtensionContext, state: UsageSessionState): void {
1116
- if (!shouldShowGitStatus(state.config) || !canRunProjectGit(ctx)) {
1117
- disposeGitCache(state);
1118
- return;
1119
- }
1120
-
1121
- if (state.gitCache) return;
1122
- state.gitCache = new GitFooterCache({
1123
- cwd: () => ctx.cwd,
1124
- canRun: () => canRunProjectGit(ctx),
1125
- refreshIntervalMs: state.config.gitStatus.refreshIntervalMs,
1126
- gitTimeoutMs: state.config.gitStatus.gitTimeoutMs,
1127
- ghTimeoutMs: state.config.gitStatus.ghTimeoutMs,
1128
- onChange: () => state.requestRender?.(),
1129
- });
1130
- }
1131
-
1132
- pi.on("session_start", (_event, ctx) => {
1133
- const previous = states.get(ctx.sessionManager);
1134
- if (previous) disposeState(previous);
1135
-
1136
- const state: UsageSessionState = {
1137
- config: loadConfig(ctx),
1138
- loading: false,
1139
- requestVersion: 0,
1140
- disposed: false,
1141
- };
1142
- states.set(ctx.sessionManager, state);
1143
-
1144
- ensureGitCache(ctx, state);
1145
-
1146
- ctx.ui.setFooter((tui, theme, footerData) => {
1147
- const unsub = footerData.onBranchChange(() => tui.requestRender());
1148
- state.requestRender = () => tui.requestRender();
1149
- void refreshUsageIfNeeded(ctx, state);
1150
-
1151
- return {
1152
- dispose() {
1153
- disposeState(state);
1154
- unsub();
1155
- },
1156
- invalidate() {},
1157
- render(width: number): string[] {
1158
- // Keep repainting cheap: account-bound identity is resolved only
1159
- // by lifecycle refreshes, never while streaming output renders.
1160
- const usageRenderKey = getUsageRenderKey(ctx);
1161
- const usageStateMatchesModel = state.usageIdentity?.renderKey === usageRenderKey;
1162
- return renderFooterLines({
1163
- width,
1164
- cwd: ctx.cwd,
1165
- config: state.config,
1166
- branch: footerData.getGitBranch() ?? state.gitCache?.getStatusSnapshot()?.branch ?? "",
1167
- gitStatus: shouldShowGitStatus(state.config)
1168
- ? formatGitFooterStatus(
1169
- state.gitCache?.getStatusSnapshot(),
1170
- state.gitCache?.getPullRequestSnapshot(),
1171
- )
1172
- : undefined,
1173
- contextUsage: ctx.getContextUsage(),
1174
- modelId: ctx.model?.id,
1175
- modelProvider: ctx.model?.provider,
1176
- thinkingLevel: pi.getThinkingLevel(),
1177
- theme,
1178
- usageSnapshot: usageStateMatchesModel ? state.snapshot : undefined,
1179
- usageStatus: usageStateMatchesModel ? state.availability : undefined,
1180
- });
1181
- },
1182
- };
1183
- });
1184
- startUsageRefreshTimer(ctx, state);
1185
- });
1186
-
1187
- pi.on("model_select", (_event, ctx) => {
1188
- const state = states.get(ctx.sessionManager);
1189
- if (!state) return;
1190
- // A model-select event is also the boundary for an account reload. Do
1191
- // not let a prior provider/account's snapshot or promise survive it.
1192
- const usageIdentity = getUsageIdentity(ctx);
1193
- invalidateUsageState(state, usageIdentity);
1194
- state.requestRender?.();
1195
- void refreshUsageIfNeeded(ctx, state, true, usageIdentity);
1196
- });
1197
-
1198
- pi.on("turn_start", (_event, ctx) => {
1199
- const state = states.get(ctx.sessionManager);
1200
- if (!state) return;
1201
- // Prompt boundaries provide an additional account-change check without
1202
- // forcing a usage request when the current cache is still fresh.
1203
- void refreshUsageIfNeeded(ctx, state);
1204
- });
1205
-
1206
- pi.on("turn_end", (_event, ctx) => {
1207
- const state = states.get(ctx.sessionManager);
1208
- if (!state) return;
1209
- void refreshUsageIfNeeded(ctx, state);
1210
- ensureGitCache(ctx, state);
1211
- void state.gitCache?.refresh();
1212
- });
1213
-
1214
- pi.on("session_shutdown", (_event, ctx) => {
1215
- const state = states.get(ctx.sessionManager);
1216
- if (!state) return;
1217
- disposeState(state);
1218
- states.delete(ctx.sessionManager);
1219
- });
1075
+ const states = new WeakMap<object, UsageSessionState>();
1076
+
1077
+ function disposeGitCache(state: UsageSessionState): void {
1078
+ state.gitCache?.dispose();
1079
+ state.gitCache = undefined;
1080
+ }
1081
+
1082
+ function disposeState(state: UsageSessionState): void {
1083
+ state.disposed = true;
1084
+ state.requestVersion += 1;
1085
+ state.snapshot = undefined;
1086
+ state.lastFetchedAt = undefined;
1087
+ state.loading = false;
1088
+ state.error = undefined;
1089
+ state.availability = undefined;
1090
+ state.usageIdentity = undefined;
1091
+ state.inflight = undefined;
1092
+ state.requestRender = undefined;
1093
+ disposeUsageRefreshTimer(state);
1094
+ disposeGitCache(state);
1095
+ }
1096
+
1097
+ function ensureGitCache(ctx: ExtensionContext, state: UsageSessionState): void {
1098
+ if (!shouldShowGitStatus(state.config) || !canRunProjectGit(ctx)) {
1099
+ disposeGitCache(state);
1100
+ return;
1101
+ }
1102
+
1103
+ if (state.gitCache) return;
1104
+ state.gitCache = new GitFooterCache({
1105
+ cwd: () => ctx.cwd,
1106
+ canRun: () => canRunProjectGit(ctx),
1107
+ refreshIntervalMs: state.config.gitStatus.refreshIntervalMs,
1108
+ gitTimeoutMs: state.config.gitStatus.gitTimeoutMs,
1109
+ ghTimeoutMs: state.config.gitStatus.ghTimeoutMs,
1110
+ onChange: () => state.requestRender?.(),
1111
+ });
1112
+ }
1113
+
1114
+ pi.on("session_start", (_event, ctx) => {
1115
+ const previous = states.get(ctx.sessionManager);
1116
+ if (previous) disposeState(previous);
1117
+
1118
+ const state: UsageSessionState = {
1119
+ config: loadConfig(ctx),
1120
+ loading: false,
1121
+ requestVersion: 0,
1122
+ disposed: false,
1123
+ };
1124
+ states.set(ctx.sessionManager, state);
1125
+
1126
+ ensureGitCache(ctx, state);
1127
+
1128
+ ctx.ui.setFooter((tui, theme, footerData) => {
1129
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
1130
+ state.requestRender = () => tui.requestRender();
1131
+ void refreshUsageIfNeeded(ctx, state);
1132
+
1133
+ return {
1134
+ dispose() {
1135
+ disposeState(state);
1136
+ unsub();
1137
+ },
1138
+ invalidate() {},
1139
+ render(width: number): string[] {
1140
+ // Keep repainting cheap: account-bound identity is resolved only
1141
+ // by lifecycle refreshes, never while streaming output renders.
1142
+ const usageRenderKey = getUsageRenderKey(ctx);
1143
+ const usageStateMatchesModel = state.usageIdentity?.renderKey === usageRenderKey;
1144
+ return renderFooterLines({
1145
+ width,
1146
+ cwd: ctx.cwd,
1147
+ config: state.config,
1148
+ branch: footerData.getGitBranch() ?? state.gitCache?.getStatusSnapshot()?.branch ?? "",
1149
+ gitStatus: shouldShowGitStatus(state.config)
1150
+ ? formatGitFooterStatus(
1151
+ state.gitCache?.getStatusSnapshot(),
1152
+ state.gitCache?.getPullRequestSnapshot(),
1153
+ )
1154
+ : undefined,
1155
+ contextUsage: ctx.getContextUsage(),
1156
+ modelId: ctx.model?.id,
1157
+ modelProvider: ctx.model?.provider,
1158
+ thinkingLevel: pi.getThinkingLevel(),
1159
+ theme,
1160
+ usageSnapshot: usageStateMatchesModel ? state.snapshot : undefined,
1161
+ usageStatus: usageStateMatchesModel ? state.availability : undefined,
1162
+ });
1163
+ },
1164
+ };
1165
+ });
1166
+ startUsageRefreshTimer(ctx, state);
1167
+ });
1168
+
1169
+ pi.on("model_select", (_event, ctx) => {
1170
+ const state = states.get(ctx.sessionManager);
1171
+ if (!state) return;
1172
+ // A model-select event is also the boundary for an account reload. Do
1173
+ // not let a prior provider/account's snapshot or promise survive it.
1174
+ const usageIdentity = getUsageIdentity(ctx);
1175
+ invalidateUsageState(state, usageIdentity);
1176
+ state.requestRender?.();
1177
+ void refreshUsageIfNeeded(ctx, state, true, usageIdentity);
1178
+ });
1179
+
1180
+ pi.on("turn_start", (_event, ctx) => {
1181
+ const state = states.get(ctx.sessionManager);
1182
+ if (!state) return;
1183
+ // Prompt boundaries provide an additional account-change check without
1184
+ // forcing a usage request when the current cache is still fresh.
1185
+ void refreshUsageIfNeeded(ctx, state);
1186
+ });
1187
+
1188
+ pi.on("turn_end", (_event, ctx) => {
1189
+ const state = states.get(ctx.sessionManager);
1190
+ if (!state) return;
1191
+ void refreshUsageIfNeeded(ctx, state);
1192
+ ensureGitCache(ctx, state);
1193
+ void state.gitCache?.refresh();
1194
+ });
1195
+
1196
+ pi.on("session_shutdown", (_event, ctx) => {
1197
+ const state = states.get(ctx.sessionManager);
1198
+ if (!state) return;
1199
+ disposeState(state);
1200
+ states.delete(ctx.sessionManager);
1201
+ });
1220
1202
  }
1221
1203
 
1222
1204
  export const __testing = {
1223
- GitFooterCache,
1224
- GIT_STATUS_ARGS,
1225
- GH_PR_VIEW_ARGS,
1226
- loadConfig,
1227
- renderFooterLines,
1228
- parseGitStatusPorcelainV2,
1229
- parsePullRequestJson,
1230
- formatGitStatusFooterSegment,
1231
- formatPullRequestFooterSegment,
1232
- formatGitFooterStatus,
1205
+ GitFooterCache,
1206
+ GIT_STATUS_ARGS,
1207
+ GH_PR_VIEW_ARGS,
1208
+ loadConfig,
1209
+ renderFooterLines,
1210
+ parseGitStatusPorcelainV2,
1211
+ parsePullRequestJson,
1212
+ formatGitStatusFooterSegment,
1213
+ formatPullRequestFooterSegment,
1214
+ formatGitFooterStatus,
1233
1215
  };