@openlines/opl-pi-sht 0.2.11 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +92 -82
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package.json +4 -4
  12. package/extensions/opl-browser/validate.ts +313 -60
  13. package/extensions/opl-configurator/README.md +40 -0
  14. package/extensions/opl-configurator/configurator.ts +148 -0
  15. package/extensions/opl-configurator/index.ts +12 -0
  16. package/extensions/opl-ctxtrim/README.md +45 -14
  17. package/extensions/opl-ctxtrim/index.ts +33 -13
  18. package/extensions/opl-footer/README.md +32 -7
  19. package/extensions/opl-footer/config.ts +11 -10
  20. package/extensions/opl-footer/configure.ts +157 -115
  21. package/extensions/opl-footer/git-status.ts +58 -1
  22. package/extensions/opl-footer/icons.ts +3 -0
  23. package/extensions/opl-footer/index.ts +287 -109
  24. package/extensions/opl-footer/segments/caveman.ts +9 -1
  25. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  26. package/extensions/opl-footer/segments/compactions.ts +13 -0
  27. package/extensions/opl-footer/segments/context.ts +6 -5
  28. package/extensions/opl-footer/segments/index.ts +2 -0
  29. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  30. package/extensions/opl-footer/segments/path.ts +1 -1
  31. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  32. package/extensions/opl-footer/theme.ts +5 -2
  33. package/extensions/opl-footer/types.ts +4 -0
  34. package/extensions/opl-guardian/README.md +17 -5
  35. package/extensions/opl-guardian/config.ts +72 -6
  36. package/extensions/opl-guardian/guardian.ts +42 -10
  37. package/extensions/opl-guardian/index.ts +52 -17
  38. package/extensions/opl-guardian/policies.ts +52 -16
  39. package/extensions/opl-init/README.md +12 -4
  40. package/extensions/opl-init/index.ts +228 -27
  41. package/extensions/opl-input/README.md +17 -3
  42. package/extensions/opl-input/config.ts +59 -5
  43. package/extensions/opl-input/configure.ts +144 -0
  44. package/extensions/opl-input/index.ts +38 -30
  45. package/extensions/opl-input/mode-style.ts +6 -2
  46. package/extensions/opl-modes/README.md +16 -4
  47. package/extensions/opl-modes/config.ts +62 -8
  48. package/extensions/opl-modes/index.ts +48 -17
  49. package/extensions/opl-modes/state.ts +50 -2
  50. package/extensions/opl-modes/types.ts +6 -0
  51. package/extensions/opl-modes/utils.ts +18 -8
  52. package/extensions/opl-questionnaire/index.ts +10 -4
  53. package/extensions/opl-questionnaire/types.ts +4 -0
  54. package/extensions/opl-simplebench/README.md +21 -15
  55. package/extensions/opl-simplebench/artifact.ts +7 -4
  56. package/extensions/opl-simplebench/benchmark.ts +96 -35
  57. package/extensions/opl-simplebench/coding.ts +9 -4
  58. package/extensions/opl-simplebench/index.ts +19 -19
  59. package/extensions/opl-simplebench/llama-server.ts +3 -2
  60. package/extensions/opl-simplebench/report.ts +0 -6
  61. package/extensions/opl-simplebench/research.ts +5 -4
  62. package/extensions/opl-simplebench/scoring.ts +4 -2
  63. package/extensions/opl-simplebench/util/config.ts +15 -8
  64. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  65. package/extensions/opl-simplebench/util/format.ts +2 -2
  66. package/extensions/opl-simplebench/util/providers.ts +23 -194
  67. package/extensions/opl-todo/README.md +5 -5
  68. package/extensions/opl-todo/config.ts +7 -2
  69. package/extensions/opl-todo/index.ts +50 -9
  70. package/extensions/opl-webaccess/README.md +31 -5
  71. package/extensions/opl-webaccess/config.ts +36 -7
  72. package/extensions/opl-webaccess/extract.ts +37 -47
  73. package/extensions/opl-webaccess/http.ts +259 -0
  74. package/extensions/opl-webaccess/index.ts +12 -6
  75. package/extensions/opl-webaccess/package.json +4 -9
  76. package/extensions/opl-webaccess/pdf.ts +4 -4
  77. package/extensions/opl-webaccess/search.ts +19 -8
  78. package/extensions/opl-webaccess/storage.ts +134 -2
  79. package/extensions/opl-webaccess/types.ts +4 -0
  80. package/extensions/opl-webaccess/utils.ts +221 -33
  81. package/images/ss-init.png +0 -0
  82. package/images/ss-input-footer.png +0 -0
  83. package/images/ss-input-pet.png +0 -0
  84. package/images/ss-mode-custom.png +0 -0
  85. package/images/ss-questionnaire0.png +0 -0
  86. package/images/ss-questionnaire1.png +0 -0
  87. package/images/ss-simplebench.png +0 -0
  88. package/images/ss-todo.png +0 -0
  89. package/package.json +20 -12
@@ -11,7 +11,7 @@ import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-st
11
11
  import { getEffectiveConfig } from "./config.js";
12
12
  import { getIcons } from "./icons.js";
13
13
  import { getDefaultColors, fg } from "./theme.js";
14
- import { showFooterConfigurator } from "./configure.js";
14
+ import { registerFooterConfiguratorTab } from "./configure.js";
15
15
  import { fetchCodexUsage, refreshCodexUsageSnapshot, type CodexUsageSnapshot } from "./codex-usage.js";
16
16
  import { fetchOpenRouterUsage, refreshOpenRouterUsageSnapshot, type OpenRouterUsageSnapshot } from "./openrouter-usage.js";
17
17
 
@@ -21,6 +21,15 @@ const GIT_BRANCH_PATTERNS: RegExp[] = [
21
21
  /\bgit\s+stash\s+(pop|apply)/,
22
22
  ];
23
23
 
24
+ /**
25
+ * Only a real string is inspected. A tool call over the wire can carry an array or object
26
+ * in `input.command`, and `String()` of it is either "[object Object]" (no match, stale
27
+ * counts) or a joined "git checkout main" that matches by accident.
28
+ */
29
+ export function mentionsGitBranchChange(command: unknown): boolean {
30
+ return typeof command === "string" && GIT_BRANCH_PATTERNS.some((pattern) => pattern.test(command));
31
+ }
32
+
24
33
  /** Row keys used to detect whether an optional segment is enabled. */
25
34
  const CODEX_USAGE_REFRESH_MS = 30_000;
26
35
 
@@ -37,12 +46,109 @@ const LAYOUT_ROWS = [
37
46
  // Status Line Builder
38
47
  // ═══════════════════════════════════════════════════════════════════════════
39
48
 
49
+ const isAssistantMessageEvent = (e: SessionEvent): e is AssistantMessageEvent =>
50
+ e.type === "message" && (e as AssistantMessageEvent).message.role === "assistant";
51
+
52
+ const isThinkingEvent = (e: SessionEvent): e is ThinkingLevelEvent =>
53
+ e.type === "thinking_level_change";
54
+
55
+ /**
56
+ * Branch-derived footer inputs. Every field is a pass over the whole branch and the
57
+ * footer re-renders on each keypress, so they are memoised by session, leaf, branch
58
+ * length, context window and whether canonical usage needs an estimate.
59
+ */
60
+ export interface BranchFacts {
61
+ key: string;
62
+ usageStats: UsageStats;
63
+ thinkingLevelFromSession: string | null;
64
+ estimatedContextUsage: { tokens: number; percent: number } | null;
65
+ branchPrompts: number;
66
+ branchCompactions: number;
67
+ branchApiCalls: number;
68
+ branchToolCalls: number;
69
+ }
70
+
71
+ function emptyBranchFacts(key: string): BranchFacts {
72
+ return {
73
+ key,
74
+ usageStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
75
+ thinkingLevelFromSession: null,
76
+ estimatedContextUsage: null,
77
+ branchPrompts: 0,
78
+ branchCompactions: 0,
79
+ branchApiCalls: 0,
80
+ branchToolCalls: 0,
81
+ };
82
+ }
83
+
84
+ export function deriveBranchFacts(
85
+ branch: SessionEvent[],
86
+ key: string,
87
+ opts: {
88
+ contextWindow: number;
89
+ needsEstimate: boolean;
90
+ projectedMessages: Parameters<typeof estimateContextUsage>[0] | undefined;
91
+ },
92
+ ): BranchFacts {
93
+ const completedMessages = branch
94
+ .filter(isAssistantMessageEvent)
95
+ .map((e) => e.message as AssistantMessage)
96
+ .filter((m) => m.stopReason !== "error" && m.stopReason !== "aborted");
97
+ // Session entries are host data: a missing usage block reads as zero instead of
98
+ // throwing and blanking every row.
99
+ const usageStats = completedMessages.reduce<UsageStats>(
100
+ (acc, m) => {
101
+ const u = (m.usage ?? {}) as unknown as Partial<UsageStats> & { cost?: { total?: number } };
102
+ return {
103
+ input: acc.input + (u.input ?? 0),
104
+ output: acc.output + (u.output ?? 0),
105
+ cacheRead: acc.cacheRead + (u.cacheRead ?? 0),
106
+ cacheWrite: acc.cacheWrite + (u.cacheWrite ?? 0),
107
+ cost: acc.cost + (u.cost?.total ?? 0),
108
+ };
109
+ },
110
+ { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
111
+ );
112
+ const thinkingEvents = branch.filter(isThinkingEvent);
113
+ return {
114
+ key,
115
+ usageStats,
116
+ // Seed null, not "off": a truthy seed made the ?? fallback unreachable, so a branch
117
+ // with no thinking_level_change entry rendered "off" instead of Pi's real level.
118
+ thinkingLevelFromSession: thinkingEvents.length
119
+ ? thinkingEvents.reduce((_, e) => e.thinkingLevel ?? "off", "off")
120
+ : null,
121
+ estimatedContextUsage:
122
+ opts.needsEstimate && opts.contextWindow > 0 && opts.projectedMessages
123
+ ? estimateContextUsage(opts.projectedMessages, opts.contextWindow)
124
+ : null,
125
+ // Prompt/API/tool counts are reconstructed from the branch so they survive quit/resume.
126
+ // Timing stats (llmMs, toolMs, ttftSamples) are ephemeral — not stored in messages.
127
+ branchPrompts: branch.filter(
128
+ (e) => e.type === "message" && (e as AssistantMessageEvent).message?.role === "user",
129
+ ).length,
130
+ branchCompactions: branch.filter((entry) => entry.type === "compaction").length,
131
+ branchApiCalls: completedMessages.length,
132
+ branchToolCalls: completedMessages.reduce((sum, msg) => {
133
+ const content = (msg as { content?: unknown }).content;
134
+ return sum + (Array.isArray(content) ? content.filter((block) => (block as any)?.type === "toolCall").length : 0);
135
+ }, 0),
136
+ };
137
+ }
138
+
40
139
  /** Render a single segment and return its content with width */
41
140
  function renderSegmentWithWidth(
42
141
  segId: StatusLineSegmentId,
43
142
  ctx: SegmentContext
44
143
  ): { content: string; width: number; visible: boolean } {
45
- const rendered = renderSegment(segId, ctx);
144
+ let rendered;
145
+ try {
146
+ rendered = renderSegment(segId, ctx);
147
+ } catch {
148
+ // One throwing segment used to blank the whole footer on every frame. Show a
149
+ // marker in its place so the failure is visible and the rest still renders.
150
+ return { content: "[?]", width: 3, visible: true };
151
+ }
46
152
  if (!rendered.visible || !rendered.content) {
47
153
  return { content: "", width: 0, visible: false };
48
154
  }
@@ -58,7 +164,7 @@ function buildFooterContent(
58
164
  leftSegments: StatusLineSegmentId[],
59
165
  rightSegments: StatusLineSegmentId[],
60
166
  availableWidth: number,
61
- ): string {
167
+ ): { text: string; visible: boolean } {
62
168
  const maxContentWidth = Math.max(0, availableWidth - 2);
63
169
 
64
170
  // Render left segments
@@ -87,15 +193,25 @@ function buildFooterContent(
87
193
  let leftStr = leftParts.join(" ");
88
194
  let rightStr = rightParts.join(" ");
89
195
 
196
+ // A row whose segments are all disabled is not padding: report it as invisible so
197
+ // render() can drop the line and its divider instead of charging the terminal a
198
+ // fixed six-line footer.
199
+ if (leftParts.length === 0 && rightParts.length === 0) {
200
+ return { text: "", visible: false };
201
+ }
202
+
90
203
  // Handle case with no right segments
91
204
  if (rightParts.length === 0) {
92
205
  const finalLeft = truncateToWidth(leftStr, maxContentWidth);
93
- return " " + finalLeft + " ".repeat(Math.max(0, maxContentWidth - visibleWidth(finalLeft))) + " ";
206
+ return {
207
+ text: " " + finalLeft + " ".repeat(Math.max(0, maxContentWidth - visibleWidth(finalLeft))) + " ",
208
+ visible: true,
209
+ };
94
210
  }
95
211
 
96
212
  // If right side alone is too big, just show right side
97
213
  if (rightWidth >= maxContentWidth) {
98
- return " " + truncateToWidth(rightStr, maxContentWidth) + " ";
214
+ return { text: " " + truncateToWidth(rightStr, maxContentWidth) + " ", visible: true };
99
215
  }
100
216
 
101
217
  // Ensure at least 1 space between left and right
@@ -106,7 +222,7 @@ function buildFooterContent(
106
222
  const padding = maxContentWidth - finalLeftWidth - rightWidth;
107
223
 
108
224
  const result = " " + finalLeft + " ".repeat(padding) + rightStr + " ";
109
- return truncateToWidth(result, availableWidth);
225
+ return { text: truncateToWidth(result, availableWidth), visible: true };
110
226
  }
111
227
 
112
228
  // ═══════════════════════════════════════════════════════════════════════════
@@ -117,21 +233,39 @@ export default function footer(pi: ExtensionAPI) {
117
233
  let sessionStartTime = Date.now();
118
234
  let currentCtx: ExtensionContext | null = null;
119
235
  let footerDataRef: ReadonlyFooterDataProvider | null = null;
120
- let lastBranchLength = 0;
121
- let cachedUsageStats: UsageStats | null = null;
236
+ // Every field below is a pass over the whole branch. The footer re-renders on each
237
+ // keypress, so unchanged branch identities reuse the derived inputs instead.
238
+ let branchFacts: BranchFacts | null = null;
122
239
  let tuiRef: TUI | null = null;
123
240
  let codexUsage: CodexUsageSnapshot | null = null;
124
241
  let codexUsageLastAttempt = 0;
125
242
  let codexUsageGeneration = 0;
126
243
  let codexUsageInFlight: Promise<void> | null = null;
244
+ let codexUsageTimer: ReturnType<typeof setTimeout> | null = null;
245
+ let codexUsagePendingCtx: ExtensionContext | null = null;
246
+
247
+ const cancelCodexUsageRefresh = () => {
248
+ if (codexUsageTimer !== null) clearTimeout(codexUsageTimer);
249
+ codexUsageTimer = null;
250
+ codexUsagePendingCtx = null;
251
+ };
127
252
  let openRouterUsage: OpenRouterUsageSnapshot | null = null;
128
253
  let openRouterUsageLastAttempt = 0;
129
254
  let openRouterUsageGeneration = 0;
130
255
  let openRouterUsageInFlight: Promise<void> | null = null;
256
+ let openRouterUsageTimer: ReturnType<typeof setTimeout> | null = null;
257
+ let openRouterUsagePendingCtx: ExtensionContext | null = null;
258
+
259
+ const cancelOpenRouterUsageRefresh = () => {
260
+ if (openRouterUsageTimer !== null) clearTimeout(openRouterUsageTimer);
261
+ openRouterUsageTimer = null;
262
+ openRouterUsagePendingCtx = null;
263
+ };
131
264
 
132
265
  const refreshOpenRouterUsage = (ctx: ExtensionContext, force = false): Promise<void> => {
133
266
  const isOpenRouter = ctx.model?.provider === "openrouter";
134
267
  if (!isOpenRouter) {
268
+ cancelOpenRouterUsageRefresh();
135
269
  openRouterUsageGeneration++;
136
270
  openRouterUsageInFlight = null;
137
271
  openRouterUsage = null;
@@ -142,6 +276,7 @@ export default function footer(pi: ExtensionAPI) {
142
276
  const config = getEffectiveConfig();
143
277
  const enabled = LAYOUT_ROWS.some((row) => config[row]?.includes("openrouter_usage"));
144
278
  if (!enabled) {
279
+ cancelOpenRouterUsageRefresh();
145
280
  openRouterUsageGeneration++;
146
281
  openRouterUsageInFlight = null;
147
282
  openRouterUsage = null;
@@ -149,10 +284,23 @@ export default function footer(pi: ExtensionAPI) {
149
284
  tuiRef?.requestRender();
150
285
  return Promise.resolve();
151
286
  }
152
- if (openRouterUsageInFlight) return openRouterUsageInFlight;
287
+ if (openRouterUsageInFlight) {
288
+ openRouterUsagePendingCtx = ctx;
289
+ return openRouterUsageInFlight;
290
+ }
153
291
 
154
292
  const now = Date.now();
155
- if (!force && now - openRouterUsageLastAttempt < CODEX_USAGE_REFRESH_MS) return Promise.resolve();
293
+ const remaining = CODEX_USAGE_REFRESH_MS - (now - openRouterUsageLastAttempt);
294
+ if (!force && remaining > 0) {
295
+ if (openRouterUsageTimer === null) {
296
+ openRouterUsageTimer = setTimeout(() => {
297
+ openRouterUsageTimer = null;
298
+ void refreshOpenRouterUsage(ctx);
299
+ }, remaining);
300
+ }
301
+ return Promise.resolve();
302
+ }
303
+ cancelOpenRouterUsageRefresh();
156
304
  openRouterUsageLastAttempt = now;
157
305
  const generation = ++openRouterUsageGeneration;
158
306
 
@@ -165,6 +313,10 @@ export default function footer(pi: ExtensionAPI) {
165
313
  })
166
314
  .finally(() => {
167
315
  if (openRouterUsageInFlight === request) openRouterUsageInFlight = null;
316
+ if (generation !== openRouterUsageGeneration) return;
317
+ const pendingCtx = openRouterUsagePendingCtx;
318
+ openRouterUsagePendingCtx = null;
319
+ if (pendingCtx) void refreshOpenRouterUsage(pendingCtx);
168
320
  });
169
321
  openRouterUsageInFlight = request;
170
322
  return request;
@@ -174,6 +326,7 @@ export default function footer(pi: ExtensionAPI) {
174
326
  const model = ctx.model;
175
327
  const isCodexSubscription = model?.provider === "openai-codex" && ctx.modelRegistry.isUsingOAuth(model);
176
328
  if (!isCodexSubscription) {
329
+ cancelCodexUsageRefresh();
177
330
  codexUsageGeneration++;
178
331
  codexUsageInFlight = null;
179
332
  codexUsage = null;
@@ -184,6 +337,7 @@ export default function footer(pi: ExtensionAPI) {
184
337
  const config = getEffectiveConfig();
185
338
  const enabled = LAYOUT_ROWS.some((row) => config[row]?.includes("codex_usage"));
186
339
  if (!enabled) {
340
+ cancelCodexUsageRefresh();
187
341
  codexUsageGeneration++;
188
342
  codexUsageInFlight = null;
189
343
  codexUsage = null;
@@ -191,10 +345,23 @@ export default function footer(pi: ExtensionAPI) {
191
345
  tuiRef?.requestRender();
192
346
  return Promise.resolve();
193
347
  }
194
- if (codexUsageInFlight) return codexUsageInFlight;
348
+ if (codexUsageInFlight) {
349
+ codexUsagePendingCtx = ctx;
350
+ return codexUsageInFlight;
351
+ }
195
352
 
196
353
  const now = Date.now();
197
- if (!force && now - codexUsageLastAttempt < CODEX_USAGE_REFRESH_MS) return Promise.resolve();
354
+ const remaining = CODEX_USAGE_REFRESH_MS - (now - codexUsageLastAttempt);
355
+ if (!force && remaining > 0) {
356
+ if (codexUsageTimer === null) {
357
+ codexUsageTimer = setTimeout(() => {
358
+ codexUsageTimer = null;
359
+ void refreshCodexUsage(ctx);
360
+ }, remaining);
361
+ }
362
+ return Promise.resolve();
363
+ }
364
+ cancelCodexUsageRefresh();
198
365
  codexUsageLastAttempt = now;
199
366
  const generation = ++codexUsageGeneration;
200
367
 
@@ -207,6 +374,10 @@ export default function footer(pi: ExtensionAPI) {
207
374
  })
208
375
  .finally(() => {
209
376
  if (codexUsageInFlight === request) codexUsageInFlight = null;
377
+ if (generation !== codexUsageGeneration) return;
378
+ const pendingCtx = codexUsagePendingCtx;
379
+ codexUsagePendingCtx = null;
380
+ if (pendingCtx) void refreshCodexUsage(pendingCtx);
210
381
  });
211
382
  codexUsageInFlight = request;
212
383
  return request;
@@ -224,27 +395,26 @@ export default function footer(pi: ExtensionAPI) {
224
395
  let statusTracker = createAgentStatusTracker();
225
396
  const toolStartTimes = new Map<string, number>();
226
397
 
227
- pi.registerCommand("configure-opl", {
228
- description: "Interactively configure the OPL footer layout",
229
- handler: async (_args, ctx) => {
230
- await showFooterConfigurator(ctx, () => {
231
- tuiRef?.requestRender();
232
- void refreshCodexUsage(ctx, true);
233
- void refreshOpenRouterUsage(ctx, true);
234
- });
235
- },
398
+ // The footer exposes itself to opl-configurator's generic tabbed shell; the shell owns
399
+ // the /configurator command, so a session that reloads the config from another tab still
400
+ // re-renders the footer and refreshes quota usage here.
401
+ registerFooterConfiguratorTab((ctx) => {
402
+ tuiRef?.requestRender();
403
+ void refreshCodexUsage(ctx, true);
404
+ void refreshOpenRouterUsage(ctx, true);
236
405
  });
237
406
 
238
407
  // Track session start
239
408
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
240
409
  sessionStartTime = Date.now();
241
410
  currentCtx = ctx;
242
- lastBranchLength = 0;
243
- cachedUsageStats = null;
411
+ branchFacts = null;
412
+ cancelCodexUsageRefresh();
244
413
  codexUsage = null;
245
414
  codexUsageLastAttempt = 0;
246
415
  codexUsageGeneration++;
247
416
  codexUsageInFlight = null;
417
+ cancelOpenRouterUsageRefresh();
248
418
  openRouterUsage = null;
249
419
  openRouterUsageLastAttempt = 0;
250
420
  openRouterUsageGeneration++;
@@ -267,6 +437,34 @@ export default function footer(pi: ExtensionAPI) {
267
437
  }
268
438
  });
269
439
 
440
+ pi.on("session_shutdown", async () => {
441
+ branchFacts = null;
442
+ cancelCodexUsageRefresh();
443
+ codexUsageGeneration++;
444
+ codexUsageInFlight = null;
445
+ cancelOpenRouterUsageRefresh();
446
+ openRouterUsageGeneration++;
447
+ openRouterUsageInFlight = null;
448
+ // The seam is a closure over this session's TUI. Leaving it installed hands `opl-modes`
449
+ // a dead component to render into on the next session, so it goes with the footer.
450
+ delete (globalThis as Record<string, unknown>).__footerRequestRender;
451
+ });
452
+
453
+ const invalidateBranchFacts = (_event: unknown, ctx: ExtensionContext) => {
454
+ currentCtx = ctx;
455
+ branchFacts = null;
456
+ tuiRef?.requestRender();
457
+ };
458
+ pi.on("session_tree", invalidateBranchFacts);
459
+ pi.on("session_compact", invalidateBranchFacts);
460
+
461
+ pi.on("message_end", async (event, ctx) => {
462
+ if (event.message.role === "assistant") {
463
+ void refreshCodexUsage(ctx);
464
+ void refreshOpenRouterUsage(ctx);
465
+ }
466
+ });
467
+
270
468
  pi.on("model_select", async (_event: unknown, ctx: ExtensionContext) => {
271
469
  void refreshCodexUsage(ctx, true);
272
470
  void refreshOpenRouterUsage(ctx, true);
@@ -320,6 +518,8 @@ export default function footer(pi: ExtensionAPI) {
320
518
  }
321
519
  statusTracker.toolEnded(event.toolCallId);
322
520
  tuiRef?.requestRender();
521
+ void refreshCodexUsage(_ctx);
522
+ void refreshOpenRouterUsage(_ctx);
323
523
  });
324
524
 
325
525
  pi.on("message_update", async (_event: unknown, _ctx: ExtensionContext) => {
@@ -334,19 +534,16 @@ export default function footer(pi: ExtensionAPI) {
334
534
  if (event.toolName === "write" || event.toolName === "edit") {
335
535
  invalidateGitStatus();
336
536
  }
337
- if (event.toolName === "bash" && event.input?.command) {
338
- const cmd = String(event.input.command);
339
- if (GIT_BRANCH_PATTERNS.some(p => p.test(cmd))) {
340
- invalidateGitStatus();
341
- invalidateGitBranch();
342
- setTimeout(() => tuiRef?.requestRender(), 100);
343
- }
537
+ if (event.toolName === "bash" && mentionsGitBranchChange(event.input?.command)) {
538
+ invalidateGitStatus();
539
+ invalidateGitBranch();
540
+ setTimeout(() => tuiRef?.requestRender(), 100);
344
541
  }
345
542
  });
346
543
 
347
544
  // Also catch user escape commands (! prefix)
348
545
  pi.on("user_bash", async (event: UserBashEvent, _ctx: ExtensionContext) => {
349
- if (GIT_BRANCH_PATTERNS.some(p => p.test(event.command))) {
546
+ if (mentionsGitBranchChange(event.command)) {
350
547
  invalidateGitStatus();
351
548
  invalidateGitBranch();
352
549
  tuiRef?.requestRender();
@@ -358,53 +555,38 @@ export default function footer(pi: ExtensionAPI) {
358
555
  const colors = effectiveConfig.colors ?? getDefaultColors();
359
556
 
360
557
  const branch = (ctx.sessionManager?.getBranch?.() ?? []) as SessionEvent[];
361
- const branchLen = branch.length;
362
-
363
- const isAssistantMessageEvent = (e: SessionEvent): e is AssistantMessageEvent =>
364
- e.type === "message" && (e as AssistantMessageEvent).message.role === "assistant";
365
- const completedMessages = branch
366
- .filter(isAssistantMessageEvent)
367
- .map(e => e.message as AssistantMessage)
368
- .filter(m => m.stopReason !== "error" && m.stopReason !== "aborted");
369
-
370
- // Cache usageStats — only recompute when branch grows
371
- let usageStats: UsageStats;
372
- if (cachedUsageStats && branchLen === lastBranchLength) {
373
- usageStats = cachedUsageStats;
374
- } else {
375
- usageStats = completedMessages.reduce<UsageStats>(
376
- (acc, m) => ({
377
- input: acc.input + m.usage.input,
378
- output: acc.output + m.usage.output,
379
- cacheRead: acc.cacheRead + m.usage.cacheRead,
380
- cacheWrite: acc.cacheWrite + m.usage.cacheWrite,
381
- cost: acc.cost + m.usage.cost.total,
382
- }),
383
- { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
384
- );
385
- cachedUsageStats = usageStats;
386
- lastBranchLength = branchLen;
387
- }
388
-
389
- const isThinkingEvent = (e: SessionEvent): e is ThinkingLevelEvent =>
390
- e.type === "thinking_level_change";
391
- const thinkingEvents = branch.filter(isThinkingEvent);
392
- // Seed null, not "off": a truthy seed made the ?? fallback below unreachable, so a
393
- // branch with no thinking_level_change entry rendered "off" instead of Pi's real level.
394
- const thinkingLevelFromSession = thinkingEvents.length
395
- ? thinkingEvents.reduce((_, e) => e.thinkingLevel ?? "off", "off")
396
- : null;
397
558
 
398
559
  // Prefer Pi's canonical context usage (0.87). Immediately after compaction it is
399
560
  // null until the next assistant reply, so estimate the rebuilt projection instead.
400
561
  const usage = typeof ctx.getContextUsage === "function" ? ctx.getContextUsage() : undefined;
401
562
  const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
402
- const projectedMessages = ctx.sessionManager?.buildSessionProjection?.().messages;
403
- const estimatedContextUsage = usage?.percent === null && contextWindow > 0 && projectedMessages
404
- ? estimateContextUsage(projectedMessages, contextWindow)
405
- : null;
406
- const contextPercent = usage?.percent ?? estimatedContextUsage?.percent ?? null;
407
- const contextEstimated = estimatedContextUsage !== null;
563
+
564
+ const needsEstimate = usage?.percent === null;
565
+ const factsKey = JSON.stringify([
566
+ ctx.sessionManager?.getSessionId?.(), ctx.sessionManager?.getLeafId?.(),
567
+ branch.length, contextWindow, needsEstimate,
568
+ ]);
569
+ let facts = branchFacts;
570
+ if (!facts || facts.key !== factsKey) {
571
+ const projectedMessages = needsEstimate
572
+ ? ctx.sessionManager?.buildSessionProjection?.().messages
573
+ : undefined;
574
+ try {
575
+ facts = deriveBranchFacts(branch, factsKey, {
576
+ contextWindow,
577
+ needsEstimate,
578
+ projectedMessages,
579
+ });
580
+ } catch {
581
+ // The transcript is host data: an unexpected entry should degrade the cells that
582
+ // read it, not throw and blank every row.
583
+ facts = emptyBranchFacts(factsKey);
584
+ }
585
+ branchFacts = facts;
586
+ }
587
+
588
+ const contextPercent = usage?.percent ?? facts.estimatedContextUsage?.percent ?? null;
589
+ const contextEstimated = needsEstimate && facts.estimatedContextUsage !== null;
408
590
 
409
591
  // Get git status (cached). Skip the probes entirely when no visible row renders the
410
592
  // git segment — otherwise an unused cell keeps spawning git once per second.
@@ -423,37 +605,36 @@ export default function footer(pi: ExtensionAPI) {
423
605
  // This allows litellm proxy to correctly separate local vs cloud models
424
606
  const isLocalModel = ctx.model?.id?.startsWith?.("local.") ?? false;
425
607
 
426
- // Reconstruct prompt/API-call/tool-call counts from the branch so they survive quit/resume.
427
- // Timing stats (llmMs, toolMs, ttftSamples) are ephemeral — not stored in messages.
428
- const branchPrompts = branch.filter(
429
- (e) => e.type === "message" && (e as AssistantMessageEvent).message?.role === "user",
430
- ).length;
431
- const branchApiCalls = completedMessages.length;
432
-
433
- // Count tool calls from assistant message content
434
- const branchToolCalls = completedMessages.reduce((sum, msg) => {
435
- const content = (msg as any).content || [];
436
- return sum + content.filter((block: any) => block?.type === "toolCall").length;
437
- }, 0);
438
-
439
608
  return {
440
609
  model: ctx.model,
441
610
  isLocalModel,
442
- thinkingLevel: thinkingLevelFromSession ?? pi.getThinkingLevel(),
611
+ thinkingLevel: facts.thinkingLevelFromSession ?? pi.getThinkingLevel(),
443
612
  sessionId: ctx.sessionManager?.getSessionId?.(),
444
- usageStats,
613
+ usageStats: facts.usageStats,
445
614
  contextPercent,
446
615
  contextEstimated,
447
616
  contextWindow,
448
617
  usingSubscription,
449
618
  sessionStartTime,
619
+ // The session directory, not the directory the harness was launched from: a session opened
620
+ // elsewhere must show that project's path.
621
+ cwd: ctx.cwd || process.cwd(),
450
622
  git: gitStatus,
451
623
  options: effectiveConfig.segmentOptions ?? {},
452
624
  width,
453
625
  theme,
454
626
  colors,
455
627
  icons: getIcons(effectiveConfig.icons),
456
- sessionStats: { prompts: branchPrompts, apiCalls: branchApiCalls, toolCalls: branchToolCalls, llmMs, toolMs, ttftSamples, lastTurnaroundMs },
628
+ sessionStats: {
629
+ prompts: facts.branchPrompts,
630
+ apiCalls: facts.branchApiCalls,
631
+ toolCalls: facts.branchToolCalls,
632
+ llmMs,
633
+ toolMs,
634
+ ttftSamples,
635
+ lastTurnaroundMs,
636
+ },
637
+ compactions: facts.branchCompactions,
457
638
  agentStatus: statusTracker.status(),
458
639
  codexUsage,
459
640
  openRouterUsage,
@@ -485,28 +666,25 @@ export default function footer(pi: ExtensionAPI) {
485
666
  return [];
486
667
  }
487
668
 
488
- const row1 = buildFooterContent(
489
- segmentCtx,
490
- effectiveConfig.row1LeftSegments,
491
- effectiveConfig.row1RightSegments,
492
- width,
493
- );
494
- const row2 = buildFooterContent(
495
- segmentCtx,
496
- effectiveConfig.row2LeftSegments,
497
- effectiveConfig.row2RightSegments,
498
- width,
499
- );
500
- const row3 = buildFooterContent(
501
- segmentCtx,
502
- effectiveConfig.row3LeftSegments,
503
- effectiveConfig.row3RightSegments,
504
- width,
505
- );
669
+ const rowSpecs: Array<[StatusLineSegmentId[], StatusLineSegmentId[]]> = [
670
+ [effectiveConfig.row1LeftSegments, effectiveConfig.row1RightSegments],
671
+ [effectiveConfig.row2LeftSegments, effectiveConfig.row2RightSegments],
672
+ [effectiveConfig.row3LeftSegments, effectiveConfig.row3RightSegments],
673
+ ];
674
+ const rows = rowSpecs
675
+ .map(([left, right]) => buildFooterContent(segmentCtx, left, right, width))
676
+ .filter((row) => row.visible);
677
+ if (rows.length === 0) return [];
506
678
 
507
679
  const divider = fg(theme, "separator", "─".repeat(width), segmentCtx.colors);
508
-
509
- return ["", row1, divider, row2, divider, row3];
680
+ // The leading blank keeps the transcript off the first footer line. Dividers
681
+ // only go between rows that exist, so a one-row layout costs two lines, not six.
682
+ const lines: string[] = [""];
683
+ rows.forEach((row, index) => {
684
+ if (index > 0) lines.push(divider);
685
+ lines.push(row.text);
686
+ });
687
+ return lines;
510
688
  },
511
689
  };
512
690
  });
@@ -7,8 +7,16 @@ interface CavemanState {
7
7
  mode: string;
8
8
  }
9
9
 
10
+ /**
11
+ * Nothing in this collection writes `__caveman` - a standalone extension does - so the shape
12
+ * is validated instead of cast: `mode.toUpperCase()` is a crash on a number, and a footer
13
+ * that throws takes every row down with it.
14
+ */
10
15
  function readCavemanState(): CavemanState | undefined {
11
- return (globalThis as Record<string, unknown>).__caveman as CavemanState | undefined;
16
+ const state = (globalThis as Record<string, unknown>).__caveman as unknown;
17
+ if (!state || typeof state !== "object") return undefined;
18
+ const { enabled, mode } = state as { enabled?: unknown; mode?: unknown };
19
+ return typeof enabled === "boolean" && typeof mode === "string" ? { enabled, mode } : undefined;
12
20
  }
13
21
 
14
22
  export const cavemanSegment = {
@@ -6,8 +6,15 @@ interface ChatModeState {
6
6
  mode: string;
7
7
  }
8
8
 
9
+ /**
10
+ * Written by `opl-modes`, which may not be installed. Only a `{ mode: string }` is honoured;
11
+ * anything else reads as "no state", so the segment stays invisible instead of throwing.
12
+ */
9
13
  function readChatModeState(): ChatModeState | undefined {
10
- return (globalThis as Record<string, unknown>).__chatMode as ChatModeState | undefined;
14
+ const state = (globalThis as Record<string, unknown>).__chatMode as unknown;
15
+ if (!state || typeof state !== "object") return undefined;
16
+ const mode = (state as { mode?: unknown }).mode;
17
+ return typeof mode === "string" ? { mode } : undefined;
11
18
  }
12
19
 
13
20
  export const chatModeSegment = {
@@ -0,0 +1,13 @@
1
+ import type { RenderedSegment, SegmentContext } from "../types.js";
2
+ import { applyColor } from "../theme.js";
3
+
4
+ export const compactionsSegment = {
5
+ id: "compactions" as const,
6
+ render(ctx: SegmentContext): RenderedSegment {
7
+ if (ctx.compactions === 0) return { content: "", visible: false };
8
+ return {
9
+ content: `${ctx.icons.compress} ${applyColor(ctx.theme, "text", `${ctx.compactions}`)}`,
10
+ visible: true,
11
+ };
12
+ },
13
+ };
@@ -72,14 +72,15 @@ export const contextPctSegment = {
72
72
  const midFrac = barOpts.gradientMidPoint ?? DEFAULT_MID_FRAC;
73
73
 
74
74
  const filled = Math.round((pct / 100) * barWidth);
75
+ // Every unfilled cell is identical, so resolve its colour once instead of probing
76
+ // the theme once per cell on every frame.
77
+ const unfilledCell = applyColor(ctx.theme, unfilledColor, unfilledChar);
75
78
 
76
79
  let bar = "";
77
80
  for (let i = 0; i < barWidth; i++) {
78
- if (i < filled) {
79
- bar += positionColor(i, barWidth, start, mid, end, midFrac) + filledChar;
80
- } else {
81
- bar += applyColor(ctx.theme, unfilledColor, unfilledChar);
82
- }
81
+ bar += i < filled
82
+ ? positionColor(i, barWidth, start, mid, end, midFrac) + filledChar
83
+ : unfilledCell;
83
84
  }
84
85
  bar += "\x1b[0m";
85
86