@jaggerxtrm/pi-extensions 0.9.3 → 0.9.5

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.
@@ -1,13 +1,6 @@
1
1
  /**
2
- * XTRM UI Extension
3
- *
4
- * Wraps pi-dex functionality with XTRM-specific preferences:
5
- * - Uses pi-dex themes and header
6
- * - Disables pi-dex footer (let custom-footer handle it)
7
- * - Provides /xtrm-ui commands for theme/density switching
8
- *
9
- * This eliminates the race condition between pi-dex's footer and
10
- * XTRM's custom-footer extension.
2
+ * XTRM-owned Pi chrome, themes, and native/external tool rendering.
3
+ * custom-footer remains the sole footer owner.
11
4
  */
12
5
 
13
6
  import type {
@@ -19,7 +12,6 @@ import type {
19
12
  GrepToolDetails,
20
13
  LsToolDetails,
21
14
  ReadToolDetails,
22
- ToolResultEvent,
23
15
  } from "@earendil-works/pi-coding-agent";
24
16
  import {
25
17
  CustomEditor,
@@ -32,7 +24,7 @@ import {
32
24
  createWriteTool,
33
25
  } from "@earendil-works/pi-coding-agent";
34
26
  import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
35
- import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
27
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
36
28
  import { basename, dirname, join } from "node:path";
37
29
  import { fileURLToPath, pathToFileURL } from "node:url";
38
30
  import {
@@ -58,21 +50,16 @@ import {
58
50
  // Types
59
51
  // ============================================================================
60
52
 
61
- export type XtrmThemeName = "pidex-dark" | "pidex-light" | "pidex-dark-flattools" | "pidex-light-flattools";
53
+ export type XtrmThemeName = "xtrm-dark" | "xtrm-light";
54
+ export type XtrmResolvedThemeName = XtrmThemeName | "xtrm-dark-flattools" | "xtrm-light-flattools";
62
55
  export type XtrmDensity = "compact" | "comfortable";
63
- export type XtrmExternalToolChrome = "background" | "box";
64
56
 
65
57
  export interface XtrmUiPrefs {
66
58
  themeName: XtrmThemeName;
67
59
  density: XtrmDensity;
68
60
  showHeader: boolean;
69
- compactTools: boolean;
70
- showFooter: boolean; // Our key addition - when false, skip setFooter()
71
- forceTheme: boolean; // When false, skip setTheme (allow external theme override)
72
- toolRowBg: boolean; // Subtle background behind tool text rows (no padding)
73
- compactExternalToolResults: boolean; // Compact extension tool results (disables full expand output)
74
- externalToolChrome: XtrmExternalToolChrome; // Visual treatment for non-native tool rows
75
- hideThinkingPlaceholder: boolean; // When false, hidden thinking blocks render no placeholder text
61
+ forceTheme: boolean;
62
+ toolRowBg: boolean;
76
63
  }
77
64
 
78
65
  // ============================================================================
@@ -82,24 +69,13 @@ export interface XtrmUiPrefs {
82
69
  export const XTRM_UI_PREFS_ENTRY = "xtrm-ui-prefs";
83
70
 
84
71
  export const DEFAULT_PREFS: XtrmUiPrefs = {
85
- themeName: "pidex-dark",
72
+ themeName: "xtrm-dark",
86
73
  density: "compact",
87
74
  showHeader: true,
88
- compactTools: true,
89
- showFooter: false, // XTRM: disable pi-dex footer, use custom-footer
90
75
  forceTheme: true,
91
76
  toolRowBg: false,
92
- compactExternalToolResults: true,
93
- externalToolChrome: "background",
94
- hideThinkingPlaceholder: false,
95
77
  };
96
78
 
97
- let activeExternalToolChrome: XtrmExternalToolChrome = DEFAULT_PREFS.externalToolChrome;
98
-
99
- function setActiveExternalToolChrome(chrome: XtrmExternalToolChrome): void {
100
- activeExternalToolChrome = chrome;
101
- }
102
-
103
79
 
104
80
  // ============================================================================
105
81
  // Preferences
@@ -113,19 +89,18 @@ type MaybeCustomEntry = {
113
89
 
114
90
  function normalizePrefs(input: unknown): XtrmUiPrefs {
115
91
  if (!input || typeof input !== "object") return { ...DEFAULT_PREFS };
116
- const source = input as Partial<XtrmUiPrefs>;
92
+ const source = input as Partial<XtrmUiPrefs> & { themeName?: unknown };
93
+ const themeName = source.themeName;
94
+ const lightTheme = themeName === "xtrm-light"
95
+ || themeName === "xtrm-light-flattools"
96
+ || themeName === "pidex-light"
97
+ || themeName === "pidex-light-flattools";
117
98
  return {
118
- themeName: source.themeName === "pidex-light" || source.themeName === "pidex-light-flattools" ? "pidex-light" : "pidex-dark",
99
+ themeName: lightTheme ? "xtrm-light" : "xtrm-dark",
119
100
  density: source.density === "comfortable" ? "comfortable" : "compact",
120
101
  showHeader: source.showHeader ?? DEFAULT_PREFS.showHeader,
121
- compactTools: source.compactTools ?? DEFAULT_PREFS.compactTools,
122
- showFooter: source.showFooter ?? DEFAULT_PREFS.showFooter,
123
102
  forceTheme: source.forceTheme ?? DEFAULT_PREFS.forceTheme,
124
103
  toolRowBg: source.toolRowBg ?? DEFAULT_PREFS.toolRowBg,
125
- compactExternalToolResults:
126
- source.compactExternalToolResults ?? DEFAULT_PREFS.compactExternalToolResults,
127
- externalToolChrome: source.externalToolChrome === "box" ? "box" : "background",
128
- hideThinkingPlaceholder: source.hideThinkingPlaceholder ?? DEFAULT_PREFS.hideThinkingPlaceholder,
129
104
  };
130
105
  }
131
106
 
@@ -247,12 +222,14 @@ type PatchableToolExecutionComponent = {
247
222
  result?: { content?: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean };
248
223
  expanded?: boolean;
249
224
  hasRendererDefinition?: () => boolean;
225
+ __xtrmExternalStartedAt?: number;
226
+ __xtrmExternalDurationMs?: number;
250
227
  };
251
228
 
252
229
  type ExternalToolFrameKind = "serena" | "gitnexus" | "structured" | "process" | "external";
253
230
 
254
231
  const PATCHED_EXTERNAL_TOOL_FRAME = "__xtrmUiExternalToolFrame";
255
- const EXTERNAL_TOOL_FRAME_PATCH_VERSION = 12;
232
+ const EXTERNAL_TOOL_FRAME_PATCH_VERSION = 17;
256
233
  const ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
257
234
 
258
235
  function stripAnsi(text: string): string {
@@ -272,16 +249,6 @@ function externalToolFrameKind(toolName: string | undefined): ExternalToolFrameK
272
249
  return "external";
273
250
  }
274
251
 
275
- function padVisible(text: string, width: number): string {
276
- const visible = visibleWidth(text);
277
- return text + " ".repeat(Math.max(0, width - visible));
278
- }
279
-
280
- function getXtrmOriginalText(details: unknown): string | undefined {
281
- const record = asRecord(details);
282
- return typeof record?.xtrmOriginalText === "string" ? record.xtrmOriginalText : undefined;
283
- }
284
-
285
252
  function getToolArgs(component: PatchableToolExecutionComponent): Record<string, unknown> {
286
253
  return component.args && typeof component.args === "object" && !Array.isArray(component.args)
287
254
  ? component.args as Record<string, unknown>
@@ -309,13 +276,10 @@ function summarizeExternalToolPending(toolName: string | undefined, input: Recor
309
276
  }
310
277
 
311
278
  function extractResultTextLines(component: PatchableToolExecutionComponent): string[] | undefined {
312
- const originalText = component.expanded ? getXtrmOriginalText(component.result?.details) : undefined;
313
- if (originalText) return originalText.split("\n");
314
-
315
279
  const text = component.result?.content?.find((content) => content.type === "text")?.text;
316
- if (text) return text.split("\n");
317
-
318
- return [summarizeExternalToolPending(component.toolName, getToolArgs(component))];
280
+ return text
281
+ ? text.split("\n")
282
+ : [summarizeExternalToolPending(component.toolName, getToolArgs(component))];
319
283
  }
320
284
 
321
285
  function trimRenderedToolLines(lines: string[]): string[] {
@@ -338,66 +302,83 @@ function externalToolBadgeColor(kind: ExternalToolFrameKind, text: string): stri
338
302
  return `\x1b[38;2;3;8;12m\x1b[48;2;${badgeR};${badgeG};${badgeB}m${text}\x1b[39m\x1b[49m`;
339
303
  }
340
304
 
341
- function highlightExternalToolBadge(kind: ExternalToolFrameKind, line: string): string {
342
- const match = line.match(/^([•›]\s+)(\S+)/u);
343
- if (!match?.[1] || !match[2]) return line;
344
- return match[1] + externalToolBadgeColor(kind, match[2]) + line.slice(match[1].length + match[2].length);
345
- }
305
+ export function highlightExternalToolBadge(kind: ExternalToolFrameKind, line: string): string {
306
+ const marked = line.match(/^([•›]\s+)(\S+)/u);
307
+ if (marked?.[1] && marked[2]) {
308
+ return marked[1] + externalToolBadgeColor(kind, marked[2]) + line.slice(marked[1].length + marked[2].length);
309
+ }
346
310
 
347
- function externalToolBorderColor(kind: ExternalToolFrameKind, text: string): string {
348
- const colors: Record<ExternalToolFrameKind, [number, number, number]> = {
349
- serena: [150, 210, 255],
350
- gitnexus: [185, 168, 255],
351
- structured: [205, 166, 255],
352
- process: [145, 231, 255],
353
- external: [168, 181, 199],
354
- };
355
- const [r, g, b] = colors[kind];
356
- return `[38;2;${r};${g};${b}m${text}`;
311
+ const provider = line.match(/^(\[[A-Za-z][A-Za-z0-9 _-]{0,31}\])/u)?.[1];
312
+ return provider ? externalToolBadgeColor(kind, provider) + line.slice(provider.length) : line;
357
313
  }
358
314
 
359
- function collapsedExternalToolLines(contentLines: string[], expanded: boolean): string[] {
360
- return expanded ? contentLines : [contentLines.join(" · ")];
315
+ export function collapsedExternalToolLines(contentLines: string[], expanded: boolean): string[] {
316
+ if (expanded || contentLines.length <= 6) return contentLines;
317
+ return [
318
+ ...contentLines.slice(0, 6),
319
+ `... (${contentLines.length - 6} more lines, ctrl+o to expand)`,
320
+ ];
361
321
  }
362
322
 
363
- function renderExternalToolBackgroundLines(
323
+ export function renderExternalToolBackgroundLines(
364
324
  contentLines: string[],
365
325
  width: number,
366
326
  kind: ExternalToolFrameKind,
367
327
  expanded: boolean,
328
+ toolName?: string,
329
+ durationMs?: number,
368
330
  ): string[] {
369
- const availableWidth = Math.max(8, width);
370
- const renderWidth = availableWidth;
371
- const visibleLines = collapsedExternalToolLines(contentLines, expanded);
331
+ let displayLines = contentLines;
332
+ const raw = contentLines.length === 1 ? contentLines[0]?.trim() : undefined;
333
+ if (raw?.startsWith("{") || raw?.startsWith("[")) {
334
+ try {
335
+ displayLines = JSON.stringify(JSON.parse(raw), null, 2).split("\n");
336
+ } catch {
337
+ // Keep non-JSON output unchanged.
338
+ }
339
+ }
372
340
 
373
- return visibleLines.map((rawLine) => {
374
- const line = truncateToWidth(rawLine, Math.max(1, renderWidth));
341
+ const firstLine = displayLines[0] ?? "";
342
+ const action = kind === "gitnexus" && toolName?.startsWith("gitnexus_")
343
+ ? toolName.slice("gitnexus_".length)
344
+ : kind === "serena" ? toolName : undefined;
345
+ const providerHeader = /^\[[A-Za-z][A-Za-z0-9 _-]{0,31}\]/u.test(firstLine);
346
+
347
+ if (providerHeader && action && !firstLine.endsWith(` ${action}`)) {
348
+ displayLines = [`${firstLine} ${action}`, ...displayLines.slice(1)];
349
+ } else if (!/^(?:[•›]\s+|\[[A-Za-z][A-Za-z0-9 _-]{0,31}\])/u.test(firstLine)) {
350
+ const labels: Record<ExternalToolFrameKind, string> = {
351
+ serena: "Serena",
352
+ gitnexus: "GitNexus",
353
+ structured: "structured_return",
354
+ process: "process",
355
+ external: "external",
356
+ };
357
+ const label = kind === "external" && toolName ? normalizeToolLabel(toolName) : labels[kind];
358
+ displayLines = [`[${label}]${action ? ` ${action}` : ""}`, ...displayLines];
359
+ }
360
+
361
+ const header = displayLines[0] ?? "";
362
+ const payloadLines = displayLines.slice(1);
363
+ const visiblePayload = expanded ? payloadLines : payloadLines.slice(0, 6);
364
+ const shown = visiblePayload.length;
365
+ const total = payloadLines.length;
366
+ const lineSummary = !expanded && shown < total
367
+ ? `showing ${shown}/${total} lines (ctrl+o expand)`
368
+ : total > 0 ? formatLineLabel(total, "line") : undefined;
369
+ const footerMeta = joinMeta([
370
+ lineSummary,
371
+ formatDuration(durationMs),
372
+ formatPayloadSize(contentLines.join("\n")),
373
+ ]);
374
+ const renderWidth = Math.max(8, width);
375
+ const body = [header, ...visiblePayload].map((rawLine) => {
376
+ const line = truncateToWidth(rawLine, renderWidth);
375
377
  return highlightExternalToolBadge(kind, line);
376
378
  });
377
- }
378
-
379
- function renderExternalToolBoxLines(
380
- contentLines: string[],
381
- width: number,
382
- kind: ExternalToolFrameKind,
383
- expanded: boolean,
384
- ): string[] {
385
- const availableWidth = Math.max(8, width - 4);
386
- const maxContentWidth = expanded ? availableWidth : Math.min(availableWidth, 34);
387
- const visibleLines = collapsedExternalToolLines(contentLines, expanded);
388
- const contentWidth = Math.max(
389
- 1,
390
- Math.min(maxContentWidth, ...visibleLines.map((line) => visibleWidth(line))),
391
- );
392
- const innerWidth = contentWidth + 2;
393
-
394
- const framed = [externalToolBorderColor(kind, `╭${"─".repeat(innerWidth)}╮`)];
395
- for (const rawLine of visibleLines) {
396
- const line = truncateToWidth(rawLine, contentWidth);
397
- framed.push(`${externalToolBorderColor(kind, "│")} ${padVisible(line, contentWidth)} ${externalToolBorderColor(kind, "│")}`);
398
- }
399
- framed.push(externalToolBorderColor(kind, `╰${"─".repeat(innerWidth)}╯`));
400
- return framed;
379
+ return footerMeta
380
+ ? [...body, `\x1b[2m${truncateToWidth(`└─ ${footerMeta}`, renderWidth)}\x1b[22m`]
381
+ : body;
401
382
  }
402
383
 
403
384
  function renderExternalToolLines(
@@ -405,13 +386,13 @@ function renderExternalToolLines(
405
386
  width: number,
406
387
  kind: ExternalToolFrameKind,
407
388
  expanded = false,
389
+ toolName?: string,
390
+ durationMs?: number,
408
391
  ): string[] {
409
392
  const contentLines = trimRenderedToolLines(lines).filter((line) => !isBlankRenderedLine(line));
410
- if (contentLines.length === 0) return [];
411
-
412
- return activeExternalToolChrome === "box"
413
- ? renderExternalToolBoxLines(contentLines, width, kind, expanded)
414
- : renderExternalToolBackgroundLines(contentLines, width, kind, expanded);
393
+ return contentLines.length > 0
394
+ ? renderExternalToolBackgroundLines(contentLines, width, kind, expanded, toolName, durationMs)
395
+ : [];
415
396
  }
416
397
 
417
398
  async function installExternalToolFramePatch(): Promise<void> {
@@ -421,7 +402,7 @@ async function installExternalToolFramePatch(): Promise<void> {
421
402
  ToolExecutionComponent?: ToolExecutionComponentCtor;
422
403
  };
423
404
  const proto = mod.ToolExecutionComponent?.prototype as
424
- | (ToolExecutionComponentCtor["prototype"] & { [PATCHED_EXTERNAL_TOOL_FRAME]?: boolean })
405
+ | (ToolExecutionComponentCtor["prototype"] & { [PATCHED_EXTERNAL_TOOL_FRAME]?: number })
425
406
  | undefined;
426
407
  if (!proto?.render || proto[PATCHED_EXTERNAL_TOOL_FRAME] === EXTERNAL_TOOL_FRAME_PATCH_VERSION) return;
427
408
 
@@ -435,24 +416,33 @@ async function installExternalToolFramePatch(): Promise<void> {
435
416
  };
436
417
 
437
418
  proto.render = function patchedRender(this: PatchableToolExecutionComponent, width: number) {
438
- const rendered = render.call(this, width);
439
419
  const kind = externalToolFrameKind(this.toolName);
420
+ if (kind) this.__xtrmExternalStartedAt ??= Date.now();
421
+ const rendered = render.call(this, width);
440
422
  if (!kind || rendered.length === 0) return rendered;
441
423
 
424
+ if (this.result && this.__xtrmExternalDurationMs == null) {
425
+ this.__xtrmExternalDurationMs = Date.now() - (this.__xtrmExternalStartedAt ?? Date.now());
426
+ }
442
427
  const firstContentIndex = rendered.findIndex((line) => !isBlankRenderedLine(line));
443
428
  const leading = firstContentIndex > 0 ? rendered.slice(0, firstContentIndex) : [];
444
429
  const content = extractResultTextLines(this) ?? rendered;
445
- const styled = renderExternalToolLines(content, width, kind, Boolean(this.expanded));
430
+ const styled = renderExternalToolLines(
431
+ content,
432
+ width,
433
+ kind,
434
+ Boolean(this.expanded),
435
+ this.toolName,
436
+ this.__xtrmExternalDurationMs,
437
+ );
446
438
  return styled.length > 0 ? [...leading, ...styled] : rendered;
447
439
  };
448
440
 
449
441
  proto[PATCHED_EXTERNAL_TOOL_FRAME] = EXTERNAL_TOOL_FRAME_PATCH_VERSION;
450
442
  }
451
443
 
452
- function applyThinkingChrome(ctx: ExtensionContext, prefs: XtrmUiPrefs): void {
453
- (ctx.ui as { setHiddenThinkingLabel?: (label?: string) => void }).setHiddenThinkingLabel?.(
454
- prefs.hideThinkingPlaceholder ? undefined : "",
455
- );
444
+ function applyThinkingChrome(ctx: ExtensionContext): void {
445
+ (ctx.ui as { setHiddenThinkingLabel?: (label?: string) => void }).setHiddenThinkingLabel?.("");
456
446
  }
457
447
 
458
448
  // ============================================================================
@@ -464,10 +454,9 @@ function fitVisible(text: string, width: number): string {
464
454
  return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
465
455
  }
466
456
 
467
- function resolveThemeForPrefs(prefs: XtrmUiPrefs): XtrmThemeName {
468
- const base = prefs.themeName === "pidex-light" || prefs.themeName === "pidex-light-flattools" ? "pidex-light" : "pidex-dark";
469
- if (!prefs.toolRowBg) return (base + "-flattools") as XtrmThemeName;
470
- return base as XtrmThemeName;
457
+ function resolveThemeForPrefs(prefs: XtrmUiPrefs): XtrmResolvedThemeName {
458
+ if (prefs.toolRowBg) return prefs.themeName;
459
+ return prefs.themeName === "xtrm-light" ? "xtrm-light-flattools" : "xtrm-dark-flattools";
471
460
  }
472
461
 
473
462
  function formatThinking(level: string): string {
@@ -477,108 +466,62 @@ function formatThinking(level: string): string {
477
466
  function applyXtrmChrome(
478
467
  ctx: ExtensionContext,
479
468
  prefs: XtrmUiPrefs,
480
- getThinkingLevel: () => string
469
+ getThinkingLevel: () => string,
481
470
  ): void {
482
- // Theme
483
- ctx.ui.setTheme(resolveThemeForPrefs(prefs));
484
-
485
- // Tool expansion
486
- ctx.ui.setToolsExpanded(!prefs.compactTools);
471
+ if (prefs.forceTheme) {
472
+ ctx.ui.setTheme(resolveThemeForPrefs(prefs));
473
+ }
487
474
 
488
- // Editor — density-aware input padding
475
+ ctx.ui.setToolsExpanded(false);
489
476
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
490
477
  const editor = new XtrmEditor(tui, theme, keybindings);
491
478
  editor.setPrefs(prefs);
492
479
  return editor;
493
480
  });
494
481
 
495
- // Header (optional)
496
- if (prefs.showHeader) {
497
- ctx.ui.setHeader((_tui, theme) => ({
498
- invalidate() {},
499
- render(width: number): string[] {
500
- const boxWidth = width >= 54 ? 50 : Math.max(24, width);
501
- const model = ctx.model?.id ?? "no-model";
502
- const thinking = getThinkingLevel();
503
- const border = (text: string) => theme.fg("borderAccent", text);
504
- const leftPad = "";
505
-
506
- const top = leftPad + border(`╭${"─".repeat(Math.max(0, boxWidth - 2))}╮`);
507
- const line1 =
508
- leftPad +
509
- border("│") +
510
- fitVisible(
511
- ` ${theme.fg("dim", ">_")} ${theme.bold("XTRM")} ${theme.fg("dim", `(v1.0.0)`)}`,
512
- boxWidth - 2
513
- ) +
514
- border("│");
515
- const gap = leftPad + border("│") + fitVisible("", boxWidth - 2) + border("│");
516
- const line2 =
517
- leftPad +
518
- border("│") +
519
- fitVisible(
520
- ` ${theme.fg("dim", "model:".padEnd(11))}${model} ${thinking}${theme.fg("accent", " /model")}${theme.fg("dim", " to change")}`,
521
- boxWidth - 2
522
- ) +
523
- border("│");
524
- const line3 =
525
- leftPad +
526
- border("│") +
527
- fitVisible(
528
- ` ${theme.fg("dim", "directory:".padEnd(11))}${basename(ctx.cwd)}`,
529
- boxWidth - 2
530
- ) +
531
- border("│");
532
- const bottom = leftPad + border(`╰${"─".repeat(Math.max(0, boxWidth - 2))}╯`);
533
-
534
- return [top, line1, gap, line2, line3, bottom];
535
- },
536
- }));
537
- } else {
482
+ if (!prefs.showHeader) {
538
483
  ctx.ui.setHeader(undefined);
484
+ return;
539
485
  }
540
486
 
541
- // Footer - ONLY if showFooter is true (default false for XTRM)
542
- // This is the key difference from pi-dex - we let custom-footer handle it
543
- if (prefs.showFooter) {
544
- ctx.ui.setFooter((tui, theme, footerData) => {
545
- const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
546
- return {
547
- dispose: unsubscribe,
548
- invalidate() {},
549
- render(width: number): string[] {
550
- const modelId = ctx.model?.id ?? "no-model";
551
- const thinking = getThinkingLevel();
552
- const contextUsage = ctx.getContextUsage();
553
- const leftPct = contextUsage?.percent != null ? `${100 - Math.round(contextUsage.percent)}% left` : undefined;
554
- const line = theme.fg(
555
- "dim",
556
- [`${modelId} ${thinking}`, leftPct, basename(ctx.cwd)]
557
- .filter(Boolean)
558
- .join(" · ")
559
- );
560
- return [truncateToWidth(line, width)];
561
- },
562
- };
563
- });
564
- }
565
- // If showFooter is false, we do NOT call setFooter - custom-footer will handle it
487
+ ctx.ui.setHeader((_tui, theme) => ({
488
+ invalidate() {},
489
+ render(width: number): string[] {
490
+ const boxWidth = width >= 54 ? 50 : Math.max(24, width);
491
+ const model = ctx.model?.id ?? "no-model";
492
+ const thinking = getThinkingLevel();
493
+ const border = (text: string) => theme.fg("borderAccent", text);
494
+ const top = border(`╭${"─".repeat(Math.max(0, boxWidth - 2))}╮`);
495
+ const line1 =
496
+ border("") +
497
+ fitVisible(
498
+ ` ${theme.fg("dim", ">_")} ${theme.bold("XTRM")} ${theme.fg("dim", "(v1.0.0)")}`,
499
+ boxWidth - 2,
500
+ ) +
501
+ border("");
502
+ const gap = border("│") + fitVisible("", boxWidth - 2) + border("│");
503
+ const line2 =
504
+ border("") +
505
+ fitVisible(
506
+ ` ${theme.fg("dim", "model:".padEnd(11))}${model} ${thinking}${theme.fg("accent", " /model")}${theme.fg("dim", " to change")}`,
507
+ boxWidth - 2,
508
+ ) +
509
+ border("│");
510
+ const line3 =
511
+ border("│") +
512
+ fitVisible(
513
+ ` ${theme.fg("dim", "directory:".padEnd(11))}${basename(ctx.cwd)}`,
514
+ boxWidth - 2,
515
+ ) +
516
+ border("│");
517
+ const bottom = border(`╰${"─".repeat(Math.max(0, boxWidth - 2))}╯`);
518
+
519
+ return [top, line1, gap, line2, line3, bottom];
520
+ },
521
+ }));
566
522
  }
567
523
 
568
524
 
569
- function writeExternalCompactFlag(enabled: boolean): void {
570
- try {
571
- const settingsPath = join(process.env.HOME ?? "", ".pi", "agent", "settings.json");
572
- if (!settingsPath) return;
573
- let settings: Record<string, unknown> = {};
574
- if (existsSync(settingsPath)) {
575
- try { settings = JSON.parse(readFileSync(settingsPath, "utf8")); } catch {}
576
- }
577
- settings.xtrmExternalCompact = enabled;
578
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
579
- } catch {}
580
- }
581
-
582
525
  // ============================================================================
583
526
  // Tool Render Helpers
584
527
  // ============================================================================
@@ -642,8 +585,8 @@ function sendInfoMessage(pi: ExtensionAPI, title: string, content: string): void
642
585
 
643
586
  function parseThemeArg(arg: string): XtrmThemeName | undefined {
644
587
  const normalized = arg.trim().toLowerCase();
645
- if (normalized === "dark" || normalized === "pidex-dark") return "pidex-dark";
646
- if (normalized === "light" || normalized === "pidex-light") return "pidex-light";
588
+ if (normalized === "dark") return "xtrm-dark";
589
+ if (normalized === "light") return "xtrm-light";
647
590
  return undefined;
648
591
  }
649
592
 
@@ -654,14 +597,19 @@ function parseDensityArg(arg: string): XtrmDensity | undefined {
654
597
  return undefined;
655
598
  }
656
599
 
657
- function parseExternalToolChromeArg(arg: string): XtrmExternalToolChrome | undefined {
600
+ function parseToggleArg(arg: string): boolean | undefined {
658
601
  const normalized = arg.trim().toLowerCase();
659
- if (normalized === "background" || normalized === "bg" || normalized === "row") return "background";
660
- if (normalized === "box" || normalized === "frame" || normalized === "border") return "box";
602
+ if (normalized === "on") return true;
603
+ if (normalized === "off") return false;
661
604
  return undefined;
662
605
  }
663
606
 
664
- function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPrefs: (p: XtrmUiPrefs) => void, getThinkingLevel: () => string) {
607
+ function registerCommands(
608
+ pi: ExtensionAPI,
609
+ getPrefs: () => XtrmUiPrefs,
610
+ setPrefs: (prefs: XtrmUiPrefs) => void,
611
+ getThinkingLevel: () => string,
612
+ ): void {
665
613
  pi.registerMessageRenderer("xtrm-ui-info", (message, _options, theme) => {
666
614
  const title = (message.details as { title?: string } | undefined)?.title ?? "XTRM UI";
667
615
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
@@ -673,41 +621,22 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
673
621
  pi.registerCommand("xtrm-ui", {
674
622
  description: "Show XTRM UI status and active preferences",
675
623
  handler: async (args, ctx) => {
676
- const trimmedArgs = args.trim();
677
- if (trimmedArgs) {
678
- const [subcommand, ...rest] = trimmedArgs.split(/\s+/u);
679
- if (subcommand === "chrome" || subcommand === "external-chrome" || subcommand === "tool-chrome") {
680
- const externalToolChrome = parseExternalToolChromeArg(rest.join(" "));
681
- if (!externalToolChrome) {
682
- ctx.ui.notify("Usage: /xtrm-ui chrome background|box", "warning");
683
- return;
684
- }
685
- const prefs = { ...getPrefs(), externalToolChrome };
686
- setPrefs(prefs);
687
- persistPrefs(pi, prefs);
688
- ctx.ui.notify(`External tool chrome set to ${externalToolChrome}.`, "info");
689
- return;
690
- }
691
- ctx.ui.notify("Usage: /xtrm-ui [chrome background|box]", "warning");
624
+ if (args.trim()) {
625
+ ctx.ui.notify("Usage: /xtrm-ui", "warning");
692
626
  return;
693
627
  }
694
628
 
695
629
  const prefs = getPrefs();
696
630
  const contextUsage = ctx.getContextUsage();
697
- const lines = [
631
+ sendInfoMessage(pi, "XTRM UI status", [
698
632
  `Theme: ${prefs.themeName}`,
699
- `Force theme: ${prefs.forceTheme ? "on" : "off"}`,
700
633
  `Density: ${prefs.density}`,
701
- `Compact tools: ${prefs.compactTools ? "on" : "off"}`,
702
634
  `Show header: ${prefs.showHeader ? "yes" : "no"}`,
703
- `Show footer: ${prefs.showFooter ? "yes" : "no"} (custom-footer handles this)`,
635
+ `Force theme: ${prefs.forceTheme ? "on" : "off"}`,
704
636
  `Tool row background: ${prefs.toolRowBg ? "on" : "off"}`,
705
- `Compact external tool results: ${prefs.compactExternalToolResults ? "on" : "off"}`,
706
- `External tool chrome: ${prefs.externalToolChrome}`,
707
637
  `Model: ${ctx.model?.id ?? "none"}`,
708
638
  `Context: ${contextUsage?.tokens ?? "unknown"}/${contextUsage?.contextWindow ?? "unknown"}`,
709
- ];
710
- sendInfoMessage(pi, "XTRM UI status", lines.join("\\n"));
639
+ ].join("\n"));
711
640
  },
712
641
  });
713
642
 
@@ -732,7 +661,7 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
732
661
  });
733
662
 
734
663
  pi.registerCommand("xtrm-ui-density", {
735
- description: "Switch XTRM UI density: compact|comfortable",
664
+ description: "Switch editor density: compact|comfortable",
736
665
  getArgumentCompletions: (prefix) => {
737
666
  const values = ["compact", "comfortable"].filter((item) => item.startsWith(prefix));
738
667
  return values.length > 0 ? values.map((value) => ({ value, label: value })) : null;
@@ -743,10 +672,9 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
743
672
  ctx.ui.notify("Usage: /xtrm-ui-density compact|comfortable", "warning");
744
673
  return;
745
674
  }
746
- const prefs = { ...getPrefs(), density, compactExternalToolResults: density === "compact" };
675
+ const prefs = { ...getPrefs(), density };
747
676
  setPrefs(prefs);
748
677
  persistPrefs(pi, prefs);
749
- writeExternalCompactFlag(prefs.compactExternalToolResults);
750
678
  applyXtrmChrome(ctx, prefs, getThinkingLevel);
751
679
  ctx.ui.notify(`XTRM UI density set to ${density}`, "info");
752
680
  },
@@ -759,7 +687,11 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
759
687
  return values.length > 0 ? values.map((value) => ({ value, label: value })) : null;
760
688
  },
761
689
  handler: async (args, ctx) => {
762
- const showHeader = args.trim().toLowerCase() === "on";
690
+ const showHeader = parseToggleArg(args);
691
+ if (showHeader === undefined) {
692
+ ctx.ui.notify("Usage: /xtrm-ui-header on|off", "warning");
693
+ return;
694
+ }
763
695
  const prefs = { ...getPrefs(), showHeader };
764
696
  setPrefs(prefs);
765
697
  persistPrefs(pi, prefs);
@@ -775,12 +707,11 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
775
707
  return values.length > 0 ? values.map((value) => ({ value, label: value })) : null;
776
708
  },
777
709
  handler: async (args, ctx) => {
778
- const normalized = args.trim().toLowerCase();
779
- if (normalized !== "on" && normalized !== "off") {
710
+ const forceTheme = parseToggleArg(args);
711
+ if (forceTheme === undefined) {
780
712
  ctx.ui.notify("Usage: /xtrm-ui-forcetheme on|off", "warning");
781
713
  return;
782
714
  }
783
- const forceTheme = normalized === "on";
784
715
  const prefs = { ...getPrefs(), forceTheme };
785
716
  setPrefs(prefs);
786
717
  persistPrefs(pi, prefs);
@@ -796,12 +727,11 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
796
727
  return values.length > 0 ? values.map((value) => ({ value, label: value })) : null;
797
728
  },
798
729
  handler: async (args, ctx) => {
799
- const normalized = args.trim().toLowerCase();
800
- if (normalized !== "on" && normalized !== "off") {
730
+ const toolRowBg = parseToggleArg(args);
731
+ if (toolRowBg === undefined) {
801
732
  ctx.ui.notify("Usage: /xtrm-ui-rowbg on|off", "warning");
802
733
  return;
803
734
  }
804
- const toolRowBg = normalized === "on";
805
735
  const prefs = { ...getPrefs(), toolRowBg };
806
736
  setPrefs(prefs);
807
737
  persistPrefs(pi, prefs);
@@ -810,49 +740,6 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
810
740
  },
811
741
  });
812
742
 
813
- pi.registerCommand("xtrm-ui-compact-tools", {
814
- description: "Compact extension tool results: on|off (off keeps full Ctrl+O expand output)",
815
- getArgumentCompletions: (prefix) => {
816
- const values = ["on", "off"].filter((item) => item.startsWith(prefix));
817
- return values.length > 0 ? values.map((value) => ({ value, label: value })) : null;
818
- },
819
- handler: async (args, ctx) => {
820
- const normalized = args.trim().toLowerCase();
821
- if (normalized !== "on" && normalized !== "off") {
822
- ctx.ui.notify("Usage: /xtrm-ui-compact-tools on|off", "warning");
823
- return;
824
- }
825
- const compactExternalToolResults = normalized === "on";
826
- const prefs = { ...getPrefs(), compactExternalToolResults };
827
- setPrefs(prefs);
828
- persistPrefs(pi, prefs);
829
- writeExternalCompactFlag(compactExternalToolResults);
830
- ctx.ui.notify(
831
- `Compact external tool results ${compactExternalToolResults ? "enabled" : "disabled"}.`,
832
- "info",
833
- );
834
- },
835
- });
836
-
837
- pi.registerCommand("xtrm-ui-external-chrome", {
838
- description: "Choose non-native tool chrome: background|box",
839
- getArgumentCompletions: (prefix) => {
840
- const values = ["background", "box"].filter((item) => item.startsWith(prefix));
841
- return values.length > 0 ? values.map((value) => ({ value, label: value })) : null;
842
- },
843
- handler: async (args, ctx) => {
844
- const externalToolChrome = parseExternalToolChromeArg(args);
845
- if (!externalToolChrome) {
846
- ctx.ui.notify("Usage: /xtrm-ui-external-chrome background|box", "warning");
847
- return;
848
- }
849
- const prefs = { ...getPrefs(), externalToolChrome };
850
- setPrefs(prefs);
851
- persistPrefs(pi, prefs);
852
- ctx.ui.notify(`External tool chrome set to ${externalToolChrome}.`, "info");
853
- },
854
- });
855
-
856
743
  pi.registerCommand("xtrm-ui-reset", {
857
744
  description: "Restore XTRM UI defaults",
858
745
  handler: async (_args, ctx) => {
@@ -866,25 +753,25 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
866
753
  }
867
754
 
868
755
  // ============================================================================
869
- // Tool Renderers (ported from pi-dex tooling.ts)
756
+ // XTRM Tool Renderers
870
757
  // ============================================================================
871
758
 
872
759
  type BuiltInTools = ReturnType<typeof createBuiltInTools>;
873
760
 
874
- type XtrmMeta<TArgs = Record<string, unknown>> = {
875
- tool: string;
876
- args: TArgs;
877
- durationMs: number;
878
- };
879
-
880
761
  type XtrmWritePreview =
881
762
  | { kind: "created"; lineCount: number }
882
763
  | { kind: "updated"; diff: string; additions: number; removals: number }
883
764
  | { kind: "unchanged" };
884
765
 
885
- type DetailsWithXtrmMeta<TDetails, TArgs = Record<string, unknown>> = TDetails & {
886
- xtrmMeta?: XtrmMeta<TArgs>;
887
- xtrmWritePreview?: XtrmWritePreview;
766
+ type XtrmToolRenderState = {
767
+ startedAt?: number;
768
+ writePreview?: XtrmWritePreview;
769
+ };
770
+
771
+ type XtrmToolRenderContext = {
772
+ executionStarted: boolean;
773
+ isPartial: boolean;
774
+ state: XtrmToolRenderState;
888
775
  };
889
776
 
890
777
  const toolCache = new Map<string, BuiltInTools>();
@@ -910,22 +797,6 @@ function getTools(cwd: string): BuiltInTools {
910
797
  return tools;
911
798
  }
912
799
 
913
- function withXtrmMeta<TDetails extends object, TArgs extends Record<string, unknown>>(
914
- details: TDetails | undefined,
915
- tool: string,
916
- args: TArgs,
917
- durationMs: number,
918
- ): DetailsWithXtrmMeta<TDetails, TArgs> {
919
- return { ...(details ?? ({} as TDetails)), xtrmMeta: { tool, args, durationMs } };
920
- }
921
-
922
- function getXtrmMeta<TDetails extends object, TArgs extends Record<string, unknown>>(
923
- details: TDetails | undefined,
924
- ): XtrmMeta<TArgs> | undefined {
925
- if (!details || typeof details !== "object") return undefined;
926
- return (details as DetailsWithXtrmMeta<TDetails, TArgs>).xtrmMeta;
927
- }
928
-
929
800
  function getTextContent(result: { content: Array<{ type: string; text?: string }> }): string {
930
801
  const item = result.content.find((content) => content.type === "text");
931
802
  return item?.text ?? "";
@@ -955,12 +826,17 @@ function createWritePreview(path: string, nextContent: string): XtrmWritePreview
955
826
  };
956
827
  }
957
828
 
958
- function renderPendingCall(toolName: string, args: Record<string, unknown>, theme: any): Text {
959
- return new Text(renderToolSummary(theme, "pending", toolName, summarizeToolSubject(toolName, args), undefined), 0, 0);
829
+ function appendToolFooter(theme: any, text: string, parts: Array<string | undefined>): string {
830
+ const meta = joinMeta(parts);
831
+ return meta ? `${text}\n${theme.fg("dim", `└─ ${meta}`)}` : text;
960
832
  }
961
833
 
962
- function stableToolSignature(toolName: string, args: Record<string, unknown>): string {
963
- return `${toolName}:${JSON.stringify(args)}`;
834
+ function renderPendingCall(toolName: string, args: Record<string, unknown>, theme: any): Text {
835
+ if (toolName === "bash") {
836
+ const command = shortenCommand(String(args.command ?? ""), 80);
837
+ return new Text(`${theme.fg("accent", TOOL_ROW_MARKER)} ${theme.fg("accent", "$")} ${theme.fg("accent", command)}`, 0, 0);
838
+ }
839
+ return new Text(renderToolSummary(theme, "pending", toolName, summarizeToolSubject(toolName, args), undefined), 0, 0);
964
840
  }
965
841
 
966
842
  function summarizeToolSubject(toolName: string, args: Record<string, unknown>): string | undefined {
@@ -1025,55 +901,10 @@ const SERENA_COMPACT_TOOLS = new Set([
1025
901
  "serena_mcp_reset",
1026
902
  ]);
1027
903
 
1028
- function parseJson(text: string): unknown | undefined {
1029
- try {
1030
- return JSON.parse(text);
1031
- } catch {
1032
- return undefined;
1033
- }
1034
- }
1035
-
1036
904
  function asRecord(value: unknown): Record<string, unknown> | undefined {
1037
905
  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
1038
906
  }
1039
907
 
1040
- function countSearchMatches(payload: unknown): number | undefined {
1041
- const record = asRecord(payload);
1042
- if (!record) return undefined;
1043
- let total = 0;
1044
- for (const value of Object.values(record)) {
1045
- if (Array.isArray(value)) total += value.length;
1046
- }
1047
- return total > 0 ? total : undefined;
1048
- }
1049
-
1050
- function countOverviewSymbols(payload: unknown): number {
1051
- if (Array.isArray(payload)) {
1052
- const nested = payload.reduce<number>((total, value) => total + countOverviewSymbols(value), 0);
1053
- return nested || payload.length;
1054
- }
1055
- const record = asRecord(payload);
1056
- if (!record) return 0;
1057
- return Object.values(record).reduce<number>((total, value) => total + countOverviewSymbols(value), 0);
1058
- }
1059
-
1060
- function countLines(text: string): number {
1061
- if (!text) return 0;
1062
- return text.split("\n").length;
1063
- }
1064
-
1065
- function countJsonItems(payload: unknown): number | undefined {
1066
- if (Array.isArray(payload)) return payload.length;
1067
- const record = asRecord(payload);
1068
- if (!record) return undefined;
1069
-
1070
- let total = 0;
1071
- for (const value of Object.values(record)) {
1072
- if (Array.isArray(value)) total += value.length;
1073
- }
1074
- return total > 0 ? total : undefined;
1075
- }
1076
-
1077
908
  function summarizeSerenaSubject(toolName: string, input: Record<string, unknown>): string | undefined {
1078
909
  switch (toolName) {
1079
910
  case "find_symbol":
@@ -1120,104 +951,8 @@ function summarizeSerenaSubject(toolName: string, input: Record<string, unknown>
1120
951
  }
1121
952
  }
1122
953
 
1123
- function summarizeSerenaToolResult(
1124
- toolName: string,
1125
- input: Record<string, unknown>,
1126
- text: string,
1127
- durationMs: number | undefined,
1128
- ): string {
1129
- const payload = parseJson(text);
1130
- const duration = formatDuration(durationMs);
1131
- const payloadSize = formatPayloadSize(text);
1132
- const subject = summarizeSerenaSubject(toolName, input);
1133
- const meta = (...parts: Array<string | undefined>) => {
1134
- const joined = joinCompactMeta([duration, payloadSize, ...parts]);
1135
- return joined ? ` · ${joined}` : "";
1136
- };
1137
-
1138
- switch (toolName) {
1139
- case "find_symbol":
1140
- case "find_referencing_symbols":
1141
- case "jet_brains_find_symbol":
1142
- case "jet_brains_find_referencing_symbols": {
1143
- const count = countJsonItems(payload) ?? (text.match(/"name_path"\s*:/g)?.length ?? 0);
1144
- return `${TOOL_ROW_MARKER} serena ${toolName} ${subject ?? "symbol"}${meta(formatLineLabel(count, "result"))}`;
1145
- }
1146
- case "get_symbols_overview":
1147
- case "jet_brains_get_symbols_overview":
1148
- case "jet_brains_type_hierarchy": {
1149
- const count = Math.max(countOverviewSymbols(payload), text.match(/"name_path"\s*:/g)?.length ?? 0);
1150
- return `${TOOL_ROW_MARKER} serena ${toolName} ${subject ?? "file"}${meta(formatLineLabel(count, "symbol"))}`;
1151
- }
1152
- case "search_for_pattern": {
1153
- const count = countSearchMatches(payload) ?? (text.match(/^\s*>\s*\d+:/gm)?.length ?? 0);
1154
- return `${TOOL_ROW_MARKER} serena search ${subject ?? "pattern"}${meta(formatLineLabel(count, "match"))}`;
1155
- }
1156
- case "read_file": {
1157
- return `${TOOL_ROW_MARKER} serena read ${subject ?? "file"}${meta(formatLineLabel(countLines(text), "line"))}`;
1158
- }
1159
- case "list_dir": {
1160
- const count = countJsonItems(payload) ?? countLines(text);
1161
- return `${TOOL_ROW_MARKER} serena list_dir ${subject ?? "."}${meta(formatLineLabel(count, "entry"))}`;
1162
- }
1163
- case "find_file": {
1164
- const count = countJsonItems(payload) ?? countLines(text);
1165
- return `${TOOL_ROW_MARKER} serena find_file ${String(input.file_mask ?? "")}${meta(formatLineLabel(count, "match"))}`;
1166
- }
1167
- case "replace_symbol_body":
1168
- case "insert_after_symbol":
1169
- case "insert_before_symbol":
1170
- case "rename_symbol":
1171
- case "create_text_file":
1172
- case "replace_content":
1173
- case "replace_lines":
1174
- case "delete_lines":
1175
- case "insert_at_line":
1176
- case "write_memory":
1177
- case "delete_memory":
1178
- case "rename_memory":
1179
- case "edit_memory":
1180
- case "activate_project":
1181
- case "remove_project":
1182
- case "switch_modes":
1183
- case "restart_language_server":
1184
- case "onboarding":
1185
- case "serena_mcp_reset":
1186
- return `${TOOL_ROW_MARKER} serena ${toolName}${subject ? ` ${subject}` : ""}${meta()}`;
1187
- case "execute_shell_command": {
1188
- const count = countLines(text);
1189
- return `${TOOL_ROW_MARKER} serena shell ${subject ?? "command"}${meta(formatLineLabel(count, "line"))}`;
1190
- }
1191
- default: {
1192
- const count = countJsonItems(payload) ?? countLines(text);
1193
- return `${TOOL_ROW_MARKER} serena ${toolName}${subject ? ` ${subject}` : ""}${meta(formatLineLabel(count, "item"))}`;
1194
- }
1195
- }
1196
- }
1197
954
 
1198
955
 
1199
- function formatHierarchyText(text: string): string {
1200
- const lines = text.split("\n");
1201
- const out: string[] = [];
1202
- for (const raw of lines) {
1203
- const line = raw.trimEnd();
1204
- if (!line.trim()) {
1205
- out.push("");
1206
- continue;
1207
- }
1208
- if (line.startsWith("● ")) {
1209
- out.push(line);
1210
- continue;
1211
- }
1212
- if (/^(Read|Searched|Listed|Update\(|File must be read first|Now )/.test(line.trimStart())) {
1213
- out.push(` └─ ${line.trimStart()}`);
1214
- continue;
1215
- }
1216
- out.push(line);
1217
- }
1218
- return out.join("\n");
1219
- }
1220
-
1221
956
  function normalizeToolLabel(toolName: string): string {
1222
957
  const gitnexusMap: Record<string, string> = {
1223
958
  gitnexus_query: "gitnexus query",
@@ -1245,155 +980,13 @@ function normalizeToolLabel(toolName: string): string {
1245
980
  return toolName;
1246
981
  }
1247
982
 
1248
- function summarizeGenericToolResult(
1249
- toolName: string,
1250
- input: Record<string, unknown>,
1251
- text: string,
1252
- durationMs: number | undefined,
1253
- ): string {
1254
- const payload = parseJson(text);
1255
- const duration = formatDuration(durationMs);
1256
- const payloadSize = formatPayloadSize(text);
1257
- const subject = summarizeToolSubject(toolName, input) ?? summarizeSerenaSubject(toolName, input);
1258
- const count = countJsonItems(payload) ?? countLines(text);
1259
- const label = formatLineLabel(count, "line");
1260
- const joined = joinCompactMeta([duration, payloadSize, label]);
1261
- const normalized = normalizeToolLabel(toolName);
1262
- return `${TOOL_ROW_MARKER} ${normalized}${subject ? ` ${subject}` : ""}${joined ? ` · ${joined}` : ""}`;
1263
- }
1264
-
1265
- function summarizeStructuredReturnToolResult(
1266
- input: Record<string, unknown>,
1267
- text: string,
1268
- details: unknown,
1269
- durationMs: number | undefined,
1270
- ): string {
1271
- const record = asRecord(details);
1272
- const command = shortenCommand(String(input.command ?? text.split("→")[0] ?? "command"), 52);
1273
- const resultText = text.includes("→") ? text.split("→").slice(1).join("→").trim() : text.trim();
1274
- const resultLines = resultText.split("\n").map((line) => line.trim()).filter(Boolean);
1275
- const summary = resultLines.find((line) => !line.startsWith("cwd:"));
1276
- const parser = typeof record?.parser === "string" ? record.parser : undefined;
1277
- const exitCode = typeof record?.exitCode === "number" ? `exit ${record.exitCode}` : undefined;
1278
- const resultMeta = joinCompactMeta([formatDuration(durationMs), formatPayloadSize(text)]);
1279
- const meta = joinMeta([summary ? shortenCommand(summary, 72) : undefined, parser, exitCode, resultMeta]);
1280
- return `${TOOL_ROW_MARKER} structured_return ${command}${meta ? ` · ${meta}` : ""}`;
1281
- }
1282
-
1283
- function summarizeProcessToolResult(
1284
- input: Record<string, unknown>,
1285
- text: string,
1286
- details: unknown,
1287
- durationMs: number | undefined,
1288
- ): string {
1289
- const record = asRecord(details);
1290
- const action = String(record?.action ?? input.action ?? "action");
1291
- const duration = formatDuration(durationMs);
1292
- const payloadSize = formatPayloadSize(text);
1293
- const meta = (...parts: Array<string | undefined>) => {
1294
- const joined = joinCompactMeta([duration, payloadSize, ...parts]);
1295
- return joined ? ` · ${joined}` : "";
1296
- };
1297
-
1298
- if (action === "start") {
1299
- const proc = asRecord(record?.process);
1300
- const name = String(proc?.name ?? input.name ?? "process");
1301
- const id = proc?.id ? String(proc.id) : undefined;
1302
- const pid = proc?.pid != null ? `pid ${String(proc.pid)}` : undefined;
1303
- return `${TOOL_ROW_MARKER} process start "${name}"${meta(id, pid)}`;
1304
- }
1305
-
1306
- if (action === "list") {
1307
- const processes = Array.isArray(record?.processes) ? record.processes : [];
1308
- const running = processes.filter((item) => {
1309
- const proc = asRecord(item);
1310
- return proc?.status === "running" || proc?.status === "terminating";
1311
- }).length;
1312
- return `${TOOL_ROW_MARKER} process list${meta(`${processes.length} ${processes.length === 1 ? "process" : "processes"}`, `${running} running`)}`;
1313
- }
1314
-
1315
- if (action === "output") {
1316
- const output = asRecord(record?.output);
1317
- const stdout = Array.isArray(output?.stdout) ? output.stdout.length : undefined;
1318
- const stderr = Array.isArray(output?.stderr) ? output.stderr.length : undefined;
1319
- return `${TOOL_ROW_MARKER} process output ${String(input.id ?? "process")}${meta(
1320
- stdout != null ? `${stdout} stdout` : undefined,
1321
- stderr != null ? `${stderr} stderr` : undefined,
1322
- )}`;
1323
- }
1324
-
1325
- if (action === "logs") {
1326
- return `${TOOL_ROW_MARKER} process logs ${String(input.id ?? "process")}${meta("log paths")}`;
1327
- }
1328
-
1329
- const message = typeof record?.message === "string" ? record.message : text.split("\n")[0];
1330
- return `${TOOL_ROW_MARKER} process ${action}${message ? ` · ${shortenCommand(message, 38)}` : ""}${meta()}`;
1331
- }
1332
-
1333
- function summarizeExternalToolResult(
1334
- toolName: string,
1335
- input: Record<string, unknown>,
1336
- text: string,
1337
- details: unknown,
1338
- durationMs: number | undefined,
1339
- ): string {
1340
- if (SERENA_COMPACT_TOOLS.has(toolName)) {
1341
- return summarizeSerenaToolResult(toolName, input, text, durationMs);
1342
- }
1343
- if (toolName === "structured_return") {
1344
- return summarizeStructuredReturnToolResult(input, text, details, durationMs);
1345
- }
1346
- if (toolName === "process") {
1347
- return summarizeProcessToolResult(input, text, details, durationMs);
1348
- }
1349
- return summarizeGenericToolResult(toolName, input, text, durationMs);
1350
- }
1351
983
 
1352
- function withXtrmToolDetails(details: unknown, sourceText: string, toolName: string): unknown {
1353
- const record = asRecord(details);
1354
- return {
1355
- ...(record ?? {}),
1356
- xtrmOriginalText: sourceText,
1357
- xtrmToolFrame: externalToolFrameKind(toolName),
1358
- };
1359
- }
1360
984
 
1361
985
  const XTRM_BUILTIN_TOOLS = new Set(["bash", "read", "edit", "write", "find", "grep", "ls"]);
1362
986
 
1363
- // Tools whose RESULT PAYLOAD the model needs verbatim in context. In pi, a tool_result
1364
- // hook's return value REPLACES the model-facing content (not just the TUI render), so
1365
- // compacting these would replace the model-facing payload with "· N lines" / "· N results"
1366
- // for every pi surface that loads xtrm-ui — interactive sessions (where the human can
1367
- // expand the TUI row but the model cannot) and any headless pi run that doesn't pass
1368
- // --no-extensions. Specialists are unaffected: they run with --no-extensions and never
1369
- // load xtrm-ui (selectively re-attach only quality-gates / service-skills / pi-gitnexus /
1370
- // pi-serena-tools). Mutation/no-payload tools are still summarized below, preserving
1371
- // most of the token savings.
1372
- const PAYLOAD_TOOLS = new Set<string>([
1373
- // Serena reads / inspection
1374
- "read_file",
1375
- "find_symbol",
1376
- "find_referencing_symbols",
1377
- "get_symbols_overview",
1378
- "search_for_pattern",
1379
- "find_file",
1380
- "list_dir",
1381
- "read_memory",
1382
- // JetBrains backend equivalents
1383
- "jet_brains_find_symbol",
1384
- "jet_brains_find_referencing_symbols",
1385
- "jet_brains_get_symbols_overview",
1386
- "jet_brains_type_hierarchy",
1387
- // GitNexus reads (same blindness risk)
1388
- "gitnexus_query",
1389
- "gitnexus_context",
1390
- "gitnexus_impact",
1391
- "gitnexus_detect_changes",
1392
- ]);
1393
987
 
1394
988
  function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): void {
1395
- const activeToolCalls = new Map<string, string>();
1396
-
989
+ const tools = getTools(process.cwd());
1397
990
  const toolRowText = (theme: any, text: string) =>
1398
991
  new Text(
1399
992
  text,
@@ -1401,110 +994,53 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1401
994
  0,
1402
995
  getPrefs().toolRowBg ? (line: string) => theme.bg("selectedBg", line) : undefined,
1403
996
  );
1404
- const activeSignatureCounts = new Map<string, number>();
1405
- const toolCallStartTimes = new Map<string, number>();
1406
-
1407
- const trackToolCallStart = (toolCallId: string, toolName: string, args: Record<string, unknown>) => {
1408
- const signature = stableToolSignature(toolName, args);
1409
- activeToolCalls.set(toolCallId, signature);
1410
- activeSignatureCounts.set(signature, (activeSignatureCounts.get(signature) ?? 0) + 1);
1411
- toolCallStartTimes.set(toolCallId, Date.now());
1412
- };
1413
-
1414
- const trackToolCallEnd = (toolCallId: string) => {
1415
- const signature = activeToolCalls.get(toolCallId);
1416
- if (!signature) return;
1417
- activeToolCalls.delete(toolCallId);
1418
- const next = (activeSignatureCounts.get(signature) ?? 1) - 1;
1419
- if (next <= 0) activeSignatureCounts.delete(signature);
1420
- else activeSignatureCounts.set(signature, next);
1421
- toolCallStartTimes.delete(toolCallId);
997
+ const renderCall = (
998
+ toolName: string,
999
+ args: Record<string, unknown>,
1000
+ theme: any,
1001
+ context: XtrmToolRenderContext,
1002
+ ) => {
1003
+ context.state.startedAt ??= Date.now();
1004
+ return context.isPartial && !context.executionStarted
1005
+ ? renderPendingCall(toolName, args, theme)
1006
+ : toolRowText(theme, "");
1422
1007
  };
1423
-
1424
- const isToolCallActive = (toolName: string, args: Record<string, unknown>) =>
1425
- activeSignatureCounts.has(stableToolSignature(toolName, args));
1426
-
1427
- const renderPendingCallIfActive = (toolName: string, args: Record<string, unknown>, theme: any) =>
1428
- isToolCallActive(toolName, args) ? renderPendingCall(toolName, args, theme) : toolRowText(theme, "");
1429
-
1430
- pi.on("tool_call", async (event) => {
1431
- trackToolCallStart(event.toolCallId, event.toolName, event.input as Record<string, unknown>);
1432
- });
1433
-
1434
- pi.on("tool_execution_end", async (event) => {
1435
- trackToolCallEnd(event.toolCallId);
1436
- });
1437
-
1438
- pi.on("tool_result", async (event: ToolResultEvent, _ctx) => {
1439
- if (event.isError) return undefined;
1440
- if (XTRM_BUILTIN_TOOLS.has(event.toolName)) {
1441
- trackToolCallEnd(event.toolCallId);
1442
- return undefined;
1443
- }
1444
- // Never compact read/inspect tools: the hook return replaces model-facing content,
1445
- // so summarizing these would blind headless agents/specialists (no row to expand).
1446
- if (PAYLOAD_TOOLS.has(event.toolName)) return undefined;
1447
- if (!getPrefs().compactExternalToolResults) return undefined;
1448
-
1449
- const text = getTextContent({ content: event.content as Array<{ type: string; text?: string }> });
1450
- const startedAt = toolCallStartTimes.get(event.toolCallId);
1451
- const durationMs = startedAt != null ? Date.now() - startedAt : undefined;
1452
-
1453
- const fallbackText = "[non-text result]";
1454
- const sourceText = text.trim() ? text : fallbackText;
1455
-
1456
- const safeInput =
1457
- event.input && typeof event.input === "object" && !Array.isArray(event.input)
1458
- ? (event.input as Record<string, unknown>)
1459
- : {};
1460
-
1461
- const compactText = summarizeExternalToolResult(
1462
- event.toolName,
1463
- safeInput,
1464
- sourceText,
1465
- event.details,
1466
- durationMs,
1467
- );
1468
-
1469
- return {
1470
- content: [{ type: "text", text: formatHierarchyText(compactText) }],
1471
- details: withXtrmToolDetails(event.details, sourceText, event.toolName),
1472
- };
1473
- });
1008
+ const renderDuration = (context: XtrmToolRenderContext) =>
1009
+ formatDuration(context.state.startedAt == null ? undefined : Date.now() - context.state.startedAt);
1474
1010
 
1475
1011
  pi.registerTool({
1476
1012
  name: "bash",
1477
1013
  label: "bash",
1478
- description: getTools(process.cwd()).bash.description,
1479
- parameters: getTools(process.cwd()).bash.parameters,
1014
+ description: tools.bash.description,
1015
+ parameters: tools.bash.parameters,
1016
+ execute: tools.bash.execute,
1480
1017
  renderShell: "self",
1481
- async execute(toolCallId, params, signal, onUpdate, ctx) {
1482
- const started = Date.now();
1483
- const result = await getTools(ctx.cwd).bash.execute(toolCallId, params, signal, onUpdate);
1484
- return { ...result, details: withXtrmMeta(result.details as BashToolDetails | undefined, "bash", params as Record<string, unknown>, Date.now() - started) };
1485
- },
1486
- renderCall: (args, theme) => renderPendingCallIfActive("bash", args as Record<string, unknown>, theme),
1487
- renderResult(result, { expanded, isPartial }, theme) {
1488
- const details = (result.details ?? {}) as DetailsWithXtrmMeta<BashToolDetails, Record<string, unknown>>;
1489
- const meta = getXtrmMeta<BashToolDetails, Record<string, unknown>>(details);
1490
- const command = shortenCommand(String(meta?.args.command ?? ""), 80);
1018
+ renderCall: (args, theme, context) =>
1019
+ renderCall("bash", args as Record<string, unknown>, theme, context),
1020
+ renderResult(result, { expanded, isPartial }, theme, context) {
1021
+ const details = (result.details ?? {}) as BashToolDetails;
1022
+ const args = context.args as Record<string, unknown>;
1023
+ const command = String(args.command ?? "");
1491
1024
  if (isPartial) {
1492
- return toolRowText(theme, `${theme.fg("accent", TOOL_ROW_MARKER)} ${theme.fg("toolTitle", "bash:")}${theme.fg("accent", command)}`);
1025
+ return toolRowText(theme, `${theme.fg("accent", TOOL_ROW_MARKER)} ${theme.fg("accent", "$")} ${theme.fg("accent", command)}`);
1493
1026
  }
1494
1027
  const output = getTextContent(result as any);
1495
1028
  const outputLines = cleanOutputLines(output);
1496
- const exitMatch = output.match(/exit code:\s*(-?\d+)/i);
1497
- const exitCode = exitMatch ? Number.parseInt(exitMatch[1] ?? "0", 10) : 0;
1498
- const bullet = exitCode === 0 ? theme.fg("success", TOOL_ROW_MARKER) : theme.fg("error", TOOL_ROW_MARKER);
1499
- const summary = joinCompactMeta([
1500
- formatDuration(meta?.durationMs),
1029
+ const statusColor = context.isError ? "error" : "success";
1030
+ let text = `${theme.fg(statusColor, TOOL_ROW_MARKER)} ${theme.fg(statusColor, "$")} ${theme.fg(statusColor, command)}`;
1031
+ const visibleLines = expanded ? outputLines : outputLines.slice(-4);
1032
+ if (visibleLines.length > 0) {
1033
+ text += "\n" + visibleLines.map((line) => ` ${theme.fg("toolOutput", line)}`).join("\n");
1034
+ }
1035
+ const lineSummary = !expanded && visibleLines.length < outputLines.length
1036
+ ? `showing ${visibleLines.length}/${outputLines.length} lines (ctrl+o expand)`
1037
+ : formatLineLabel(outputLines.length, "line");
1038
+ text = appendToolFooter(theme, text, [
1039
+ lineSummary,
1040
+ renderDuration(context),
1501
1041
  formatPayloadSize(output),
1502
- formatLineLabel(outputLines.length, "line"),
1503
1042
  details.truncation?.truncated ? "truncated" : undefined,
1504
1043
  ]);
1505
- let text = `${bullet} ${theme.fg("toolTitle", "bash:")}${theme.fg("accent", command)}`;
1506
- if (summary) text += theme.fg("dim", ` · ${summary}`);
1507
- if (expanded && outputLines.length > 0) text += `\n${renderVerticalPreview(theme, outputLines, 10)}`;
1508
1044
  return toolRowText(theme, text);
1509
1045
  },
1510
1046
  });
@@ -1512,30 +1048,45 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1512
1048
  pi.registerTool({
1513
1049
  name: "read",
1514
1050
  label: "read",
1515
- description: getTools(process.cwd()).read.description,
1516
- parameters: getTools(process.cwd()).read.parameters,
1051
+ description: tools.read.description,
1052
+ parameters: tools.read.parameters,
1053
+ execute: tools.read.execute,
1517
1054
  renderShell: "self",
1518
- async execute(toolCallId, params, signal, onUpdate, ctx) {
1519
- const started = Date.now();
1520
- const result = await getTools(ctx.cwd).read.execute(toolCallId, params, signal, onUpdate);
1521
- return { ...result, details: withXtrmMeta(result.details as ReadToolDetails | undefined, "read", params as Record<string, unknown>, Date.now() - started) };
1522
- },
1523
- renderCall: (args, theme) => renderPendingCallIfActive("read", args as Record<string, unknown>, theme),
1524
- renderResult(result, { expanded, isPartial }, theme) {
1055
+ renderCall: (args, theme, context) =>
1056
+ renderCall("read", args as Record<string, unknown>, theme, context),
1057
+ renderResult(result, { expanded, isPartial }, theme, context) {
1525
1058
  if (isPartial) return toolRowText(theme, renderToolSummary(theme, "pending", "read", "loading", undefined));
1526
- const details = (result.details ?? {}) as DetailsWithXtrmMeta<ReadToolDetails, Record<string, unknown>>;
1527
- const meta = getXtrmMeta<ReadToolDetails, Record<string, unknown>>(details);
1528
- const subjectBase = shortenPath(String(meta?.args.path ?? ""));
1529
- const range = lineRange(meta?.args.offset as number | undefined, meta?.args.limit as number | undefined);
1059
+ const details = (result.details ?? {}) as ReadToolDetails;
1060
+ const args = context.args as Record<string, unknown>;
1061
+ const subjectBase = shortenPath(String(args.path ?? ""));
1062
+ const range = lineRange(args.offset as number | undefined, args.limit as number | undefined);
1530
1063
  const subject = range ? `${subjectBase}:${range}` : subjectBase;
1531
1064
  const first = result.content[0];
1532
1065
  if (first?.type === "image") {
1533
- return toolRowText(theme, renderToolSummary(theme, "success", "read", subject, joinMeta(["image", formatDuration(meta?.durationMs)])));
1066
+ const text = appendToolFooter(
1067
+ theme,
1068
+ renderToolSummary(theme, "success", "read", subject, undefined),
1069
+ ["image", renderDuration(context)],
1070
+ );
1071
+ return toolRowText(theme, text);
1534
1072
  }
1535
1073
  const textContent = getTextContent(result as any);
1536
1074
  const lines = textContent.split("\n");
1537
- let text = renderToolSummary(theme, "success", "read", subject, joinMeta([formatLineLabel(lines.length, "line"), formatDuration(meta?.durationMs), details.truncation?.truncated ? `from ${details.truncation.totalLines}` : undefined]));
1538
- if (expanded && textContent.length > 0) text += `\n${renderOutputPreview(theme, previewLines(textContent, 14), 14)}`;
1075
+ const totalLines = lines.length;
1076
+ const showContent = expanded || totalLines <= 6;
1077
+ let text = renderToolSummary(theme, context.isError ? "error" : "success", "read", subject, undefined);
1078
+ if (showContent && totalLines > 0) {
1079
+ text += "\n" + lines.map((line) => ` ${theme.fg("toolOutput", line)}`).join("\n");
1080
+ }
1081
+ const lineSummary = !showContent && totalLines > 0
1082
+ ? `${formatLineLabel(totalLines, "line")} (ctrl+o expand)`
1083
+ : formatLineLabel(totalLines, "line");
1084
+ text = appendToolFooter(theme, text, [
1085
+ lineSummary,
1086
+ renderDuration(context),
1087
+ formatPayloadSize(textContent),
1088
+ details.truncation?.truncated ? `from ${details.truncation.totalLines}` : undefined,
1089
+ ]);
1539
1090
  return toolRowText(theme, text);
1540
1091
  },
1541
1092
  });
@@ -1543,26 +1094,30 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1543
1094
  pi.registerTool({
1544
1095
  name: "edit",
1545
1096
  label: "edit",
1546
- description: getTools(process.cwd()).edit.description,
1547
- parameters: getTools(process.cwd()).edit.parameters,
1097
+ description: tools.edit.description,
1098
+ parameters: tools.edit.parameters,
1099
+ execute: tools.edit.execute,
1548
1100
  renderShell: "self",
1549
- async execute(toolCallId, params, signal, onUpdate, ctx) {
1550
- const started = Date.now();
1551
- const result = await getTools(ctx.cwd).edit.execute(toolCallId, params, signal, onUpdate);
1552
- return { ...result, details: withXtrmMeta(result.details as EditToolDetails | undefined, "edit", params as Record<string, unknown>, Date.now() - started) };
1553
- },
1554
- renderCall: (args, theme) => renderPendingCallIfActive("edit", args as Record<string, unknown>, theme),
1555
- renderResult(result, { expanded, isPartial }, theme) {
1101
+ renderCall: (args, theme, context) =>
1102
+ renderCall("edit", args as Record<string, unknown>, theme, context),
1103
+ renderResult(result, { isPartial }, theme, context) {
1556
1104
  if (isPartial) return toolRowText(theme, renderToolSummary(theme, "pending", "edit", "applying", undefined));
1557
- const details = (result.details ?? {}) as DetailsWithXtrmMeta<EditToolDetails, Record<string, unknown>>;
1558
- const meta = getXtrmMeta<EditToolDetails, Record<string, unknown>>(details);
1105
+ const details = (result.details ?? {}) as EditToolDetails;
1106
+ const args = context.args as Record<string, unknown>;
1107
+ const path = String(args.path ?? "");
1559
1108
  const textContent = getTextContent(result as any);
1560
- if (/^error/i.test(textContent.trim())) {
1561
- return toolRowText(theme, renderToolSummary(theme, "error", "edit", shortenPath(String(meta?.args.path ?? "")), textContent.split("\n")[0]));
1109
+ if (context.isError) {
1110
+ const text = appendToolFooter(
1111
+ theme,
1112
+ renderToolSummary(theme, "error", "edit", path, textContent.split("\n")[0]),
1113
+ [renderDuration(context)],
1114
+ );
1115
+ return toolRowText(theme, text);
1562
1116
  }
1563
1117
  const stats = details.diff ? diffStats(details.diff) : { additions: 0, removals: 0 };
1564
- let text = renderToolSummary(theme, "success", "edit", shortenPath(String(meta?.args.path ?? "")), joinMeta([`+${stats.additions}`, `-${stats.removals}`, formatDuration(meta?.durationMs)]));
1565
- if (expanded && details.diff) text += `\n${renderRichDiffPreview(theme, details.diff, 18)}`;
1118
+ let text = renderToolSummary(theme, "success", "edit", path, undefined);
1119
+ if (details.diff) text += `\n${renderRichDiffPreview(theme, details.diff, 18)}`;
1120
+ text = appendToolFooter(theme, text, [`+${stats.additions}`, `-${stats.removals}`, renderDuration(context)]);
1566
1121
  return toolRowText(theme, text);
1567
1122
  },
1568
1123
  });
@@ -1570,85 +1125,88 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1570
1125
  pi.registerTool({
1571
1126
  name: "write",
1572
1127
  label: "write",
1573
- description: getTools(process.cwd()).write.description,
1574
- parameters: getTools(process.cwd()).write.parameters,
1128
+ description: tools.write.description,
1129
+ parameters: tools.write.parameters,
1130
+ execute: tools.write.execute,
1575
1131
  renderShell: "self",
1576
- async execute(toolCallId, params, signal, onUpdate, ctx) {
1577
- const started = Date.now();
1578
- const args = params as Record<string, unknown>;
1579
- const path = String(args.path ?? "");
1580
- const content = String(args.content ?? "");
1581
- const preview = createWritePreview(path, content);
1582
- const result = await getTools(ctx.cwd).write.execute(toolCallId, params, signal, onUpdate);
1583
- const details = withXtrmMeta(result.details as Record<string, never> | undefined, "write", args, Date.now() - started);
1584
- return { ...result, details: { ...details, xtrmWritePreview: preview } };
1132
+ renderCall(args, theme, context) {
1133
+ const input = args as Record<string, unknown>;
1134
+ const state = context.state as XtrmToolRenderState;
1135
+ if (context.argsComplete && !state.writePreview) {
1136
+ state.writePreview = createWritePreview(String(input.path ?? ""), String(input.content ?? ""));
1137
+ }
1138
+ return renderCall("write", input, theme, context);
1585
1139
  },
1586
- renderCall: (args, theme) => renderPendingCallIfActive("write", args as Record<string, unknown>, theme),
1587
- renderResult(result, { expanded, isPartial }, theme) {
1140
+ renderResult(result, { expanded, isPartial }, theme, context) {
1588
1141
  if (isPartial) return toolRowText(theme, renderToolSummary(theme, "pending", "write", "writing", undefined));
1589
- const details = (result.details ?? {}) as DetailsWithXtrmMeta<Record<string, never>, Record<string, unknown>>;
1590
- const meta = getXtrmMeta<Record<string, never>, Record<string, unknown>>(details);
1142
+ const args = context.args as Record<string, unknown>;
1143
+ const path = String(args.path ?? "");
1144
+ const content = String(args.content ?? "");
1591
1145
  const textContent = getTextContent(result as any);
1592
- if (/^error/i.test(textContent.trim())) {
1593
- return toolRowText(theme, renderToolSummary(theme, "error", "write", shortenPath(String(meta?.args.path ?? "")), textContent.split("\n")[0]));
1146
+ if (context.isError) {
1147
+ const text = appendToolFooter(
1148
+ theme,
1149
+ renderToolSummary(theme, "error", "write", path, textContent.split("\n")[0]),
1150
+ [renderDuration(context)],
1151
+ );
1152
+ return toolRowText(theme, text);
1594
1153
  }
1595
1154
 
1596
- const subject = shortenPath(String(meta?.args.path ?? ""));
1597
- const preview = details.xtrmWritePreview;
1598
-
1155
+ const preview = (context.state as XtrmToolRenderState).writePreview;
1599
1156
  if (preview?.kind === "unchanged") {
1600
- return toolRowText(theme, renderToolSummary(theme, "success", "write", subject, joinMeta(["no changes", formatDuration(meta?.durationMs)])));
1601
- }
1602
-
1603
- if (preview?.kind === "updated") {
1604
- let text = renderToolSummary(
1157
+ const text = appendToolFooter(
1605
1158
  theme,
1606
- "success",
1607
- "write",
1608
- subject,
1609
- joinMeta([`+${preview.additions}`, `-${preview.removals}`, formatDuration(meta?.durationMs)]),
1159
+ renderToolSummary(theme, "success", "write", path, undefined),
1160
+ ["no changes", renderDuration(context)],
1610
1161
  );
1611
- if (expanded && preview.diff) text += `\n${renderRichDiffPreview(theme, preview.diff, 18)}`;
1162
+ return toolRowText(theme, text);
1163
+ }
1164
+ if (preview?.kind === "updated") {
1165
+ let text = renderToolSummary(theme, "success", "write", path, undefined);
1166
+ if (preview.diff) text += `\n${renderRichDiffPreview(theme, preview.diff, 18)}`;
1167
+ text = appendToolFooter(theme, text, [`+${preview.additions}`, `-${preview.removals}`, renderDuration(context)]);
1612
1168
  return toolRowText(theme, text);
1613
1169
  }
1614
1170
 
1615
- const lines = preview?.kind === "created"
1616
- ? preview.lineCount
1617
- : lineCount(String(meta?.args.content ?? ""));
1618
-
1619
- return toolRowText(theme, renderToolSummary(
1620
- theme,
1621
- "success",
1622
- "write",
1623
- subject,
1624
- joinMeta([formatLineLabel(lines, "line"), formatDuration(meta?.durationMs)]),
1625
- ),
1626
- 0,
1627
- 0,
1628
- );
1171
+ const lines = preview?.kind === "created" ? preview.lineCount : lineCount(content);
1172
+ let text = renderToolSummary(theme, "success", "write", path, undefined);
1173
+ const contentLines = content.split("\n");
1174
+ const showContent = content && (expanded || contentLines.length <= 6);
1175
+ if (showContent) {
1176
+ text += "\n" + contentLines.map((line) => ` ${theme.fg("toolOutput", line)}`).join("\n");
1177
+ }
1178
+ text = appendToolFooter(theme, text, [
1179
+ !showContent && lines > 0 ? `${formatLineLabel(lines, "line")} (ctrl+o expand)` : formatLineLabel(lines, "line"),
1180
+ renderDuration(context),
1181
+ formatPayloadSize(content),
1182
+ ]);
1183
+ return toolRowText(theme, text);
1629
1184
  },
1630
1185
  });
1631
1186
 
1632
1187
  pi.registerTool({
1633
1188
  name: "find",
1634
1189
  label: "find",
1635
- description: getTools(process.cwd()).find.description,
1636
- parameters: getTools(process.cwd()).find.parameters,
1190
+ description: tools.find.description,
1191
+ parameters: tools.find.parameters,
1192
+ execute: tools.find.execute,
1637
1193
  renderShell: "self",
1638
- async execute(toolCallId, params, signal, onUpdate, ctx) {
1639
- const started = Date.now();
1640
- const result = await getTools(ctx.cwd).find.execute(toolCallId, params, signal, onUpdate);
1641
- return { ...result, details: withXtrmMeta(result.details as FindToolDetails | undefined, "find", params as Record<string, unknown>, Date.now() - started) };
1642
- },
1643
- renderCall: (args, theme) => renderPendingCallIfActive("find", args as Record<string, unknown>, theme),
1644
- renderResult(result, { expanded, isPartial }, theme) {
1194
+ renderCall: (args, theme, context) =>
1195
+ renderCall("find", args as Record<string, unknown>, theme, context),
1196
+ renderResult(result, { expanded, isPartial }, theme, context) {
1645
1197
  if (isPartial) return toolRowText(theme, renderToolSummary(theme, "pending", "find", "searching", undefined));
1646
- const details = (result.details ?? {}) as DetailsWithXtrmMeta<FindToolDetails, Record<string, unknown>>;
1647
- const meta = getXtrmMeta<FindToolDetails, Record<string, unknown>>(details);
1198
+ const details = (result.details ?? {}) as FindToolDetails;
1199
+ const args = context.args as Record<string, unknown>;
1648
1200
  const textContent = getTextContent(result as any);
1649
1201
  const count = summarizeCount(textContent);
1650
- let text = renderToolSummary(theme, "success", "find", String(meta?.args.pattern ?? ""), joinMeta([formatLineLabel(count, "match"), formatDuration(meta?.durationMs), details.resultLimitReached ? "limit reached" : undefined]));
1202
+ let text = renderToolSummary(theme, context.isError ? "error" : "success", "find", String(args.pattern ?? ""), undefined);
1651
1203
  if (expanded && count > 0) text += `\n${renderOutputPreview(theme, previewLines(textContent, 10), 10)}`;
1204
+ text = appendToolFooter(theme, text, [
1205
+ !expanded && count > 0 ? `${formatLineLabel(count, "match")} (ctrl+o expand)` : formatLineLabel(count, "match"),
1206
+ renderDuration(context),
1207
+ formatPayloadSize(textContent),
1208
+ details.resultLimitReached ? "limit reached" : undefined,
1209
+ ]);
1652
1210
  return toolRowText(theme, text);
1653
1211
  },
1654
1212
  });
@@ -1656,23 +1214,26 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1656
1214
  pi.registerTool({
1657
1215
  name: "grep",
1658
1216
  label: "grep",
1659
- description: getTools(process.cwd()).grep.description,
1660
- parameters: getTools(process.cwd()).grep.parameters,
1217
+ description: tools.grep.description,
1218
+ parameters: tools.grep.parameters,
1219
+ execute: tools.grep.execute,
1661
1220
  renderShell: "self",
1662
- async execute(toolCallId, params, signal, onUpdate, ctx) {
1663
- const started = Date.now();
1664
- const result = await getTools(ctx.cwd).grep.execute(toolCallId, params, signal, onUpdate);
1665
- return { ...result, details: withXtrmMeta(result.details as GrepToolDetails | undefined, "grep", params as Record<string, unknown>, Date.now() - started) };
1666
- },
1667
- renderCall: (args, theme) => renderPendingCallIfActive("grep", args as Record<string, unknown>, theme),
1668
- renderResult(result, { expanded, isPartial }, theme) {
1221
+ renderCall: (args, theme, context) =>
1222
+ renderCall("grep", args as Record<string, unknown>, theme, context),
1223
+ renderResult(result, { expanded, isPartial }, theme, context) {
1669
1224
  if (isPartial) return toolRowText(theme, renderToolSummary(theme, "pending", "grep", "searching", undefined));
1670
- const details = (result.details ?? {}) as DetailsWithXtrmMeta<GrepToolDetails, Record<string, unknown>>;
1671
- const meta = getXtrmMeta<GrepToolDetails, Record<string, unknown>>(details);
1225
+ const details = (result.details ?? {}) as GrepToolDetails;
1226
+ const args = context.args as Record<string, unknown>;
1672
1227
  const textContent = getTextContent(result as any);
1673
1228
  const count = countPrefixedItems(textContent, ["-- "]) || summarizeCount(textContent);
1674
- let text = renderToolSummary(theme, "success", "grep", String(meta?.args.pattern ?? ""), joinMeta([formatLineLabel(count, "match"), formatDuration(meta?.durationMs), details.matchLimitReached ? "limit reached" : undefined]));
1229
+ let text = renderToolSummary(theme, context.isError ? "error" : "success", "grep", String(args.pattern ?? ""), undefined);
1675
1230
  if (expanded && textContent.length > 0) text += `\n${renderOutputPreview(theme, previewLines(textContent, 12), 12)}`;
1231
+ text = appendToolFooter(theme, text, [
1232
+ !expanded && count > 0 ? `${formatLineLabel(count, "match")} (ctrl+o expand)` : formatLineLabel(count, "match"),
1233
+ renderDuration(context),
1234
+ formatPayloadSize(textContent),
1235
+ details.matchLimitReached ? "limit reached" : undefined,
1236
+ ]);
1676
1237
  return toolRowText(theme, text);
1677
1238
  },
1678
1239
  });
@@ -1680,23 +1241,26 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1680
1241
  pi.registerTool({
1681
1242
  name: "ls",
1682
1243
  label: "ls",
1683
- description: getTools(process.cwd()).ls.description,
1684
- parameters: getTools(process.cwd()).ls.parameters,
1244
+ description: tools.ls.description,
1245
+ parameters: tools.ls.parameters,
1246
+ execute: tools.ls.execute,
1685
1247
  renderShell: "self",
1686
- async execute(toolCallId, params, signal, onUpdate, ctx) {
1687
- const started = Date.now();
1688
- const result = await getTools(ctx.cwd).ls.execute(toolCallId, params, signal, onUpdate);
1689
- return { ...result, details: withXtrmMeta(result.details as LsToolDetails | undefined, "ls", params as Record<string, unknown>, Date.now() - started) };
1690
- },
1691
- renderCall: (args, theme) => renderPendingCallIfActive("ls", args as Record<string, unknown>, theme),
1692
- renderResult(result, { expanded, isPartial }, theme) {
1248
+ renderCall: (args, theme, context) =>
1249
+ renderCall("ls", args as Record<string, unknown>, theme, context),
1250
+ renderResult(result, { expanded, isPartial }, theme, context) {
1693
1251
  if (isPartial) return toolRowText(theme, renderToolSummary(theme, "pending", "ls", "listing", undefined));
1694
- const details = (result.details ?? {}) as DetailsWithXtrmMeta<LsToolDetails, Record<string, unknown>>;
1695
- const meta = getXtrmMeta<LsToolDetails, Record<string, unknown>>(details);
1252
+ const details = (result.details ?? {}) as LsToolDetails;
1253
+ const args = context.args as Record<string, unknown>;
1696
1254
  const textContent = getTextContent(result as any);
1697
1255
  const count = summarizeCount(textContent);
1698
- let text = renderToolSummary(theme, "success", "ls", shortenPath(String(meta?.args.path ?? ".")), joinMeta([formatLineLabel(count, "entry"), formatDuration(meta?.durationMs), details.entryLimitReached ? "limit reached" : undefined]));
1256
+ let text = renderToolSummary(theme, context.isError ? "error" : "success", "ls", shortenPath(String(args.path ?? ".")), undefined);
1699
1257
  if (expanded && count > 0) text += `\n${renderOutputPreview(theme, previewLines(textContent, 12), 12)}`;
1258
+ text = appendToolFooter(theme, text, [
1259
+ !expanded && count > 0 ? `${formatLineLabel(count, "entry")} (ctrl+o expand)` : formatLineLabel(count, "entry"),
1260
+ renderDuration(context),
1261
+ formatPayloadSize(textContent),
1262
+ details.entryLimitReached ? "limit reached" : undefined,
1263
+ ]);
1700
1264
  return toolRowText(theme, text);
1701
1265
  },
1702
1266
  });
@@ -1706,22 +1270,16 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1706
1270
  // Main Extension
1707
1271
  // ============================================================================
1708
1272
 
1709
- function isXtrmTheme(name: string | undefined): boolean {
1710
- return name === "pidex-dark" || name === "pidex-light";
1711
- }
1712
-
1713
1273
  export default function xtrmUiExtension(pi: ExtensionAPI): void {
1714
1274
  void installSilentHiddenThinkingPatch().catch(() => undefined);
1715
1275
  void installExternalToolFramePatch().catch(() => undefined);
1716
1276
 
1717
1277
  let prefs: XtrmUiPrefs = { ...DEFAULT_PREFS };
1718
- let previousThemeName: string | null = null;
1719
1278
  const extensionThemeDir = join(__dirname, "../../themes/xtrm-ui");
1720
1279
 
1721
1280
  const getPrefs = () => prefs;
1722
- const setPrefs = (p: XtrmUiPrefs) => {
1723
- prefs = p;
1724
- setActiveExternalToolChrome(p.externalToolChrome);
1281
+ const setPrefs = (nextPrefs: XtrmUiPrefs) => {
1282
+ prefs = nextPrefs;
1725
1283
  };
1726
1284
  const getThinkingLevel = () => formatThinking(pi.getThinkingLevel());
1727
1285
 
@@ -1730,7 +1288,7 @@ export default function xtrmUiExtension(pi: ExtensionAPI): void {
1730
1288
 
1731
1289
  const refresh = (ctx: ExtensionContext) => {
1732
1290
  applyXtrmChrome(ctx, prefs, getThinkingLevel);
1733
- applyThinkingChrome(ctx, prefs);
1291
+ applyThinkingChrome(ctx);
1734
1292
  };
1735
1293
 
1736
1294
  pi.on("resources_discover", async () => ({
@@ -1739,27 +1297,14 @@ export default function xtrmUiExtension(pi: ExtensionAPI): void {
1739
1297
 
1740
1298
  pi.on("session_start", async (_event, ctx) => {
1741
1299
  setPrefs(loadPrefs(ctx.sessionManager.getEntries() as Array<MaybeCustomEntry>));
1742
- if (!previousThemeName && !isXtrmTheme(ctx.ui.theme.name)) {
1743
- previousThemeName = ctx.ui.theme.name ?? null;
1744
- }
1745
1300
  refresh(ctx);
1746
-
1747
- setTimeout(() => {
1748
- ctx.ui.setTheme(resolveThemeForPrefs(prefs));
1749
- }, 0);
1750
1301
  });
1751
1302
 
1752
1303
  pi.on("session_switch", async (_event, ctx) => {
1753
- if (!previousThemeName && !isXtrmTheme(ctx.ui.theme.name)) {
1754
- previousThemeName = ctx.ui.theme.name ?? null;
1755
- }
1756
1304
  refresh(ctx);
1757
1305
  });
1758
1306
 
1759
1307
  pi.on("session_fork", async (_event, ctx) => {
1760
- if (!previousThemeName && !isXtrmTheme(ctx.ui.theme.name)) {
1761
- previousThemeName = ctx.ui.theme.name ?? null;
1762
- }
1763
1308
  refresh(ctx);
1764
1309
  });
1765
1310
 
@@ -1767,10 +1312,8 @@ export default function xtrmUiExtension(pi: ExtensionAPI): void {
1767
1312
  refresh(ctx);
1768
1313
  });
1769
1314
 
1770
- pi.on("session_shutdown", async (_event, ctx) => {
1771
- if (previousThemeName) {
1772
- ctx.ui.setTheme(previousThemeName);
1773
- }
1315
+ pi.on("session_shutdown", async () => {
1316
+ // No-op: no theme restoration on shutdown
1774
1317
  });
1775
1318
 
1776
1319
  pi.on("input", async (event) => {