pi-cc-extensions 0.8.9 → 0.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +3 -2
  2. package/extensions/agent-autocomplete.ts +11 -16
  3. package/extensions/ask-user-question/ask-user-question.ts +29 -7
  4. package/extensions/ask-user-question/config.ts +6 -2
  5. package/extensions/ask-user-question/index.ts +8 -8
  6. package/extensions/ask-user-question/rpc-fallback.ts +16 -4
  7. package/extensions/ask-user-question/state/build-questionnaire.ts +16 -5
  8. package/extensions/ask-user-question/state/key-router.ts +29 -7
  9. package/extensions/ask-user-question/state/questionnaire-session.ts +1 -4
  10. package/extensions/ask-user-question/state/selectors/projections.ts +4 -1
  11. package/extensions/ask-user-question/state/state-reducer.ts +24 -7
  12. package/extensions/ask-user-question/tool/response-envelope.ts +4 -1
  13. package/extensions/ask-user-question/tool/types.ts +5 -1
  14. package/extensions/ask-user-question/tool/validate-questionnaire.ts +10 -2
  15. package/extensions/ask-user-question/view/components/inline-input.ts +15 -2
  16. package/extensions/ask-user-question/view/components/multi-select-view.ts +11 -4
  17. package/extensions/ask-user-question/view/components/option-list-view.ts +14 -5
  18. package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +13 -7
  19. package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +16 -4
  20. package/extensions/ask-user-question/view/components/preview/preview-pane.ts +5 -1
  21. package/extensions/ask-user-question/view/components/submit-picker.ts +3 -1
  22. package/extensions/ask-user-question/view/components/wrapping-select.ts +20 -4
  23. package/extensions/ask-user-question/view/dialog-builder.ts +27 -7
  24. package/extensions/ask-user-question/view/tab-content-strategy.ts +23 -8
  25. package/extensions/claude-code-style.ts +1722 -1485
  26. package/extensions/compact-style.ts +120 -43
  27. package/extensions/context.ts +502 -394
  28. package/extensions/session-reference-core.ts +8 -3
  29. package/extensions/session-reference.ts +475 -448
  30. package/extensions/tool-diff/ATTRIBUTION.md +28 -0
  31. package/extensions/tool-diff/ansi-utils.ts +113 -0
  32. package/extensions/tool-diff/diff-presentation.ts +70 -0
  33. package/extensions/tool-diff/diff-renderer.ts +2827 -0
  34. package/extensions/tool-diff/index.ts +106 -0
  35. package/extensions/tool-diff/line-width-safety.ts +91 -0
  36. package/extensions/tool-diff/render-utils.ts +5 -0
  37. package/extensions/tool-diff/types.ts +20 -0
  38. package/extensions/tool-diff/write-display-utils.ts +29 -0
  39. package/extensions/tool-diff/write-execution.ts +113 -0
  40. package/extensions/tool-result-sanitize.ts +4 -4
  41. package/package.json +64 -61
  42. package/themes/github-dark-default.json +91 -91
@@ -1,1485 +1,1722 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import {
3
- DynamicBorder,
4
- keyHint,
5
- ToolExecutionComponent,
6
- } from "@earendil-works/pi-coding-agent";
7
- import {
8
- installCompactStyle,
9
- type CompactStyleHooks,
10
- type CompactStyleMode,
11
- } from "./compact-style.ts";
12
- import { sanitizeToolResultText } from "./tool-result-sanitize.ts";
13
- import { Container, SelectList, Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
14
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
- import { homedir } from "node:os";
16
- import { join } from "node:path";
17
-
18
- /**
19
- * Claude Code Style for pi.
20
- *
21
- * This is the package's only entry point. Compact transcript rendering lives in
22
- * the internal compact-style module and is routed by the mode below.
23
- */
24
-
25
- export type Config = {
26
- mode: CompactStyleMode;
27
- excludeRenderers: string[];
28
- };
29
-
30
- const CONFIG_PATH = join(homedir(), ".pi", "agent", "claude-code-style.json");
31
-
32
- export function normalizeConfig(input: unknown): Config {
33
- const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
34
- const mode = source.mode;
35
- const migratedMode: CompactStyleMode =
36
- mode === "on" || mode === "off" || mode === "compact"
37
- ? mode
38
- : typeof source.enabled === "boolean"
39
- ? (source.enabled ? "on" : "off")
40
- : "on";
41
- const excludeRenderers = Array.isArray(source.excludeRenderers)
42
- ? [...new Set(source.excludeRenderers.filter((name): name is string => typeof name === "string" && name.length > 0))]
43
- : [];
44
- return { mode: migratedMode, excludeRenderers };
45
- }
46
-
47
- let config: Config = loadConfig();
48
-
49
- function loadConfig(): Config {
50
- try {
51
- if (existsSync(CONFIG_PATH)) {
52
- const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as unknown;
53
- const normalized = normalizeConfig(parsed);
54
- const source = parsed as Record<string, unknown>;
55
- // Persist the one-time enabled:boolean -> mode migration while retaining
56
- // the existing exclusion list.
57
- if (
58
- typeof source.enabled === "boolean"
59
- && (source.mode !== "on" && source.mode !== "off" && source.mode !== "compact")
60
- ) {
61
- try {
62
- writeFileSync(CONFIG_PATH, JSON.stringify(normalized, null, 2));
63
- } catch {
64
- // A read-only config still uses the migrated in-memory value.
65
- }
66
- }
67
- return normalized;
68
- }
69
- } catch {
70
- // Ignore bad config and fall back to defaults.
71
- }
72
- return { mode: "on", excludeRenderers: [] };
73
- }
74
-
75
- function saveConfig() {
76
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
77
- }
78
-
79
- function oneLine(value: unknown, max = 72): string {
80
- const text = String(value ?? "")
81
- .replace(/\s+/g, " ")
82
- .trim();
83
- return text.length > max ? `${text.slice(0, max - 1)}…` : text;
84
- }
85
-
86
- function textFromResult(result: any): string {
87
- const item = result?.content?.find?.((c: any) => c.type === "text");
88
- return item?.type === "text" ? sanitizeToolResultText(item.text ?? "") : "";
89
- }
90
-
91
- function countLines(text: string): number {
92
- return text.trim().split("\n").filter((line) => line.trim().length > 0).length;
93
- }
94
-
95
- function hasExpandableResult(text: string): boolean {
96
- return countLines(text) > 1;
97
- }
98
-
99
- function toolIcon(_name: string): string {
100
- return "●";
101
- }
102
-
103
- // Match the braille loader shown to the left of pi's "Working..." row.
104
- // Every frame is one cell wide, so tool titles remain horizontally stable.
105
- const WORKING_LOADER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
106
- const TOOL_PENDING_FRAMES: Record<string, string[]> = {
107
- read: WORKING_LOADER_FRAMES,
108
- bash: WORKING_LOADER_FRAMES,
109
- edit: WORKING_LOADER_FRAMES,
110
- write: WORKING_LOADER_FRAMES,
111
- find: WORKING_LOADER_FRAMES,
112
- grep: WORKING_LOADER_FRAMES,
113
- ls: WORKING_LOADER_FRAMES,
114
- };
115
-
116
- function animationFrame(frames: string[], intervalMs = 120): string {
117
- return frames[Math.floor(Date.now() / intervalMs) % frames.length] ?? frames[0] ?? "";
118
- }
119
-
120
- const activeAnimationContexts = new Set<any>();
121
- let sharedAnimationTimer: ReturnType<typeof setTimeout> | null = null;
122
-
123
- function clearAnimation(context: any) {
124
- if (!context?.state?.ccstyleAnimationScheduled) return;
125
- context.state.ccstyleAnimationScheduled = false;
126
- activeAnimationContexts.delete(context);
127
- if (activeAnimationContexts.size === 0 && sharedAnimationTimer) {
128
- clearTimeout(sharedAnimationTimer);
129
- sharedAnimationTimer = null;
130
- }
131
- }
132
-
133
- function clearAllAnimations() {
134
- for (const ctx of activeAnimationContexts) {
135
- ctx.state.ccstyleAnimationScheduled = false;
136
- }
137
- activeAnimationContexts.clear();
138
- if (sharedAnimationTimer) {
139
- clearTimeout(sharedAnimationTimer);
140
- sharedAnimationTimer = null;
141
- }
142
- }
143
-
144
- function scheduleAnimation(context: any, intervalMs = 80) {
145
- const state = (context.state ??= {});
146
- if (state.ccstyleAnimationScheduled) return;
147
- state.ccstyleAnimationScheduled = true;
148
- activeAnimationContexts.add(context);
149
- if (!sharedAnimationTimer) {
150
- sharedAnimationTimer = setTimeout(() => {
151
- sharedAnimationTimer = null;
152
- const contexts = Array.from(activeAnimationContexts);
153
- activeAnimationContexts.clear();
154
- for (const ctx of contexts) {
155
- ctx.state.ccstyleAnimationScheduled = false;
156
- ctx.invalidate?.();
157
- }
158
- }, intervalMs);
159
- }
160
- }
161
-
162
- function pendingIcon(name: string): string {
163
- return animationFrame(TOOL_PENDING_FRAMES[name] ?? [toolIcon(name)], 80);
164
- }
165
-
166
- type ToolVisualState = "pending" | "success" | "error";
167
-
168
- function settledIcon(name: string, state: ToolVisualState | undefined): string {
169
- if (state === "success") return "✓";
170
- if (state === "error") return "✗";
171
- return toolIcon(name);
172
- }
173
-
174
- function setToolVisualState(context: any, visualState: ToolVisualState) {
175
- const state = (context.state ??= {});
176
- if (visualState !== "pending") clearAnimation(context);
177
- if (state.ccstyleToolVisualState === visualState) return;
178
- state.ccstyleToolVisualState = visualState;
179
- // Do not invalidate synchronously from renderResult. Pi is already rendering
180
- // this tool row; recursively scheduling another render here can retain both
181
- // the finalized result component and its previous secondary/partial component,
182
- // which displays the result summary twice. The current render pass also
183
- // refreshes renderCall, so the settled icon still updates immediately.
184
- }
185
-
186
- function getToolVisualState(context: any): ToolVisualState | undefined {
187
- return context?.state?.ccstyleToolVisualState as ToolVisualState | undefined;
188
- }
189
-
190
- function resolveToolVisualState(context: any): ToolVisualState | undefined {
191
- const visualState = getToolVisualState(context);
192
- if (visualState || context?.isPartial !== false) return visualState;
193
- const settledState: ToolVisualState = context?.isError ? "error" : "success";
194
- setToolVisualState(context, settledState);
195
- return settledState;
196
- }
197
-
198
- function toolIconColor(context: any): "accent" | "error" | "success" | "muted" {
199
- const visualState = getToolVisualState(context);
200
- if (context?.isError || visualState === "error") return "error";
201
- if (visualState === "success") return "success";
202
- if (context?.isPartial || context?.executionStarted || visualState === "pending") return "accent";
203
- return "muted";
204
- }
205
-
206
- function isToolExpanded(options: any, context: any): boolean {
207
- const local = context?.state?.ccstyleToolExpanded;
208
- return typeof local === "boolean" ? local : Boolean(options?.expanded ?? context?.expanded);
209
- }
210
-
211
- /** Keep the guide aligned when long result lines wrap at the viewport edge. */
212
- export class ExpandedToolResultText {
213
- private text: string;
214
- private prefix: string;
215
- private normalizedText: string;
216
- private cachedWidth: number | undefined;
217
- private cachedLines: string[] | undefined;
218
-
219
- constructor(text: string, prefix: string) {
220
- this.text = text;
221
- this.prefix = prefix;
222
- this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
223
- }
224
-
225
- setText(text: string): void {
226
- if (this.text === text) return;
227
- this.text = text;
228
- this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
229
- this.invalidate();
230
- }
231
-
232
- setPrefix(prefix: string): void {
233
- if (this.prefix === prefix) return;
234
- this.prefix = prefix;
235
- this.invalidate();
236
- }
237
-
238
- render(width: number): string[] {
239
- if (this.cachedLines !== undefined && this.cachedWidth === width) return this.cachedLines;
240
-
241
- const prefixWidth = visibleWidth(this.prefix);
242
- const contentWidth = Math.max(1, width - prefixWidth);
243
- const lines = wrapTextWithAnsi(this.normalizedText, contentWidth)
244
- .map((line) => truncateToWidth(this.prefix + line, width, ""));
245
- this.cachedWidth = width;
246
- this.cachedLines = lines;
247
- return lines;
248
- }
249
-
250
- invalidate(): void {
251
- this.cachedWidth = undefined;
252
- this.cachedLines = undefined;
253
- }
254
- }
255
-
256
- export function renderCollapsedToolResult(body: string, collapsedHint = ""): string {
257
- return ` ↳ ${body}${collapsedHint}`;
258
- }
259
-
260
- function renderExpandedToolResult(
261
- body: string,
262
- theme: any,
263
- isError: boolean,
264
- lastComponent?: unknown,
265
- ): ExpandedToolResultText | Text {
266
- const color = isError ? "error" : "muted";
267
- if (!body.trim()) {
268
- return new Text(theme.fg(color, renderCollapsedToolResult("Done")), 0, 0);
269
- }
270
-
271
- const text = theme.fg(color, body);
272
- const prefix = theme.fg(color, " │ ");
273
- if (lastComponent instanceof ExpandedToolResultText) {
274
- lastComponent.setText(text);
275
- lastComponent.setPrefix(prefix);
276
- return lastComponent;
277
- }
278
- return new ExpandedToolResultText(text, prefix);
279
- }
280
-
281
- function expandHint(theme: any): string {
282
- // Keep interaction guidance neutral; it should not inherit success/error
283
- // coloring from the tool result surrounding it.
284
- return `${theme.fg("muted", " (")}${keyHint("app.tools.expand", "expand")}${theme.fg("muted", " / click)")}`;
285
- }
286
-
287
- type SgrMousePacket = {
288
- code: number;
289
- col: number;
290
- row: number;
291
- final: "M" | "m";
292
- };
293
-
294
- type ToolRenderHit = {
295
- component: any;
296
- start: number;
297
- end: number;
298
- };
299
-
300
- const TOOL_MOUSE_WIDGET_KEY = "ccstyle-tool-mouse";
301
- const TOOL_MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
302
- const TOOL_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1000l";
303
- const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
304
- const ZENTUI_PAGE_DOWN_INPUT = /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
305
- const SCROLL_BOTTOM_SHORTCUT = "ctrl+end";
306
- let toolMouseTui: any = null;
307
- let toolMouseUi: any = null;
308
- let toolMouseInputUnsubscribe: (() => void) | null = null;
309
- let toolMouseInputPatchTui: any = null;
310
- let toolMouseInputPatchOriginalHandle: ((...args: any[]) => any) | null = null;
311
- let toolMouseInputPatchWrapper: ((...args: any[]) => any) | null = null;
312
- let scrollButtonVisible = false;
313
- let scrollButtonWidget: any = null;
314
- let pendingScrollMessages = 0;
315
- let assistantMessageActive = false;
316
- let scrollButtonSyncScheduled = false;
317
- let sessionRenderTimer: ReturnType<typeof setTimeout> | null = null;
318
-
319
- function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
320
- const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
321
- const packets: SgrMousePacket[] = [];
322
- let offset = 0;
323
-
324
- for (const match of data.matchAll(pattern)) {
325
- if (match.index !== offset) return null;
326
- offset = match.index + match[0].length;
327
- packets.push({
328
- code: Number(match[1]),
329
- col: Number(match[2]),
330
- row: Number(match[3]),
331
- final: match[4] as "M" | "m",
332
- });
333
- }
334
-
335
- return packets.length > 0 && offset === data.length ? packets : null;
336
- }
337
-
338
- function isSgrLeftPress(packet: SgrMousePacket): boolean {
339
- const baseButton = packet.code & ~(4 | 8 | 16 | 32);
340
- return packet.final === "M" && baseButton === 0 && (packet.code & 32) === 0;
341
- }
342
-
343
- function stripTerminalSequences(value: string): string {
344
- return value
345
- .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
346
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
347
- .replace(/\s+/g, " ")
348
- .trim();
349
- }
350
-
351
- function isToolExecutionComponent(value: any): boolean {
352
- return Boolean(
353
- value
354
- && typeof value === "object"
355
- && typeof value.toolCallId === "string"
356
- && typeof value.setExpanded === "function"
357
- && typeof value.render === "function",
358
- );
359
- }
360
-
361
- function renderedLineCount(component: any, width: number, cache: WeakMap<object, number>): number {
362
- if (!component || typeof component !== "object") return 0;
363
- const cached = cache.get(component);
364
- if (cached !== undefined) return cached;
365
-
366
- let count = 0;
367
- try {
368
- const lines = component.render(width);
369
- count = Array.isArray(lines) ? lines.length : 0;
370
- } catch {
371
- count = 0;
372
- }
373
- cache.set(component, count);
374
- return count;
375
- }
376
-
377
- function collectToolRenderHits(
378
- component: any,
379
- start: number,
380
- width: number,
381
- hits: ToolRenderHit[],
382
- cache: WeakMap<object, number>,
383
- seen: Set<object>,
384
- ): number {
385
- if (!component || typeof component !== "object" || seen.has(component)) return start;
386
- seen.add(component);
387
-
388
- const count = renderedLineCount(component, width, cache);
389
- if (isToolExecutionComponent(component)) {
390
- if (count > 0) hits.push({ component, start, end: start + count });
391
- return start + count;
392
- }
393
-
394
- let childStart = start;
395
- if (Array.isArray(component.children)) {
396
- for (const child of component.children) {
397
- collectToolRenderHits(child, childStart, width, hits, cache, seen);
398
- childStart += renderedLineCount(child, width, cache);
399
- }
400
- }
401
- return start + count;
402
- }
403
-
404
- function collectToolComponents(component: any, tools: any[], seen = new Set<any>()): void {
405
- if (!component || typeof component !== "object" || seen.has(component)) return;
406
- seen.add(component);
407
- if (isToolExecutionComponent(component)) {
408
- tools.push(component);
409
- return;
410
- }
411
- if (!Array.isArray(component.children)) return;
412
- for (const child of component.children) collectToolComponents(child, tools, seen);
413
- }
414
-
415
- function fixedEditorLineMatch(rendered: string, visible: string): boolean {
416
- return rendered === visible
417
- || (visible.length >= 8 && (rendered.includes(visible) || visible.includes(rendered)));
418
- }
419
-
420
- function fixedEditorContextScore(
421
- renderedLines: string[],
422
- renderedRow: number,
423
- visibleLines: string[],
424
- visibleRow: number,
425
- ): number {
426
- let score = renderedLines[renderedRow] === visibleLines[visibleRow] ? 100 : 50;
427
- for (const direction of [-1, 1]) {
428
- for (let distance = 1; distance <= 4; distance++) {
429
- const candidate = renderedLines[renderedRow + direction * distance];
430
- const visible = visibleLines[visibleRow + direction * distance];
431
- if (candidate === undefined || visible === undefined || candidate !== visible) break;
432
- score += 5 - distance;
433
- }
434
- }
435
- return score;
436
- }
437
-
438
- function findToolAtFixedEditorRow(
439
- tui: any,
440
- visibleRow: number,
441
- previousLines: string[],
442
- width: number,
443
- ): ToolRenderHit | null {
444
- if (visibleRow < 0 || visibleRow >= previousLines.length) return null;
445
- const visibleLines = previousLines.map((line) => stripTerminalSequences(String(line)));
446
- const clickedLine = visibleLines[visibleRow] ?? "";
447
- if (!clickedLine) return null;
448
-
449
- const tools: any[] = [];
450
- collectToolComponents(tui, tools);
451
- let best: { hit: ToolRenderHit; score: number } | null = null;
452
- for (const component of tools) {
453
- const expanded = Boolean(component.expanded);
454
- if (!expanded && !/(?:expand|\/ click)/i.test(clickedLine)) continue;
455
- const renderedLines = renderComponentTree(component, width)
456
- .map((line) => stripTerminalSequences(String(line)));
457
- for (let renderedRow = 0; renderedRow < renderedLines.length; renderedRow++) {
458
- if (!fixedEditorLineMatch(renderedLines[renderedRow] ?? "", clickedLine)) continue;
459
- const score = fixedEditorContextScore(renderedLines, renderedRow, visibleLines, visibleRow);
460
- if (!best || score > best.score) {
461
- best = {
462
- hit: { component, start: visibleRow, end: visibleRow + renderedLines.length },
463
- score,
464
- };
465
- }
466
- }
467
- }
468
- return best?.hit ?? null;
469
- }
470
-
471
- function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null {
472
- const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
473
- const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
474
- if (isFixedEditorTui(tui)) {
475
- // Zentui replaces Pi's root render with the already-sliced visible
476
- // transcript. previousViewportTop remains cursor bookkeeping and must not
477
- // be added to a physical mouse row here.
478
- return findToolAtFixedEditorRow(tui, screenRow - 1, previousLines, width);
479
- }
480
- const viewportTop = Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0;
481
- const bufferRow = viewportTop + screenRow - 1;
482
- if (bufferRow < 0 || bufferRow >= previousLines.length) return null;
483
-
484
- const hits: ToolRenderHit[] = [];
485
- const cache = new WeakMap<object, number>();
486
- collectToolRenderHits(tui, 0, width, hits, cache, new Set<object>());
487
-
488
- const clickedLine = stripTerminalSequences(String(previousLines[bufferRow] ?? ""));
489
- for (const hit of hits) {
490
- if (bufferRow < hit.start || bufferRow >= hit.end) continue;
491
- const component = hit.component;
492
- const expanded = Boolean(component.expanded);
493
- // Match Pi's old click contract: collapsed results expose a click hint;
494
- // expanded results can be collapsed by clicking any rendered line.
495
- if (!expanded && !/(?:expand|\/ click)/i.test(clickedLine)) continue;
496
- return hit;
497
- }
498
- return null;
499
- }
500
-
501
- function isFixedEditorTui(tui: any): boolean {
502
- const terminal = tui?.terminal;
503
- if (!terminal) return false;
504
- const ownRows = Object.getOwnPropertyDescriptor(terminal, "rows");
505
- const prototype = Object.getPrototypeOf(terminal);
506
- const inheritedRows = prototype
507
- ? Object.getOwnPropertyDescriptor(prototype, "rows")
508
- : undefined;
509
- return typeof ownRows?.get === "function" && ownRows.get !== inheritedRows?.get;
510
- }
511
-
512
- function formatShortcut(shortcut: string): string {
513
- return shortcut
514
- .split("+")
515
- .map((part) => part.length <= 1 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
516
- .join("+");
517
- }
518
-
519
- function isScrollBottomInput(data: string): boolean {
520
- return matchesKey(data, SCROLL_BOTTOM_SHORTCUT);
521
- }
522
-
523
- function isScrollNavigationInput(data: string): boolean {
524
- if (
525
- matchesKey(data, "pageUp")
526
- || matchesKey(data, "pageDown")
527
- || ZENTUI_PAGE_UP_INPUT.test(data)
528
- || ZENTUI_PAGE_DOWN_INPUT.test(data)
529
- ) {
530
- return true;
531
- }
532
- const packets = parseSgrMousePackets(data);
533
- return Boolean(packets?.some((packet) => {
534
- const baseButton = packet.code & ~(4 | 8 | 16 | 32);
535
- return packet.final === "M" && (baseButton === 64 || baseButton === 65);
536
- }));
537
- }
538
-
539
- function directRenderLines(component: any, width: number): string[] {
540
- try {
541
- const lines = component?.render?.(width);
542
- return Array.isArray(lines) ? lines : [];
543
- } catch {
544
- return [];
545
- }
546
- }
547
-
548
- /** Render only the transcript portion that fixed-editor leaves scrollable. */
549
- function renderFixedScrollableRoot(tui: any, width: number): string[] {
550
- const children = Array.isArray(tui?.children) ? tui.children : [];
551
- const editorIndex = children.findIndex((child: any) =>
552
- containsEditorLike(child, tui.focusedComponent),
553
- );
554
- const end = editorIndex >= 2 ? editorIndex - 2 : children.length;
555
- return children.slice(0, end).flatMap((child: any) => directRenderLines(child, width));
556
- }
557
-
558
- function isFixedEditorAtBottom(tui: any): boolean {
559
- if (!isFixedEditorTui(tui)) return true;
560
- const visibleLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
561
- if (visibleLines.length === 0) return true;
562
- const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
563
- const rootLines = renderFixedScrollableRoot(tui, width);
564
- if (rootLines.length <= visibleLines.length) return true;
565
- const tail = rootLines.slice(-visibleLines.length);
566
- return tail.every((line, index) =>
567
- stripTerminalSequences(String(line)) === stripTerminalSequences(String(visibleLines[index] ?? "")),
568
- );
569
- }
570
-
571
- function hideScrollButton(tui: any): void {
572
- const changed = scrollButtonVisible || pendingScrollMessages > 0;
573
- scrollButtonVisible = false;
574
- pendingScrollMessages = 0;
575
- if (changed) tui.requestRender?.();
576
- }
577
-
578
- function scheduleScrollButtonSync(tui: any, data: string): void {
579
- if (!isScrollNavigationInput(data) || scrollButtonSyncScheduled) return;
580
- scrollButtonSyncScheduled = true;
581
- const previousLines = tui.previousLines;
582
- const check = (attempt: number) => {
583
- scrollButtonSyncScheduled = false;
584
- if (toolMouseTui !== tui) return;
585
- // Pi renders on its own frame timer. Inspect the resulting viewport before
586
- // showing the button so empty or non-scrollable transcripts never flash it.
587
- const rendered = tui.previousLines !== previousLines;
588
- if (!rendered && attempt < 4) {
589
- scrollButtonSyncScheduled = true;
590
- const timer = setTimeout(() => check(attempt + 1), 16);
591
- if (typeof timer === "object" && timer !== null && "unref" in timer) {
592
- (timer as { unref: () => void }).unref();
593
- }
594
- return;
595
- }
596
- const nextVisible = !isFixedEditorAtBottom(tui);
597
- if (!nextVisible) pendingScrollMessages = 0;
598
- if (nextVisible !== scrollButtonVisible) {
599
- scrollButtonVisible = nextVisible;
600
- tui.requestRender?.();
601
- }
602
- };
603
- process.nextTick(() => check(0));
604
- }
605
-
606
- function updateScrollButtonFromInput(tui: any, data: string): void {
607
- if (!isFixedEditorTui(tui)) return;
608
- if (matchesKey(data, "enter") || matchesKey(data, "return") || isScrollBottomInput(data)) {
609
- hideScrollButton(tui);
610
- }
611
- }
612
-
613
- function renderComponentTree(component: any, width: number): string[] {
614
- if (!component || typeof component !== "object") return [];
615
- try {
616
- const lines = component.render?.(width);
617
- if (Array.isArray(lines) && lines.length > 0) return lines;
618
- } catch {
619
- // Fall through to children for hidden container renderers.
620
- }
621
- if (!Array.isArray(component.children)) return [];
622
- return component.children.flatMap((child: any) => renderComponentTree(child, width));
623
- }
624
-
625
- function renderTreeWithTarget(
626
- component: any,
627
- target: any,
628
- width: number,
629
- seen = new Set<any>(),
630
- ): { lines: string[]; targetStart: number | null } {
631
- if (!component || typeof component !== "object" || seen.has(component)) {
632
- return { lines: [], targetStart: null };
633
- }
634
- seen.add(component);
635
- if (component === target) {
636
- return { lines: renderComponentTree(component, width), targetStart: 0 };
637
- }
638
-
639
- if (Array.isArray(component.children)) {
640
- const lines: string[] = [];
641
- let targetStart: number | null = null;
642
- for (const child of component.children) {
643
- const result = renderTreeWithTarget(child, target, width, seen);
644
- if (result.targetStart !== null) targetStart = lines.length + result.targetStart;
645
- lines.push(...result.lines);
646
- }
647
- if (targetStart !== null) return { lines, targetStart };
648
- }
649
-
650
- return { lines: renderComponentTree(component, width), targetStart: null };
651
- }
652
-
653
- function normalizedClusterLines(component: any, width: number): string[] {
654
- if (!component) return [];
655
- const lines = renderComponentTree(component, width);
656
- let end = lines.length;
657
- while (end > 0 && visibleWidth(lines[end - 1] ?? "") === 0) end--;
658
- return lines.slice(0, Math.max(end, 1));
659
- }
660
-
661
- function rawTerminalRows(tui: any): number {
662
- const terminal = tui?.terminal;
663
- if (!terminal) return 0;
664
- const prototype = Object.getPrototypeOf(terminal);
665
- const rows = prototype ? Object.getOwnPropertyDescriptor(prototype, "rows") : undefined;
666
- if (typeof rows?.get === "function") {
667
- try {
668
- const value = rows.get.call(terminal);
669
- if (typeof value === "number" && Number.isFinite(value)) return value;
670
- } catch {
671
- // Fall through to the current terminal value.
672
- }
673
- }
674
- return typeof terminal.rows === "number" && Number.isFinite(terminal.rows) ? terminal.rows : 0;
675
- }
676
-
677
- function containsEditorLike(component: any, focused: any, seen = new Set<any>()): boolean {
678
- if (!component || typeof component !== "object" || seen.has(component)) return false;
679
- seen.add(component);
680
- if (component === focused) return true;
681
- if (
682
- typeof component.getText === "function"
683
- && typeof component.setText === "function"
684
- && typeof component.handleInput === "function"
685
- ) return true;
686
- return Array.isArray(component.children)
687
- && component.children.some((child: any) => containsEditorLike(child, focused, seen));
688
- }
689
-
690
- function scrollButtonScreenRow(tui: any, width: number): number | null {
691
- if (!scrollButtonVisible || !isFixedEditorTui(tui) || !scrollButtonWidget) return null;
692
- const children = Array.isArray(tui?.children) ? tui.children : [];
693
- const editorIndex = children.findIndex((child: any) =>
694
- containsEditorLike(child, tui.focusedComponent),
695
- );
696
- if (editorIndex < 2 || editorIndex + 2 >= children.length) return null;
697
-
698
- const above = children[editorIndex - 1];
699
- const widthValue = Math.max(1, width || Number(tui?.terminal?.columns) || 80);
700
- const target = renderTreeWithTarget(above, scrollButtonWidget, widthValue);
701
- if (target.targetStart === null) return null;
702
-
703
- const rawRows = rawTerminalRows(tui);
704
- if (rawRows <= 0) return null;
705
- const maxRows = Math.max(1, rawRows - 1);
706
- const status = normalizedClusterLines(children[editorIndex - 2], widthValue);
707
- const editor = normalizedClusterLines(children[editorIndex], widthValue);
708
- const below = normalizedClusterLines(children[editorIndex + 1], widthValue);
709
- const footer = normalizedClusterLines(children[editorIndex + 2], widthValue);
710
- const aboveLines = target.lines.length > 0 ? target.lines : normalizedClusterLines(above, widthValue);
711
-
712
- const takeLast = (lines: string[], count: number): string[] =>
713
- count > 0 ? lines.slice(-count) : [];
714
- const editorVisible = takeLast(editor, Math.min(editor.length, maxRows));
715
- let remaining = Math.max(0, maxRows - editorVisible.length);
716
- const footerVisible = takeLast(footer, remaining);
717
- remaining -= footerVisible.length;
718
- const belowVisible = takeLast(below, remaining);
719
- remaining -= belowVisible.length;
720
- const aboveVisible = takeLast(aboveLines, remaining);
721
- const statusVisible = takeLast(status, Math.max(0, remaining - aboveVisible.length));
722
- const aboveStart = aboveLines.length - aboveVisible.length;
723
- const targetRow = target.targetStart - aboveStart;
724
- if (targetRow < 0 || targetRow >= aboveVisible.length) return null;
725
-
726
- const allLines = [...statusVisible, ...aboveVisible, ...editorVisible, ...belowVisible, ...footerVisible];
727
- let leadingBlank = 0;
728
- while (leadingBlank < allLines.length - 1 && visibleWidth(allLines[leadingBlank] ?? "") === 0) {
729
- leadingBlank++;
730
- }
731
- const clusterRow = statusVisible.length + targetRow - leadingBlank;
732
- if (clusterRow < 0 || clusterRow >= allLines.length - leadingBlank) return null;
733
- return rawRows - allLines.length + clusterRow + 1;
734
- }
735
-
736
- function isScrollButtonAtScreenRow(tui: any, packet: SgrMousePacket): boolean {
737
- const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
738
- if (scrollButtonScreenRow(tui, width) !== packet.row || !scrollButtonWidget) return false;
739
- const rendered = scrollButtonWidget.render?.(width)?.[0];
740
- if (typeof rendered !== "string") return false;
741
- const plain = rendered
742
- .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
743
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
744
- const leading = plain.length - plain.trimStart().length;
745
- const end = visibleWidth(plain.trimEnd());
746
- return packet.col >= leading + 1 && packet.col <= end;
747
- }
748
-
749
- function jumpToBottomWithoutSubmit(tui: any): boolean {
750
- const originalHandle = toolMouseInputPatchTui === tui
751
- ? toolMouseInputPatchOriginalHandle
752
- : null;
753
- if (!originalHandle) return false;
754
-
755
- // Route Enter through Pi's normal listener chain so pi-zentui can update its
756
- // private scroll offset, but suppress the focused editor for this synthetic
757
- // dispatch so clicking the button never submits the current input.
758
- const focused = tui.focusedComponent;
759
- try {
760
- tui.focusedComponent = null;
761
- Reflect.apply(originalHandle, tui, ["\r"]);
762
- } finally {
763
- tui.focusedComponent = focused;
764
- }
765
- hideScrollButton(tui);
766
- return true;
767
- }
768
-
769
- function handleScrollButtonClick(tui: any, packet: SgrMousePacket): boolean {
770
- if (!isScrollButtonAtScreenRow(tui, packet)) return false;
771
- return jumpToBottomWithoutSubmit(tui);
772
- }
773
-
774
- function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
775
- const hit = findToolAtScreenRow(tui, packet.row);
776
- if (!hit) return false;
777
-
778
- const nextExpanded = !Boolean(hit.component.expanded);
779
- hit.component.setExpanded(nextExpanded);
780
- hit.component.invalidate?.();
781
- tui.requestRender?.();
782
- return true;
783
- }
784
-
785
- function renderScrollButton(width: number, theme: any): string[] {
786
- if (!scrollButtonVisible || !isFixedEditorTui(toolMouseTui)) return [];
787
- const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
788
- const messageText = pendingScrollMessages > 0
789
- ? `${pendingScrollMessages} new message${pendingScrollMessages === 1 ? "" : "s"}`
790
- : "Back to bottom";
791
- const label = theme.fg("accent", `[ ↓ ${messageText} · ${shortcut} · click ]`);
792
- const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
793
- return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
794
- }
795
-
796
- /**
797
- * pi-zentui consumes left-button presses for text selection. Intercept only a
798
- * tool-row click at the TUI input boundary, before extension listeners run.
799
- * Keyboard, wheel, drag, release, and non-tool clicks continue through Pi's
800
- * original dispatcher, preserving pi-zentui's scroll-to-bottom behavior.
801
- */
802
- function patchToolMouseInputCapture(tui: any): void {
803
- if (toolMouseInputPatchTui === tui) return;
804
-
805
- restoreToolMouseInputCapture();
806
- const originalHandle = tui?.handleInput;
807
- if (typeof originalHandle !== "function") return;
808
-
809
- const wrapper = function (this: any, ...args: any[]): any {
810
- const data = args[0];
811
- if (typeof data === "string") {
812
- updateScrollButtonFromInput(this, data);
813
- // Capture the current viewport before Pi/Zentui applies the scroll input.
814
- scheduleScrollButtonSync(this, data);
815
- if (isFixedEditorTui(this) && isScrollBottomInput(data) && jumpToBottomWithoutSubmit(this)) return;
816
- const packets = parseSgrMousePackets(data);
817
- if (packets) {
818
- for (const packet of packets) {
819
- if (!isSgrLeftPress(packet)) continue;
820
- if (handleScrollButtonClick(this, packet) || toggleToolAtMouseClick(this, packet)) return;
821
- }
822
- }
823
- }
824
- const result = Reflect.apply(originalHandle, this, args);
825
- if (typeof data === "string") scheduleScrollButtonSync(this, data);
826
- return result;
827
- };
828
-
829
- try {
830
- tui.handleInput = wrapper;
831
- } catch {
832
- return;
833
- }
834
- toolMouseInputPatchTui = tui;
835
- toolMouseInputPatchOriginalHandle = originalHandle;
836
- toolMouseInputPatchWrapper = wrapper;
837
- }
838
-
839
- function restoreToolMouseInputCapture(): void {
840
- if (
841
- toolMouseInputPatchTui
842
- && toolMouseInputPatchOriginalHandle
843
- && toolMouseInputPatchTui.handleInput === toolMouseInputPatchWrapper
844
- ) {
845
- toolMouseInputPatchTui.handleInput = toolMouseInputPatchOriginalHandle;
846
- }
847
- toolMouseInputPatchTui = null;
848
- toolMouseInputPatchOriginalHandle = null;
849
- toolMouseInputPatchWrapper = null;
850
- }
851
-
852
- function handleToolMouseInput(data: string): { consume: true } | undefined {
853
- if (!toolMouseTui) return undefined;
854
- updateScrollButtonFromInput(toolMouseTui, data);
855
- if (isFixedEditorTui(toolMouseTui) && isScrollBottomInput(data) && jumpToBottomWithoutSubmit(toolMouseTui)) {
856
- return { consume: true };
857
- }
858
- const packets = parseSgrMousePackets(data);
859
- if (!packets) {
860
- scheduleScrollButtonSync(toolMouseTui, data);
861
- return undefined;
862
- }
863
-
864
- let consumed = false;
865
- for (const packet of packets) {
866
- if (!isSgrLeftPress(packet)) continue;
867
- if (handleScrollButtonClick(toolMouseTui, packet) || toggleToolAtMouseClick(toolMouseTui, packet)) {
868
- consumed = true;
869
- }
870
- }
871
-
872
- // Let scrolling, motion, release, and clicks outside tool results reach the
873
- // normal TUI input chain (including other extensions such as pi-zentui).
874
- scheduleScrollButtonSync(toolMouseTui, data);
875
- return consumed ? { consume: true } : undefined;
876
- }
877
-
878
- function teardownToolMouseInteraction(): void {
879
- if (sessionRenderTimer) {
880
- clearTimeout(sessionRenderTimer);
881
- sessionRenderTimer = null;
882
- }
883
- toolMouseInputUnsubscribe?.();
884
- toolMouseInputUnsubscribe = null;
885
- try {
886
- toolMouseTui?.terminal?.write?.(TOOL_MOUSE_DISABLE);
887
- } catch {
888
- // The terminal may already be closed during shutdown.
889
- }
890
- try {
891
- toolMouseUi?.setWidget?.(TOOL_MOUSE_WIDGET_KEY, undefined);
892
- } catch {
893
- // The UI context may already have been reset during /reload.
894
- }
895
- restoreToolMouseInputCapture();
896
- scrollButtonVisible = false;
897
- scrollButtonWidget = null;
898
- pendingScrollMessages = 0;
899
- assistantMessageActive = false;
900
- scrollButtonSyncScheduled = false;
901
- toolMouseTui = null;
902
- toolMouseUi = null;
903
- }
904
-
905
- function installToolMouseInteraction(ctx: any): void {
906
- teardownToolMouseInteraction();
907
- if (ctx?.mode !== "tui" || !ctx?.hasUI) return;
908
- if (typeof ctx.ui?.onTerminalInput !== "function" || typeof ctx.ui?.setWidget !== "function") return;
909
-
910
- toolMouseUi = ctx.ui;
911
- ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any, theme: any) => {
912
- toolMouseTui = tui;
913
- patchToolMouseInputCapture(tui);
914
- tui?.terminal?.write?.(TOOL_MOUSE_ENABLE);
915
- const widget = {
916
- render: (width: number) => renderScrollButton(width, theme),
917
- invalidate() {},
918
- };
919
- scrollButtonWidget = widget;
920
- return widget;
921
- });
922
- toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
923
- }
924
-
925
- function scheduleSessionRender(): void {
926
- const tui = toolMouseTui;
927
- if (!tui || typeof tui.requestRender !== "function") return;
928
- if (sessionRenderTimer) clearTimeout(sessionRenderTimer);
929
- // Restored transcripts are populated at different points for startup, reload,
930
- // and session replacement. Repaint after session_start and the surrounding UI
931
- // rebuild finish so messages are not left hidden until the next terminal input.
932
- sessionRenderTimer = setTimeout(() => {
933
- sessionRenderTimer = null;
934
- if (toolMouseTui === tui) tui.requestRender(true);
935
- }, 0);
936
- }
937
-
938
- // Bright green for success icon (truecolor ANSI escape)
939
- const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
940
- const ANSI_RESET = "\x1b[0m";
941
-
942
- function refreshCurrentTranscript(compactStyle: CompactStyleHooks, ctx?: any): void {
943
- compactStyle.refresh();
944
- toolMouseTui?.requestRender?.(true);
945
- ctx?.ui?.requestRender?.(true);
946
- }
947
-
948
- function applyStyleMode(mode: CompactStyleMode, ctx: any, compactStyle: CompactStyleHooks): void {
949
- config.mode = mode;
950
- saveConfig();
951
- refreshCurrentTranscript(compactStyle, ctx);
952
- ctx.ui.notify(`Claude Code style: ${mode}`, "info");
953
- }
954
-
955
- async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Promise<void> {
956
- if (ctx?.mode !== "tui" || !ctx?.hasUI || typeof ctx.ui?.custom !== "function") {
957
- ctx.ui?.notify?.("/ccstyle requires TUI mode", "warning");
958
- return;
959
- }
960
-
961
- const modes: Array<{ value: CompactStyleMode; label: string; description: string }> = [
962
- { value: "on", label: "on", description: "Claude Code style" },
963
- { value: "off", label: "off", description: "Pi native output" },
964
- { value: "compact", label: "compact", description: "Compact transcript" },
965
- ];
966
- const selectedMode = await (ctx.ui.custom((tui: any, theme: any, _keybindings: any, done: (value?: CompactStyleMode) => void) => {
967
- const container = new Container();
968
- container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
969
- container.addChild(new Text(theme.fg("accent", theme.bold("Claude Code Style")), 1, 0));
970
- const selectList = new SelectList(modes, modes.length, {
971
- selectedPrefix: (text: string) => theme.fg("accent", text),
972
- selectedText: (text: string) => theme.fg("accent", text),
973
- description: (text: string) => theme.fg("muted", text),
974
- scrollInfo: (text: string) => theme.fg("dim", text),
975
- noMatch: (text: string) => theme.fg("warning", text),
976
- });
977
- selectList.setSelectedIndex(modes.findIndex((item) => item.value === config.mode));
978
- selectList.onSelect = (item: { value: string }) => done(item.value as CompactStyleMode);
979
- selectList.onCancel = () => done();
980
- container.addChild(selectList);
981
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"), 1, 0));
982
- container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
983
- return {
984
- render: (width: number) => container.render(width),
985
- invalidate: () => container.invalidate(),
986
- handleInput(data: string) {
987
- selectList.handleInput(data);
988
- tui.requestRender();
989
- },
990
- };
991
- }) as Promise<CompactStyleMode | undefined>);
992
- if (selectedMode) applyStyleMode(selectedMode, ctx, compactStyle);
993
- }
994
-
995
- function renderDefault(tool: any, slot: "renderCall" | "renderResult", args: any[], fallback = "") {
996
- try {
997
- if (typeof tool?.[slot] === "function") return tool[slot](...args);
998
- } catch {
999
- // Fall through to raw fallback.
1000
- }
1001
- return new Text(fallback, 0, 0);
1002
- }
1003
-
1004
- /**
1005
- * Generate a descriptive label for an unknown tool from its args.
1006
- * Uses the tool label + first stringable arg value.
1007
- */
1008
- function toolCallLabel(toolName: string, toolLabel: string, args: any): string {
1009
- if (!args) return toolLabel;
1010
- const keys = Object.keys(args);
1011
- if (keys.length === 0) return toolLabel;
1012
- // Prefer "query" or "question" args, then fall back to the first stringable value
1013
- const preferred = ["query", "question", "command", "pattern", "name", "path", "url", "message"];
1014
- for (const key of preferred) {
1015
- const val = args[key];
1016
- if (val !== undefined && val !== null && typeof val !== "object") {
1017
- return `${toolLabel}(${oneLine(val, 60)})`;
1018
- }
1019
- }
1020
- const firstKey = keys[0];
1021
- const firstVal = args[firstKey];
1022
- if (firstVal !== undefined && firstVal !== null && typeof firstVal !== "object") {
1023
- return `${toolLabel}(${oneLine(firstVal, 60)})`;
1024
- }
1025
- return toolLabel;
1026
- }
1027
-
1028
- /** Wrap an arbitrary tool definition with ccstyle call/result rendering. */
1029
- function createCcstyleTool(originalTool: any): any {
1030
- const toolName = originalTool.name;
1031
- const label = isMcpToolDefinition(originalTool, toolName)
1032
- ? toolName
1033
- : originalTool.label || toolName;
1034
-
1035
- return {
1036
- ...originalTool,
1037
- renderShell: "self",
1038
- renderCall(args: any, theme: any, context: any) {
1039
- if (config.mode !== "on") {
1040
- return renderDefault(originalTool, "renderCall", [args, theme, context], String(toolName));
1041
- }
1042
-
1043
- const visualState = resolveToolVisualState(context);
1044
- const isPending =
1045
- visualState === "pending" ||
1046
- (!visualState && (context?.isPartial || context?.executionStarted));
1047
- if (isPending) scheduleAnimation(context);
1048
- const rawIcon = isPending ? pendingIcon(toolName) : settledIcon(toolName, visualState);
1049
- const icon =
1050
- visualState === "success"
1051
- ? `${BRIGHT_GREEN}${rawIcon}${ANSI_RESET}`
1052
- : theme.fg(toolIconColor(context), rawIcon);
1053
-
1054
- const title = `${icon} ${theme.fg("toolTitle", toolCallLabel(toolName, label, args))}`;
1055
- return new Text(title, 0, 0);
1056
- },
1057
- renderResult(result: any, options: any, theme: any, context: any) {
1058
- if (config.mode !== "on") {
1059
- return renderDefault(originalTool, "renderResult", [result, options, theme, context], textFromResult(result));
1060
- }
1061
-
1062
- if (options?.isPartial) {
1063
- return new Text(theme.fg("muted", " ⎿ Pending…"), 0, 0);
1064
- }
1065
-
1066
- const isError = options?.isError || context?.isError;
1067
- setToolVisualState(context, isError ? "error" : "success");
1068
- const expanded = isToolExpanded(options, context);
1069
-
1070
- const text = textFromResult(result);
1071
- const rendered = !expanded ? (text ? oneLine(text, 96) : "Done") : text;
1072
-
1073
- const hint = !expanded && hasExpandableResult(text) ? expandHint(theme) : "";
1074
- if (expanded) return renderExpandedToolResult(rendered, theme, isError, context?.lastComponent);
1075
- return new Text(
1076
- theme.fg(isError ? "error" : "muted", renderCollapsedToolResult(rendered, hint)),
1077
- 0,
1078
- 0,
1079
- );
1080
- },
1081
- };
1082
- }
1083
-
1084
- /**
1085
- * This extension does not register tools. ToolExecutionComponent is shared by
1086
- * the TUI and exported by pi; patch its renderer lookup once so tools use the
1087
- * same compact fallback shell by default. Tools named in excludeRenderers keep
1088
- * their original renderer; subagent rendering remains protected.
1089
- */
1090
- const GLOBAL_TOOL_RENDER_PATCH = Symbol.for("pi.ccstyle.global-tool-render-patch");
1091
- const COMPONENT_TOOL_RENDER_MODE = Symbol.for("pi.ccstyle.component-tool-render-mode");
1092
- const COMPONENT_TOOL_SELF_SHELL_MODE = Symbol.for("pi.ccstyle.component-tool-self-shell-mode");
1093
- const SUBAGENT_TOOL_NAMES = new Set(["Agent"]);
1094
-
1095
- type ToolRenderMethods = {
1096
- hasRendererDefinition: (...args: any[]) => boolean;
1097
- getRenderShell: (...args: any[]) => "default" | "self";
1098
- getCallRenderer: (...args: any[]) => any;
1099
- getResultRenderer: (...args: any[]) => any;
1100
- };
1101
-
1102
- type GlobalToolRenderPatch = {
1103
- version: 2;
1104
- prototype: any;
1105
- owner: object;
1106
- active: boolean;
1107
- enabled: () => boolean;
1108
- mode: () => CompactStyleMode;
1109
- wrap: (tool: any) => any;
1110
- byDefinition: WeakMap<object, any>;
1111
- byName: Map<string, any>;
1112
- downstream: ToolRenderMethods;
1113
- installed: ToolRenderMethods;
1114
- // Keep the legacy aliases so an older extension build can read this Symbol.
1115
- originalHasRendererDefinition: ToolRenderMethods["hasRendererDefinition"];
1116
- originalGetRenderShell: ToolRenderMethods["getRenderShell"];
1117
- originalGetCallRenderer: ToolRenderMethods["getCallRenderer"];
1118
- originalGetResultRenderer: ToolRenderMethods["getResultRenderer"];
1119
- };
1120
-
1121
- function isMcpToolDefinition(definition: any, toolName: string): boolean {
1122
- const label = typeof definition?.label === "string" ? definition.label : "";
1123
- return toolName === "mcp" || label === "MCP" || label.startsWith("MCP: ");
1124
- }
1125
-
1126
- /** Return true when this tool must keep its original renderer. */
1127
- export function preservesOriginalRenderer(
1128
- extensionDefinition: any,
1129
- toolName: string,
1130
- builtInToolDefinition?: any,
1131
- excludeRenderers: readonly string[] = config.excludeRenderers,
1132
- ): boolean {
1133
- if (SUBAGENT_TOOL_NAMES.has(toolName)) return true;
1134
- if (!excludeRenderers.includes(toolName)) return false;
1135
- return [extensionDefinition, builtInToolDefinition].some((definition) =>
1136
- definition?.renderShell === "self"
1137
- || typeof definition?.renderCall === "function"
1138
- || typeof definition?.renderResult === "function",
1139
- );
1140
- }
1141
-
1142
- function syncToolShell(component: any, shell: "default" | "self"): void {
1143
- const target = shell === "self" ? component.selfRenderContainer : component.contentBox;
1144
- if (!target || !Array.isArray(component.children)) return;
1145
- const candidates = new Set([component.contentText, component.contentBox, component.selfRenderContainer].filter(Boolean));
1146
- const indexes = component.children
1147
- .map((child: any, index: number) => candidates.has(child) ? index : -1)
1148
- .filter((index: number) => index >= 0);
1149
- const targetIndex = indexes[0];
1150
- // During construction getRenderShell() runs immediately before Pi mounts the
1151
- // selected shell. Do not mount it here or the constructor will add it twice.
1152
- if (targetIndex === undefined) return;
1153
- component.children[targetIndex] = target;
1154
- for (const index of indexes.sort((left: number, right: number) => right - left)) {
1155
- if (index !== targetIndex) component.children.splice(index, 1);
1156
- }
1157
- }
1158
-
1159
- function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch): boolean {
1160
- const extensionDefinition = component.toolDefinition;
1161
- const builtInDefinition = component.builtInToolDefinition;
1162
- const definition = extensionDefinition ?? builtInDefinition;
1163
- const toolName = String(component.toolName || definition?.name || "");
1164
- const useCcstyle =
1165
- patch.mode() === "on" &&
1166
- !preservesOriginalRenderer(extensionDefinition, toolName, builtInDefinition);
1167
- component[COMPONENT_TOOL_RENDER_MODE] = useCcstyle;
1168
- return useCcstyle;
1169
- }
1170
-
1171
- function shouldUseSelfShell(component: any, patch: GlobalToolRenderPatch): boolean {
1172
- const definition = component.toolDefinition ?? component.builtInToolDefinition;
1173
- const toolName = String(component.toolName || definition?.name || "");
1174
- const useSelfShell =
1175
- patch.enabled() &&
1176
- SUBAGENT_TOOL_NAMES.has(toolName) &&
1177
- definition != null &&
1178
- definition.renderShell === undefined;
1179
- component[COMPONENT_TOOL_SELF_SHELL_MODE] = useSelfShell;
1180
- return useSelfShell;
1181
- }
1182
-
1183
- function getGloballyStyledTool(component: any, patch: GlobalToolRenderPatch): any {
1184
- const definition = component.toolDefinition ?? component.builtInToolDefinition;
1185
- if (definition && typeof definition === "object") {
1186
- let wrapped = patch.byDefinition.get(definition);
1187
- if (!wrapped) {
1188
- wrapped = patch.wrap(definition);
1189
- patch.byDefinition.set(definition, wrapped);
1190
- }
1191
- return wrapped;
1192
- }
1193
-
1194
- const name = String(component.toolName || "tool");
1195
- let wrapped = patch.byName.get(name);
1196
- if (!wrapped) {
1197
- wrapped = patch.wrap({ name, label: name });
1198
- patch.byName.set(name, wrapped);
1199
- }
1200
- return wrapped;
1201
- }
1202
-
1203
- function prototypeToolRenderMethods(prototype: any): ToolRenderMethods {
1204
- return {
1205
- hasRendererDefinition: prototype.hasRendererDefinition,
1206
- getRenderShell: prototype.getRenderShell,
1207
- getCallRenderer: prototype.getCallRenderer,
1208
- getResultRenderer: prototype.getResultRenderer,
1209
- };
1210
- }
1211
-
1212
- function isOwnershipAwarePatch(value: any): value is GlobalToolRenderPatch {
1213
- if (!value || value.version !== 2 || !value.installed || !value.downstream) return false;
1214
- return ["hasRendererDefinition", "getRenderShell", "getCallRenderer", "getResultRenderer"]
1215
- .every((name) => typeof value.installed[name] === "function" && typeof value.downstream[name] === "function");
1216
- }
1217
-
1218
- function isLegacyInstalledWrapper(method: unknown, downstreamField: string): boolean {
1219
- if (typeof method !== "function") return false;
1220
- try {
1221
- const source = Function.prototype.toString.call(method);
1222
- return source.includes(downstreamField)
1223
- && (
1224
- source.includes("shouldGloballyStyleTool")
1225
- || source.includes("shouldUseSelfShell")
1226
- || source.includes("getGloballyStyledTool")
1227
- );
1228
- } catch {
1229
- return false;
1230
- }
1231
- }
1232
-
1233
- function downstreamForGlobalToolInstall(prototype: any, previous: any): ToolRenderMethods {
1234
- const current = prototypeToolRenderMethods(prototype);
1235
- if (!previous || previous.prototype !== prototype) return current;
1236
- if (isOwnershipAwarePatch(previous)) {
1237
- return {
1238
- hasRendererDefinition: current.hasRendererDefinition === previous.installed.hasRendererDefinition
1239
- ? previous.downstream.hasRendererDefinition
1240
- : current.hasRendererDefinition,
1241
- getRenderShell: current.getRenderShell === previous.installed.getRenderShell
1242
- ? previous.downstream.getRenderShell
1243
- : current.getRenderShell,
1244
- getCallRenderer: current.getCallRenderer === previous.installed.getCallRenderer
1245
- ? previous.downstream.getCallRenderer
1246
- : current.getCallRenderer,
1247
- getResultRenderer: current.getResultRenderer === previous.installed.getResultRenderer
1248
- ? previous.downstream.getResultRenderer
1249
- : current.getResultRenderer,
1250
- };
1251
- }
1252
-
1253
- // Pre-v2 Symbol state did not retain wrapper references. Recognize its known
1254
- // wrappers when possible; otherwise preserve the current method as external.
1255
- const legacyDownstream = (method: Function, field: string): Function => {
1256
- const saved = previous[field];
1257
- return typeof saved === "function" && isLegacyInstalledWrapper(method, field) ? saved : method;
1258
- };
1259
- return {
1260
- hasRendererDefinition: legacyDownstream(
1261
- current.hasRendererDefinition,
1262
- "originalHasRendererDefinition",
1263
- ) as ToolRenderMethods["hasRendererDefinition"],
1264
- getRenderShell: legacyDownstream(
1265
- current.getRenderShell,
1266
- "originalGetRenderShell",
1267
- ) as ToolRenderMethods["getRenderShell"],
1268
- getCallRenderer: legacyDownstream(
1269
- current.getCallRenderer,
1270
- "originalGetCallRenderer",
1271
- ) as ToolRenderMethods["getCallRenderer"],
1272
- getResultRenderer: legacyDownstream(
1273
- current.getResultRenderer,
1274
- "originalGetResultRenderer",
1275
- ) as ToolRenderMethods["getResultRenderer"],
1276
- };
1277
- }
1278
-
1279
- function disconnectGlobalToolRenderPatch(patch: any): void {
1280
- if (!patch || typeof patch !== "object") return;
1281
- patch.active = false;
1282
- patch.enabled = () => false;
1283
- patch.mode = () => "off";
1284
- patch.wrap = (tool: any) => tool;
1285
- patch.byDefinition = new WeakMap();
1286
- if (patch.byName && typeof patch.byName.clear === "function") patch.byName.clear();
1287
- else patch.byName = new Map();
1288
- }
1289
-
1290
- function installGlobalToolRendering(): GlobalToolRenderPatch {
1291
- const prototype = (ToolExecutionComponent as any).prototype;
1292
- const host = globalThis as any;
1293
- const previous = host[GLOBAL_TOOL_RENDER_PATCH];
1294
- const downstream = downstreamForGlobalToolInstall(prototype, previous);
1295
- // Any wrapper retained by an external owner must become a callback-free
1296
- // pass-through before the new installation is linked above it.
1297
- disconnectGlobalToolRenderPatch(previous);
1298
-
1299
- const patch: GlobalToolRenderPatch = {
1300
- version: 2,
1301
- prototype,
1302
- owner: {},
1303
- active: true,
1304
- enabled: () => config.mode === "on",
1305
- mode: () => config.mode,
1306
- wrap: createCcstyleTool,
1307
- byDefinition: new WeakMap(),
1308
- byName: new Map(),
1309
- downstream,
1310
- installed: undefined as any,
1311
- originalHasRendererDefinition: downstream.hasRendererDefinition,
1312
- originalGetRenderShell: downstream.getRenderShell,
1313
- originalGetCallRenderer: downstream.getCallRenderer,
1314
- originalGetResultRenderer: downstream.getResultRenderer,
1315
- };
1316
-
1317
- patch.installed = {
1318
- hasRendererDefinition: function (this: any, ...args: any[]) {
1319
- if (patch.active && shouldGloballyStyleTool(this, patch)) return true;
1320
- return patch.downstream.hasRendererDefinition.apply(this, args);
1321
- },
1322
- getRenderShell: function (this: any, ...args: any[]) {
1323
- if (!patch.active) return patch.downstream.getRenderShell.apply(this, args);
1324
- const shell = shouldUseSelfShell(this, patch) || shouldGloballyStyleTool(this, patch)
1325
- ? "self"
1326
- : patch.downstream.getRenderShell.apply(this, args);
1327
- syncToolShell(this, shell);
1328
- return shell;
1329
- },
1330
- getCallRenderer: function (this: any, ...args: any[]) {
1331
- if (patch.active && shouldGloballyStyleTool(this, patch)) {
1332
- return getGloballyStyledTool(this, patch).renderCall;
1333
- }
1334
- return patch.downstream.getCallRenderer.apply(this, args);
1335
- },
1336
- getResultRenderer: function (this: any, ...args: any[]) {
1337
- if (patch.active && shouldGloballyStyleTool(this, patch)) {
1338
- return getGloballyStyledTool(this, patch).renderResult;
1339
- }
1340
- return patch.downstream.getResultRenderer.apply(this, args);
1341
- },
1342
- };
1343
-
1344
- prototype.hasRendererDefinition = patch.installed.hasRendererDefinition;
1345
- prototype.getRenderShell = patch.installed.getRenderShell;
1346
- prototype.getCallRenderer = patch.installed.getCallRenderer;
1347
- prototype.getResultRenderer = patch.installed.getResultRenderer;
1348
- host[GLOBAL_TOOL_RENDER_PATCH] = patch;
1349
- return patch;
1350
- }
1351
-
1352
- function deactivateGlobalToolRendering(patch: GlobalToolRenderPatch): void {
1353
- if (!patch.active) return;
1354
- disconnectGlobalToolRenderPatch(patch);
1355
- const prototype = patch.prototype;
1356
- if (prototype.hasRendererDefinition === patch.installed.hasRendererDefinition) {
1357
- prototype.hasRendererDefinition = patch.downstream.hasRendererDefinition;
1358
- }
1359
- if (prototype.getRenderShell === patch.installed.getRenderShell) {
1360
- prototype.getRenderShell = patch.downstream.getRenderShell;
1361
- }
1362
- if (prototype.getCallRenderer === patch.installed.getCallRenderer) {
1363
- prototype.getCallRenderer = patch.downstream.getCallRenderer;
1364
- }
1365
- if (prototype.getResultRenderer === patch.installed.getResultRenderer) {
1366
- prototype.getResultRenderer = patch.downstream.getResultRenderer;
1367
- }
1368
- }
1369
-
1370
- const GLOBAL_COMPACTION_RENDER_PATCH = Symbol.for("pi.ccstyle.compaction-render-patch");
1371
-
1372
- type LegacyCompactionRenderPatch = {
1373
- enabled?: () => boolean;
1374
- };
1375
-
1376
- /** Disable the pre-native compaction monkey patch left alive by /reload. */
1377
- function deactivateLegacyCompactionRendering() {
1378
- const patch = (globalThis as any)[GLOBAL_COMPACTION_RENDER_PATCH] as LegacyCompactionRenderPatch | undefined;
1379
- if (patch) patch.enabled = () => false;
1380
- }
1381
-
1382
- function notePendingScrollMessage(role: unknown): void {
1383
- if (!toolMouseTui || !isFixedEditorTui(toolMouseTui) || !scrollButtonVisible) return;
1384
- if (role === "assistant") {
1385
- if (assistantMessageActive) return;
1386
- assistantMessageActive = true;
1387
- } else if (role !== "toolResult") {
1388
- return;
1389
- }
1390
- pendingScrollMessages += 1;
1391
- toolMouseTui.requestRender?.();
1392
- }
1393
-
1394
- export default function (pi: ExtensionAPI) {
1395
- const globalToolRendering = installGlobalToolRendering();
1396
- deactivateLegacyCompactionRendering();
1397
- const compactStyle: CompactStyleHooks = installCompactStyle(pi, {
1398
- getMode: () => config.mode,
1399
- getExcludeRenderers: () => config.excludeRenderers,
1400
- });
1401
-
1402
- pi.registerCommand("ccstyle", {
1403
- description: "Configure Claude Code style",
1404
- getArgumentCompletions: (prefix) => {
1405
- const topLevel = [
1406
- { value: "on", label: "on", description: "Enable Claude Code style" },
1407
- { value: "off", label: "off", description: "Use Pi's native renderer" },
1408
- { value: "compact", label: "compact", description: "Use compact transcript rendering" },
1409
- { value: "status", label: "status", description: "Show current state" },
1410
- ];
1411
- return topLevel.filter((item) => item.value.startsWith(prefix));
1412
- },
1413
- handler: async (args, ctx) => {
1414
- const arg = args.trim().toLowerCase();
1415
- if (!arg) {
1416
- await showCcstylePanel(ctx, compactStyle);
1417
- return;
1418
- }
1419
- if (arg === "on" || arg === "off" || arg === "compact") {
1420
- applyStyleMode(arg, ctx, compactStyle);
1421
- return;
1422
- }
1423
- if (arg === "status") {
1424
- ctx.ui.notify(`Claude Code style: ${config.mode}`, "info");
1425
- return;
1426
- }
1427
- ctx.ui.notify("Usage: /ccstyle [on|off|compact|status]", "warning");
1428
- },
1429
- });
1430
-
1431
- pi.on("session_start", async (event, ctx) => {
1432
- compactStyle?.onSessionStart(event, ctx);
1433
- pendingScrollMessages = 0;
1434
- assistantMessageActive = false;
1435
- ctx.ui.setStatus("ccstyle", undefined);
1436
- installToolMouseInteraction(ctx);
1437
- scheduleSessionRender();
1438
- });
1439
-
1440
- pi.on("message_start", async (event) => {
1441
- notePendingScrollMessage(event?.message?.role);
1442
- });
1443
-
1444
- pi.on("message_update", async (event, ctx) => {
1445
- compactStyle?.onMessageUpdate(event, ctx);
1446
- if (event?.message?.role === "assistant") notePendingScrollMessage("assistant");
1447
- });
1448
-
1449
- pi.on("message_end", async (event) => {
1450
- if (event?.message?.role === "assistant") assistantMessageActive = false;
1451
- });
1452
-
1453
- pi.on("agent_start", async (event, ctx) => {
1454
- compactStyle?.onAgentStart(event, ctx);
1455
- });
1456
-
1457
- pi.on("agent_end", async (event, ctx) => {
1458
- compactStyle?.onAgentEnd(event, ctx);
1459
- });
1460
-
1461
- pi.on("turn_start", async (event, ctx) => {
1462
- compactStyle?.onTurnStart(event, ctx);
1463
- });
1464
-
1465
- pi.on("tool_execution_start", async (event, ctx) => {
1466
- compactStyle?.onToolExecutionStart(event, ctx);
1467
- });
1468
-
1469
- pi.on("tool_execution_update", async (event, ctx) => {
1470
- compactStyle?.onToolExecutionUpdate(event, ctx);
1471
- });
1472
-
1473
- pi.on("tool_execution_end", async (event, ctx) => {
1474
- compactStyle?.onToolExecutionEnd(event, ctx);
1475
- });
1476
-
1477
- pi.on("session_shutdown", async (event, ctx) => {
1478
- compactStyle?.onSessionShutdown(event, ctx);
1479
- teardownToolMouseInteraction();
1480
- deactivateGlobalToolRendering(globalToolRendering);
1481
- deactivateLegacyCompactionRendering();
1482
- clearAllAnimations();
1483
- });
1484
-
1485
- }
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ DynamicBorder,
4
+ getSettingsListTheme,
5
+ keyHint,
6
+ ToolExecutionComponent,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ installCompactStyle,
10
+ type CompactStyleHooks,
11
+ type CompactStyleMode,
12
+ } from "./compact-style.ts";
13
+ import { sanitizeToolResultText } from "./tool-result-sanitize.ts";
14
+ import {
15
+ installWriteOverride,
16
+ renderRichToolResult,
17
+ WriteExecutionMetadataStore,
18
+ } from "./tool-diff/index.ts";
19
+ import {
20
+ Container,
21
+ SettingsList,
22
+ Text,
23
+ matchesKey,
24
+ truncateToWidth,
25
+ visibleWidth,
26
+ wrapTextWithAnsi,
27
+ } from "@earendil-works/pi-tui";
28
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { join } from "node:path";
31
+
32
+ /**
33
+ * Claude Code Style for pi.
34
+ *
35
+ * This is the package's only entry point. Compact transcript rendering lives in
36
+ * the internal compact-style module and is routed by the mode below.
37
+ */
38
+
39
+ export type Config = {
40
+ mode: CompactStyleMode;
41
+ excludeRenderers: string[];
42
+ fixedEditorFeatures: boolean;
43
+ };
44
+
45
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "claude-code-style.json");
46
+
47
+ export function normalizeConfig(input: unknown): Config {
48
+ const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
49
+ const mode = source.mode;
50
+ const migratedMode: CompactStyleMode =
51
+ mode === "on" || mode === "off" || mode === "compact"
52
+ ? mode
53
+ : typeof source.enabled === "boolean"
54
+ ? source.enabled
55
+ ? "on"
56
+ : "off"
57
+ : "on";
58
+ const excludeRenderers = Array.isArray(source.excludeRenderers)
59
+ ? [
60
+ ...new Set(
61
+ source.excludeRenderers.filter(
62
+ (name): name is string => typeof name === "string" && name.length > 0,
63
+ ),
64
+ ),
65
+ ]
66
+ : [];
67
+ const fixedEditorFeatures = source.fixedEditorFeatures !== false;
68
+ return { mode: migratedMode, excludeRenderers, fixedEditorFeatures };
69
+ }
70
+
71
+ let config: Config = loadConfig();
72
+
73
+ function loadConfig(): Config {
74
+ try {
75
+ if (existsSync(CONFIG_PATH)) {
76
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as unknown;
77
+ const normalized = normalizeConfig(parsed);
78
+ const source = parsed as Record<string, unknown>;
79
+ // Persist the one-time enabled:boolean -> mode migration while retaining
80
+ // the existing exclusion list.
81
+ if (
82
+ typeof source.enabled === "boolean" &&
83
+ source.mode !== "on" &&
84
+ source.mode !== "off" &&
85
+ source.mode !== "compact"
86
+ ) {
87
+ try {
88
+ writeFileSync(CONFIG_PATH, JSON.stringify(normalized, null, 2));
89
+ } catch {
90
+ // A read-only config still uses the migrated in-memory value.
91
+ }
92
+ }
93
+ return normalized;
94
+ }
95
+ } catch {
96
+ // Ignore bad config and fall back to defaults.
97
+ }
98
+ return { mode: "on", excludeRenderers: [], fixedEditorFeatures: true };
99
+ }
100
+
101
+ function saveConfig() {
102
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
103
+ }
104
+
105
+ function oneLine(value: unknown, max = 72): string {
106
+ const text = String(value ?? "")
107
+ .replace(/\s+/g, " ")
108
+ .trim();
109
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
110
+ }
111
+
112
+ function textFromResult(result: any): string {
113
+ const item = result?.content?.find?.((c: any) => c.type === "text");
114
+ return item?.type === "text" ? sanitizeToolResultText(item.text ?? "") : "";
115
+ }
116
+
117
+ function countLines(text: string): number {
118
+ return text
119
+ .trim()
120
+ .split("\n")
121
+ .filter((line) => line.trim().length > 0).length;
122
+ }
123
+
124
+ function hasExpandableResult(text: string): boolean {
125
+ return countLines(text) > 1;
126
+ }
127
+
128
+ function toolIcon(_name: string): string {
129
+ return "●";
130
+ }
131
+
132
+ // Match the braille loader shown to the left of pi's "Working..." row.
133
+ // Every frame is one cell wide, so tool titles remain horizontally stable.
134
+ const WORKING_LOADER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
135
+ const TOOL_PENDING_FRAMES: Record<string, string[]> = {
136
+ read: WORKING_LOADER_FRAMES,
137
+ bash: WORKING_LOADER_FRAMES,
138
+ edit: WORKING_LOADER_FRAMES,
139
+ write: WORKING_LOADER_FRAMES,
140
+ find: WORKING_LOADER_FRAMES,
141
+ grep: WORKING_LOADER_FRAMES,
142
+ ls: WORKING_LOADER_FRAMES,
143
+ };
144
+
145
+ function animationFrame(frames: string[], intervalMs = 120): string {
146
+ return frames[Math.floor(Date.now() / intervalMs) % frames.length] ?? frames[0] ?? "";
147
+ }
148
+
149
+ const activeAnimationContexts = new Set<any>();
150
+ let sharedAnimationTimer: ReturnType<typeof setTimeout> | null = null;
151
+
152
+ function clearAnimation(context: any) {
153
+ if (!context?.state?.ccstyleAnimationScheduled) return;
154
+ context.state.ccstyleAnimationScheduled = false;
155
+ activeAnimationContexts.delete(context);
156
+ if (activeAnimationContexts.size === 0 && sharedAnimationTimer) {
157
+ clearTimeout(sharedAnimationTimer);
158
+ sharedAnimationTimer = null;
159
+ }
160
+ }
161
+
162
+ function clearAllAnimations() {
163
+ for (const ctx of activeAnimationContexts) {
164
+ ctx.state.ccstyleAnimationScheduled = false;
165
+ }
166
+ activeAnimationContexts.clear();
167
+ if (sharedAnimationTimer) {
168
+ clearTimeout(sharedAnimationTimer);
169
+ sharedAnimationTimer = null;
170
+ }
171
+ }
172
+
173
+ function scheduleAnimation(context: any, intervalMs = 80) {
174
+ const state = (context.state ??= {});
175
+ if (state.ccstyleAnimationScheduled) return;
176
+ state.ccstyleAnimationScheduled = true;
177
+ activeAnimationContexts.add(context);
178
+ if (!sharedAnimationTimer) {
179
+ sharedAnimationTimer = setTimeout(() => {
180
+ sharedAnimationTimer = null;
181
+ const contexts = Array.from(activeAnimationContexts);
182
+ activeAnimationContexts.clear();
183
+ for (const ctx of contexts) {
184
+ ctx.state.ccstyleAnimationScheduled = false;
185
+ ctx.invalidate?.();
186
+ }
187
+ }, intervalMs);
188
+ }
189
+ }
190
+
191
+ function pendingIcon(name: string): string {
192
+ return animationFrame(TOOL_PENDING_FRAMES[name] ?? [toolIcon(name)], 80);
193
+ }
194
+
195
+ type ToolVisualState = "pending" | "success" | "error";
196
+
197
+ function settledIcon(name: string, state: ToolVisualState | undefined): string {
198
+ if (state === "success") return "✓";
199
+ if (state === "error") return "✗";
200
+ return toolIcon(name);
201
+ }
202
+
203
+ function setToolVisualState(context: any, visualState: ToolVisualState) {
204
+ const state = (context.state ??= {});
205
+ if (visualState !== "pending") clearAnimation(context);
206
+ if (state.ccstyleToolVisualState === visualState) return;
207
+ state.ccstyleToolVisualState = visualState;
208
+ // Do not invalidate synchronously from renderResult. Pi is already rendering
209
+ // this tool row; recursively scheduling another render here can retain both
210
+ // the finalized result component and its previous secondary/partial component,
211
+ // which displays the result summary twice. The current render pass also
212
+ // refreshes renderCall, so the settled icon still updates immediately.
213
+ }
214
+
215
+ function getToolVisualState(context: any): ToolVisualState | undefined {
216
+ return context?.state?.ccstyleToolVisualState as ToolVisualState | undefined;
217
+ }
218
+
219
+ function resolveToolVisualState(context: any): ToolVisualState | undefined {
220
+ const visualState = getToolVisualState(context);
221
+ if (visualState || context?.isPartial !== false) return visualState;
222
+ const settledState: ToolVisualState = context?.isError ? "error" : "success";
223
+ setToolVisualState(context, settledState);
224
+ return settledState;
225
+ }
226
+
227
+ function toolIconColor(context: any): "accent" | "error" | "success" | "muted" {
228
+ const visualState = getToolVisualState(context);
229
+ if (context?.isError || visualState === "error") return "error";
230
+ if (visualState === "success") return "success";
231
+ if (context?.isPartial || context?.executionStarted || visualState === "pending") return "accent";
232
+ return "muted";
233
+ }
234
+
235
+ function isToolExpanded(options: any, context: any): boolean {
236
+ const local = context?.state?.ccstyleToolExpanded;
237
+ return typeof local === "boolean" ? local : Boolean(options?.expanded ?? context?.expanded);
238
+ }
239
+
240
+ /** Keep the guide aligned when long result lines wrap at the viewport edge. */
241
+ export class ExpandedToolResultText {
242
+ private text: string;
243
+ private prefix: string;
244
+ private normalizedText: string;
245
+ private cachedWidth: number | undefined;
246
+ private cachedLines: string[] | undefined;
247
+
248
+ constructor(text: string, prefix: string) {
249
+ this.text = text;
250
+ this.prefix = prefix;
251
+ this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
252
+ }
253
+
254
+ setText(text: string): void {
255
+ if (this.text === text) return;
256
+ this.text = text;
257
+ this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
258
+ this.invalidate();
259
+ }
260
+
261
+ setPrefix(prefix: string): void {
262
+ if (this.prefix === prefix) return;
263
+ this.prefix = prefix;
264
+ this.invalidate();
265
+ }
266
+
267
+ render(width: number): string[] {
268
+ if (this.cachedLines !== undefined && this.cachedWidth === width) return this.cachedLines;
269
+
270
+ const prefixWidth = visibleWidth(this.prefix);
271
+ const contentWidth = Math.max(1, width - prefixWidth);
272
+ const lines = wrapTextWithAnsi(this.normalizedText, contentWidth).map((line) =>
273
+ truncateToWidth(this.prefix + line, width, ""),
274
+ );
275
+ this.cachedWidth = width;
276
+ this.cachedLines = lines;
277
+ return lines;
278
+ }
279
+
280
+ invalidate(): void {
281
+ this.cachedWidth = undefined;
282
+ this.cachedLines = undefined;
283
+ }
284
+ }
285
+
286
+ export function renderCollapsedToolResult(body: string, collapsedHint = ""): string {
287
+ return ` ↳ ${body}${collapsedHint}`;
288
+ }
289
+
290
+ function renderExpandedToolResult(
291
+ body: string,
292
+ theme: any,
293
+ isError: boolean,
294
+ lastComponent?: unknown,
295
+ ): ExpandedToolResultText | Text {
296
+ const color = isError ? "error" : "muted";
297
+ if (!body.trim()) {
298
+ return new Text(theme.fg(color, renderCollapsedToolResult("Done")), 0, 0);
299
+ }
300
+
301
+ const text = theme.fg(color, body);
302
+ const prefix = theme.fg(color, " │ ");
303
+ if (lastComponent instanceof ExpandedToolResultText) {
304
+ lastComponent.setText(text);
305
+ lastComponent.setPrefix(prefix);
306
+ return lastComponent;
307
+ }
308
+ return new ExpandedToolResultText(text, prefix);
309
+ }
310
+
311
+ function expandHint(theme: any): string {
312
+ // Keep interaction guidance neutral; it should not inherit success/error
313
+ // coloring from the tool result surrounding it.
314
+ return `${theme.fg("muted", " (")}${keyHint("app.tools.expand", "expand")}${theme.fg("muted", " / click)")}`;
315
+ }
316
+
317
+ type SgrMousePacket = {
318
+ code: number;
319
+ col: number;
320
+ row: number;
321
+ final: "M" | "m";
322
+ };
323
+
324
+ type ToolRenderHit = {
325
+ component: any;
326
+ start: number;
327
+ end: number;
328
+ };
329
+
330
+ const TOOL_MOUSE_WIDGET_KEY = "ccstyle-tool-mouse";
331
+ const TOOL_MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
332
+ const TOOL_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1000l";
333
+ const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
334
+ const ZENTUI_PAGE_DOWN_INPUT = /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
335
+ const SCROLL_BOTTOM_SHORTCUT = "ctrl+end";
336
+ const ZENTUI_WHEEL_ROWS = 3;
337
+ const FIXED_EDITOR_WHEEL_ROWS = 5;
338
+ let toolMouseTui: any = null;
339
+ let toolMouseUi: any = null;
340
+ let toolMouseFixedFeaturesEnabled = false;
341
+ let wheelExtraRowRemainder = 0;
342
+ let lastWheelDirection: "up" | "down" | null = null;
343
+ let collapseCompensationRemainder = 0;
344
+ let toolMouseInputUnsubscribe: (() => void) | null = null;
345
+ let toolMouseInputPatchTui: any = null;
346
+ let toolMouseInputPatchOriginalHandle: ((...args: any[]) => any) | null = null;
347
+ let toolMouseInputPatchWrapper: ((...args: any[]) => any) | null = null;
348
+ let scrollButtonVisible = false;
349
+ let scrollButtonWidget: any = null;
350
+ let pendingScrollMessages = 0;
351
+ let assistantMessageActive = false;
352
+ let scrollButtonSyncScheduled = false;
353
+ let sessionRenderTimer: ReturnType<typeof setTimeout> | null = null;
354
+
355
+ function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
356
+ const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
357
+ const packets: SgrMousePacket[] = [];
358
+ let offset = 0;
359
+
360
+ for (const match of data.matchAll(pattern)) {
361
+ if (match.index !== offset) return null;
362
+ offset = match.index + match[0].length;
363
+ packets.push({
364
+ code: Number(match[1]),
365
+ col: Number(match[2]),
366
+ row: Number(match[3]),
367
+ final: match[4] as "M" | "m",
368
+ });
369
+ }
370
+
371
+ return packets.length > 0 && offset === data.length ? packets : null;
372
+ }
373
+
374
+ function isSgrLeftPress(packet: SgrMousePacket): boolean {
375
+ const baseButton = packet.code & ~(4 | 8 | 16 | 32);
376
+ return packet.final === "M" && baseButton === 0 && (packet.code & 32) === 0;
377
+ }
378
+
379
+ function stripTerminalSequences(value: string): string {
380
+ return value
381
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
382
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
383
+ .replace(/\s+/g, " ")
384
+ .trim();
385
+ }
386
+
387
+ function isToolExecutionComponent(value: any): boolean {
388
+ return Boolean(
389
+ value &&
390
+ typeof value === "object" &&
391
+ typeof value.toolCallId === "string" &&
392
+ typeof value.setExpanded === "function" &&
393
+ typeof value.render === "function",
394
+ );
395
+ }
396
+
397
+ function renderedLineCount(component: any, width: number, cache: WeakMap<object, number>): number {
398
+ if (!component || typeof component !== "object") return 0;
399
+ const cached = cache.get(component);
400
+ if (cached !== undefined) return cached;
401
+
402
+ let count = 0;
403
+ try {
404
+ const lines = component.render(width);
405
+ count = Array.isArray(lines) ? lines.length : 0;
406
+ } catch {
407
+ count = 0;
408
+ }
409
+ cache.set(component, count);
410
+ return count;
411
+ }
412
+
413
+ function collectToolRenderHits(
414
+ component: any,
415
+ start: number,
416
+ width: number,
417
+ hits: ToolRenderHit[],
418
+ cache: WeakMap<object, number>,
419
+ seen: Set<object>,
420
+ ): number {
421
+ if (!component || typeof component !== "object" || seen.has(component)) return start;
422
+ seen.add(component);
423
+
424
+ const count = renderedLineCount(component, width, cache);
425
+ if (isToolExecutionComponent(component)) {
426
+ if (count > 0) hits.push({ component, start, end: start + count });
427
+ return start + count;
428
+ }
429
+
430
+ let childStart = start;
431
+ if (Array.isArray(component.children)) {
432
+ for (const child of component.children) {
433
+ collectToolRenderHits(child, childStart, width, hits, cache, seen);
434
+ childStart += renderedLineCount(child, width, cache);
435
+ }
436
+ }
437
+ return start + count;
438
+ }
439
+
440
+ function collectToolComponents(component: any, tools: any[], seen = new Set<any>()): void {
441
+ if (!component || typeof component !== "object" || seen.has(component)) return;
442
+ seen.add(component);
443
+ if (isToolExecutionComponent(component)) {
444
+ tools.push(component);
445
+ return;
446
+ }
447
+ if (!Array.isArray(component.children)) return;
448
+ for (const child of component.children) collectToolComponents(child, tools, seen);
449
+ }
450
+
451
+ function fixedEditorLineMatch(rendered: string, visible: string): boolean {
452
+ return (
453
+ rendered === visible ||
454
+ (visible.length >= 8 && (rendered.includes(visible) || visible.includes(rendered)))
455
+ );
456
+ }
457
+
458
+ function fixedEditorContextScore(
459
+ renderedLines: string[],
460
+ renderedRow: number,
461
+ visibleLines: string[],
462
+ visibleRow: number,
463
+ ): number {
464
+ let score = renderedLines[renderedRow] === visibleLines[visibleRow] ? 100 : 50;
465
+ for (const direction of [-1, 1]) {
466
+ for (let distance = 1; distance <= 4; distance++) {
467
+ const candidate = renderedLines[renderedRow + direction * distance];
468
+ const visible = visibleLines[visibleRow + direction * distance];
469
+ if (candidate === undefined || visible === undefined || candidate !== visible) break;
470
+ score += 5 - distance;
471
+ }
472
+ }
473
+ return score;
474
+ }
475
+
476
+ function findToolAtFixedEditorRow(
477
+ tui: any,
478
+ visibleRow: number,
479
+ previousLines: string[],
480
+ width: number,
481
+ ): ToolRenderHit | null {
482
+ if (visibleRow < 0 || visibleRow >= previousLines.length) return null;
483
+ const visibleLines = previousLines.map((line) => stripTerminalSequences(String(line)));
484
+ const clickedLine = visibleLines[visibleRow] ?? "";
485
+ if (!clickedLine) return null;
486
+
487
+ const tools: any[] = [];
488
+ collectToolComponents(tui, tools);
489
+ let best: { hit: ToolRenderHit; score: number } | null = null;
490
+ for (const component of tools) {
491
+ const expanded = Boolean(component.expanded);
492
+ if (!expanded && !/(?:expand|\/ click)/i.test(clickedLine)) continue;
493
+ const renderedLines = renderComponentTree(component, width).map((line) =>
494
+ stripTerminalSequences(String(line)),
495
+ );
496
+ for (let renderedRow = 0; renderedRow < renderedLines.length; renderedRow++) {
497
+ if (!fixedEditorLineMatch(renderedLines[renderedRow] ?? "", clickedLine)) continue;
498
+ const score = fixedEditorContextScore(renderedLines, renderedRow, visibleLines, visibleRow);
499
+ if (!best || score > best.score) {
500
+ best = {
501
+ hit: { component, start: visibleRow, end: visibleRow + renderedLines.length },
502
+ score,
503
+ };
504
+ }
505
+ }
506
+ }
507
+ return best?.hit ?? null;
508
+ }
509
+
510
+ function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null {
511
+ const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
512
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
513
+ if (useFixedEditorFeatures(tui)) {
514
+ // Zentui replaces Pi's root render with the already-sliced visible
515
+ // transcript. previousViewportTop remains cursor bookkeeping and must not
516
+ // be added to a physical mouse row here.
517
+ return findToolAtFixedEditorRow(tui, screenRow - 1, previousLines, width);
518
+ }
519
+ const viewportTop = Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0;
520
+ const bufferRow = viewportTop + screenRow - 1;
521
+ if (bufferRow < 0 || bufferRow >= previousLines.length) return null;
522
+
523
+ const hits: ToolRenderHit[] = [];
524
+ const cache = new WeakMap<object, number>();
525
+ collectToolRenderHits(tui, 0, width, hits, cache, new Set<object>());
526
+
527
+ const clickedLine = stripTerminalSequences(String(previousLines[bufferRow] ?? ""));
528
+ for (const hit of hits) {
529
+ if (bufferRow < hit.start || bufferRow >= hit.end) continue;
530
+ const component = hit.component;
531
+ const expanded = Boolean(component.expanded);
532
+ // Match Pi's old click contract: collapsed results expose a click hint;
533
+ // expanded results can be collapsed by clicking any rendered line.
534
+ if (!expanded && !/(?:expand|\/ click)/i.test(clickedLine)) continue;
535
+ return hit;
536
+ }
537
+ return null;
538
+ }
539
+
540
+ function isFixedEditorTui(tui: any): boolean {
541
+ const terminal = tui?.terminal;
542
+ if (!terminal) return false;
543
+ const ownRows = Object.getOwnPropertyDescriptor(terminal, "rows");
544
+ const prototype = Object.getPrototypeOf(terminal);
545
+ const inheritedRows = prototype ? Object.getOwnPropertyDescriptor(prototype, "rows") : undefined;
546
+ return typeof ownRows?.get === "function" && ownRows.get !== inheritedRows?.get;
547
+ }
548
+
549
+ function useFixedEditorFeatures(tui: any): boolean {
550
+ return toolMouseFixedFeaturesEnabled && isFixedEditorTui(tui);
551
+ }
552
+
553
+ function formatShortcut(shortcut: string): string {
554
+ return shortcut
555
+ .split("+")
556
+ .map((part) =>
557
+ part.length <= 1 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`,
558
+ )
559
+ .join("+");
560
+ }
561
+
562
+ function isScrollBottomInput(data: string): boolean {
563
+ return matchesKey(data, SCROLL_BOTTOM_SHORTCUT);
564
+ }
565
+
566
+ function wheelDirection(data: string): "up" | "down" | null {
567
+ const packets = parseSgrMousePackets(data);
568
+ for (const packet of packets ?? []) {
569
+ if (packet.final !== "M") continue;
570
+ const baseButton = packet.code & ~(4 | 8 | 16 | 32);
571
+ if (baseButton === 64) return "up";
572
+ if (baseButton === 65) return "down";
573
+ }
574
+ return null;
575
+ }
576
+
577
+ /** Return how often Zentui's 3-row wheel handler should receive this event. */
578
+ export function fixedEditorWheelDispatchCount(direction: "up" | "down"): number {
579
+ if (lastWheelDirection !== direction) {
580
+ lastWheelDirection = direction;
581
+ wheelExtraRowRemainder = 0;
582
+ }
583
+ wheelExtraRowRemainder += FIXED_EDITOR_WHEEL_ROWS - ZENTUI_WHEEL_ROWS;
584
+ if (wheelExtraRowRemainder < ZENTUI_WHEEL_ROWS) return 1;
585
+ wheelExtraRowRemainder -= ZENTUI_WHEEL_ROWS;
586
+ return 2;
587
+ }
588
+
589
+ function isScrollNavigationInput(data: string): boolean {
590
+ if (
591
+ matchesKey(data, "pageUp") ||
592
+ matchesKey(data, "pageDown") ||
593
+ ZENTUI_PAGE_UP_INPUT.test(data) ||
594
+ ZENTUI_PAGE_DOWN_INPUT.test(data)
595
+ ) {
596
+ return true;
597
+ }
598
+ const packets = parseSgrMousePackets(data);
599
+ return Boolean(
600
+ packets?.some((packet) => {
601
+ const baseButton = packet.code & ~(4 | 8 | 16 | 32);
602
+ return packet.final === "M" && (baseButton === 64 || baseButton === 65);
603
+ }),
604
+ );
605
+ }
606
+
607
+ function directRenderLines(component: any, width: number): string[] {
608
+ try {
609
+ const lines = component?.render?.(width);
610
+ return Array.isArray(lines) ? lines : [];
611
+ } catch {
612
+ return [];
613
+ }
614
+ }
615
+
616
+ /** Render only the transcript portion that fixed-editor leaves scrollable. */
617
+ function renderFixedScrollableRoot(tui: any, width: number): string[] {
618
+ const children = Array.isArray(tui?.children) ? tui.children : [];
619
+ const editorIndex = children.findIndex((child: any) =>
620
+ containsEditorLike(child, tui.focusedComponent),
621
+ );
622
+ const end = editorIndex >= 2 ? editorIndex - 2 : children.length;
623
+ return children.slice(0, end).flatMap((child: any) => directRenderLines(child, width));
624
+ }
625
+
626
+ function isFixedEditorAtBottom(tui: any): boolean {
627
+ if (!useFixedEditorFeatures(tui)) return true;
628
+ const visibleLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
629
+ if (visibleLines.length === 0) return true;
630
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
631
+ const rootLines = renderFixedScrollableRoot(tui, width);
632
+ if (rootLines.length <= visibleLines.length) return true;
633
+ const tail = rootLines.slice(-visibleLines.length);
634
+ return tail.every(
635
+ (line, index) =>
636
+ stripTerminalSequences(String(line)) ===
637
+ stripTerminalSequences(String(visibleLines[index] ?? "")),
638
+ );
639
+ }
640
+
641
+ function hideScrollButton(tui: any): void {
642
+ const changed = scrollButtonVisible || pendingScrollMessages > 0;
643
+ scrollButtonVisible = false;
644
+ pendingScrollMessages = 0;
645
+ if (changed) tui.requestRender?.();
646
+ }
647
+
648
+ function scheduleScrollButtonSync(tui: any, data: string): void {
649
+ if (!useFixedEditorFeatures(tui) || !isScrollNavigationInput(data) || scrollButtonSyncScheduled)
650
+ return;
651
+ scrollButtonSyncScheduled = true;
652
+ const previousLines = tui.previousLines;
653
+ const check = (attempt: number) => {
654
+ scrollButtonSyncScheduled = false;
655
+ if (toolMouseTui !== tui) return;
656
+ // Pi renders on its own frame timer. Inspect the resulting viewport before
657
+ // showing the button so empty or non-scrollable transcripts never flash it.
658
+ const rendered = tui.previousLines !== previousLines;
659
+ if (!rendered && attempt < 4) {
660
+ scrollButtonSyncScheduled = true;
661
+ const timer = setTimeout(() => check(attempt + 1), 16);
662
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
663
+ (timer as { unref: () => void }).unref();
664
+ }
665
+ return;
666
+ }
667
+ const nextVisible = !isFixedEditorAtBottom(tui);
668
+ if (!nextVisible) pendingScrollMessages = 0;
669
+ if (nextVisible !== scrollButtonVisible) {
670
+ scrollButtonVisible = nextVisible;
671
+ tui.requestRender?.();
672
+ }
673
+ };
674
+ process.nextTick(() => check(0));
675
+ }
676
+
677
+ function updateScrollButtonFromInput(tui: any, data: string): void {
678
+ if (!useFixedEditorFeatures(tui)) return;
679
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || isScrollBottomInput(data)) {
680
+ hideScrollButton(tui);
681
+ }
682
+ }
683
+
684
+ function renderComponentTree(component: any, width: number): string[] {
685
+ if (!component || typeof component !== "object") return [];
686
+ try {
687
+ const lines = component.render?.(width);
688
+ if (Array.isArray(lines) && lines.length > 0) return lines;
689
+ } catch {
690
+ // Fall through to children for hidden container renderers.
691
+ }
692
+ if (!Array.isArray(component.children)) return [];
693
+ return component.children.flatMap((child: any) => renderComponentTree(child, width));
694
+ }
695
+
696
+ function renderTreeWithTarget(
697
+ component: any,
698
+ target: any,
699
+ width: number,
700
+ seen = new Set<any>(),
701
+ ): { lines: string[]; targetStart: number | null } {
702
+ if (!component || typeof component !== "object" || seen.has(component)) {
703
+ return { lines: [], targetStart: null };
704
+ }
705
+ seen.add(component);
706
+ if (component === target) {
707
+ return { lines: renderComponentTree(component, width), targetStart: 0 };
708
+ }
709
+
710
+ if (Array.isArray(component.children)) {
711
+ const lines: string[] = [];
712
+ let targetStart: number | null = null;
713
+ for (const child of component.children) {
714
+ const result = renderTreeWithTarget(child, target, width, seen);
715
+ if (result.targetStart !== null) targetStart = lines.length + result.targetStart;
716
+ lines.push(...result.lines);
717
+ }
718
+ if (targetStart !== null) return { lines, targetStart };
719
+ }
720
+
721
+ return { lines: renderComponentTree(component, width), targetStart: null };
722
+ }
723
+
724
+ function normalizedClusterLines(component: any, width: number): string[] {
725
+ if (!component) return [];
726
+ const lines = renderComponentTree(component, width);
727
+ let end = lines.length;
728
+ while (end > 0 && visibleWidth(lines[end - 1] ?? "") === 0) end--;
729
+ return lines.slice(0, Math.max(end, 1));
730
+ }
731
+
732
+ function rawTerminalRows(tui: any): number {
733
+ const terminal = tui?.terminal;
734
+ if (!terminal) return 0;
735
+ const prototype = Object.getPrototypeOf(terminal);
736
+ const rows = prototype ? Object.getOwnPropertyDescriptor(prototype, "rows") : undefined;
737
+ if (typeof rows?.get === "function") {
738
+ try {
739
+ const value = rows.get.call(terminal);
740
+ if (typeof value === "number" && Number.isFinite(value)) return value;
741
+ } catch {
742
+ // Fall through to the current terminal value.
743
+ }
744
+ }
745
+ return typeof terminal.rows === "number" && Number.isFinite(terminal.rows) ? terminal.rows : 0;
746
+ }
747
+
748
+ function containsEditorLike(component: any, focused: any, seen = new Set<any>()): boolean {
749
+ if (!component || typeof component !== "object" || seen.has(component)) return false;
750
+ seen.add(component);
751
+ if (component === focused) return true;
752
+ if (
753
+ typeof component.getText === "function" &&
754
+ typeof component.setText === "function" &&
755
+ typeof component.handleInput === "function"
756
+ )
757
+ return true;
758
+ return (
759
+ Array.isArray(component.children) &&
760
+ component.children.some((child: any) => containsEditorLike(child, focused, seen))
761
+ );
762
+ }
763
+
764
+ function scrollButtonScreenRow(tui: any, width: number): number | null {
765
+ if (!scrollButtonVisible || !useFixedEditorFeatures(tui) || !scrollButtonWidget) return null;
766
+ const children = Array.isArray(tui?.children) ? tui.children : [];
767
+ const editorIndex = children.findIndex((child: any) =>
768
+ containsEditorLike(child, tui.focusedComponent),
769
+ );
770
+ if (editorIndex < 2 || editorIndex + 2 >= children.length) return null;
771
+
772
+ const above = children[editorIndex - 1];
773
+ const widthValue = Math.max(1, width || Number(tui?.terminal?.columns) || 80);
774
+ const target = renderTreeWithTarget(above, scrollButtonWidget, widthValue);
775
+ if (target.targetStart === null) return null;
776
+
777
+ const rawRows = rawTerminalRows(tui);
778
+ if (rawRows <= 0) return null;
779
+ const maxRows = Math.max(1, rawRows - 1);
780
+ const status = normalizedClusterLines(children[editorIndex - 2], widthValue);
781
+ const editor = normalizedClusterLines(children[editorIndex], widthValue);
782
+ const below = normalizedClusterLines(children[editorIndex + 1], widthValue);
783
+ const footer = normalizedClusterLines(children[editorIndex + 2], widthValue);
784
+ const aboveLines =
785
+ target.lines.length > 0 ? target.lines : normalizedClusterLines(above, widthValue);
786
+
787
+ const takeLast = (lines: string[], count: number): string[] =>
788
+ count > 0 ? lines.slice(-count) : [];
789
+ const editorVisible = takeLast(editor, Math.min(editor.length, maxRows));
790
+ let remaining = Math.max(0, maxRows - editorVisible.length);
791
+ const footerVisible = takeLast(footer, remaining);
792
+ remaining -= footerVisible.length;
793
+ const belowVisible = takeLast(below, remaining);
794
+ remaining -= belowVisible.length;
795
+ const aboveVisible = takeLast(aboveLines, remaining);
796
+ const statusVisible = takeLast(status, Math.max(0, remaining - aboveVisible.length));
797
+ const aboveStart = aboveLines.length - aboveVisible.length;
798
+ const targetRow = target.targetStart - aboveStart;
799
+ if (targetRow < 0 || targetRow >= aboveVisible.length) return null;
800
+
801
+ const allLines = [
802
+ ...statusVisible,
803
+ ...aboveVisible,
804
+ ...editorVisible,
805
+ ...belowVisible,
806
+ ...footerVisible,
807
+ ];
808
+ let leadingBlank = 0;
809
+ while (leadingBlank < allLines.length - 1 && visibleWidth(allLines[leadingBlank] ?? "") === 0) {
810
+ leadingBlank++;
811
+ }
812
+ const clusterRow = statusVisible.length + targetRow - leadingBlank;
813
+ if (clusterRow < 0 || clusterRow >= allLines.length - leadingBlank) return null;
814
+ return rawRows - allLines.length + clusterRow + 1;
815
+ }
816
+
817
+ function isScrollButtonAtScreenRow(tui: any, packet: SgrMousePacket): boolean {
818
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
819
+ if (scrollButtonScreenRow(tui, width) !== packet.row || !scrollButtonWidget) return false;
820
+ const rendered = scrollButtonWidget.render?.(width)?.[0];
821
+ if (typeof rendered !== "string") return false;
822
+ const plain = rendered
823
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
824
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
825
+ const leading = plain.length - plain.trimStart().length;
826
+ const end = visibleWidth(plain.trimEnd());
827
+ return packet.col >= leading + 1 && packet.col <= end;
828
+ }
829
+
830
+ function jumpToBottomWithoutSubmit(tui: any): boolean {
831
+ const originalHandle = toolMouseInputPatchTui === tui ? toolMouseInputPatchOriginalHandle : null;
832
+ if (!originalHandle) return false;
833
+
834
+ // Route Enter through Pi's normal listener chain so pi-zentui can update its
835
+ // private scroll offset, but suppress the focused editor for this synthetic
836
+ // dispatch so clicking the button never submits the current input.
837
+ const focused = tui.focusedComponent;
838
+ try {
839
+ tui.focusedComponent = null;
840
+ Reflect.apply(originalHandle, tui, ["\r"]);
841
+ } finally {
842
+ tui.focusedComponent = focused;
843
+ }
844
+ hideScrollButton(tui);
845
+ return true;
846
+ }
847
+
848
+ function handleScrollButtonClick(tui: any, packet: SgrMousePacket): boolean {
849
+ if (!isScrollButtonAtScreenRow(tui, packet)) return false;
850
+ return jumpToBottomWithoutSubmit(tui);
851
+ }
852
+
853
+ function scheduleCollapseViewportCompensation(
854
+ tui: any,
855
+ removedRows: number,
856
+ packet: SgrMousePacket,
857
+ ): void {
858
+ if (removedRows <= 0 || !useFixedEditorFeatures(tui)) return;
859
+ const originalHandle = toolMouseInputPatchTui === tui ? toolMouseInputPatchOriginalHandle : null;
860
+ if (!originalHandle) return;
861
+
862
+ process.nextTick(() => {
863
+ if (toolMouseTui !== tui || toolMouseInputPatchOriginalHandle !== originalHandle) return;
864
+ const targetRows = removedRows + collapseCompensationRemainder;
865
+ const dispatches = Math.max(0, Math.round(targetRows / ZENTUI_WHEEL_ROWS));
866
+ collapseCompensationRemainder = targetRows - dispatches * ZENTUI_WHEEL_ROWS;
867
+ const wheelDown = `\x1b[<65;${packet.col};${packet.row}M`;
868
+ for (let index = 0; index < dispatches; index++) {
869
+ Reflect.apply(originalHandle, tui, [wheelDown]);
870
+ }
871
+ });
872
+ }
873
+
874
+ function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
875
+ const hit = findToolAtScreenRow(tui, packet.row);
876
+ if (!hit) return false;
877
+
878
+ const wasExpanded = Boolean(hit.component.expanded);
879
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
880
+ const previousHeight = wasExpanded ? renderComponentTree(hit.component, width).length : 0;
881
+ hit.component.setExpanded(!wasExpanded);
882
+ hit.component.invalidate?.();
883
+ const nextHeight = wasExpanded ? renderComponentTree(hit.component, width).length : 0;
884
+ tui.requestRender?.();
885
+ if (wasExpanded) {
886
+ scheduleCollapseViewportCompensation(tui, previousHeight - nextHeight, packet);
887
+ }
888
+ return true;
889
+ }
890
+
891
+ function renderScrollButton(width: number, theme: any): string[] {
892
+ if (!scrollButtonVisible || !useFixedEditorFeatures(toolMouseTui)) return [];
893
+ const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
894
+ const messageText =
895
+ pendingScrollMessages > 0
896
+ ? `${pendingScrollMessages} new message${pendingScrollMessages === 1 ? "" : "s"}`
897
+ : "Back to bottom";
898
+ const label = theme.fg("accent", `[ ↓ ${messageText} · ${shortcut} · click ]`);
899
+ const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
900
+ return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
901
+ }
902
+
903
+ /**
904
+ * pi-zentui consumes left-button presses for text selection. Intercept only a
905
+ * tool-row click at the TUI input boundary, before extension listeners run.
906
+ * Keyboard, wheel, drag, release, and non-tool clicks continue through Pi's
907
+ * original dispatcher, preserving pi-zentui's scroll-to-bottom behavior.
908
+ */
909
+ function patchToolMouseInputCapture(tui: any): void {
910
+ if (toolMouseInputPatchTui === tui) return;
911
+
912
+ restoreToolMouseInputCapture();
913
+ const originalHandle = tui?.handleInput;
914
+ if (typeof originalHandle !== "function") return;
915
+
916
+ const wrapper = function (this: any, ...args: any[]): any {
917
+ const data = args[0];
918
+ if (typeof data === "string") {
919
+ updateScrollButtonFromInput(this, data);
920
+ // Capture the current viewport before Pi/Zentui applies the scroll input.
921
+ scheduleScrollButtonSync(this, data);
922
+ if (
923
+ useFixedEditorFeatures(this) &&
924
+ isScrollBottomInput(data) &&
925
+ jumpToBottomWithoutSubmit(this)
926
+ )
927
+ return;
928
+ const packets = parseSgrMousePackets(data);
929
+ if (packets) {
930
+ for (const packet of packets) {
931
+ if (!isSgrLeftPress(packet)) continue;
932
+ if (handleScrollButtonClick(this, packet) || toggleToolAtMouseClick(this, packet)) return;
933
+ }
934
+ }
935
+ }
936
+ const direction =
937
+ typeof data === "string" && useFixedEditorFeatures(this) ? wheelDirection(data) : null;
938
+ const dispatchCount = direction ? fixedEditorWheelDispatchCount(direction) : 1;
939
+ let result = Reflect.apply(originalHandle, this, args);
940
+ for (let index = 1; index < dispatchCount; index++) {
941
+ result = Reflect.apply(originalHandle, this, args);
942
+ }
943
+ if (typeof data === "string") scheduleScrollButtonSync(this, data);
944
+ return result;
945
+ };
946
+
947
+ try {
948
+ tui.handleInput = wrapper;
949
+ } catch {
950
+ return;
951
+ }
952
+ toolMouseInputPatchTui = tui;
953
+ toolMouseInputPatchOriginalHandle = originalHandle;
954
+ toolMouseInputPatchWrapper = wrapper;
955
+ }
956
+
957
+ function restoreToolMouseInputCapture(): void {
958
+ if (
959
+ toolMouseInputPatchTui &&
960
+ toolMouseInputPatchOriginalHandle &&
961
+ toolMouseInputPatchTui.handleInput === toolMouseInputPatchWrapper
962
+ ) {
963
+ toolMouseInputPatchTui.handleInput = toolMouseInputPatchOriginalHandle;
964
+ }
965
+ toolMouseInputPatchTui = null;
966
+ toolMouseInputPatchOriginalHandle = null;
967
+ toolMouseInputPatchWrapper = null;
968
+ }
969
+
970
+ function handleToolMouseInput(data: string): { consume: true } | undefined {
971
+ if (!toolMouseTui) return undefined;
972
+ updateScrollButtonFromInput(toolMouseTui, data);
973
+ if (isScrollBottomInput(data)) {
974
+ if (useFixedEditorFeatures(toolMouseTui) && jumpToBottomWithoutSubmit(toolMouseTui)) {
975
+ return { consume: true };
976
+ }
977
+ if (!toolMouseFixedFeaturesEnabled) {
978
+ // Native Pi scrolls through terminal history rather than an internal
979
+ // viewport. A harmless terminal write makes Ctrl+End snap that history
980
+ // to the active cursor without enabling mouse reporting.
981
+ toolMouseTui.terminal?.write?.("\x1b[0m");
982
+ toolMouseTui.requestRender?.();
983
+ return { consume: true };
984
+ }
985
+ }
986
+ const packets = parseSgrMousePackets(data);
987
+ if (!packets) {
988
+ scheduleScrollButtonSync(toolMouseTui, data);
989
+ return undefined;
990
+ }
991
+
992
+ let consumed = false;
993
+ for (const packet of packets) {
994
+ if (!isSgrLeftPress(packet)) continue;
995
+ if (
996
+ handleScrollButtonClick(toolMouseTui, packet) ||
997
+ toggleToolAtMouseClick(toolMouseTui, packet)
998
+ ) {
999
+ consumed = true;
1000
+ }
1001
+ }
1002
+
1003
+ // Let scrolling, motion, release, and clicks outside tool results reach the
1004
+ // normal TUI input chain (including other extensions such as pi-zentui).
1005
+ scheduleScrollButtonSync(toolMouseTui, data);
1006
+ return consumed ? { consume: true } : undefined;
1007
+ }
1008
+
1009
+ function teardownToolMouseInteraction(): void {
1010
+ if (sessionRenderTimer) {
1011
+ clearTimeout(sessionRenderTimer);
1012
+ sessionRenderTimer = null;
1013
+ }
1014
+ toolMouseInputUnsubscribe?.();
1015
+ toolMouseInputUnsubscribe = null;
1016
+ try {
1017
+ toolMouseTui?.terminal?.write?.(TOOL_MOUSE_DISABLE);
1018
+ } catch {
1019
+ // The terminal may already be closed during shutdown.
1020
+ }
1021
+ try {
1022
+ toolMouseUi?.setWidget?.(TOOL_MOUSE_WIDGET_KEY, undefined);
1023
+ } catch {
1024
+ // The UI context may already have been reset during /reload.
1025
+ }
1026
+ restoreToolMouseInputCapture();
1027
+ scrollButtonVisible = false;
1028
+ scrollButtonWidget = null;
1029
+ pendingScrollMessages = 0;
1030
+ assistantMessageActive = false;
1031
+ scrollButtonSyncScheduled = false;
1032
+ toolMouseTui = null;
1033
+ toolMouseUi = null;
1034
+ toolMouseFixedFeaturesEnabled = false;
1035
+ wheelExtraRowRemainder = 0;
1036
+ lastWheelDirection = null;
1037
+ collapseCompensationRemainder = 0;
1038
+ }
1039
+
1040
+ export function installToolMouseInteraction(
1041
+ ctx: any,
1042
+ fixedEditorFeatures = config.fixedEditorFeatures,
1043
+ ): void {
1044
+ teardownToolMouseInteraction();
1045
+ if (ctx?.mode !== "tui" || !ctx?.hasUI) return;
1046
+ if (typeof ctx.ui?.onTerminalInput !== "function" || typeof ctx.ui?.setWidget !== "function")
1047
+ return;
1048
+
1049
+ toolMouseUi = ctx.ui;
1050
+ toolMouseFixedFeaturesEnabled = fixedEditorFeatures;
1051
+ ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any, theme: any) => {
1052
+ toolMouseTui = tui;
1053
+ if (fixedEditorFeatures) {
1054
+ patchToolMouseInputCapture(tui);
1055
+ tui?.terminal?.write?.(TOOL_MOUSE_ENABLE);
1056
+ }
1057
+ const widget = {
1058
+ render: (width: number) => renderScrollButton(width, theme),
1059
+ invalidate() {},
1060
+ };
1061
+ scrollButtonWidget = widget;
1062
+ return widget;
1063
+ });
1064
+ toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
1065
+ }
1066
+
1067
+ function scheduleSessionRender(refresh?: () => void): void {
1068
+ const tui = toolMouseTui;
1069
+ if (!tui || typeof tui.requestRender !== "function") return;
1070
+ if (sessionRenderTimer) clearTimeout(sessionRenderTimer);
1071
+ // Restored transcripts are populated at different points for startup, reload,
1072
+ // and session replacement. Repaint after session_start and the surrounding UI
1073
+ // rebuild finish so messages are not left hidden until the next terminal input.
1074
+ sessionRenderTimer = setTimeout(() => {
1075
+ sessionRenderTimer = null;
1076
+ if (toolMouseTui !== tui) return;
1077
+ refresh?.();
1078
+ tui.requestRender(true);
1079
+ }, 0);
1080
+ }
1081
+
1082
+ // Bright green for success icon (truecolor ANSI escape)
1083
+ const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
1084
+ const ANSI_RESET = "\x1b[0m";
1085
+
1086
+ function refreshCurrentTranscript(compactStyle: CompactStyleHooks, ctx?: any): void {
1087
+ compactStyle.refresh();
1088
+ toolMouseTui?.requestRender?.(true);
1089
+ ctx?.ui?.requestRender?.(true);
1090
+ }
1091
+
1092
+ function applyStyleMode(mode: CompactStyleMode, ctx: any, compactStyle: CompactStyleHooks): void {
1093
+ config.mode = mode;
1094
+ saveConfig();
1095
+ refreshCurrentTranscript(compactStyle, ctx);
1096
+ ctx.ui.notify(`Claude Code style: ${mode}`, "info");
1097
+ }
1098
+
1099
+ function modeSettingDescription(mode: CompactStyleMode): string {
1100
+ if (mode === "compact") {
1101
+ return "Compact transcript summaries are enabled. Fixed editor interaction is configured separately below.";
1102
+ }
1103
+ if (mode === "off") {
1104
+ return "Pi native tool rendering is enabled. Fixed editor interaction is configured separately below.";
1105
+ }
1106
+ return "Claude Code style and rich edit/write diffs are enabled. Fixed editor interaction is configured separately below.";
1107
+ }
1108
+
1109
+ function fixedEditorSettingDescription(enabled: boolean): string {
1110
+ return enabled
1111
+ ? "Enables mouse capture, 5-row wheel scrolling, tool click expansion with collapse anchoring, fixed viewport mapping, the back-to-bottom button, message count, and Ctrl+End."
1112
+ : "Uses terminal-native wheel scrolling; disables mouse capture, tool clicks, fixed viewport mapping, button, and message count. Ctrl+End remains enabled.";
1113
+ }
1114
+
1115
+ async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Promise<void> {
1116
+ if (ctx?.mode !== "tui" || !ctx?.hasUI || typeof ctx.ui?.custom !== "function") {
1117
+ ctx.ui?.notify?.("/ccstyle requires TUI mode", "warning");
1118
+ return;
1119
+ }
1120
+
1121
+ await ctx.ui.custom((tui: any, theme: any, _keybindings: any, done: () => void) => {
1122
+ const container = new Container();
1123
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
1124
+ container.addChild(new Text(theme.fg("accent", theme.bold("Claude Code Style")), 1, 0));
1125
+ const modeSetting = {
1126
+ id: "mode",
1127
+ label: "Mode",
1128
+ description: modeSettingDescription(config.mode),
1129
+ currentValue: config.mode,
1130
+ values: ["on", "off", "compact"],
1131
+ };
1132
+ const fixedEditorSetting = {
1133
+ id: "fixedEditorFeatures",
1134
+ label: "Fixed editor feature",
1135
+ description: fixedEditorSettingDescription(config.fixedEditorFeatures),
1136
+ currentValue: config.fixedEditorFeatures ? "on" : "off",
1137
+ values: ["on", "off"],
1138
+ };
1139
+ const settingsList = new SettingsList(
1140
+ [modeSetting, fixedEditorSetting],
1141
+ 4,
1142
+ getSettingsListTheme(),
1143
+ (id: string, value: string) => {
1144
+ if (id === "mode") {
1145
+ modeSetting.description = modeSettingDescription(value as CompactStyleMode);
1146
+ applyStyleMode(value as CompactStyleMode, ctx, compactStyle);
1147
+ return;
1148
+ }
1149
+ if (id === "fixedEditorFeatures") {
1150
+ config.fixedEditorFeatures = value === "on";
1151
+ fixedEditorSetting.description = fixedEditorSettingDescription(
1152
+ config.fixedEditorFeatures,
1153
+ );
1154
+ saveConfig();
1155
+ installToolMouseInteraction(ctx);
1156
+ refreshCurrentTranscript(compactStyle, ctx);
1157
+ ctx.ui.notify(`Fixed editor feature: ${value}`, "info");
1158
+ }
1159
+ },
1160
+ () => done(),
1161
+ { enableSearch: false },
1162
+ );
1163
+ container.addChild(settingsList);
1164
+ container.addChild(
1165
+ new Text(theme.fg("dim", "↑↓ navigate · enter/space change · esc close"), 1, 0),
1166
+ );
1167
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
1168
+ return {
1169
+ render: (width: number) => container.render(width),
1170
+ invalidate: () => container.invalidate(),
1171
+ handleInput(data: string) {
1172
+ settingsList.handleInput?.(data);
1173
+ tui.requestRender();
1174
+ },
1175
+ };
1176
+ });
1177
+ }
1178
+
1179
+ function renderDefault(tool: any, slot: "renderCall" | "renderResult", args: any[], fallback = "") {
1180
+ try {
1181
+ if (typeof tool?.[slot] === "function") return tool[slot](...args);
1182
+ } catch {
1183
+ // Fall through to raw fallback.
1184
+ }
1185
+ return new Text(fallback, 0, 0);
1186
+ }
1187
+
1188
+ /**
1189
+ * Generate a descriptive label for an unknown tool from its args.
1190
+ * Uses the tool label + first stringable arg value.
1191
+ */
1192
+ function toolCallLabel(toolName: string, toolLabel: string, args: any): string {
1193
+ if (!args) return toolLabel;
1194
+ const keys = Object.keys(args);
1195
+ if (keys.length === 0) return toolLabel;
1196
+ // Prefer "query" or "question" args, then fall back to the first stringable value
1197
+ const preferred = ["query", "question", "command", "pattern", "name", "path", "url", "message"];
1198
+ for (const key of preferred) {
1199
+ const val = args[key];
1200
+ if (val !== undefined && val !== null && typeof val !== "object") {
1201
+ return `${toolLabel}(${oneLine(val, 60)})`;
1202
+ }
1203
+ }
1204
+ const firstKey = keys[0];
1205
+ const firstVal = args[firstKey];
1206
+ if (firstVal !== undefined && firstVal !== null && typeof firstVal !== "object") {
1207
+ return `${toolLabel}(${oneLine(firstVal, 60)})`;
1208
+ }
1209
+ return toolLabel;
1210
+ }
1211
+
1212
+ export function shouldRenderRichDiff(
1213
+ mode: CompactStyleMode,
1214
+ toolName: string,
1215
+ isError: boolean,
1216
+ ): boolean {
1217
+ return mode === "on" && !isError && (toolName === "edit" || toolName === "write");
1218
+ }
1219
+
1220
+ /** Wrap an arbitrary tool definition with ccstyle call/result rendering. */
1221
+ function createCcstyleTool(
1222
+ originalTool: any,
1223
+ writeExecutionMetadata: WriteExecutionMetadataStore,
1224
+ ): any {
1225
+ const toolName = originalTool.name;
1226
+ const label = isMcpToolDefinition(originalTool, toolName)
1227
+ ? toolName
1228
+ : originalTool.label || toolName;
1229
+
1230
+ return {
1231
+ ...originalTool,
1232
+ renderShell: "self",
1233
+ renderCall(args: any, theme: any, context: any) {
1234
+ if (config.mode !== "on") {
1235
+ return renderDefault(originalTool, "renderCall", [args, theme, context], String(toolName));
1236
+ }
1237
+
1238
+ const visualState = resolveToolVisualState(context);
1239
+ const isPending =
1240
+ visualState === "pending" ||
1241
+ (!visualState && (context?.isPartial || context?.executionStarted));
1242
+ if (isPending) scheduleAnimation(context);
1243
+ const rawIcon = isPending ? pendingIcon(toolName) : settledIcon(toolName, visualState);
1244
+ const icon =
1245
+ visualState === "success"
1246
+ ? `${BRIGHT_GREEN}${rawIcon}${ANSI_RESET}`
1247
+ : theme.fg(toolIconColor(context), rawIcon);
1248
+
1249
+ const title = `${icon} ${theme.fg("toolTitle", toolCallLabel(toolName, label, args))}`;
1250
+ return new Text(title, 0, 0);
1251
+ },
1252
+ renderResult(result: any, options: any, theme: any, context: any) {
1253
+ if (config.mode !== "on") {
1254
+ return renderDefault(
1255
+ originalTool,
1256
+ "renderResult",
1257
+ [result, options, theme, context],
1258
+ textFromResult(result),
1259
+ );
1260
+ }
1261
+
1262
+ if (options?.isPartial) {
1263
+ return new Text(theme.fg("muted", " ⎿ Pending…"), 0, 0);
1264
+ }
1265
+
1266
+ const isError = options?.isError || context?.isError;
1267
+ setToolVisualState(context, isError ? "error" : "success");
1268
+ const expanded = isToolExpanded(options, context);
1269
+ if (shouldRenderRichDiff(config.mode, toolName, Boolean(isError))) {
1270
+ const richResult = renderRichToolResult(
1271
+ toolName,
1272
+ result,
1273
+ { ...options, expanded },
1274
+ theme,
1275
+ context,
1276
+ writeExecutionMetadata,
1277
+ );
1278
+ if (richResult) return richResult;
1279
+ }
1280
+
1281
+ const text = textFromResult(result);
1282
+ const rendered = !expanded ? (text ? oneLine(text, 96) : "Done") : text;
1283
+
1284
+ const hint = !expanded && hasExpandableResult(text) ? expandHint(theme) : "";
1285
+ if (expanded)
1286
+ return renderExpandedToolResult(rendered, theme, isError, context?.lastComponent);
1287
+ return new Text(
1288
+ theme.fg(isError ? "error" : "muted", renderCollapsedToolResult(rendered, hint)),
1289
+ 0,
1290
+ 0,
1291
+ );
1292
+ },
1293
+ };
1294
+ }
1295
+
1296
+ /**
1297
+ * Apart from the write override used to capture pre-write content, renderers are
1298
+ * applied through ToolExecutionComponent. Patch its lookup once so tools use the
1299
+ * same compact fallback shell by default. Tools named in excludeRenderers keep
1300
+ * their original renderer; subagent rendering remains protected.
1301
+ */
1302
+ const GLOBAL_TOOL_RENDER_PATCH = Symbol.for("pi.ccstyle.global-tool-render-patch");
1303
+ const COMPONENT_TOOL_RENDER_MODE = Symbol.for("pi.ccstyle.component-tool-render-mode");
1304
+ const COMPONENT_TOOL_SELF_SHELL_MODE = Symbol.for("pi.ccstyle.component-tool-self-shell-mode");
1305
+ const SUBAGENT_TOOL_NAMES = new Set(["Agent"]);
1306
+
1307
+ type ToolRenderMethods = {
1308
+ hasRendererDefinition: (...args: any[]) => boolean;
1309
+ getRenderShell: (...args: any[]) => "default" | "self";
1310
+ getCallRenderer: (...args: any[]) => any;
1311
+ getResultRenderer: (...args: any[]) => any;
1312
+ };
1313
+
1314
+ type GlobalToolRenderPatch = {
1315
+ version: 2;
1316
+ prototype: any;
1317
+ owner: object;
1318
+ active: boolean;
1319
+ enabled: () => boolean;
1320
+ mode: () => CompactStyleMode;
1321
+ wrap: (tool: any) => any;
1322
+ byDefinition: WeakMap<object, any>;
1323
+ byName: Map<string, any>;
1324
+ downstream: ToolRenderMethods;
1325
+ installed: ToolRenderMethods;
1326
+ // Keep the legacy aliases so an older extension build can read this Symbol.
1327
+ originalHasRendererDefinition: ToolRenderMethods["hasRendererDefinition"];
1328
+ originalGetRenderShell: ToolRenderMethods["getRenderShell"];
1329
+ originalGetCallRenderer: ToolRenderMethods["getCallRenderer"];
1330
+ originalGetResultRenderer: ToolRenderMethods["getResultRenderer"];
1331
+ };
1332
+
1333
+ function isMcpToolDefinition(definition: any, toolName: string): boolean {
1334
+ const label = typeof definition?.label === "string" ? definition.label : "";
1335
+ return toolName === "mcp" || label === "MCP" || label.startsWith("MCP: ");
1336
+ }
1337
+
1338
+ /** Return true when this tool must keep its original renderer. */
1339
+ export function preservesOriginalRenderer(
1340
+ extensionDefinition: any,
1341
+ toolName: string,
1342
+ builtInToolDefinition?: any,
1343
+ excludeRenderers: readonly string[] = config.excludeRenderers,
1344
+ ): boolean {
1345
+ if (SUBAGENT_TOOL_NAMES.has(toolName)) return true;
1346
+ if (!excludeRenderers.includes(toolName)) return false;
1347
+ return [extensionDefinition, builtInToolDefinition].some(
1348
+ (definition) =>
1349
+ definition?.renderShell === "self" ||
1350
+ typeof definition?.renderCall === "function" ||
1351
+ typeof definition?.renderResult === "function",
1352
+ );
1353
+ }
1354
+
1355
+ function syncToolShell(component: any, shell: "default" | "self"): void {
1356
+ const target = shell === "self" ? component.selfRenderContainer : component.contentBox;
1357
+ if (!target || !Array.isArray(component.children)) return;
1358
+ const candidates = new Set(
1359
+ [component.contentText, component.contentBox, component.selfRenderContainer].filter(Boolean),
1360
+ );
1361
+ const indexes = component.children
1362
+ .map((child: any, index: number) => (candidates.has(child) ? index : -1))
1363
+ .filter((index: number) => index >= 0);
1364
+ const targetIndex = indexes[0];
1365
+ // During construction getRenderShell() runs immediately before Pi mounts the
1366
+ // selected shell. Do not mount it here or the constructor will add it twice.
1367
+ if (targetIndex === undefined) return;
1368
+ component.children[targetIndex] = target;
1369
+ for (const index of indexes.sort((left: number, right: number) => right - left)) {
1370
+ if (index !== targetIndex) component.children.splice(index, 1);
1371
+ }
1372
+ }
1373
+
1374
+ function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch): boolean {
1375
+ const extensionDefinition = component.toolDefinition;
1376
+ const builtInDefinition = component.builtInToolDefinition;
1377
+ const definition = extensionDefinition ?? builtInDefinition;
1378
+ const toolName = String(component.toolName || definition?.name || "");
1379
+ const useCcstyle =
1380
+ patch.mode() === "on" &&
1381
+ !preservesOriginalRenderer(extensionDefinition, toolName, builtInDefinition);
1382
+ component[COMPONENT_TOOL_RENDER_MODE] = useCcstyle;
1383
+ return useCcstyle;
1384
+ }
1385
+
1386
+ function shouldUseSelfShell(component: any, patch: GlobalToolRenderPatch): boolean {
1387
+ const definition = component.toolDefinition ?? component.builtInToolDefinition;
1388
+ const toolName = String(component.toolName || definition?.name || "");
1389
+ const useSelfShell =
1390
+ patch.enabled() &&
1391
+ SUBAGENT_TOOL_NAMES.has(toolName) &&
1392
+ definition != null &&
1393
+ definition.renderShell === undefined;
1394
+ component[COMPONENT_TOOL_SELF_SHELL_MODE] = useSelfShell;
1395
+ return useSelfShell;
1396
+ }
1397
+
1398
+ function getGloballyStyledTool(component: any, patch: GlobalToolRenderPatch): any {
1399
+ const definition = component.toolDefinition ?? component.builtInToolDefinition;
1400
+ if (definition && typeof definition === "object") {
1401
+ let wrapped = patch.byDefinition.get(definition);
1402
+ if (!wrapped) {
1403
+ wrapped = patch.wrap(definition);
1404
+ patch.byDefinition.set(definition, wrapped);
1405
+ }
1406
+ return wrapped;
1407
+ }
1408
+
1409
+ const name = String(component.toolName || "tool");
1410
+ let wrapped = patch.byName.get(name);
1411
+ if (!wrapped) {
1412
+ wrapped = patch.wrap({ name, label: name });
1413
+ patch.byName.set(name, wrapped);
1414
+ }
1415
+ return wrapped;
1416
+ }
1417
+
1418
+ function prototypeToolRenderMethods(prototype: any): ToolRenderMethods {
1419
+ return {
1420
+ hasRendererDefinition: prototype.hasRendererDefinition,
1421
+ getRenderShell: prototype.getRenderShell,
1422
+ getCallRenderer: prototype.getCallRenderer,
1423
+ getResultRenderer: prototype.getResultRenderer,
1424
+ };
1425
+ }
1426
+
1427
+ function isOwnershipAwarePatch(value: any): value is GlobalToolRenderPatch {
1428
+ if (!value || value.version !== 2 || !value.installed || !value.downstream) return false;
1429
+ return ["hasRendererDefinition", "getRenderShell", "getCallRenderer", "getResultRenderer"].every(
1430
+ (name) =>
1431
+ typeof value.installed[name] === "function" && typeof value.downstream[name] === "function",
1432
+ );
1433
+ }
1434
+
1435
+ function isLegacyInstalledWrapper(method: unknown, downstreamField: string): boolean {
1436
+ if (typeof method !== "function") return false;
1437
+ try {
1438
+ const source = Function.prototype.toString.call(method);
1439
+ return (
1440
+ source.includes(downstreamField) &&
1441
+ (source.includes("shouldGloballyStyleTool") ||
1442
+ source.includes("shouldUseSelfShell") ||
1443
+ source.includes("getGloballyStyledTool"))
1444
+ );
1445
+ } catch {
1446
+ return false;
1447
+ }
1448
+ }
1449
+
1450
+ function downstreamForGlobalToolInstall(prototype: any, previous: any): ToolRenderMethods {
1451
+ const current = prototypeToolRenderMethods(prototype);
1452
+ if (!previous || previous.prototype !== prototype) return current;
1453
+ if (isOwnershipAwarePatch(previous)) {
1454
+ return {
1455
+ hasRendererDefinition:
1456
+ current.hasRendererDefinition === previous.installed.hasRendererDefinition
1457
+ ? previous.downstream.hasRendererDefinition
1458
+ : current.hasRendererDefinition,
1459
+ getRenderShell:
1460
+ current.getRenderShell === previous.installed.getRenderShell
1461
+ ? previous.downstream.getRenderShell
1462
+ : current.getRenderShell,
1463
+ getCallRenderer:
1464
+ current.getCallRenderer === previous.installed.getCallRenderer
1465
+ ? previous.downstream.getCallRenderer
1466
+ : current.getCallRenderer,
1467
+ getResultRenderer:
1468
+ current.getResultRenderer === previous.installed.getResultRenderer
1469
+ ? previous.downstream.getResultRenderer
1470
+ : current.getResultRenderer,
1471
+ };
1472
+ }
1473
+
1474
+ // Pre-v2 Symbol state did not retain wrapper references. Recognize its known
1475
+ // wrappers when possible; otherwise preserve the current method as external.
1476
+ const legacyDownstream = (method: Function, field: string): Function => {
1477
+ const saved = previous[field];
1478
+ return typeof saved === "function" && isLegacyInstalledWrapper(method, field) ? saved : method;
1479
+ };
1480
+ return {
1481
+ hasRendererDefinition: legacyDownstream(
1482
+ current.hasRendererDefinition,
1483
+ "originalHasRendererDefinition",
1484
+ ) as ToolRenderMethods["hasRendererDefinition"],
1485
+ getRenderShell: legacyDownstream(
1486
+ current.getRenderShell,
1487
+ "originalGetRenderShell",
1488
+ ) as ToolRenderMethods["getRenderShell"],
1489
+ getCallRenderer: legacyDownstream(
1490
+ current.getCallRenderer,
1491
+ "originalGetCallRenderer",
1492
+ ) as ToolRenderMethods["getCallRenderer"],
1493
+ getResultRenderer: legacyDownstream(
1494
+ current.getResultRenderer,
1495
+ "originalGetResultRenderer",
1496
+ ) as ToolRenderMethods["getResultRenderer"],
1497
+ };
1498
+ }
1499
+
1500
+ function disconnectGlobalToolRenderPatch(patch: any): void {
1501
+ if (!patch || typeof patch !== "object") return;
1502
+ patch.active = false;
1503
+ patch.enabled = () => false;
1504
+ patch.mode = () => "off";
1505
+ patch.wrap = (tool: any) => tool;
1506
+ patch.byDefinition = new WeakMap();
1507
+ if (patch.byName && typeof patch.byName.clear === "function") patch.byName.clear();
1508
+ else patch.byName = new Map();
1509
+ }
1510
+
1511
+ function installGlobalToolRendering(
1512
+ writeExecutionMetadata: WriteExecutionMetadataStore,
1513
+ ): GlobalToolRenderPatch {
1514
+ const prototype = (ToolExecutionComponent as any).prototype;
1515
+ const host = globalThis as any;
1516
+ const previous = host[GLOBAL_TOOL_RENDER_PATCH];
1517
+ const downstream = downstreamForGlobalToolInstall(prototype, previous);
1518
+ // Any wrapper retained by an external owner must become a callback-free
1519
+ // pass-through before the new installation is linked above it.
1520
+ disconnectGlobalToolRenderPatch(previous);
1521
+
1522
+ const patch: GlobalToolRenderPatch = {
1523
+ version: 2,
1524
+ prototype,
1525
+ owner: {},
1526
+ active: true,
1527
+ enabled: () => config.mode === "on",
1528
+ mode: () => config.mode,
1529
+ wrap: (tool: any) => createCcstyleTool(tool, writeExecutionMetadata),
1530
+ byDefinition: new WeakMap(),
1531
+ byName: new Map(),
1532
+ downstream,
1533
+ installed: undefined as any,
1534
+ originalHasRendererDefinition: downstream.hasRendererDefinition,
1535
+ originalGetRenderShell: downstream.getRenderShell,
1536
+ originalGetCallRenderer: downstream.getCallRenderer,
1537
+ originalGetResultRenderer: downstream.getResultRenderer,
1538
+ };
1539
+
1540
+ patch.installed = {
1541
+ hasRendererDefinition: function (this: any, ...args: any[]) {
1542
+ if (patch.active && shouldGloballyStyleTool(this, patch)) return true;
1543
+ return patch.downstream.hasRendererDefinition.apply(this, args);
1544
+ },
1545
+ getRenderShell: function (this: any, ...args: any[]) {
1546
+ if (!patch.active) return patch.downstream.getRenderShell.apply(this, args);
1547
+ const shell =
1548
+ shouldUseSelfShell(this, patch) || shouldGloballyStyleTool(this, patch)
1549
+ ? "self"
1550
+ : patch.downstream.getRenderShell.apply(this, args);
1551
+ syncToolShell(this, shell);
1552
+ return shell;
1553
+ },
1554
+ getCallRenderer: function (this: any, ...args: any[]) {
1555
+ if (patch.active && shouldGloballyStyleTool(this, patch)) {
1556
+ return getGloballyStyledTool(this, patch).renderCall;
1557
+ }
1558
+ return patch.downstream.getCallRenderer.apply(this, args);
1559
+ },
1560
+ getResultRenderer: function (this: any, ...args: any[]) {
1561
+ if (patch.active && shouldGloballyStyleTool(this, patch)) {
1562
+ return getGloballyStyledTool(this, patch).renderResult;
1563
+ }
1564
+ return patch.downstream.getResultRenderer.apply(this, args);
1565
+ },
1566
+ };
1567
+
1568
+ prototype.hasRendererDefinition = patch.installed.hasRendererDefinition;
1569
+ prototype.getRenderShell = patch.installed.getRenderShell;
1570
+ prototype.getCallRenderer = patch.installed.getCallRenderer;
1571
+ prototype.getResultRenderer = patch.installed.getResultRenderer;
1572
+ host[GLOBAL_TOOL_RENDER_PATCH] = patch;
1573
+ return patch;
1574
+ }
1575
+
1576
+ function deactivateGlobalToolRendering(patch: GlobalToolRenderPatch): void {
1577
+ if (!patch.active) return;
1578
+ disconnectGlobalToolRenderPatch(patch);
1579
+ const prototype = patch.prototype;
1580
+ if (prototype.hasRendererDefinition === patch.installed.hasRendererDefinition) {
1581
+ prototype.hasRendererDefinition = patch.downstream.hasRendererDefinition;
1582
+ }
1583
+ if (prototype.getRenderShell === patch.installed.getRenderShell) {
1584
+ prototype.getRenderShell = patch.downstream.getRenderShell;
1585
+ }
1586
+ if (prototype.getCallRenderer === patch.installed.getCallRenderer) {
1587
+ prototype.getCallRenderer = patch.downstream.getCallRenderer;
1588
+ }
1589
+ if (prototype.getResultRenderer === patch.installed.getResultRenderer) {
1590
+ prototype.getResultRenderer = patch.downstream.getResultRenderer;
1591
+ }
1592
+ }
1593
+
1594
+ const GLOBAL_COMPACTION_RENDER_PATCH = Symbol.for("pi.ccstyle.compaction-render-patch");
1595
+
1596
+ type LegacyCompactionRenderPatch = {
1597
+ enabled?: () => boolean;
1598
+ };
1599
+
1600
+ /** Disable the pre-native compaction monkey patch left alive by /reload. */
1601
+ function deactivateLegacyCompactionRendering() {
1602
+ const patch = (globalThis as any)[GLOBAL_COMPACTION_RENDER_PATCH] as
1603
+ | LegacyCompactionRenderPatch
1604
+ | undefined;
1605
+ if (patch) patch.enabled = () => false;
1606
+ }
1607
+
1608
+ function notePendingScrollMessage(role: unknown): void {
1609
+ if (!toolMouseTui || !useFixedEditorFeatures(toolMouseTui) || !scrollButtonVisible) return;
1610
+ if (role === "assistant") {
1611
+ if (assistantMessageActive) return;
1612
+ assistantMessageActive = true;
1613
+ } else if (role !== "toolResult") {
1614
+ return;
1615
+ }
1616
+ pendingScrollMessages += 1;
1617
+ toolMouseTui.requestRender?.();
1618
+ }
1619
+
1620
+ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
1621
+ // The optional override keeps integration tests independent from the user's global config.
1622
+ if (configOverride) config = normalizeConfig({ ...config, ...configOverride });
1623
+ const writeExecutionMetadata = installWriteOverride(pi, new WriteExecutionMetadataStore());
1624
+ const globalToolRendering = installGlobalToolRendering(writeExecutionMetadata);
1625
+ deactivateLegacyCompactionRendering();
1626
+ const compactStyle: CompactStyleHooks = installCompactStyle(pi, {
1627
+ getMode: () => config.mode,
1628
+ getExcludeRenderers: () => config.excludeRenderers,
1629
+ });
1630
+
1631
+ pi.registerCommand("ccstyle", {
1632
+ description: "Configure Claude Code style",
1633
+ getArgumentCompletions: (prefix) => {
1634
+ const topLevel = [
1635
+ { value: "on", label: "on", description: "Enable Claude Code style" },
1636
+ { value: "off", label: "off", description: "Use Pi's native renderer" },
1637
+ { value: "compact", label: "compact", description: "Use compact transcript rendering" },
1638
+ { value: "status", label: "status", description: "Show current state" },
1639
+ ];
1640
+ return topLevel.filter((item) => item.value.startsWith(prefix));
1641
+ },
1642
+ handler: async (args, ctx) => {
1643
+ const arg = args.trim().toLowerCase();
1644
+ if (!arg) {
1645
+ await showCcstylePanel(ctx, compactStyle);
1646
+ return;
1647
+ }
1648
+ if (arg === "on" || arg === "off" || arg === "compact") {
1649
+ applyStyleMode(arg, ctx, compactStyle);
1650
+ return;
1651
+ }
1652
+ if (arg === "status") {
1653
+ ctx.ui.notify(`Claude Code style: ${config.mode}`, "info");
1654
+ return;
1655
+ }
1656
+ ctx.ui.notify("Usage: /ccstyle [on|off|compact|status]", "warning");
1657
+ },
1658
+ });
1659
+
1660
+ pi.on("session_start", async (event, ctx) => {
1661
+ compactStyle?.onSessionStart(event, ctx);
1662
+ pendingScrollMessages = 0;
1663
+ assistantMessageActive = false;
1664
+ ctx.ui.setStatus("ccstyle", undefined);
1665
+ installToolMouseInteraction(ctx);
1666
+ scheduleSessionRender(compactStyle.refresh);
1667
+ });
1668
+
1669
+ pi.on("session_compact", async (event, ctx) => {
1670
+ compactStyle?.onSessionCompact(event, ctx);
1671
+ // Compaction rebuilds the transcript without session_start. Rebind after
1672
+ // other TUI extensions may have replaced the root input dispatcher.
1673
+ installToolMouseInteraction(ctx);
1674
+ scheduleSessionRender(compactStyle.refresh);
1675
+ });
1676
+
1677
+ pi.on("message_start", async (event) => {
1678
+ notePendingScrollMessage(event?.message?.role);
1679
+ });
1680
+
1681
+ pi.on("message_update", async (event, ctx) => {
1682
+ compactStyle?.onMessageUpdate(event, ctx);
1683
+ if (event?.message?.role === "assistant") notePendingScrollMessage("assistant");
1684
+ });
1685
+
1686
+ pi.on("message_end", async (event) => {
1687
+ if (event?.message?.role === "assistant") assistantMessageActive = false;
1688
+ });
1689
+
1690
+ pi.on("agent_start", async (event, ctx) => {
1691
+ compactStyle?.onAgentStart(event, ctx);
1692
+ });
1693
+
1694
+ pi.on("agent_end", async (event, ctx) => {
1695
+ compactStyle?.onAgentEnd(event, ctx);
1696
+ });
1697
+
1698
+ pi.on("turn_start", async (event, ctx) => {
1699
+ compactStyle?.onTurnStart(event, ctx);
1700
+ });
1701
+
1702
+ pi.on("tool_execution_start", async (event, ctx) => {
1703
+ compactStyle?.onToolExecutionStart(event, ctx);
1704
+ });
1705
+
1706
+ pi.on("tool_execution_update", async (event, ctx) => {
1707
+ compactStyle?.onToolExecutionUpdate(event, ctx);
1708
+ });
1709
+
1710
+ pi.on("tool_execution_end", async (event, ctx) => {
1711
+ compactStyle?.onToolExecutionEnd(event, ctx);
1712
+ });
1713
+
1714
+ pi.on("session_shutdown", async (event, ctx) => {
1715
+ compactStyle?.onSessionShutdown(event, ctx);
1716
+ writeExecutionMetadata.clear();
1717
+ teardownToolMouseInteraction();
1718
+ deactivateGlobalToolRendering(globalToolRendering);
1719
+ deactivateLegacyCompactionRendering();
1720
+ clearAllAnimations();
1721
+ });
1722
+ }