@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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/configs/opl-browser.json.sample +8 -0
  4. package/configs/opl-footer.json +48 -0
  5. package/configs/opl-footer.json.sample +48 -0
  6. package/configs/opl-input.json +22 -0
  7. package/configs/opl-input.json.sample +22 -0
  8. package/configs/opl-modes.json +291 -0
  9. package/configs/opl-modes.json.sample +285 -0
  10. package/configs/opl-simplebench.json +8 -0
  11. package/configs/opl-simplebench.json.sample +8 -0
  12. package/configs/opl-todo.json +12 -0
  13. package/configs/opl-todo.json.sample +12 -0
  14. package/configs/opl-webaccess.json +31 -0
  15. package/configs/opl-webaccess.json.sample +31 -0
  16. package/extensions/opl-browser/README.md +83 -0
  17. package/extensions/opl-browser/browser.ts +185 -0
  18. package/extensions/opl-browser/config.ts +37 -0
  19. package/extensions/opl-browser/index.ts +89 -0
  20. package/extensions/opl-browser/package-lock.json +60 -0
  21. package/extensions/opl-browser/package.json +10 -0
  22. package/extensions/opl-ctxtrim/README.md +78 -0
  23. package/extensions/opl-ctxtrim/index.ts +151 -0
  24. package/extensions/opl-footer/README.md +193 -0
  25. package/extensions/opl-footer/config.ts +91 -0
  26. package/extensions/opl-footer/git-status.ts +170 -0
  27. package/extensions/opl-footer/icons.ts +49 -0
  28. package/extensions/opl-footer/index.ts +368 -0
  29. package/extensions/opl-footer/segments/caveman.ts +25 -0
  30. package/extensions/opl-footer/segments/chat-mode.ts +24 -0
  31. package/extensions/opl-footer/segments/context.ts +92 -0
  32. package/extensions/opl-footer/segments/cost.ts +25 -0
  33. package/extensions/opl-footer/segments/git.ts +47 -0
  34. package/extensions/opl-footer/segments/helpers.ts +18 -0
  35. package/extensions/opl-footer/segments/index.ts +51 -0
  36. package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
  37. package/extensions/opl-footer/segments/model.ts +22 -0
  38. package/extensions/opl-footer/segments/path.ts +34 -0
  39. package/extensions/opl-footer/segments/pi.ts +11 -0
  40. package/extensions/opl-footer/segments/plan-mode.ts +24 -0
  41. package/extensions/opl-footer/segments/separator.ts +9 -0
  42. package/extensions/opl-footer/segments/session-stats.ts +82 -0
  43. package/extensions/opl-footer/segments/thinking.ts +51 -0
  44. package/extensions/opl-footer/segments/tokens.ts +66 -0
  45. package/extensions/opl-footer/theme.ts +113 -0
  46. package/extensions/opl-footer/types.ts +172 -0
  47. package/extensions/opl-init/README.md +23 -0
  48. package/extensions/opl-init/index.ts +451 -0
  49. package/extensions/opl-input/README.md +83 -0
  50. package/extensions/opl-input/config.ts +137 -0
  51. package/extensions/opl-input/index.ts +284 -0
  52. package/extensions/opl-input/mode-style.ts +38 -0
  53. package/extensions/opl-input/utils.ts +378 -0
  54. package/extensions/opl-modes/README.md +132 -0
  55. package/extensions/opl-modes/config.ts +473 -0
  56. package/extensions/opl-modes/index.ts +1081 -0
  57. package/extensions/opl-modes/menus.ts +60 -0
  58. package/extensions/opl-modes/state.ts +152 -0
  59. package/extensions/opl-modes/types.ts +164 -0
  60. package/extensions/opl-modes/utils.ts +138 -0
  61. package/extensions/opl-questionnaire/README.md +26 -0
  62. package/extensions/opl-questionnaire/index.ts +445 -0
  63. package/extensions/opl-questionnaire/types.ts +55 -0
  64. package/extensions/opl-simplebench/README.md +242 -0
  65. package/extensions/opl-simplebench/artifact.ts +41 -0
  66. package/extensions/opl-simplebench/benchmark.ts +873 -0
  67. package/extensions/opl-simplebench/coding.ts +282 -0
  68. package/extensions/opl-simplebench/index.ts +203 -0
  69. package/extensions/opl-simplebench/llama-server.ts +290 -0
  70. package/extensions/opl-simplebench/metrics.ts +73 -0
  71. package/extensions/opl-simplebench/report.ts +46 -0
  72. package/extensions/opl-simplebench/research.ts +200 -0
  73. package/extensions/opl-simplebench/scoring.ts +48 -0
  74. package/extensions/opl-simplebench/tests.ts +39 -0
  75. package/extensions/opl-simplebench/types.ts +6 -0
  76. package/extensions/opl-simplebench/util/config.ts +170 -0
  77. package/extensions/opl-simplebench/util/debug.ts +32 -0
  78. package/extensions/opl-simplebench/util/format.ts +402 -0
  79. package/extensions/opl-simplebench/util/providers.ts +841 -0
  80. package/extensions/opl-todo/README.md +43 -0
  81. package/extensions/opl-todo/config.ts +71 -0
  82. package/extensions/opl-todo/index.ts +415 -0
  83. package/extensions/opl-webaccess/README.md +47 -0
  84. package/extensions/opl-webaccess/config.ts +55 -0
  85. package/extensions/opl-webaccess/extract.ts +83 -0
  86. package/extensions/opl-webaccess/index.ts +232 -0
  87. package/extensions/opl-webaccess/package-lock.json +345 -0
  88. package/extensions/opl-webaccess/package.json +18 -0
  89. package/extensions/opl-webaccess/pdf.ts +8 -0
  90. package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
  91. package/extensions/opl-webaccess/providers/exa.ts +74 -0
  92. package/extensions/opl-webaccess/providers/gemini.ts +60 -0
  93. package/extensions/opl-webaccess/providers/searxng.ts +86 -0
  94. package/extensions/opl-webaccess/providers/tavily.ts +60 -0
  95. package/extensions/opl-webaccess/search.ts +36 -0
  96. package/extensions/opl-webaccess/storage.ts +51 -0
  97. package/extensions/opl-webaccess/turndown.d.ts +1 -0
  98. package/extensions/opl-webaccess/types.ts +26 -0
  99. package/extensions/opl-webaccess/utils.ts +28 -0
  100. 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
+ };