@openlines/opl-pi-sht 0.1.14
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/LICENSE +21 -0
- package/README.md +191 -0
- package/configs/opl-browser.json.sample +8 -0
- package/configs/opl-footer.json +48 -0
- package/configs/opl-footer.json.sample +48 -0
- package/configs/opl-input.json +22 -0
- package/configs/opl-input.json.sample +22 -0
- package/configs/opl-modes.json +291 -0
- package/configs/opl-modes.json.sample +285 -0
- package/configs/opl-simplebench.json +8 -0
- package/configs/opl-simplebench.json.sample +8 -0
- package/configs/opl-todo.json +12 -0
- package/configs/opl-todo.json.sample +12 -0
- package/configs/opl-webaccess.json +31 -0
- package/configs/opl-webaccess.json.sample +31 -0
- package/extensions/opl-browser/README.md +83 -0
- package/extensions/opl-browser/browser.ts +185 -0
- package/extensions/opl-browser/config.ts +37 -0
- package/extensions/opl-browser/index.ts +89 -0
- package/extensions/opl-browser/package-lock.json +60 -0
- package/extensions/opl-browser/package.json +10 -0
- package/extensions/opl-ctxtrim/README.md +78 -0
- package/extensions/opl-ctxtrim/index.ts +151 -0
- package/extensions/opl-footer/README.md +193 -0
- package/extensions/opl-footer/config.ts +91 -0
- package/extensions/opl-footer/git-status.ts +170 -0
- package/extensions/opl-footer/icons.ts +49 -0
- package/extensions/opl-footer/index.ts +368 -0
- package/extensions/opl-footer/segments/caveman.ts +25 -0
- package/extensions/opl-footer/segments/chat-mode.ts +24 -0
- package/extensions/opl-footer/segments/context.ts +92 -0
- package/extensions/opl-footer/segments/cost.ts +25 -0
- package/extensions/opl-footer/segments/git.ts +47 -0
- package/extensions/opl-footer/segments/helpers.ts +18 -0
- package/extensions/opl-footer/segments/index.ts +51 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
- package/extensions/opl-footer/segments/model.ts +22 -0
- package/extensions/opl-footer/segments/path.ts +34 -0
- package/extensions/opl-footer/segments/pi.ts +11 -0
- package/extensions/opl-footer/segments/plan-mode.ts +24 -0
- package/extensions/opl-footer/segments/separator.ts +9 -0
- package/extensions/opl-footer/segments/session-stats.ts +82 -0
- package/extensions/opl-footer/segments/thinking.ts +51 -0
- package/extensions/opl-footer/segments/tokens.ts +66 -0
- package/extensions/opl-footer/theme.ts +113 -0
- package/extensions/opl-footer/types.ts +172 -0
- package/extensions/opl-init/README.md +23 -0
- package/extensions/opl-init/index.ts +451 -0
- package/extensions/opl-input/README.md +83 -0
- package/extensions/opl-input/config.ts +137 -0
- package/extensions/opl-input/index.ts +284 -0
- package/extensions/opl-input/mode-style.ts +38 -0
- package/extensions/opl-input/utils.ts +378 -0
- package/extensions/opl-modes/README.md +132 -0
- package/extensions/opl-modes/config.ts +473 -0
- package/extensions/opl-modes/index.ts +1081 -0
- package/extensions/opl-modes/menus.ts +60 -0
- package/extensions/opl-modes/state.ts +152 -0
- package/extensions/opl-modes/types.ts +164 -0
- package/extensions/opl-modes/utils.ts +138 -0
- package/extensions/opl-questionnaire/README.md +26 -0
- package/extensions/opl-questionnaire/index.ts +445 -0
- package/extensions/opl-questionnaire/types.ts +55 -0
- package/extensions/opl-simplebench/README.md +242 -0
- package/extensions/opl-simplebench/artifact.ts +41 -0
- package/extensions/opl-simplebench/benchmark.ts +873 -0
- package/extensions/opl-simplebench/coding.ts +282 -0
- package/extensions/opl-simplebench/index.ts +203 -0
- package/extensions/opl-simplebench/llama-server.ts +290 -0
- package/extensions/opl-simplebench/metrics.ts +73 -0
- package/extensions/opl-simplebench/report.ts +46 -0
- package/extensions/opl-simplebench/research.ts +200 -0
- package/extensions/opl-simplebench/scoring.ts +48 -0
- package/extensions/opl-simplebench/tests.ts +39 -0
- package/extensions/opl-simplebench/types.ts +6 -0
- package/extensions/opl-simplebench/util/config.ts +170 -0
- package/extensions/opl-simplebench/util/debug.ts +32 -0
- package/extensions/opl-simplebench/util/format.ts +402 -0
- package/extensions/opl-simplebench/util/providers.ts +841 -0
- package/extensions/opl-todo/README.md +43 -0
- package/extensions/opl-todo/config.ts +71 -0
- package/extensions/opl-todo/index.ts +415 -0
- package/extensions/opl-webaccess/README.md +47 -0
- package/extensions/opl-webaccess/config.ts +55 -0
- package/extensions/opl-webaccess/extract.ts +83 -0
- package/extensions/opl-webaccess/index.ts +232 -0
- package/extensions/opl-webaccess/package-lock.json +345 -0
- package/extensions/opl-webaccess/package.json +18 -0
- package/extensions/opl-webaccess/pdf.ts +8 -0
- package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
- package/extensions/opl-webaccess/providers/exa.ts +74 -0
- package/extensions/opl-webaccess/providers/gemini.ts +60 -0
- package/extensions/opl-webaccess/providers/searxng.ts +86 -0
- package/extensions/opl-webaccess/providers/tavily.ts +60 -0
- package/extensions/opl-webaccess/search.ts +36 -0
- package/extensions/opl-webaccess/storage.ts +51 -0
- package/extensions/opl-webaccess/turndown.d.ts +1 -0
- package/extensions/opl-webaccess/types.ts +26 -0
- package/extensions/opl-webaccess/utils.ts +28 -0
- package/package.json +55 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import type { ExtensionAPI, ReadonlyFooterDataProvider, Theme, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
import type { SegmentContext, StatusLineSegmentId, UsageStats, SessionStats, SessionEvent, ThinkingLevelEvent, AssistantMessageEvent, ToolResultEvent, UserBashEvent } from "./types.js";
|
|
7
|
+
import { renderSegment } from "./segments/index.js";
|
|
8
|
+
import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-status.js";
|
|
9
|
+
import { getEffectiveConfig } from "./config.js";
|
|
10
|
+
import { getIcons } from "./icons.js";
|
|
11
|
+
import { getDefaultColors, fg } from "./theme.js";
|
|
12
|
+
|
|
13
|
+
const GIT_BRANCH_PATTERNS: RegExp[] = [
|
|
14
|
+
/\bgit\s+(checkout|switch|branch\s+-[dDmM]|merge|rebase|pull|reset|worktree)/,
|
|
15
|
+
/\bgit\s+stash\s+(pop|apply)/,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
19
|
+
// Status Line Builder
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
21
|
+
|
|
22
|
+
/** Render a single segment and return its content with width */
|
|
23
|
+
function renderSegmentWithWidth(
|
|
24
|
+
segId: StatusLineSegmentId,
|
|
25
|
+
ctx: SegmentContext
|
|
26
|
+
): { content: string; width: number; visible: boolean } {
|
|
27
|
+
const rendered = renderSegment(segId, ctx);
|
|
28
|
+
if (!rendered.visible || !rendered.content) {
|
|
29
|
+
return { content: "", width: 0, visible: false };
|
|
30
|
+
}
|
|
31
|
+
return { content: rendered.content, width: visibleWidth(rendered.content), visible: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build footer content from left and right segments.
|
|
36
|
+
* Left segments are left-aligned, right segments are right-aligned.
|
|
37
|
+
*/
|
|
38
|
+
function buildFooterContent(
|
|
39
|
+
ctx: SegmentContext,
|
|
40
|
+
leftSegments: StatusLineSegmentId[],
|
|
41
|
+
rightSegments: StatusLineSegmentId[],
|
|
42
|
+
availableWidth: number,
|
|
43
|
+
): string {
|
|
44
|
+
const maxContentWidth = Math.max(0, availableWidth - 2);
|
|
45
|
+
|
|
46
|
+
// Render left segments
|
|
47
|
+
const leftParts: string[] = [];
|
|
48
|
+
for (const segId of leftSegments) {
|
|
49
|
+
const { content, visible } = renderSegmentWithWidth(segId, ctx);
|
|
50
|
+
if (visible) {
|
|
51
|
+
leftParts.push(content);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Render right segments
|
|
56
|
+
const rightParts: string[] = [];
|
|
57
|
+
let rightWidth = 0;
|
|
58
|
+
for (const segId of rightSegments) {
|
|
59
|
+
const { content, width, visible } = renderSegmentWithWidth(segId, ctx);
|
|
60
|
+
if (visible) {
|
|
61
|
+
rightParts.push(content);
|
|
62
|
+
rightWidth += width + 1; // +1 for space between
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (rightParts.length > 0) {
|
|
66
|
+
rightWidth -= 1; // Remove trailing space
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let leftStr = leftParts.join(" ");
|
|
70
|
+
let rightStr = rightParts.join(" ");
|
|
71
|
+
|
|
72
|
+
// Handle case with no right segments
|
|
73
|
+
if (rightParts.length === 0) {
|
|
74
|
+
const finalLeft = truncateToWidth(leftStr, maxContentWidth);
|
|
75
|
+
return " " + finalLeft + " ".repeat(Math.max(0, maxContentWidth - visibleWidth(finalLeft))) + " ";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// If right side alone is too big, just show right side
|
|
79
|
+
if (rightWidth >= maxContentWidth) {
|
|
80
|
+
return " " + truncateToWidth(rightStr, maxContentWidth) + " ";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Ensure at least 1 space between left and right
|
|
84
|
+
const maxLeftWidth = maxContentWidth - rightWidth - 1;
|
|
85
|
+
const finalLeft = truncateToWidth(leftStr, Math.max(0, maxLeftWidth));
|
|
86
|
+
const finalLeftWidth = visibleWidth(finalLeft);
|
|
87
|
+
|
|
88
|
+
const padding = maxContentWidth - finalLeftWidth - rightWidth;
|
|
89
|
+
|
|
90
|
+
const result = " " + finalLeft + " ".repeat(padding) + rightStr + " ";
|
|
91
|
+
return truncateToWidth(result, availableWidth);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
95
|
+
// Extension
|
|
96
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
97
|
+
|
|
98
|
+
export default function footer(pi: ExtensionAPI) {
|
|
99
|
+
let sessionStartTime = Date.now();
|
|
100
|
+
let currentCtx: ExtensionContext | null = null;
|
|
101
|
+
let footerDataRef: ReadonlyFooterDataProvider | null = null;
|
|
102
|
+
let lastBranchLength = 0;
|
|
103
|
+
let cachedUsageStats: UsageStats | null = null;
|
|
104
|
+
let tuiRef: TUI | null = null;
|
|
105
|
+
|
|
106
|
+
// Session stats accumulators
|
|
107
|
+
let turns = 0;
|
|
108
|
+
let steps = 0;
|
|
109
|
+
let modelRequests = 0;
|
|
110
|
+
let modelToolCalls = 0;
|
|
111
|
+
let llmMs = 0;
|
|
112
|
+
let toolMs = 0;
|
|
113
|
+
let ttftSamples: number[] = [];
|
|
114
|
+
let turnStartMs = 0;
|
|
115
|
+
let toolMsThisTurn = 0;
|
|
116
|
+
let ttftRecordedThisTurn = false;
|
|
117
|
+
let agentStartMs = 0;
|
|
118
|
+
let lastTurnaroundMs = 0;
|
|
119
|
+
const toolStartTimes = new Map<string, number>();
|
|
120
|
+
|
|
121
|
+
// Track session start
|
|
122
|
+
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
123
|
+
sessionStartTime = Date.now();
|
|
124
|
+
currentCtx = ctx;
|
|
125
|
+
lastBranchLength = 0;
|
|
126
|
+
cachedUsageStats = null;
|
|
127
|
+
turns = 0;
|
|
128
|
+
steps = 0;
|
|
129
|
+
modelRequests = 0;
|
|
130
|
+
modelToolCalls = 0;
|
|
131
|
+
llmMs = 0;
|
|
132
|
+
toolMs = 0;
|
|
133
|
+
ttftSamples = [];
|
|
134
|
+
turnStartMs = 0;
|
|
135
|
+
toolMsThisTurn = 0;
|
|
136
|
+
ttftRecordedThisTurn = false;
|
|
137
|
+
agentStartMs = 0;
|
|
138
|
+
lastTurnaroundMs = 0;
|
|
139
|
+
toolStartTimes.clear();
|
|
140
|
+
|
|
141
|
+
if (ctx.hasUI) {
|
|
142
|
+
setupFooter(ctx);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Track user-prompt-to-completion turnaround. agent_start may fire multiple
|
|
147
|
+
// times per user turn (retries/compaction); only the first since the last
|
|
148
|
+
// settle marks when the user's prompt began processing. agent_settled is the
|
|
149
|
+
// authoritative "fully done, no auto-continuation" signal.
|
|
150
|
+
pi.on("agent_start", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
151
|
+
if (agentStartMs === 0) agentStartMs = Date.now();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
pi.on("agent_settled", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
155
|
+
if (agentStartMs > 0) {
|
|
156
|
+
lastTurnaroundMs = Date.now() - agentStartMs;
|
|
157
|
+
agentStartMs = 0;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
pi.on("turn_start", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
162
|
+
turnStartMs = Date.now();
|
|
163
|
+
toolMsThisTurn = 0;
|
|
164
|
+
ttftRecordedThisTurn = false;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
pi.on("turn_end", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
168
|
+
turns++;
|
|
169
|
+
const elapsed = Date.now() - turnStartMs;
|
|
170
|
+
llmMs += Math.max(0, elapsed - toolMsThisTurn);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
pi.on("tool_execution_start", async (event: { toolCallId: string }, _ctx: ExtensionContext) => {
|
|
174
|
+
steps++;
|
|
175
|
+
toolStartTimes.set(event.toolCallId, Date.now());
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
pi.on("tool_execution_end", async (event: { toolCallId: string }, _ctx: ExtensionContext) => {
|
|
179
|
+
const start = toolStartTimes.get(event.toolCallId);
|
|
180
|
+
if (start !== undefined) {
|
|
181
|
+
const elapsed = Date.now() - start;
|
|
182
|
+
toolMs += elapsed;
|
|
183
|
+
toolMsThisTurn += elapsed;
|
|
184
|
+
toolStartTimes.delete(event.toolCallId);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
pi.on("message_update", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
189
|
+
if (!ttftRecordedThisTurn && turnStartMs > 0) {
|
|
190
|
+
ttftSamples.push(Date.now() - turnStartMs);
|
|
191
|
+
ttftRecordedThisTurn = true;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Invalidate git status on file changes
|
|
196
|
+
pi.on("tool_result", async (event: ToolResultEvent, _ctx: ExtensionContext) => {
|
|
197
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
198
|
+
invalidateGitStatus();
|
|
199
|
+
}
|
|
200
|
+
if (event.toolName === "bash" && event.input?.command) {
|
|
201
|
+
const cmd = String(event.input.command);
|
|
202
|
+
if (GIT_BRANCH_PATTERNS.some(p => p.test(cmd))) {
|
|
203
|
+
invalidateGitStatus();
|
|
204
|
+
invalidateGitBranch();
|
|
205
|
+
setTimeout(() => tuiRef?.requestRender(), 100);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Also catch user escape commands (! prefix)
|
|
211
|
+
pi.on("user_bash", async (event: UserBashEvent, _ctx: ExtensionContext) => {
|
|
212
|
+
if (GIT_BRANCH_PATTERNS.some(p => p.test(event.command))) {
|
|
213
|
+
invalidateGitStatus();
|
|
214
|
+
invalidateGitBranch();
|
|
215
|
+
tuiRef?.requestRender();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
function buildSegmentContext(ctx: ExtensionContext, width: number, theme: Theme): SegmentContext {
|
|
220
|
+
const effectiveConfig = getEffectiveConfig();
|
|
221
|
+
const colors = effectiveConfig.colors ?? getDefaultColors();
|
|
222
|
+
|
|
223
|
+
const branch = (ctx.sessionManager?.getBranch?.() ?? []) as SessionEvent[];
|
|
224
|
+
const branchLen = branch.length;
|
|
225
|
+
|
|
226
|
+
const isAssistantMessageEvent = (e: SessionEvent): e is AssistantMessageEvent =>
|
|
227
|
+
e.type === "message" && (e as AssistantMessageEvent).message.role === "assistant";
|
|
228
|
+
const completedMessages = branch
|
|
229
|
+
.filter(isAssistantMessageEvent)
|
|
230
|
+
.map(e => e.message as AssistantMessage)
|
|
231
|
+
.filter(m => m.stopReason !== "error" && m.stopReason !== "aborted");
|
|
232
|
+
|
|
233
|
+
// Cache usageStats — only recompute when branch grows
|
|
234
|
+
let usageStats: UsageStats;
|
|
235
|
+
if (cachedUsageStats && branchLen === lastBranchLength) {
|
|
236
|
+
usageStats = cachedUsageStats;
|
|
237
|
+
} else {
|
|
238
|
+
usageStats = completedMessages.reduce<UsageStats>(
|
|
239
|
+
(acc, m) => ({
|
|
240
|
+
input: acc.input + m.usage.input,
|
|
241
|
+
output: acc.output + m.usage.output,
|
|
242
|
+
cacheRead: acc.cacheRead + m.usage.cacheRead,
|
|
243
|
+
cacheWrite: acc.cacheWrite + m.usage.cacheWrite,
|
|
244
|
+
cost: acc.cost + m.usage.cost.total,
|
|
245
|
+
}),
|
|
246
|
+
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
|
|
247
|
+
);
|
|
248
|
+
cachedUsageStats = usageStats;
|
|
249
|
+
lastBranchLength = branchLen;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const isThinkingEvent = (e: SessionEvent): e is ThinkingLevelEvent =>
|
|
253
|
+
e.type === "thinking_level_change";
|
|
254
|
+
const thinkingLevelFromSession = branch
|
|
255
|
+
.filter(isThinkingEvent)
|
|
256
|
+
.reduce((_, e) => e.thinkingLevel ?? "off", "off");
|
|
257
|
+
|
|
258
|
+
const lastAssistant = completedMessages.at(-1);
|
|
259
|
+
|
|
260
|
+
// Calculate context percentage
|
|
261
|
+
const contextTokens = lastAssistant
|
|
262
|
+
? lastAssistant.usage.input + lastAssistant.usage.output +
|
|
263
|
+
lastAssistant.usage.cacheRead + lastAssistant.usage.cacheWrite
|
|
264
|
+
: 0;
|
|
265
|
+
const contextWindow = ctx.model?.contextWindow || 0;
|
|
266
|
+
const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
|
|
267
|
+
|
|
268
|
+
// Get git status (cached)
|
|
269
|
+
const gitBranch = footerDataRef?.getGitBranch() ?? null;
|
|
270
|
+
const gitStatus = getGitStatus(gitBranch);
|
|
271
|
+
|
|
272
|
+
// Check if using OAuth subscription
|
|
273
|
+
const usingSubscription = ctx.model
|
|
274
|
+
? ctx.modelRegistry?.isUsingOAuth?.(ctx.model) ?? false
|
|
275
|
+
: false;
|
|
276
|
+
|
|
277
|
+
// Local model detection: use model ID prefix (local.*) instead of URL
|
|
278
|
+
// This allows litellm proxy to correctly separate local vs cloud models
|
|
279
|
+
const isLocalModel = ctx.model?.id?.startsWith?.("local.") ?? false;
|
|
280
|
+
|
|
281
|
+
// Reconstruct turns/steps from branch so they survive quit/resume.
|
|
282
|
+
// Timing stats (llmMs, toolMs, ttftSamples) are ephemeral — not stored in messages.
|
|
283
|
+
const branchTurns = completedMessages.length;
|
|
284
|
+
const branchSteps = branch.filter(
|
|
285
|
+
(e) => e.type === "message" && (e as AssistantMessageEvent).message?.role === "toolResult",
|
|
286
|
+
).length;
|
|
287
|
+
|
|
288
|
+
// Count model requests (each assistant message = 1 request)
|
|
289
|
+
const branchModelRequests = completedMessages.length;
|
|
290
|
+
|
|
291
|
+
// Count tool calls from assistant message content
|
|
292
|
+
const branchModelToolCalls = completedMessages.reduce((sum, msg) => {
|
|
293
|
+
const content = (msg as any).content || [];
|
|
294
|
+
return sum + content.filter((block: any) => block?.type === "toolCall").length;
|
|
295
|
+
}, 0);
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
model: ctx.model,
|
|
299
|
+
isLocalModel,
|
|
300
|
+
thinkingLevel: thinkingLevelFromSession || pi.getThinkingLevel(),
|
|
301
|
+
sessionId: ctx.sessionManager?.getSessionId?.(),
|
|
302
|
+
usageStats,
|
|
303
|
+
contextPercent,
|
|
304
|
+
contextWindow,
|
|
305
|
+
usingSubscription,
|
|
306
|
+
sessionStartTime,
|
|
307
|
+
git: gitStatus,
|
|
308
|
+
options: effectiveConfig.segmentOptions ?? {},
|
|
309
|
+
width,
|
|
310
|
+
theme,
|
|
311
|
+
colors,
|
|
312
|
+
icons: getIcons(effectiveConfig.icons),
|
|
313
|
+
sessionStats: { turns: branchTurns, steps: branchSteps, modelRequests: branchModelRequests, modelToolCalls: branchModelToolCalls, llmMs, toolMs, ttftSamples, lastTurnaroundMs },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function setupFooter(ctx: ExtensionContext) {
|
|
318
|
+
ctx.ui.setFooter((tui: TUI, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
|
|
319
|
+
footerDataRef = footerData;
|
|
320
|
+
tuiRef = tui;
|
|
321
|
+
|
|
322
|
+
// Expose a re-render trigger for out-of-turn state changes (e.g. /caveman toggle).
|
|
323
|
+
(globalThis as Record<string, unknown>).__footerRequestRender = () => tui.requestRender();
|
|
324
|
+
|
|
325
|
+
// Subscribe to branch changes for re-render
|
|
326
|
+
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
dispose: unsub,
|
|
330
|
+
invalidate() {},
|
|
331
|
+
render(width: number): string[] {
|
|
332
|
+
if (!currentCtx) return [];
|
|
333
|
+
|
|
334
|
+
const effectiveConfig = getEffectiveConfig();
|
|
335
|
+
let segmentCtx;
|
|
336
|
+
try {
|
|
337
|
+
segmentCtx = buildSegmentContext(currentCtx, width, theme);
|
|
338
|
+
} catch {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const row1 = buildFooterContent(
|
|
343
|
+
segmentCtx,
|
|
344
|
+
effectiveConfig.row1LeftSegments,
|
|
345
|
+
effectiveConfig.row1RightSegments,
|
|
346
|
+
width,
|
|
347
|
+
);
|
|
348
|
+
const row2 = buildFooterContent(
|
|
349
|
+
segmentCtx,
|
|
350
|
+
effectiveConfig.row2LeftSegments,
|
|
351
|
+
effectiveConfig.row2RightSegments,
|
|
352
|
+
width,
|
|
353
|
+
);
|
|
354
|
+
const row3 = buildFooterContent(
|
|
355
|
+
segmentCtx,
|
|
356
|
+
effectiveConfig.row3LeftSegments,
|
|
357
|
+
effectiveConfig.row3RightSegments,
|
|
358
|
+
width,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const divider = fg(theme, "separator", "─".repeat(width), segmentCtx.colors);
|
|
362
|
+
|
|
363
|
+
return ["", row1, divider, row2, divider, row3];
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RenderedSegment, SegmentContext } from "../types.js";
|
|
2
|
+
import { applyColor } from "../theme.js";
|
|
3
|
+
import { color } from "./helpers.js";
|
|
4
|
+
|
|
5
|
+
interface CavemanState {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
mode: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readCavemanState(): CavemanState | undefined {
|
|
11
|
+
return (globalThis as Record<string, unknown>).__caveman as CavemanState | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const cavemanSegment = {
|
|
15
|
+
id: "caveman" as const,
|
|
16
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
17
|
+
const state = readCavemanState();
|
|
18
|
+
if (!state) return { content: "", visible: false };
|
|
19
|
+
|
|
20
|
+
const label = applyColor(ctx.theme, "dim", "Caveman mode:");
|
|
21
|
+
const value = state.enabled ? state.mode.toUpperCase() : "OFF";
|
|
22
|
+
|
|
23
|
+
return { content: `${label} ${color(ctx, "modeIndicator", value)}`, visible: true };
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RenderedSegment, SegmentContext } from "../types.js";
|
|
2
|
+
import { applyColor } from "../theme.js";
|
|
3
|
+
import { color } from "./helpers.js";
|
|
4
|
+
|
|
5
|
+
interface ChatModeState {
|
|
6
|
+
mode: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readChatModeState(): ChatModeState | undefined {
|
|
10
|
+
return (globalThis as Record<string, unknown>).__chatMode as ChatModeState | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const chatModeSegment = {
|
|
14
|
+
id: "chat_mode" as const,
|
|
15
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
16
|
+
const state = readChatModeState();
|
|
17
|
+
if (!state) return { content: "", visible: false };
|
|
18
|
+
|
|
19
|
+
const label = applyColor(ctx.theme, "dim", "Chat mode:");
|
|
20
|
+
const value = state.mode === "off" ? "OFF" : "ON";
|
|
21
|
+
|
|
22
|
+
return { content: `${label} ${color(ctx, "modeIndicator", value)}`, visible: true };
|
|
23
|
+
},
|
|
24
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { RenderedSegment, SegmentContext } from "../types.js";
|
|
2
|
+
import { applyColor, resolveColorToRgb } from "../theme.js";
|
|
3
|
+
import { color } from "./helpers.js";
|
|
4
|
+
import { formatTokens } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
// Set to a number (0–100) to override context % for visual testing, null to disable
|
|
7
|
+
const DEBUG_PCT: number | null = null;
|
|
8
|
+
|
|
9
|
+
const DEFAULT_BAR_WIDTH = 18;
|
|
10
|
+
const DEFAULT_FILLED_CHAR = "▋";
|
|
11
|
+
const DEFAULT_UNFILLED_CHAR = "▋";
|
|
12
|
+
const DEFAULT_UNFILLED_COLOR = "#4e4c49";
|
|
13
|
+
const DEFAULT_START = { r: 0xf2, g: 0x93, b: 0x73 };
|
|
14
|
+
const DEFAULT_MID = { r: 0xd6, g: 0x78, b: 0x58 };
|
|
15
|
+
const DEFAULT_END = { r: 0xae, g: 0x4f, b: 0x2f };
|
|
16
|
+
const DEFAULT_MID_FRAC = 0.55; // 0–1: where MID sits along the gradient
|
|
17
|
+
|
|
18
|
+
export function lerp(a: number, b: number, t: number): number {
|
|
19
|
+
return Math.round(a + (b - a) * t);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function positionColor(
|
|
23
|
+
pos: number,
|
|
24
|
+
barWidth: number,
|
|
25
|
+
start: { r: number; g: number; b: number },
|
|
26
|
+
mid: { r: number; g: number; b: number },
|
|
27
|
+
end: { r: number; g: number; b: number },
|
|
28
|
+
midFrac: number,
|
|
29
|
+
): string {
|
|
30
|
+
const t = pos / Math.max(barWidth - 1, 1);
|
|
31
|
+
let r: number, g: number, b: number;
|
|
32
|
+
if (t <= midFrac) {
|
|
33
|
+
const f = t / midFrac;
|
|
34
|
+
r = lerp(start.r, mid.r, f);
|
|
35
|
+
g = lerp(start.g, mid.g, f);
|
|
36
|
+
b = lerp(start.b, mid.b, f);
|
|
37
|
+
} else {
|
|
38
|
+
const f = (t - midFrac) / (1 - midFrac);
|
|
39
|
+
r = lerp(mid.r, end.r, f);
|
|
40
|
+
g = lerp(mid.g, end.g, f);
|
|
41
|
+
b = lerp(mid.b, end.b, f);
|
|
42
|
+
}
|
|
43
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const contextPctSegment = {
|
|
47
|
+
id: "context_pct" as const,
|
|
48
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
49
|
+
const pct = DEBUG_PCT ?? ctx.contextPercent;
|
|
50
|
+
const barOpts = ctx.options.contextBar ?? {};
|
|
51
|
+
|
|
52
|
+
const barWidth = barOpts.barWidth ?? DEFAULT_BAR_WIDTH;
|
|
53
|
+
const filledChar = barOpts.filledChar ?? DEFAULT_FILLED_CHAR;
|
|
54
|
+
const unfilledChar = barOpts.unfilledChar ?? DEFAULT_UNFILLED_CHAR;
|
|
55
|
+
const unfilledColor = barOpts.unfilledColor ?? DEFAULT_UNFILLED_COLOR;
|
|
56
|
+
const start = (barOpts.gradientStart ? resolveColorToRgb(ctx.theme, barOpts.gradientStart) : null) ?? DEFAULT_START;
|
|
57
|
+
const mid = (barOpts.gradientMid ? resolveColorToRgb(ctx.theme, barOpts.gradientMid) : null) ?? DEFAULT_MID;
|
|
58
|
+
const end = (barOpts.gradientEnd ? resolveColorToRgb(ctx.theme, barOpts.gradientEnd) : null) ?? DEFAULT_END;
|
|
59
|
+
const midFrac = barOpts.gradientMidPoint ?? DEFAULT_MID_FRAC;
|
|
60
|
+
|
|
61
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
62
|
+
|
|
63
|
+
let bar = "";
|
|
64
|
+
for (let i = 0; i < barWidth; i++) {
|
|
65
|
+
if (i < filled) {
|
|
66
|
+
bar += positionColor(i, barWidth, start, mid, end, midFrac) + filledChar;
|
|
67
|
+
} else {
|
|
68
|
+
bar += applyColor(ctx.theme, unfilledColor, unfilledChar);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
bar += "\x1b[0m";
|
|
72
|
+
|
|
73
|
+
// Show: (00.00%) 99999 / 99999
|
|
74
|
+
const used = Math.round((pct / 100) * ctx.contextWindow);
|
|
75
|
+
const pctLabel = `(${pct.toFixed(2)}%) ${formatTokens(used)} / ${formatTokens(ctx.contextWindow)}`;
|
|
76
|
+
const pctStr = color(ctx, "contextLabel", pctLabel);
|
|
77
|
+
|
|
78
|
+
return { content: `${bar} ${pctStr}`, visible: true };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const contextTotalSegment = {
|
|
83
|
+
id: "context_total" as const,
|
|
84
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
85
|
+
const window = ctx.contextWindow;
|
|
86
|
+
if (!window) return { content: "", visible: false };
|
|
87
|
+
return {
|
|
88
|
+
content: color(ctx, "context", `${window}`),
|
|
89
|
+
visible: true,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RenderedSegment, SegmentContext } from "../types.js";
|
|
2
|
+
import { applyColor } from "../theme.js";
|
|
3
|
+
import { color } from "./helpers.js";
|
|
4
|
+
|
|
5
|
+
export const costSegment = {
|
|
6
|
+
id: "cost" as const,
|
|
7
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
8
|
+
const { cost } = ctx.usageStats;
|
|
9
|
+
|
|
10
|
+
if (cost === 0 && !ctx.isLocalModel) {
|
|
11
|
+
return {
|
|
12
|
+
content: color(ctx, "cost", "$0.00") + applyColor(ctx.theme, "dim", " (no pricing)"),
|
|
13
|
+
visible: true,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const content = color(ctx, "cost", `$${cost.toFixed(2)}`);
|
|
18
|
+
|
|
19
|
+
if (ctx.isLocalModel) {
|
|
20
|
+
return { content: content + applyColor(ctx.theme, "dim", " (local model)"), visible: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { content, visible: true };
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RenderedSegment, SegmentContext } from "../types.js";
|
|
2
|
+
import { applyColor } from "../theme.js";
|
|
3
|
+
import { color, withIcon } from "./helpers.js";
|
|
4
|
+
|
|
5
|
+
export const gitSegment = {
|
|
6
|
+
id: "git" as const,
|
|
7
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
8
|
+
const opts = ctx.options.git ?? {};
|
|
9
|
+
const { branch, staged, unstaged, untracked } = ctx.git;
|
|
10
|
+
|
|
11
|
+
if (!branch && staged === 0 && unstaged === 0 && untracked === 0) {
|
|
12
|
+
return { content: "", visible: false };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const isDirty = staged > 0 || unstaged > 0 || untracked > 0;
|
|
16
|
+
const showBranch = opts.showBranch !== false;
|
|
17
|
+
const branchColor = isDirty ? "gitDirty" : "gitClean";
|
|
18
|
+
|
|
19
|
+
let content = "";
|
|
20
|
+
if (showBranch && branch) {
|
|
21
|
+
content = color(ctx, branchColor, withIcon(ctx.icons.branch, branch));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const indicators: string[] = [];
|
|
25
|
+
if (opts.showUnstaged !== false && unstaged > 0) {
|
|
26
|
+
indicators.push(applyColor(ctx.theme, "warning", `*${unstaged}`));
|
|
27
|
+
}
|
|
28
|
+
if (opts.showStaged !== false && staged > 0) {
|
|
29
|
+
indicators.push(applyColor(ctx.theme, "success", `+${staged}`));
|
|
30
|
+
}
|
|
31
|
+
if (opts.showUntracked !== false && untracked > 0) {
|
|
32
|
+
indicators.push(applyColor(ctx.theme, "muted", `?${untracked}`));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (indicators.length > 0) {
|
|
36
|
+
const indicatorText = indicators.join(" ");
|
|
37
|
+
if (!content && showBranch === false) {
|
|
38
|
+
content = color(ctx, branchColor, ctx.icons.git ? `${ctx.icons.git} ` : "") + indicatorText;
|
|
39
|
+
} else {
|
|
40
|
+
content += content ? ` ${indicatorText}` : indicatorText;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!content) return { content: "", visible: false };
|
|
45
|
+
return { content, visible: true };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SegmentContext } from "../types.js";
|
|
2
|
+
import { fg } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
export function formatTokens(n: number): string {
|
|
5
|
+
if (n < 1000) return n.toString();
|
|
6
|
+
if (n < 10000) return `${(n / 1000).toFixed(2)}k`;
|
|
7
|
+
if (n < 1000000) return `${(n / 1000).toFixed(2)}k`;
|
|
8
|
+
if (n < 10000000) return `${(n / 1000000).toFixed(2)}M`;
|
|
9
|
+
return `${(n / 1000000).toFixed(2)}M`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function withIcon(icon: string, text: string): string {
|
|
13
|
+
return icon ? `${icon} ${text}` : text;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function color(ctx: SegmentContext, semantic: string, text: string): string {
|
|
17
|
+
return fg(ctx.theme, semantic as any, text, ctx.colors);
|
|
18
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RenderedSegment, SegmentContext, StatusLineSegmentId } from "../types.js";
|
|
2
|
+
import { piSegment } from "./pi.js";
|
|
3
|
+
import { modelSegment } from "./model.js";
|
|
4
|
+
import { pathSegment } from "./path.js";
|
|
5
|
+
import { gitSegment } from "./git.js";
|
|
6
|
+
import { thinkingSegment } from "./thinking.js";
|
|
7
|
+
import { tokenInSegment, tokenOutSegment, tokenTotalSegment, cacheReadSegment, cacheWriteSegment } from "./tokens.js";
|
|
8
|
+
import { costSegment } from "./cost.js";
|
|
9
|
+
import { contextPctSegment, contextTotalSegment } from "./context.js";
|
|
10
|
+
import { separatorSegment } from "./separator.js";
|
|
11
|
+
import { cavemanSegment } from "./caveman.js";
|
|
12
|
+
import { planModeSegment } from "./plan-mode.js";
|
|
13
|
+
import { chatModeSegment } from "./chat-mode.js";
|
|
14
|
+
import { modeSwitcherSegment } from "./mode-switcher.js";
|
|
15
|
+
import { sessionStatsSegment, perfStatsSegment } from "./session-stats.js";
|
|
16
|
+
|
|
17
|
+
const SEGMENTS = {
|
|
18
|
+
pi: piSegment,
|
|
19
|
+
model: modelSegment,
|
|
20
|
+
path: pathSegment,
|
|
21
|
+
git: gitSegment,
|
|
22
|
+
thinking: thinkingSegment,
|
|
23
|
+
token_in: tokenInSegment,
|
|
24
|
+
token_out: tokenOutSegment,
|
|
25
|
+
token_total: tokenTotalSegment,
|
|
26
|
+
cost: costSegment,
|
|
27
|
+
context_pct: contextPctSegment,
|
|
28
|
+
context_total: contextTotalSegment,
|
|
29
|
+
cache_read: cacheReadSegment,
|
|
30
|
+
cache_write: cacheWriteSegment,
|
|
31
|
+
separator: separatorSegment,
|
|
32
|
+
caveman: cavemanSegment,
|
|
33
|
+
plan_mode: planModeSegment,
|
|
34
|
+
chat_mode: chatModeSegment,
|
|
35
|
+
mode_switcher: modeSwitcherSegment,
|
|
36
|
+
session_stats: sessionStatsSegment,
|
|
37
|
+
perf_stats: perfStatsSegment,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
|
|
41
|
+
if (id.startsWith("text:")) {
|
|
42
|
+
const text = id.slice(5);
|
|
43
|
+
return { content: text, visible: text.length > 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const segment = SEGMENTS[id as keyof typeof SEGMENTS];
|
|
47
|
+
if (!segment) {
|
|
48
|
+
return { content: "", visible: false };
|
|
49
|
+
}
|
|
50
|
+
return segment.render(ctx);
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RenderedSegment, SegmentContext } from "../types.js";
|
|
2
|
+
import { applyColor } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
interface AgentModeState {
|
|
5
|
+
mode: string;
|
|
6
|
+
appearance?: { modeColor?: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readAgentModeState(): AgentModeState | undefined {
|
|
10
|
+
return (globalThis as Record<string, unknown>).__agentMode as AgentModeState | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const modeSwitcherSegment = {
|
|
14
|
+
id: "mode_switcher" as const,
|
|
15
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
16
|
+
const state = readAgentModeState();
|
|
17
|
+
const mode = state?.mode ?? "off";
|
|
18
|
+
|
|
19
|
+
const label = applyColor(ctx.theme, "dim", "Mode:");
|
|
20
|
+
const value = mode === "off" ? "Normal" : mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
21
|
+
const valueStr = applyColor(ctx.theme, state?.appearance?.modeColor ?? "muted", value);
|
|
22
|
+
return { content: `${label} ${valueStr}`, visible: true };
|
|
23
|
+
},
|
|
24
|
+
};
|