@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.
- package/README.md +92 -84
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-footer.json.sample +3 -2
- package/configs/opl-guardian.json.sample +8 -2
- package/configs/opl-modes.json.sample +5 -0
- package/configs/opl-webaccess.json.sample +7 -1
- package/extensions/opl-browser/README.md +81 -55
- package/extensions/opl-browser/browser.ts +244 -50
- package/extensions/opl-browser/config.ts +15 -7
- package/extensions/opl-browser/index.ts +5 -4
- package/extensions/opl-browser/package.json +4 -4
- package/extensions/opl-browser/validate.ts +313 -60
- package/extensions/opl-configurator/README.md +40 -0
- package/extensions/opl-configurator/configurator.ts +148 -0
- package/extensions/opl-configurator/index.ts +12 -0
- package/extensions/opl-ctxtrim/README.md +45 -14
- package/extensions/opl-ctxtrim/index.ts +33 -13
- package/extensions/opl-footer/README.md +31 -6
- package/extensions/opl-footer/config.ts +11 -10
- package/extensions/opl-footer/configure.ts +157 -115
- package/extensions/opl-footer/git-status.ts +58 -1
- package/extensions/opl-footer/icons.ts +3 -0
- package/extensions/opl-footer/index.ts +209 -105
- package/extensions/opl-footer/segments/caveman.ts +9 -1
- package/extensions/opl-footer/segments/chat-mode.ts +8 -1
- package/extensions/opl-footer/segments/compactions.ts +13 -0
- package/extensions/opl-footer/segments/context.ts +6 -5
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
- package/extensions/opl-footer/segments/path.ts +1 -1
- package/extensions/opl-footer/segments/plan-mode.ts +8 -1
- package/extensions/opl-footer/theme.ts +5 -2
- package/extensions/opl-footer/types.ts +4 -0
- package/extensions/opl-guardian/README.md +17 -5
- package/extensions/opl-guardian/config.ts +72 -6
- package/extensions/opl-guardian/guardian.ts +42 -10
- package/extensions/opl-guardian/index.ts +52 -17
- package/extensions/opl-guardian/policies.ts +52 -16
- package/extensions/opl-init/README.md +12 -4
- package/extensions/opl-init/index.ts +228 -27
- package/extensions/opl-input/README.md +17 -3
- package/extensions/opl-input/config.ts +59 -5
- package/extensions/opl-input/configure.ts +144 -0
- package/extensions/opl-input/index.ts +38 -30
- package/extensions/opl-input/mode-style.ts +6 -2
- package/extensions/opl-modes/README.md +16 -4
- package/extensions/opl-modes/config.ts +62 -8
- package/extensions/opl-modes/index.ts +48 -17
- package/extensions/opl-modes/state.ts +50 -2
- package/extensions/opl-modes/types.ts +6 -0
- package/extensions/opl-modes/utils.ts +18 -8
- package/extensions/opl-questionnaire/index.ts +10 -4
- package/extensions/opl-questionnaire/types.ts +4 -0
- package/extensions/opl-simplebench/README.md +21 -15
- package/extensions/opl-simplebench/artifact.ts +7 -4
- package/extensions/opl-simplebench/benchmark.ts +96 -35
- package/extensions/opl-simplebench/coding.ts +9 -4
- package/extensions/opl-simplebench/index.ts +19 -19
- package/extensions/opl-simplebench/llama-server.ts +3 -2
- package/extensions/opl-simplebench/report.ts +0 -6
- package/extensions/opl-simplebench/research.ts +5 -4
- package/extensions/opl-simplebench/scoring.ts +4 -2
- package/extensions/opl-simplebench/util/config.ts +15 -8
- package/extensions/opl-simplebench/util/exec-env.ts +36 -0
- package/extensions/opl-simplebench/util/format.ts +2 -2
- package/extensions/opl-simplebench/util/providers.ts +23 -194
- package/extensions/opl-todo/README.md +5 -5
- package/extensions/opl-todo/config.ts +7 -2
- package/extensions/opl-todo/index.ts +50 -9
- package/extensions/opl-webaccess/README.md +31 -5
- package/extensions/opl-webaccess/config.ts +36 -7
- package/extensions/opl-webaccess/extract.ts +37 -47
- package/extensions/opl-webaccess/http.ts +259 -0
- package/extensions/opl-webaccess/index.ts +12 -6
- package/extensions/opl-webaccess/package.json +4 -9
- package/extensions/opl-webaccess/pdf.ts +4 -4
- package/extensions/opl-webaccess/search.ts +19 -8
- package/extensions/opl-webaccess/storage.ts +134 -2
- package/extensions/opl-webaccess/types.ts +4 -0
- package/extensions/opl-webaccess/utils.ts +221 -33
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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 (
|
|
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
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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: {
|
|
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
|
|
563
|
-
|
|
564
|
-
effectiveConfig.
|
|
565
|
-
effectiveConfig.
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|