@owlburtoe/pi-cc-tools 1.0.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,4485 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { readFile as readFileAsync } from "node:fs/promises";
3
+ import { extname, relative, resolve } from "node:path";
4
+
5
+ import type {
6
+ BashToolDetails,
7
+ EditToolDetails,
8
+ ExtensionAPI,
9
+ GrepToolDetails,
10
+ ReadToolDetails,
11
+ Theme,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ AssistantMessageComponent,
15
+ CustomMessageComponent,
16
+ ToolExecutionComponent,
17
+ UserMessageComponent,
18
+ createBashTool,
19
+ createEditTool,
20
+ createFindTool,
21
+ createGrepTool,
22
+ createLsTool,
23
+ createReadTool,
24
+ createWriteTool,
25
+ } from "@earendil-works/pi-coding-agent";
26
+ import {
27
+ Box,
28
+ Container,
29
+ deleteAllKittyImages,
30
+ getCapabilities,
31
+ getImageDimensions,
32
+ imageFallback,
33
+ Markdown,
34
+ Spacer,
35
+ Text,
36
+ truncateToWidth,
37
+ visibleWidth,
38
+ wrapTextWithAnsi,
39
+ } from "@earendil-works/pi-tui";
40
+
41
+ import * as Diff from "diff";
42
+ import type { BundledLanguage, BundledTheme } from "shiki";
43
+
44
+ const RESET = "\x1b[0m";
45
+ const TRANSPARENT_BG = "\x1b[49m";
46
+ const TRANSPARENT_RESET = `${RESET}${TRANSPARENT_BG}`;
47
+
48
+ // Border / branch rule colors. Defaults match the previous hardcoded values
49
+ // so behavior is identical when the theme is unavailable or themeAdaptive=false.
50
+ // `applyThemePaletteIfNeeded(theme)` re-derives these from `theme.fg("borderMuted"|"muted")`.
51
+ let BORDER_COLOR = "\x1b[38;5;238m";
52
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
53
+ const ANSI_PRESENT_RE = /\x1b\[[0-9;]*m/;
54
+ const PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-container-render");
55
+ const TOOL_RENDER_CACHE = Symbol.for("pi-claude-style-tools:tool-render-cache");
56
+ const TOOL_CACHE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-cache-invalidation");
57
+ const TOOL_IMAGE_EXPAND_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-read-image-expansion");
58
+ const CUSTOM_MESSAGE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-custom-message-render");
59
+ const USER_MESSAGE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-user-message-render");
60
+ const WRAP_MARK = "\uE000";
61
+ const KITTY_IMAGE_PREFIX = "\x1b_G";
62
+ const ITERM2_IMAGE_PREFIX = "\x1b]1337;File=";
63
+
64
+ let toolBackgroundMode: "default" | "transparent" | "outlines" = "outlines";
65
+
66
+ type SpinnerVerbMode = "append" | "replace";
67
+
68
+ interface SettingsFile {
69
+ toolBackground?: "default" | "transparent" | "outlines" | "border";
70
+ readOutputMode?: "hidden" | "summary" | "preview";
71
+ searchOutputMode?: "hidden" | "count" | "preview";
72
+ mcpOutputMode?: "hidden" | "summary" | "preview";
73
+ previewLines?: number;
74
+ expandedPreviewMaxLines?: number;
75
+ bashOutputMode?: "opencode" | "summary" | "preview";
76
+ bashCollapsedLines?: number;
77
+ showTruncationHints?: boolean;
78
+ diffCollapsedLines?: number;
79
+ diffTheme?: string;
80
+ diffColors?: Record<string, string>;
81
+ /**
82
+ * When true (default), derive borders, dim text, branch rules, and diff
83
+ * accents from the active pi theme via `theme.getFgAnsi`/`getBgAnsi`.
84
+ * Explicit `diffTheme` / `diffColors` always win over theme-derived
85
+ * defaults so users keep full control.
86
+ */
87
+ themeAdaptive?: boolean;
88
+ /**
89
+ * Theme color key used for the spinner glyph and verb text (e.g. "✻ Cooking…").
90
+ * Defaults to "borderAccent". Valid keys are any of the pi theme `ThemeColor`
91
+ * names (e.g. accent, borderAccent, success, warning, mdHeading, thinkingMedium, bashMode).
92
+ */
93
+ spinnerColor?: string;
94
+ /** Backward-compatible alias for spinnerColor from earlier releases. */
95
+ spinnerVerbColor?: string;
96
+ /**
97
+ * Theme color key used for the spinner status suffix (the parenthesized
98
+ * "(thinking · ↓ 10 tokens · 2s)" trailer). Defaults to "muted".
99
+ */
100
+ spinnerStatusColor?: string;
101
+ /**
102
+ * Optional custom spinner verbs. These are appended to defaults unless
103
+ * spinnerVerbMode is "replace". Values are sanitized before display.
104
+ */
105
+ spinnerVerbs?: string[];
106
+ /** Whether custom spinnerVerbs append to or replace the default verb list. */
107
+ spinnerVerbMode?: SpinnerVerbMode;
108
+ }
109
+
110
+ let _settingsCache: { value: SettingsFile; timestamp: number } | null = null;
111
+ const SETTINGS_CACHE_TTL_MS = 5_000;
112
+
113
+ function readSettings(): SettingsFile {
114
+ const now = Date.now();
115
+ if (_settingsCache && now - _settingsCache.timestamp < SETTINGS_CACHE_TTL_MS) {
116
+ return _settingsCache.value;
117
+ }
118
+ const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
119
+ for (const path of paths) {
120
+ try {
121
+ if (!path || !existsSync(path)) continue;
122
+ const raw = JSON.parse(readFileSync(path, "utf8"));
123
+ if (raw && typeof raw === "object") {
124
+ const result = raw as SettingsFile;
125
+ _settingsCache = { value: result, timestamp: now };
126
+ return result;
127
+ }
128
+ } catch {
129
+ // ignore invalid settings files
130
+ }
131
+ }
132
+ const empty: SettingsFile = {};
133
+ _settingsCache = { value: empty, timestamp: now };
134
+ return empty;
135
+ }
136
+
137
+ // Cross-extension bust signal for spinner.ts — it watches this counter on
138
+ // globalThis and invalidates its settings cache when it changes. Lets
139
+ // /cc-spinner edits take effect on the next 250ms spinner tick instead of
140
+ // waiting for the file-stat TTL.
141
+ const SPINNER_BUST_KEY = Symbol.for("pi-claude-style-tools:spinner-settings-bust");
142
+ function bustSpinnerSettingsCache(): void {
143
+ const current = ((globalThis as any)[SPINNER_BUST_KEY] as number | undefined) ?? 0;
144
+ (globalThis as any)[SPINNER_BUST_KEY] = current + 1;
145
+ }
146
+
147
+ function readMergedSettings(): SettingsFile {
148
+ const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
149
+ const merged: SettingsFile = {};
150
+ for (const path of paths) {
151
+ try {
152
+ if (!path || !existsSync(path)) continue;
153
+ const raw = JSON.parse(readFileSync(path, "utf8"));
154
+ if (raw && typeof raw === "object") Object.assign(merged, raw as SettingsFile);
155
+ } catch {
156
+ // ignore invalid settings files
157
+ }
158
+ }
159
+ return merged;
160
+ }
161
+
162
+ const MAX_CUSTOM_SPINNER_VERBS = 200;
163
+ const MAX_SPINNER_VERB_LENGTH = 48;
164
+ const ANSI_ESCAPE_SEQUENCE_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
165
+ const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F-\u009F]/g;
166
+
167
+ function sanitizeSpinnerVerb(value: unknown): string | null {
168
+ if (typeof value !== "string") return null;
169
+ const cleaned = value
170
+ .replace(ANSI_ESCAPE_SEQUENCE_RE, "")
171
+ .replace(CONTROL_CHARS_RE, "")
172
+ .trim();
173
+ if (!cleaned) return null;
174
+ return Array.from(cleaned).slice(0, MAX_SPINNER_VERB_LENGTH).join("");
175
+ }
176
+
177
+ function sanitizeSpinnerVerbs(value: unknown): string[] {
178
+ if (!Array.isArray(value)) return [];
179
+ const seen = new Set<string>();
180
+ const verbs: string[] = [];
181
+ for (const item of value) {
182
+ const verb = sanitizeSpinnerVerb(item);
183
+ if (!verb) continue;
184
+ const key = verb.toLocaleLowerCase();
185
+ if (seen.has(key)) continue;
186
+ seen.add(key);
187
+ verbs.push(verb);
188
+ if (verbs.length >= MAX_CUSTOM_SPINNER_VERBS) break;
189
+ }
190
+ return verbs;
191
+ }
192
+
193
+ function getSpinnerVerbMode(settings: SettingsFile): SpinnerVerbMode {
194
+ return settings.spinnerVerbMode === "replace" ? "replace" : "append";
195
+ }
196
+
197
+ function writeSettingsKey(key: string, value: unknown): void {
198
+ _settingsCache = null; // invalidate cache on write
199
+ const home = process.env.HOME ?? "";
200
+ if (!home) return;
201
+ const dir = `${home}/.pi`;
202
+ const path = `${dir}/settings.json`;
203
+ let settings: Record<string, unknown> = {};
204
+ try {
205
+ if (existsSync(path)) settings = JSON.parse(readFileSync(path, "utf8")) ?? {};
206
+ } catch { /* start fresh */ }
207
+ if (value === undefined) {
208
+ delete settings[key];
209
+ } else {
210
+ settings[key] = value;
211
+ }
212
+ try {
213
+ mkdirSync(dir, { recursive: true });
214
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
215
+ } catch { /* best effort */ }
216
+ }
217
+
218
+ let toolBackgroundOverride: "default" | "transparent" | "outlines" | null = null;
219
+
220
+ function syncToolBackgroundMode(): void {
221
+ if (toolBackgroundOverride) {
222
+ toolBackgroundMode = toolBackgroundOverride;
223
+ return;
224
+ }
225
+ const settings = readSettings();
226
+ // Backward compat: "border" was renamed to "outlines"
227
+ const raw = settings.toolBackground === "border" ? "outlines" : settings.toolBackground;
228
+ toolBackgroundMode = raw ?? "outlines";
229
+ }
230
+
231
+ function setThemeBg(theme: unknown, key: string, value: string): void {
232
+ const themeAny = theme as any;
233
+ if (themeAny.bgColors instanceof Map) {
234
+ themeAny.bgColors.set(key, value);
235
+ } else if (themeAny.bgColors && typeof themeAny.bgColors === "object") {
236
+ themeAny.bgColors[key] = value;
237
+ }
238
+ }
239
+
240
+ function applyToolBackgroundMode(theme: unknown): void {
241
+ syncToolBackgroundMode();
242
+ setThemeBg(theme, "userMessageBg", TRANSPARENT_BG);
243
+ if (toolBackgroundMode === "default") return;
244
+
245
+ setThemeBg(theme, "toolPendingBg", TRANSPARENT_BG);
246
+ setThemeBg(theme, "toolSuccessBg", TRANSPARENT_BG);
247
+ setThemeBg(theme, "toolErrorBg", TRANSPARENT_BG);
248
+ }
249
+
250
+ function stripAnsi(text: string): string {
251
+ return text.replace(ANSI_RE, "");
252
+ }
253
+
254
+ function stripRenderedHeadingMarkers(line: string): string {
255
+ return line.replace(/^((?:\x1b\[[0-9;]*m|[ \t])*)#{3,6}[ \t]*((?:\x1b\[[0-9;]*m)*)/, "$1$2");
256
+ }
257
+
258
+ function sanitizeRenderedTextBlockLines(lines: string[]): string[] {
259
+ let inFence = false;
260
+ return lines.map((line) => {
261
+ const plain = stripAnsi(line).trimStart();
262
+ if (plain.startsWith("```")) {
263
+ inFence = !inFence;
264
+ return line;
265
+ }
266
+ if (inFence) return line;
267
+ return stripRenderedHeadingMarkers(line).replace(/###/g, "");
268
+ });
269
+ }
270
+
271
+ function isBlankLine(text: string): boolean {
272
+ return stripAnsi(text).trim().length === 0;
273
+ }
274
+
275
+ function borderLine(width: number): string {
276
+ return `${BORDER_COLOR}${"─".repeat(Math.max(1, width))}${TRANSPARENT_RESET}`;
277
+ }
278
+
279
+ function clampLineWidth(line: string, width: number): string {
280
+ if (width <= 0) return "";
281
+ return visibleWidth(line) > width ? truncateToWidth(line, width) : line;
282
+ }
283
+
284
+ function isToolExecutionLike(value: unknown): value is { toolName: string; toolCallId: string } {
285
+ if (!value || typeof value !== "object") return false;
286
+ const candidate = value as Record<string, unknown>;
287
+ return typeof candidate.toolName === "string" && typeof candidate.toolCallId === "string";
288
+ }
289
+
290
+ function isTerminalImageLine(line: string): boolean {
291
+ return line.includes(KITTY_IMAGE_PREFIX) || line.includes(ITERM2_IMAGE_PREFIX);
292
+ }
293
+
294
+ function normalizeLeadingCheckGlyph(line: string): string {
295
+ return line.replace(/^((?:\x1b\[[0-9;]*m|[ \t])*)[✓✔](?=\s)/, "$1●");
296
+ }
297
+
298
+ function firstImageBlockStart(lines: string[]): number {
299
+ const imageLineIndex = lines.findIndex(isTerminalImageLine);
300
+ if (imageLineIndex === -1) return -1;
301
+ let start = imageLineIndex;
302
+ while (start > 0 && isBlankLine(lines[start - 1])) start--;
303
+ return start;
304
+ }
305
+
306
+ function splitRenderedImageBlock(lines: string[]): { textLines: string[]; imageLines: string[] } {
307
+ const imageStart = firstImageBlockStart(lines);
308
+ if (imageStart === -1) return { textLines: lines, imageLines: [] };
309
+ const textLines = lines.slice(0, imageStart);
310
+ while (textLines.length > 0 && isBlankLine(textLines[textLines.length - 1])) textLines.pop();
311
+ return { textLines, imageLines: lines.slice(imageStart) };
312
+ }
313
+
314
+ function patchGlobalToolBorders(): void {
315
+ const proto = Container.prototype as any;
316
+ if (proto[PATCH_FLAG]) return;
317
+
318
+ const originalRender = proto.render;
319
+ proto.render = function patchedContainerRender(width: number): string[] {
320
+ if (isToolExecutionLike(this)) {
321
+ const cached = (this as any)[TOOL_RENDER_CACHE];
322
+ if (cached?.width === width && cached?.mode === toolBackgroundMode) {
323
+ return cached.lines;
324
+ }
325
+ }
326
+
327
+ const rendered = originalRender.call(this, width);
328
+ if (!Array.isArray(rendered) || rendered.length === 0) return rendered;
329
+ if (!isToolExecutionLike(this)) return rendered;
330
+ if (toolBackgroundMode === "default") {
331
+ (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: rendered };
332
+ return rendered;
333
+ }
334
+
335
+ let start = 0;
336
+ while (start < rendered.length && isBlankLine(rendered[start])) start++;
337
+ let end = rendered.length - 1;
338
+ while (end >= start && isBlankLine(rendered[end])) end--;
339
+ if (start > end) return rendered;
340
+
341
+ const { textLines, imageLines } = splitRenderedImageBlock(rendered.slice(start, end + 1));
342
+ if (imageLines.length > 0) {
343
+ (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: rendered };
344
+ return rendered;
345
+ }
346
+ const core = textLines.map((line) => clampLineWidth(normalizeLeadingCheckGlyph(line), width));
347
+ const spacerLine = " ".repeat(width);
348
+ let result: string[];
349
+
350
+ if (toolBackgroundMode === "outlines") {
351
+ const ruleWidth = Math.max(1, width);
352
+ const framed = core.length > 0 ? [borderLine(ruleWidth), ...core, borderLine(ruleWidth)] : [];
353
+ result = [spacerLine, ...framed, ...imageLines];
354
+ } else {
355
+ result = [spacerLine, ...core, ...imageLines];
356
+ }
357
+
358
+ (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: result };
359
+ return result;
360
+ };
361
+
362
+ proto[PATCH_FLAG] = true;
363
+ }
364
+
365
+ function summarizeText(text: string, max = 60): string {
366
+ const oneLine = text.replace(/\n/g, " ").trim();
367
+ if (oneLine.length <= max) return oneLine;
368
+ return `${oneLine.slice(0, Math.max(0, max - 3))}...`;
369
+ }
370
+
371
+ function hashText(text: string): string {
372
+ let hash = 2166136261;
373
+ for (let i = 0; i < text.length; i++) {
374
+ hash ^= text.charCodeAt(i);
375
+ hash = Math.imul(hash, 16777619);
376
+ }
377
+ return (hash >>> 0).toString(36);
378
+ }
379
+
380
+ function expandHint(theme: Theme): string {
381
+ return theme.fg("muted", " • Ctrl+O to expand");
382
+ }
383
+
384
+ function clearStateKeys(state: Record<string, unknown> | undefined, ...keys: string[]): void {
385
+ if (!state) return;
386
+ for (const key of keys) {
387
+ delete state[key];
388
+ }
389
+ }
390
+
391
+ function clearToolRenderCache(value: unknown): void {
392
+ if (!value || typeof value !== "object") return;
393
+ delete (value as any)[TOOL_RENDER_CACHE];
394
+ }
395
+
396
+ function unrefTimer(timer: ReturnType<typeof setTimeout> | null | undefined): void {
397
+ (timer as any)?.unref?.();
398
+ }
399
+
400
+ const ASSISTANT_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-assistant-message");
401
+ const TOOL_EXECUTION_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-execution");
402
+ const OSC133_ZONE_START = "\x1b]133;A\x07";
403
+ const OSC133_ZONE_END = "\x1b]133;B\x07";
404
+ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
405
+ const WORKED_DURATION_KEY = "_piClaudeStyleWorkedDurationMs";
406
+ const WORKED_START_KEY = "_piClaudeStyleWorkedStartMs";
407
+ const WORKED_DURATION_MARKER = "Worked for";
408
+ // WORKED_LINE_FG is theme-derived (from "muted") when themeAdaptive is on.
409
+ let WORKED_LINE_FG = "\x1b[38;2;140;140;140m";
410
+ let currentAgentWorkStartMs: number | undefined;
411
+ let currentAssistantMessageStartMs: number | undefined;
412
+
413
+ function formatWorkedDuration(ms: number): string {
414
+ const safeMs = Math.max(0, Number.isFinite(ms) ? ms : 0);
415
+ if (safeMs < 60_000) {
416
+ return `${Math.max(0, Math.floor(safeMs / 1000))}s`;
417
+ }
418
+ let days = Math.floor(safeMs / 86_400_000);
419
+ let hours = Math.floor((safeMs % 86_400_000) / 3_600_000);
420
+ let minutes = Math.floor((safeMs % 3_600_000) / 60_000);
421
+ let seconds = Math.round((safeMs % 60_000) / 1000);
422
+ if (seconds === 60) {
423
+ seconds = 0;
424
+ minutes++;
425
+ }
426
+ if (minutes === 60) {
427
+ minutes = 0;
428
+ hours++;
429
+ }
430
+ if (hours === 24) {
431
+ hours = 0;
432
+ days++;
433
+ }
434
+ if (days > 0) return `${days}d ${hours}h ${minutes}m`;
435
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
436
+ return `${minutes}m ${seconds}s`;
437
+ }
438
+
439
+ function workedDurationText(ms: number): string {
440
+ return `${WORKED_LINE_FG}✻ Worked for ${formatWorkedDuration(ms)}${RESET}`;
441
+ }
442
+
443
+ function inlineWorkedDurationText(ms: number): string {
444
+ return `${WORKED_LINE_FG}✻ Worked for ${formatWorkedDuration(ms)}${RESET}`;
445
+ }
446
+
447
+ function isWorkedDurationLine(line: string): boolean {
448
+ return line.includes(WORKED_DURATION_MARKER) && /^✻ Worked for [^\r\n]+$/.test(stripAnsi(line).trim());
449
+ }
450
+
451
+ function stripWorkedDurationLine(text: string): string {
452
+ if (!text.includes(WORKED_DURATION_MARKER)) return text;
453
+ return text
454
+ .split(/\r?\n/)
455
+ .filter((line) => !isWorkedDurationLine(line))
456
+ .join("\n")
457
+ .replace(/\n{3,}/g, "\n\n");
458
+ }
459
+
460
+ function hasWorkedDurationLine(message: any): boolean {
461
+ if (!Array.isArray(message?.content)) return false;
462
+ return message.content.some((block: any) => {
463
+ if (block?.type !== "text" || typeof block.text !== "string" || !block.text.includes(WORKED_DURATION_MARKER)) return false;
464
+ return block.text.split(/\r?\n/).some(isWorkedDurationLine);
465
+ });
466
+ }
467
+
468
+ function appendWorkedDurationLine(message: any, durationMs: number): void {
469
+ if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
470
+ const textBlocks = message.content.filter((block: any) => block?.type === "text" && typeof block.text === "string" && block.text.trim());
471
+ const lastText = textBlocks[textBlocks.length - 1];
472
+ if (!lastText) return;
473
+ const text = lastText.text.includes(WORKED_DURATION_MARKER) ? stripWorkedDurationLine(lastText.text) : lastText.text;
474
+ lastText.text = `${text.trimEnd()}\n\n${inlineWorkedDurationText(durationMs)}`;
475
+ }
476
+
477
+ class DottedParagraph {
478
+ private md: InstanceType<typeof Markdown>;
479
+ private cachedWidth?: number;
480
+ private cachedLines?: string[];
481
+
482
+ constructor(text: string, markdownTheme: ConstructorParameters<typeof Markdown>[3]) {
483
+ this.md = new Markdown(text, 0, 0, markdownTheme);
484
+ }
485
+
486
+ invalidate(): void {
487
+ this.cachedWidth = undefined;
488
+ this.cachedLines = undefined;
489
+ this.md.invalidate();
490
+ }
491
+
492
+ render(width: number): string[] {
493
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
494
+ // " ● " = 1 margin + dot + space = 3 visible chars
495
+ const PREFIX_W = 3;
496
+ if (width <= PREFIX_W) {
497
+ this.cachedWidth = width;
498
+ this.cachedLines = [" ● "];
499
+ return this.cachedLines;
500
+ }
501
+ const lines = sanitizeRenderedTextBlockLines(this.md.render(width - PREFIX_W));
502
+ const looksLikeTaskStatus = lines.some((line) => /\b(?:transcript:|No output\.|Wrapped up)/.test(stripAnsi(line)));
503
+ const displayLines = looksLikeTaskStatus ? lines.map(normalizeLeadingCheckGlyph) : lines;
504
+ let dotPlaced = false;
505
+ const rendered = displayLines.map((line: string) => {
506
+ if (!dotPlaced && stripAnsi(line).trim()) {
507
+ dotPlaced = true;
508
+ return ` ● ${line}`;
509
+ }
510
+ return ` ${line}`;
511
+ });
512
+ this.cachedWidth = width;
513
+ this.cachedLines = rendered;
514
+ return rendered;
515
+ }
516
+ }
517
+
518
+ class ThinkingParagraph {
519
+ private md: InstanceType<typeof Markdown>;
520
+ private cachedWidth?: number;
521
+ private cachedLines?: string[];
522
+
523
+ constructor(
524
+ text: string,
525
+ _markdownTheme: ConstructorParameters<typeof Markdown>[3],
526
+ _defaultTextStyle?: ConstructorParameters<typeof Markdown>[4],
527
+ ) {
528
+ // Use a plain theme that strips all color/formatting from thinking blocks.
529
+ // Every element gets the same dim italic treatment, tracking the active
530
+ // pi theme's "muted" color (falls back to the previous gray when no theme).
531
+ const DIM_FG = WORKED_LINE_FG;
532
+ const ITALIC = "\x1b[3m";
533
+ const wrap = (s: string) => `${DIM_FG}${ITALIC}${s}`;
534
+ const plainTheme: ConstructorParameters<typeof Markdown>[3] = {
535
+ heading: wrap,
536
+ link: wrap,
537
+ linkUrl: wrap,
538
+ code: wrap,
539
+ codeBlock: wrap,
540
+ codeBlockBorder: wrap,
541
+ quote: wrap,
542
+ quoteBorder: wrap,
543
+ hr: wrap,
544
+ listBullet: wrap,
545
+ bold: wrap,
546
+ italic: wrap,
547
+ strikethrough: wrap,
548
+ underline: wrap,
549
+ // Override code highlighting to return plain lines (no syntax colors)
550
+ highlightCode: (code: string, _lang?: string) => code.split("\n").map(line => `${DIM_FG}${ITALIC}${line}`),
551
+ };
552
+ // Same dim gray italic as the base style for all inline text
553
+ const plainStyle: ConstructorParameters<typeof Markdown>[4] = {
554
+ italic: true,
555
+ color: (s: string) => `${DIM_FG}${ITALIC}${s}`,
556
+ };
557
+ this.md = new Markdown(text, 0, 0, plainTheme, plainStyle);
558
+ }
559
+
560
+ invalidate(): void {
561
+ this.cachedWidth = undefined;
562
+ this.cachedLines = undefined;
563
+ this.md.invalidate();
564
+ }
565
+
566
+ render(width: number): string[] {
567
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
568
+ // " ✻ " = 1 margin + symbol + space = 3 visible chars
569
+ const PREFIX_W = 3;
570
+ const prefix = `${WORKED_LINE_FG}✻${RESET}`;
571
+ if (width <= PREFIX_W) {
572
+ this.cachedWidth = width;
573
+ this.cachedLines = [` ${prefix} `];
574
+ return this.cachedLines;
575
+ }
576
+ const lines = sanitizeRenderedTextBlockLines(this.md.render(width - PREFIX_W));
577
+ let symbolPlaced = false;
578
+ const rendered = lines.map((line: string) => {
579
+ if (!symbolPlaced && stripAnsi(line).trim()) {
580
+ symbolPlaced = true;
581
+ return ` ${prefix} ${line}`;
582
+ }
583
+ return ` ${line}`;
584
+ });
585
+ this.cachedWidth = width;
586
+ this.cachedLines = rendered;
587
+ return rendered;
588
+ }
589
+ }
590
+
591
+ function patchCustomMessageRender(): void {
592
+ const proto = CustomMessageComponent.prototype as any;
593
+ if (proto[CUSTOM_MESSAGE_PATCH_FLAG]) return;
594
+ const originalRender = proto.render;
595
+ if (typeof originalRender !== "function") return;
596
+ proto.render = function patchedCustomMessageRender(width: number) {
597
+ const lines = originalRender.call(this, width);
598
+ return Array.isArray(lines) ? lines.map(normalizeLeadingCheckGlyph) : lines;
599
+ };
600
+ proto[CUSTOM_MESSAGE_PATCH_FLAG] = true;
601
+ }
602
+
603
+ function stripOsc133Zones(line: string): string {
604
+ return line
605
+ .replace(OSC133_ZONE_START, "")
606
+ .replace(OSC133_ZONE_END, "")
607
+ .replace(OSC133_ZONE_FINAL, "");
608
+ }
609
+
610
+ function stripBackgroundAnsi(text: string): string {
611
+ return text.replace(/\x1b\[([0-9;]*)m/g, (match, paramsText: string) => {
612
+ const params = paramsText === "" ? ["0"] : paramsText.split(";");
613
+ const kept: string[] = [];
614
+ for (let i = 0; i < params.length; i++) {
615
+ const code = Number(params[i] || "0");
616
+ if (code === 48) {
617
+ const mode = Number(params[i + 1] || "0");
618
+ i += mode === 2 ? 4 : mode === 5 ? 2 : 0;
619
+ continue;
620
+ }
621
+ if (code === 49 || (code >= 40 && code <= 47) || (code >= 100 && code <= 107)) continue;
622
+ kept.push(params[i]);
623
+ }
624
+ return kept.length === 0 ? "" : `\x1b[${kept.join(";")}m`;
625
+ });
626
+ }
627
+
628
+ function roundedUserBorder(width: number, top: boolean): string {
629
+ if (width <= 1) return `${BORDER_COLOR}│${TRANSPARENT_RESET}`;
630
+ const left = top ? "╭" : "╰";
631
+ const right = top ? "╮" : "╯";
632
+ if (!top || width < 10) {
633
+ return `${BORDER_COLOR}${left}${"─".repeat(Math.max(0, width - 2))}${right}${TRANSPARENT_RESET}`;
634
+ }
635
+ const label = " User ";
636
+ const prefix = "─";
637
+ const suffixWidth = Math.max(0, width - 2 - visibleWidth(prefix) - visibleWidth(label));
638
+ return `${BORDER_COLOR}${left}${prefix}${TRANSPARENT_RESET}${label}${BORDER_COLOR}${"─".repeat(suffixWidth)}${right}${TRANSPARENT_RESET}`;
639
+ }
640
+
641
+ function trimAnsiRight(text: string): string {
642
+ let trimmed = text;
643
+ while (true) {
644
+ const next = trimmed.replace(/[ \t]+((?:\x1b\[[0-9;]*m)*)$/g, "$1");
645
+ if (next === trimmed) return trimmed;
646
+ trimmed = next;
647
+ }
648
+ }
649
+
650
+ function cleanUserMessageLine(line: string): string {
651
+ return `${TRANSPARENT_BG}${trimAnsiRight(stripBackgroundAnsi(stripOsc133Zones(line)))}${TRANSPARENT_BG}`;
652
+ }
653
+
654
+ function borderedUserMessageLine(line: string, width: number): string {
655
+ const innerWidth = Math.max(1, width - 4);
656
+ const content = clampLineWidth(cleanUserMessageLine(line), innerWidth);
657
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
658
+ return `${BORDER_COLOR}│${TRANSPARENT_RESET} ${content}${padding} ${BORDER_COLOR}│${TRANSPARENT_RESET}`;
659
+ }
660
+
661
+ function patchUserMessageRender(): void {
662
+ const proto = UserMessageComponent.prototype as any;
663
+ if (proto[USER_MESSAGE_PATCH_FLAG]) return;
664
+ const originalRender = proto.render;
665
+ if (typeof originalRender !== "function") return;
666
+ proto.render = function patchedUserMessageRender(width: number) {
667
+ for (const child of (this as any).children ?? []) {
668
+ const markdown = child as any;
669
+ if (child instanceof Markdown && markdown.defaultTextStyle?.bgColor) {
670
+ markdown.defaultTextStyle.bgColor = undefined;
671
+ markdown.invalidate?.();
672
+ }
673
+ }
674
+ const borderWidth = Math.max(1, width);
675
+ const contentWidth = Math.max(1, borderWidth - 4);
676
+ const lines = originalRender.call(this, contentWidth);
677
+ if (!Array.isArray(lines) || lines.length === 0) return lines;
678
+ const rendered = [
679
+ roundedUserBorder(borderWidth, true),
680
+ ...lines.map((line: string) => borderedUserMessageLine(line, borderWidth)),
681
+ roundedUserBorder(borderWidth, false),
682
+ ];
683
+ rendered[0] = OSC133_ZONE_START + rendered[0];
684
+ rendered[rendered.length - 1] += OSC133_ZONE_END + OSC133_ZONE_FINAL;
685
+ return rendered;
686
+ };
687
+ proto[USER_MESSAGE_PATCH_FLAG] = true;
688
+ }
689
+
690
+ function patchAssistantMessages(): void {
691
+ const proto = AssistantMessageComponent.prototype as any;
692
+ if (proto[ASSISTANT_PATCH_FLAG]) return;
693
+ const originalUpdateContent = proto.updateContent;
694
+ proto.updateContent = function patchedUpdateContent(message: any) {
695
+ if (!(this as any)[WORKED_START_KEY]) {
696
+ (this as any)[WORKED_START_KEY] = Date.now();
697
+ }
698
+ if (!message || !Array.isArray(message.content)) {
699
+ return originalUpdateContent.call(this, message);
700
+ }
701
+ // Call original to build all children (text, thinking, spacers, errors)
702
+ originalUpdateContent.call(this, message);
703
+ // Replace text-block Markdown children with DottedParagraph wrappers
704
+ const container = (this as any).contentContainer;
705
+ if (!container?.children) return;
706
+ const mdTheme = (this as any).markdownTheme;
707
+ for (let i = container.children.length - 1; i >= 0; i--) {
708
+ const child = container.children[i];
709
+ if (child instanceof Markdown) {
710
+ const text = (child as any).text;
711
+ if (!text) continue;
712
+ const isThinking = !!(child as any).defaultTextStyle?.italic;
713
+ if (isThinking) {
714
+ const style = (child as any).defaultTextStyle;
715
+ container.children[i] = new ThinkingParagraph(text, mdTheme, style);
716
+ } else {
717
+ container.children[i] = new DottedParagraph(text, mdTheme);
718
+ }
719
+ }
720
+ }
721
+ const explicitDuration = (message as any)[WORKED_DURATION_KEY];
722
+ const componentStart = (this as any)[WORKED_START_KEY];
723
+ const isFinished = typeof message.stopReason === "string" && message.stopReason.length > 0;
724
+ const isFinalAssistantMessage = isFinished && message.stopReason !== "toolUse";
725
+ const fallbackStart = typeof currentAgentWorkStartMs === "number" ? currentAgentWorkStartMs : componentStart;
726
+ const workedDuration = typeof explicitDuration === "number"
727
+ ? explicitDuration
728
+ : isFinalAssistantMessage && typeof fallbackStart === "number"
729
+ ? Date.now() - fallbackStart
730
+ : undefined;
731
+ const hasAssistantText = message.content.some((block: any) => block?.type === "text" && typeof block.text === "string" && block.text.trim());
732
+ if (typeof workedDuration === "number" && isFinalAssistantMessage && hasAssistantText && !hasWorkedDurationLine(message)) {
733
+ container.children.push(new Spacer(1), new Text(workedDurationText(workedDuration), 1, 0));
734
+ }
735
+ };
736
+ proto[ASSISTANT_PATCH_FLAG] = true;
737
+ }
738
+
739
+ function patchToolRenderCacheInvalidation(): void {
740
+ const proto = ToolExecutionComponent.prototype as any;
741
+ if (proto[TOOL_CACHE_PATCH_FLAG]) return;
742
+
743
+ const methods = [
744
+ "updateDisplay",
745
+ "updateArgs",
746
+ "markExecutionStarted",
747
+ "setArgsComplete",
748
+ "updateResult",
749
+ "setExpanded",
750
+ "setShowImages",
751
+ "setImageWidthCells",
752
+ "invalidate",
753
+ ];
754
+
755
+ for (const method of methods) {
756
+ const original = proto[method];
757
+ if (typeof original !== "function") continue;
758
+ proto[method] = function patchedToolMutation(...args: any[]) {
759
+ clearToolRenderCache(this);
760
+ const result = original.apply(this, args);
761
+ clearToolRenderCache(this);
762
+ return result;
763
+ };
764
+ }
765
+
766
+ proto[TOOL_CACHE_PATCH_FLAG] = true;
767
+ }
768
+
769
+ function deleteRenderedKittyImages(component: any): void {
770
+ if (!process.stdout.isTTY || getCapabilities().images !== "kitty" || !Array.isArray(component.imageComponents) || component.imageComponents.length === 0) return;
771
+ try { process.stdout.write(deleteAllKittyImages()); } catch { /* noop */ }
772
+ }
773
+
774
+ function removeImageChildren(component: any): void {
775
+ deleteRenderedKittyImages(component);
776
+ const children = [
777
+ ...(Array.isArray(component.imageComponents) ? component.imageComponents : []),
778
+ ...(Array.isArray(component.imageSpacers) ? component.imageSpacers : []),
779
+ ];
780
+ for (const child of children) {
781
+ try { component.removeChild?.(child); } catch { /* noop */ }
782
+ }
783
+ component.imageComponents = [];
784
+ component.imageSpacers = [];
785
+ }
786
+
787
+ function patchReadImageExpansion(): void {
788
+ const proto = ToolExecutionComponent.prototype as any;
789
+ if (proto[TOOL_IMAGE_EXPAND_PATCH_FLAG]) return;
790
+ const originalUpdateDisplay = proto.updateDisplay;
791
+ if (typeof originalUpdateDisplay !== "function") return;
792
+ proto.updateDisplay = function patchedReadImageUpdateDisplay(...args: any[]) {
793
+ const result = originalUpdateDisplay.apply(this, args);
794
+ const hasImage = Array.isArray(this.result?.content) && this.result.content.some((block: any) => block?.type === "image");
795
+ if (this.toolName === "read" && hasImage && this.expanded !== true) {
796
+ removeImageChildren(this);
797
+ clearToolRenderCache(this);
798
+ }
799
+ return result;
800
+ };
801
+ proto[TOOL_IMAGE_EXPAND_PATCH_FLAG] = true;
802
+ }
803
+
804
+ function patchToolExecutionRenderers(): void {
805
+ const proto = ToolExecutionComponent.prototype as any;
806
+ if (proto[TOOL_EXECUTION_PATCH_FLAG]) return;
807
+
808
+ const originalHasRendererDefinition = proto.hasRendererDefinition;
809
+ const originalGetCallRenderer = proto.getCallRenderer;
810
+ const originalGetResultRenderer = proto.getResultRenderer;
811
+
812
+ if (typeof originalHasRendererDefinition === "function") {
813
+ proto.hasRendererDefinition = function patchedHasRendererDefinition() {
814
+ return originalHasRendererDefinition.call(this) || shouldUseGenericToolRenderer(this?.toolName);
815
+ };
816
+ }
817
+
818
+ proto.getCallRenderer = function patchedGetCallRenderer() {
819
+ const toolName = typeof this?.toolName === "string" ? this.toolName : "";
820
+ if (toolName === "apply_patch") {
821
+ return (args: any, theme: Theme, ctx: any) =>
822
+ renderApplyPatchCall(args, theme, ctx, (path: string) => shortPath(ctx.cwd ?? process.cwd(), path));
823
+ }
824
+ if (shouldUseGenericToolRenderer(toolName)) {
825
+ return (args: any, theme: Theme, ctx: any) => renderGenericToolCall(toolName, args, theme, ctx);
826
+ }
827
+ return typeof originalGetCallRenderer === "function" ? originalGetCallRenderer.call(this) : undefined;
828
+ };
829
+
830
+ proto.getResultRenderer = function patchedGetResultRenderer() {
831
+ const toolName = typeof this?.toolName === "string" ? this.toolName : "";
832
+ if (toolName === "apply_patch") {
833
+ return (result: any, options: any, theme: Theme, ctx: any) =>
834
+ renderApplyPatchResult({ content: result.content, details: result.details }, options.isPartial, theme, ctx);
835
+ }
836
+ if (shouldUseGenericToolRenderer(toolName)) {
837
+ return (result: any, options: any, theme: Theme, ctx: any) =>
838
+ renderGenericToolResult(toolName, result, options, theme, ctx);
839
+ }
840
+ return typeof originalGetResultRenderer === "function" ? originalGetResultRenderer.call(this) : undefined;
841
+ };
842
+
843
+ proto[TOOL_EXECUTION_PATCH_FLAG] = true;
844
+ }
845
+
846
+ function shortPath(cwd: string, filePath: string): string {
847
+ if (!filePath) return "";
848
+ const rel = relative(cwd, filePath);
849
+ if (!rel.startsWith("..") && !rel.startsWith("/")) return rel || ".";
850
+ const home = process.env.HOME ?? "";
851
+ return home ? filePath.replace(home, "~") : filePath;
852
+ }
853
+
854
+ // ---------------------------------------------------------------------------
855
+ // Status dot — flickers green/gray while pending
856
+ // ---------------------------------------------------------------------------
857
+
858
+ function isBlinkOn(): boolean {
859
+ return Math.floor(Date.now() / 500) % 2 === 0;
860
+ }
861
+
862
+ function toolHeader(tool: string, summary: string, theme: Theme, prefix = ""): string {
863
+ applyThemePaletteIfNeeded(theme);
864
+ const label = theme.fg("toolTitle", theme.bold(tool));
865
+ if (!summary) return `${prefix}${label}`;
866
+ return `${prefix}${label} ${WRAP_MARK}${theme.fg("accent", summary)}`;
867
+ }
868
+
869
+ function setToolStatus(ctx: any, status: "pending" | "success" | "error"): void {
870
+ ctx.state._toolStatus = status;
871
+ }
872
+
873
+ function syncToolCallStatus(ctx: any): void {
874
+ if (!ctx?.executionStarted || ctx?.isPartial) {
875
+ setToolStatus(ctx, "pending");
876
+ return;
877
+ }
878
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
879
+ }
880
+
881
+ function shouldRevealCallArgs(ctx: any): boolean {
882
+ if (ctx?.argsComplete === true || ctx?.executionStarted === true) return true;
883
+ const args = ctx?.args;
884
+ if (!args || typeof args !== "object") return false;
885
+ return Object.keys(args).some((key) => args[key] !== undefined && args[key] !== null && args[key] !== "");
886
+ }
887
+
888
+ function stableCallSummary(ctx: any, key: string, build: () => string, reveal = shouldRevealCallArgs(ctx)): string {
889
+ const state = ctx?.state;
890
+ const cached = state?.[key];
891
+ const completeKey = `${key}Complete`;
892
+ if (!reveal) return typeof cached === "string" ? cached : "";
893
+ if (ctx?.argsComplete === true && state?.[completeKey] === true && typeof cached === "string") return cached;
894
+ if (!shouldRevealCallArgs(ctx) && typeof cached === "string" && cached) return cached;
895
+ const summary = build();
896
+ if (state) {
897
+ state[key] = summary;
898
+ if (ctx?.argsComplete === true) state[completeKey] = true;
899
+ else delete state[completeKey];
900
+ }
901
+ return summary;
902
+ }
903
+
904
+ function hasOwnArg(args: any, key: string): boolean {
905
+ return !!args && Object.prototype.hasOwnProperty.call(args, key);
906
+ }
907
+
908
+ function fileExistsForTool(cwd: string, filePath: string): boolean {
909
+ if (!filePath) return false;
910
+ try {
911
+ return existsSync(resolve(cwd, filePath));
912
+ } catch {
913
+ return false;
914
+ }
915
+ }
916
+
917
+ const WRITE_EXISTED_BEFORE = new Map<string, boolean>();
918
+
919
+ function getWriteWasNewFile(ctx: any, cwd: string, filePath: string, reveal = shouldRevealCallArgs(ctx)): boolean | undefined {
920
+ if (typeof ctx?.state?._writeWasNewFile === "boolean") return ctx.state._writeWasNewFile;
921
+ if (!filePath || !reveal) return undefined;
922
+ const existedBefore = typeof ctx?.toolCallId === "string" ? WRITE_EXISTED_BEFORE.get(ctx.toolCallId) : undefined;
923
+ const wasNew = existedBefore === undefined ? !fileExistsForTool(cwd, filePath) : !existedBefore;
924
+ if (ctx?.state) ctx.state._writeWasNewFile = wasNew;
925
+ return wasNew;
926
+ }
927
+
928
+ function toolStatusDot(ctx: any, theme: Theme): string {
929
+ const status = ctx.state?._toolStatus as "pending" | "success" | "error" | undefined;
930
+ if (status === "success") return `${theme.fg("success", "●")} `;
931
+ if (status === "error") return `${theme.fg("error", "●")} `;
932
+ return `${blinkDot(ctx, theme)} `;
933
+ }
934
+
935
+ // ---------------------------------------------------------------------------
936
+ // Branch connector — visual tree from header to output
937
+ // ---------------------------------------------------------------------------
938
+
939
+ function branchIndent(text: string, continued = false): string {
940
+ const prefix = continued ? `${TOOL_RULE}│${TRANSPARENT_RESET} ` : " ";
941
+ return `${prefix}${WRAP_MARK}${text}`;
942
+ }
943
+
944
+ function branchLead(text: string, continued = false): string {
945
+ return `${TOOL_RULE}${continued ? "├─" : "└─"}${TRANSPARENT_RESET} ${WRAP_MARK}${text}`;
946
+ }
947
+
948
+ function withBranch(content: string, _theme: Theme, _isError = false, continued = false): string {
949
+ if (!content || !content.trim()) return "";
950
+ const lines = content.split("\n");
951
+ const first = lines[0] ?? "";
952
+ if (lines.length === 1) return branchLead(first, continued);
953
+ const rest = lines.slice(1).map((line) => branchIndent(line, continued));
954
+ return `${branchLead(first, continued)}\n${rest.join("\n")}`;
955
+ }
956
+
957
+ function withFinalBranchBlock(content: string, theme: Theme, isError = false): string {
958
+ if (!content || !content.trim()) return "";
959
+ const lines = content.split("\n");
960
+ const first = lines[0] ?? "";
961
+ if (lines.length === 1) return branchLead(first, false);
962
+ const middle = lines.slice(1, -1).map((line) => branchIndent(line, true));
963
+ const last = lines[lines.length - 1] ?? "";
964
+ return [branchLead(first, true), ...middle, branchLead(last, false)].join("\n");
965
+ }
966
+
967
+ function indentBranchBlock(block: string): string {
968
+ return block
969
+ .split("\n")
970
+ .map((line) => (line ? ` ${line}` : line))
971
+ .join("\n");
972
+ }
973
+
974
+ // ---------------------------------------------------------------------------
975
+ // Blink timer for partial (running) states
976
+ // ---------------------------------------------------------------------------
977
+
978
+ // ---------------------------------------------------------------------------
979
+ // Global blink timer — single timer invalidates all active contexts
980
+ // ---------------------------------------------------------------------------
981
+
982
+ const MAX_BLINKING_TOOLS = 5;
983
+ const BLINK_INTERVAL_MS = 500;
984
+
985
+ type BlinkEntry = { key: any; order: number; invalidate: () => void };
986
+
987
+ const _blinkContexts = new Map<any, BlinkEntry>();
988
+ let _globalBlinkTimer: ReturnType<typeof setTimeout> | null = null;
989
+ let _blinkOrder = 0;
990
+ let _globalBlinkPhase = true;
991
+
992
+ function getBlinkIntervalMs(): number {
993
+ return BLINK_INTERVAL_MS;
994
+ }
995
+
996
+ function getBlinkKey(ctx: any): any {
997
+ return ctx?.state ?? ctx;
998
+ }
999
+
1000
+ function getBlinkingEntries(): BlinkEntry[] {
1001
+ return [..._blinkContexts.values()]
1002
+ .sort((a, b) => b.order - a.order)
1003
+ .slice(0, MAX_BLINKING_TOOLS);
1004
+ }
1005
+
1006
+ function updateBlinkActiveStates(): void {
1007
+ const activeSet = new Set(getBlinkingEntries().map((entry) => entry.key));
1008
+ for (const entry of _blinkContexts.values()) {
1009
+ const active = activeSet.has(entry.key);
1010
+ if (entry.key?._blinkActive !== active) {
1011
+ entry.key._blinkActive = active;
1012
+ try { entry.invalidate(); } catch { /* noop */ }
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ function _scheduleGlobalBlinkTimer(): void {
1018
+ if (_globalBlinkTimer) return;
1019
+ const intervalMs = getBlinkIntervalMs();
1020
+ if (_blinkContexts.size === 0) return;
1021
+ _globalBlinkTimer = setTimeout(() => {
1022
+ _globalBlinkTimer = null;
1023
+ if (_blinkContexts.size === 0) {
1024
+ updateBlinkActiveStates();
1025
+ return;
1026
+ }
1027
+ _globalBlinkPhase = !_globalBlinkPhase;
1028
+ for (const entry of getBlinkingEntries()) {
1029
+ try { entry.invalidate(); } catch { /* noop */ }
1030
+ }
1031
+ _scheduleGlobalBlinkTimer();
1032
+ }, intervalMs);
1033
+ unrefTimer(_globalBlinkTimer);
1034
+ }
1035
+
1036
+ function _stopGlobalBlinkTimerIfEmpty(): void {
1037
+ if (_globalBlinkTimer && _blinkContexts.size === 0) {
1038
+ clearTimeout(_globalBlinkTimer);
1039
+ _globalBlinkTimer = null;
1040
+ }
1041
+ }
1042
+
1043
+ function setupBlinkTimer(ctx: any): void {
1044
+ const key = getBlinkKey(ctx);
1045
+ if (!key) return;
1046
+ const invalidate = typeof ctx?.invalidate === "function" ? () => ctx.invalidate() : () => {};
1047
+ const existing = _blinkContexts.get(key);
1048
+ if (existing) {
1049
+ // Already tracked — just refresh the invalidate fn, skip expensive recalc
1050
+ existing.invalidate = invalidate;
1051
+ return;
1052
+ }
1053
+ _blinkContexts.set(key, { key, order: ++_blinkOrder, invalidate });
1054
+ key._blinkActive = false;
1055
+ updateBlinkActiveStates();
1056
+ _stopGlobalBlinkTimerIfEmpty();
1057
+ _scheduleGlobalBlinkTimer();
1058
+ }
1059
+
1060
+ function clearBlinkTimer(ctx: any): void {
1061
+ const key = getBlinkKey(ctx);
1062
+ if (!key) return;
1063
+ _blinkContexts.delete(key);
1064
+ key._blinkActive = false;
1065
+ updateBlinkActiveStates();
1066
+ _stopGlobalBlinkTimerIfEmpty();
1067
+ _scheduleGlobalBlinkTimer();
1068
+ }
1069
+
1070
+ function blinkDot(ctx: any, theme: Theme): string {
1071
+ setupBlinkTimer(ctx);
1072
+ const key = getBlinkKey(ctx);
1073
+ if (key?._blinkActive !== true) return theme.fg("muted", "○");
1074
+ return _globalBlinkPhase ? theme.fg("success", "●") : theme.fg("muted", "○");
1075
+ }
1076
+
1077
+ // ---------------------------------------------------------------------------
1078
+ // File icons — Nerd Font glyphs (requires Nerd Font terminal)
1079
+ // ---------------------------------------------------------------------------
1080
+
1081
+ const NF_DIR = `\x1b[38;2;100;140;220m\ue5ff\x1b[0m`;
1082
+ const NF_DEFAULT = `\x1b[38;2;80;80;80m\uf15b\x1b[0m`;
1083
+
1084
+ const EXT_ICON: Record<string, string> = {
1085
+ ts: `\x1b[38;2;49;120;198m\ue628\x1b[0m`,
1086
+ tsx: `\x1b[38;2;49;120;198m\ue7ba\x1b[0m`,
1087
+ js: `\x1b[38;2;241;224;90m\ue74e\x1b[0m`,
1088
+ jsx: `\x1b[38;2;97;218;251m\ue7ba\x1b[0m`,
1089
+ py: `\x1b[38;2;55;118;171m\ue73c\x1b[0m`,
1090
+ rs: `\x1b[38;2;222;165;132m\ue7a8\x1b[0m`,
1091
+ go: `\x1b[38;2;0;173;216m\ue724\x1b[0m`,
1092
+ java: `\x1b[38;2;204;62;68m\ue738\x1b[0m`,
1093
+ rb: `\x1b[38;2;204;52;45m\ue739\x1b[0m`,
1094
+ swift: `\x1b[38;2;255;172;77m\ue755\x1b[0m`,
1095
+ c: `\x1b[38;2;85;154;211m\ue61e\x1b[0m`,
1096
+ cpp: `\x1b[38;2;85;154;211m\ue61d\x1b[0m`,
1097
+ html: `\x1b[38;2;228;77;38m\ue736\x1b[0m`,
1098
+ css: `\x1b[38;2;66;165;245m\ue749\x1b[0m`,
1099
+ scss: `\x1b[38;2;207;100;154m\ue749\x1b[0m`,
1100
+ vue: `\x1b[38;2;65;184;131m\ue6a0\x1b[0m`,
1101
+ svelte: `\x1b[38;2;255;62;0m\ue697\x1b[0m`,
1102
+ json: `\x1b[38;2;241;224;90m\ue60b\x1b[0m`,
1103
+ yaml: `\x1b[38;2;160;116;196m\ue6a8\x1b[0m`,
1104
+ yml: `\x1b[38;2;160;116;196m\ue6a8\x1b[0m`,
1105
+ toml: `\x1b[38;2;160;116;196m\ue6b2\x1b[0m`,
1106
+ md: `\x1b[38;2;66;165;245m\ue73e\x1b[0m`,
1107
+ sh: `\x1b[38;2;137;180;130m\ue795\x1b[0m`,
1108
+ bash: `\x1b[38;2;137;180;130m\ue795\x1b[0m`,
1109
+ zsh: `\x1b[38;2;137;180;130m\ue795\x1b[0m`,
1110
+ lua: `\x1b[38;2;81;160;207m\ue620\x1b[0m`,
1111
+ php: `\x1b[38;2;137;147;186m\ue73d\x1b[0m`,
1112
+ sql: `\x1b[38;2;218;218;218m\ue706\x1b[0m`,
1113
+ xml: `\x1b[38;2;228;77;38m\ue619\x1b[0m`,
1114
+ graphql: `\x1b[38;2;224;51;144m\ue662\x1b[0m`,
1115
+ dockerfile: `\x1b[38;2;56;152;236m\ue7b0\x1b[0m`,
1116
+ lock: `\x1b[38;2;130;130;130m\uf023\x1b[0m`,
1117
+ png: `\x1b[38;2;160;116;196m\uf1c5\x1b[0m`,
1118
+ jpg: `\x1b[38;2;160;116;196m\uf1c5\x1b[0m`,
1119
+ svg: `\x1b[38;2;255;180;50m\uf1c5\x1b[0m`,
1120
+ gif: `\x1b[38;2;160;116;196m\uf1c5\x1b[0m`,
1121
+ };
1122
+
1123
+ const NAME_ICON: Record<string, string> = {
1124
+ "package.json": `\x1b[38;2;137;180;130m\ue71e\x1b[0m`,
1125
+ "tsconfig.json": `\x1b[38;2;49;120;198m\ue628\x1b[0m`,
1126
+ ".gitignore": `\x1b[38;2;222;165;132m\ue702\x1b[0m`,
1127
+ "dockerfile": `\x1b[38;2;56;152;236m\ue7b0\x1b[0m`,
1128
+ "makefile": `\x1b[38;2;130;130;130m\ue615\x1b[0m`,
1129
+ "readme.md": `\x1b[38;2;66;165;245m\ue73e\x1b[0m`,
1130
+ "license": `\x1b[38;2;218;218;218m\ue60a\x1b[0m`,
1131
+ };
1132
+
1133
+ function fileIcon(fp: string): string {
1134
+ const base = fp.split('/').pop()?.toLowerCase() ?? '';
1135
+ if (NAME_ICON[base]) return `${NAME_ICON[base]} `;
1136
+ const ext = base.includes('.') ? base.split('.').pop() ?? '' : '';
1137
+ return EXT_ICON[ext] ? `${EXT_ICON[ext]} ` : `${NF_DEFAULT} `;
1138
+ }
1139
+
1140
+ function dirIcon(): string {
1141
+ return `${NF_DIR} `;
1142
+ }
1143
+
1144
+ function lineCount(text: string): number {
1145
+ if (!text) return 0;
1146
+ return text.split("\n").length;
1147
+ }
1148
+
1149
+ function padToWidth(line: string, width: number): string {
1150
+ const padding = Math.max(0, width - visibleWidth(line));
1151
+ return `${line}${" ".repeat(padding)}`;
1152
+ }
1153
+
1154
+ function markedContinuationPrefix(prefix: string): string {
1155
+ const plain = stripAnsi(prefix);
1156
+ const branchMatch = /^(\s*)(?:│ |├─ |└─ )/.exec(plain);
1157
+ if (branchMatch) {
1158
+ return `${branchMatch[1]}${TOOL_RULE}│${TRANSPARENT_RESET} `;
1159
+ }
1160
+ return " ".repeat(visibleWidth(prefix));
1161
+ }
1162
+
1163
+ function wrapMarkedLine(line: string, width: number): string[] {
1164
+ const markerIndex = line.indexOf(WRAP_MARK);
1165
+ if (markerIndex === -1) return wrapTextWithAnsi(line, width);
1166
+ const prefix = line.slice(0, markerIndex);
1167
+ const body = line.slice(markerIndex + WRAP_MARK.length);
1168
+ const prefixWidth = visibleWidth(prefix);
1169
+ const bodyWidth = Math.max(1, width - prefixWidth);
1170
+ const wrapped = wrapTextWithAnsi(body, bodyWidth);
1171
+ const continuation = markedContinuationPrefix(prefix);
1172
+ return wrapped.map((part, index) => (index === 0 ? `${prefix}${part}` : `${continuation}${part}`));
1173
+ }
1174
+
1175
+ class ToolText extends Text {
1176
+ private value = "";
1177
+ private toolCachedValue?: string;
1178
+ private toolCachedWidth?: number;
1179
+ private toolCachedLines?: string[];
1180
+
1181
+ constructor(text = "") {
1182
+ super("", 0, 0);
1183
+ this.value = text;
1184
+ }
1185
+
1186
+ setText(text: string): void {
1187
+ if (this.value === text) return;
1188
+ this.value = text;
1189
+ this.invalidate();
1190
+ }
1191
+
1192
+ invalidate(): void {
1193
+ this.toolCachedValue = undefined;
1194
+ this.toolCachedWidth = undefined;
1195
+ this.toolCachedLines = undefined;
1196
+ }
1197
+
1198
+ render(width: number): string[] {
1199
+ if (this.toolCachedLines && this.toolCachedValue === this.value && this.toolCachedWidth === width) return this.toolCachedLines;
1200
+ if (!this.value || this.value.trim() === "") {
1201
+ this.toolCachedValue = this.value;
1202
+ this.toolCachedWidth = width;
1203
+ this.toolCachedLines = [];
1204
+ return this.toolCachedLines;
1205
+ }
1206
+ const contentWidth = Math.max(1, width);
1207
+ const lines = this.value.replace(/\t/g, " ").split("\n");
1208
+ const rendered = lines.flatMap((line) => wrapMarkedLine(line, contentWidth)).map((line) => padToWidth(line, width));
1209
+ this.toolCachedValue = this.value;
1210
+ this.toolCachedWidth = width;
1211
+ this.toolCachedLines = rendered;
1212
+ return rendered;
1213
+ }
1214
+ }
1215
+
1216
+ function makeText(last: unknown, text: string): Text {
1217
+ const component = last instanceof ToolText ? last : new ToolText();
1218
+ component.setText(text);
1219
+ return component;
1220
+ }
1221
+
1222
+ function previewLimit(): number {
1223
+ const value = readSettings().previewLines;
1224
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 8;
1225
+ }
1226
+
1227
+ function expandedPreviewLimit(): number {
1228
+ const value = readSettings().expandedPreviewMaxLines;
1229
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 4000;
1230
+ }
1231
+
1232
+ function bashCollapsedLimit(): number {
1233
+ const value = readSettings().bashCollapsedLines;
1234
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 10;
1235
+ }
1236
+
1237
+ function diffCollapsedLimit(): number {
1238
+ const value = readSettings().diffCollapsedLines;
1239
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 24;
1240
+ }
1241
+
1242
+ function collapsedPreviewCount(expanded: boolean, fallback: number): number {
1243
+ return expanded ? expandedPreviewLimit() : fallback;
1244
+ }
1245
+
1246
+ function buildPreviewText(lines: string[], expanded: boolean, theme: Theme, fallbackCollapsed = 8): string {
1247
+ if (lines.length === 0) return theme.fg("muted", "(no output)");
1248
+ const maxLines = collapsedPreviewCount(expanded, fallbackCollapsed);
1249
+ const shown = lines.slice(0, maxLines);
1250
+ let text = shown.join("\n");
1251
+ const remaining = lines.length - shown.length;
1252
+ if (remaining > 0) {
1253
+ text += `\n${theme.fg("muted", `... (${remaining} more lines${expanded ? "" : " • Ctrl+O to expand"})`)}`;
1254
+ }
1255
+ if (expanded && lines.length > maxLines) {
1256
+ text += `\n${theme.fg("warning", `(display capped at ${maxLines} lines)`)}`;
1257
+ }
1258
+ return text;
1259
+ }
1260
+
1261
+ // ===========================================================================
1262
+ // Diff rendering — adapted from /tmp/pi-diff
1263
+ // ===========================================================================
1264
+
1265
+ interface DiffPreset {
1266
+ name: string;
1267
+ description: string;
1268
+ shikiTheme?: string;
1269
+ bgAdd?: string;
1270
+ bgDel?: string;
1271
+ bgAddHighlight?: string;
1272
+ bgDelHighlight?: string;
1273
+ bgGutterAdd?: string;
1274
+ bgGutterDel?: string;
1275
+ bgEmpty?: string;
1276
+ fgAdd?: string;
1277
+ fgDel?: string;
1278
+ fgDim?: string;
1279
+ fgLnum?: string;
1280
+ fgRule?: string;
1281
+ fgStripe?: string;
1282
+ fgSafeMuted?: string;
1283
+ }
1284
+
1285
+ interface DiffUserConfig {
1286
+ diffTheme?: string;
1287
+ diffColors?: Record<string, string>;
1288
+ }
1289
+
1290
+ const DIFF_PRESETS: Record<string, DiffPreset> = {
1291
+ default: {
1292
+ name: "default",
1293
+ description: "Original pi-diff colors",
1294
+ bgAdd: "#162620",
1295
+ bgDel: "#2d1919",
1296
+ bgAddHighlight: "#234b32",
1297
+ bgDelHighlight: "#502323",
1298
+ bgGutterAdd: "#12201a",
1299
+ bgGutterDel: "#261616",
1300
+ bgEmpty: "#121212",
1301
+ fgDim: "#505050",
1302
+ fgLnum: "#646464",
1303
+ fgRule: "#323232",
1304
+ fgStripe: "#282828",
1305
+ fgSafeMuted: "#8b949e",
1306
+ },
1307
+ midnight: {
1308
+ name: "midnight",
1309
+ description: "Subtle tints for black backgrounds",
1310
+ bgAdd: "#0d1a12",
1311
+ bgDel: "#1a0d0d",
1312
+ bgAddHighlight: "#1a3825",
1313
+ bgDelHighlight: "#381a1a",
1314
+ bgGutterAdd: "#091208",
1315
+ bgGutterDel: "#120908",
1316
+ bgEmpty: "#080808",
1317
+ fgDim: "#404040",
1318
+ fgLnum: "#505050",
1319
+ fgRule: "#282828",
1320
+ fgStripe: "#1e1e1e",
1321
+ fgSafeMuted: "#8b949e",
1322
+ },
1323
+ neon: {
1324
+ name: "neon",
1325
+ description: "Higher contrast backgrounds",
1326
+ bgAdd: "#1a3320",
1327
+ bgDel: "#331a16",
1328
+ bgAddHighlight: "#2d5c3a",
1329
+ bgDelHighlight: "#5c2d2d",
1330
+ bgGutterAdd: "#142818",
1331
+ bgGutterDel: "#28120e",
1332
+ bgEmpty: "#141414",
1333
+ fgDim: "#606060",
1334
+ fgLnum: "#787878",
1335
+ fgRule: "#404040",
1336
+ fgStripe: "#303030",
1337
+ fgSafeMuted: "#9da5ae",
1338
+ },
1339
+ };
1340
+
1341
+ function loadDiffConfig(): DiffUserConfig {
1342
+ const settings = readSettings();
1343
+ return { diffTheme: settings.diffTheme, diffColors: settings.diffColors };
1344
+ }
1345
+
1346
+ // 6x6x6 color cube channel values used by pi's 256color fallback.
1347
+ const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
1348
+
1349
+ function xterm256ToRgb(index: number): { r: number; g: number; b: number } | null {
1350
+ if (!Number.isInteger(index) || index < 0 || index > 255) return null;
1351
+ if (index < 16) {
1352
+ // Standard 16 ANSI colors — terminal-defined, approximate with VS Code defaults.
1353
+ const basic: Array<[number, number, number]> = [
1354
+ [0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
1355
+ [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
1356
+ [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
1357
+ [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255],
1358
+ ];
1359
+ const [r, g, b] = basic[index];
1360
+ return { r, g, b };
1361
+ }
1362
+ if (index < 232) {
1363
+ const i = index - 16;
1364
+ return {
1365
+ r: CUBE_VALUES[Math.floor(i / 36) % 6],
1366
+ g: CUBE_VALUES[Math.floor(i / 6) % 6],
1367
+ b: CUBE_VALUES[i % 6],
1368
+ };
1369
+ }
1370
+ const level = 8 + (index - 232) * 10;
1371
+ return { r: level, g: level, b: level };
1372
+ }
1373
+
1374
+ function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
1375
+ if (!ansi) return null;
1376
+ const esc = "\u001b";
1377
+ // Truecolor: \e[38;2;R;G;Bm or \e[48;2;R;G;Bm
1378
+ const tc = ansi.match(new RegExp(`${esc}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
1379
+ if (tc) return { r: +tc[1], g: +tc[2], b: +tc[3] };
1380
+ // 256-color: \e[38;5;Nm or \e[48;5;Nm — happens on Apple Terminal, screen, etc.
1381
+ const idx = ansi.match(new RegExp(`${esc}\\[(?:38|48);5;(\\d+)m`));
1382
+ if (idx) return xterm256ToRgb(+idx[1]);
1383
+ return null;
1384
+ }
1385
+
1386
+ function hexToBgAnsi(hex: string): string {
1387
+ if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return "";
1388
+ const r = Number.parseInt(hex.slice(1, 3), 16);
1389
+ const g = Number.parseInt(hex.slice(3, 5), 16);
1390
+ const b = Number.parseInt(hex.slice(5, 7), 16);
1391
+ return `\x1b[48;2;${r};${g};${b}m`;
1392
+ }
1393
+
1394
+ function hexToFgAnsi(hex: string): string {
1395
+ if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return "";
1396
+ const r = Number.parseInt(hex.slice(1, 3), 16);
1397
+ const g = Number.parseInt(hex.slice(3, 5), 16);
1398
+ const b = Number.parseInt(hex.slice(5, 7), 16);
1399
+ return `\x1b[38;2;${r};${g};${b}m`;
1400
+ }
1401
+
1402
+ // ---------------------------------------------------------------------------
1403
+ // Theme palette extraction — pull RGB from the active pi theme so our
1404
+ // hardcoded greys and accent colors track the user's selected theme.
1405
+ //
1406
+ // `theme.getFgAnsi(name)` / `theme.getBgAnsi(name)` return raw ANSI escapes
1407
+ // (either truecolor or 256color depending on the terminal). We parse those
1408
+ // back into RGB so we can mix tints for diff backgrounds.
1409
+ // ---------------------------------------------------------------------------
1410
+
1411
+ type Rgb = { r: number; g: number; b: number };
1412
+
1413
+ function safeFgAnsi(theme: any, key: string): string | null {
1414
+ try {
1415
+ const ansi = theme?.getFgAnsi?.(key);
1416
+ return typeof ansi === "string" && ansi.length > 0 ? ansi : null;
1417
+ } catch {
1418
+ return null;
1419
+ }
1420
+ }
1421
+
1422
+ function safeBgAnsi(theme: any, key: string): string | null {
1423
+ try {
1424
+ const ansi = theme?.getBgAnsi?.(key);
1425
+ return typeof ansi === "string" && ansi.length > 0 ? ansi : null;
1426
+ } catch {
1427
+ return null;
1428
+ }
1429
+ }
1430
+
1431
+ function themeFgRgb(theme: any, key: string): Rgb | null {
1432
+ const ansi = safeFgAnsi(theme, key);
1433
+ return ansi ? parseAnsiRgb(ansi) : null;
1434
+ }
1435
+
1436
+ function themeBgRgb(theme: any, key: string): Rgb | null {
1437
+ const ansi = safeBgAnsi(theme, key);
1438
+ return ansi ? parseAnsiRgb(ansi) : null;
1439
+ }
1440
+
1441
+ // Cache theme identity so we only recompute on theme change. The Theme
1442
+ // object is reused across renders within a single session unless the user
1443
+ // switches themes via the picker.
1444
+ let _themePaletteCacheTheme: unknown = null;
1445
+
1446
+ function themeAdaptiveEnabled(): boolean {
1447
+ const settings = readSettings();
1448
+ return settings.themeAdaptive !== false;
1449
+ }
1450
+
1451
+ let DIFF_THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
1452
+ let codeToAnsiLoader: Promise<any> | null = null;
1453
+
1454
+ const SPLIT_MIN_WIDTH = 150;
1455
+ const SPLIT_MIN_CODE_WIDTH = 60;
1456
+ const SPLIT_MAX_WRAP_RATIO = 0.2;
1457
+ const SPLIT_MAX_WRAP_LINES = 8;
1458
+ const MAX_TERM_WIDTH = 210;
1459
+ const DEFAULT_TERM_WIDTH = 200;
1460
+ const MAX_PREVIEW_LINES = 60;
1461
+ const MAX_RENDER_LINES = 150;
1462
+ const MAX_HL_CHARS = 32_000;
1463
+ const CACHE_LIMIT = 48;
1464
+ const WORD_DIFF_MIN_SIM = 0.15;
1465
+ const MAX_WRAP_ROWS_WIDE = 3;
1466
+ const MAX_WRAP_ROWS_MED = 2;
1467
+ const MAX_WRAP_ROWS_NARROW = 1;
1468
+
1469
+ let D_RST = "\x1b[0m";
1470
+ const D_BOLD = "\x1b[1m";
1471
+ const D_DIM = "\x1b[2m";
1472
+
1473
+ // Diff backgrounds — defaults are transparent; autoDeriveBgFromTheme fills them
1474
+ // using pi-tool-display's mix ratios against the theme's toolSuccessBg.
1475
+ let BG_ADD = "\x1b[49m";
1476
+ let BG_DEL = "\x1b[49m";
1477
+ let BG_ADD_W = "\x1b[49m";
1478
+ let BG_DEL_W = "\x1b[49m";
1479
+ let BG_GUTTER_ADD = "\x1b[49m";
1480
+ let BG_GUTTER_DEL = "\x1b[49m";
1481
+ let BG_EMPTY = "\x1b[49m";
1482
+ let BG_BASE = "\x1b[49m";
1483
+
1484
+ let FG_ADD = "\x1b[38;2;100;180;120m";
1485
+ let FG_DEL = "\x1b[38;2;200;100;100m";
1486
+ let FG_DIM = "\x1b[38;2;80;80;80m";
1487
+ let FG_LNUM = "\x1b[38;2;100;100;100m";
1488
+ let FG_RULE = "\x1b[38;2;50;50;50m";
1489
+ let TOOL_RULE = "\x1b[38;2;153;153;153m";
1490
+ let FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
1491
+ let FG_STRIPE = "\x1b[38;2;40;40;40m";
1492
+
1493
+ let DIVIDER = `${FG_RULE}│${D_RST}`;
1494
+
1495
+ interface DiffColors {
1496
+ fgAdd: string;
1497
+ fgDel: string;
1498
+ fgCtx: string;
1499
+ }
1500
+
1501
+ let DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
1502
+ let autoDerivePending = true;
1503
+ let hasExplicitBgConfig = false;
1504
+
1505
+ function mixBg(
1506
+ base: { r: number; g: number; b: number },
1507
+ accent: { r: number; g: number; b: number },
1508
+ intensity: number,
1509
+ ): string {
1510
+ const r = Math.round(base.r + (accent.r - base.r) * intensity);
1511
+ const g = Math.round(base.g + (accent.g - base.g) * intensity);
1512
+ const b = Math.round(base.b + (accent.b - base.b) * intensity);
1513
+ return `\x1b[48;2;${r};${g};${b}m`;
1514
+ }
1515
+
1516
+ // pi-tool-display tint targets for diff palette derivation
1517
+ const ADDITION_TINT_TARGET = { r: 84, g: 190, b: 118 };
1518
+ const DELETION_TINT_TARGET = { r: 232, g: 95, b: 122 };
1519
+ // Fallback base that matches most dark themes (NOT black)
1520
+ const FALLBACK_BASE_BG = { r: 32, g: 35, b: 42 };
1521
+ const UNIVERSAL_DIFF_ADD_FG = { r: 110, g: 210, b: 130 };
1522
+ const UNIVERSAL_DIFF_DEL_FG = { r: 225, g: 110, b: 110 };
1523
+
1524
+ function mixRgb(
1525
+ a: { r: number; g: number; b: number },
1526
+ b: { r: number; g: number; b: number },
1527
+ ratio: number,
1528
+ ): { r: number; g: number; b: number } {
1529
+ return {
1530
+ r: a.r + (b.r - a.r) * ratio,
1531
+ g: a.g + (b.g - a.g) * ratio,
1532
+ b: a.b + (b.b - a.b) * ratio,
1533
+ };
1534
+ }
1535
+
1536
+ function rgbToBgAnsi(c: { r: number; g: number; b: number }): string {
1537
+ return `\x1b[48;2;${Math.round(c.r)};${Math.round(c.g)};${Math.round(c.b)}m`;
1538
+ }
1539
+
1540
+ function autoDeriveBgFromTheme(theme: any): void {
1541
+ // Diff palette derivation.
1542
+ //
1543
+ // `toolDiffAdded` / `toolDiffRemoved` from the active pi theme give us the
1544
+ // fg accents. The base background is taken from `toolSuccessBg` (close to
1545
+ // the panel color the row will sit on) so the tinted backgrounds blend in
1546
+ // instead of forcing a hardcoded dark hue. Falls back to the universal
1547
+ // dark palette when the theme is unavailable or themeAdaptive=false.
1548
+ const useTheme = themeAdaptiveEnabled() && theme;
1549
+ const addFgRgb = (useTheme && themeFgRgb(theme, "toolDiffAdded")) || UNIVERSAL_DIFF_ADD_FG;
1550
+ const delFgRgb = (useTheme && themeFgRgb(theme, "toolDiffRemoved")) || UNIVERSAL_DIFF_DEL_FG;
1551
+ const base = (useTheme && themeBgRgb(theme, "toolSuccessBg")) || FALLBACK_BASE_BG;
1552
+
1553
+ const addTint = mixRgb(addFgRgb, ADDITION_TINT_TARGET, 0.35);
1554
+ const delTint = mixRgb(delFgRgb, DELETION_TINT_TARGET, 0.65);
1555
+
1556
+ FG_ADD = `\x1b[38;2;${Math.round(addFgRgb.r)};${Math.round(addFgRgb.g)};${Math.round(addFgRgb.b)}m`;
1557
+ FG_DEL = `\x1b[38;2;${Math.round(delFgRgb.r)};${Math.round(delFgRgb.g)};${Math.round(delFgRgb.b)}m`;
1558
+ BG_ADD = rgbToBgAnsi(mixRgb(base, addTint, 0.24));
1559
+ BG_DEL = rgbToBgAnsi(mixRgb(base, delTint, 0.12));
1560
+ BG_ADD_W = rgbToBgAnsi(mixRgb(base, addTint, 0.44));
1561
+ BG_DEL_W = rgbToBgAnsi(mixRgb(base, delTint, 0.26));
1562
+ BG_GUTTER_ADD = rgbToBgAnsi(mixRgb(base, addTint, 0.14));
1563
+ BG_GUTTER_DEL = rgbToBgAnsi(mixRgb(base, delTint, 0.08));
1564
+ BG_EMPTY = TRANSPARENT_BG;
1565
+ BG_BASE = TRANSPARENT_BG;
1566
+ D_RST = TRANSPARENT_RESET;
1567
+ DIVIDER = `${FG_RULE}│${D_RST}`;
1568
+ DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
1569
+ }
1570
+
1571
+ // Track which palette fields the user explicitly set so theme-derived
1572
+ // updates don't clobber their config.
1573
+ const _explicitFgFields = new Set<"fgAdd" | "fgDel" | "fgDim" | "fgLnum" | "fgRule" | "fgStripe" | "fgSafeMuted">();
1574
+
1575
+ // Original Claude-Code-style palette captured at module-load so we can
1576
+ // restore it when the user toggles themeAdaptive off at runtime.
1577
+ const _claudeStyleDefaults = {
1578
+ BORDER_COLOR: "\x1b[38;5;238m",
1579
+ WORKED_LINE_FG: "\x1b[38;2;140;140;140m",
1580
+ TOOL_RULE: "\x1b[38;2;153;153;153m",
1581
+ FG_DIM: "\x1b[38;2;80;80;80m",
1582
+ FG_LNUM: "\x1b[38;2;100;100;100m",
1583
+ FG_RULE: "\x1b[38;2;50;50;50m",
1584
+ FG_STRIPE: "\x1b[38;2;40;40;40m",
1585
+ FG_SAFE_MUTED: "\x1b[38;2;139;148;158m",
1586
+ FG_ADD: "\x1b[38;2;100;180;120m",
1587
+ FG_DEL: "\x1b[38;2;200;100;100m",
1588
+ };
1589
+
1590
+ function resetThemePalette(): void {
1591
+ BORDER_COLOR = _claudeStyleDefaults.BORDER_COLOR;
1592
+ WORKED_LINE_FG = _claudeStyleDefaults.WORKED_LINE_FG;
1593
+ TOOL_RULE = _claudeStyleDefaults.TOOL_RULE;
1594
+ if (!_explicitFgFields.has("fgDim")) FG_DIM = _claudeStyleDefaults.FG_DIM;
1595
+ if (!_explicitFgFields.has("fgLnum")) FG_LNUM = _claudeStyleDefaults.FG_LNUM;
1596
+ if (!_explicitFgFields.has("fgRule")) FG_RULE = _claudeStyleDefaults.FG_RULE;
1597
+ if (!_explicitFgFields.has("fgStripe")) FG_STRIPE = _claudeStyleDefaults.FG_STRIPE;
1598
+ if (!_explicitFgFields.has("fgSafeMuted")) FG_SAFE_MUTED = _claudeStyleDefaults.FG_SAFE_MUTED;
1599
+ if (!_explicitFgFields.has("fgAdd")) FG_ADD = _claudeStyleDefaults.FG_ADD;
1600
+ if (!_explicitFgFields.has("fgDel")) FG_DEL = _claudeStyleDefaults.FG_DEL;
1601
+ DIVIDER = `${FG_RULE}│${D_RST}`;
1602
+ DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
1603
+ }
1604
+
1605
+ function applyThemePaletteIfNeeded(theme: any): void {
1606
+ if (!theme) return;
1607
+ if (!themeAdaptiveEnabled()) return;
1608
+ if (_themePaletteCacheTheme === theme) return; // already applied for this theme instance
1609
+ _themePaletteCacheTheme = theme;
1610
+
1611
+ // Borders (top/bottom outlines, user-message frame, branch rule).
1612
+ const borderMuted = safeFgAnsi(theme, "borderMuted");
1613
+ if (borderMuted) BORDER_COLOR = borderMuted;
1614
+
1615
+ // "Worked for Ns" line + thinking-block italics share pi's `muted` color.
1616
+ const muted = safeFgAnsi(theme, "muted");
1617
+ if (muted) WORKED_LINE_FG = muted;
1618
+
1619
+ // Tool branch rule (├─ / └─ connectors). Use `dim` if present, else `muted`.
1620
+ const dim = safeFgAnsi(theme, "dim") ?? muted;
1621
+ if (dim) TOOL_RULE = dim;
1622
+
1623
+ // Diff support text colors. These are user-overridable via diffColors.* so
1624
+ // we only touch the ones not explicitly set.
1625
+ if (!_explicitFgFields.has("fgDim") && muted) FG_DIM = muted;
1626
+ if (!_explicitFgFields.has("fgLnum") && muted) FG_LNUM = muted;
1627
+ if (!_explicitFgFields.has("fgRule") && borderMuted) FG_RULE = borderMuted;
1628
+ if (!_explicitFgFields.has("fgStripe") && borderMuted) FG_STRIPE = borderMuted;
1629
+ if (!_explicitFgFields.has("fgSafeMuted") && muted) FG_SAFE_MUTED = muted;
1630
+
1631
+ DIVIDER = `${FG_RULE}│${D_RST}`;
1632
+
1633
+ // Re-trigger background derivation against the new theme unless the user
1634
+ // set explicit bg overrides via diffTheme/diffColors.
1635
+ if (!hasExplicitBgConfig) {
1636
+ autoDeriveBgFromTheme(theme);
1637
+ autoDerivePending = false;
1638
+ }
1639
+ }
1640
+
1641
+ function applyDiffPalette(): void {
1642
+ const config = loadDiffConfig();
1643
+ const preset = config.diffTheme ? DIFF_PRESETS[config.diffTheme] : null;
1644
+ if (preset) hasExplicitBgConfig = true;
1645
+ const overrides = config.diffColors ?? {};
1646
+ if (Object.keys(overrides).length > 0) hasExplicitBgConfig = true;
1647
+ _explicitFgFields.clear();
1648
+
1649
+ const applyBg = (key: string, presetValue: string | undefined, set: (value: string) => void) => {
1650
+ const hex = overrides[key] ?? presetValue;
1651
+ if (!hex) return;
1652
+ const ansi = hexToBgAnsi(hex);
1653
+ if (ansi) set(ansi);
1654
+ };
1655
+ const applyFg = (
1656
+ key: "fgAdd" | "fgDel" | "fgDim" | "fgLnum" | "fgRule" | "fgStripe" | "fgSafeMuted",
1657
+ presetValue: string | undefined,
1658
+ set: (value: string) => void,
1659
+ ) => {
1660
+ const hex = overrides[key] ?? presetValue;
1661
+ if (!hex) return;
1662
+ const ansi = hexToFgAnsi(hex);
1663
+ if (!ansi) return;
1664
+ set(ansi);
1665
+ _explicitFgFields.add(key);
1666
+ };
1667
+
1668
+ applyBg("bgAdd", preset?.bgAdd, (v) => {
1669
+ BG_ADD = v;
1670
+ });
1671
+ applyBg("bgDel", preset?.bgDel, (v) => {
1672
+ BG_DEL = v;
1673
+ });
1674
+ applyBg("bgAddHighlight", preset?.bgAddHighlight, (v) => {
1675
+ BG_ADD_W = v;
1676
+ });
1677
+ applyBg("bgDelHighlight", preset?.bgDelHighlight, (v) => {
1678
+ BG_DEL_W = v;
1679
+ });
1680
+ applyBg("bgGutterAdd", preset?.bgGutterAdd, (v) => {
1681
+ BG_GUTTER_ADD = v;
1682
+ });
1683
+ applyBg("bgGutterDel", preset?.bgGutterDel, (v) => {
1684
+ BG_GUTTER_DEL = v;
1685
+ });
1686
+ applyBg("bgEmpty", preset?.bgEmpty, (v) => {
1687
+ BG_EMPTY = v;
1688
+ });
1689
+
1690
+ applyFg("fgAdd", preset?.fgAdd, (v) => {
1691
+ FG_ADD = v;
1692
+ });
1693
+ applyFg("fgDel", preset?.fgDel, (v) => {
1694
+ FG_DEL = v;
1695
+ });
1696
+ applyFg("fgDim", preset?.fgDim, (v) => {
1697
+ FG_DIM = v;
1698
+ });
1699
+ applyFg("fgLnum", preset?.fgLnum, (v) => {
1700
+ FG_LNUM = v;
1701
+ });
1702
+ applyFg("fgRule", preset?.fgRule, (v) => {
1703
+ FG_RULE = v;
1704
+ });
1705
+ applyFg("fgStripe", preset?.fgStripe, (v) => {
1706
+ FG_STRIPE = v;
1707
+ });
1708
+ applyFg("fgSafeMuted", preset?.fgSafeMuted, (v) => {
1709
+ FG_SAFE_MUTED = v;
1710
+ });
1711
+
1712
+ const shiki = overrides.shikiTheme ?? preset?.shikiTheme;
1713
+ if (shiki) DIFF_THEME = shiki as BundledTheme;
1714
+
1715
+ DIVIDER = `${FG_RULE}│${D_RST}`;
1716
+ DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
1717
+ // Only trigger auto-derive when the user did NOT supply an explicit
1718
+ // preset or per-color override; otherwise we would overwrite their config
1719
+ // with the hardcoded dark palette on first render.
1720
+ autoDerivePending = !hasExplicitBgConfig;
1721
+ }
1722
+
1723
+ function resolveDiffColors(theme?: any): DiffColors {
1724
+ applyThemePaletteIfNeeded(theme);
1725
+ if (autoDerivePending && theme?.getFgAnsi) {
1726
+ autoDeriveBgFromTheme(theme);
1727
+ autoDerivePending = false;
1728
+ }
1729
+ return DEFAULT_DIFF_COLORS;
1730
+ }
1731
+
1732
+ interface DiffLine {
1733
+ type: "add" | "del" | "ctx" | "sep";
1734
+ oldNum: number | null;
1735
+ newNum: number | null;
1736
+ content: string;
1737
+ }
1738
+
1739
+ interface ParsedDiff {
1740
+ lines: DiffLine[];
1741
+ added: number;
1742
+ removed: number;
1743
+ chars: number;
1744
+ }
1745
+
1746
+ function diffStrip(value: string): string {
1747
+ return value.replace(ANSI_RE, "");
1748
+ }
1749
+
1750
+ function tabs(text: string): string {
1751
+ return text.replace(/\t/g, " ");
1752
+ }
1753
+
1754
+ function termW(): number {
1755
+ const raw =
1756
+ process.stdout.columns ||
1757
+ (process.stderr as any).columns ||
1758
+ Number.parseInt(process.env.COLUMNS ?? "", 10) ||
1759
+ DEFAULT_TERM_WIDTH;
1760
+ return Math.max(40, Math.min(raw - 4, MAX_TERM_WIDTH));
1761
+ }
1762
+
1763
+ function branchDiffWidth(): number {
1764
+ return Math.max(40, termW() - 8);
1765
+ }
1766
+
1767
+ function adaptiveWrapRows(tw?: number): number {
1768
+ const width = tw ?? termW();
1769
+ if (width >= 180) return MAX_WRAP_ROWS_WIDE;
1770
+ if (width >= 120) return MAX_WRAP_ROWS_MED;
1771
+ return MAX_WRAP_ROWS_NARROW;
1772
+ }
1773
+
1774
+ function fit(value: string, width: number): string {
1775
+ if (width <= 0) return "";
1776
+ const plain = diffStrip(value);
1777
+ if (plain.length <= width) return value + " ".repeat(width - plain.length);
1778
+ const showWidth = width > 2 ? width - 1 : width;
1779
+ let vis = 0;
1780
+ let i = 0;
1781
+ while (i < value.length && vis < showWidth) {
1782
+ if (value[i] === "\x1b") {
1783
+ const end = value.indexOf("m", i);
1784
+ if (end !== -1) {
1785
+ i = end + 1;
1786
+ continue;
1787
+ }
1788
+ }
1789
+ vis++;
1790
+ i++;
1791
+ }
1792
+ return width > 2 ? `${value.slice(0, i)}${D_RST}${FG_DIM}›${D_RST}` : `${value.slice(0, i)}${D_RST}`;
1793
+ }
1794
+
1795
+ function ansiState(text: string): string {
1796
+ const matches = text.match(/\x1b\[[0-9;]*m/g) ?? [];
1797
+ let fg = "";
1798
+ let bg = "";
1799
+ for (const seq of matches) {
1800
+ const params = seq.slice(2, -1);
1801
+ if (params === "0") {
1802
+ fg = "";
1803
+ bg = "";
1804
+ } else if (params === "39") {
1805
+ fg = "";
1806
+ } else if (params.startsWith("38;")) {
1807
+ fg = seq;
1808
+ } else if (params.startsWith("48;")) {
1809
+ bg = seq;
1810
+ }
1811
+ }
1812
+ return bg + fg;
1813
+ }
1814
+
1815
+ function normalizeShikiContrast(ansi: string): string {
1816
+ return ansi.replace(/\x1b\[([0-9;]*)m/g, (seq, params: string) => {
1817
+ if (params === "30" || params === "90" || params === "38;5;0" || params === "38;5;8") return FG_SAFE_MUTED;
1818
+ if (!params.startsWith("38;2;")) return seq;
1819
+ const parts = params.split(";").map(Number);
1820
+ if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return seq;
1821
+ const [, , r, g, b] = parts;
1822
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
1823
+ return luminance < 72 ? FG_SAFE_MUTED : seq;
1824
+ });
1825
+ }
1826
+
1827
+ function wrapAnsi(text: string, width: number, maxRows = adaptiveWrapRows(), fillBg = ""): string[] {
1828
+ if (width <= 0) return [""];
1829
+ const plain = diffStrip(text);
1830
+ if (plain.length <= width) {
1831
+ const pad = width - plain.length;
1832
+ return pad > 0 ? [text + fillBg + " ".repeat(pad) + (fillBg ? D_RST : "")] : [text];
1833
+ }
1834
+
1835
+ const rows: string[] = [];
1836
+ let row = "";
1837
+ let vis = 0;
1838
+ let i = 0;
1839
+ let onLastRow = false;
1840
+ let effectiveWidth = width;
1841
+
1842
+ while (i < text.length) {
1843
+ if (!onLastRow && rows.length >= maxRows - 1) {
1844
+ onLastRow = true;
1845
+ effectiveWidth = width > 2 ? width - 1 : width;
1846
+ }
1847
+ if (text[i] === "\x1b") {
1848
+ const end = text.indexOf("m", i);
1849
+ if (end !== -1) {
1850
+ row += text.slice(i, end + 1);
1851
+ i = end + 1;
1852
+ continue;
1853
+ }
1854
+ }
1855
+ if (vis >= effectiveWidth) {
1856
+ if (onLastRow) {
1857
+ let hasMore = false;
1858
+ for (let j = i; j < text.length; j++) {
1859
+ if (text[j] === "\x1b") {
1860
+ const e2 = text.indexOf("m", j);
1861
+ if (e2 !== -1) {
1862
+ j = e2;
1863
+ continue;
1864
+ }
1865
+ }
1866
+ hasMore = true;
1867
+ break;
1868
+ }
1869
+ if (hasMore && width > 2) row += `${D_RST}${FG_DIM}›${D_RST}`;
1870
+ else row += fillBg + " ".repeat(Math.max(0, width - vis)) + D_RST;
1871
+ rows.push(row);
1872
+ return rows;
1873
+ }
1874
+ const state = ansiState(row);
1875
+ rows.push(row + D_RST);
1876
+ row = state + fillBg;
1877
+ vis = 0;
1878
+ if (rows.length >= maxRows - 1) {
1879
+ onLastRow = true;
1880
+ effectiveWidth = width > 2 ? width - 1 : width;
1881
+ }
1882
+ }
1883
+ row += text[i];
1884
+ vis++;
1885
+ i++;
1886
+ }
1887
+
1888
+ if (row.length > 0 || rows.length === 0) {
1889
+ rows.push(row + fillBg + " ".repeat(Math.max(0, width - vis)) + D_RST);
1890
+ }
1891
+ return rows;
1892
+ }
1893
+
1894
+ function lnum(n: number | null, width: number, fg = FG_LNUM): string {
1895
+ if (n === null) return " ".repeat(width);
1896
+ const value = String(n);
1897
+ return `${fg}${" ".repeat(Math.max(0, width - value.length))}${value}${D_RST}`;
1898
+ }
1899
+
1900
+ function stripes(width: number): string {
1901
+ return BG_BASE + FG_STRIPE + "╱".repeat(width) + D_RST;
1902
+ }
1903
+
1904
+ function renderDiffStatBar(added: number, removed: number, width = termW()): string {
1905
+ const total = added + removed;
1906
+ if (total === 0 || width < 20) return "";
1907
+ const slots = Math.max(8, Math.min(20, Math.floor(width / 14)));
1908
+ let addSlots = Math.max(0, Math.min(slots, Math.round((added / total) * slots)));
1909
+ if (added > 0 && addSlots === 0) addSlots = 1;
1910
+ if (removed > 0 && addSlots >= slots) addSlots = slots - 1;
1911
+ const removeSlots = Math.max(0, slots - addSlots);
1912
+ const addBar = addSlots > 0 ? `${FG_ADD}${"━".repeat(addSlots)}${D_RST}` : "";
1913
+ const removeBar = removeSlots > 0 ? `${FG_DEL}${"━".repeat(removeSlots)}${D_RST}` : "";
1914
+ return `${FG_DIM}[${D_RST}${addBar}${removeBar}${FG_DIM}]${D_RST}`;
1915
+ }
1916
+
1917
+ function summarizeDiff(added: number, removed: number): string {
1918
+ const parts: string[] = [];
1919
+ if (added > 0) parts.push(`${FG_ADD}+${added}${D_RST}`);
1920
+ if (removed > 0) parts.push(`${FG_DEL}-${removed}${D_RST}`);
1921
+ if (!parts.length) return `${FG_DIM}no changes${D_RST}`;
1922
+ const bar = renderDiffStatBar(added, removed);
1923
+ return bar ? `${parts.join(" ")} ${bar}` : parts.join(" ");
1924
+ }
1925
+
1926
+ function diffSummaryWithMeta(added: number, removed: number, hunks: number, mode: string): string {
1927
+ const base = summarizeDiff(added, removed);
1928
+ const extras: string[] = [];
1929
+ if (hunks > 0) extras.push(`${FG_DIM}${hunks} hunk${hunks === 1 ? "" : "s"}${D_RST}`);
1930
+ if (mode) extras.push(`${FG_DIM}${mode}${D_RST}`);
1931
+ return extras.length ? `${base} ${FG_DIM}•${D_RST} ${extras.join(` ${FG_DIM}•${D_RST} `)}` : base;
1932
+ }
1933
+
1934
+ function collapsedDiffHint(remainingLines: number, hiddenHunks: number): string {
1935
+ const width = termW();
1936
+ const candidates = [
1937
+ `… (${remainingLines} more diff lines${hiddenHunks > 0 ? ` • ${hiddenHunks} more hunks` : ""} • Ctrl+O to expand)`,
1938
+ `… (${remainingLines} more lines${hiddenHunks > 0 ? ` • ${hiddenHunks} hunks` : ""})`,
1939
+ `… (+${remainingLines}${hiddenHunks > 0 ? ` • +${hiddenHunks}h` : ""})`,
1940
+ "…",
1941
+ ];
1942
+ for (const candidate of candidates) {
1943
+ if (visibleWidth(candidate) <= width) return candidate;
1944
+ }
1945
+ return truncateToWidth("…", width, "");
1946
+ }
1947
+
1948
+ function diffRule(width: number): string {
1949
+ return `${BG_BASE}${FG_RULE}${"─".repeat(width)}${D_RST}`;
1950
+ }
1951
+
1952
+ function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean {
1953
+ if (!diff.lines.length) return false;
1954
+ if (tw < SPLIT_MIN_WIDTH) return false;
1955
+ const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
1956
+ const half = Math.floor((tw - 1) / 2);
1957
+ const gw = nw + 5;
1958
+ const cw = Math.max(12, half - gw);
1959
+ if (cw < SPLIT_MIN_CODE_WIDTH) return false;
1960
+ const vis = diff.lines.slice(0, maxRows);
1961
+ let contentLines = 0;
1962
+ let wrapCandidates = 0;
1963
+ for (const line of vis) {
1964
+ if (line.type === "sep") continue;
1965
+ contentLines++;
1966
+ if (tabs(line.content).length > cw) wrapCandidates++;
1967
+ }
1968
+ if (contentLines === 0) return true;
1969
+ const wrapRatio = wrapCandidates / contentLines;
1970
+ if (wrapCandidates >= SPLIT_MAX_WRAP_LINES) return false;
1971
+ if (wrapRatio >= SPLIT_MAX_WRAP_RATIO) return false;
1972
+ return true;
1973
+ }
1974
+
1975
+ const EXT_LANG: Record<string, BundledLanguage> = {
1976
+ ts: "typescript",
1977
+ tsx: "tsx",
1978
+ js: "javascript",
1979
+ jsx: "jsx",
1980
+ mjs: "javascript",
1981
+ cjs: "javascript",
1982
+ py: "python",
1983
+ rb: "ruby",
1984
+ rs: "rust",
1985
+ go: "go",
1986
+ java: "java",
1987
+ c: "c",
1988
+ cpp: "cpp",
1989
+ h: "c",
1990
+ hpp: "cpp",
1991
+ cs: "csharp",
1992
+ swift: "swift",
1993
+ kt: "kotlin",
1994
+ html: "html",
1995
+ css: "css",
1996
+ scss: "scss",
1997
+ json: "json",
1998
+ yaml: "yaml",
1999
+ yml: "yaml",
2000
+ toml: "toml",
2001
+ md: "markdown",
2002
+ sql: "sql",
2003
+ sh: "bash",
2004
+ bash: "bash",
2005
+ zsh: "bash",
2006
+ lua: "lua",
2007
+ php: "php",
2008
+ dart: "dart",
2009
+ xml: "xml",
2010
+ graphql: "graphql",
2011
+ svelte: "svelte",
2012
+ vue: "vue",
2013
+ };
2014
+
2015
+ function lang(filePath: string): BundledLanguage | undefined {
2016
+ return EXT_LANG[extname(filePath).slice(1).toLowerCase()];
2017
+ }
2018
+
2019
+ async function codeToAnsiLazy(code: string, language: BundledLanguage, theme: BundledTheme): Promise<string> {
2020
+ if (!codeToAnsiLoader) {
2021
+ codeToAnsiLoader = import("@shikijs/cli").then((mod) => mod.codeToANSI);
2022
+ }
2023
+ const codeToAnsi = await codeToAnsiLoader;
2024
+ return codeToAnsi(code, language, theme);
2025
+ }
2026
+
2027
+ const hlCache = new Map<string, string[]>();
2028
+
2029
+ function clearHighlightCache(): void {
2030
+ hlCache.clear();
2031
+ }
2032
+
2033
+ function touchCache(key: string, value: string[]): string[] {
2034
+ hlCache.delete(key);
2035
+ hlCache.set(key, value);
2036
+ while (hlCache.size > CACHE_LIMIT) {
2037
+ const first = hlCache.keys().next().value;
2038
+ if (first === undefined) break;
2039
+ hlCache.delete(first);
2040
+ }
2041
+ return value;
2042
+ }
2043
+
2044
+ async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
2045
+ if (!code) return [""];
2046
+ if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
2047
+ const key = `${DIFF_THEME}\0${language}\0${code}`;
2048
+ const hit = hlCache.get(key);
2049
+ if (hit) return touchCache(key, hit);
2050
+ try {
2051
+ const ansi = normalizeShikiContrast(await codeToAnsiLazy(code, language, DIFF_THEME));
2052
+ const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
2053
+ return touchCache(key, out);
2054
+ } catch {
2055
+ return code.split("\n");
2056
+ }
2057
+ }
2058
+
2059
+ function parseDiff(oldContent: string, newContent: string, ctxLines = 3): ParsedDiff {
2060
+ const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: ctxLines });
2061
+ const lines: DiffLine[] = [];
2062
+ let added = 0;
2063
+ let removed = 0;
2064
+ for (let hi = 0; hi < patch.hunks.length; hi++) {
2065
+ if (hi > 0) {
2066
+ const prev = patch.hunks[hi - 1];
2067
+ const gap = patch.hunks[hi].oldStart - (prev.oldStart + prev.oldLines);
2068
+ lines.push({ type: "sep", oldNum: null, newNum: gap > 0 ? gap : null, content: "" });
2069
+ }
2070
+ const hunk = patch.hunks[hi];
2071
+ let oldLine = hunk.oldStart;
2072
+ let newLine = hunk.newStart;
2073
+ for (const raw of hunk.lines) {
2074
+ if (raw === "\") continue;
2075
+ const ch = raw[0];
2076
+ const text = raw.slice(1);
2077
+ if (ch === "+") {
2078
+ lines.push({ type: "add", oldNum: null, newNum: newLine++, content: text });
2079
+ added++;
2080
+ } else if (ch === "-") {
2081
+ lines.push({ type: "del", oldNum: oldLine++, newNum: null, content: text });
2082
+ removed++;
2083
+ } else {
2084
+ lines.push({ type: "ctx", oldNum: oldLine++, newNum: newLine++, content: text });
2085
+ }
2086
+ }
2087
+ }
2088
+ return { lines, added, removed, chars: oldContent.length + newContent.length };
2089
+ }
2090
+
2091
+ function getCachedParsedDiff(ctx: any, key: string, oldContent: string, newContent: string): ParsedDiff {
2092
+ if (ctx.state?._parsedDiffKey === key && ctx.state._parsedDiff) {
2093
+ return ctx.state._parsedDiff as ParsedDiff;
2094
+ }
2095
+ const diff = parseDiff(oldContent, newContent);
2096
+ if (ctx.state) {
2097
+ ctx.state._parsedDiffKey = key;
2098
+ ctx.state._parsedDiff = diff;
2099
+ }
2100
+ return diff;
2101
+ }
2102
+
2103
+ function wordDiffAnalysis(
2104
+ oldText: string,
2105
+ newText: string,
2106
+ ): { similarity: number; oldRanges: Array<[number, number]>; newRanges: Array<[number, number]> } {
2107
+ if (!oldText && !newText) return { similarity: 1, oldRanges: [], newRanges: [] };
2108
+ const parts = Diff.diffWords(oldText, newText);
2109
+ const oldRanges: Array<[number, number]> = [];
2110
+ const newRanges: Array<[number, number]> = [];
2111
+ let oldPos = 0;
2112
+ let newPos = 0;
2113
+ let same = 0;
2114
+ for (const part of parts) {
2115
+ if (part.removed) {
2116
+ oldRanges.push([oldPos, oldPos + part.value.length]);
2117
+ oldPos += part.value.length;
2118
+ } else if (part.added) {
2119
+ newRanges.push([newPos, newPos + part.value.length]);
2120
+ newPos += part.value.length;
2121
+ } else {
2122
+ const len = part.value.length;
2123
+ same += len;
2124
+ oldPos += len;
2125
+ newPos += len;
2126
+ }
2127
+ }
2128
+ const maxLen = Math.max(oldText.length, newText.length);
2129
+ return { similarity: maxLen > 0 ? same / maxLen : 1, oldRanges, newRanges };
2130
+ }
2131
+
2132
+ function injectBg(ansiLine: string, ranges: Array<[number, number]>, baseBg: string, hlBg: string): string {
2133
+ if (!ranges.length) return baseBg + ansiLine + D_RST;
2134
+ let out = baseBg;
2135
+ let vis = 0;
2136
+ let inHL = false;
2137
+ let rangeIndex = 0;
2138
+ let i = 0;
2139
+ while (i < ansiLine.length) {
2140
+ if (ansiLine[i] === "\x1b") {
2141
+ const end = ansiLine.indexOf("m", i);
2142
+ if (end !== -1) {
2143
+ const seq = ansiLine.slice(i, end + 1);
2144
+ out += seq;
2145
+ if (seq === "\x1b[0m") out += inHL ? hlBg : baseBg;
2146
+ i = end + 1;
2147
+ continue;
2148
+ }
2149
+ }
2150
+ while (rangeIndex < ranges.length && vis >= ranges[rangeIndex][1]) rangeIndex++;
2151
+ const want = rangeIndex < ranges.length && vis >= ranges[rangeIndex][0] && vis < ranges[rangeIndex][1];
2152
+ if (want !== inHL) {
2153
+ inHL = want;
2154
+ out += inHL ? hlBg : baseBg;
2155
+ }
2156
+ out += ansiLine[i];
2157
+ vis++;
2158
+ i++;
2159
+ }
2160
+ return out + D_RST;
2161
+ }
2162
+
2163
+ function plainWordDiff(oldText: string, newText: string): { old: string; new: string } {
2164
+ const parts = Diff.diffWords(oldText, newText);
2165
+ let oldOut = "";
2166
+ let newOut = "";
2167
+ for (const part of parts) {
2168
+ if (part.removed) oldOut += `${BG_DEL_W}${part.value}${D_RST}${BG_DEL}`;
2169
+ else if (part.added) newOut += `${BG_ADD_W}${part.value}${D_RST}${BG_ADD}`;
2170
+ else {
2171
+ oldOut += part.value;
2172
+ newOut += part.value;
2173
+ }
2174
+ }
2175
+ return { old: oldOut, new: newOut };
2176
+ }
2177
+
2178
+ async function renderUnified(
2179
+ diff: ParsedDiff,
2180
+ language: BundledLanguage | undefined,
2181
+ max = MAX_RENDER_LINES,
2182
+ dc: DiffColors = DEFAULT_DIFF_COLORS,
2183
+ width = termW(),
2184
+ ): Promise<string> {
2185
+ if (!diff.lines.length) return "";
2186
+ const vis = diff.lines.slice(0, max);
2187
+ const tw = width;
2188
+ const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
2189
+ const gw = nw + 5;
2190
+ const cw = Math.max(20, tw - gw);
2191
+ const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
2192
+
2193
+ const oldSrc: string[] = [];
2194
+ const newSrc: string[] = [];
2195
+ for (const line of vis) {
2196
+ if (line.type === "ctx" || line.type === "del") oldSrc.push(line.content);
2197
+ if (line.type === "ctx" || line.type === "add") newSrc.push(line.content);
2198
+ }
2199
+ const [oldHL, newHL] = canHL
2200
+ ? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
2201
+ : [oldSrc, newSrc];
2202
+
2203
+ let oldIndex = 0;
2204
+ let newIndex = 0;
2205
+ let index = 0;
2206
+ const out: string[] = [diffRule(tw)];
2207
+
2208
+ function emitRow(num: number | null, sign: string, gutterBg: string, signFg: string, body: string, bodyBg = ""): void {
2209
+ const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
2210
+ const border = borderFg ? `${borderFg}▌${D_RST}` : `${BG_BASE} `;
2211
+ const numFg = borderFg || FG_LNUM;
2212
+ const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${signFg}${sign}${D_RST} ${DIVIDER} `;
2213
+ const cont = `${border}${gutterBg}${" ".repeat(nw + 1)}${D_RST} ${DIVIDER} `;
2214
+ const rows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), bodyBg);
2215
+ out.push(`${gutter}${rows[0]}${D_RST}`);
2216
+ for (let r = 1; r < rows.length; r++) out.push(`${cont}${rows[r]}${D_RST}`);
2217
+ }
2218
+
2219
+ while (index < vis.length) {
2220
+ const line = vis[index];
2221
+ if (line.type === "sep") {
2222
+ const gap = line.newNum;
2223
+ const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
2224
+ const totalW = Math.min(tw, 72);
2225
+ const pad = Math.max(0, totalW - label.length - 2);
2226
+ const half1 = Math.floor(pad / 2);
2227
+ const half2 = pad - half1;
2228
+ out.push(`${BG_BASE}${FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${D_RST}`);
2229
+ index++;
2230
+ continue;
2231
+ }
2232
+ if (line.type === "ctx") {
2233
+ const hl = oldHL[oldIndex] ?? line.content;
2234
+ emitRow(line.newNum, " ", BG_BASE, dc.fgCtx, `${BG_BASE}${D_DIM}${hl}`, BG_BASE);
2235
+ oldIndex++;
2236
+ newIndex++;
2237
+ index++;
2238
+ continue;
2239
+ }
2240
+
2241
+ const dels: Array<{ l: DiffLine; hl: string }> = [];
2242
+ while (index < vis.length && vis[index].type === "del") {
2243
+ dels.push({ l: vis[index], hl: oldHL[oldIndex] ?? vis[index].content });
2244
+ oldIndex++;
2245
+ index++;
2246
+ }
2247
+ const adds: Array<{ l: DiffLine; hl: string }> = [];
2248
+ while (index < vis.length && vis[index].type === "add") {
2249
+ adds.push({ l: vis[index], hl: newHL[newIndex] ?? vis[index].content });
2250
+ newIndex++;
2251
+ index++;
2252
+ }
2253
+
2254
+ const isPaired = dels.length === 1 && adds.length === 1;
2255
+ const wd = isPaired ? wordDiffAnalysis(dels[0].l.content, adds[0].l.content) : null;
2256
+ if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
2257
+ emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${D_BOLD}`, injectBg(dels[0].hl, wd.oldRanges, BG_DEL, BG_DEL_W), BG_DEL);
2258
+ emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${D_BOLD}`, injectBg(adds[0].hl, wd.newRanges, BG_ADD, BG_ADD_W), BG_ADD);
2259
+ continue;
2260
+ }
2261
+ if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
2262
+ const pwd = plainWordDiff(dels[0].l.content, adds[0].l.content);
2263
+ emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${D_BOLD}`, `${BG_DEL}${pwd.old}`, BG_DEL);
2264
+ emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${D_BOLD}`, `${BG_ADD}${pwd.new}`, BG_ADD);
2265
+ continue;
2266
+ }
2267
+ for (const d of dels) emitRow(d.l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${D_BOLD}`, `${BG_DEL}${canHL ? d.hl : d.l.content}`, BG_DEL);
2268
+ for (const a of adds) emitRow(a.l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${D_BOLD}`, `${BG_ADD}${canHL ? a.hl : a.l.content}`, BG_ADD);
2269
+ }
2270
+
2271
+ out.push(diffRule(tw));
2272
+ if (diff.lines.length > vis.length) out.push(`${BG_BASE}${FG_DIM} ${collapsedDiffHint(diff.lines.length - vis.length, 0)}${D_RST}`);
2273
+ return out.join("\n");
2274
+ }
2275
+
2276
+ async function renderSplit(
2277
+ diff: ParsedDiff,
2278
+ language: BundledLanguage | undefined,
2279
+ max = MAX_PREVIEW_LINES,
2280
+ dc: DiffColors = DEFAULT_DIFF_COLORS,
2281
+ width = termW(),
2282
+ ): Promise<string> {
2283
+ const tw = width;
2284
+ if (!shouldUseSplit(diff, tw, max)) return renderUnified(diff, language, max, dc, width);
2285
+ if (!diff.lines.length) return "";
2286
+
2287
+ type Row = { left: DiffLine | null; right: DiffLine | null };
2288
+ const rows: Row[] = [];
2289
+ let i = 0;
2290
+ while (i < diff.lines.length) {
2291
+ const line = diff.lines[i];
2292
+ if (line.type === "sep" || line.type === "ctx") {
2293
+ rows.push({ left: line, right: line });
2294
+ i++;
2295
+ continue;
2296
+ }
2297
+ const dels: DiffLine[] = [];
2298
+ const adds: DiffLine[] = [];
2299
+ while (i < diff.lines.length && diff.lines[i].type === "del") dels.push(diff.lines[i++]);
2300
+ while (i < diff.lines.length && diff.lines[i].type === "add") adds.push(diff.lines[i++]);
2301
+ const n = Math.max(dels.length, adds.length);
2302
+ for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
2303
+ }
2304
+
2305
+ const vis = rows.slice(0, max);
2306
+ const half = Math.floor((tw - 1) / 2);
2307
+ const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
2308
+ const gw = nw + 5;
2309
+ const cw = Math.max(12, half - gw);
2310
+ const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
2311
+
2312
+ const leftSrc: string[] = [];
2313
+ const rightSrc: string[] = [];
2314
+ for (const row of vis) {
2315
+ if (row.left && row.left.type !== "sep") leftSrc.push(row.left.content);
2316
+ if (row.right && row.right.type !== "sep") rightSrc.push(row.right.content);
2317
+ }
2318
+ const [leftHL, rightHL] = canHL
2319
+ ? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
2320
+ : [leftSrc, rightSrc];
2321
+
2322
+ let leftIndex = 0;
2323
+ let rightIndex = 0;
2324
+
2325
+ type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] };
2326
+ function halfBuild(
2327
+ line: DiffLine | null,
2328
+ hl: string,
2329
+ ranges: Array<[number, number]> | null,
2330
+ side: "left" | "right",
2331
+ ): HalfResult {
2332
+ if (!line) {
2333
+ const gPat = FG_STRIPE + "╱".repeat(nw + 2) + D_RST;
2334
+ const gutter = ` ${gPat}${FG_RULE}│${D_RST} `;
2335
+ return { gutter, contGutter: gutter, bodyRows: [stripes(cw)] };
2336
+ }
2337
+ if (line.type === "sep") {
2338
+ const gap = line.newNum;
2339
+ const label = gap && gap > 0 ? `··· ${gap} lines ···` : "···";
2340
+ const gutter = `${BG_BASE} ${FG_DIM}${fit("", nw + 2)}${D_RST}${FG_RULE}│${D_RST} `;
2341
+ return { gutter, contGutter: gutter, bodyRows: [`${BG_BASE}${FG_DIM}${fit(label, cw)}${D_RST}`] };
2342
+ }
2343
+ const isDel = line.type === "del";
2344
+ const isAdd = line.type === "add";
2345
+ const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_BASE;
2346
+ const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_BASE;
2347
+ const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
2348
+ const sign = isDel ? "-" : isAdd ? "+" : " ";
2349
+ const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
2350
+ const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
2351
+ const border = borderFg ? `${borderFg}▌${D_RST}` : ` ${BG_BASE}`;
2352
+ const numFg = borderFg || FG_LNUM;
2353
+ let body: string;
2354
+ if (ranges && ranges.length > 0) body = injectBg(hl, ranges, cBg, isDel ? BG_DEL_W : BG_ADD_W);
2355
+ else if (isDel || isAdd) body = `${cBg}${hl}`;
2356
+ else body = `${BG_BASE}${D_DIM}${hl}`;
2357
+ const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${sFg}${D_BOLD}${sign}${D_RST} ${FG_RULE}│${D_RST} `;
2358
+ const contGutter = `${border}${gBg}${" ".repeat(nw + 1)}${D_RST} ${FG_RULE}│${D_RST} `;
2359
+ return { gutter, contGutter, bodyRows: wrapAnsi(tabs(body), cw, adaptiveWrapRows(), cBg) };
2360
+ }
2361
+
2362
+ const out: string[] = [];
2363
+ const hdrOld = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgDel}${D_DIM}old${D_RST}`;
2364
+ const hdrNew = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgAdd}${D_DIM}new${D_RST}`;
2365
+ out.push(`${BG_BASE}${hdrOld}${" ".repeat(Math.max(0, half - nw - 1))}${FG_RULE}┊${D_RST}${hdrNew}`);
2366
+ out.push(`${diffRule(half)}${FG_RULE}┊${D_RST}${diffRule(half)}`);
2367
+
2368
+ for (const row of vis) {
2369
+ const leftLine = row.left;
2370
+ const rightLine = row.right;
2371
+ const paired = Boolean(leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add");
2372
+ const wd = paired && leftLine && rightLine ? wordDiffAnalysis(leftLine.content, rightLine.content) : null;
2373
+ let leftResult: HalfResult;
2374
+ let rightResult: HalfResult;
2375
+ if (paired && wd && leftLine && rightLine && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
2376
+ leftResult = halfBuild(leftLine, leftHL[leftIndex++] ?? leftLine.content, wd.oldRanges, "left");
2377
+ rightResult = halfBuild(rightLine, rightHL[rightIndex++] ?? rightLine.content, wd.newRanges, "right");
2378
+ } else if (paired && wd && leftLine && rightLine && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
2379
+ const pwd = plainWordDiff(leftLine.content, rightLine.content);
2380
+ leftIndex++;
2381
+ rightIndex++;
2382
+ leftResult = halfBuild(leftLine, pwd.old, null, "left");
2383
+ rightResult = halfBuild(rightLine, pwd.new, null, "right");
2384
+ } else {
2385
+ leftResult = halfBuild(
2386
+ row.left,
2387
+ row.left && row.left.type !== "sep" ? (leftHL[leftIndex++] ?? row.left.content) : "",
2388
+ null,
2389
+ "left",
2390
+ );
2391
+ rightResult = halfBuild(
2392
+ row.right,
2393
+ row.right && row.right.type !== "sep" ? (rightHL[rightIndex++] ?? row.right.content) : "",
2394
+ null,
2395
+ "right",
2396
+ );
2397
+ }
2398
+ const maxRows = Math.max(leftResult.bodyRows.length, rightResult.bodyRows.length);
2399
+ for (let rowIndex = 0; rowIndex < maxRows; rowIndex++) {
2400
+ const lg = rowIndex === 0 ? leftResult.gutter : leftResult.contGutter;
2401
+ const rg = rowIndex === 0 ? rightResult.gutter : rightResult.contGutter;
2402
+ const lb = leftResult.bodyRows[rowIndex] ?? (!row.left ? stripes(cw) : `${BG_EMPTY}${" ".repeat(cw)}${D_RST}`);
2403
+ const rb = rightResult.bodyRows[rowIndex] ?? (!row.right ? stripes(cw) : `${BG_EMPTY}${" ".repeat(cw)}${D_RST}`);
2404
+ out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`);
2405
+ }
2406
+ }
2407
+
2408
+ out.push(`${diffRule(half)}${FG_RULE}┊${D_RST}${diffRule(half)}`);
2409
+ if (rows.length > vis.length) out.push(`${BG_BASE}${FG_DIM} ${collapsedDiffHint(rows.length - vis.length, 0)}${D_RST}`);
2410
+ return out.join("\n");
2411
+ }
2412
+
2413
+ function getEditOperations(input: any): Array<{ oldText: string; newText: string }> {
2414
+ if (Array.isArray(input?.edits)) {
2415
+ return input.edits
2416
+ .map((edit: any) => ({
2417
+ oldText: typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
2418
+ newText: typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
2419
+ }))
2420
+ .filter((edit: { oldText: string; newText: string }) => edit.oldText && edit.oldText !== edit.newText);
2421
+ }
2422
+ const oldText = typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
2423
+ const newText = typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
2424
+ return oldText && oldText !== newText ? [{ oldText, newText }] : [];
2425
+ }
2426
+
2427
+ function summarizeEditOperations(operations: Array<{ oldText: string; newText: string }>) {
2428
+ const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText));
2429
+ const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0);
2430
+ const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0);
2431
+ const totalLines = diffs.reduce((sum, diff) => sum + diff.lines.length, 0);
2432
+ const totalHunks = diffs.reduce((sum, diff) => sum + diff.lines.filter((l) => l.type === "sep").length + (diff.lines.length ? 1 : 0), 0);
2433
+ return { diffs, totalAdded, totalRemoved, totalLines, totalHunks, summary: summarizeDiff(totalAdded, totalRemoved) };
2434
+ }
2435
+
2436
+ type EditOperationSummary = ReturnType<typeof summarizeEditOperations>;
2437
+
2438
+ function getCachedEditOperationSummary(ctx: any, key: string, operations: Array<{ oldText: string; newText: string }>): EditOperationSummary {
2439
+ if (ctx.state?._editSummaryKey === key && ctx.state._editSummary) {
2440
+ return ctx.state._editSummary as EditOperationSummary;
2441
+ }
2442
+ const summary = summarizeEditOperations(operations);
2443
+ if (ctx.state) {
2444
+ ctx.state._editSummaryKey = key;
2445
+ ctx.state._editSummary = summary;
2446
+ }
2447
+ return summary;
2448
+ }
2449
+
2450
+ function normalizeToLf(text: string): string {
2451
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2452
+ }
2453
+
2454
+ function stripBomText(text: string): string {
2455
+ return text.startsWith("\uFEFF") ? text.slice(1) : text;
2456
+ }
2457
+
2458
+ function normalizeTextForFuzzyMatch(text: string): string {
2459
+ return text
2460
+ .normalize("NFKC")
2461
+ .split("\n")
2462
+ .map((line) => line.trimEnd())
2463
+ .join("\n")
2464
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
2465
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
2466
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
2467
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
2468
+ }
2469
+
2470
+ function findEditMatch(content: string, oldText: string): { found: boolean; index: number; matchLength: number; usedFuzzyMatch: boolean } {
2471
+ const exactIndex = content.indexOf(oldText);
2472
+ if (exactIndex !== -1) return { found: true, index: exactIndex, matchLength: oldText.length, usedFuzzyMatch: false };
2473
+ const fuzzyContent = normalizeTextForFuzzyMatch(content);
2474
+ const fuzzyOldText = normalizeTextForFuzzyMatch(oldText);
2475
+ const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
2476
+ return fuzzyIndex === -1
2477
+ ? { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false }
2478
+ : { found: true, index: fuzzyIndex, matchLength: fuzzyOldText.length, usedFuzzyMatch: true };
2479
+ }
2480
+
2481
+ function countFuzzyOccurrences(content: string, oldText: string): number {
2482
+ const fuzzyContent = normalizeTextForFuzzyMatch(content);
2483
+ const fuzzyOldText = normalizeTextForFuzzyMatch(oldText);
2484
+ return fuzzyContent.split(fuzzyOldText).length - 1;
2485
+ }
2486
+
2487
+ function lineNumberAtIndex(text: string, index: number): number {
2488
+ return text.slice(0, Math.max(0, index)).split("\n").length;
2489
+ }
2490
+
2491
+ function countLineBreaks(text: string): number {
2492
+ return (text.match(/\n/g) ?? []).length;
2493
+ }
2494
+
2495
+ function offsetParsedDiff(diff: ParsedDiff, oldOffset: number, newOffset = oldOffset): ParsedDiff {
2496
+ return {
2497
+ ...diff,
2498
+ lines: diff.lines.map((line) =>
2499
+ line.type === "sep"
2500
+ ? line
2501
+ : {
2502
+ ...line,
2503
+ oldNum: line.oldNum === null ? null : line.oldNum + oldOffset,
2504
+ newNum: line.newNum === null ? null : line.newNum + newOffset,
2505
+ },
2506
+ ),
2507
+ };
2508
+ }
2509
+
2510
+ function getFirstChangedNewLine(diff: ParsedDiff): number {
2511
+ let currentNewLine = 0;
2512
+ for (let i = 0; i < diff.lines.length; i++) {
2513
+ const line = diff.lines[i];
2514
+ if (line.type === "sep") {
2515
+ currentNewLine = 0;
2516
+ continue;
2517
+ }
2518
+ if (line.type === "ctx") {
2519
+ currentNewLine = (line.newNum ?? currentNewLine) + 1;
2520
+ continue;
2521
+ }
2522
+ if (line.type === "add") return line.newNum ?? currentNewLine;
2523
+ if (currentNewLine > 0) return currentNewLine;
2524
+ const next = diff.lines.slice(i + 1).find((entry) => entry.type !== "sep" && entry.newNum !== null);
2525
+ if (next && next.newNum !== null) return next.newNum;
2526
+ return line.oldNum ?? 0;
2527
+ }
2528
+ return 0;
2529
+ }
2530
+
2531
+ interface LocalizedEditDiff {
2532
+ diff: ParsedDiff;
2533
+ line: number;
2534
+ }
2535
+
2536
+ async function computeLocalizedEditDiffs(filePath: string, operations: Array<{ oldText: string; newText: string }>, cwd: string): Promise<LocalizedEditDiff[] | null> {
2537
+ if (!filePath || operations.length === 0) return null;
2538
+ try {
2539
+ const rawContent = await readFileAsync(resolve(cwd, filePath), "utf8");
2540
+ const normalizedContent = normalizeToLf(stripBomText(rawContent));
2541
+ const normalizedOps = operations.map((edit) => ({ oldText: normalizeToLf(edit.oldText), newText: normalizeToLf(edit.newText) }));
2542
+ const baseContent = normalizedOps.some((edit) => findEditMatch(normalizedContent, edit.oldText).usedFuzzyMatch)
2543
+ ? normalizeTextForFuzzyMatch(normalizedContent)
2544
+ : normalizedContent;
2545
+ const matches = normalizedOps.map((edit, editIndex) => {
2546
+ const match = findEditMatch(baseContent, edit.oldText);
2547
+ if (!match.found || countFuzzyOccurrences(baseContent, edit.oldText) !== 1) return null;
2548
+ return { editIndex, matchIndex: match.index, matchLength: match.matchLength, newText: edit.newText };
2549
+ });
2550
+ if (matches.some((match) => match === null)) return null;
2551
+ const ordered = [...(matches as Array<{ editIndex: number; matchIndex: number; matchLength: number; newText: string }>)].sort((a, b) => a.matchIndex - b.matchIndex);
2552
+ for (let i = 1; i < ordered.length; i++) {
2553
+ const prev = ordered[i - 1];
2554
+ const current = ordered[i];
2555
+ if (prev.matchIndex + prev.matchLength > current.matchIndex) return null;
2556
+ }
2557
+ const localized: Array<LocalizedEditDiff | null> = Array(operations.length).fill(null);
2558
+ let lineDelta = 0;
2559
+ for (const match of ordered) {
2560
+ const oldChunk = baseContent.slice(match.matchIndex, match.matchIndex + match.matchLength);
2561
+ const oldStartLine = lineNumberAtIndex(baseContent, match.matchIndex);
2562
+ const newStartLine = oldStartLine + lineDelta;
2563
+ const diff = offsetParsedDiff(parseDiff(oldChunk, match.newText), oldStartLine - 1, newStartLine - 1);
2564
+ localized[match.editIndex] = { diff, line: getFirstChangedNewLine(diff) };
2565
+ lineDelta += countLineBreaks(match.newText) - countLineBreaks(oldChunk);
2566
+ }
2567
+ return localized.every(Boolean) ? (localized as LocalizedEditDiff[]) : null;
2568
+ } catch {
2569
+ return null;
2570
+ }
2571
+ }
2572
+
2573
+ function renderEditPreviewBody(
2574
+ ctx: any,
2575
+ key: string,
2576
+ theme: Theme,
2577
+ language: BundledLanguage | undefined,
2578
+ operations: Array<{ oldText: string; newText: string }>,
2579
+ diffs: ParsedDiff[],
2580
+ lines: number[],
2581
+ summary: string,
2582
+ ): void {
2583
+ const dc = resolveDiffColors(theme);
2584
+ const branchWidth = branchDiffWidth();
2585
+ if (operations.length === 1) {
2586
+ const [diff] = diffs;
2587
+ const line = lines[0] ?? getFirstChangedNewLine(diff);
2588
+ renderSplit(diff, language, ctx.expanded ? MAX_PREVIEW_LINES : 32, dc, branchWidth)
2589
+ .then((rendered) => {
2590
+ if (ctx.state._pk !== key) return;
2591
+ ctx.state._ptBody = `${summarizeDiff(diff.added, diff.removed)}${formatLineMeta(line, theme)}\n${rendered}`;
2592
+ ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
2593
+ ctx.invalidate();
2594
+ })
2595
+ .catch(() => {
2596
+ if (ctx.state._pk !== key) return;
2597
+ ctx.state._ptBody = `${summarizeDiff(diff.added, diff.removed)}${formatLineMeta(line, theme)}`;
2598
+ ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
2599
+ ctx.invalidate();
2600
+ });
2601
+ return;
2602
+ }
2603
+ const maxShown = ctx.expanded ? operations.length : Math.min(operations.length, 3);
2604
+ const previewLines = ctx.expanded
2605
+ ? Math.max(6, Math.floor(MAX_RENDER_LINES / Math.max(1, maxShown)))
2606
+ : Math.max(8, Math.floor(MAX_PREVIEW_LINES / Math.max(1, maxShown)));
2607
+ Promise.all(
2608
+ diffs.slice(0, maxShown).map((diff, index) => {
2609
+ const line = lines[index] ?? getFirstChangedNewLine(diff);
2610
+ return renderSplit(diff, language, previewLines, dc, branchWidth)
2611
+ .then((rendered) => `Edit ${index + 1}/${operations.length}${formatLineMeta(line, theme)}\n${rendered}`)
2612
+ .catch(() => `Edit ${index + 1}/${operations.length}${formatLineMeta(line, theme)} ${summarizeDiff(diff.added, diff.removed)}`);
2613
+ }),
2614
+ )
2615
+ .then((sections) => {
2616
+ if (ctx.state._pk !== key) return;
2617
+ const remainder = operations.length - maxShown;
2618
+ const suffix = remainder > 0
2619
+ ? `\n${theme.fg("muted", `… ${remainder} more edit blocks${ctx.expanded ? "" : " • Ctrl+O to expand"}`)}`
2620
+ : "";
2621
+ ctx.state._ptBody = `${operations.length} edits ${summary}\n\n${sections.join("\n\n")}${suffix}`;
2622
+ ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
2623
+ ctx.invalidate();
2624
+ })
2625
+ .catch(() => {
2626
+ if (ctx.state._pk !== key) return;
2627
+ ctx.state._ptBody = `${operations.length} edits ${summary}`;
2628
+ ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
2629
+ ctx.invalidate();
2630
+ });
2631
+ }
2632
+
2633
+ function stripThinkingPresentationArtifacts(text: string): string {
2634
+ if (!ANSI_PRESENT_RE.test(text) && !/^\s*thinking:\s*/i.test(text)) return text;
2635
+ let current = ANSI_PRESENT_RE.test(text) ? text.replace(ANSI_RE, "") : text;
2636
+ while (true) {
2637
+ const next = current.replace(/^(?:thinking:\s*)+/i, "").trimStart();
2638
+ if (next === current) return current;
2639
+ current = next;
2640
+ }
2641
+ }
2642
+
2643
+ function prefixThinkingLine(text: string, _theme: Theme | undefined): string {
2644
+ if (!ANSI_PRESENT_RE.test(text) && text.startsWith("Thinking: ") && !/^Thinking:\s*thinking:\s*/i.test(text)) {
2645
+ return text;
2646
+ }
2647
+ const normalized = stripThinkingPresentationArtifacts(text).trim();
2648
+ if (!normalized) return text;
2649
+ // Plain text — no ANSI colors, no theme. The ThinkingParagraph handles styling.
2650
+ return `Thinking: ${normalized}`;
2651
+ }
2652
+
2653
+ function registerThinkingLabels(pi: ExtensionAPI): void {
2654
+ const patchMessage = (event: any, theme?: Theme) => {
2655
+ // Keep theme-derived border / dim text colors in sync with the
2656
+ // active pi theme. Cheap when the theme hasn't changed (identity check).
2657
+ if (theme) applyThemePaletteIfNeeded(theme);
2658
+ const message = event?.message;
2659
+ if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
2660
+ for (const block of message.content) {
2661
+ if (block && block.type === "thinking" && typeof block.thinking === "string") {
2662
+ block.thinking = prefixThinkingLine(block.thinking, theme);
2663
+ }
2664
+ }
2665
+ };
2666
+ pi.on("before_agent_start", async () => {
2667
+ // Start once per top-level request. Steering/follow-up messages can be
2668
+ // injected while the agent is already active; those must not reset the
2669
+ // request timer.
2670
+ if (currentAgentWorkStartMs === undefined) {
2671
+ currentAgentWorkStartMs = Date.now();
2672
+ }
2673
+ currentAssistantMessageStartMs = undefined;
2674
+ });
2675
+ pi.on("agent_start", async () => {
2676
+ if (currentAgentWorkStartMs === undefined) {
2677
+ currentAgentWorkStartMs = Date.now();
2678
+ }
2679
+ currentAssistantMessageStartMs = undefined;
2680
+ });
2681
+ pi.on("message_start", async (event: any) => {
2682
+ const message = event?.message;
2683
+ if (message?.role === "user" && currentAgentWorkStartMs === undefined) {
2684
+ currentAgentWorkStartMs = Date.now();
2685
+ }
2686
+ if (message?.role === "assistant") {
2687
+ currentAssistantMessageStartMs = Date.now();
2688
+ (message as any)[WORKED_START_KEY] = currentAssistantMessageStartMs;
2689
+ }
2690
+ });
2691
+ pi.on("message_update", async (event, ctx) => patchMessage(event, ctx.ui?.theme));
2692
+ pi.on("message_end", async (event, ctx) => {
2693
+ const message = (event as any)?.message;
2694
+ if (message?.role === "assistant") {
2695
+ const started = typeof currentAgentWorkStartMs === "number"
2696
+ ? currentAgentWorkStartMs
2697
+ : typeof (message as any)[WORKED_START_KEY] === "number"
2698
+ ? (message as any)[WORKED_START_KEY]
2699
+ : currentAssistantMessageStartMs;
2700
+ const isFinalAssistantMessage = message.stopReason !== "toolUse";
2701
+ if (started !== undefined && isFinalAssistantMessage) {
2702
+ const durationMs = Date.now() - started;
2703
+ (message as any)[WORKED_DURATION_KEY] = durationMs;
2704
+ // Mutate the message itself before pi renders/persists it. This is more
2705
+ // reliable than the spinner because pi removes the loader on agent_end,
2706
+ // and more reliable than component monkey-patching when extensions are
2707
+ // loaded from a different package instance than the running TUI.
2708
+ appendWorkedDurationLine(message, durationMs);
2709
+ }
2710
+ currentAssistantMessageStartMs = undefined;
2711
+ }
2712
+ patchMessage(event, ctx.ui?.theme);
2713
+ });
2714
+ pi.on("agent_end", async () => {
2715
+ currentAgentWorkStartMs = undefined;
2716
+ currentAssistantMessageStartMs = undefined;
2717
+ });
2718
+ pi.on("context", async (event) => {
2719
+ if (!Array.isArray((event as any).messages)) return;
2720
+ for (const msg of (event as any).messages) {
2721
+ if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
2722
+ for (const block of msg.content) {
2723
+ if (block && block.type === "thinking" && typeof block.thinking === "string") {
2724
+ block.thinking = stripThinkingPresentationArtifacts(block.thinking);
2725
+ }
2726
+ if (block && block.type === "text" && typeof block.text === "string") {
2727
+ block.text = stripWorkedDurationLine(block.text);
2728
+ }
2729
+ }
2730
+ }
2731
+ });
2732
+ }
2733
+
2734
+ function getMode<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
2735
+ return typeof value === "string" && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
2736
+ }
2737
+
2738
+ const CORE_TOOL_OVERRIDES = new Set(["read", "bash", "grep", "find", "ls", "write", "edit"]);
2739
+
2740
+ const OPENAI_STYLE_TOOL_NAMES = new Set([
2741
+ "apply_patch",
2742
+ "webfetch",
2743
+ "question",
2744
+ "questionnaire",
2745
+ "context_tag",
2746
+ "context_log",
2747
+ "context_checkout",
2748
+ "annotate",
2749
+ "web_search",
2750
+ "code_search",
2751
+ "fetch_content",
2752
+ "get_search_content",
2753
+ "alpha_search",
2754
+ "alpha_get_paper",
2755
+ "alpha_ask_paper",
2756
+ "alpha_annotate_paper",
2757
+ "alpha_list_annotations",
2758
+ "alpha_read_code",
2759
+ "Skill",
2760
+ "EnterPlanMode",
2761
+ "ExitPlanMode",
2762
+ "Agent",
2763
+ "get_subagent_result",
2764
+ "steer_subagent",
2765
+ "TaskCreate",
2766
+ "TaskList",
2767
+ "TaskGet",
2768
+ "TaskUpdate",
2769
+ "TaskOutput",
2770
+ "TaskStop",
2771
+ "TaskExecute",
2772
+ ]);
2773
+
2774
+ function isMcpToolCandidate(tool: unknown): boolean {
2775
+ const rec = tool as Record<string, unknown> | undefined;
2776
+ const name = typeof rec?.name === "string" ? rec.name : "";
2777
+ const description = typeof rec?.description === "string" ? rec.description : "";
2778
+ return name === "mcp" || /\bmcp\b/i.test(description);
2779
+ }
2780
+
2781
+ function isOpenAiToolCandidate(tool: unknown): boolean {
2782
+ const rec = tool as Record<string, unknown> | undefined;
2783
+ const name = typeof rec?.name === "string" ? rec.name : "";
2784
+ if (!name || CORE_TOOL_OVERRIDES.has(name) || isMcpToolCandidate(tool)) return false;
2785
+ return OPENAI_STYLE_TOOL_NAMES.has(name);
2786
+ }
2787
+
2788
+ function humanizeToolName(name: string): string {
2789
+ return name
2790
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
2791
+ .replace(/[_-]+/g, " ")
2792
+ .replace(/\b\w/g, (char) => char.toUpperCase());
2793
+ }
2794
+
2795
+ function isMcpToolName(name: string): boolean {
2796
+ return name === "mcp" || /^mcp[_:-]/i.test(name) || /[_:-]mcp[_:-]/i.test(name);
2797
+ }
2798
+
2799
+ function shouldUseGenericToolRenderer(name: unknown): boolean {
2800
+ return typeof name === "string" && name.length > 0 && !CORE_TOOL_OVERRIDES.has(name);
2801
+ }
2802
+
2803
+ function genericToolLabel(name: string): string {
2804
+ return isMcpToolName(name) ? "MCP" : humanizeToolName(name);
2805
+ }
2806
+
2807
+ function renderGenericToolCall(name: string, args: any, theme: Theme, ctx: any): Text {
2808
+ syncToolCallStatus(ctx);
2809
+ ctx.state._openAiPatchFiles = [];
2810
+ const sp = (path: string) => shortPath(ctx.cwd ?? process.cwd(), path);
2811
+ const summary = stableCallSummary(ctx, "_callSummary", () => summarizeGenericToolCall(name, args, theme, sp));
2812
+ return makeText(ctx.lastComponent, toolHeader(genericToolLabel(name), summary, theme, toolStatusDot(ctx, theme)));
2813
+ }
2814
+
2815
+ function renderGenericToolResult(name: string, result: any, options: any, theme: Theme, ctx: any): Text {
2816
+ if (isMcpToolName(name)) {
2817
+ return renderMcpToolResult(result, !!options?.expanded, !!options?.isPartial, theme, ctx);
2818
+ }
2819
+ return renderOpenAiToolResult(
2820
+ name,
2821
+ { content: result.content, details: result.details },
2822
+ !!options?.expanded,
2823
+ !!options?.isPartial,
2824
+ theme,
2825
+ ctx,
2826
+ );
2827
+ }
2828
+
2829
+ function getTextContent(result: any): string {
2830
+ if (!Array.isArray(result?.content)) return "";
2831
+ return result.content
2832
+ .filter((block: any) => block?.type === "text" && typeof block.text === "string")
2833
+ .map((block: any) => block.text)
2834
+ .join("\n");
2835
+ }
2836
+
2837
+ function getStringArg(args: any, ...keys: string[]): string {
2838
+ for (const key of keys) {
2839
+ const value = args?.[key];
2840
+ if (typeof value === "string" && value.trim()) return value.trim();
2841
+ }
2842
+ return "";
2843
+ }
2844
+
2845
+ function getStringArrayArg(args: any, ...keys: string[]): string[] {
2846
+ for (const key of keys) {
2847
+ const value = args?.[key];
2848
+ if (!Array.isArray(value)) continue;
2849
+ const items = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
2850
+ if (items.length > 0) return items;
2851
+ }
2852
+ return [];
2853
+ }
2854
+
2855
+ function extractApplyPatchFiles(patchText: string): string[] {
2856
+ if (!patchText) return [];
2857
+ const files = new Set<string>();
2858
+ for (const match of patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) {
2859
+ const filePath = match[1]?.trim();
2860
+ if (filePath) files.add(filePath);
2861
+ }
2862
+ return [...files];
2863
+ }
2864
+
2865
+ interface ApplyPatchChangePreview {
2866
+ kind: "add" | "update" | "delete";
2867
+ path: string;
2868
+ displayPath: string;
2869
+ moveTo?: string;
2870
+ diff: ParsedDiff;
2871
+ language: BundledLanguage | undefined;
2872
+ hunks: number;
2873
+ summary: string;
2874
+ line: number;
2875
+ }
2876
+
2877
+ interface ApplyPatchPreview {
2878
+ changes: ApplyPatchChangePreview[];
2879
+ totalAdded: number;
2880
+ totalRemoved: number;
2881
+ totalHunks: number;
2882
+ totalLines: number;
2883
+ summary: string;
2884
+ }
2885
+
2886
+ interface ApplyPatchResultMeta {
2887
+ changeCount: number;
2888
+ totalAdded: number;
2889
+ totalRemoved: number;
2890
+ totalHunks: number;
2891
+ totalLines: number;
2892
+ firstChange?: {
2893
+ displayPath: string;
2894
+ kind: ApplyPatchChangePreview["kind"];
2895
+ hunks: number;
2896
+ line: number;
2897
+ added: number;
2898
+ removed: number;
2899
+ };
2900
+ }
2901
+
2902
+ function buildApplyPatchResultMeta(preview: ApplyPatchPreview): ApplyPatchResultMeta {
2903
+ const firstChange = preview.changes[0];
2904
+ return {
2905
+ changeCount: preview.changes.length,
2906
+ totalAdded: preview.totalAdded,
2907
+ totalRemoved: preview.totalRemoved,
2908
+ totalHunks: preview.totalHunks,
2909
+ totalLines: preview.totalLines,
2910
+ firstChange: firstChange
2911
+ ? {
2912
+ displayPath: firstChange.displayPath,
2913
+ kind: firstChange.kind,
2914
+ hunks: firstChange.hunks,
2915
+ line: firstChange.line,
2916
+ added: firstChange.diff.added,
2917
+ removed: firstChange.diff.removed,
2918
+ }
2919
+ : undefined,
2920
+ };
2921
+ }
2922
+
2923
+ function countDiffHunks(diff: ParsedDiff): number {
2924
+ return diff.lines.length === 0 ? 0 : diff.lines.filter((line) => line.type === "sep").length + 1;
2925
+ }
2926
+
2927
+ function getApplyPatchLine(diff: ParsedDiff, kind: ApplyPatchChangePreview["kind"]): number {
2928
+ if (kind === "add") {
2929
+ return diff.lines.find((line) => line.type === "add" && line.newNum !== null)?.newNum ?? 1;
2930
+ }
2931
+ if (kind === "delete") {
2932
+ return diff.lines.find((line) => line.type === "del" && line.oldNum !== null)?.oldNum ?? 1;
2933
+ }
2934
+ for (const line of diff.lines) {
2935
+ if (line.type === "add" && line.newNum !== null) return line.newNum;
2936
+ if (line.type === "del" && line.oldNum !== null) return line.oldNum;
2937
+ }
2938
+ return 0;
2939
+ }
2940
+
2941
+ function parsePatchBodyLine(rawLine: string): { marker: "+" | "-" | " "; content: string } {
2942
+ const marker = rawLine[0];
2943
+ if (marker === "+" || marker === "-" || marker === " ") return { marker, content: rawLine.slice(1) };
2944
+ return { marker: " ", content: rawLine };
2945
+ }
2946
+
2947
+ function findLineSequence(haystack: string[], needle: string[], fromIndex = 0): number {
2948
+ if (needle.length === 0) return Math.max(0, fromIndex);
2949
+ outer: for (let i = Math.max(0, fromIndex); i <= haystack.length - needle.length; i++) {
2950
+ for (let j = 0; j < needle.length; j++) {
2951
+ if (haystack[i + j] !== needle[j]) continue outer;
2952
+ }
2953
+ return i;
2954
+ }
2955
+ return -1;
2956
+ }
2957
+
2958
+ function inferApplyPatchHunkStarts(lines: string[], sourceContent: string): Array<{ oldStart: number | null; newStart: number | null }> {
2959
+ const sourceLines = normalizeToLf(sourceContent).split("\n");
2960
+ const hunks: string[][] = [];
2961
+ let currentHunk: string[] | null = null;
2962
+ for (const rawLine of lines) {
2963
+ if (rawLine.startsWith("*** Move to: ")) continue;
2964
+ if (rawLine.startsWith("@@")) {
2965
+ if (currentHunk) hunks.push(currentHunk);
2966
+ currentHunk = [];
2967
+ continue;
2968
+ }
2969
+ if (!currentHunk) currentHunk = [];
2970
+ currentHunk.push(rawLine);
2971
+ }
2972
+ if (currentHunk) hunks.push(currentHunk);
2973
+
2974
+ const starts: Array<{ oldStart: number | null; newStart: number | null }> = [];
2975
+ let searchFrom = 0;
2976
+ let lineDelta = 0;
2977
+ for (const hunk of hunks) {
2978
+ const oldLines = hunk
2979
+ .map((rawLine) => parsePatchBodyLine(rawLine))
2980
+ .filter((line) => line.marker !== "+")
2981
+ .map((line) => line.content);
2982
+ let matchIndex = findLineSequence(sourceLines, oldLines, searchFrom);
2983
+ if (matchIndex === -1) matchIndex = findLineSequence(sourceLines, oldLines, 0);
2984
+ const oldStart = matchIndex === -1 ? null : matchIndex + 1;
2985
+ const newStart = oldStart === null ? null : oldStart + lineDelta;
2986
+ starts.push({ oldStart, newStart });
2987
+ if (matchIndex === -1) continue;
2988
+ searchFrom = matchIndex + oldLines.length;
2989
+ const added = hunk.filter((rawLine) => parsePatchBodyLine(rawLine).marker === "+").length;
2990
+ const removed = hunk.filter((rawLine) => parsePatchBodyLine(rawLine).marker === "-").length;
2991
+ lineDelta += added - removed;
2992
+ }
2993
+ return starts;
2994
+ }
2995
+
2996
+ function stripPatchLinePrefix(line: string, prefix: "+" | "-"): string {
2997
+ return line.startsWith(prefix) ? line.slice(1) : line;
2998
+ }
2999
+
3000
+ function trimDiffSeparators(lines: DiffLine[]): DiffLine[] {
3001
+ const trimmed = [...lines];
3002
+ while (trimmed[0]?.type === "sep") trimmed.shift();
3003
+ while (trimmed[trimmed.length - 1]?.type === "sep") trimmed.pop();
3004
+ return trimmed;
3005
+ }
3006
+
3007
+ function parseApplyPatchUpdateDiff(lines: string[], sourceContent?: string): ParsedDiff {
3008
+ const diffLines: DiffLine[] = [];
3009
+ let added = 0;
3010
+ let removed = 0;
3011
+ let chars = 0;
3012
+ let oldLine: number | null = null;
3013
+ let newLine: number | null = null;
3014
+ let inHunk = false;
3015
+ const inferredStarts = sourceContent ? inferApplyPatchHunkStarts(lines, sourceContent) : [];
3016
+ let hunkIndex = 0;
3017
+
3018
+ for (const rawLine of lines) {
3019
+ if (rawLine.startsWith("*** Move to: ")) continue;
3020
+ if (rawLine.startsWith("@@")) {
3021
+ if (diffLines.length > 0 && diffLines[diffLines.length - 1]?.type !== "sep") {
3022
+ diffLines.push({ type: "sep", oldNum: null, newNum: null, content: "" });
3023
+ }
3024
+ const match = rawLine.match(/^@@\s*-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/);
3025
+ const inferred = inferredStarts[hunkIndex] ?? { oldStart: null, newStart: null };
3026
+ oldLine = match ? Number.parseInt(match[1], 10) : inferred.oldStart;
3027
+ newLine = match ? Number.parseInt(match[2], 10) : inferred.newStart;
3028
+ hunkIndex++;
3029
+ inHunk = true;
3030
+ continue;
3031
+ }
3032
+ if (rawLine === "\") continue;
3033
+ if (!inHunk) {
3034
+ const inferred = inferredStarts[hunkIndex] ?? { oldStart: null, newStart: null };
3035
+ oldLine = inferred.oldStart;
3036
+ newLine = inferred.newStart;
3037
+ hunkIndex++;
3038
+ inHunk = true;
3039
+ }
3040
+
3041
+ const { marker, content } = parsePatchBodyLine(rawLine);
3042
+
3043
+ chars += content.length;
3044
+ if (marker === "+") {
3045
+ diffLines.push({ type: "add", oldNum: null, newNum: newLine, content });
3046
+ added++;
3047
+ if (newLine !== null) newLine++;
3048
+ continue;
3049
+ }
3050
+ if (marker === "-") {
3051
+ diffLines.push({ type: "del", oldNum: oldLine, newNum: null, content });
3052
+ removed++;
3053
+ if (oldLine !== null) oldLine++;
3054
+ continue;
3055
+ }
3056
+ diffLines.push({ type: "ctx", oldNum: oldLine, newNum: newLine, content });
3057
+ if (oldLine !== null) oldLine++;
3058
+ if (newLine !== null) newLine++;
3059
+ }
3060
+
3061
+ return {
3062
+ lines: trimDiffSeparators(diffLines),
3063
+ added,
3064
+ removed,
3065
+ chars,
3066
+ };
3067
+ }
3068
+
3069
+ function parseApplyPatchPreview(patchText: string, sp: (path: string) => string, cwd = process.cwd()): ApplyPatchPreview {
3070
+ const normalized = patchText.replace(/\r\n/g, "\n");
3071
+ const lines = normalized.split("\n");
3072
+ const changes: ApplyPatchChangePreview[] = [];
3073
+ let index = 0;
3074
+
3075
+ const fileHeader = /^\*\*\* (Add|Update|Delete) File: (.+)$/;
3076
+ const endHeader = /^\*\*\* End Patch$/;
3077
+
3078
+ while (index < lines.length) {
3079
+ const line = lines[index];
3080
+ if (!line || line === "*** Begin Patch") {
3081
+ index++;
3082
+ continue;
3083
+ }
3084
+ if (endHeader.test(line)) break;
3085
+ const header = line.match(fileHeader);
3086
+ if (!header) {
3087
+ index++;
3088
+ continue;
3089
+ }
3090
+
3091
+ const kind = header[1].toLowerCase() as ApplyPatchChangePreview["kind"];
3092
+ const path = header[2].trim();
3093
+ index++;
3094
+
3095
+ let moveTo: string | undefined;
3096
+ const body: string[] = [];
3097
+ while (index < lines.length && !fileHeader.test(lines[index]) && !endHeader.test(lines[index])) {
3098
+ if (lines[index].startsWith("*** Move to: ")) {
3099
+ moveTo = lines[index].slice("*** Move to: ".length).trim();
3100
+ index++;
3101
+ continue;
3102
+ }
3103
+ body.push(lines[index]);
3104
+ index++;
3105
+ }
3106
+
3107
+ const displayPath = moveTo ? `${sp(path)} ${BORDER_COLOR}→${TRANSPARENT_RESET} ${sp(moveTo)}` : sp(path);
3108
+ let sourceContent: string | undefined;
3109
+ if (kind === "update") {
3110
+ try {
3111
+ sourceContent = readFileSync(resolve(cwd, path), "utf8");
3112
+ } catch {
3113
+ sourceContent = undefined;
3114
+ }
3115
+ }
3116
+ const diff = kind === "add"
3117
+ ? parseDiff("", body.map((entry) => stripPatchLinePrefix(entry, "+")).join("\n"))
3118
+ : kind === "delete"
3119
+ ? parseDiff(body.map((entry) => stripPatchLinePrefix(entry, "-")).join("\n"), "")
3120
+ : parseApplyPatchUpdateDiff(body, sourceContent);
3121
+ changes.push({
3122
+ kind,
3123
+ path,
3124
+ displayPath,
3125
+ moveTo,
3126
+ diff,
3127
+ language: lang(moveTo || path),
3128
+ hunks: countDiffHunks(diff),
3129
+ summary: summarizeDiff(diff.added, diff.removed),
3130
+ line: getApplyPatchLine(diff, kind),
3131
+ });
3132
+ }
3133
+
3134
+ const totalAdded = changes.reduce((sum, change) => sum + change.diff.added, 0);
3135
+ const totalRemoved = changes.reduce((sum, change) => sum + change.diff.removed, 0);
3136
+ const totalHunks = changes.reduce((sum, change) => sum + change.hunks, 0);
3137
+ const totalLines = changes.reduce((sum, change) => sum + change.diff.lines.length, 0);
3138
+ return {
3139
+ changes,
3140
+ totalAdded,
3141
+ totalRemoved,
3142
+ totalHunks,
3143
+ totalLines,
3144
+ summary: summarizeDiff(totalAdded, totalRemoved),
3145
+ };
3146
+ }
3147
+
3148
+ function describeApplyPatchChange(change: ApplyPatchChangePreview): string {
3149
+ if (change.moveTo) return `Rename ${change.displayPath}`;
3150
+ if (change.kind === "add") return `Create ${change.displayPath}`;
3151
+ if (change.kind === "delete") return `Delete ${change.displayPath}`;
3152
+ return `Update ${change.displayPath}`;
3153
+ }
3154
+
3155
+ function formatLineMeta(line: number, theme: Theme): string {
3156
+ return line > 0 ? ` ${theme.fg("muted", `at line ${line}`)}` : "";
3157
+ }
3158
+
3159
+ function formatApplyPatchLine(change: ApplyPatchChangePreview, theme: Theme): string {
3160
+ return formatLineMeta(change.line, theme);
3161
+ }
3162
+
3163
+ function getCachedApplyPatchPreview(patchText: string, sp: (path: string) => string, ctx: any): ApplyPatchPreview | null {
3164
+ if (!patchText) return null;
3165
+ const key = `apply-meta:${ctx.cwd ?? process.cwd()}:${hashText(patchText)}`;
3166
+ if (ctx.state?._applyPatchMetaKey === key && ctx.state._applyPatchPreview) {
3167
+ return ctx.state._applyPatchPreview as ApplyPatchPreview;
3168
+ }
3169
+ try {
3170
+ const preview = parseApplyPatchPreview(patchText, sp, ctx.cwd ?? process.cwd());
3171
+ if (ctx.state) {
3172
+ ctx.state._applyPatchMetaKey = key;
3173
+ ctx.state._applyPatchPreview = preview;
3174
+ ctx.state._applyPatchMeta = buildApplyPatchResultMeta(preview);
3175
+ }
3176
+ return preview;
3177
+ } catch {
3178
+ return null;
3179
+ }
3180
+ }
3181
+
3182
+ function getApplyPatchResultMeta(args: any, ctx: any, sp: (path: string) => string): ApplyPatchResultMeta | null {
3183
+ const patchText = getStringArg(args ?? ctx?.args, "patchText", "patch_text");
3184
+ if (!patchText) return null;
3185
+ const preview = getCachedApplyPatchPreview(patchText, sp, ctx);
3186
+ return preview && ctx.state?._applyPatchMeta ? (ctx.state._applyPatchMeta as ApplyPatchResultMeta) : null;
3187
+ }
3188
+
3189
+ function renderApplyPatchCall(args: any, theme: Theme, ctx: any, sp: (path: string) => string): Text {
3190
+ syncToolCallStatus(ctx);
3191
+ const patchText = getStringArg(args, "patchText", "patch_text");
3192
+ const summary = stableCallSummary(ctx, "_callSummary", () => summarizeOpenAiToolCall("apply_patch", args, theme, sp));
3193
+ const hdr = toolHeader("Apply Patch", summary, theme, toolStatusDot(ctx, theme));
3194
+
3195
+ if (!ctx.argsComplete) return makeText(ctx.lastComponent, hdr);
3196
+ const preview = getCachedApplyPatchPreview(patchText, sp, ctx);
3197
+ if (!preview || preview.changes.length === 0) {
3198
+ ctx.state._openAiPatchFiles = [];
3199
+ return makeText(ctx.lastComponent, hdr);
3200
+ }
3201
+ ctx.state._openAiPatchFiles = preview.changes.map((change) => change.displayPath);
3202
+
3203
+ const diffWidth = branchDiffWidth();
3204
+ const key = `apply-preview:${ctx.state._applyPatchMetaKey ?? hashText(patchText)}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
3205
+ if (ctx.state._applyPatchPreviewKey !== key) {
3206
+ ctx.state._applyPatchPreviewKey = key;
3207
+ ctx.state._applyPatchPreviewBody = theme.fg("muted", "(rendering…)");
3208
+ ctx.state._applyPatchPreviewDisplay = withBranch(ctx.state._applyPatchPreviewBody, theme, false, true);
3209
+ const dc = resolveDiffColors(theme);
3210
+ if (preview.changes.length === 1) {
3211
+ const [change] = preview.changes;
3212
+ renderSplit(change.diff, change.language, ctx.expanded ? MAX_PREVIEW_LINES : 32, dc, diffWidth)
3213
+ .then((rendered) => {
3214
+ if (ctx.state._applyPatchPreviewKey !== key) return;
3215
+ ctx.state._applyPatchPreviewBody = `${describeApplyPatchChange(change)} ${change.summary}${formatApplyPatchLine(change, theme)}\n${rendered}`;
3216
+ ctx.state._applyPatchPreviewDisplay = withBranch(ctx.state._applyPatchPreviewBody, theme, false, true);
3217
+ ctx.invalidate();
3218
+ })
3219
+ .catch(() => {
3220
+ if (ctx.state._applyPatchPreviewKey !== key) return;
3221
+ ctx.state._applyPatchPreviewBody = `${describeApplyPatchChange(change)} ${change.summary}${formatApplyPatchLine(change, theme)}`;
3222
+ ctx.state._applyPatchPreviewDisplay = withBranch(ctx.state._applyPatchPreviewBody, theme, false, true);
3223
+ ctx.invalidate();
3224
+ });
3225
+ } else {
3226
+ const maxShown = ctx.expanded ? preview.changes.length : Math.min(preview.changes.length, 3);
3227
+ const previewLines = ctx.expanded
3228
+ ? Math.max(6, Math.floor(MAX_RENDER_LINES / Math.max(1, maxShown)))
3229
+ : Math.max(8, Math.floor(MAX_PREVIEW_LINES / Math.max(1, maxShown)));
3230
+ Promise.all(
3231
+ preview.changes.slice(0, maxShown).map((change, index) =>
3232
+ renderSplit(change.diff, change.language, previewLines, dc, diffWidth)
3233
+ .then((rendered) => `${describeApplyPatchChange(change)} ${change.summary}${formatApplyPatchLine(change, theme)}\n${rendered}`)
3234
+ .catch(() => `${index + 1}. ${describeApplyPatchChange(change)} ${change.summary}${formatApplyPatchLine(change, theme)}`),
3235
+ ),
3236
+ )
3237
+ .then((sections) => {
3238
+ if (ctx.state._applyPatchPreviewKey !== key) return;
3239
+ const remainder = preview.changes.length - maxShown;
3240
+ const suffix = remainder > 0
3241
+ ? `\n${theme.fg("muted", `… ${remainder} more file patches${ctx.expanded ? "" : " • Ctrl+O to expand"}`)}`
3242
+ : "";
3243
+ const summary = `${preview.changes.length} files ${preview.summary}`;
3244
+ ctx.state._applyPatchPreviewBody = `${summary}\n\n${sections.join("\n\n")}${suffix}`;
3245
+ ctx.state._applyPatchPreviewDisplay = withBranch(ctx.state._applyPatchPreviewBody, theme, false, true);
3246
+ ctx.invalidate();
3247
+ })
3248
+ .catch(() => {
3249
+ if (ctx.state._applyPatchPreviewKey !== key) return;
3250
+ ctx.state._applyPatchPreviewBody = `${preview.changes.length} files ${preview.summary}`;
3251
+ ctx.state._applyPatchPreviewDisplay = withBranch(ctx.state._applyPatchPreviewBody, theme, false, true);
3252
+ ctx.invalidate();
3253
+ });
3254
+ }
3255
+ }
3256
+
3257
+ const body = ctx.state._applyPatchPreviewDisplay as string | undefined;
3258
+ return makeText(ctx.lastComponent, body ? `${hdr}\n${body}` : hdr);
3259
+ }
3260
+
3261
+ function renderApplyPatchResult(result: any, isPartial: boolean, theme: Theme, ctx: any): Text {
3262
+ if (isPartial) {
3263
+ setupBlinkTimer(ctx);
3264
+ return makeText(ctx.lastComponent, withBranch(theme.fg("dim", "Applying Patch..."), theme));
3265
+ }
3266
+ clearBlinkTimer(ctx);
3267
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
3268
+
3269
+ if (ctx.isError) {
3270
+ const raw = getTextContent(result).trim();
3271
+ const firstLine = raw ? raw.split("\n")[0] : "Apply patch failed";
3272
+ return makeText(ctx.lastComponent, withBranch(theme.fg("error", firstLine), theme));
3273
+ }
3274
+
3275
+ const meta = getApplyPatchResultMeta(ctx.args, ctx, (path: string) => shortPath(ctx.cwd ?? process.cwd(), path));
3276
+ if (!meta || meta.changeCount === 0) {
3277
+ return makeText(ctx.lastComponent, withBranch(theme.fg("success", "Applied"), theme));
3278
+ }
3279
+
3280
+ if (meta.changeCount === 1 && meta.firstChange) {
3281
+ const change = meta.firstChange;
3282
+ const summary = diffSummaryWithMeta(change.added, change.removed, change.hunks, change.kind === "add" ? "new file" : change.kind === "delete" ? "delete" : "");
3283
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg("success", "Applied")} ${theme.fg("muted", change.displayPath)} ${summary}${formatLineMeta(change.line, theme)}`, theme));
3284
+ }
3285
+
3286
+ const summary = diffSummaryWithMeta(meta.totalAdded, meta.totalRemoved, meta.totalHunks, "");
3287
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg("success", "Applied")} ${meta.changeCount} files ${summary}${meta.totalLines ? ` ${theme.fg("muted", `(${meta.totalLines} diff lines)`)}` : ""}`, theme));
3288
+ }
3289
+
3290
+ function summarizeMcpToolCall(args: any, theme: Theme): string {
3291
+ const tool = getStringArg(args, "tool");
3292
+ if (tool) return args?.server ? `${args.server}:${tool}` : tool;
3293
+ const connect = getStringArg(args, "connect");
3294
+ if (connect) return `connect ${connect}`;
3295
+ const search = getStringArg(args, "search", "describe", "server", "action");
3296
+ if (search) return summarizeText(search, 72);
3297
+ return theme.fg("muted", "status");
3298
+ }
3299
+
3300
+ function summarizeGenericToolCall(name: string, args: any, theme: Theme, sp: (path: string) => string): string {
3301
+ if (isMcpToolName(name)) return summarizeMcpToolCall(args, theme);
3302
+ return summarizeOpenAiToolCall(name, args, theme, sp);
3303
+ }
3304
+
3305
+ function renderMcpToolResult(result: any, expanded: boolean, isPartial: boolean, theme: Theme, ctx: any): Text {
3306
+ if (isPartial) {
3307
+ setupBlinkTimer(ctx);
3308
+ return makeText(ctx.lastComponent, withBranch(theme.fg("dim", "MCP running..."), theme));
3309
+ }
3310
+ clearBlinkTimer(ctx);
3311
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
3312
+
3313
+ const mode = getMode(readSettings().mcpOutputMode, ["hidden", "summary", "preview"] as const, "preview");
3314
+ if (mode === "hidden") return makeText(ctx.lastComponent, "");
3315
+
3316
+ const raw = getTextContent(result).trim();
3317
+ const lines = raw ? raw.split("\n") : [];
3318
+ if (lines.length === 0) {
3319
+ return makeText(ctx.lastComponent, withBranch(theme.fg(ctx.isError ? "error" : "success", ctx.isError ? "Failed" : "Done"), theme));
3320
+ }
3321
+
3322
+ const statusText = ctx.isError ? theme.fg("error", lines[0]) : theme.fg("muted", `${lines.length} line${lines.length === 1 ? "" : "s"} returned`);
3323
+ if (mode === "summary") return makeText(ctx.lastComponent, withBranch(statusText, theme));
3324
+ if (!expanded) return makeText(ctx.lastComponent, withBranch(`${statusText}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
3325
+ const preview = buildPreviewText(lines.map((line) => theme.fg(ctx.isError ? "error" : "toolOutput", line || " ")), true, theme, previewLimit());
3326
+ return makeText(ctx.lastComponent, withBranch(`${statusText}\n${preview}`, theme));
3327
+ }
3328
+
3329
+ function summarizeOpenAiToolCall(name: string, args: any, theme: Theme, sp: (path: string) => string): string {
3330
+ switch (name) {
3331
+ case "apply_patch": {
3332
+ const patchText = getStringArg(args, "patchText", "patch_text");
3333
+ const files = extractApplyPatchFiles(patchText);
3334
+ if (files.length === 0) return theme.fg("muted", "patch");
3335
+ if (files.length === 1) return sp(files[0]);
3336
+ return `${sp(files[0])} ${theme.fg("muted", `(+${files.length - 1} files)`)}`;
3337
+ }
3338
+ case "webfetch":
3339
+ return getStringArg(args, "url") || theme.fg("muted", "fetch page");
3340
+ case "fetch_content": {
3341
+ const url = getStringArg(args, "url");
3342
+ if (url) return url;
3343
+ const urls = getStringArrayArg(args, "urls");
3344
+ if (urls.length === 0) return theme.fg("muted", "fetch content");
3345
+ if (urls.length === 1) return urls[0];
3346
+ return `${urls[0]} ${theme.fg("muted", `(+${urls.length - 1} urls)`)}`;
3347
+ }
3348
+ case "get_search_content":
3349
+ return getStringArg(args, "responseId", "response_id") || theme.fg("muted", "load cached content");
3350
+ case "web_search": {
3351
+ const query = getStringArg(args, "query");
3352
+ if (query) return summarizeText(query, 72);
3353
+ const queries = getStringArrayArg(args, "queries");
3354
+ if (queries.length === 0) return theme.fg("muted", "search web");
3355
+ if (queries.length === 1) return summarizeText(queries[0], 72);
3356
+ return `${summarizeText(queries[0], 48)} ${theme.fg("muted", `(+${queries.length - 1} queries)`)}`;
3357
+ }
3358
+ case "code_search":
3359
+ return summarizeText(getStringArg(args, "query") || "search code", 72);
3360
+ case "question":
3361
+ return summarizeText(getStringArg(args, "question") || "ask user", 72);
3362
+ case "questionnaire": {
3363
+ const questions = Array.isArray(args?.questions) ? args.questions.length : 0;
3364
+ return questions > 0 ? `${questions} questions` : theme.fg("muted", "questionnaire");
3365
+ }
3366
+ case "context_tag":
3367
+ return getStringArg(args, "name") || theme.fg("muted", "save point");
3368
+ case "context_log":
3369
+ return theme.fg("muted", "history");
3370
+ case "context_checkout":
3371
+ return getStringArg(args, "target") || theme.fg("muted", "checkout context");
3372
+ case "annotate":
3373
+ return getStringArg(args, "url") || theme.fg("muted", "current tab");
3374
+ case "alpha_search":
3375
+ return summarizeText(getStringArg(args, "query") || "search papers", 72);
3376
+ case "alpha_get_paper":
3377
+ case "alpha_ask_paper":
3378
+ case "alpha_annotate_paper":
3379
+ return getStringArg(args, "paper") || theme.fg("muted", "paper");
3380
+ case "alpha_read_code":
3381
+ return getStringArg(args, "githubUrl", "github_url") || theme.fg("muted", "repository");
3382
+ case "Skill":
3383
+ return getStringArg(args, "name") || theme.fg("muted", "run skill");
3384
+ case "EnterPlanMode":
3385
+ return theme.fg("muted", "enable read-only planning");
3386
+ case "ExitPlanMode":
3387
+ return theme.fg("muted", "present plan");
3388
+ case "Agent":
3389
+ return summarizeText(getStringArg(args, "description", "prompt") || "launch agent", 72);
3390
+ case "get_subagent_result":
3391
+ return getStringArg(args, "agent_id") || theme.fg("muted", "agent result");
3392
+ case "steer_subagent":
3393
+ return getStringArg(args, "agent_id") || theme.fg("muted", "steer agent");
3394
+ case "TaskCreate":
3395
+ return summarizeText(getStringArg(args, "subject") || "create task", 72);
3396
+ case "TaskList":
3397
+ return theme.fg("muted", "task list");
3398
+ case "TaskGet":
3399
+ case "TaskUpdate":
3400
+ return getStringArg(args, "taskId", "task_id") || theme.fg("muted", "task");
3401
+ case "TaskOutput":
3402
+ case "TaskStop":
3403
+ return getStringArg(args, "task_id", "taskId") || theme.fg("muted", "background task");
3404
+ case "TaskExecute": {
3405
+ const taskIds = getStringArrayArg(args, "task_ids", "taskIds");
3406
+ if (taskIds.length === 0) return theme.fg("muted", "start tasks");
3407
+ return taskIds.length === 1 ? taskIds[0] : `${taskIds[0]} ${theme.fg("muted", `(+${taskIds.length - 1} tasks)`)}`;
3408
+ }
3409
+ default:
3410
+ return summarizeText(
3411
+ getStringArg(args, "path", "file_path", "url", "query", "name", "subject", "tool", "description", "prompt") || humanizeToolName(name),
3412
+ 72,
3413
+ );
3414
+ }
3415
+ }
3416
+
3417
+ interface ParsedTaskListLine {
3418
+ id: string;
3419
+ status: string;
3420
+ subject: string;
3421
+ }
3422
+
3423
+ function parseTaskListLine(line: string): ParsedTaskListLine | null {
3424
+ const match = line.match(/^#(\d+) \[([^\]]+)\] (.+)$/);
3425
+ if (!match) return null;
3426
+ return {
3427
+ id: match[1],
3428
+ status: match[2],
3429
+ subject: match[3],
3430
+ };
3431
+ }
3432
+
3433
+ function formatTaskStatus(status: string, theme: Theme): string {
3434
+ if (status === "completed") return theme.fg("success", status);
3435
+ if (status === "in_progress") return theme.fg("warning", status);
3436
+ return theme.fg("muted", status);
3437
+ }
3438
+
3439
+ function formatOpenAiSuccessLine(name: string, line: string, theme: Theme): string {
3440
+ const trimmed = line.trim();
3441
+ if (!trimmed) return theme.fg("success", "Done");
3442
+
3443
+ if (name === "TaskCreate") {
3444
+ const match = trimmed.match(/^Task #(\d+) created successfully: (.+)$/);
3445
+ if (match) {
3446
+ return `${theme.fg("success", "Created task")} ${theme.fg("accent", `#${match[1]}`)} ${theme.fg("muted", match[2])}`;
3447
+ }
3448
+ }
3449
+
3450
+ if (name === "TaskUpdate") {
3451
+ const match = trimmed.match(/^Updated task #(\d+) (.+)$/);
3452
+ if (match) {
3453
+ return `${theme.fg("success", "Updated task")} ${theme.fg("accent", `#${match[1]}`)} ${theme.fg("muted", match[2])}`;
3454
+ }
3455
+ }
3456
+
3457
+ if (name === "TaskExecute") {
3458
+ return `${theme.fg("success", "Started")} ${theme.fg("muted", trimmed)}`;
3459
+ }
3460
+
3461
+ if (name === "context_tag") {
3462
+ const match = trimmed.match(/^Created tag '([^']+)' at (.+)$/);
3463
+ if (match) {
3464
+ return `${theme.fg("success", "Created tag")} ${theme.fg("accent", match[1])} ${theme.fg("muted", match[2])}`;
3465
+ }
3466
+ }
3467
+
3468
+ if (name === "context_checkout") {
3469
+ return `${theme.fg("success", "Checked out")} ${theme.fg("muted", trimmed.replace(/^Checked out\s*/i, ""))}`;
3470
+ }
3471
+
3472
+ if (name === "TaskStop") {
3473
+ return `${theme.fg("success", "Stopped")} ${theme.fg("muted", trimmed)}`;
3474
+ }
3475
+
3476
+ return theme.fg("muted", trimmed);
3477
+ }
3478
+
3479
+ function renderTaskListResult(lines: string[], expanded: boolean, theme: Theme, ctx: any): Text {
3480
+ const tasks = lines.map(parseTaskListLine).filter((task): task is ParsedTaskListLine => task !== null);
3481
+ if (tasks.length === 0) {
3482
+ const text = lines.length === 0 ? theme.fg("muted", "no tasks") : buildPreviewText(lines.map((line) => theme.fg("dim", line)), expanded, theme, previewLimit());
3483
+ return makeText(ctx.lastComponent, withBranch(text, theme));
3484
+ }
3485
+
3486
+ const pending = tasks.filter((task) => task.status === "pending").length;
3487
+ const inProgress = tasks.filter((task) => task.status === "in_progress").length;
3488
+ const completed = tasks.filter((task) => task.status === "completed").length;
3489
+ let summary = theme.fg("muted", `${tasks.length} tasks`);
3490
+ const parts: string[] = [];
3491
+ if (inProgress > 0) parts.push(`${theme.fg("warning", String(inProgress))} in progress`);
3492
+ if (pending > 0) parts.push(`${theme.fg("muted", String(pending))} pending`);
3493
+ if (completed > 0) parts.push(`${theme.fg("success", String(completed))} completed`);
3494
+ if (parts.length > 0) summary += ` ${theme.fg("muted", "•")} ${parts.join(` ${theme.fg("muted", "•")} `)}`;
3495
+
3496
+ if (!expanded) {
3497
+ return makeText(ctx.lastComponent, withBranch(`${summary}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
3498
+ }
3499
+
3500
+ const shown = tasks.slice(0, previewLimit());
3501
+ const preview = shown.map((task) => `${theme.fg("accent", `#${task.id}`)} ${formatTaskStatus(task.status, theme)} ${theme.fg("dim", task.subject)}`);
3502
+ const remaining = tasks.length - shown.length;
3503
+ if (remaining > 0) preview.push(theme.fg("muted", `… ${remaining} more tasks`));
3504
+ return makeText(ctx.lastComponent, withBranch(`${summary}\n${preview.join("\n")}`, theme));
3505
+ }
3506
+
3507
+ function getFirstImageBlock(result: any): { data: string; mimeType: string } | undefined {
3508
+ if (!Array.isArray(result?.content)) return undefined;
3509
+ return result.content.find((block: any) => block?.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string");
3510
+ }
3511
+
3512
+ function getReadImageFallback(result: any, ctx: any): string {
3513
+ const image = getFirstImageBlock(result);
3514
+ if (!image) return "";
3515
+ let dimensions;
3516
+ try {
3517
+ dimensions = getImageDimensions(image.data, image.mimeType) ?? undefined;
3518
+ } catch {
3519
+ dimensions = undefined;
3520
+ }
3521
+ const path = getStringArg(ctx.args, "path", "file_path");
3522
+ const filename = path ? shortPath(ctx.cwd ?? process.cwd(), path) : undefined;
3523
+ return imageFallback(image.mimeType, dimensions, filename);
3524
+ }
3525
+
3526
+ function renderReadImageResult(result: any, expanded: boolean, theme: Theme, ctx: any): Text {
3527
+ const image = getFirstImageBlock(result);
3528
+ const mimeType = image?.mimeType ?? "image";
3529
+ const summary = `${theme.fg("success", "Image loaded")} ${theme.fg("muted", `[${mimeType}]`)}`;
3530
+ if (!expanded) {
3531
+ return makeText(ctx.lastComponent, withBranch(`${summary}${theme.fg("muted", " • Ctrl+O to show")}`, theme));
3532
+ }
3533
+
3534
+ const noteLines = getTextContent(result)
3535
+ .split("\n")
3536
+ .map((line) => line.trim())
3537
+ .filter((line) => line && !/^Read image file\b/i.test(line));
3538
+ const lines = [summary, ...noteLines.map((line) => theme.fg("dim", line))];
3539
+ if (!getCapabilities().images || !ctx.showImages) {
3540
+ const fallback = getReadImageFallback(result, ctx);
3541
+ if (fallback) lines.push(theme.fg("toolOutput", fallback));
3542
+ }
3543
+ return makeText(ctx.lastComponent, withBranch(lines.join("\n"), theme));
3544
+ }
3545
+
3546
+ function renderOpenAiToolResult(name: string, result: any, expanded: boolean, isPartial: boolean, theme: Theme, ctx: any): Text {
3547
+ if (isPartial) {
3548
+ setupBlinkTimer(ctx);
3549
+ return makeText(ctx.lastComponent, withBranch(theme.fg("dim", `${humanizeToolName(name)}...`), theme));
3550
+ }
3551
+ clearBlinkTimer(ctx);
3552
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
3553
+
3554
+ const raw = getTextContent(result).trim();
3555
+ const lines = raw ? raw.split("\n") : [];
3556
+ const patchFiles = Array.isArray(ctx.state?._openAiPatchFiles) ? ctx.state._openAiPatchFiles : [];
3557
+
3558
+ if (lines.length === 0) {
3559
+ if (patchFiles.length > 0) {
3560
+ const suffix = patchFiles.length === 1 ? patchFiles[0] : `${patchFiles.length} files`;
3561
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg(ctx.isError ? "error" : "success", ctx.isError ? "Failed" : "Applied")} ${theme.fg("muted", suffix)}`, theme));
3562
+ }
3563
+ return makeText(ctx.lastComponent, withBranch(theme.fg(ctx.isError ? "error" : "success", ctx.isError ? "Failed" : "Done"), theme));
3564
+ }
3565
+
3566
+ if (!ctx.isError && name === "TaskList") {
3567
+ return renderTaskListResult(lines, expanded, theme, ctx);
3568
+ }
3569
+
3570
+ const statusText = ctx.isError
3571
+ ? theme.fg("error", lines[0])
3572
+ : theme.fg("muted", `${lines.length} line${lines.length === 1 ? "" : "s"} returned`);
3573
+ if (!expanded) {
3574
+ return makeText(ctx.lastComponent, withBranch(`${statusText}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
3575
+ }
3576
+
3577
+ if (!ctx.isError && lines.length === 1) {
3578
+ return makeText(ctx.lastComponent, withBranch(formatOpenAiSuccessLine(name, lines[0], theme), theme));
3579
+ }
3580
+
3581
+ const preview = lines.length === 1
3582
+ ? theme.fg(ctx.isError ? "error" : "dim", lines[0])
3583
+ : buildPreviewText(lines.map((line) => theme.fg(ctx.isError ? "error" : "dim", line || " ")), true, theme, previewLimit());
3584
+ return makeText(ctx.lastComponent, withBranch(`${statusText}\n${preview}`, theme));
3585
+ }
3586
+
3587
+ // ===========================================================================
3588
+ // Extension
3589
+ // ===========================================================================
3590
+
3591
+ export default function (pi: ExtensionAPI) {
3592
+ patchToolRenderCacheInvalidation();
3593
+ patchReadImageExpansion();
3594
+ patchGlobalToolBorders();
3595
+ patchCustomMessageRender();
3596
+ patchUserMessageRender();
3597
+ patchAssistantMessages();
3598
+ patchToolExecutionRenderers();
3599
+ applyDiffPalette();
3600
+ registerThinkingLabels(pi);
3601
+
3602
+ // /cc-tools command — toggle tool border style at runtime
3603
+ const TOOL_MODES = ["outlines", "transparent", "default"] as const;
3604
+ pi.registerCommand("cc-tools", {
3605
+ description: "Switch tool display style: outlines (lines around tools), transparent (no chrome), default (pi built-in backgrounds)",
3606
+ getArgumentCompletions(prefix) {
3607
+ return TOOL_MODES
3608
+ .filter((m) => m.startsWith(prefix))
3609
+ .map((m) => ({
3610
+ value: m,
3611
+ label: m,
3612
+ description:
3613
+ m === "outlines" ? "Horizontal rules around each tool (default)"
3614
+ : m === "transparent" ? "No borders or backgrounds"
3615
+ : "Pi built-in tool backgrounds",
3616
+ }));
3617
+ },
3618
+ async handler(args, ctx) {
3619
+ const mode = args.trim().toLowerCase();
3620
+ if (!mode) {
3621
+ if (ctx.hasUI) ctx.ui.notify(`Tool style: ${toolBackgroundMode}`, "info");
3622
+ return;
3623
+ }
3624
+ if (!(TOOL_MODES as readonly string[]).includes(mode)) {
3625
+ if (ctx.hasUI) ctx.ui.notify(`Unknown mode "${mode}". Options: ${TOOL_MODES.join(", ")}`, "error");
3626
+ return;
3627
+ }
3628
+ toolBackgroundOverride = mode as typeof toolBackgroundMode;
3629
+ toolBackgroundMode = toolBackgroundOverride;
3630
+ writeSettingsKey("toolBackground", mode);
3631
+ if (ctx.hasUI) {
3632
+ applyToolBackgroundMode(ctx.ui.theme);
3633
+ ctx.ui.notify(`Tool style → ${mode}`, "info");
3634
+ }
3635
+ },
3636
+ });
3637
+
3638
+ // /cc-theme command — toggle pi-theme-adaptive coloring at runtime.
3639
+ const THEME_MODES = ["on", "off", "toggle", "status"] as const;
3640
+ pi.registerCommand("cc-theme", {
3641
+ description: "Toggle whether tool borders / branch rules / diff colors follow the active pi theme",
3642
+ getArgumentCompletions(prefix) {
3643
+ return THEME_MODES
3644
+ .filter((m) => m.startsWith(prefix))
3645
+ .map((m) => ({
3646
+ value: m,
3647
+ label: m,
3648
+ description:
3649
+ m === "on" ? "Derive borders, branch rules, dim text and diff tints from the active pi theme (default)"
3650
+ : m === "off" ? "Keep the fixed Claude-style palette regardless of theme"
3651
+ : m === "toggle" ? "Flip between on and off"
3652
+ : "Show the current setting and a preview of the derived colors",
3653
+ }));
3654
+ },
3655
+ async handler(args, ctx) {
3656
+ const raw = args.trim().toLowerCase();
3657
+ const current = themeAdaptiveEnabled();
3658
+
3659
+ if (!raw || raw === "status") {
3660
+ if (!ctx.hasUI) return;
3661
+ const theme = ctx.ui.theme as any;
3662
+ const themeName = theme?.name ?? "unknown";
3663
+ const state = current ? "on" : "off";
3664
+ if (raw === "status" && current) {
3665
+ const settings = readMergedSettings();
3666
+ const spinnerKey = settings.spinnerColor || settings.spinnerVerbColor || "borderAccent";
3667
+ const statusKey = settings.spinnerStatusColor || "muted";
3668
+ const spinnerAnsi = safeFgAnsi(theme, spinnerKey) ?? safeFgAnsi(theme, "accent");
3669
+ const statusAnsi = safeFgAnsi(theme, statusKey) ?? safeFgAnsi(theme, "muted");
3670
+ // Print a short preview of what we derived.
3671
+ const preview = [
3672
+ `borders : ${safeFgAnsi(theme, "borderMuted") ? `${safeFgAnsi(theme, "borderMuted")}────\x1b[39m` : "(unchanged)"}`,
3673
+ `branches : ${safeFgAnsi(theme, "dim") ?? safeFgAnsi(theme, "muted") ? `${safeFgAnsi(theme, "dim") ?? safeFgAnsi(theme, "muted")}├─ └─\x1b[39m` : "(unchanged)"}`,
3674
+ `muted text : ${safeFgAnsi(theme, "muted") ? `${safeFgAnsi(theme, "muted")}example dim text\x1b[39m` : "(unchanged)"}`,
3675
+ `diff add : ${safeFgAnsi(theme, "toolDiffAdded") ? `${safeFgAnsi(theme, "toolDiffAdded")}+ added line\x1b[39m` : "(unchanged)"}`,
3676
+ `diff del : ${safeFgAnsi(theme, "toolDiffRemoved") ? `${safeFgAnsi(theme, "toolDiffRemoved")}- removed line\x1b[39m` : "(unchanged)"}`,
3677
+ `spinner : ${spinnerAnsi ? `${spinnerAnsi}✻ Cooking…\x1b[39m` : "(unchanged)"} (key: ${spinnerKey})`,
3678
+ `spinner stat: ${statusAnsi ? `${statusAnsi}(thinking · ↓ 10 tokens · 2s)\x1b[39m` : "(unchanged)"} (key: ${statusKey})`,
3679
+ ].join("\n ");
3680
+ ctx.ui.notify(`Theme adaptive: ${state} (theme "${themeName}")\n ${preview}`, "info");
3681
+ } else {
3682
+ ctx.ui.notify(`Theme adaptive: ${state} (theme "${themeName}")`, "info");
3683
+ }
3684
+ return;
3685
+ }
3686
+
3687
+ let next: boolean;
3688
+ if (raw === "on") next = true;
3689
+ else if (raw === "off") next = false;
3690
+ else if (raw === "toggle") next = !current;
3691
+ else {
3692
+ if (ctx.hasUI) ctx.ui.notify(`Unknown option "${raw}". Options: ${THEME_MODES.join(", ")}`, "error");
3693
+ return;
3694
+ }
3695
+
3696
+ writeSettingsKey("themeAdaptive", next);
3697
+ bustSpinnerSettingsCache();
3698
+ // Invalidate caches so the next render re-derives from the active
3699
+ // theme (or falls back to the fixed Claude palette).
3700
+ _themePaletteCacheTheme = null;
3701
+ autoDerivePending = true;
3702
+ if (next) {
3703
+ if (ctx.hasUI) applyThemePaletteIfNeeded(ctx.ui.theme);
3704
+ } else {
3705
+ resetThemePalette();
3706
+ }
3707
+ if (ctx.hasUI) {
3708
+ const label = next ? "on — colors follow pi theme" : "off — fixed Claude palette";
3709
+ ctx.ui.notify(`Theme adaptive: ${label}`, "info");
3710
+ }
3711
+ },
3712
+ });
3713
+
3714
+ // /cc-spinner command — pick which theme color keys drive the spinner glyph,
3715
+ // verb text, and status suffix.
3716
+ const COMMON_COLOR_KEYS: readonly string[] = [
3717
+ "accent", "borderAccent", "success", "error", "warning",
3718
+ "muted", "dim", "text", "thinkingText",
3719
+ "toolTitle", "mdHeading", "mdCode", "mdLink", "mdListBullet",
3720
+ "bashMode",
3721
+ "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh",
3722
+ "syntaxKeyword", "syntaxFunction", "syntaxString", "syntaxType",
3723
+ ];
3724
+ pi.registerCommand("cc-spinner", {
3725
+ description: "Set spinner colors or manage custom spinner verbs",
3726
+ getArgumentCompletions(prefix: string) {
3727
+ const subCommands = ["color", "verb", "verbs", "status", "reset", "preview"];
3728
+ const parts = prefix.split(/\s+/);
3729
+ if (parts.length <= 1) {
3730
+ return subCommands
3731
+ .filter((c) => c.startsWith(parts[0] ?? ""))
3732
+ .map((c) => ({
3733
+ value: c,
3734
+ label: c,
3735
+ description:
3736
+ c === "color" ? "Set one color key for both spinner glyph and verb text"
3737
+ : c === "verb" ? "Alias for color; kept for older muscle memory"
3738
+ : c === "verbs" ? "List, add, remove, reset, or set mode for custom spinner verbs"
3739
+ : c === "status" ? "Set the color key used for the spinner status suffix"
3740
+ : c === "reset" ? "Reset spinner colors to defaults (spinner=borderAccent, status=muted)"
3741
+ : "Preview every theme color key with its current sample",
3742
+ }));
3743
+ }
3744
+ // Second arg: color key completions for color/verb/status.
3745
+ if (parts[0] === "color" || parts[0] === "verb" || parts[0] === "status") {
3746
+ const subCommand = parts[0];
3747
+ const keyPrefix = (parts[1] ?? "").toLowerCase();
3748
+ return COMMON_COLOR_KEYS
3749
+ .filter((k) => k.toLowerCase().startsWith(keyPrefix))
3750
+ .map((k) => ({ value: `${subCommand} ${k}`, label: k, description: `theme.fg("${k}", …)` }));
3751
+ }
3752
+ if (parts[0] === "verbs") {
3753
+ const verbCommands = ["list", "add", "remove", "reset", "mode"];
3754
+ if (parts.length <= 2) {
3755
+ const actionPrefix = (parts[1] ?? "").toLowerCase();
3756
+ return verbCommands
3757
+ .filter((c) => c.startsWith(actionPrefix))
3758
+ .map((c) => ({
3759
+ value: `verbs ${c}`,
3760
+ label: c,
3761
+ description:
3762
+ c === "list" ? "Show custom spinner verbs and mode"
3763
+ : c === "add" ? "Add a custom spinner verb or phrase"
3764
+ : c === "remove" ? "Remove a custom spinner verb or phrase"
3765
+ : c === "reset" ? "Remove user custom spinner verbs"
3766
+ : "Set append or replace mode",
3767
+ }));
3768
+ }
3769
+ if (parts[1] === "mode") {
3770
+ const modePrefix = (parts[2] ?? "").toLowerCase();
3771
+ return ["append", "replace"]
3772
+ .filter((m) => m.startsWith(modePrefix))
3773
+ .map((m) => ({ value: `verbs mode ${m}`, label: m, description: `${m} custom verbs ${m === "append" ? "to" : "instead of"} defaults` }));
3774
+ }
3775
+ }
3776
+ return [];
3777
+ },
3778
+ async handler(args: string, ctx: any) {
3779
+ const rawArgs = args.trim();
3780
+ const parts = rawArgs.split(/\s+/).filter((p: string) => p.length > 0);
3781
+ const sub = (parts[0] ?? "").toLowerCase();
3782
+ const theme = ctx.hasUI ? (ctx.ui.theme as any) : null;
3783
+ const settings = readMergedSettings();
3784
+ const currentSpinner = settings.spinnerColor || settings.spinnerVerbColor || "borderAccent";
3785
+ const currentStatus = settings.spinnerStatusColor || "muted";
3786
+ const customVerbs = sanitizeSpinnerVerbs(settings.spinnerVerbs);
3787
+ const customVerbMode = getSpinnerVerbMode(settings);
3788
+
3789
+ if (!sub || sub === "preview") {
3790
+ if (!ctx.hasUI) return;
3791
+ if (!theme) {
3792
+ ctx.ui.notify(`Spinner color: ${currentSpinner}, status: ${currentStatus}; custom verbs: ${customVerbs.length} (${customVerbMode}) (no theme)`, "info");
3793
+ return;
3794
+ }
3795
+ const lines: string[] = [
3796
+ `Current: spinnerColor=${currentSpinner}, status=${currentStatus}, customVerbs=${customVerbs.length}, mode=${customVerbMode}`,
3797
+ "",
3798
+ "Preview of common theme keys (pick one for spinner or status):",
3799
+ ];
3800
+ for (const key of COMMON_COLOR_KEYS) {
3801
+ const ansi = safeFgAnsi(theme, key);
3802
+ const marker = key === currentSpinner ? "(spinner)" : key === currentStatus ? "(status)" : "";
3803
+ const sample = ansi ? `${ansi}✻ Cooking…\x1b[39m` : "(unmapped)";
3804
+ lines.push(` ${key.padEnd(16)} ${sample} ${marker}`);
3805
+ }
3806
+ ctx.ui.notify(lines.join("\n"), "info");
3807
+ return;
3808
+ }
3809
+
3810
+ if (sub === "verbs") {
3811
+ const action = (parts[1] ?? "list").toLowerCase();
3812
+ if (action === "list") {
3813
+ if (!ctx.hasUI) return;
3814
+ const listed = customVerbs.length > 0 ? customVerbs.map((v) => `- ${v}`).join("\n") : "No custom spinner verbs configured.";
3815
+ ctx.ui.notify(`Custom spinner verbs (${customVerbMode}, ${customVerbs.length})\n${listed}\n\nCommands write user settings in ~/.pi/settings.json; project settings can be set manually in .pi/settings.json.`, "info");
3816
+ return;
3817
+ }
3818
+
3819
+ if (action === "add") {
3820
+ const verbArg = rawArgs.match(/^verbs\s+add(?:\s+(.+))?$/i)?.[1] ?? "";
3821
+ const verb = sanitizeSpinnerVerb(verbArg);
3822
+ if (!verb) {
3823
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cc-spinner verbs add <verb or phrase>", "error");
3824
+ return;
3825
+ }
3826
+ const verbKey = verb.toLocaleLowerCase();
3827
+ if (customVerbs.some((v) => v.toLocaleLowerCase() === verbKey)) {
3828
+ if (ctx.hasUI) ctx.ui.notify(`Custom spinner verb already exists: ${verb}`, "info");
3829
+ return;
3830
+ }
3831
+ if (customVerbs.length >= MAX_CUSTOM_SPINNER_VERBS) {
3832
+ if (ctx.hasUI) ctx.ui.notify(`Custom spinner verb limit reached (${MAX_CUSTOM_SPINNER_VERBS})`, "error");
3833
+ return;
3834
+ }
3835
+ const next = sanitizeSpinnerVerbs([...customVerbs, verb]);
3836
+ writeSettingsKey("spinnerVerbs", next);
3837
+ bustSpinnerSettingsCache();
3838
+ if (ctx.hasUI) ctx.ui.notify(`Added custom spinner verb: ${verb}`, "info");
3839
+ return;
3840
+ }
3841
+
3842
+ if (action === "remove") {
3843
+ const verbArg = rawArgs.match(/^verbs\s+remove(?:\s+(.+))?$/i)?.[1] ?? "";
3844
+ const verb = sanitizeSpinnerVerb(verbArg);
3845
+ if (!verb) {
3846
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cc-spinner verbs remove <verb or phrase>", "error");
3847
+ return;
3848
+ }
3849
+ const removeKey = verb.toLocaleLowerCase();
3850
+ const next = customVerbs.filter((v) => v.toLocaleLowerCase() !== removeKey);
3851
+ if (next.length === customVerbs.length) {
3852
+ if (ctx.hasUI) ctx.ui.notify(`Custom spinner verb not found: ${verb}`, "info");
3853
+ return;
3854
+ }
3855
+ writeSettingsKey("spinnerVerbs", next.length > 0 ? next : undefined);
3856
+ bustSpinnerSettingsCache();
3857
+ if (ctx.hasUI) ctx.ui.notify(`Removed custom spinner verb: ${verb}`, "info");
3858
+ return;
3859
+ }
3860
+
3861
+ if (action === "reset") {
3862
+ writeSettingsKey("spinnerVerbs", undefined);
3863
+ writeSettingsKey("spinnerVerbMode", undefined);
3864
+ bustSpinnerSettingsCache();
3865
+ if (ctx.hasUI) ctx.ui.notify("User custom spinner verbs reset. Project .pi/settings.json custom verbs may still apply.", "info");
3866
+ return;
3867
+ }
3868
+
3869
+ if (action === "mode") {
3870
+ const mode = (parts[2] ?? "").toLowerCase();
3871
+ if (mode !== "append" && mode !== "replace") {
3872
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cc-spinner verbs mode append|replace", "error");
3873
+ return;
3874
+ }
3875
+ writeSettingsKey("spinnerVerbMode", mode);
3876
+ bustSpinnerSettingsCache();
3877
+ if (ctx.hasUI) ctx.ui.notify(`Custom spinner verb mode → ${mode}`, "info");
3878
+ return;
3879
+ }
3880
+
3881
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cc-spinner verbs list|add <verb>|remove <verb>|reset|mode append|replace", "error");
3882
+ return;
3883
+ }
3884
+
3885
+ if (sub === "reset") {
3886
+ writeSettingsKey("spinnerColor", undefined);
3887
+ writeSettingsKey("spinnerVerbColor", undefined);
3888
+ writeSettingsKey("spinnerStatusColor", undefined);
3889
+ bustSpinnerSettingsCache();
3890
+ if (ctx.hasUI) ctx.ui.notify("Spinner colors reset to defaults (spinner=borderAccent, status=muted)", "info");
3891
+ return;
3892
+ }
3893
+
3894
+ if (sub !== "color" && sub !== "verb" && sub !== "status") {
3895
+ if (ctx.hasUI) ctx.ui.notify(`Usage: /cc-spinner color <key> | status <key> | reset | preview | verbs list|add|remove|reset|mode`, "error");
3896
+ return;
3897
+ }
3898
+
3899
+ const key = parts[1];
3900
+ if (!key) {
3901
+ if (ctx.hasUI) ctx.ui.notify(`Missing color key. Try /cc-spinner preview to see available keys.`, "error");
3902
+ return;
3903
+ }
3904
+
3905
+ // Validate the key resolves to *some* color in the active theme;
3906
+ // accept anyway if the user insists so themes with custom keys work.
3907
+ const ansi = theme ? safeFgAnsi(theme, key) : null;
3908
+ if (sub === "status") {
3909
+ writeSettingsKey("spinnerStatusColor", key);
3910
+ } else {
3911
+ writeSettingsKey("spinnerColor", key);
3912
+ writeSettingsKey("spinnerVerbColor", undefined);
3913
+ }
3914
+ bustSpinnerSettingsCache();
3915
+ if (ctx.hasUI) {
3916
+ const sample = ansi ? `${ansi}sample\x1b[39m` : "(key unmapped in current theme)";
3917
+ const target = sub === "status" ? "status" : "glyph+verb";
3918
+ ctx.ui.notify(`Spinner ${target} color → ${key} ${sample}`, "info");
3919
+ }
3920
+ },
3921
+ });
3922
+
3923
+ pi.on("session_start", async (_event, ctx) => {
3924
+ if (!ctx.hasUI) return;
3925
+ applyToolBackgroundMode(ctx.ui.theme);
3926
+ applyThemePaletteIfNeeded(ctx.ui.theme);
3927
+ });
3928
+
3929
+ pi.on("turn_start", async (_event, ctx) => {
3930
+ if (!ctx.hasUI) return;
3931
+ applyToolBackgroundMode(ctx.ui.theme);
3932
+ applyThemePaletteIfNeeded(ctx.ui.theme);
3933
+ });
3934
+
3935
+ const cwd = process.cwd();
3936
+ const sp = (path: string) => shortPath(cwd, path);
3937
+
3938
+ const readTool = createReadTool(cwd);
3939
+ pi.registerTool({
3940
+ name: "read",
3941
+ label: "read",
3942
+ description: readTool.description,
3943
+ parameters: readTool.parameters,
3944
+ async execute(toolCallId, params, signal, onUpdate) {
3945
+ return readTool.execute(toolCallId, params, signal, onUpdate);
3946
+ },
3947
+ renderCall(args, theme, ctx) {
3948
+ syncToolCallStatus(ctx);
3949
+ const summary = stableCallSummary(ctx, "_callSummary", () => {
3950
+ let value = sp(args.path ?? "");
3951
+ if (args.offset || args.limit) {
3952
+ const parts: string[] = [];
3953
+ if (args.offset) parts.push(`offset=${args.offset}`);
3954
+ if (args.limit) parts.push(`limit=${args.limit}`);
3955
+ value += ` ${theme.fg("muted", `(${parts.join(", ")})`)}`;
3956
+ }
3957
+ return value;
3958
+ });
3959
+ return makeText(ctx.lastComponent, toolHeader("Read", summary, theme, toolStatusDot(ctx, theme)));
3960
+ },
3961
+ renderResult(result, { expanded, isPartial }, theme, ctx) {
3962
+ if (isPartial) {
3963
+ setupBlinkTimer(ctx);
3964
+ return makeText(ctx.lastComponent, withBranch(theme.fg("dim", "Reading..."), theme));
3965
+ }
3966
+ clearBlinkTimer(ctx);
3967
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
3968
+ if (getFirstImageBlock(result)) return renderReadImageResult(result, expanded, theme, ctx);
3969
+ const details = result.details as ReadToolDetails | undefined;
3970
+ const content = result.content.find((block: any) => block?.type === "text");
3971
+ if (content?.type !== "text") return makeText(ctx.lastComponent, withBranch(theme.fg("error", "No text content"), theme));
3972
+ const lines = content.text.split("\n");
3973
+ let text = theme.fg("muted", `${lines.length} lines loaded`);
3974
+ if (details?.truncation?.truncated) text += theme.fg("warning", " (truncated)");
3975
+ if (!expanded) return makeText(ctx.lastComponent, withBranch(`${text}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
3976
+ const shown = lines.slice(0, previewLimit());
3977
+ text += `\n${buildPreviewText(shown.map((line) => theme.fg("dim", line || " ")), false, theme, previewLimit())}`;
3978
+ return makeText(ctx.lastComponent, withBranch(text, theme));
3979
+ },
3980
+ });
3981
+
3982
+ const bashTool = createBashTool(cwd);
3983
+ pi.registerTool({
3984
+ name: "bash",
3985
+ label: "bash",
3986
+ description: bashTool.description,
3987
+ parameters: bashTool.parameters,
3988
+ async execute(toolCallId, params, signal, onUpdate) {
3989
+ return bashTool.execute(toolCallId, params, signal, onUpdate);
3990
+ },
3991
+ renderCall(args, theme, ctx) {
3992
+ syncToolCallStatus(ctx);
3993
+ const summary = stableCallSummary(ctx, "_callSummary", () => summarizeText(args.command, 72));
3994
+ return makeText(ctx.lastComponent, toolHeader("Bash", summary, theme, toolStatusDot(ctx, theme)));
3995
+ },
3996
+ renderResult(result, { expanded, isPartial }, theme, ctx) {
3997
+ const details = result.details as BashToolDetails | undefined;
3998
+ const output = result.content[0]?.type === "text" ? result.content[0].text : "";
3999
+ const nonEmpty = output.split("\n").filter((line) => line.trim().length > 0);
4000
+ if (isPartial) {
4001
+ setupBlinkTimer(ctx);
4002
+ return makeText(ctx.lastComponent, withBranch(theme.fg("warning", `Running... (${nonEmpty.length} lines)`), theme));
4003
+ }
4004
+ clearBlinkTimer(ctx);
4005
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
4006
+ const exitMatch = output.match(/exit code: (\d+)/);
4007
+ const exitCode = exitMatch ? Number.parseInt(exitMatch[1], 10) : null;
4008
+ let text = exitCode === null || exitCode === 0 ? theme.fg("success", "Done") : theme.fg("error", `Exit ${exitCode}`);
4009
+ text += theme.fg("muted", ` (${nonEmpty.length} lines)`);
4010
+ if (details?.truncation?.truncated) text += theme.fg("warning", " [truncated]");
4011
+ if (!expanded && nonEmpty.length > 0) return makeText(ctx.lastComponent, withBranch(`${text}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
4012
+ if (!expanded) return makeText(ctx.lastComponent, withBranch(text, theme));
4013
+ const collapsed = bashCollapsedLimit();
4014
+ text += `\n${buildPreviewText(nonEmpty.map((line) => theme.fg("dim", line)), false, theme, collapsed)}`;
4015
+ return makeText(ctx.lastComponent, withBranch(text, theme));
4016
+ },
4017
+ });
4018
+
4019
+ const grepTool = createGrepTool(cwd);
4020
+ pi.registerTool({
4021
+ name: "grep",
4022
+ label: "grep",
4023
+ description: grepTool.description,
4024
+ parameters: grepTool.parameters,
4025
+ async execute(toolCallId, params, signal, onUpdate) {
4026
+ return grepTool.execute(toolCallId, params, signal, onUpdate);
4027
+ },
4028
+ renderCall(args, theme, ctx) {
4029
+ syncToolCallStatus(ctx);
4030
+ const summary = stableCallSummary(ctx, "_callSummary", () => {
4031
+ let value = `\"${summarizeText(args.pattern, 40)}\"`;
4032
+ if (args.path) value += ` in ${args.path}`;
4033
+ return value;
4034
+ });
4035
+ return makeText(ctx.lastComponent, toolHeader("Grep", summary, theme, toolStatusDot(ctx, theme)));
4036
+ },
4037
+ renderResult(result, { expanded, isPartial }, theme, ctx) {
4038
+ if (isPartial) {
4039
+ setupBlinkTimer(ctx);
4040
+ return makeText(ctx.lastComponent, withBranch(theme.fg("dim", "Searching..."), theme));
4041
+ }
4042
+ clearBlinkTimer(ctx);
4043
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
4044
+ const details = result.details as GrepToolDetails | undefined;
4045
+ const matches = (result.content[0]?.type === "text" ? result.content[0].text : "")
4046
+ .split("\n")
4047
+ .filter((line) => line.trim().length > 0);
4048
+ if (matches.length === 0) return makeText(ctx.lastComponent, withBranch(theme.fg("muted", "no matches"), theme));
4049
+ let text = theme.fg("muted", `${matches.length} matches`);
4050
+ if (details?.truncation?.truncated) text += theme.fg("warning", " (truncated)");
4051
+ if (!expanded) return makeText(ctx.lastComponent, withBranch(`${text}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
4052
+ text += `\n${buildPreviewText(matches.map((line) => theme.fg("dim", line)), false, theme, previewLimit())}`;
4053
+ return makeText(ctx.lastComponent, withBranch(text, theme));
4054
+ },
4055
+ });
4056
+
4057
+ const findTool = createFindTool(cwd);
4058
+ pi.registerTool({
4059
+ name: "find",
4060
+ label: "find",
4061
+ description: findTool.description,
4062
+ parameters: findTool.parameters,
4063
+ async execute(toolCallId, params, signal, onUpdate) {
4064
+ return findTool.execute(toolCallId, params, signal, onUpdate);
4065
+ },
4066
+ renderCall(args, theme, ctx) {
4067
+ syncToolCallStatus(ctx);
4068
+ const summary = stableCallSummary(ctx, "_callSummary", () => {
4069
+ let value = `\"${summarizeText(args.pattern, 40)}\"`;
4070
+ if (args.path) value += ` in ${args.path}`;
4071
+ return value;
4072
+ });
4073
+ return makeText(ctx.lastComponent, toolHeader("Find", summary, theme, toolStatusDot(ctx, theme)));
4074
+ },
4075
+ renderResult(result, { expanded, isPartial }, theme, ctx) {
4076
+ if (isPartial) {
4077
+ setupBlinkTimer(ctx);
4078
+ return makeText(ctx.lastComponent, withBranch(theme.fg("dim", "Finding..."), theme));
4079
+ }
4080
+ clearBlinkTimer(ctx);
4081
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
4082
+ const items = (result.content[0]?.type === "text" ? result.content[0].text : "")
4083
+ .split("\n")
4084
+ .filter((line) => line.trim().length > 0);
4085
+ if (items.length === 0) return makeText(ctx.lastComponent, withBranch(theme.fg("muted", "no files found"), theme));
4086
+ let text = theme.fg("muted", `${items.length} files`);
4087
+ if (!expanded) return makeText(ctx.lastComponent, withBranch(`${text}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
4088
+ // Expanded: grouped find results with icons
4089
+ const maxShow = previewLimit();
4090
+ const shown = items.slice(0, maxShow);
4091
+ const findLines: string[] = [];
4092
+ for (let i = 0; i < shown.length; i++) {
4093
+ const item = shown[i].trim();
4094
+ const icon = fileIcon(item);
4095
+ findLines.push(` ${icon}${theme.fg("dim", item)}`);
4096
+ }
4097
+ const remaining = items.length - shown.length;
4098
+ if (remaining > 0) {
4099
+ findLines.push(` ${theme.fg("muted", `… ${remaining} more files`)}`);
4100
+ }
4101
+ text += `\n${findLines.join('\n')}`;
4102
+ return makeText(ctx.lastComponent, withBranch(text, theme));
4103
+ },
4104
+ });
4105
+
4106
+ const lsTool = createLsTool(cwd);
4107
+ pi.registerTool({
4108
+ name: "ls",
4109
+ label: "ls",
4110
+ description: lsTool.description,
4111
+ parameters: lsTool.parameters,
4112
+ async execute(toolCallId, params, signal, onUpdate) {
4113
+ return lsTool.execute(toolCallId, params, signal, onUpdate);
4114
+ },
4115
+ renderCall(args, theme, ctx) {
4116
+ syncToolCallStatus(ctx);
4117
+ const summary = stableCallSummary(ctx, "_callSummary", () => sp(args.path ?? "."));
4118
+ return makeText(ctx.lastComponent, toolHeader("List", summary, theme, toolStatusDot(ctx, theme)));
4119
+ },
4120
+ renderResult(result, { expanded, isPartial }, theme, ctx) {
4121
+ if (isPartial) {
4122
+ setupBlinkTimer(ctx);
4123
+ return makeText(ctx.lastComponent, withBranch(theme.fg("dim", "Listing..."), theme));
4124
+ }
4125
+ clearBlinkTimer(ctx);
4126
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
4127
+ const items = (result.content[0]?.type === "text" ? result.content[0].text : "")
4128
+ .split("\n")
4129
+ .filter((line) => line.trim().length > 0);
4130
+ if (items.length === 0) return makeText(ctx.lastComponent, withBranch(theme.fg("muted", "empty directory"), theme));
4131
+ let text = theme.fg("muted", `${items.length} entries`);
4132
+ if (!expanded) return makeText(ctx.lastComponent, withBranch(`${text}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
4133
+ // Expanded: tree-view with icons
4134
+ const maxShow = previewLimit();
4135
+ const shown = items.slice(0, maxShow);
4136
+ const treeLines: string[] = [];
4137
+ for (let i = 0; i < shown.length; i++) {
4138
+ const item = shown[i];
4139
+ const isDir = item.endsWith("/");
4140
+ const isLast = i === shown.length - 1 && items.length <= maxShow;
4141
+ const prefix = isLast ? `${FG_RULE}\u2514\u2500\u2500${D_RST} ` : `${FG_RULE}\u251c\u2500\u2500${D_RST} `;
4142
+ const icon = isDir ? dirIcon() : fileIcon(item);
4143
+ const name = isDir ? theme.fg("accent", theme.bold(item)) : theme.fg("dim", item);
4144
+ treeLines.push(`${prefix}${icon}${name}`);
4145
+ }
4146
+ const remaining = items.length - shown.length;
4147
+ if (remaining > 0) {
4148
+ treeLines.push(`${FG_RULE}\u2514\u2500\u2500${D_RST} ${theme.fg("muted", `\u2026 ${remaining} more entries`)}`);
4149
+ }
4150
+ text += `\n${treeLines.join('\n')}`;
4151
+ return makeText(ctx.lastComponent, withBranch(text, theme));
4152
+ },
4153
+ });
4154
+
4155
+ const writeTool = createWriteTool(cwd);
4156
+ pi.registerTool({
4157
+ name: "write",
4158
+ label: "write",
4159
+ description: writeTool.description,
4160
+ parameters: writeTool.parameters,
4161
+ async execute(toolCallId, params, signal, onUpdate, _ctx) {
4162
+ const fp = params.path ?? (params as any).file_path ?? "";
4163
+ const fullPath = fp ? resolve(cwd, fp) : "";
4164
+ const existedBefore = !!fullPath && fileExistsForTool(cwd, fp);
4165
+ WRITE_EXISTED_BEFORE.set(toolCallId, existedBefore);
4166
+ let old: string | null = null;
4167
+ try {
4168
+ if (fullPath && existedBefore) old = readFileSync(fullPath, "utf-8");
4169
+ } catch {
4170
+ old = null;
4171
+ }
4172
+ const result = await writeTool.execute(toolCallId, params, signal, onUpdate);
4173
+ const content = params.content ?? "";
4174
+ if (old !== null && old !== content) {
4175
+ const diff = parseDiff(old, content);
4176
+ (result as any).details = { _type: "diff", summary: summarizeDiff(diff.added, diff.removed), diff, language: lang(fp) };
4177
+ } else if (old === null) {
4178
+ (result as any).details = { _type: "new", lines: lineCount(content), filePath: fp };
4179
+ } else if (old === content) {
4180
+ (result as any).details = { _type: "noChange" };
4181
+ }
4182
+ return result;
4183
+ },
4184
+ renderCall(args, theme, ctx) {
4185
+ const fp = args?.path ?? (args as any)?.file_path ?? "";
4186
+ const revealSummary = shouldRevealCallArgs(ctx) || (!!fp && hasOwnArg(args, "content"));
4187
+ syncToolCallStatus(ctx);
4188
+ const wasNew = getWriteWasNewFile(ctx, cwd, fp, revealSummary);
4189
+ const label = wasNew === true ? "Create" : "Write";
4190
+ const summary = stableCallSummary(ctx, "_callSummary", () => {
4191
+ const base = sp(fp);
4192
+ return shouldRevealCallArgs(ctx) ? `${base} ${theme.fg("muted", `(${lineCount(args.content ?? "")} lines)`)}` : base;
4193
+ }, revealSummary);
4194
+ const hdr = toolHeader(label, summary, theme, toolStatusDot(ctx, theme));
4195
+ return makeText(ctx.lastComponent, hdr);
4196
+ },
4197
+ renderResult(result, { isPartial }, theme, ctx) {
4198
+ if (isPartial) {
4199
+ setupBlinkTimer(ctx);
4200
+ return makeText(ctx.lastComponent, withBranch(theme.fg("dim", "Writing..."), theme));
4201
+ }
4202
+ clearBlinkTimer(ctx);
4203
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
4204
+ if (typeof ctx?.toolCallId === "string") WRITE_EXISTED_BEFORE.delete(ctx.toolCallId);
4205
+ if (ctx.isError) {
4206
+ const e =
4207
+ result.content
4208
+ ?.filter((c: any) => c.type === "text")
4209
+ .map((c: any) => c.text || "")
4210
+ .join("\n") ?? "Error";
4211
+ return makeText(ctx.lastComponent, withBranch(theme.fg("error", e), theme));
4212
+ }
4213
+ const d = (result as any).details;
4214
+ if (d?._type === "diff") {
4215
+ const previewLines = ctx.expanded ? MAX_RENDER_LINES : diffCollapsedLimit();
4216
+ const hunks = d.diff?.lines?.filter((l: any) => l.type === "sep").length + (d.diff?.lines?.length ? 1 : 0);
4217
+ const diffWidth = branchDiffWidth();
4218
+ const mode = shouldUseSplit(d.diff, diffWidth, previewLines) ? "split" : "unified";
4219
+ const richSummary = diffSummaryWithMeta(d.diff.added, d.diff.removed, hunks, mode);
4220
+ const key = `wd:${diffWidth}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}:${ctx.expanded ? 1 : 0}`;
4221
+ if (ctx.state._wdk !== key) {
4222
+ ctx.state._wdk = key;
4223
+ ctx.state._wdt = withFinalBranchBlock(`${richSummary}\n${theme.fg("muted", "rendering diff…")}`, theme);
4224
+ const dc = resolveDiffColors(theme);
4225
+ renderSplit(d.diff, d.language, previewLines, dc, diffWidth)
4226
+ .then((rendered) => {
4227
+ if (ctx.state._wdk !== key) return;
4228
+ ctx.state._wdt = withFinalBranchBlock(`${richSummary}\n${rendered}`, theme);
4229
+ ctx.invalidate();
4230
+ })
4231
+ .catch(() => {
4232
+ if (ctx.state._wdk !== key) return;
4233
+ ctx.state._wdt = withBranch(richSummary, theme);
4234
+ ctx.invalidate();
4235
+ });
4236
+ }
4237
+ return makeText(ctx.lastComponent, ctx.state._wdt ?? withBranch(richSummary, theme));
4238
+ }
4239
+ if (d?._type === "noChange") return makeText(ctx.lastComponent, withBranch(theme.fg("muted", "✓ no changes"), theme));
4240
+ if (d?._type === "new") {
4241
+ const content = typeof ctx.args?.content === "string" ? ctx.args.content : "";
4242
+ const lineTotal = typeof d.lines === "number" ? d.lines : lineCount(content);
4243
+ const contentHash = hashText(content);
4244
+ const syntheticDiff = getCachedParsedDiff(ctx, `nf-diff:${d.filePath}:${contentHash}`, "", content);
4245
+ const richSummary = diffSummaryWithMeta(syntheticDiff.added, 0, 1, "new file");
4246
+ const previewLines = ctx.expanded ? MAX_RENDER_LINES : diffCollapsedLimit();
4247
+ const diffWidth = branchDiffWidth();
4248
+ const pk = `nf:${d.filePath}:${contentHash}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
4249
+ if (ctx.state._nfk !== pk) {
4250
+ ctx.state._nfk = pk;
4251
+ ctx.state._nft = withFinalBranchBlock(`${richSummary}\n${theme.fg("muted", "rendering diff…")}`, theme);
4252
+ const dc = resolveDiffColors(theme);
4253
+ renderUnified(syntheticDiff, lang(d.filePath), previewLines, dc, diffWidth)
4254
+ .then((rendered) => {
4255
+ if (ctx.state._nfk !== pk) return;
4256
+ ctx.state._nft = withFinalBranchBlock(`${richSummary}\n${rendered}`, theme);
4257
+ ctx.invalidate();
4258
+ })
4259
+ .catch(() => {
4260
+ if (ctx.state._nfk !== pk) return;
4261
+ ctx.state._nft = withBranch(`${richSummary} ${theme.fg("muted", `(${lineTotal} lines)`)}`, theme);
4262
+ ctx.invalidate();
4263
+ });
4264
+ }
4265
+ return makeText(ctx.lastComponent, ctx.state._nft ?? withBranch(`${richSummary} ${theme.fg("muted", `(${lineTotal} lines)`)}`, theme));
4266
+ }
4267
+ return makeText(ctx.lastComponent, withBranch(theme.fg("success", "Written"), theme));
4268
+ },
4269
+ });
4270
+
4271
+ const editTool = createEditTool(cwd);
4272
+ pi.registerTool({
4273
+ name: "edit",
4274
+ label: "edit",
4275
+ description: editTool.description,
4276
+ parameters: editTool.parameters,
4277
+ async execute(toolCallId, params, signal, onUpdate, _ctx) {
4278
+ const fp = params.path ?? (params as any).file_path ?? "";
4279
+ const operations = getEditOperations(params);
4280
+ const localizedDiffs = operations.length === 1 ? await computeLocalizedEditDiffs(fp, operations, cwd) : null;
4281
+ const result = await editTool.execute(toolCallId, params, signal, onUpdate);
4282
+ if (operations.length === 0) return result;
4283
+ const { diffs, summary, totalLines, totalHunks } = summarizeEditOperations(operations);
4284
+ const baseDetails = (((result as any).details ?? {}) as Record<string, unknown>);
4285
+ if (operations.length === 1) {
4286
+ const localized = localizedDiffs?.[0];
4287
+ const editLine = localized?.line ?? (typeof baseDetails.firstChangedLine === "number" ? baseDetails.firstChangedLine : 0);
4288
+ const diff = localized?.diff ?? diffs[0];
4289
+ (result as any).details = {
4290
+ ...baseDetails,
4291
+ _type: "editInfo",
4292
+ summary,
4293
+ editLine,
4294
+ hunks: countDiffHunks(diff),
4295
+ added: diff?.added ?? 0,
4296
+ removed: diff?.removed ?? 0,
4297
+ };
4298
+ return result;
4299
+ }
4300
+ (result as any).details = {
4301
+ ...baseDetails,
4302
+ _type: "multiEditInfo",
4303
+ summary,
4304
+ editCount: operations.length,
4305
+ diffLineCount: totalLines,
4306
+ hunks: totalHunks,
4307
+ totalAdded: diffs.reduce((sum, diff) => sum + diff.added, 0),
4308
+ totalRemoved: diffs.reduce((sum, diff) => sum + diff.removed, 0),
4309
+ };
4310
+ return result;
4311
+ },
4312
+ renderCall(args, theme, ctx) {
4313
+ const fp = args?.path ?? (args as any)?.file_path ?? "";
4314
+ const operations = getEditOperations(args);
4315
+ const revealSummary = shouldRevealCallArgs(ctx) || (!!fp && hasOwnArg(args, "edits"));
4316
+ const summary = stableCallSummary(ctx, "_callSummary", () => shouldRevealCallArgs(ctx) && operations.length > 1 ? `${sp(fp)} ${theme.fg("muted", `(${operations.length} edits)`)}` : sp(fp), revealSummary);
4317
+ syncToolCallStatus(ctx);
4318
+ const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`);
4319
+ if (!(ctx.argsComplete && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
4320
+ const diffWidth = branchDiffWidth();
4321
+ const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
4322
+ const { diffs: fallbackDiffs, summary: editSummary } = getCachedEditOperationSummary(ctx, key, operations);
4323
+ if (ctx.state._pk !== key) {
4324
+ ctx.state._pk = key;
4325
+ ctx.state._ptBody = theme.fg("muted", "(rendering…)");
4326
+ ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
4327
+ const lg = lang(fp);
4328
+ void computeLocalizedEditDiffs(fp, operations, cwd)
4329
+ .then((localizedDiffs) => {
4330
+ if (ctx.state._pk !== key) return;
4331
+ const diffs = localizedDiffs?.map((entry) => entry.diff) ?? fallbackDiffs;
4332
+ const lines = localizedDiffs?.map((entry) => entry.line) ?? diffs.map((diff) => getFirstChangedNewLine(diff));
4333
+ renderEditPreviewBody(ctx, key, theme, lg, operations, diffs, lines, editSummary);
4334
+ })
4335
+ .catch(() => {
4336
+ if (ctx.state._pk !== key) return;
4337
+ renderEditPreviewBody(ctx, key, theme, lg, operations, fallbackDiffs, fallbackDiffs.map((diff) => getFirstChangedNewLine(diff)), editSummary);
4338
+ });
4339
+ }
4340
+ const body = ctx.state._ptDisplay as string | undefined;
4341
+ return makeText(ctx.lastComponent, body ? `${hdr}\n${body}` : hdr);
4342
+ },
4343
+ renderResult(result, { isPartial }, theme, ctx) {
4344
+ if (isPartial) {
4345
+ setupBlinkTimer(ctx);
4346
+ return makeText(ctx.lastComponent, indentBranchBlock(withBranch(theme.fg("dim", "Editing..."), theme)));
4347
+ }
4348
+ clearBlinkTimer(ctx);
4349
+ setToolStatus(ctx, ctx.isError ? "error" : "success");
4350
+ if (ctx.isError) {
4351
+ const e =
4352
+ result.content
4353
+ ?.filter((c: any) => c.type === "text")
4354
+ .map((c: any) => c.text || "")
4355
+ .join("\n") ?? "Error";
4356
+ return makeText(ctx.lastComponent, indentBranchBlock(withBranch(theme.fg("error", e), theme)));
4357
+ }
4358
+ if ((result as any).details?._type === "editInfo") {
4359
+ const { editLine, hunks, added, removed } = (result as any).details;
4360
+ const loc = formatLineMeta(editLine ?? 0, theme);
4361
+ const summary = diffSummaryWithMeta(added ?? 0, removed ?? 0, hunks ?? 0, "");
4362
+ return makeText(ctx.lastComponent, indentBranchBlock(withBranch(`${summary}${loc}`, theme)));
4363
+ }
4364
+ if ((result as any).details?._type === "multiEditInfo") {
4365
+ const { editCount, diffLineCount, hunks, totalAdded, totalRemoved } = (result as any).details;
4366
+ const summary = diffSummaryWithMeta(totalAdded ?? 0, totalRemoved ?? 0, hunks ?? 0, "");
4367
+ return makeText(ctx.lastComponent, indentBranchBlock(withBranch(`${editCount} edits ${summary}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`, theme)));
4368
+ }
4369
+ return makeText(ctx.lastComponent, indentBranchBlock(withBranch(theme.fg("success", "Applied"), theme)));
4370
+ },
4371
+ });
4372
+
4373
+ const wrappedOpenAiTools = new Set<string>();
4374
+ const registerOpenAiToolOverrides = (): void => {
4375
+ let allTools: unknown[] = [];
4376
+ try {
4377
+ allTools = typeof (pi as any).getAllTools === "function" ? (pi as any).getAllTools() : [];
4378
+ } catch {
4379
+ allTools = [];
4380
+ }
4381
+ for (const tool of allTools) {
4382
+ if (!isOpenAiToolCandidate(tool)) continue;
4383
+ const record = tool as Record<string, unknown>;
4384
+ const name = typeof record.name === "string" ? record.name : "";
4385
+ if (!name || wrappedOpenAiTools.has(name)) continue;
4386
+ const execute = typeof record.execute === "function" ? (record.execute as any) : null;
4387
+ if (!execute) continue;
4388
+ const rawLabel = typeof record.label === "string" ? record.label.trim() : "";
4389
+ const label = rawLabel && rawLabel !== name && !rawLabel.includes("_") ? rawLabel : humanizeToolName(name);
4390
+ const description = typeof record.description === "string" ? record.description : label;
4391
+ (pi as any).registerTool({
4392
+ name,
4393
+ label,
4394
+ description,
4395
+ parameters: record.parameters,
4396
+ prepareArguments: typeof record.prepareArguments === "function" ? record.prepareArguments : undefined,
4397
+ async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
4398
+ return await Promise.resolve(execute(toolCallId, params, signal, onUpdate, ctx));
4399
+ },
4400
+ renderCall(args: any, theme: Theme, ctx: any) {
4401
+ if (name === "apply_patch") return renderApplyPatchCall(args, theme, ctx, sp);
4402
+ syncToolCallStatus(ctx);
4403
+ ctx.state._openAiPatchFiles = [];
4404
+ const summary = stableCallSummary(ctx, "_callSummary", () => summarizeOpenAiToolCall(name, args, theme, sp));
4405
+ return makeText(ctx.lastComponent, toolHeader(label, summary, theme, toolStatusDot(ctx, theme)));
4406
+ },
4407
+ renderResult(result: any, { expanded, isPartial }: any, theme: Theme, ctx: any) {
4408
+ if (name === "apply_patch") return renderApplyPatchResult(result, isPartial, theme, ctx);
4409
+ return renderOpenAiToolResult(name, result, expanded, isPartial, theme, ctx);
4410
+ },
4411
+ });
4412
+ wrappedOpenAiTools.add(name);
4413
+ }
4414
+ };
4415
+
4416
+ const wrappedMcpTools = new Set<string>();
4417
+ const registerMcpToolOverrides = (): void => {
4418
+ let allTools: unknown[] = [];
4419
+ try {
4420
+ allTools = typeof (pi as any).getAllTools === "function" ? (pi as any).getAllTools() : [];
4421
+ } catch {
4422
+ allTools = [];
4423
+ }
4424
+ for (const tool of allTools) {
4425
+ if (!isMcpToolCandidate(tool)) continue;
4426
+ const record = tool as Record<string, unknown>;
4427
+ const name = typeof record.name === "string" ? record.name : "";
4428
+ if (!name || wrappedMcpTools.has(name)) continue;
4429
+ const execute = typeof record.execute === "function" ? (record.execute as any) : null;
4430
+ if (!execute) continue;
4431
+ const label = typeof record.label === "string" ? record.label : name === "mcp" ? "MCP" : `MCP ${name}`;
4432
+ const description = typeof record.description === "string" ? record.description : "MCP tool";
4433
+ (pi as any).registerTool({
4434
+ name,
4435
+ label,
4436
+ description,
4437
+ parameters: record.parameters,
4438
+ prepareArguments: typeof record.prepareArguments === "function" ? record.prepareArguments : undefined,
4439
+ async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
4440
+ return await Promise.resolve(execute(toolCallId, params, signal, onUpdate, ctx));
4441
+ },
4442
+ renderCall(args: any, theme: Theme, ctx: any) {
4443
+ return renderGenericToolCall(name, args, theme, ctx);
4444
+ },
4445
+ renderResult(result: any, { expanded, isPartial }: any, theme: Theme, ctx: any) {
4446
+ return renderMcpToolResult(result, expanded, isPartial, theme, ctx);
4447
+ },
4448
+ });
4449
+ wrappedMcpTools.add(name);
4450
+ }
4451
+ };
4452
+
4453
+ pi.on("session_start", async () => {
4454
+ registerOpenAiToolOverrides();
4455
+ registerMcpToolOverrides();
4456
+ });
4457
+ pi.on("before_agent_start", async () => {
4458
+ registerOpenAiToolOverrides();
4459
+ registerMcpToolOverrides();
4460
+ });
4461
+
4462
+ // Safety net: clear all blink timers on turn/session boundaries
4463
+ pi.on("turn_end", async () => {
4464
+ for (const entry of _blinkContexts.values()) {
4465
+ entry.key._blinkActive = false;
4466
+ }
4467
+ _blinkContexts.clear();
4468
+ clearHighlightCache();
4469
+ if (_globalBlinkTimer) {
4470
+ clearTimeout(_globalBlinkTimer);
4471
+ _globalBlinkTimer = null;
4472
+ }
4473
+ });
4474
+ pi.on("session_shutdown", async () => {
4475
+ for (const entry of _blinkContexts.values()) {
4476
+ entry.key._blinkActive = false;
4477
+ }
4478
+ _blinkContexts.clear();
4479
+ clearHighlightCache();
4480
+ if (_globalBlinkTimer) {
4481
+ clearTimeout(_globalBlinkTimer);
4482
+ _globalBlinkTimer = null;
4483
+ }
4484
+ });
4485
+ }