@jaggerxtrm/pi-extensions 0.7.17 → 0.7.20

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.
package/README.md CHANGED
@@ -41,3 +41,10 @@ Pi discovers this package through:
41
41
  - `pi.extensions: ["./src/index.ts"]`
42
42
 
43
43
  After install, keep `.pi/settings.json` package wiring pointed at `npm:@jaggerxtrm/pi-extensions`.
44
+
45
+ ## Managed extensions
46
+
47
+ Notable bundled extensions include:
48
+
49
+ - `xtrm-ui` — XTRM Pi chrome, native tool summaries, selectable external tool chrome (`/xtrm-ui chrome background|box`).
50
+ - `sp-terminal-overlay` — `/sp-feed`, `/sp-ps`, and `/xtrm-terminal` streaming overlays for specialist monitoring.
@@ -3,3 +3,15 @@
3
3
  This directory is the canonical source for managed Pi extension entrypoints.
4
4
 
5
5
  Runtime delivery is package-based via `npm:@jaggerxtrm/pi-extensions`.
6
+
7
+ ## sp-terminal-overlay
8
+
9
+ Streaming terminal-style overlay for specialist/process monitoring commands.
10
+
11
+ Commands:
12
+
13
+ - `/sp-feed [args]` — opens `sp feed -f [args]` in an overlay.
14
+ - `/sp-ps [args]` / `/xtrm-ps [args]` — opens `sp ps [args]` (defaults to `sp ps --follow`) in an overlay.
15
+ - `/xtrm-terminal <command>` — opens an arbitrary shell command in an overlay.
16
+
17
+ Keys: `Esc`/`q` close, `r` restart, arrows/page keys scroll.
@@ -0,0 +1,495 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
3
+ import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
4
+
5
+ const MAX_BUFFER_LINES = 2000;
6
+ const DEFAULT_VISIBLE_LINES = 24;
7
+ const RENDER_THROTTLE_MS = 100;
8
+ const ANSI_SGR_PATTERN = /^\x1b\[[0-9;]*m$/u;
9
+ const DISALLOWED_SGR_CODES = new Set([5, 6, 8]);
10
+
11
+ function padVisible(text: string, width: number): string {
12
+ return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
13
+ }
14
+
15
+ function resetAnsi(text: string): string {
16
+ return text.includes("\x1b") ? `${text}\x1b[0m` : text;
17
+ }
18
+
19
+ function shellQuote(value: string): string {
20
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
21
+ }
22
+
23
+ function resolveSpFeedCommand(args: string): string {
24
+ const trimmed = args.trim();
25
+ return trimmed ? `sp feed -f ${trimmed}` : "sp feed -f";
26
+ }
27
+
28
+ function resolveSpPsCommand(args: string): string {
29
+ const trimmed = args.trim();
30
+ return trimmed ? `sp ps ${trimmed}` : "sp ps --follow";
31
+ }
32
+
33
+ function openTerminalOverlay(ctx: ExtensionCommandContext, title: string, command: string): Promise<void> {
34
+ return ctx.ui.custom<void>(
35
+ (tui, theme, _keybindings, done) => {
36
+ const terminal = new StreamingTerminalOverlay({
37
+ title,
38
+ command,
39
+ cwd: process.cwd(),
40
+ theme,
41
+ requestRender: () => {
42
+ tui.requestRender();
43
+ },
44
+ close: () => {
45
+ terminal.dispose();
46
+ done(undefined);
47
+ },
48
+ });
49
+ return terminal;
50
+ },
51
+ {
52
+ overlay: true,
53
+ overlayOptions: {
54
+ anchor: "center",
55
+ width: "80%",
56
+ minWidth: 72,
57
+ maxHeight: "80%",
58
+ margin: 1,
59
+ },
60
+ } as Parameters<ExtensionCommandContext["ui"]["custom"]>[1],
61
+ ).then(() => undefined);
62
+ }
63
+
64
+ type StreamingTerminalOverlayOptions = {
65
+ title: string;
66
+ command: string;
67
+ cwd: string;
68
+ theme: Theme;
69
+ requestRender: () => void;
70
+ close: () => void;
71
+ };
72
+
73
+ class StreamingTerminalOverlay {
74
+ private child: ChildProcessWithoutNullStreams | undefined;
75
+ private lines: string[] = [];
76
+ private currentLine = "";
77
+ private screenLines: string[] = [];
78
+ private cursorRow = 0;
79
+ private cursorCol = 0;
80
+ private terminalMode = false;
81
+ private scrollOffset = 0;
82
+ private status = "starting";
83
+ private closed = false;
84
+ private renderTimer: ReturnType<typeof setTimeout> | undefined;
85
+ private lastRenderAt = 0;
86
+
87
+ constructor(private readonly options: StreamingTerminalOverlayOptions) {
88
+ this.start();
89
+ }
90
+
91
+ handleInput(data: string): void {
92
+ if (matchesKey(data, "escape") || matchesKey(data, "q") || matchesKey(data, "ctrl+c")) {
93
+ this.options.close();
94
+ return;
95
+ }
96
+ if (matchesKey(data, "r")) {
97
+ this.restart();
98
+ return;
99
+ }
100
+ if (matchesKey(data, "up")) {
101
+ this.scrollOffset = Math.min(this.scrollOffset + 1, Math.max(0, this.renderSourceLines().length - 1));
102
+ this.options.requestRender();
103
+ return;
104
+ }
105
+ if (matchesKey(data, "down")) {
106
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
107
+ this.options.requestRender();
108
+ return;
109
+ }
110
+ if (matchesKey(data, "pageup")) {
111
+ this.scrollOffset = Math.min(this.scrollOffset + DEFAULT_VISIBLE_LINES, Math.max(0, this.renderSourceLines().length - 1));
112
+ this.options.requestRender();
113
+ return;
114
+ }
115
+ if (matchesKey(data, "pagedown")) {
116
+ this.scrollOffset = Math.max(0, this.scrollOffset - DEFAULT_VISIBLE_LINES);
117
+ this.options.requestRender();
118
+ return;
119
+ }
120
+ if (matchesKey(data, "home")) {
121
+ this.scrollOffset = Math.max(0, this.renderSourceLines().length - 1);
122
+ this.options.requestRender();
123
+ return;
124
+ }
125
+ if (matchesKey(data, "end")) {
126
+ this.scrollOffset = 0;
127
+ this.options.requestRender();
128
+ }
129
+ }
130
+
131
+ render(width: number): string[] {
132
+ const theme = this.options.theme;
133
+ const overlayWidth = Math.max(40, width);
134
+ const innerWidth = overlayWidth - 2;
135
+ const contentWidth = Math.max(1, innerWidth - 2);
136
+ const allLines = this.renderSourceLines();
137
+ const visibleCount = DEFAULT_VISIBLE_LINES;
138
+ const end = Math.max(0, allLines.length - this.scrollOffset);
139
+ const start = Math.max(0, end - visibleCount);
140
+ const visible = allLines.slice(start, end);
141
+ const hiddenAbove = start;
142
+ const hiddenBelow = Math.max(0, allLines.length - end);
143
+
144
+ const border = (text: string) => theme.fg("border", text);
145
+ const title = ` ${theme.fg("accent", theme.bold(this.options.title))} ${theme.fg("dim", this.status)} `;
146
+ const top = border("╭") + truncateToWidth(title, Math.max(0, innerWidth), "") + border("─".repeat(Math.max(0, innerWidth - visibleWidth(title)))) + border("╮");
147
+ const row = (content: string) => {
148
+ const truncated = resetAnsi(truncateToWidth(content, contentWidth));
149
+ return `${border("│")} ${padVisible(truncated, contentWidth)} ${border("│")}`;
150
+ };
151
+
152
+ const output = [top];
153
+ output.push(row(theme.fg("dim", `$ ${this.options.command}`)));
154
+ output.push(row(theme.fg("dim", "Esc/q close • r restart • ↑↓ scroll • PgUp/PgDn page")));
155
+ output.push(row(""));
156
+ const bodyRows: string[] = [];
157
+ if (hiddenAbove > 0) bodyRows.push(theme.fg("dim", `… ${hiddenAbove} lines above`));
158
+ bodyRows.push(...visible);
159
+ if (visible.length === 0) bodyRows.push(theme.fg("dim", "waiting for output…"));
160
+ if (hiddenBelow > 0) bodyRows.push(theme.fg("dim", `… ${hiddenBelow} lines below`));
161
+
162
+ for (let index = 0; index < visibleCount; index++) {
163
+ output.push(row(bodyRows[index] ?? ""));
164
+ }
165
+ output.push(border("╰" + "─".repeat(innerWidth) + "╯"));
166
+ return output;
167
+ }
168
+
169
+ invalidate(): void {}
170
+
171
+ dispose(): void {
172
+ this.closed = true;
173
+ if (this.renderTimer) clearTimeout(this.renderTimer);
174
+ this.renderTimer = undefined;
175
+ this.stop();
176
+ }
177
+
178
+ private start(): void {
179
+ this.stop();
180
+ this.status = "running";
181
+ this.lines = [];
182
+ this.currentLine = "";
183
+ this.screenLines = [];
184
+ this.cursorRow = 0;
185
+ this.cursorCol = 0;
186
+ this.terminalMode = false;
187
+ this.scrollOffset = 0;
188
+
189
+ const shell = process.env.SHELL || "/bin/sh";
190
+ this.child = spawn(shell, ["-lc", this.options.command], {
191
+ cwd: this.options.cwd,
192
+ env: {
193
+ ...process.env,
194
+ FORCE_COLOR: process.env.FORCE_COLOR ?? "1",
195
+ TERM: process.env.TERM ?? "xterm-256color",
196
+ COLUMNS: process.env.COLUMNS ?? "120",
197
+ LINES: process.env.LINES ?? "40",
198
+ },
199
+ stdio: ["pipe", "pipe", "pipe"],
200
+ });
201
+
202
+ this.child.stdout.on("data", (chunk) => this.append(String(chunk)));
203
+ this.child.stderr.on("data", (chunk) => this.append(String(chunk)));
204
+ this.child.on("error", (error) => {
205
+ this.status = "error";
206
+ this.append(`\n[error] ${error.message}\n`);
207
+ });
208
+ this.child.on("close", (code, signal) => {
209
+ this.flushCurrentLine();
210
+ this.status = signal ? `stopped (${signal})` : `exited ${code ?? "unknown"}`;
211
+ this.requestRenderSoon(true);
212
+ });
213
+
214
+ this.requestRenderSoon(true);
215
+ }
216
+
217
+ private restart(): void {
218
+ this.start();
219
+ }
220
+
221
+ private stop(): void {
222
+ const child = this.child;
223
+ this.child = undefined;
224
+ if (child && !child.killed) {
225
+ child.kill("SIGTERM");
226
+ setTimeout(() => {
227
+ if (!child.killed) child.kill("SIGKILL");
228
+ }, 750).unref?.();
229
+ }
230
+ }
231
+
232
+ private requestRenderSoon(immediate = false): void {
233
+ if (this.closed) return;
234
+ if (immediate) {
235
+ if (this.renderTimer) clearTimeout(this.renderTimer);
236
+ this.renderTimer = undefined;
237
+ this.lastRenderAt = Date.now();
238
+ this.options.requestRender();
239
+ return;
240
+ }
241
+
242
+ const now = Date.now();
243
+ const elapsed = now - this.lastRenderAt;
244
+ if (elapsed >= RENDER_THROTTLE_MS) {
245
+ this.lastRenderAt = now;
246
+ this.options.requestRender();
247
+ return;
248
+ }
249
+
250
+ if (this.renderTimer) return;
251
+ this.renderTimer = setTimeout(() => {
252
+ this.renderTimer = undefined;
253
+ this.lastRenderAt = Date.now();
254
+ this.options.requestRender();
255
+ }, RENDER_THROTTLE_MS - elapsed);
256
+ this.renderTimer.unref?.();
257
+ }
258
+
259
+ private renderSourceLines(): string[] {
260
+ if (this.terminalMode) {
261
+ return this.screenLines.map((line) => line.replace(/\s+$/u, ""));
262
+ }
263
+ return this.currentLine ? [...this.lines, this.currentLine] : this.lines;
264
+ }
265
+
266
+ private append(text: string): void {
267
+ if (this.closed) return;
268
+ for (let index = 0; index < text.length; index++) {
269
+ const char = text[index]!;
270
+ if (char === "\x1b") {
271
+ const sgrMatch = text.slice(index).match(/^\x1b\[[0-9;]*m/u);
272
+ if (sgrMatch?.[0]) {
273
+ this.appendSafeSgr(sgrMatch[0]);
274
+ index += sgrMatch[0].length - 1;
275
+ continue;
276
+ }
277
+ const nextIndex = this.consumeEscape(text, index);
278
+ if (nextIndex !== index) {
279
+ index = nextIndex;
280
+ continue;
281
+ }
282
+ }
283
+ this.appendChar(char);
284
+ }
285
+ this.trimBuffer();
286
+ this.requestRenderSoon();
287
+ }
288
+
289
+ private appendSafeSgr(sequence: string): void {
290
+ if (!ANSI_SGR_PATTERN.test(sequence)) return;
291
+ const params = sequence.slice(2, -1).split(";").filter(Boolean).map((part) => Number.parseInt(part, 10));
292
+ if (params.some((param) => Number.isNaN(param) || DISALLOWED_SGR_CODES.has(param))) return;
293
+
294
+ // Cursor-addressed dashboards need cell-aware mutation. Keeping raw SGR inside
295
+ // screenLines makes cursorCol slicing unsafe, so preserve colors only for
296
+ // append-only feed output.
297
+ if (this.terminalMode) return;
298
+ this.currentLine += sequence;
299
+ }
300
+
301
+ private appendChar(char: string): void {
302
+ if (char === "\r") {
303
+ if (this.terminalMode) this.cursorCol = 0;
304
+ else this.currentLine = "";
305
+ return;
306
+ }
307
+ if (char === "\n") {
308
+ if (this.terminalMode) {
309
+ this.cursorRow++;
310
+ this.cursorCol = 0;
311
+ this.ensureScreenLine(this.cursorRow);
312
+ } else {
313
+ this.flushCurrentLine();
314
+ }
315
+ return;
316
+ }
317
+ if (char === "\b" || char === "\x7f") {
318
+ if (this.terminalMode) this.cursorCol = Math.max(0, this.cursorCol - 1);
319
+ else this.currentLine = this.currentLine.slice(0, -1);
320
+ return;
321
+ }
322
+ if (char < " " && char !== "\t") return;
323
+
324
+ if (this.terminalMode) {
325
+ this.writeScreenChar(char === "\t" ? " " : char);
326
+ return;
327
+ }
328
+ this.currentLine += char;
329
+ }
330
+
331
+ private consumeEscape(text: string, start: number): number {
332
+ const introducer = text[start + 1];
333
+ if (introducer === "[") {
334
+ let end = start + 2;
335
+ while (end < text.length && !/[A-Za-z~]/.test(text[end]!)) end++;
336
+ if (end >= text.length) return start;
337
+ this.handleCsi(text.slice(start + 2, end), text[end]!);
338
+ return end;
339
+ }
340
+ if (introducer === "]") {
341
+ let end = start + 2;
342
+ while (end < text.length) {
343
+ if (text[end] === "\x07") return end;
344
+ if (text[end] === "\x1b" && text[end + 1] === "\\") return end + 1;
345
+ end++;
346
+ }
347
+ return start;
348
+ }
349
+ if (introducer) return start + 1;
350
+ return start;
351
+ }
352
+
353
+ private handleCsi(params: string, final: string): void {
354
+ const numbers = params
355
+ .replace(/[?>!]/g, "")
356
+ .split(";")
357
+ .map((part) => Number.parseInt(part || "0", 10));
358
+ const first = numbers[0] ?? 0;
359
+
360
+ if (final === "m") return;
361
+ if (final === "H" || final === "f") {
362
+ this.enterTerminalMode();
363
+ this.cursorRow = Math.max(0, (numbers[0] || 1) - 1);
364
+ this.cursorCol = Math.max(0, (numbers[1] || 1) - 1);
365
+ this.ensureScreenLine(this.cursorRow);
366
+ return;
367
+ }
368
+ if (final === "J") {
369
+ this.enterTerminalMode();
370
+ if (first === 2 || first === 3) {
371
+ this.screenLines = [];
372
+ this.cursorRow = 0;
373
+ this.cursorCol = 0;
374
+ this.ensureScreenLine(0);
375
+ } else if (first === 0) {
376
+ this.screenLines = this.screenLines.slice(0, this.cursorRow + 1);
377
+ this.clearScreenLineFromCursor();
378
+ }
379
+ return;
380
+ }
381
+ if (final === "K") {
382
+ this.enterTerminalMode();
383
+ if (first === 2) this.screenLines[this.cursorRow] = "";
384
+ else this.clearScreenLineFromCursor();
385
+ return;
386
+ }
387
+ if (final === "A") {
388
+ this.enterTerminalMode();
389
+ this.cursorRow = Math.max(0, this.cursorRow - Math.max(1, first));
390
+ return;
391
+ }
392
+ if (final === "B") {
393
+ this.enterTerminalMode();
394
+ this.cursorRow += Math.max(1, first);
395
+ this.ensureScreenLine(this.cursorRow);
396
+ return;
397
+ }
398
+ if (final === "C") {
399
+ this.enterTerminalMode();
400
+ this.cursorCol += Math.max(1, first);
401
+ return;
402
+ }
403
+ if (final === "D") {
404
+ this.enterTerminalMode();
405
+ this.cursorCol = Math.max(0, this.cursorCol - Math.max(1, first));
406
+ }
407
+ }
408
+
409
+ private enterTerminalMode(): void {
410
+ if (this.terminalMode) return;
411
+ this.terminalMode = true;
412
+ this.screenLines = this.currentLine ? [...this.lines, this.currentLine] : [...this.lines];
413
+ if (this.screenLines.length === 0) this.screenLines.push("");
414
+ this.cursorRow = Math.max(0, this.screenLines.length - 1);
415
+ this.cursorCol = visibleWidth(this.screenLines[this.cursorRow] ?? "");
416
+ this.currentLine = "";
417
+ }
418
+
419
+ private ensureScreenLine(row: number): void {
420
+ while (this.screenLines.length <= row) this.screenLines.push("");
421
+ }
422
+
423
+ private writeScreenChar(char: string): void {
424
+ this.ensureScreenLine(this.cursorRow);
425
+ const line = this.screenLines[this.cursorRow] ?? "";
426
+ const padded = line.length < this.cursorCol ? line + " ".repeat(this.cursorCol - line.length) : line;
427
+ this.screenLines[this.cursorRow] = padded.slice(0, this.cursorCol) + char + padded.slice(this.cursorCol + 1);
428
+ this.cursorCol++;
429
+ }
430
+
431
+ private clearScreenLineFromCursor(): void {
432
+ this.ensureScreenLine(this.cursorRow);
433
+ this.screenLines[this.cursorRow] = (this.screenLines[this.cursorRow] ?? "").slice(0, this.cursorCol);
434
+ }
435
+
436
+ private flushCurrentLine(): void {
437
+ if (this.terminalMode) return;
438
+ this.lines.push(this.currentLine);
439
+ this.currentLine = "";
440
+ this.trimBuffer();
441
+ }
442
+
443
+ private trimBuffer(): void {
444
+ if (this.lines.length > MAX_BUFFER_LINES) this.lines.splice(0, this.lines.length - MAX_BUFFER_LINES);
445
+ if (this.screenLines.length > MAX_BUFFER_LINES) this.screenLines.splice(0, this.screenLines.length - MAX_BUFFER_LINES);
446
+ }
447
+ }
448
+
449
+ export default function spTerminalOverlayExtension(pi: ExtensionAPI): void {
450
+ pi.registerCommand("sp-feed", {
451
+ description: "Open a streaming overlay for `sp feed -f`",
452
+ handler: async (args, ctx) => {
453
+ await openTerminalOverlay(ctx, "sp feed", resolveSpFeedCommand(args));
454
+ },
455
+ });
456
+
457
+ pi.registerCommand("sp-ps", {
458
+ description: "Open a streaming overlay for `sp ps --follow`",
459
+ handler: async (args, ctx) => {
460
+ await openTerminalOverlay(ctx, "sp ps", resolveSpPsCommand(args));
461
+ },
462
+ });
463
+
464
+ pi.registerCommand("xtrm-ps", {
465
+ description: "Alias for /sp-ps",
466
+ handler: async (args, ctx) => {
467
+ await openTerminalOverlay(ctx, "sp ps", resolveSpPsCommand(args));
468
+ },
469
+ });
470
+
471
+ pi.registerCommand("xtrm-terminal", {
472
+ description: "Open a streaming terminal overlay for an arbitrary shell command",
473
+ handler: async (args, ctx) => {
474
+ const command = args.trim();
475
+ if (!command) {
476
+ ctx.ui.notify("Usage: /xtrm-terminal <command>", "warning");
477
+ return;
478
+ }
479
+ await openTerminalOverlay(ctx, command, command);
480
+ },
481
+ });
482
+
483
+ pi.registerCommand("xtrm-terminal-file", {
484
+ description: "Open a streaming overlay for a command with one shell-quoted file/path argument",
485
+ handler: async (args, ctx) => {
486
+ const [commandName, ...rest] = args.trim().split(/\s+/u);
487
+ const fileArg = rest.join(" ");
488
+ if (!commandName || !fileArg) {
489
+ ctx.ui.notify("Usage: /xtrm-terminal-file <command> <path>", "warning");
490
+ return;
491
+ }
492
+ await openTerminalOverlay(ctx, commandName, `${commandName} ${shellQuote(fileArg)}`);
493
+ },
494
+ });
495
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@xtrm/pi-sp-terminal-overlay",
3
+ "version": "0.1.0",
4
+ "description": "XTRM Pi overlay for streaming sp feed and terminal command output",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts"
8
+ },
9
+ "license": "MIT",
10
+ "pi": {
11
+ "extensions": [
12
+ "./index.ts"
13
+ ]
14
+ }
15
+ }
@@ -258,6 +258,9 @@ export function formatLineLabel(count: number, noun: string): string {
258
258
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
259
259
  }
260
260
 
261
+ const TOOL_SUMMARY_SUBJECT_MAX = 34;
262
+ const TOOL_SUMMARY_META_MAX = 34;
263
+
261
264
  export function renderToolSummary(
262
265
  theme: { fg(color: string, text: string): string; bold(text: string): string },
263
266
  status: "pending" | "success" | "error" | "muted",
@@ -270,9 +273,11 @@ export function renderToolSummary(
270
273
  : status === "error" ? "error"
271
274
  : status === "success" ? "success"
272
275
  : "muted";
276
+ const compactSubject = subject ? shortenCommand(subject, TOOL_SUMMARY_SUBJECT_MAX) : undefined;
277
+ const compactMeta = meta ? shortenCommand(meta, TOOL_SUMMARY_META_MAX) : undefined;
273
278
  let text = `${theme.fg(color, "•")} ${theme.fg("toolTitle", theme.bold(label))}`;
274
- if (subject) text += ` ${theme.fg("accent", subject)}`;
275
- if (meta) text += theme.fg("muted", ` · ${meta}`);
279
+ if (compactSubject) text += ` ${theme.fg("accent", compactSubject)}`;
280
+ if (compactMeta) text += theme.fg("muted", ` · ${compactMeta}`);
276
281
  return text;
277
282
  }
278
283
 
@@ -32,7 +32,7 @@ import {
32
32
  createWriteTool,
33
33
  } from "@mariozechner/pi-coding-agent";
34
34
  import { Box, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
35
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
35
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
36
36
  import { basename, dirname, join } from "node:path";
37
37
  import { fileURLToPath, pathToFileURL } from "node:url";
38
38
  import {
@@ -57,6 +57,7 @@ import {
57
57
 
58
58
  export type XtrmThemeName = "pidex-dark" | "pidex-light" | "pidex-dark-flattools" | "pidex-light-flattools";
59
59
  export type XtrmDensity = "compact" | "comfortable";
60
+ export type XtrmExternalToolChrome = "background" | "box";
60
61
 
61
62
  export interface XtrmUiPrefs {
62
63
  themeName: XtrmThemeName;
@@ -67,6 +68,7 @@ export interface XtrmUiPrefs {
67
68
  forceTheme: boolean; // When false, skip setTheme (allow external theme override)
68
69
  toolRowBg: boolean; // Subtle background behind tool text rows (no padding)
69
70
  compactExternalToolResults: boolean; // Compact extension tool results (disables full expand output)
71
+ externalToolChrome: XtrmExternalToolChrome; // Visual treatment for non-native tool rows
70
72
  hideThinkingPlaceholder: boolean; // When false, hidden thinking blocks render no placeholder text
71
73
  }
72
74
 
@@ -85,9 +87,17 @@ export const DEFAULT_PREFS: XtrmUiPrefs = {
85
87
  forceTheme: true,
86
88
  toolRowBg: false,
87
89
  compactExternalToolResults: true,
90
+ externalToolChrome: "background",
88
91
  hideThinkingPlaceholder: false,
89
92
  };
90
93
 
94
+ let activeExternalToolChrome: XtrmExternalToolChrome = DEFAULT_PREFS.externalToolChrome;
95
+
96
+ function setActiveExternalToolChrome(chrome: XtrmExternalToolChrome): void {
97
+ activeExternalToolChrome = chrome;
98
+ }
99
+
100
+
91
101
  // ============================================================================
92
102
  // Preferences
93
103
  // ============================================================================
@@ -111,6 +121,7 @@ function normalizePrefs(input: unknown): XtrmUiPrefs {
111
121
  toolRowBg: source.toolRowBg ?? DEFAULT_PREFS.toolRowBg,
112
122
  compactExternalToolResults:
113
123
  source.compactExternalToolResults ?? DEFAULT_PREFS.compactExternalToolResults,
124
+ externalToolChrome: source.externalToolChrome === "box" ? "box" : "background",
114
125
  hideThinkingPlaceholder: source.hideThinkingPlaceholder ?? DEFAULT_PREFS.hideThinkingPlaceholder,
115
126
  };
116
127
  }
@@ -153,8 +164,39 @@ type PatchableAssistantMessage = {
153
164
 
154
165
  const PATCHED_ASSISTANT_MESSAGE = "__xtrmUiSilentHiddenThinking";
155
166
 
167
+ function maybeFileUrlToPath(value: string): string {
168
+ return value.startsWith("file:") ? fileURLToPath(value) : value;
169
+ }
170
+
171
+ function resolvePiCodingAgentEntryPath(): string {
172
+ const candidates: string[] = [];
173
+
174
+ const argvPath = process.argv[1];
175
+ if (argvPath && existsSync(argvPath)) {
176
+ const realArgvPath = realpathSync(argvPath);
177
+ if (realArgvPath.endsWith("/dist/cli.js")) {
178
+ candidates.push(join(dirname(realArgvPath), "index.js"));
179
+ }
180
+ }
181
+
182
+ candidates.push(
183
+ join(dirname(process.execPath), "..", "lib", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "index.js"),
184
+ join(dirname(process.execPath), "..", "lib", "node_modules", "@mariozechner", "pi-coding-agent", "dist", "index.js"),
185
+ );
186
+
187
+ for (const packageName of ["@earendil-works/pi-coding-agent", "@mariozechner/pi-coding-agent"]) {
188
+ try {
189
+ candidates.push(maybeFileUrlToPath(import.meta.resolve(packageName)));
190
+ } catch {}
191
+ }
192
+
193
+ const entryPath = candidates.find((candidate) => existsSync(candidate));
194
+ if (!entryPath) throw new Error("Could not resolve pi-coding-agent entry path");
195
+ return entryPath;
196
+ }
197
+
156
198
  async function installSilentHiddenThinkingPatch(): Promise<void> {
157
- const entryPath = fileURLToPath(import.meta.resolve("@mariozechner/pi-coding-agent"));
199
+ const entryPath = resolvePiCodingAgentEntryPath();
158
200
  const componentPath = join(dirname(entryPath), "modes", "interactive", "components", "assistant-message.js");
159
201
  const mod = await import(pathToFileURL(componentPath).href) as {
160
202
  AssistantMessageComponent?: AssistantMessageComponentCtor;
@@ -191,6 +233,240 @@ async function installSilentHiddenThinkingPatch(): Promise<void> {
191
233
  proto[PATCHED_ASSISTANT_MESSAGE] = true;
192
234
  }
193
235
 
236
+ type ToolExecutionComponentCtor = {
237
+ prototype: {
238
+ getRenderShell?: () => "default" | "self";
239
+ hasRendererDefinition?: () => boolean;
240
+ render?: (width: number) => string[];
241
+ };
242
+ };
243
+
244
+ type PatchableToolExecutionComponent = {
245
+ toolName?: string;
246
+ args?: unknown;
247
+ result?: { content?: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean };
248
+ expanded?: boolean;
249
+ hasRendererDefinition?: () => boolean;
250
+ };
251
+
252
+ type ExternalToolFrameKind = "serena" | "gitnexus" | "structured" | "process" | "external";
253
+
254
+ const PATCHED_EXTERNAL_TOOL_FRAME = "__xtrmUiExternalToolFrame";
255
+ const EXTERNAL_TOOL_FRAME_PATCH_VERSION = 10;
256
+ const ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
257
+
258
+ function stripAnsi(text: string): string {
259
+ return text.replace(ANSI_PATTERN, "");
260
+ }
261
+
262
+ function isBlankRenderedLine(line: string): boolean {
263
+ return stripAnsi(line).trim().length === 0;
264
+ }
265
+
266
+ function externalToolFrameKind(toolName: string | undefined): ExternalToolFrameKind | undefined {
267
+ if (!toolName || XTRM_BUILTIN_TOOLS.has(toolName)) return undefined;
268
+ if (toolName === "structured_return") return "structured";
269
+ if (toolName === "process") return "process";
270
+ if (toolName.startsWith("gitnexus_")) return "gitnexus";
271
+ if (SERENA_COMPACT_TOOLS.has(toolName)) return "serena";
272
+ return "external";
273
+ }
274
+
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
+ function getToolArgs(component: PatchableToolExecutionComponent): Record<string, unknown> {
286
+ return component.args && typeof component.args === "object" && !Array.isArray(component.args)
287
+ ? component.args as Record<string, unknown>
288
+ : {};
289
+ }
290
+
291
+ function summarizeExternalToolPending(toolName: string | undefined, input: Record<string, unknown>): string {
292
+ const name = toolName ?? "tool";
293
+ if (name === "structured_return") {
294
+ return `• structured_return ${shortenCommand(String(input.command ?? "running"), 38)}`;
295
+ }
296
+ if (name === "process") {
297
+ return `• process ${String(input.action ?? "running")}`;
298
+ }
299
+ if (name.startsWith("gitnexus_")) {
300
+ const subject = summarizeSerenaSubject(name, input) ?? summarizeToolSubject(name, input);
301
+ return `• ${normalizeToolLabel(name)}${subject ? ` ${subject}` : ""}`;
302
+ }
303
+ if (SERENA_COMPACT_TOOLS.has(name)) {
304
+ const subject = summarizeSerenaSubject(name, input);
305
+ return `• serena ${name}${subject ? ` ${subject}` : ""}`;
306
+ }
307
+ const subject = summarizeToolSubject(name, input) ?? summarizeSerenaSubject(name, input);
308
+ return `• ${normalizeToolLabel(name)}${subject ? ` ${subject}` : ""}`;
309
+ }
310
+
311
+ 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
+ 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))];
319
+ }
320
+
321
+ function trimRenderedToolLines(lines: string[]): string[] {
322
+ let start = 0;
323
+ let end = lines.length;
324
+ while (start < end && isBlankRenderedLine(lines[start] ?? "")) start++;
325
+ while (end > start && isBlankRenderedLine(lines[end - 1] ?? "")) end--;
326
+ return lines.slice(start, end).map((line) => line.replace(/\s+$/u, ""));
327
+ }
328
+
329
+ function externalToolBgRgb(kind: ExternalToolFrameKind): [number, number, number] {
330
+ const bgColors: Record<ExternalToolFrameKind, [number, number, number]> = {
331
+ serena: [13, 34, 49],
332
+ gitnexus: [31, 23, 55],
333
+ structured: [35, 23, 55],
334
+ process: [10, 42, 52],
335
+ external: [27, 33, 43],
336
+ };
337
+ return bgColors[kind];
338
+ }
339
+
340
+ function externalToolBgColor(kind: ExternalToolFrameKind, text: string): string {
341
+ const [r, g, b] = externalToolBgRgb(kind);
342
+ return `\x1b[48;2;${r};${g};${b}m${text}\x1b[49m`;
343
+ }
344
+
345
+ function externalToolBadgeColor(kind: ExternalToolFrameKind, text: string): string {
346
+ const bgColors: Record<ExternalToolFrameKind, [number, number, number]> = {
347
+ serena: [26, 96, 132],
348
+ gitnexus: [82, 58, 150],
349
+ structured: [105, 61, 150],
350
+ process: [17, 118, 145],
351
+ external: [74, 88, 112],
352
+ };
353
+ const [badgeR, badgeG, badgeB] = bgColors[kind];
354
+ const [rowR, rowG, rowB] = externalToolBgRgb(kind);
355
+ return `\x1b[1m\x1b[48;2;${badgeR};${badgeG};${badgeB}m${text}\x1b[22m\x1b[48;2;${rowR};${rowG};${rowB}m`;
356
+ }
357
+
358
+ function highlightExternalToolBadge(kind: ExternalToolFrameKind, line: string): string {
359
+ const match = line.match(/^(•\s+(?:serena\s+\S+|gitnexus(?:_\S+)?|structured_return|process|\S+))/u);
360
+ if (!match?.[1]) return line;
361
+ return externalToolBadgeColor(kind, match[1]) + line.slice(match[1].length);
362
+ }
363
+
364
+ function externalToolBorderColor(kind: ExternalToolFrameKind, text: string): string {
365
+ const colors: Record<ExternalToolFrameKind, [number, number, number]> = {
366
+ serena: [150, 210, 255],
367
+ gitnexus: [185, 168, 255],
368
+ structured: [205, 166, 255],
369
+ process: [145, 231, 255],
370
+ external: [168, 181, 199],
371
+ };
372
+ const [r, g, b] = colors[kind];
373
+ return `[38;2;${r};${g};${b}m${text}`;
374
+ }
375
+
376
+ function collapsedExternalToolLines(contentLines: string[], expanded: boolean): string[] {
377
+ return expanded ? contentLines : [contentLines.join(" · ")];
378
+ }
379
+
380
+ function renderExternalToolBackgroundLines(
381
+ contentLines: string[],
382
+ width: number,
383
+ kind: ExternalToolFrameKind,
384
+ expanded: boolean,
385
+ ): string[] {
386
+ const availableWidth = Math.max(8, width);
387
+ const renderWidth = availableWidth;
388
+ const visibleLines = collapsedExternalToolLines(contentLines, expanded);
389
+
390
+ return visibleLines.map((rawLine) => {
391
+ const line = truncateToWidth(rawLine, Math.max(1, renderWidth - 2));
392
+ const highlighted = highlightExternalToolBadge(kind, line);
393
+ return externalToolBgColor(kind, ` ${padVisible(highlighted, Math.max(1, renderWidth - 2))} `);
394
+ });
395
+ }
396
+
397
+ function renderExternalToolBoxLines(
398
+ contentLines: string[],
399
+ width: number,
400
+ kind: ExternalToolFrameKind,
401
+ expanded: boolean,
402
+ ): string[] {
403
+ const availableWidth = Math.max(8, width - 4);
404
+ const maxContentWidth = expanded ? availableWidth : Math.min(availableWidth, 34);
405
+ const visibleLines = collapsedExternalToolLines(contentLines, expanded);
406
+ const contentWidth = Math.max(
407
+ 1,
408
+ Math.min(maxContentWidth, ...visibleLines.map((line) => visibleWidth(line))),
409
+ );
410
+ const innerWidth = contentWidth + 2;
411
+
412
+ const framed = [externalToolBorderColor(kind, `╭${"─".repeat(innerWidth)}╮`)];
413
+ for (const rawLine of visibleLines) {
414
+ const line = truncateToWidth(rawLine, contentWidth);
415
+ framed.push(`${externalToolBorderColor(kind, "│")} ${padVisible(line, contentWidth)} ${externalToolBorderColor(kind, "│")}`);
416
+ }
417
+ framed.push(externalToolBorderColor(kind, `╰${"─".repeat(innerWidth)}╯`));
418
+ return framed;
419
+ }
420
+
421
+ function renderExternalToolLines(
422
+ lines: string[],
423
+ width: number,
424
+ kind: ExternalToolFrameKind,
425
+ expanded = false,
426
+ ): string[] {
427
+ const contentLines = trimRenderedToolLines(lines).filter((line) => !isBlankRenderedLine(line));
428
+ if (contentLines.length === 0) return [];
429
+
430
+ return activeExternalToolChrome === "box"
431
+ ? renderExternalToolBoxLines(contentLines, width, kind, expanded)
432
+ : renderExternalToolBackgroundLines(contentLines, width, kind, expanded);
433
+ }
434
+
435
+ async function installExternalToolFramePatch(): Promise<void> {
436
+ const entryPath = resolvePiCodingAgentEntryPath();
437
+ const componentPath = join(dirname(entryPath), "modes", "interactive", "components", "tool-execution.js");
438
+ const mod = await import(pathToFileURL(componentPath).href) as {
439
+ ToolExecutionComponent?: ToolExecutionComponentCtor;
440
+ };
441
+ const proto = mod.ToolExecutionComponent?.prototype as
442
+ | (ToolExecutionComponentCtor["prototype"] & { [PATCHED_EXTERNAL_TOOL_FRAME]?: boolean })
443
+ | undefined;
444
+ if (!proto?.render || proto[PATCHED_EXTERNAL_TOOL_FRAME] === EXTERNAL_TOOL_FRAME_PATCH_VERSION) return;
445
+
446
+ const getRenderShell = proto.getRenderShell;
447
+ const render = proto.render;
448
+
449
+ proto.getRenderShell = function patchedGetRenderShell(this: PatchableToolExecutionComponent) {
450
+ const kind = externalToolFrameKind(this.toolName);
451
+ if (kind) return "self";
452
+ return getRenderShell?.call(this) ?? "default";
453
+ };
454
+
455
+ proto.render = function patchedRender(this: PatchableToolExecutionComponent, width: number) {
456
+ const rendered = render.call(this, width);
457
+ const kind = externalToolFrameKind(this.toolName);
458
+ if (!kind || rendered.length === 0) return rendered;
459
+
460
+ const firstContentIndex = rendered.findIndex((line) => !isBlankRenderedLine(line));
461
+ const leading = firstContentIndex > 0 ? rendered.slice(0, firstContentIndex) : [];
462
+ const content = extractResultTextLines(this) ?? rendered;
463
+ const styled = renderExternalToolLines(content, width, kind, Boolean(this.expanded));
464
+ return styled.length > 0 ? [...leading, ...styled] : rendered;
465
+ };
466
+
467
+ proto[PATCHED_EXTERNAL_TOOL_FRAME] = EXTERNAL_TOOL_FRAME_PATCH_VERSION;
468
+ }
469
+
194
470
  function applyThinkingChrome(ctx: ExtensionContext, prefs: XtrmUiPrefs): void {
195
471
  (ctx.ui as { setHiddenThinkingLabel?: (label?: string) => void }).setHiddenThinkingLabel?.(
196
472
  prefs.hideThinkingPlaceholder ? undefined : "",
@@ -396,6 +672,13 @@ function parseDensityArg(arg: string): XtrmDensity | undefined {
396
672
  return undefined;
397
673
  }
398
674
 
675
+ function parseExternalToolChromeArg(arg: string): XtrmExternalToolChrome | undefined {
676
+ const normalized = arg.trim().toLowerCase();
677
+ if (normalized === "background" || normalized === "bg" || normalized === "row") return "background";
678
+ if (normalized === "box" || normalized === "frame" || normalized === "border") return "box";
679
+ return undefined;
680
+ }
681
+
399
682
  function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPrefs: (p: XtrmUiPrefs) => void, getThinkingLevel: () => string) {
400
683
  pi.registerMessageRenderer("xtrm-ui-info", (message, _options, theme) => {
401
684
  const title = (message.details as { title?: string } | undefined)?.title ?? "XTRM UI";
@@ -407,7 +690,26 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
407
690
 
408
691
  pi.registerCommand("xtrm-ui", {
409
692
  description: "Show XTRM UI status and active preferences",
410
- handler: async (_args, ctx) => {
693
+ handler: async (args, ctx) => {
694
+ const trimmedArgs = args.trim();
695
+ if (trimmedArgs) {
696
+ const [subcommand, ...rest] = trimmedArgs.split(/\s+/u);
697
+ if (subcommand === "chrome" || subcommand === "external-chrome" || subcommand === "tool-chrome") {
698
+ const externalToolChrome = parseExternalToolChromeArg(rest.join(" "));
699
+ if (!externalToolChrome) {
700
+ ctx.ui.notify("Usage: /xtrm-ui chrome background|box", "warning");
701
+ return;
702
+ }
703
+ const prefs = { ...getPrefs(), externalToolChrome };
704
+ setPrefs(prefs);
705
+ persistPrefs(pi, prefs);
706
+ ctx.ui.notify(`External tool chrome set to ${externalToolChrome}.`, "info");
707
+ return;
708
+ }
709
+ ctx.ui.notify("Usage: /xtrm-ui [chrome background|box]", "warning");
710
+ return;
711
+ }
712
+
411
713
  const prefs = getPrefs();
412
714
  const contextUsage = ctx.getContextUsage();
413
715
  const lines = [
@@ -419,6 +721,7 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
419
721
  `Show footer: ${prefs.showFooter ? "yes" : "no"} (custom-footer handles this)`,
420
722
  `Tool row background: ${prefs.toolRowBg ? "on" : "off"}`,
421
723
  `Compact external tool results: ${prefs.compactExternalToolResults ? "on" : "off"}`,
724
+ `External tool chrome: ${prefs.externalToolChrome}`,
422
725
  `Model: ${ctx.model?.id ?? "none"}`,
423
726
  `Context: ${contextUsage?.tokens ?? "unknown"}/${contextUsage?.contextWindow ?? "unknown"}`,
424
727
  ];
@@ -549,6 +852,25 @@ function registerCommands(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs, setPref
549
852
  },
550
853
  });
551
854
 
855
+ pi.registerCommand("xtrm-ui-external-chrome", {
856
+ description: "Choose non-native tool chrome: background|box",
857
+ getArgumentCompletions: (prefix) => {
858
+ const values = ["background", "box"].filter((item) => item.startsWith(prefix));
859
+ return values.length > 0 ? values.map((value) => ({ value, label: value })) : null;
860
+ },
861
+ handler: async (args, ctx) => {
862
+ const externalToolChrome = parseExternalToolChromeArg(args);
863
+ if (!externalToolChrome) {
864
+ ctx.ui.notify("Usage: /xtrm-ui-external-chrome background|box", "warning");
865
+ return;
866
+ }
867
+ const prefs = { ...getPrefs(), externalToolChrome };
868
+ setPrefs(prefs);
869
+ persistPrefs(pi, prefs);
870
+ ctx.ui.notify(`External tool chrome set to ${externalToolChrome}.`, "info");
871
+ },
872
+ });
873
+
552
874
  pi.registerCommand("xtrm-ui-reset", {
553
875
  description: "Restore XTRM UI defaults",
554
876
  handler: async (_args, ctx) => {
@@ -956,6 +1278,101 @@ function summarizeGenericToolResult(
956
1278
  return `• ${normalized}${subject ? ` ${subject}` : ""}${joined ? ` · ${joined}` : ""}`;
957
1279
  }
958
1280
 
1281
+ function summarizeStructuredReturnToolResult(
1282
+ input: Record<string, unknown>,
1283
+ text: string,
1284
+ details: unknown,
1285
+ durationMs: number | undefined,
1286
+ ): string {
1287
+ const record = asRecord(details);
1288
+ const command = shortenCommand(String(input.command ?? text.split("→")[0] ?? "command"), 52);
1289
+ const resultText = text.includes("→") ? text.split("→").slice(1).join("→").trim() : text.trim();
1290
+ const resultLines = resultText.split("\n").map((line) => line.trim()).filter(Boolean);
1291
+ const summary = resultLines.find((line) => !line.startsWith("cwd:"));
1292
+ const parser = typeof record?.parser === "string" ? record.parser : undefined;
1293
+ const exitCode = typeof record?.exitCode === "number" ? `exit ${record.exitCode}` : undefined;
1294
+ const duration = formatDuration(durationMs);
1295
+ const meta = joinMeta([summary ? shortenCommand(summary, 72) : undefined, parser, exitCode, duration]);
1296
+ return `• structured_return ${command}${meta ? ` · ${meta}` : ""}`;
1297
+ }
1298
+
1299
+ function summarizeProcessToolResult(
1300
+ input: Record<string, unknown>,
1301
+ text: string,
1302
+ details: unknown,
1303
+ durationMs: number | undefined,
1304
+ ): string {
1305
+ const record = asRecord(details);
1306
+ const action = String(record?.action ?? input.action ?? "action");
1307
+ const duration = formatDuration(durationMs);
1308
+ const meta = (...parts: Array<string | undefined>) => {
1309
+ const joined = joinMeta([...parts, duration]);
1310
+ return joined ? ` · ${joined}` : "";
1311
+ };
1312
+
1313
+ if (action === "start") {
1314
+ const proc = asRecord(record?.process);
1315
+ const name = String(proc?.name ?? input.name ?? "process");
1316
+ const id = proc?.id ? String(proc.id) : undefined;
1317
+ const pid = proc?.pid != null ? `pid ${String(proc.pid)}` : undefined;
1318
+ return `• process start "${name}"${meta(id, pid)}`;
1319
+ }
1320
+
1321
+ if (action === "list") {
1322
+ const processes = Array.isArray(record?.processes) ? record.processes : [];
1323
+ const running = processes.filter((item) => {
1324
+ const proc = asRecord(item);
1325
+ return proc?.status === "running" || proc?.status === "terminating";
1326
+ }).length;
1327
+ return `• process list${meta(`${processes.length} ${processes.length === 1 ? "process" : "processes"}`, `${running} running`)}`;
1328
+ }
1329
+
1330
+ if (action === "output") {
1331
+ const output = asRecord(record?.output);
1332
+ const stdout = Array.isArray(output?.stdout) ? output.stdout.length : undefined;
1333
+ const stderr = Array.isArray(output?.stderr) ? output.stderr.length : undefined;
1334
+ return `• process output ${String(input.id ?? "process")}${meta(
1335
+ stdout != null ? `${stdout} stdout` : undefined,
1336
+ stderr != null ? `${stderr} stderr` : undefined,
1337
+ )}`;
1338
+ }
1339
+
1340
+ if (action === "logs") {
1341
+ return `• process logs ${String(input.id ?? "process")}${meta("log paths")}`;
1342
+ }
1343
+
1344
+ const message = typeof record?.message === "string" ? record.message : text.split("\n")[0];
1345
+ return `• process ${action}${message ? ` · ${shortenCommand(message, 38)}` : ""}${duration ? ` · ${duration}` : ""}`;
1346
+ }
1347
+
1348
+ function summarizeExternalToolResult(
1349
+ toolName: string,
1350
+ input: Record<string, unknown>,
1351
+ text: string,
1352
+ details: unknown,
1353
+ durationMs: number | undefined,
1354
+ ): string {
1355
+ if (SERENA_COMPACT_TOOLS.has(toolName)) {
1356
+ return summarizeSerenaToolResult(toolName, input, text, durationMs);
1357
+ }
1358
+ if (toolName === "structured_return") {
1359
+ return summarizeStructuredReturnToolResult(input, text, details, durationMs);
1360
+ }
1361
+ if (toolName === "process") {
1362
+ return summarizeProcessToolResult(input, text, details, durationMs);
1363
+ }
1364
+ return summarizeGenericToolResult(toolName, input, text, durationMs);
1365
+ }
1366
+
1367
+ function withXtrmToolDetails(details: unknown, sourceText: string, toolName: string): unknown {
1368
+ const record = asRecord(details);
1369
+ return {
1370
+ ...(record ?? {}),
1371
+ xtrmOriginalText: sourceText,
1372
+ xtrmToolFrame: externalToolFrameKind(toolName),
1373
+ };
1374
+ }
1375
+
959
1376
  const XTRM_BUILTIN_TOOLS = new Set(["bash", "read", "edit", "write", "find", "grep", "ls"]);
960
1377
 
961
1378
  function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): void {
@@ -1005,6 +1422,7 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1005
1422
  pi.on("tool_result", async (event: ToolResultEvent, _ctx) => {
1006
1423
  if (event.isError) return undefined;
1007
1424
  if (XTRM_BUILTIN_TOOLS.has(event.toolName)) return undefined;
1425
+ if (!getPrefs().compactExternalToolResults) return undefined;
1008
1426
 
1009
1427
  const text = getTextContent({ content: event.content as Array<{ type: string; text?: string }> });
1010
1428
  const startedAt = toolCallStartTimes.get(event.toolCallId);
@@ -1018,13 +1436,17 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1018
1436
  ? (event.input as Record<string, unknown>)
1019
1437
  : {};
1020
1438
 
1021
- const compactText = SERENA_COMPACT_TOOLS.has(event.toolName)
1022
- ? summarizeSerenaToolResult(event.toolName, safeInput, sourceText, durationMs)
1023
- : summarizeGenericToolResult(event.toolName, safeInput, sourceText, durationMs);
1439
+ const compactText = summarizeExternalToolResult(
1440
+ event.toolName,
1441
+ safeInput,
1442
+ sourceText,
1443
+ event.details,
1444
+ durationMs,
1445
+ );
1024
1446
 
1025
1447
  return {
1026
1448
  content: [{ type: "text", text: formatHierarchyText(compactText) }],
1027
- details: event.details,
1449
+ details: withXtrmToolDetails(event.details, sourceText, event.toolName),
1028
1450
  };
1029
1451
  });
1030
1452
 
@@ -1043,7 +1465,7 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1043
1465
  renderResult(result, { expanded, isPartial }, theme) {
1044
1466
  const details = (result.details ?? {}) as DetailsWithXtrmMeta<BashToolDetails, Record<string, unknown>>;
1045
1467
  const meta = getXtrmMeta<BashToolDetails, Record<string, unknown>>(details);
1046
- const command = shortenCommand(String(meta?.args.command ?? ""));
1468
+ const command = shortenCommand(String(meta?.args.command ?? ""), 38);
1047
1469
  if (isPartial) {
1048
1470
  return toolRowText(theme, `${theme.fg("accent", "•")} ${theme.fg("toolTitle", "Running ")}${theme.fg("accent", command)}${theme.fg("toolTitle", " in bash")}`);
1049
1471
  }
@@ -1263,13 +1685,17 @@ function isXtrmTheme(name: string | undefined): boolean {
1263
1685
 
1264
1686
  export default function xtrmUiExtension(pi: ExtensionAPI): void {
1265
1687
  void installSilentHiddenThinkingPatch().catch(() => undefined);
1688
+ void installExternalToolFramePatch().catch(() => undefined);
1266
1689
 
1267
1690
  let prefs: XtrmUiPrefs = { ...DEFAULT_PREFS };
1268
1691
  let previousThemeName: string | null = null;
1269
1692
  const extensionThemeDir = join(__dirname, "../../themes/xtrm-ui");
1270
1693
 
1271
1694
  const getPrefs = () => prefs;
1272
- const setPrefs = (p: XtrmUiPrefs) => { prefs = p; };
1695
+ const setPrefs = (p: XtrmUiPrefs) => {
1696
+ prefs = p;
1697
+ setActiveExternalToolChrome(p.externalToolChrome);
1698
+ };
1273
1699
  const getThinkingLevel = () => formatThinking(pi.getThinkingLevel());
1274
1700
 
1275
1701
  registerXtrmUiTools(pi, getPrefs);
@@ -1285,7 +1711,7 @@ export default function xtrmUiExtension(pi: ExtensionAPI): void {
1285
1711
  }));
1286
1712
 
1287
1713
  pi.on("session_start", async (_event, ctx) => {
1288
- prefs = loadPrefs(ctx.sessionManager.getEntries() as Array<MaybeCustomEntry>);
1714
+ setPrefs(loadPrefs(ctx.sessionManager.getEntries() as Array<MaybeCustomEntry>));
1289
1715
  if (!previousThemeName && !isXtrmTheme(ctx.ui.theme.name)) {
1290
1716
  previousThemeName = ctx.ui.theme.name ?? null;
1291
1717
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaggerxtrm/pi-extensions",
3
- "version": "0.7.17",
3
+ "version": "0.7.20",
4
4
  "description": "Unified Pi extension entrypoint for xtrm-managed extensions",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -0,0 +1,3 @@
1
+ import registerExtension from "../../extensions/sp-terminal-overlay/index.ts";
2
+
3
+ export default registerExtension;
package/src/registry.ts CHANGED
@@ -12,6 +12,7 @@ import piSerenaCompactExtension from "./extensions/pi-serena-compact.ts";
12
12
  import qualityGatesExtension from "./extensions/quality-gates.ts";
13
13
  import serviceSkillsExtension from "./extensions/service-skills.ts";
14
14
  import sessionFlowExtension from "./extensions/session-flow.ts";
15
+ import spTerminalOverlayExtension from "./extensions/sp-terminal-overlay.ts";
15
16
  import xtrmLoaderExtension from "./extensions/xtrm-loader.ts";
16
17
  import xtrmUiExtension from "./extensions/xtrm-ui.ts";
17
18
 
@@ -33,6 +34,7 @@ export const managedPiExtensions: readonly ManagedPiExtension[] = [
33
34
  { id: "quality-gates", register: qualityGatesExtension },
34
35
  { id: "service-skills", register: serviceSkillsExtension },
35
36
  { id: "session-flow", register: sessionFlowExtension },
37
+ { id: "sp-terminal-overlay", register: spTerminalOverlayExtension },
36
38
  { id: "xtrm-loader", register: xtrmLoaderExtension },
37
39
  { id: "xtrm-ui", register: xtrmUiExtension },
38
40
  ];