@vanillagreen/pi-qol 1.2.0 → 1.4.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.
@@ -0,0 +1,96 @@
1
+ export type QolSessionSearchScope = "current" | "all";
2
+
3
+ export interface QolSessionSearchSession {
4
+ allMessagesText: string;
5
+ created: Date;
6
+ cwd: string;
7
+ firstMessage: string;
8
+ id: string;
9
+ messageCount: number;
10
+ modified: Date;
11
+ name?: string;
12
+ parentSessionPath?: string;
13
+ path: string;
14
+ }
15
+
16
+ export interface QolSessionSearchResult extends QolSessionSearchSession {
17
+ rank: number;
18
+ snippets: string[];
19
+ }
20
+
21
+ export interface QolSessionUserMessage {
22
+ entryId?: string;
23
+ index: number;
24
+ parentId?: string | null;
25
+ text: string;
26
+ timestamp?: number;
27
+ }
28
+
29
+ export interface QolSessionSearchHit {
30
+ message: QolSessionUserMessage;
31
+ rank: number;
32
+ result: QolSessionSearchResult;
33
+ snippet: string;
34
+ }
35
+
36
+ export interface QolParsedSessionQuery {
37
+ mode: "tokens" | "regex";
38
+ tokens: Array<{ kind: "fuzzy" | "phrase"; value: string }>;
39
+ regex?: RegExp;
40
+ error?: string;
41
+ }
42
+
43
+ export interface QolSessionSearchState {
44
+ cursor: number;
45
+ query: string;
46
+ results: QolSessionSearchHit[];
47
+ selected: number;
48
+ scope: QolSessionSearchScope;
49
+ total: number;
50
+ }
51
+
52
+ export interface QolSessionMessagesState {
53
+ messages: QolSessionUserMessage[];
54
+ result: QolSessionSearchResult;
55
+ selected: number;
56
+ }
57
+
58
+ export interface QolSessionActionState {
59
+ message: QolSessionUserMessage;
60
+ result: QolSessionSearchResult;
61
+ }
62
+
63
+ export interface QolSessionContextConfirmState {
64
+ message: QolSessionUserMessage;
65
+ result: QolSessionSearchResult;
66
+ returnScreen: "search" | "messages" | "actions";
67
+ type: "summarize" | "newSession";
68
+ }
69
+
70
+ export interface QolSessionForkConfirmState {
71
+ messages: QolSessionUserMessage[];
72
+ result: QolSessionSearchResult;
73
+ returnScreen: "search" | "messages" | "actions";
74
+ selected: number;
75
+ }
76
+
77
+ export interface QolSessionConfirmModelState {
78
+ result: QolSessionSearchResult;
79
+ returnScreen: "search" | "messages" | "actions";
80
+ selected: 0 | 1;
81
+ previousModel: { provider: string; id: string };
82
+ currentModel: { provider: string; id: string };
83
+ }
84
+
85
+ export interface QolSessionSearchPendingMessage {
86
+ content: string;
87
+ details: Record<string, unknown>;
88
+ }
89
+
90
+ export interface QolSessionPaletteAction {
91
+ type: "cancel" | "resume" | "fork" | "copy" | "summarize" | "newSession";
92
+ customPrompt?: string;
93
+ keepCurrentModel?: boolean;
94
+ message?: QolSessionUserMessage;
95
+ result?: QolSessionSearchResult;
96
+ }
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { CONFIG_ID } from "./constants.js";
5
+
6
+ export type VstackConfig = Record<string, unknown>;
7
+
8
+ export function expandHome(input: string): string {
9
+ if (input === "~") return homedir();
10
+ if (input.startsWith("~/")) return join(homedir(), input.slice(2));
11
+ return input;
12
+ }
13
+
14
+ export function projectSettingsPath(cwd: string): string {
15
+ let current = resolve(cwd);
16
+ while (true) {
17
+ const candidate = join(current, ".pi", "settings.json");
18
+ if (existsSync(candidate)) return candidate;
19
+ if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".vstack-lock.json"))) return candidate;
20
+ const parent = dirname(current);
21
+ if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
22
+ current = parent;
23
+ }
24
+ }
25
+
26
+ export function piSettingsPaths(cwd = process.cwd()): string[] {
27
+ const userDir = resolve(expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "~/.pi/agent"));
28
+ return [join(userDir, "settings.json"), projectSettingsPath(cwd)];
29
+ }
30
+
31
+ export function readVstackConfig(cwd?: string): VstackConfig {
32
+ const merged: VstackConfig = {};
33
+ for (const path of piSettingsPaths(cwd)) {
34
+ if (!existsSync(path)) continue;
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
37
+ const config = parsed?.vstack?.extensionManager?.config?.[CONFIG_ID];
38
+ if (config && typeof config === "object" && !Array.isArray(config)) Object.assign(merged, config);
39
+ } catch {
40
+ // Ignore malformed optional manager config.
41
+ }
42
+ }
43
+ return merged;
44
+ }
45
+
46
+ export function settingBoolean(key: string, fallback: boolean, cwd?: string): boolean {
47
+ const value = readVstackConfig(cwd)[key];
48
+ return typeof value === "boolean" ? value : fallback;
49
+ }
50
+
51
+ export function settingString(key: string, fallback: string, cwd?: string): string {
52
+ const value = readVstackConfig(cwd)[key];
53
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
54
+ }
55
+
56
+ export function settingStringAllowEmpty(key: string, fallback: string, cwd?: string): string {
57
+ const value = readVstackConfig(cwd)[key];
58
+ return typeof value === "string" ? value.trim() : fallback;
59
+ }
60
+
61
+ export function newlineFallbackKey(cwd?: string): "ctrl+j" | "none" {
62
+ const configured = settingString("newlineFallbackKey", "ctrl+j", cwd).toLowerCase();
63
+ return configured === "none" ? "none" : "ctrl+j";
64
+ }
65
+
66
+ export function settingNumber(key: string, fallback: number, cwd?: string): number {
67
+ const value = readVstackConfig(cwd)[key];
68
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
69
+ return Number.isFinite(parsed) ? parsed : fallback;
70
+ }
71
+
72
+ export function boundedSettingNumber(key: string, fallback: number, min: number, max: number, cwd?: string): number {
73
+ return Math.max(min, Math.min(max, Math.floor(settingNumber(key, fallback, cwd))));
74
+ }
@@ -0,0 +1,41 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { compactionProfile } from "./compaction.js";
3
+ import {
4
+ DEFAULT_AUTO_RENAME_MODEL,
5
+ DEFAULT_COMPACTION_MODEL,
6
+ DEFAULT_IDLE_COMPACTION_SECONDS,
7
+ DEFAULT_PERMISSION_GATE_PREVIEW_CHARS,
8
+ DEFAULT_PERMISSION_GATE_PREVIEW_LINES,
9
+ } from "./constants.js";
10
+ import { currentEditorText } from "./editor.js";
11
+ import { attachmentLabels } from "./images.js";
12
+ import { permissionGateCommands } from "./permission-gate.js";
13
+ import { autoRenameEnabled } from "./session-rename.js";
14
+ import { sessionSearchShortcut } from "./session-search/index.js";
15
+ import { boundedSettingNumber, newlineFallbackKey, settingBoolean, settingNumber, settingString } from "./settings.js";
16
+
17
+ export function statusMessage(ctx: ExtensionContext): string {
18
+ const labels = attachmentLabels(currentEditorText(ctx), ctx.cwd);
19
+ const searchShortcut = sessionSearchShortcut(ctx.cwd);
20
+ return [
21
+ "Pi QOL status",
22
+ `Statusline: ${settingBoolean("replaceFooter", true, ctx.cwd) ? "replaces footer" : "footer preserved"}; prompt=${settingBoolean("compactPrompt", true, ctx.cwd) ? "π compact" : "default chrome"}`,
23
+ `shift+enter newline: ${settingBoolean("newlineOnShiftEnter", true, ctx.cwd) ? "enabled" : "disabled"}`,
24
+ `Fallback newline key: ${newlineFallbackKey(ctx.cwd)}`,
25
+ `Pending queue preview: ${settingBoolean("pendingQueue.asciiGreen", true, ctx.cwd) ? "ANSI green" : "Pi default"}`,
26
+ `Image chips: ${settingBoolean("showImageChips", true, ctx.cwd) ? "filled (placeholders and existing image paths)" : "off"}`,
27
+ `Image placeholders/paths in draft: ${labels.length ? labels.join(", ") : "none"}`,
28
+ `Rename command: ${settingBoolean("enableSessionNameCommand", true, ctx.cwd) ? "enabled (/rename)" : "disabled"}`,
29
+ `Auto session rename: ${autoRenameEnabled(ctx.cwd) ? `enabled (${settingString("sessionAutoRename.model", DEFAULT_AUTO_RENAME_MODEL, ctx.cwd)})` : "disabled"}`,
30
+ `Handoff command: ${settingBoolean("enableHandoffCommand", true, ctx.cwd) ? "enabled" : "disabled"}`,
31
+ `Handoff prompt review: ${settingBoolean("handoffReviewPrompt", true, ctx.cwd) ? "enabled" : "disabled"}`,
32
+ `Session search: ${settingBoolean("sessionSearch.enabled", true, ctx.cwd) ? `enabled (/search${searchShortcut ? `, ${searchShortcut}` : ""})` : "disabled"}`,
33
+ `Custom compaction: ${settingBoolean("compaction.customEnabled", false, ctx.cwd) ? `enabled (${settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd)}, ${compactionProfile(ctx.cwd)})` : "disabled (Pi default)"}`,
34
+ `Idle compaction: ${settingBoolean("compaction.idleEnabled", false, ctx.cwd) ? `enabled after ${Math.max(1, Math.floor(settingNumber("compaction.idleTimeoutSeconds", DEFAULT_IDLE_COMPACTION_SECONDS, ctx.cwd)))}s idle` : "disabled"}`,
35
+ `Branch summary override: ${settingBoolean("compaction.branchSummaryEnabled", false, ctx.cwd) ? "enabled" : "disabled"}`,
36
+ `Notifications: ${settingBoolean("notification.enabled", true, ctx.cwd) ? `enabled (bell=${settingBoolean("notification.bell", true, ctx.cwd)}, native=${settingBoolean("notification.native", true, ctx.cwd)}, tmuxClientTty=${settingBoolean("notification.tmuxNativeClientTty", true, ctx.cwd)}, tmuxMessage=${settingBoolean("notification.tmux", false, ctx.cwd)})` : "disabled"}`,
37
+ `Permission gate: ${settingBoolean("permissionGate.enabled", false, ctx.cwd) ? `enabled (${permissionGateCommands(ctx.cwd).join(", ") || "none configured"}; preview ${boundedSettingNumber("permissionGate.previewLines", DEFAULT_PERMISSION_GATE_PREVIEW_LINES, 4, 40, ctx.cwd)} lines/${boundedSettingNumber("permissionGate.previewChars", DEFAULT_PERMISSION_GATE_PREVIEW_CHARS, 200, 5000, ctx.cwd)} chars)` : "disabled"}`,
38
+ `Thinking timer: ${settingBoolean("thinkingTimer.enabled", true, ctx.cwd) ? "enabled" : "disabled"}`,
39
+ "If shift+enter still submits, configure your terminal/tmux to send a distinct shift+enter sequence or use the fallback key.",
40
+ ].join("\n");
41
+ }
@@ -0,0 +1,219 @@
1
+ import { execFile } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+ import { subagentStatuslineMarker } from "./agent-statusline.js";
6
+ import { stripAnsi } from "./ansi.js";
7
+ import { readCavemanBridge } from "./bridges.js";
8
+ import { CAVEMAN_ICON_ACTIVE, CAVEMAN_ICON_INACTIVE } from "./constants.js";
9
+ import { settingBoolean, settingNumber } from "./settings.js";
10
+
11
+ export interface GitState {
12
+ projectName: string;
13
+ branch?: string;
14
+ dirty: boolean;
15
+ inLinkedWorktree: boolean;
16
+ }
17
+
18
+ function repoNameFromRemote(remote: string): string | undefined {
19
+ const trimmed = remote.trim().replace(/\.git$/, "");
20
+ const match = trimmed.match(/([^/:]+)$/);
21
+ return match?.[1];
22
+ }
23
+
24
+ function formatModelName(ctx: ExtensionContext): string {
25
+ const model = ctx.model;
26
+ if (!model) return "no model";
27
+ let name = model.name || model.id;
28
+ name = name.replace(/^Claude\s+/i, "");
29
+ name = name.replace(/^claude[-_]/i, "");
30
+ name = name.replace(/[-_](20\d{6}|latest)$/i, "");
31
+ name = name.replace(/^gpt[-_]/i, "GPT ");
32
+ name = name.replace(/[-_]/g, " ");
33
+ name = name.replace(/\bopus\b/i, "Opus");
34
+ name = name.replace(/\bsonnet\b/i, "Sonnet");
35
+ name = name.replace(/\bhaiku\b/i, "Haiku");
36
+ name = name.replace(/\s+/g, " ").trim();
37
+ name = name.replace(/\b(Opus|Sonnet|Haiku) (\d) (\d)\b/, "$1 $2.$3");
38
+ return name;
39
+ }
40
+
41
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
42
+ const THINKING_TOKEN: Record<ThinkingLevel, "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh"> = {
43
+ off: "thinkingOff",
44
+ minimal: "thinkingMinimal",
45
+ low: "thinkingLow",
46
+ medium: "thinkingMedium",
47
+ high: "thinkingHigh",
48
+ xhigh: "thinkingXhigh",
49
+ };
50
+
51
+ function normalizeThinkingLevel(value: string | undefined): ThinkingLevel {
52
+ switch ((value ?? "").toLowerCase()) {
53
+ case "off": return "off";
54
+ case "minimal": return "minimal";
55
+ case "low": return "low";
56
+ case "medium": return "medium";
57
+ case "high": return "high";
58
+ case "xhigh": return "xhigh";
59
+ default: return "off";
60
+ }
61
+ }
62
+
63
+ function formatWindow(tokens: number | undefined): string {
64
+ if (!tokens || tokens <= 0) return "?";
65
+ if (tokens >= 1_000_000) {
66
+ const value = tokens / 1_000_000;
67
+ return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
68
+ }
69
+ if (tokens >= 1_000) {
70
+ const value = tokens / 1_000;
71
+ return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}k`;
72
+ }
73
+ return `${tokens}`;
74
+ }
75
+
76
+ function statuslineContextInfo(ctx: ExtensionContext): { label: string; percent: number | null } {
77
+ const usage = ctx.getContextUsage();
78
+ const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
79
+ if (typeof usage?.percent !== "number") return { label: formatWindow(contextWindow), percent: null };
80
+ const usedPercent = Math.max(0, Math.min(100, Math.round(usage.percent)));
81
+ return { label: formatWindow(contextWindow), percent: 100 - usedPercent };
82
+ }
83
+
84
+ function gitBadge(state: GitState, showDirtyMarker: boolean): string {
85
+ if (!state.branch) return "";
86
+ const icon = state.inLinkedWorktree || state.branch !== "main" ? `🌳 ${state.branch}` : "🦀";
87
+ return ` (${icon}${state.dirty && showDirtyMarker ? "*" : ""})`;
88
+ }
89
+
90
+ export function makeFallbackGitState(cwd: string): GitState {
91
+ return { projectName: basename(cwd), dirty: false, inLinkedWorktree: false };
92
+ }
93
+
94
+ async function runGit(pi: ExtensionAPI, cwd: string, args: string[]): Promise<string | undefined> {
95
+ try {
96
+ const result = await pi.exec("git", ["-C", cwd, ...args], { timeout: settingNumber("gitRefreshTimeoutMs", 1500, cwd) });
97
+ if (result.code !== 0) return undefined;
98
+ const stdout = result.stdout.trim();
99
+ return stdout.length > 0 ? stdout : undefined;
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ export async function refreshGitState(pi: ExtensionAPI, ctx: ExtensionContext): Promise<GitState> {
106
+ const cwd = ctx.cwd;
107
+ const topLevel = await runGit(pi, cwd, ["rev-parse", "--show-toplevel"]);
108
+ if (!topLevel) return makeFallbackGitState(cwd);
109
+ const [remote, worktreesRaw, branchRaw, shortHead, diffExit] = await Promise.all([
110
+ runGit(pi, cwd, ["remote", "get-url", "origin"]),
111
+ runGit(pi, cwd, ["worktree", "list", "--porcelain"]),
112
+ runGit(pi, cwd, ["branch", "--show-current"]),
113
+ runGit(pi, cwd, ["rev-parse", "--short", "HEAD"]),
114
+ pi.exec("git", ["-C", cwd, "diff-index", "--quiet", "HEAD", "--"], { timeout: settingNumber("gitRefreshTimeoutMs", 1500, cwd) })
115
+ .then((result) => result.code)
116
+ .catch(() => 0),
117
+ ]);
118
+ const firstWorktreeLine = worktreesRaw?.split("\n").find((line) => line.startsWith("worktree "));
119
+ const mainWorktree = firstWorktreeLine?.slice("worktree ".length).trim();
120
+ const inLinkedWorktree = Boolean(mainWorktree && mainWorktree !== topLevel);
121
+ const projectName = repoNameFromRemote(remote ?? "") ?? basename(mainWorktree || topLevel);
122
+ const branch = branchRaw || shortHead;
123
+ return { projectName, branch, dirty: diffExit === 1, inLinkedWorktree };
124
+ }
125
+
126
+ export function normalizedSessionName(pi: ExtensionAPI): string | undefined {
127
+ const name = pi.getSessionName();
128
+ if (!name) return undefined;
129
+ const normalized = stripAnsi(name).replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim();
130
+ return normalized || undefined;
131
+ }
132
+
133
+ export function sessionNameHeader(width: number, pi: ExtensionAPI, theme: Pick<Theme, "fg" | "bg">): string[] {
134
+ const name = normalizedSessionName(pi);
135
+ if (!name || width < 4) return [];
136
+ const prefixPlain = "Session ";
137
+ const prefix = theme.fg("muted", prefixPlain);
138
+ const innerWidth = Math.max(1, width - visibleWidth(prefixPlain) - 2);
139
+ const inner = truncateToWidth(name, innerWidth, "…");
140
+ const plain = ` ${inner} `;
141
+ const badge = theme.bg("selectedBg", theme.fg("text", plain));
142
+ return [truncateToWidth(`${prefix}${badge}`, width, "")];
143
+ }
144
+
145
+ export function formatTmuxSessionTitle(sessionName: string): string {
146
+ return `π ${sessionName}`;
147
+ }
148
+
149
+ export function tmuxPaneTarget(): string | undefined {
150
+ return process.env.TMUX && process.env.TMUX_PANE ? process.env.TMUX_PANE : undefined;
151
+ }
152
+
153
+ export function readTmuxPaneTitle(target: string, callback: (title: string | undefined) => void): void {
154
+ execFile("tmux", ["display-message", "-p", "-t", target, "#{pane_title}"], { timeout: 1000 }, (error, stdout) => callback(error ? undefined : stdout.replace(/\r?\n$/, "")));
155
+ }
156
+
157
+ export function readTmuxWindowOption(target: string, option: string, callback: (value: string | undefined) => void): void {
158
+ execFile("tmux", ["show-options", "-wqv", "-t", target, option], { timeout: 1000 }, (error, stdout) => callback(error ? undefined : stdout.replace(/\r?\n$/, "")));
159
+ }
160
+
161
+ export function setTmuxPaneTitle(target: string, title: string): void {
162
+ execFile("tmux", ["select-pane", "-t", target, "-T", title], { timeout: 1000 }, () => undefined);
163
+ }
164
+
165
+ export function setTmuxWindowOption(target: string, option: string, value: string): void {
166
+ execFile("tmux", ["set-option", "-wq", "-t", target, option, value], { timeout: 1000 }, () => undefined);
167
+ }
168
+
169
+ export function readTmuxWindowName(target: string, callback: (name: string | undefined) => void): void {
170
+ execFile("tmux", ["display-message", "-p", "-t", target, "#{window_name}"], { timeout: 1000 }, (error, stdout) => callback(error ? undefined : stdout.replace(/\r?\n$/, "")));
171
+ }
172
+
173
+ export function setTmuxWindowName(target: string, name: string): void {
174
+ execFile("tmux", ["rename-window", "-t", target, name], { timeout: 1000 }, () => undefined);
175
+ }
176
+
177
+ function cavemanIconTone(mode: string, active: boolean): "muted" | "text" | "success" | "thinkingHigh" | "error" {
178
+ if (!active) return "muted";
179
+ switch (mode) {
180
+ case "micro": return "text";
181
+ case "lite": return "success";
182
+ case "full": return "thinkingHigh";
183
+ case "ultra": return "error";
184
+ default: return "muted";
185
+ }
186
+ }
187
+
188
+ export function renderStatusLine(width: number, ctx: ExtensionContext, git: GitState, pi: ExtensionAPI, theme: Pick<Theme, "fg">): string {
189
+ const { label: contextLabel, percent } = statuslineContextInfo(ctx);
190
+ const projectChunk = `${git.projectName}${gitBadge(git, settingBoolean("showDirtyMarker", true, ctx.cwd))} ${formatModelName(ctx)}`;
191
+ const statusSeparator = " / ";
192
+ const thinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel());
193
+ const thinkingChunk = thinkingLevel;
194
+ const contextChunk = ` ${contextLabel}`;
195
+ const cavemanBridge = readCavemanBridge();
196
+ const cavemanVisible = !!cavemanBridge && (cavemanBridge.isStatusBadgeEnabled?.(ctx.cwd) ?? true);
197
+ const caveman = cavemanVisible ? cavemanBridge : undefined;
198
+ const cavemanActive = caveman?.isActive() ?? false;
199
+ const cavemanGlyph = caveman ? (cavemanActive ? CAVEMAN_ICON_ACTIVE : CAVEMAN_ICON_INACTIVE) : "";
200
+ const cavemanTone = cavemanIconTone(caveman?.getMode() ?? "off", cavemanActive);
201
+ const cavemanSegment = caveman ? `${statusSeparator}${cavemanGlyph}` : "";
202
+ const contextSeparator = caveman ? ` ${statusSeparator}` : "";
203
+ const leftPlain = `${projectChunk}${statusSeparator}${thinkingChunk}${cavemanSegment}${contextSeparator}${contextChunk.trimStart()}`;
204
+ const percentPlain = percent === null ? "…%" : `${percent}%`;
205
+ const subagentMarker = subagentStatuslineMarker(ctx.cwd);
206
+ const rightPlain = subagentMarker ? `${percentPlain} ${subagentMarker.plain}` : percentPlain;
207
+ const percentColor = percent === null ? "muted" : percent <= 15 ? "error" : percent <= 30 ? "warning" : "success";
208
+ const separatorColored = theme.fg("muted", statusSeparator);
209
+ const leftColored = caveman
210
+ ? `${theme.fg("accent", projectChunk)}${separatorColored}${theme.fg(THINKING_TOKEN[thinkingLevel], thinkingChunk)}${separatorColored}${theme.fg(cavemanTone, cavemanGlyph)}${theme.fg("muted", contextSeparator)}${theme.fg("accent", contextChunk.trimStart())}`
211
+ : `${theme.fg("accent", projectChunk)}${separatorColored}${theme.fg(THINKING_TOKEN[thinkingLevel], thinkingChunk)}${theme.fg("accent", contextChunk)}`;
212
+ const right = subagentMarker ? `${theme.fg(percentColor, percentPlain)} ${subagentMarker.styled}` : theme.fg(percentColor, percentPlain);
213
+ const minimumGap = 1;
214
+ const gapWidth = Math.max(minimumGap, width - visibleWidth(leftPlain) - visibleWidth(rightPlain) - 2);
215
+ const filled = percent === null ? 0 : Math.round(gapWidth * (percent / 100));
216
+ const empty = Math.max(0, gapWidth - filled);
217
+ const bar = " ".repeat(empty) + theme.fg("warning", "─".repeat(filled));
218
+ return truncateToWidth(`${leftColored} ${bar} ${right}`, width, "");
219
+ }
@@ -0,0 +1,97 @@
1
+ import { AssistantMessageComponent, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { THINKING_LABEL_DEFAULT, THINKING_TIMER_PATCH_SYMBOL, THINKING_TIMER_STORE_SYMBOL } from "./constants.js";
4
+ import { settingString } from "./settings.js";
5
+
6
+ export interface ThinkingTimerStore {
7
+ cwd?: string;
8
+ enabled: boolean;
9
+ starts: Map<string, number>;
10
+ durations: Map<string, number>;
11
+ labels: Map<string, Text>;
12
+ theme?: ExtensionContext["ui"]["theme"];
13
+ }
14
+
15
+ export function getThinkingTimerStore(): ThinkingTimerStore | undefined {
16
+ return (globalThis as unknown as Record<PropertyKey, unknown>)[THINKING_TIMER_STORE_SYMBOL] as ThinkingTimerStore | undefined;
17
+ }
18
+
19
+ export function formatThinkingElapsed(ms: number): string {
20
+ const totalSeconds = ms / 1000;
21
+ if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
22
+ const minutes = Math.floor(totalSeconds / 60);
23
+ const seconds = totalSeconds - minutes * 60;
24
+ return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`;
25
+ }
26
+
27
+ export function thinkingLabel(cwd?: string): string {
28
+ const configured = settingString("thinkingLabel.text", THINKING_LABEL_DEFAULT, cwd);
29
+ return configured.trim() ? configured : THINKING_LABEL_DEFAULT;
30
+ }
31
+
32
+ export function thinkingTimerLabel(theme: ThinkingTimerStore["theme"], ms: number, cwd?: string): string {
33
+ const base = thinkingLabel(cwd);
34
+ const separator = /\s$/.test(base) ? "" : " ";
35
+ const elapsed = `${separator}${formatThinkingElapsed(ms)}`;
36
+ if (!theme) return `${base}${elapsed}`;
37
+ return theme.italic(theme.fg("muted", base) + theme.fg("dim", elapsed));
38
+ }
39
+
40
+ export function hiddenThinkingLabel(theme: ThinkingTimerStore["theme"], cwd?: string): string {
41
+ const base = thinkingLabel(cwd);
42
+ return theme ? theme.fg("muted", base) : base;
43
+ }
44
+
45
+ export function thinkingTimerKey(timestamp: number, contentIndex: number): string {
46
+ return `${timestamp}:${contentIndex}`;
47
+ }
48
+
49
+ export function installThinkingTimerPatch(): void {
50
+ const proto = AssistantMessageComponent.prototype as unknown as Record<PropertyKey, any>;
51
+ if (proto[THINKING_TIMER_PATCH_SYMBOL]) return;
52
+ const originalUpdateContent = proto.updateContent;
53
+ if (typeof originalUpdateContent !== "function") return;
54
+ proto[THINKING_TIMER_PATCH_SYMBOL] = true;
55
+ proto.updateContent = function patchedUpdateContent(this: any, message: any): void {
56
+ originalUpdateContent.call(this, message);
57
+ try {
58
+ const store = getThinkingTimerStore();
59
+ if (!store?.enabled) return;
60
+ if (!message || !Array.isArray(message.content) || typeof message.timestamp !== "number") return;
61
+ if (!this.hideThinkingBlock) return;
62
+ if (!this.contentContainer || !Array.isArray(this.contentContainer.children)) return;
63
+
64
+ const thinkingIndices: number[] = [];
65
+ for (let i = 0; i < message.content.length; i++) {
66
+ const content = message.content[i];
67
+ if (content?.type === "thinking" && typeof content.thinking === "string" && content.thinking.trim()) thinkingIndices.push(i);
68
+ }
69
+ if (thinkingIndices.length === 0) return;
70
+
71
+ const labelComponents: Text[] = [];
72
+ for (const child of this.contentContainer.children as any[]) {
73
+ if (!child || typeof child !== "object") continue;
74
+ if (typeof child.setText !== "function") continue;
75
+ if (typeof child.text !== "string") continue;
76
+ const expectedLabel = thinkingLabel(store.cwd);
77
+ if (!child.text.includes(expectedLabel) && !child.text.includes("Thinking...")) continue;
78
+ labelComponents.push(child as Text);
79
+ }
80
+ if (labelComponents.length === 0) return;
81
+
82
+ const count = Math.min(thinkingIndices.length, labelComponents.length);
83
+ for (let i = 0; i < count; i++) {
84
+ const contentIndex = thinkingIndices[i]!;
85
+ const label = labelComponents[i]!;
86
+ const key = thinkingTimerKey(message.timestamp, contentIndex);
87
+ store.labels.set(key, label);
88
+ const duration = store.durations.get(key);
89
+ const start = store.starts.get(key);
90
+ const ms = duration ?? (start === undefined ? undefined : Date.now() - start);
91
+ if (ms !== undefined) label.setText(thinkingTimerLabel(store.theme, ms, store.cwd));
92
+ }
93
+ } catch {
94
+ // Rendering must never break because of this optional monkey-patch.
95
+ }
96
+ };
97
+ }
@@ -0,0 +1,7 @@
1
+ export function stringifyError(error: unknown): string {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
4
+
5
+ export function escapeRegex(input: string): string {
6
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ }