@diegopetrucci/pi-extensions 0.1.74 → 0.1.78

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