@quandev104/pi-style 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +193 -0
  4. package/dist/extensions/pi-style.js +7897 -0
  5. package/dist/extensions/pi-style.js.map +1 -0
  6. package/extension-src/pi-style/app/command-service.ts +244 -0
  7. package/extension-src/pi-style/app/commands.ts +12 -0
  8. package/extension-src/pi-style/app/config-storage.ts +98 -0
  9. package/extension-src/pi-style/app/doctor.ts +53 -0
  10. package/extension-src/pi-style/app/index.ts +322 -0
  11. package/extension-src/pi-style/app/providers.ts +268 -0
  12. package/extension-src/pi-style/app/render-scheduler.ts +48 -0
  13. package/extension-src/pi-style/app/runtime.ts +404 -0
  14. package/extension-src/pi-style/app/snapshot.ts +15 -0
  15. package/extension-src/pi-style/domain/capabilities.ts +34 -0
  16. package/extension-src/pi-style/domain/config-authorization.ts +20 -0
  17. package/extension-src/pi-style/domain/config-diagnostics.ts +20 -0
  18. package/extension-src/pi-style/domain/config-diff.ts +30 -0
  19. package/extension-src/pi-style/domain/config-migrations.ts +52 -0
  20. package/extension-src/pi-style/domain/config-normalization.ts +567 -0
  21. package/extension-src/pi-style/domain/config-presets.ts +51 -0
  22. package/extension-src/pi-style/domain/config-types.ts +115 -0
  23. package/extension-src/pi-style/domain/providers.ts +37 -0
  24. package/extension-src/pi-style/domain/status-presets.ts +60 -0
  25. package/extension-src/pi-style/domain/status-renderer.ts +142 -0
  26. package/extension-src/pi-style/domain/status.ts +430 -0
  27. package/extension-src/pi-style/domain/theme.ts +232 -0
  28. package/extension-src/pi-style/features/.gitkeep +0 -0
  29. package/extension-src/pi-style/features/editor/index.ts +304 -0
  30. package/extension-src/pi-style/features/messages/boxed-block.ts +74 -0
  31. package/extension-src/pi-style/features/messages/index.ts +145 -0
  32. package/extension-src/pi-style/features/messages/special-blocks.ts +281 -0
  33. package/extension-src/pi-style/features/startup/index.ts +564 -0
  34. package/extension-src/pi-style/features/startup/logo.ts +151 -0
  35. package/extension-src/pi-style/features/status-line/index.ts +319 -0
  36. package/extension-src/pi-style/features/tools/boxed/bash.ts +347 -0
  37. package/extension-src/pi-style/features/tools/boxed/edit.ts +113 -0
  38. package/extension-src/pi-style/features/tools/boxed/fallback.ts +67 -0
  39. package/extension-src/pi-style/features/tools/boxed/find.ts +65 -0
  40. package/extension-src/pi-style/features/tools/boxed/grep.ts +108 -0
  41. package/extension-src/pi-style/features/tools/boxed/index.ts +64 -0
  42. package/extension-src/pi-style/features/tools/boxed/ls.ts +65 -0
  43. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +190 -0
  44. package/extension-src/pi-style/features/tools/boxed/read.ts +204 -0
  45. package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -0
  46. package/extension-src/pi-style/features/tools/boxed/shared.ts +157 -0
  47. package/extension-src/pi-style/features/tools/boxed/write.ts +89 -0
  48. package/extension-src/pi-style/features/tools/index.ts +480 -0
  49. package/extension-src/pi-style/pi/commands.ts +17 -0
  50. package/extension-src/pi-style/pi/compatibility-coordinator.ts +195 -0
  51. package/extension-src/pi-style/pi/compatibility-probe.ts +645 -0
  52. package/extension-src/pi-style/pi/compatibility-registry.ts +329 -0
  53. package/extension-src/pi-style/pi/config-host.ts +52 -0
  54. package/extension-src/pi-style/pi/config-session.ts +105 -0
  55. package/extension-src/pi-style/pi/index.ts +77 -0
  56. package/extension-src/pi-style/pi/operational-state.ts +29 -0
  57. package/extension-src/pi-style/pi/session-coordinator.ts +187 -0
  58. package/extension-src/pi-style/pi/session-usage.ts +62 -0
  59. package/extension-src/pi-style/pi/startup-resources.ts +70 -0
  60. package/extension-src/pi-style/shared/.gitkeep +0 -0
  61. package/extension-src/pi-style/shared/ansi.ts +386 -0
  62. package/extension-src/pi-style/shared/box.ts +805 -0
  63. package/extension-src/pi-style/shared/disposable-store.ts +28 -0
  64. package/extension-src/pi-style/shared/elapsed.ts +88 -0
  65. package/extension-src/pi-style/shared/render-budget.ts +367 -0
  66. package/extension-src/pi-style/shared/split-diff.ts +692 -0
  67. package/extension-src/pi-style/shared/theme-extras.ts +292 -0
  68. package/package.json +68 -0
  69. package/themes/.gitkeep +0 -0
@@ -0,0 +1,347 @@
1
+ // Boxed bash tool renderer
2
+ // (renderCall/renderResult only).
3
+
4
+ import { highlightCode } from "@earendil-works/pi-coding-agent";
5
+ import type { Component } from "@earendil-works/pi-tui";
6
+ import { stripAnsi } from "../../../shared/ansi.js";
7
+ import type { BoxTheme } from "../../../shared/box.js";
8
+ import {
9
+ boxedToolWidthKey,
10
+ formatBoxedFooter,
11
+ formatToolOutputLine,
12
+ getTextOutput,
13
+ renderBoxedToolCall,
14
+ renderBoxedToolResult,
15
+ replaceTabs,
16
+ } from "../../../shared/box.js";
17
+ import { safeTruncateToWidth, truncateAtCodePointBoundary } from "../../../shared/render-budget.js";
18
+ import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
19
+ import { type BoxedToolContext, type BoxedToolDefinition, noteExecutionStart } from "./shared.js";
20
+
21
+ const MAX_BASH_PREVIEW_LINES = 5;
22
+ const MAX_LINE_CHARS = 2000;
23
+ const ESC = "\x1b";
24
+ const BASH_TOOL_NOTICE_PATTERN = /^\[Showing (?:last|lines)\b.*\. Full output: .+\]$/;
25
+ const BG_ANSI_PATTERN = new RegExp(`${ESC}\\[4[0-9;]*m`, "g");
26
+ const SHELL_VAR_PATTERN = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/;
27
+ const SHELL_OP_PATTERN = /^(?:&&|\|\||>>|>&|\|&|[|&;()<>])$/;
28
+
29
+ function highlightBashFallback(line: string): string {
30
+ try {
31
+ const highlighted = highlightCode(line, "bash")[0] ?? line;
32
+ // Strip background colors to avoid clashing with badge/parens styling
33
+ return highlighted.replace(BG_ANSI_PATTERN, "");
34
+ } catch {
35
+ return line;
36
+ }
37
+ }
38
+
39
+ function normalizeShellWord(word: string): string {
40
+ return word.replace(/^(['"])(.*)\1$/, "$2");
41
+ }
42
+
43
+ function colorShellWord(theme: BoxTheme, word: string, commandExpected: boolean): string {
44
+ const normalized = normalizeShellWord(word);
45
+ if (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(normalized)) return theme.fg("syntaxVariable", word);
46
+ if (normalized.startsWith("-")) return theme.fg("syntaxKeyword", word);
47
+ if (normalized.includes("/") || /^\.{1,2}(?:\/|$)/.test(normalized)) return theme.fg("syntaxVariable", word);
48
+ if (SHELL_VAR_PATTERN.test(normalized)) return theme.fg("syntaxVariable", word);
49
+ return commandExpected ? theme.fg("syntaxFunction", word) : theme.fg("syntaxString", word);
50
+ }
51
+
52
+ function tokenizeShellLinePreservingText(line: string): string[] | undefined {
53
+ const tokens: string[] = [];
54
+ let current = "";
55
+ let quote: string | null = null;
56
+
57
+ for (let i = 0; i < line.length; i++) {
58
+ const char = line[i] ?? "";
59
+ const next = line[i + 1] ?? "";
60
+
61
+ if (quote) {
62
+ current += char;
63
+ if (char === "\\" && next) current += line[++i] ?? "";
64
+ else if (char === quote) quote = null;
65
+ continue;
66
+ }
67
+
68
+ if (char === "'" || char === '"') {
69
+ quote = char;
70
+ current += char;
71
+ continue;
72
+ }
73
+
74
+ if (/\s/.test(char)) {
75
+ if (current) tokens.push(current);
76
+ current = "";
77
+ tokens.push(char);
78
+ continue;
79
+ }
80
+
81
+ if (char === "#" && !current) {
82
+ if (current) tokens.push(current);
83
+ tokens.push(line.slice(i));
84
+ return tokens;
85
+ }
86
+
87
+ const two = `${char}${next}`;
88
+ if (SHELL_OP_PATTERN.test(two) || SHELL_OP_PATTERN.test(char)) {
89
+ if (current) tokens.push(current);
90
+ current = "";
91
+ if (SHELL_OP_PATTERN.test(two)) {
92
+ tokens.push(two);
93
+ i++;
94
+ } else {
95
+ tokens.push(char);
96
+ }
97
+ continue;
98
+ }
99
+
100
+ current += char;
101
+ }
102
+
103
+ if (quote) return undefined;
104
+ if (current) tokens.push(current);
105
+ return tokens;
106
+ }
107
+
108
+ function highlightBashLine(line: string, theme: BoxTheme): string {
109
+ const tokens = tokenizeShellLinePreservingText(line);
110
+ if (!tokens) return highlightBashFallback(line);
111
+ let commandExpected = true;
112
+ return tokens
113
+ .map((token) => {
114
+ if (/^\s+$/.test(token)) return token;
115
+ if (token.startsWith("#")) return theme.fg("syntaxComment", token);
116
+ if (SHELL_OP_PATTERN.test(token)) {
117
+ commandExpected = token === "|" || token === "||" || token === "&&" || token === ";" || token === "&";
118
+ return theme.fg("syntaxOperator", token);
119
+ }
120
+ const styled = colorShellWord(theme, token, commandExpected);
121
+ if (!/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(normalizeShellWord(token))) commandExpected = false;
122
+ return styled;
123
+ })
124
+ .join("");
125
+ }
126
+
127
+ function clampLineLength(line: string, max: number = MAX_LINE_CHARS): string {
128
+ if (line.length <= max) return line;
129
+ return `${truncateAtCodePointBoundary(line, max)}… (truncated)`;
130
+ }
131
+
132
+ function countNewlines(text: string, from: number, to: number): number {
133
+ let count = 0;
134
+ for (let i = from; i < to; i++) {
135
+ if (text.charCodeAt(i) === 10) count++;
136
+ }
137
+ return count;
138
+ }
139
+
140
+ function stripBashToolNoticeLines(text: string): string {
141
+ const filteredLines = text
142
+ .replace(/\r/g, "")
143
+ .split("\n")
144
+ .filter((line) => !BASH_TOOL_NOTICE_PATTERN.test(line.trim()));
145
+ return filteredLines
146
+ .join("\n")
147
+ .replace(/\n{3,}/g, "\n\n")
148
+ .trimEnd();
149
+ }
150
+
151
+ function bashWidthKey(rawCommand: string, timeout: unknown): string {
152
+ return boxedToolWidthKey("Bash", `${rawCommand}|${timeout ?? ""}`);
153
+ }
154
+
155
+ function renderBoxedBashCall(
156
+ theme: BoxTheme,
157
+ commandLines: string[],
158
+ context: BoxedToolContext,
159
+ widthKey: string,
160
+ ): Component {
161
+ const maxCommandLines = 5;
162
+ const shownCount = Math.min(commandLines.length, maxCommandLines + 1);
163
+ const detailLines: string[] = [];
164
+ for (let i = 0; i < shownCount; i++) {
165
+ const prefix = i === 0 ? theme.fg("dim", "$ ") : theme.fg("dim", "> ");
166
+ detailLines.push(`${prefix}${highlightBashLine(commandLines[i] ?? "", theme)}`);
167
+ }
168
+ if (commandLines.length > maxCommandLines + 1) {
169
+ detailLines.push(theme.fg("muted", `... ${commandLines.length - maxCommandLines - 1} more lines`));
170
+ }
171
+ return renderBoxedToolCall(theme, "Bash", detailLines, {
172
+ widthKey,
173
+ isError: Boolean(context.isError),
174
+ isPartial: Boolean(context.isPartial),
175
+ isPending: Boolean(context.isPartial),
176
+ });
177
+ }
178
+
179
+ function formatTimeout(context: BoxedToolContext): string {
180
+ const timeout = context?.args?.timeout ?? 300;
181
+ return `${timeout}s`;
182
+ }
183
+
184
+ function renderBoxedBashResult(
185
+ theme: BoxTheme,
186
+ inner: Component,
187
+ result: unknown,
188
+ context: BoxedToolContext,
189
+ ): Component {
190
+ const rawCommand = String(context?.args?.command ?? "...");
191
+ const referenceLines = rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`);
192
+ return renderBoxedToolResult(theme, inner, {
193
+ widthKey: bashWidthKey(rawCommand, context?.args?.timeout),
194
+ referenceLines,
195
+ footerLines: [formatBoxedFooter(theme, result as never, [`⏹ ${formatTimeout(context)}`], getElapsed(context))],
196
+ isError: context.isError,
197
+ isPartial: Boolean(context.isPartial),
198
+ });
199
+ }
200
+
201
+ function getElapsed(context: BoxedToolContext): number | undefined {
202
+ return getStateElapsedMs(context.state);
203
+ }
204
+
205
+ function createBashResultPreview(
206
+ theme: BoxTheme,
207
+ text: string,
208
+ options: { expanded: boolean },
209
+ color: "toolOutput" | "error",
210
+ extraLinesBefore: number = 0,
211
+ ): Component {
212
+ let cacheKey = "";
213
+ let cacheLines: string[] | null = null;
214
+
215
+ return {
216
+ invalidate() {
217
+ cacheKey = "";
218
+ cacheLines = null;
219
+ },
220
+ render(width: number): string[] {
221
+ const bodyWidth = Math.max(1, width);
222
+ const cfg = getToolsRenderConfig();
223
+ const expanded = Boolean(options.expanded);
224
+ const cacheId = `${bodyWidth}|${expanded ? 1 : 0}|${cfg.maxExpandedLines}|${cfg.dimOutput ? 1 : 0}`;
225
+ if (cacheLines && cacheKey === cacheId) return cacheLines;
226
+
227
+ if (!expanded) {
228
+ // Collapsed: only process the tail of the output
229
+ const needed = MAX_BASH_PREVIEW_LINES;
230
+ let totalNewlines = 0;
231
+ let scanFrom = 0; // default: take full text if fewer than needed newlines
232
+ for (let i = text.length - 1; i >= 0; i--) {
233
+ if (text.charCodeAt(i) === 10) {
234
+ totalNewlines++;
235
+ if (totalNewlines === needed) {
236
+ scanFrom = i + 1;
237
+ break;
238
+ }
239
+ }
240
+ }
241
+
242
+ if (text.length === 0) {
243
+ cacheKey = cacheId;
244
+ cacheLines = [];
245
+ return cacheLines;
246
+ }
247
+
248
+ const tail = replaceTabs(text.slice(scanFrom)).replace(/\r/g, "");
249
+ const shownLines = tail ? tail.split("\n").map((l) => clampLineLength(l)) : [];
250
+
251
+ if (shownLines.length === 0) {
252
+ cacheKey = cacheId;
253
+ cacheLines = [];
254
+ return cacheLines;
255
+ }
256
+
257
+ const truncatedShown = shownLines.map((line) => {
258
+ const truncated = safeTruncateToWidth(line, bodyWidth, "…");
259
+ if (color === "error") return formatToolOutputLine(theme, truncated, "error");
260
+ return cfg.dimOutput
261
+ ? formatToolOutputLine(theme, truncated)
262
+ : formatToolOutputLine(theme, truncated, "text");
263
+ });
264
+
265
+ // Count remaining lines (lines before scanFrom)
266
+ const remaining = extraLinesBefore + (scanFrom > 0 ? countNewlines(text, 0, scanFrom) : 0);
267
+
268
+ if (remaining <= 0) {
269
+ cacheKey = cacheId;
270
+ cacheLines = truncatedShown;
271
+ return cacheLines;
272
+ }
273
+
274
+ const hint = safeTruncateToWidth(`... ${remaining} more lines, press Ctrl+o to expand`, bodyWidth, "…");
275
+ cacheKey = cacheId;
276
+ cacheLines = [...truncatedShown, "", theme.fg("muted", hint)];
277
+ return cacheLines;
278
+ }
279
+
280
+ // Expanded: process all lines
281
+ const normalized = replaceTabs(text);
282
+ const logicalLines = normalized.split("\n").map((l) => clampLineLength(l));
283
+ const hasOutput = !(logicalLines.length === 1 && logicalLines[0] === "");
284
+
285
+ if (!hasOutput) {
286
+ cacheKey = cacheId;
287
+ cacheLines = [];
288
+ return cacheLines;
289
+ }
290
+
291
+ const truncatedLines = logicalLines.map((line) => safeTruncateToWidth(line, bodyWidth, "…"));
292
+ const expandedLines = truncatedLines.length === 1 && truncatedLines[0] === "" ? [] : truncatedLines;
293
+ const applyColor = (l: string) =>
294
+ color === "error"
295
+ ? formatToolOutputLine(theme, l, "error")
296
+ : cfg.dimOutput
297
+ ? formatToolOutputLine(theme, l)
298
+ : formatToolOutputLine(theme, l, "text");
299
+ if (cfg.maxExpandedLines > 0 && expandedLines.length > cfg.maxExpandedLines) {
300
+ const truncated = expandedLines.slice(-cfg.maxExpandedLines).map(applyColor);
301
+ const remaining = expandedLines.length - cfg.maxExpandedLines;
302
+ truncated.unshift(theme.fg("dim", `… ${remaining} earlier lines`));
303
+ cacheKey = cacheId;
304
+ cacheLines = truncated;
305
+ return cacheLines;
306
+ }
307
+
308
+ cacheKey = cacheId;
309
+ cacheLines = expandedLines.map(applyColor);
310
+ return cacheLines;
311
+ },
312
+ };
313
+ }
314
+
315
+ export const bashTool: BoxedToolDefinition = {
316
+ call(args, theme, context) {
317
+ noteExecutionStart(context);
318
+ const rawCommand = String(args?.command ?? "...");
319
+ return renderBoxedBashCall(theme, rawCommand.split("\n"), context, bashWidthKey(rawCommand, args?.timeout));
320
+ },
321
+ result(result, options, theme, context) {
322
+ const raw = getTextOutput(result);
323
+ const outputColor = context.isError ? "error" : "toolOutput";
324
+
325
+ if (!options.expanded) {
326
+ const scanLines = MAX_BASH_PREVIEW_LINES + 10;
327
+ let nlCount = 0;
328
+ let tailStart = 0;
329
+ for (let i = raw.length - 1; i >= 0; i--) {
330
+ if (raw.charCodeAt(i) === 10) {
331
+ nlCount++;
332
+ if (nlCount >= scanLines) {
333
+ tailStart = i + 1;
334
+ break;
335
+ }
336
+ }
337
+ }
338
+ const tail = stripBashToolNoticeLines(stripAnsi(raw.slice(tailStart)));
339
+ const totalLinesBefore = tailStart > 0 ? countNewlines(raw, 0, tailStart) : 0;
340
+ const inner = createBashResultPreview(theme, tail, options, outputColor, totalLinesBefore);
341
+ return renderBoxedBashResult(theme, inner, result, context);
342
+ }
343
+ const output = stripBashToolNoticeLines(stripAnsi(raw));
344
+ const inner = createBashResultPreview(theme, output, options, outputColor, 0);
345
+ return renderBoxedBashResult(theme, inner, result, context);
346
+ },
347
+ };
@@ -0,0 +1,113 @@
1
+ // Boxed edit tool renderer
2
+ // (renderCall/renderResult only; no edit-core re-registration).
3
+
4
+ import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
5
+ import { Text } from "@earendil-works/pi-tui";
6
+ import { stripAnsi } from "../../../shared/ansi.js";
7
+ import { getTextOutput, renderBoxedToolCall, renderBoxedToolResult } from "../../../shared/box.js";
8
+ import {
9
+ buildSplitRows,
10
+ countDiffStats,
11
+ extractEditedPath,
12
+ firstText,
13
+ renderDiffMeter,
14
+ SplitDiffComponent,
15
+ } from "../../../shared/split-diff.js";
16
+ import {
17
+ type BoxedToolContext,
18
+ type BoxedToolDefinition,
19
+ displayPath,
20
+ noteExecutionStart,
21
+ resultFooterLines,
22
+ } from "./shared.js";
23
+
24
+ const MAX_HIGHLIGHT_DIFF_CHARS = 12000;
25
+ const MAX_HIGHLIGHT_DIFF_ROWS = 120;
26
+
27
+ type EditResultDetails = { diff?: string; path?: string } | undefined;
28
+
29
+ export const editTool: BoxedToolDefinition = {
30
+ call(args, theme, context) {
31
+ noteExecutionStart(context);
32
+ const detail = displayPath(String(args?.path ?? args?.file_path ?? ""), context);
33
+ return renderBoxedToolCall(theme, "Edit", [`${theme.fg("dim", "Path: ")}${detail}`], {
34
+ isError: Boolean(context.isError),
35
+ isPartial: Boolean(context.isPartial),
36
+ isPending: Boolean(context.isPartial),
37
+ });
38
+ },
39
+ result(result, options, theme, context) {
40
+ // Handle partial/streaming state
41
+ if (options.isPartial) {
42
+ return renderBoxedToolResult(theme, () => [`${theme.fg("dim", "↳")} ${theme.fg("muted", "Applying edit...")}`], {
43
+ isPartial: true,
44
+ });
45
+ }
46
+
47
+ // Handle errors
48
+ if (context.isError) {
49
+ const output = getTextOutput(result);
50
+ return renderBoxedToolResult(theme, () => [theme.fg("error", stripAnsi(output).trim() || "Error")], {
51
+ footerLines: resultFooterLines(theme, result, context),
52
+ isError: true,
53
+ });
54
+ }
55
+
56
+ // Extract diff from result details
57
+ const details = result.details as EditResultDetails;
58
+ const diff = details?.diff as string | undefined;
59
+
60
+ if (!diff) {
61
+ const output = stripAnsi(getTextOutput(result)).trim();
62
+ const fallback = `↳ ${output || "Edit applied"}`;
63
+ return renderBoxedToolResult(theme, () => [theme.fg("dim", fallback)], {
64
+ footerLines: resultFooterLines(theme, result, context),
65
+ });
66
+ }
67
+
68
+ // Resolve language for syntax highlighting
69
+ const message = firstText(result.content as Array<{ type: string; text?: string }>);
70
+ const argPath = String(context?.args?.path ?? context?.args?.file_path ?? "");
71
+ const sourcePath = details?.path ?? (argPath || extractEditedPath(message));
72
+ const language = sourcePath ? getLanguageFromPath(sourcePath) : undefined;
73
+
74
+ // Build split-diff rows
75
+ const rows = buildSplitRows(diff);
76
+ const expanded = options.expanded;
77
+ const shouldHighlight =
78
+ Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
79
+
80
+ // Build summary header with diff stats and meter
81
+ const { additions, removals } = countDiffStats(diff);
82
+ const meter = renderDiffMeter(theme, additions, removals);
83
+ const summary =
84
+ `${theme.fg("dim", "↳")} ${theme.fg("muted", "diff")}` +
85
+ ` ${theme.fg("toolDiffAdded", `+${additions}`)}` +
86
+ ` ${theme.fg("toolDiffRemoved", `-${removals}`)}` +
87
+ ` ${theme.fg("muted", "split")}` +
88
+ (meter ? ` ${meter}` : "");
89
+
90
+ // Render split-diff with syntax colors for small outputs.
91
+ const maxRows = expanded ? 160 : 36;
92
+ const split = new SplitDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
93
+
94
+ return renderBoxedToolResult(
95
+ theme,
96
+ {
97
+ render(width: number): string[] {
98
+ const safeWidth = Math.max(20, width);
99
+ const headerLines = new Text(summary, 0, 0).render(safeWidth);
100
+ return [...headerLines, ...split.render(safeWidth)];
101
+ },
102
+ invalidate(): void {
103
+ split.invalidate();
104
+ },
105
+ },
106
+ {
107
+ footerLines: resultFooterLines(theme, result, context),
108
+ },
109
+ );
110
+ },
111
+ };
112
+
113
+ export type { BoxedToolContext };
@@ -0,0 +1,67 @@
1
+ // Boxed fallback for tools without a dedicated renderer.
2
+ // (component side only; no ToolExecutionComponent prototype patching).
3
+
4
+ import type { Component } from "@earendil-works/pi-tui";
5
+ import type { BoxTheme, MetricResultLike } from "../../../shared/box.js";
6
+ import {
7
+ formatBoxedFooter,
8
+ formatToolName,
9
+ formatToolParamLines,
10
+ getTextOutput,
11
+ renderBoxedToolCall,
12
+ renderBoxedToolResult,
13
+ renderLines,
14
+ } from "../../../shared/box.js";
15
+ import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
16
+ import { type BoxedToolContext, noteExecutionStart } from "./shared.js";
17
+
18
+ const MAX_FALLBACK_PREVIEW_LINES = 10;
19
+
20
+ export function renderFallbackCall(
21
+ toolName: unknown,
22
+ args: Record<string, unknown>,
23
+ theme: BoxTheme,
24
+ context: BoxedToolContext,
25
+ ): Component {
26
+ noteExecutionStart(context);
27
+ return renderBoxedToolCall(theme, formatToolName(String(toolName ?? "Tool")), formatToolParamLines(args, theme), {
28
+ isError: Boolean(context.isError),
29
+ isPartial: Boolean(context.isPartial),
30
+ isPending: Boolean(context.isPartial),
31
+ });
32
+ }
33
+
34
+ export function renderFallbackResult(
35
+ _toolName: unknown,
36
+ result: MetricResultLike,
37
+ options: { expanded: boolean; isPartial: boolean },
38
+ theme: BoxTheme,
39
+ context: BoxedToolContext,
40
+ ): Component {
41
+ const isError = Boolean(context.isError);
42
+ const expanded = Boolean(options.expanded);
43
+ const maxLines = expanded ? getToolsRenderConfig().maxExpandedLines : MAX_FALLBACK_PREVIEW_LINES;
44
+ const output = getTextOutput(result);
45
+ const elapsedMs = getStateElapsedMs(context.state);
46
+
47
+ return renderBoxedToolResult(
48
+ theme,
49
+ (contentWidth) => {
50
+ const body = renderLines(theme, output, options, {
51
+ maxLines,
52
+ color: isError ? "error" : "toolOutput",
53
+ width: contentWidth,
54
+ });
55
+ return body ? body.split("\n") : [];
56
+ },
57
+ {
58
+ footerLines: [formatBoxedFooter(theme, result, [], elapsedMs)],
59
+ renderLineBudget: maxLines,
60
+ isError,
61
+ isPartial: Boolean(options.isPartial),
62
+ },
63
+ );
64
+ }
65
+
66
+ // Re-exported for callers that need the tool-name label normalization.
67
+ export { formatToolName };
@@ -0,0 +1,65 @@
1
+ // Boxed find tool renderer.
2
+
3
+ import { stripAnsi } from "../../../shared/ansi.js";
4
+ import {
5
+ boxedToolWidthKey,
6
+ countLines,
7
+ getTextOutput,
8
+ renderBoxedToolResult,
9
+ shortenPath,
10
+ stripTrailingNotice,
11
+ } from "../../../shared/box.js";
12
+ import {
13
+ type BoxedToolDefinition,
14
+ clearFooterState,
15
+ compactCall,
16
+ compactFooterWithState,
17
+ noteExecutionStart,
18
+ resultFooterLines,
19
+ truncationOutputLines,
20
+ } from "./shared.js";
21
+
22
+ function queryDetail(pattern: string, rawPath: string): string {
23
+ const displayPath = String(rawPath ?? ".");
24
+ const path = displayPath === "." || displayPath === "" ? "current directory" : shortenPath(displayPath);
25
+ return pattern ? `${pattern} in ${path}` : path;
26
+ }
27
+
28
+ export const findTool: BoxedToolDefinition = {
29
+ call(args, theme, context) {
30
+ noteExecutionStart(context);
31
+ const detail = queryDetail(String(args?.pattern ?? ""), String(args?.path ?? "."));
32
+ return compactCall(theme, "Find", `${theme.fg("dim", "Query: ")}${detail}`, {
33
+ detailKey: detail,
34
+ context,
35
+ });
36
+ },
37
+ result(result, options, theme, context) {
38
+ clearFooterState(context);
39
+ const output = stripAnsi(getTextOutput(result)).trimEnd();
40
+ const detail = queryDetail(String(context?.args?.pattern ?? ""), String(context?.args?.path ?? "."));
41
+ const widthKey = boxedToolWidthKey("Find", detail);
42
+
43
+ if (context.isError) {
44
+ return renderBoxedToolResult(theme, () => [theme.fg("error", output || "Error")], {
45
+ widthKey,
46
+ footerLines: resultFooterLines(theme, result, context),
47
+ isError: true,
48
+ });
49
+ }
50
+
51
+ if (!options.expanded) return compactFooterWithState(theme, result, context);
52
+
53
+ let fileCount = 0;
54
+ if (output && output !== "No files found matching pattern") {
55
+ const stripped = stripTrailingNotice(output);
56
+ fileCount = truncationOutputLines(result) ?? countLines(stripped);
57
+ }
58
+
59
+ const summary = `↳ Found ${fileCount} ${fileCount === 1 ? "file" : "files"}.`;
60
+ return renderBoxedToolResult(theme, () => [theme.fg("dim", summary)], {
61
+ widthKey,
62
+ footerLines: resultFooterLines(theme, result, context),
63
+ });
64
+ },
65
+ };
@@ -0,0 +1,108 @@
1
+ // Boxed grep/search tool renderer.
2
+
3
+ import { stripAnsi } from "../../../shared/ansi.js";
4
+ import {
5
+ boxedToolWidthKey,
6
+ countLines,
7
+ getTextOutput,
8
+ renderBoxedToolResult,
9
+ renderLines,
10
+ shortenPath,
11
+ stripTrailingNotice,
12
+ } from "../../../shared/box.js";
13
+ import { getToolsRenderConfig } from "./session-config.js";
14
+ import {
15
+ type BoxedToolDefinition,
16
+ clearFooterState,
17
+ compactCall,
18
+ compactFooterWithState,
19
+ matchLimitReached,
20
+ noteExecutionStart,
21
+ resultFooterLines,
22
+ } from "./shared.js";
23
+
24
+ const MAX_GREP_PREVIEW_LINES = 10;
25
+
26
+ function queryDetail(pattern: string, rawPath: string): string {
27
+ const displayPath = String(rawPath ?? ".");
28
+ const path = displayPath === "." || displayPath === "" ? "current directory" : shortenPath(displayPath);
29
+ return pattern ? `/${pattern}/ in ${path}` : path;
30
+ }
31
+
32
+ export const grepTool: BoxedToolDefinition = {
33
+ call(args, theme, context) {
34
+ noteExecutionStart(context);
35
+ const detail = queryDetail(String(args?.pattern ?? ""), String(args?.path ?? "."));
36
+ return compactCall(theme, "Search", `${theme.fg("dim", "Query: ")}${detail}`, {
37
+ detailKey: detail,
38
+ context,
39
+ });
40
+ },
41
+ result(result, options, theme, context) {
42
+ clearFooterState(context);
43
+ const output = stripAnsi(getTextOutput(result)).trimEnd();
44
+ const stripped = stripTrailingNotice(output);
45
+ const detail = queryDetail(String(context?.args?.pattern ?? ""), String(context?.args?.path ?? "."));
46
+ const widthKey = boxedToolWidthKey("Search", detail);
47
+ const cfg = getToolsRenderConfig();
48
+
49
+ if (context.isError) {
50
+ return renderBoxedToolResult(
51
+ theme,
52
+ (width) => {
53
+ const body = renderLines(theme, stripped || output || "Error", options, {
54
+ maxLines: MAX_GREP_PREVIEW_LINES,
55
+ color: "error",
56
+ width,
57
+ });
58
+ return body ? body.split("\n") : [];
59
+ },
60
+ {
61
+ widthKey,
62
+ footerLines: resultFooterLines(theme, result, context),
63
+ isError: true,
64
+ },
65
+ );
66
+ }
67
+
68
+ if (!options.expanded) return compactFooterWithState(theme, result, context);
69
+
70
+ let matchCount = 0;
71
+ if (stripped && stripped !== "No matches found") {
72
+ const lines = stripped.split("\n");
73
+ matchCount = lines.filter((line) => /:\d+:/.test(line)).length;
74
+
75
+ if (matchCount === 0) {
76
+ matchCount = countLines(stripped);
77
+ }
78
+
79
+ if (matchLimitReached(result) !== undefined) {
80
+ matchCount = Math.max(matchCount, matchLimitReached(result) as number);
81
+ }
82
+ }
83
+
84
+ const summary = theme.fg("dim", `↳ Found ${matchCount} ${matchCount === 1 ? "match" : "matches"}.`);
85
+ if (!stripped || stripped === "No matches found") {
86
+ return renderBoxedToolResult(theme, () => [summary], {
87
+ widthKey,
88
+ footerLines: resultFooterLines(theme, result, context),
89
+ });
90
+ }
91
+
92
+ return renderBoxedToolResult(
93
+ theme,
94
+ (width) => {
95
+ const body = renderLines(theme, stripped, options, {
96
+ maxLines: options.expanded ? cfg.maxExpandedLines : MAX_GREP_PREVIEW_LINES,
97
+ color: "toolOutput",
98
+ width,
99
+ });
100
+ return [summary, ...(body ? body.split("\n") : [])];
101
+ },
102
+ {
103
+ widthKey,
104
+ footerLines: resultFooterLines(theme, result, context),
105
+ },
106
+ );
107
+ },
108
+ };