@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.
- package/README.md +92 -82
- 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 +32 -7
- 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 +287 -109
- 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/images/ss-init.png +0 -0
- package/images/ss-input-footer.png +0 -0
- package/images/ss-input-pet.png +0 -0
- package/images/ss-mode-custom.png +0 -0
- package/images/ss-questionnaire0.png +0 -0
- package/images/ss-questionnaire1.png +0 -0
- package/images/ss-simplebench.png +0 -0
- package/images/ss-todo.png +0 -0
- 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 {
|
|
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,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
|
-
|
|
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;
|
|
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)
|
|
287
|
+
if (openRouterUsageInFlight) {
|
|
288
|
+
openRouterUsagePendingCtx = ctx;
|
|
289
|
+
return openRouterUsageInFlight;
|
|
290
|
+
}
|
|
153
291
|
|
|
154
292
|
const now = Date.now();
|
|
155
|
-
|
|
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)
|
|
348
|
+
if (codexUsageInFlight) {
|
|
349
|
+
codexUsagePendingCtx = ctx;
|
|
350
|
+
return codexUsageInFlight;
|
|
351
|
+
}
|
|
195
352
|
|
|
196
353
|
const now = Date.now();
|
|
197
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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 (
|
|
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
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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: {
|
|
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
|
|
489
|
-
|
|
490
|
-
effectiveConfig.
|
|
491
|
-
effectiveConfig.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|