@openlines/opl-pi-sht 0.2.12 → 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 (81) hide show
  1. package/README.md +92 -84
  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 +31 -6
  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 +209 -105
  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/package.json +19 -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,8 +233,9 @@ 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;
@@ -278,23 +395,20 @@ export default function footer(pi: ExtensionAPI) {
278
395
  let statusTracker = createAgentStatusTracker();
279
396
  const toolStartTimes = new Map<string, number>();
280
397
 
281
- pi.registerCommand("configure-opl", {
282
- description: "Interactively configure the OPL footer layout",
283
- handler: async (_args, ctx) => {
284
- await showFooterConfigurator(ctx, () => {
285
- tuiRef?.requestRender();
286
- void refreshCodexUsage(ctx, true);
287
- void refreshOpenRouterUsage(ctx, true);
288
- });
289
- },
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);
290
405
  });
291
406
 
292
407
  // Track session start
293
408
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
294
409
  sessionStartTime = Date.now();
295
410
  currentCtx = ctx;
296
- lastBranchLength = 0;
297
- cachedUsageStats = null;
411
+ branchFacts = null;
298
412
  cancelCodexUsageRefresh();
299
413
  codexUsage = null;
300
414
  codexUsageLastAttempt = 0;
@@ -324,14 +438,26 @@ export default function footer(pi: ExtensionAPI) {
324
438
  });
325
439
 
326
440
  pi.on("session_shutdown", async () => {
441
+ branchFacts = null;
327
442
  cancelCodexUsageRefresh();
328
443
  codexUsageGeneration++;
329
444
  codexUsageInFlight = null;
330
445
  cancelOpenRouterUsageRefresh();
331
446
  openRouterUsageGeneration++;
332
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;
333
451
  });
334
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
+
335
461
  pi.on("message_end", async (event, ctx) => {
336
462
  if (event.message.role === "assistant") {
337
463
  void refreshCodexUsage(ctx);
@@ -408,19 +534,16 @@ export default function footer(pi: ExtensionAPI) {
408
534
  if (event.toolName === "write" || event.toolName === "edit") {
409
535
  invalidateGitStatus();
410
536
  }
411
- if (event.toolName === "bash" && event.input?.command) {
412
- const cmd = String(event.input.command);
413
- if (GIT_BRANCH_PATTERNS.some(p => p.test(cmd))) {
414
- invalidateGitStatus();
415
- invalidateGitBranch();
416
- setTimeout(() => tuiRef?.requestRender(), 100);
417
- }
537
+ if (event.toolName === "bash" && mentionsGitBranchChange(event.input?.command)) {
538
+ invalidateGitStatus();
539
+ invalidateGitBranch();
540
+ setTimeout(() => tuiRef?.requestRender(), 100);
418
541
  }
419
542
  });
420
543
 
421
544
  // Also catch user escape commands (! prefix)
422
545
  pi.on("user_bash", async (event: UserBashEvent, _ctx: ExtensionContext) => {
423
- if (GIT_BRANCH_PATTERNS.some(p => p.test(event.command))) {
546
+ if (mentionsGitBranchChange(event.command)) {
424
547
  invalidateGitStatus();
425
548
  invalidateGitBranch();
426
549
  tuiRef?.requestRender();
@@ -432,53 +555,38 @@ export default function footer(pi: ExtensionAPI) {
432
555
  const colors = effectiveConfig.colors ?? getDefaultColors();
433
556
 
434
557
  const branch = (ctx.sessionManager?.getBranch?.() ?? []) as SessionEvent[];
435
- const branchLen = branch.length;
436
-
437
- const isAssistantMessageEvent = (e: SessionEvent): e is AssistantMessageEvent =>
438
- e.type === "message" && (e as AssistantMessageEvent).message.role === "assistant";
439
- const completedMessages = branch
440
- .filter(isAssistantMessageEvent)
441
- .map(e => e.message as AssistantMessage)
442
- .filter(m => m.stopReason !== "error" && m.stopReason !== "aborted");
443
-
444
- // Cache usageStats — only recompute when branch grows
445
- let usageStats: UsageStats;
446
- if (cachedUsageStats && branchLen === lastBranchLength) {
447
- usageStats = cachedUsageStats;
448
- } else {
449
- usageStats = completedMessages.reduce<UsageStats>(
450
- (acc, m) => ({
451
- input: acc.input + m.usage.input,
452
- output: acc.output + m.usage.output,
453
- cacheRead: acc.cacheRead + m.usage.cacheRead,
454
- cacheWrite: acc.cacheWrite + m.usage.cacheWrite,
455
- cost: acc.cost + m.usage.cost.total,
456
- }),
457
- { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
458
- );
459
- cachedUsageStats = usageStats;
460
- lastBranchLength = branchLen;
461
- }
462
-
463
- const isThinkingEvent = (e: SessionEvent): e is ThinkingLevelEvent =>
464
- e.type === "thinking_level_change";
465
- const thinkingEvents = branch.filter(isThinkingEvent);
466
- // Seed null, not "off": a truthy seed made the ?? fallback below unreachable, so a
467
- // branch with no thinking_level_change entry rendered "off" instead of Pi's real level.
468
- const thinkingLevelFromSession = thinkingEvents.length
469
- ? thinkingEvents.reduce((_, e) => e.thinkingLevel ?? "off", "off")
470
- : null;
471
558
 
472
559
  // Prefer Pi's canonical context usage (0.87). Immediately after compaction it is
473
560
  // null until the next assistant reply, so estimate the rebuilt projection instead.
474
561
  const usage = typeof ctx.getContextUsage === "function" ? ctx.getContextUsage() : undefined;
475
562
  const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
476
- const projectedMessages = ctx.sessionManager?.buildSessionProjection?.().messages;
477
- const estimatedContextUsage = usage?.percent === null && contextWindow > 0 && projectedMessages
478
- ? estimateContextUsage(projectedMessages, contextWindow)
479
- : null;
480
- const contextPercent = usage?.percent ?? estimatedContextUsage?.percent ?? null;
481
- 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;
482
590
 
483
591
  // Get git status (cached). Skip the probes entirely when no visible row renders the
484
592
  // git segment — otherwise an unused cell keeps spawning git once per second.
@@ -497,37 +605,36 @@ export default function footer(pi: ExtensionAPI) {
497
605
  // This allows litellm proxy to correctly separate local vs cloud models
498
606
  const isLocalModel = ctx.model?.id?.startsWith?.("local.") ?? false;
499
607
 
500
- // Reconstruct prompt/API-call/tool-call counts from the branch so they survive quit/resume.
501
- // Timing stats (llmMs, toolMs, ttftSamples) are ephemeral — not stored in messages.
502
- const branchPrompts = branch.filter(
503
- (e) => e.type === "message" && (e as AssistantMessageEvent).message?.role === "user",
504
- ).length;
505
- const branchApiCalls = completedMessages.length;
506
-
507
- // Count tool calls from assistant message content
508
- const branchToolCalls = completedMessages.reduce((sum, msg) => {
509
- const content = (msg as any).content || [];
510
- return sum + content.filter((block: any) => block?.type === "toolCall").length;
511
- }, 0);
512
-
513
608
  return {
514
609
  model: ctx.model,
515
610
  isLocalModel,
516
- thinkingLevel: thinkingLevelFromSession ?? pi.getThinkingLevel(),
611
+ thinkingLevel: facts.thinkingLevelFromSession ?? pi.getThinkingLevel(),
517
612
  sessionId: ctx.sessionManager?.getSessionId?.(),
518
- usageStats,
613
+ usageStats: facts.usageStats,
519
614
  contextPercent,
520
615
  contextEstimated,
521
616
  contextWindow,
522
617
  usingSubscription,
523
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(),
524
622
  git: gitStatus,
525
623
  options: effectiveConfig.segmentOptions ?? {},
526
624
  width,
527
625
  theme,
528
626
  colors,
529
627
  icons: getIcons(effectiveConfig.icons),
530
- 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,
531
638
  agentStatus: statusTracker.status(),
532
639
  codexUsage,
533
640
  openRouterUsage,
@@ -559,28 +666,25 @@ export default function footer(pi: ExtensionAPI) {
559
666
  return [];
560
667
  }
561
668
 
562
- const row1 = buildFooterContent(
563
- segmentCtx,
564
- effectiveConfig.row1LeftSegments,
565
- effectiveConfig.row1RightSegments,
566
- width,
567
- );
568
- const row2 = buildFooterContent(
569
- segmentCtx,
570
- effectiveConfig.row2LeftSegments,
571
- effectiveConfig.row2RightSegments,
572
- width,
573
- );
574
- const row3 = buildFooterContent(
575
- segmentCtx,
576
- effectiveConfig.row3LeftSegments,
577
- effectiveConfig.row3RightSegments,
578
- width,
579
- );
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 [];
580
678
 
581
679
  const divider = fg(theme, "separator", "─".repeat(width), segmentCtx.colors);
582
-
583
- 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;
584
688
  },
585
689
  };
586
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
 
@@ -13,6 +13,7 @@ import { planModeSegment } from "./plan-mode.js";
13
13
  import { chatModeSegment } from "./chat-mode.js";
14
14
  import { modeSwitcherSegment } from "./mode-switcher.js";
15
15
  import { sessionStatsSegment, perfStatsSegment } from "./session-stats.js";
16
+ import { compactionsSegment } from "./compactions.js";
16
17
  import { statusSegment } from "./status.js";
17
18
  import { codexUsageSegment } from "./codex-usage.js";
18
19
  import { openRouterUsageSegment } from "./openrouter-usage.js";
@@ -37,6 +38,7 @@ const SEGMENTS = {
37
38
  chat_mode: chatModeSegment,
38
39
  mode_switcher: modeSwitcherSegment,
39
40
  session_stats: sessionStatsSegment,
41
+ compactions: compactionsSegment,
40
42
  perf_stats: perfStatsSegment,
41
43
  status: statusSegment,
42
44
  codex_usage: codexUsageSegment,
@@ -6,8 +6,21 @@ interface AgentModeState {
6
6
  appearance?: { modeColor?: string };
7
7
  }
8
8
 
9
+ /**
10
+ * Written by `opl-modes`, which may not be installed, and may be written by anything else
11
+ * that claims the name. Only a `{ mode: string }` is honoured; anything else renders nothing
12
+ * rather than throwing inside the footer's render loop.
13
+ */
9
14
  function readAgentModeState(): AgentModeState | undefined {
10
- return (globalThis as Record<string, unknown>).__agentMode as AgentModeState | undefined;
15
+ const state = (globalThis as Record<string, unknown>).__agentMode as unknown;
16
+ if (!state || typeof state !== "object") return undefined;
17
+ const { mode, appearance } = state as { mode?: unknown; appearance?: unknown };
18
+ if (typeof mode !== "string" || !mode) return undefined;
19
+ const modeColor =
20
+ appearance && typeof appearance === "object"
21
+ ? (appearance as { modeColor?: unknown }).modeColor
22
+ : undefined;
23
+ return { mode, ...(typeof modeColor === "string" ? { appearance: { modeColor } } : {}) };
11
24
  }
12
25
 
13
26
  export const modeSwitcherSegment = {
@@ -8,7 +8,7 @@ export const pathSegment = {
8
8
  const opts = ctx.options.path ?? {};
9
9
  const mode = opts.mode ?? "basename";
10
10
 
11
- let pwd = process.cwd();
11
+ let pwd = ctx.cwd || process.cwd();
12
12
  const home = process.env.HOME || process.env.USERPROFILE;
13
13
 
14
14
  if (mode === "basename") {
@@ -6,8 +6,15 @@ interface PlanModeState {
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 readPlanModeState(): PlanModeState | undefined {
10
- return (globalThis as Record<string, unknown>).__planMode as PlanModeState | undefined;
14
+ const state = (globalThis as Record<string, unknown>).__planMode 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 planModeSegment = {
@@ -5,7 +5,8 @@ import type { ColorScheme, ColorValue, SemanticColor } from "./types.js";
5
5
  // their absence signals "use rainbow" in thinking.ts.
6
6
  const DEFAULT_COLORS: ColorScheme = {
7
7
  pi: "accent",
8
- model: "#c07898",
8
+ // model: "#c07898",
9
+ model: "accent",
9
10
  path: "dim",
10
11
  git: "success",
11
12
  gitDirty: "warning",
@@ -128,5 +129,7 @@ export function resolveColorToRgb(
128
129
  }
129
130
  const match = probed.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
130
131
  if (!match) return null;
131
- return { r: parseInt(match[1]), g: parseInt(match[2]), b: parseInt(match[3]) };
132
+ const [r, g, b] = [match[1], match[2], match[3]];
133
+ if (r === undefined || g === undefined || b === undefined) return null;
134
+ return { r: parseInt(r, 10), g: parseInt(g, 10), b: parseInt(b, 10) };
132
135
  }
@@ -54,6 +54,7 @@ export type StatusLineSegmentId =
54
54
  | "chat_mode"
55
55
  | "mode_switcher"
56
56
  | "session_stats"
57
+ | "compactions"
57
58
  | "perf_stats"
58
59
  | "status"
59
60
  | "codex_usage"
@@ -127,6 +128,8 @@ export interface SegmentContext {
127
128
  contextWindow: number;
128
129
  usingSubscription: boolean;
129
130
  sessionStartTime: number;
131
+ /** Session directory (`ExtensionContext.cwd`): the base the Path segment renders. */
132
+ cwd: string;
130
133
  git: GitStatus;
131
134
  options: StatusLineSegmentOptions;
132
135
  width: number;
@@ -134,6 +137,7 @@ export interface SegmentContext {
134
137
  colors: ColorScheme;
135
138
  icons: IconSet;
136
139
  sessionStats: SessionStats;
140
+ compactions: number;
137
141
  agentStatus: AgentStatus;
138
142
  codexUsage: CodexUsageSnapshot | null;
139
143
  openRouterUsage: OpenRouterUsageSnapshot | null;