@heyhuynhgiabuu/pi-pretty 0.6.30 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +21 -0
  3. package/dist/claude-style.d.ts +45 -0
  4. package/dist/claude-style.d.ts.map +1 -0
  5. package/dist/claude-style.js +582 -0
  6. package/dist/claude-style.js.map +1 -0
  7. package/dist/config.d.ts +2 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +2 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +22 -12
  13. package/dist/index.js.map +1 -1
  14. package/dist/prompt-editor.d.ts +4 -1
  15. package/dist/prompt-editor.d.ts.map +1 -1
  16. package/dist/prompt-editor.js +27 -12
  17. package/dist/prompt-editor.js.map +1 -1
  18. package/dist/render.d.ts +10 -7
  19. package/dist/render.d.ts.map +1 -1
  20. package/dist/render.js +41 -25
  21. package/dist/render.js.map +1 -1
  22. package/dist/tools/bash.d.ts.map +1 -1
  23. package/dist/tools/bash.js +17 -13
  24. package/dist/tools/bash.js.map +1 -1
  25. package/dist/tools/find.d.ts.map +1 -1
  26. package/dist/tools/find.js +15 -17
  27. package/dist/tools/find.js.map +1 -1
  28. package/dist/tools/grep.d.ts.map +1 -1
  29. package/dist/tools/grep.js +10 -8
  30. package/dist/tools/grep.js.map +1 -1
  31. package/dist/tools/ls.d.ts.map +1 -1
  32. package/dist/tools/ls.js +11 -9
  33. package/dist/tools/ls.js.map +1 -1
  34. package/dist/tools/read.d.ts.map +1 -1
  35. package/dist/tools/read.js +25 -21
  36. package/dist/tools/read.js.map +1 -1
  37. package/dist/types.d.ts +9 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/working-indicator.d.ts +2 -0
  40. package/dist/working-indicator.d.ts.map +1 -1
  41. package/dist/working-indicator.js +20 -8
  42. package/dist/working-indicator.js.map +1 -1
  43. package/package.json +5 -5
  44. package/src/claude-style.ts +650 -0
  45. package/src/config.ts +3 -0
  46. package/src/index.ts +35 -15
  47. package/src/prompt-editor.ts +26 -11
  48. package/src/render.ts +54 -24
  49. package/src/tools/bash.ts +28 -23
  50. package/src/tools/find.ts +20 -16
  51. package/src/tools/grep.ts +10 -6
  52. package/src/tools/ls.ts +17 -8
  53. package/src/tools/read.ts +29 -30
  54. package/src/types.ts +9 -0
  55. package/src/working-indicator.ts +20 -8
@@ -0,0 +1,650 @@
1
+ /**
2
+ * pi-pretty: opt-in "claudecode" tool style (config `toolStyle: "claudecode"`).
3
+ *
4
+ * ✓ Read (src/a.ts) collapsed call row: status icon (spinner while running), bold name
5
+ * └ 42 lines · ◷ 0.02s collapsed result row hanging from a connector
6
+ * ✓ Done (3 tool calls) consecutive tool calls fold into one group row
7
+ * ├─ • Bash(ls)
8
+ * └─ • Read(a.ts)
9
+ *
10
+ * Collapsed rows replace the pretty output; Ctrl+O (expanded) keeps pi-pretty's
11
+ * full rendering. Grouping is a pure render-time projection of the chat children.
12
+ * Modeled on the look of sting8k/pi-droid-styling's `claudecode` preset.
13
+ */
14
+
15
+ import { highlightCode, keyText } from "@earendil-works/pi-coding-agent";
16
+ import { Container, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
+ import { ELAPSED_KEY, shortPath } from "./helpers.js";
18
+ import type { RenderCtxLike, ThemeLike } from "./types.js";
19
+
20
+ type AnyFn = (...args: any[]) => any;
21
+ type AnyComponent = { render(width: number): string[]; invalidate?(): void; [key: string]: any };
22
+
23
+ const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
24
+ // Kitty (`ESC _G`) and iTerm2 (`ESC ]1337;File=`) inline-image payload lines.
25
+ const IMAGE_LINE_RE = new RegExp(`${String.fromCharCode(27)}(?:_G|\\]1337;File=)`);
26
+ const isImageLine = (line: string): boolean => IMAGE_LINE_RE.test(line);
27
+ // Leading padding after any SGR prefix (thought rows start with `ESC[3m ` etc.).
28
+ const LEADING_SPACE_RE = new RegExp(`^((?:${String.fromCharCode(27)}\\[[0-9;]*m)*) +`);
29
+ const stripAnsi = (text: string): string => text.replace(ANSI_RE, "");
30
+
31
+ const PLAIN_THEME: ThemeLike = { fg: (_color, text) => text, bold: (text) => text };
32
+
33
+ // Theme for group rows, which are built outside any tool's render context.
34
+ let groupTheme: ThemeLike = PLAIN_THEME;
35
+
36
+ export function setClaudeStyleTheme(theme: unknown): void {
37
+ groupTheme = theme as ThemeLike;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Rows
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
45
+ const SPINNER_INTERVAL_MS = 80;
46
+ /** Time-based so every host redraw (the working indicator already ticks while streaming) advances it. */
47
+ const spinnerFrame = (): string => SPINNER_FRAMES[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length];
48
+
49
+ const ICON_SUCCESS = "✓";
50
+ const ICON_ERROR = "✗";
51
+ const ICON_PENDING = "·";
52
+
53
+ type ToolState = "pending" | "running" | "success" | "error";
54
+
55
+ function stateIcon(theme: ThemeLike, state: ToolState): string {
56
+ switch (state) {
57
+ case "pending":
58
+ return theme.fg("dim", ICON_PENDING);
59
+ case "running":
60
+ return theme.fg("accent", spinnerFrame());
61
+ case "error":
62
+ return theme.fg("error", ICON_ERROR);
63
+ default:
64
+ return theme.fg("success", ICON_SUCCESS);
65
+ }
66
+ }
67
+
68
+ /** Dim ` (ctrl+o to expand)` / ` (ctrl+o to collapse)` using the user's binding; empty when unbound. */
69
+ function toggleHint(theme: ThemeLike, verb: "expand" | "collapse"): string {
70
+ const key = keyText("app.tools.expand");
71
+ return key ? ` ${theme.fg("dim", `(${key} to ${verb})`)}` : "";
72
+ }
73
+
74
+ function indentOf(ctx: RenderCtxLike): string {
75
+ const pad = ctx.outputPad;
76
+ return " ".repeat(typeof pad === "number" && pad >= 0 ? Math.floor(pad) : 1);
77
+ }
78
+
79
+ function oneLine(text: string): string {
80
+ return text.replace(/\s*\r?\n\s*/g, " ").trim();
81
+ }
82
+
83
+ function firstString(args: Record<string, unknown>, keys: string[]): string {
84
+ for (const key of keys) {
85
+ const value = args[key];
86
+ if (typeof value === "string" && value) return value;
87
+ }
88
+ return "";
89
+ }
90
+
91
+ function callDetail(toolName: string, args: Record<string, unknown>): string {
92
+ const home = process.env.HOME ?? "";
93
+ const cwd = process.cwd();
94
+ const path = (value: string): string => shortPath(cwd, home, value);
95
+ switch (toolName) {
96
+ case "read": {
97
+ const offset = typeof args.offset === "number" ? `:${args.offset}` : "";
98
+ return `${path(firstString(args, ["path", "file_path"]))}${offset}`;
99
+ }
100
+ case "bash":
101
+ return oneLine(firstString(args, ["command"]));
102
+ case "grep": {
103
+ const where = firstString(args, ["path", "glob"]);
104
+ return [firstString(args, ["pattern"]), where ? path(where) : ""].filter(Boolean).join(", ");
105
+ }
106
+ case "find": {
107
+ const where = firstString(args, ["path"]);
108
+ return [firstString(args, ["pattern"]), where ? path(where) : ""].filter(Boolean).join(", ");
109
+ }
110
+ case "ls":
111
+ return path(firstString(args, ["path"]) || ".");
112
+ case "write":
113
+ case "edit":
114
+ return path(firstString(args, ["path", "file_path"]));
115
+ case "codemode":
116
+ return oneLine(firstScriptLine(firstString(args, ["code"])));
117
+ case "recall": {
118
+ const query = firstString(args, ["query"]);
119
+ const expand = Array.isArray(args.expand) ? args.expand.map((index) => `#${index}`).join(" ") : "";
120
+ const page = typeof args.page === "number" && args.page > 1 ? `page ${args.page}` : "";
121
+ return [
122
+ expand ? `expand ${expand}` : query ? `"${oneLine(query)}"` : "browse",
123
+ args.scope === "all" ? "all sessions" : "",
124
+ page,
125
+ ]
126
+ .filter(Boolean)
127
+ .join(", ");
128
+ }
129
+ case "apply_patch": {
130
+ const paths = (Array.isArray(args.changes) ? args.changes : [])
131
+ .map((change: { path?: unknown }) => (typeof change?.path === "string" ? path(change.path) : ""))
132
+ .filter(Boolean);
133
+ return paths.length > 1 ? `${paths[0]}, +${paths.length - 1} more` : (paths[0] ?? "");
134
+ }
135
+ default: {
136
+ const first = Object.values(args).find((value) => typeof value === "string" && value) as string | undefined;
137
+ return first ? oneLine(first) : "";
138
+ }
139
+ }
140
+ }
141
+
142
+ /** First line of a codemode script that is not its `// @options:` line. */
143
+ function firstScriptLine(code: string): string {
144
+ return code.split("\n").find((line) => line.trim() !== "" && !/^\s*\/\/\s*@options:/.test(line)) ?? "";
145
+ }
146
+
147
+ /** The header lines codemode prepends to its output, which say nothing about what happened. */
148
+ const CODEMODE_HEADER_LINE = /^(Script (completed|failed)|Wall time [\d.]+ seconds|Output:|Script error:)$/;
149
+
150
+ function resultText(result: any): string {
151
+ return ((result?.content ?? []) as Array<{ type?: string; text?: string }>)
152
+ .filter((block) => block.type === "text")
153
+ .map((block) => block.text ?? "")
154
+ .join("\n");
155
+ }
156
+
157
+ function countLines(text: string, skip?: RegExp): number {
158
+ return text.split("\n").filter((line) => line.trim() !== "" && !(skip && skip.test(line.trim()))).length;
159
+ }
160
+
161
+ function plural(count: number, noun: string, pluralNoun = `${noun}s`): string {
162
+ return `${count} ${count === 1 ? noun : pluralNoun}`;
163
+ }
164
+
165
+ /** `2 files`, or `empty` when there is nothing to count. */
166
+ function countOrEmpty(count: number, empty: string, noun: string, pluralNoun?: string): string {
167
+ return count === 0 ? empty : plural(count, noun, pluralNoun);
168
+ }
169
+
170
+ /** `+3 -1` with additions in the success color and removals in the error color. */
171
+ function diffCounts(added: number, removed: number, theme: ThemeLike): string {
172
+ return [theme.fg("success", `+${added}`), theme.fg("error", `-${removed}`)].join(" ");
173
+ }
174
+
175
+ function countPatchLines(patch: string): { added: number; removed: number } {
176
+ let added = 0;
177
+ let removed = 0;
178
+ for (const line of patch.split("\n")) {
179
+ if (line.startsWith("+") && !line.startsWith("+++")) added++;
180
+ else if (line.startsWith("-") && !line.startsWith("---")) removed++;
181
+ }
182
+ return { added, removed };
183
+ }
184
+
185
+ /** pi-diff's apply_patch: per-file `diff` hunks (updates), full content (adds) or old content (deletes). */
186
+ function summarizeApplyPatch(result: any, theme: ThemeLike): string {
187
+ const applied: Array<Record<string, any>> = Array.isArray(result?.details?.result?.applied)
188
+ ? result.details.result.applied
189
+ : [];
190
+ if (applied.length === 0) return theme.fg("muted", "applied");
191
+ let added = 0;
192
+ let removed = 0;
193
+ for (const change of applied) {
194
+ if (typeof change.diff === "string") {
195
+ const counts = countPatchLines(change.diff);
196
+ added += counts.added;
197
+ removed += counts.removed;
198
+ } else if (change.action === "add" && typeof change.newContent === "string") {
199
+ added += countLines(change.newContent);
200
+ } else if (change.action === "delete" && typeof change.oldContent === "string") {
201
+ removed += countLines(change.oldContent);
202
+ }
203
+ }
204
+ return [diffCounts(added, removed, theme), theme.fg("muted", plural(applied.length, "file"))].join(
205
+ theme.fg("dim", " · "),
206
+ );
207
+ }
208
+
209
+ /** Written by pi-diff (`diff`/`new`/`noChange`, `editInfo`) or Pi's builtin edit (`patch`). */
210
+ function summarizeFileChange(toolName: string, result: any, theme: ThemeLike): string {
211
+ const details = (result?.details ?? {}) as Record<string, any>;
212
+ const note = (text: string): string => theme.fg("muted", text);
213
+ if (details._type === "noChange") return note("no changes");
214
+ if (details._type === "new")
215
+ return [note("new file"), diffCounts(Number(details.lines) || 0, 0, theme)].join(theme.fg("dim", " · "));
216
+ let counts: { added: number; removed: number } | undefined;
217
+ if (typeof details.linesAdded === "number" && typeof details.linesRemoved === "number") {
218
+ counts = { added: details.linesAdded, removed: details.linesRemoved };
219
+ } else if (typeof details.diff?.added === "number" && typeof details.diff?.removed === "number") {
220
+ counts = { added: details.diff.added, removed: details.diff.removed };
221
+ } else if (typeof details.patch === "string") {
222
+ counts = countPatchLines(details.patch);
223
+ }
224
+ if (!counts) return note(toolName === "write" ? "written" : "edited");
225
+ const parts = [diffCounts(counts.added, counts.removed, theme)];
226
+ if (typeof details.editCount === "number" && details.editCount > 1)
227
+ parts.push(note(plural(details.editCount, "edit")));
228
+ return parts.join(theme.fg("dim", " · "));
229
+ }
230
+
231
+ /** `3 tool calls · 1 failed · 12 lines · $0.0042` for a codemode script. */
232
+ function summarizeCodemode(result: any, theme: ThemeLike): string {
233
+ const calls: Array<{ status?: string; cost?: number }> = Array.isArray(result?.details?.calls)
234
+ ? result.details.calls
235
+ : [];
236
+ const failed = calls.filter((call) => call.status === "error").length;
237
+ const cost = calls.reduce((sum, call) => sum + (call.cost ?? 0), 0);
238
+ const outputLines = countLines(
239
+ resultText(result)
240
+ .split("\n")
241
+ .filter((line) => !CODEMODE_HEADER_LINE.test(line))
242
+ .join("\n"),
243
+ );
244
+ const parts = [theme.fg("muted", calls.length === 0 ? "no tool calls" : plural(calls.length, "tool call"))];
245
+ if (failed > 0) parts.push(theme.fg("error", `${failed} failed`));
246
+ if (outputLines > 0) parts.push(theme.fg("muted", plural(outputLines, "line")));
247
+ if (cost > 0) parts.push(theme.fg("dim", `$${cost >= 0.01 ? cost.toFixed(2) : cost.toPrecision(2)}`));
248
+ return parts.join(theme.fg("dim", " · "));
249
+ }
250
+
251
+ /** pikit's recall: `{ total, entries }` in details; `args.expand` fetches full entries instead of searching. */
252
+ function summarizeRecall(result: any, args: Record<string, unknown> | undefined, theme: ThemeLike): string {
253
+ const entries: unknown[] = Array.isArray(result?.details?.entries) ? result.details.entries : [];
254
+ if (Array.isArray(args?.expand)) return theme.fg("muted", `${plural(entries.length, "entry", "entries")} expanded`);
255
+ const total = typeof result?.details?.total === "number" ? result.details.total : entries.length;
256
+ return theme.fg("muted", total === 0 ? "no results" : plural(total, "result"));
257
+ }
258
+
259
+ function summarize(toolName: string, result: any, theme: ThemeLike, args?: Record<string, unknown>): string {
260
+ if (toolName === "recall") return summarizeRecall(result, args, theme);
261
+ if (toolName === "codemode") return summarizeCodemode(result, theme);
262
+ if (toolName === "apply_patch") return summarizeApplyPatch(result, theme);
263
+ if (toolName === "write" || toolName === "edit") return summarizeFileChange(toolName, result, theme);
264
+ return theme.fg("muted", summarizeOutput(toolName, result));
265
+ }
266
+
267
+ function summarizeOutput(toolName: string, result: any): string {
268
+ const details = result?.details as Record<string, unknown> | undefined;
269
+ const text = resultText(result);
270
+ switch (toolName) {
271
+ case "read":
272
+ if (details?._type === "readImage") return "image";
273
+ return plural(typeof details?.lineCount === "number" ? details.lineCount : countLines(text), "line");
274
+ case "bash":
275
+ return countOrEmpty(countLines(typeof details?.text === "string" ? details.text : text), "no output", "line");
276
+ case "grep":
277
+ return countOrEmpty(countLines(text, /^--$/), "no matches", "line");
278
+ case "find":
279
+ return countOrEmpty(countLines(text), "no files", "file");
280
+ case "ls":
281
+ return countOrEmpty(countLines(text), "empty", "entry", "entries");
282
+ default:
283
+ return countOrEmpty(countLines(text), "done", "line");
284
+ }
285
+ }
286
+
287
+ function formatSeconds(ms: number): string {
288
+ return ms < 995 ? `${(ms / 1000).toFixed(2)}s` : `${(ms / 1000).toFixed(ms < 9950 ? 1 : 0)}s`;
289
+ }
290
+
291
+ function elapsedMs(result: any, ctx: RenderCtxLike): number | undefined {
292
+ if (typeof ctx.durationMs === "number") return ctx.durationMs;
293
+ const value = (result?.details as Record<string, unknown> | undefined)?.[ELAPSED_KEY];
294
+ return typeof value === "number" ? value : undefined;
295
+ }
296
+
297
+ function rowComponent(build: (width: number) => string[]): AnyComponent {
298
+ return { render: (width: number) => build(Math.max(1, width)), invalidate() {} };
299
+ }
300
+
301
+ function fitLine(line: string, width: number, theme: ThemeLike): string {
302
+ return visibleWidth(line) > width ? truncateToWidth(line, width, theme.fg("dim", "…")) : line;
303
+ }
304
+
305
+ function buildCallRow(
306
+ theme: ThemeLike,
307
+ ctx: RenderCtxLike,
308
+ label: string,
309
+ detail: string,
310
+ state: ToolState,
311
+ hint = "",
312
+ bodyLines: string[] = [],
313
+ ): AnyComponent {
314
+ return rowComponent((width) => {
315
+ const head = `${indentOf(ctx)}${stateIcon(theme, state)} ${theme.bold(theme.fg("text", label))}`;
316
+ const body = detail ? ` ${theme.fg("dim", "(")}${theme.fg("muted", detail)}${theme.fg("dim", ")")}` : "";
317
+ const indent = `${indentOf(ctx)} `;
318
+ return [
319
+ fitLine(`${head}${body}${hint}`, width, theme),
320
+ ...bodyLines.map((line) => fitLine(`${indent}${line}`, width, theme)),
321
+ ];
322
+ });
323
+ }
324
+
325
+ function buildResultRow(theme: ThemeLike, ctx: RenderCtxLike, text: string, failed: boolean): AnyComponent {
326
+ return rowComponent((width) => {
327
+ const connector = theme.fg("dim", "└ ");
328
+ const body = failed ? theme.fg("error", text) : text;
329
+ return [fitLine(`${indentOf(ctx)} ${connector}${body}`, width, theme)];
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Tools wearing the claudecode rows. Only these render as compact rows, so only these may be
335
+ * folded into a group: other tools (apply_patch, MCP tools, …) render their own multi-line
336
+ * bodies, which a group's tree would flatten, so they break a run instead.
337
+ */
338
+ const styledToolNames = new Set<string>();
339
+
340
+ /** Extra lines shown under the call row when expanded (e.g. a script's source). */
341
+ type CallBody = (args: Record<string, unknown>) => string[];
342
+
343
+ /** A codemode script, highlighted, shown under its call row when expanded. */
344
+ const codemodeSource: CallBody = (args) =>
345
+ highlightCode(firstString(args, ["code"]).replace(/\r/g, "").replace(/\t/g, " ").trimEnd(), "javascript");
346
+
347
+ /** Renderer pair for one tool; `original` is the result renderer shown when expanded. */
348
+ function claudeRenderers(name: string, label: string, original?: AnyFn, expandedCallBody?: CallBody) {
349
+ return {
350
+ renderCall(args: Record<string, unknown>, theme: ThemeLike, ctx: RenderCtxLike) {
351
+ const state: ToolState = ctx.isError
352
+ ? "error"
353
+ : ctx.isPartial
354
+ ? ctx.executionStarted === false
355
+ ? "pending"
356
+ : "running"
357
+ : "success";
358
+ return buildCallRow(
359
+ theme,
360
+ ctx,
361
+ label,
362
+ callDetail(name, args ?? {}),
363
+ state,
364
+ ctx.expanded ? toggleHint(theme, "collapse") : "",
365
+ ctx.expanded && expandedCallBody ? expandedCallBody(args ?? {}) : [],
366
+ );
367
+ },
368
+ renderResult(
369
+ result: any,
370
+ options: { expanded?: boolean; isPartial?: boolean },
371
+ theme: ThemeLike,
372
+ ctx: RenderCtxLike,
373
+ ) {
374
+ if (options?.isPartial) return rowComponent(() => []);
375
+ if (ctx.expanded && original) return original(result, options, theme, ctx);
376
+ if (ctx.isError) {
377
+ const message = oneLine(
378
+ resultText(result)
379
+ .split("\n")
380
+ .find((line) => line.trim() !== "" && !CODEMODE_HEADER_LINE.test(line)) ?? "Error",
381
+ );
382
+ return buildResultRow(theme, ctx, message, true);
383
+ }
384
+ const ms = elapsedMs(result, ctx);
385
+ const parts = [summarize(name, result, theme, ctx.args)];
386
+ if (ms !== undefined) parts.push(theme.fg("dim", `◷ ${formatSeconds(ms)}`));
387
+ // Labeled so a bash timeout limit is never mistaken for the measured duration.
388
+ if (typeof ctx.args?.timeout === "number") parts.push(theme.fg("dim", `timeout ${ctx.args.timeout}s`));
389
+ return buildResultRow(theme, ctx, parts.join(theme.fg("dim", " · ")), false);
390
+ },
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Wrap a tool definition so collapsed rows use the Claude Code look. Expanded
396
+ * calls keep the call row but defer the result body to the original renderer.
397
+ */
398
+ export function withClaudeToolStyle<
399
+ T extends { name: string; label?: string; renderCall?: AnyFn; renderResult?: AnyFn },
400
+ >(def: T): T {
401
+ styledToolNames.add(def.name);
402
+ return { ...def, ...claudeRenderers(def.name, def.label || def.name, def.renderResult) } as T;
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // write / edit / apply_patch (pi-diff), codemode (Pi built-in), recall (pikit): tools registered elsewhere
407
+ // ---------------------------------------------------------------------------
408
+
409
+ const EXTERNAL_TOOLS = ["write", "edit", "apply_patch", "codemode", "recall"];
410
+ const EXTERNAL_TOOL_ORIGINALS = Symbol.for("pi-pretty.claude-style.file-tool-originals");
411
+ /** Where the delegated (expanded) renderer's own component is kept between renders. */
412
+ const DELEGATE_COMPONENT = "__claudeDelegateComponent";
413
+
414
+ type RendererLookup = "getCallRenderer" | "getResultRenderer" | "getRenderShell";
415
+ type ComponentProto = Record<RendererLookup, AnyFn> & { [EXTERNAL_TOOL_ORIGINALS]?: Record<RendererLookup, AnyFn> };
416
+
417
+ /**
418
+ * Give write/edit/apply_patch the Claude Code rows without owning their registration (pi-diff or Pi's
419
+ * builtins keep their schema, execute and diff renderer). Rendering is decided per component,
420
+ * so the host's lookups are patched by tool name, as pi-droid-styling does. Collapsed: call
421
+ * and `+N -N` rows; expanded: the provider's own result renderer. Pass `false` to restore.
422
+ */
423
+ export function installClaudeExternalToolRenderers(toolExecutionComponent: unknown, enabled = true): void {
424
+ const proto = (toolExecutionComponent as { prototype?: ComponentProto } | undefined)?.prototype;
425
+ if (!proto) return;
426
+ proto[EXTERNAL_TOOL_ORIGINALS] ??= {
427
+ getCallRenderer: proto.getCallRenderer,
428
+ getResultRenderer: proto.getResultRenderer,
429
+ getRenderShell: proto.getRenderShell,
430
+ };
431
+ const originals = proto[EXTERNAL_TOOL_ORIGINALS];
432
+ if (!enabled) {
433
+ Object.assign(proto, originals);
434
+ for (const name of EXTERNAL_TOOLS) styledToolNames.delete(name);
435
+ return;
436
+ }
437
+ for (const name of EXTERNAL_TOOLS) styledToolNames.add(name);
438
+ const isExternalTool = (component: { toolName?: string }): boolean =>
439
+ EXTERNAL_TOOLS.includes(component.toolName ?? "");
440
+ const labelOf = (component: { toolName: string }): string => {
441
+ const words = component.toolName.replace(/_/g, " ");
442
+ return words.charAt(0).toUpperCase() + words.slice(1);
443
+ };
444
+
445
+ proto.getCallRenderer = function (this: any) {
446
+ return isExternalTool(this)
447
+ ? claudeRenderers(
448
+ this.toolName,
449
+ labelOf(this),
450
+ undefined,
451
+ this.toolName === "codemode" ? codemodeSource : undefined,
452
+ ).renderCall
453
+ : originals.getCallRenderer.call(this);
454
+ };
455
+ proto.getResultRenderer = function (this: any) {
456
+ const original = originals.getResultRenderer.call(this);
457
+ if (!isExternalTool(this)) return original;
458
+ // A tool with no result renderer of its own (recall) gets the host's plain text fallback when expanded.
459
+ if (!original && this.expanded) return undefined;
460
+ // A provider's renderer reuses and mutates `lastComponent`, so it must only ever be handed
461
+ // its own previous component, never one of this style's rows (a collapse/expand round trip).
462
+ const delegate: AnyFn | undefined = original
463
+ ? (result, options, theme, ctx) => {
464
+ const lastComponent = ctx.state[DELEGATE_COMPONENT];
465
+ const component = original(result, options, theme, { ...ctx, lastComponent });
466
+ ctx.state[DELEGATE_COMPONENT] = component;
467
+ return component;
468
+ }
469
+ : undefined;
470
+ return claudeRenderers(this.toolName, labelOf(this), delegate).renderResult;
471
+ };
472
+ // The shell is fixed when the component is built, and the style draws its own rows without a box.
473
+ proto.getRenderShell = function (this: any) {
474
+ return isExternalTool(this) ? "self" : originals.getRenderShell.call(this);
475
+ };
476
+ }
477
+
478
+ /** Proxy `pi` so tools registered through it get the Claude Code rows. */
479
+ export function withClaudeToolStyleApi<P extends { registerTool: AnyFn }>(pi: P): P {
480
+ return new Proxy(pi, {
481
+ get(target, prop, receiver) {
482
+ if (prop === "registerTool") return (def: any) => target.registerTool(withClaudeToolStyle(def));
483
+ const value = Reflect.get(target, prop, receiver);
484
+ return typeof value === "function" ? value.bind(target) : value;
485
+ },
486
+ });
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // Tool-run grouping
491
+ // ---------------------------------------------------------------------------
492
+
493
+ const MIN_GROUPED_TOOLS = 2;
494
+
495
+ const fg = (color: string, text: string): string => groupTheme.fg(color, text);
496
+ const bold = (text: string): string => groupTheme.bold(text);
497
+
498
+ function isToolComponent(component: any): boolean {
499
+ return typeof component?.toolCallId === "string" && styledToolNames.has(component.toolName);
500
+ }
501
+
502
+ /** Assistant turns with no answer text whose tool calls are all styled belong to the tool run. */
503
+ function toolOnlyAssistantContent(component: any): any[] | null {
504
+ const message = component?.lastMessage;
505
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) return null;
506
+ if (message.stopReason === "error" || message.stopReason === "aborted") return null;
507
+ const content = message.content as any[];
508
+ if (content.some((block) => block?.type === "text" && typeof block.text === "string" && block.text.trim()))
509
+ return null;
510
+ const calls = content.filter((block) => block?.type === "toolCall");
511
+ return calls.length > 0 && calls.every((block) => styledToolNames.has(block.name)) ? content : null;
512
+ }
513
+
514
+ const isGroupMember = (component: any): boolean =>
515
+ isToolComponent(component) || toolOnlyAssistantContent(component) !== null;
516
+
517
+ const hasThought = (component: any): boolean =>
518
+ Boolean(
519
+ toolOnlyAssistantContent(component)?.some(
520
+ (block) => block?.type === "thinking" && typeof block.thinking === "string" && block.thinking.trim(),
521
+ ),
522
+ );
523
+
524
+ const isRunning = (tool: any): boolean => !tool.result || Boolean(tool.isPartial);
525
+
526
+ function toolLabel(tool: any): string {
527
+ const label = tool.toolDefinition?.label;
528
+ return typeof label === "string" && label ? label : String(tool.toolName);
529
+ }
530
+
531
+ function summaryRow(members: any[], width: number): string[] {
532
+ const tools = members.filter(isToolComponent);
533
+ const running = tools.filter(isRunning);
534
+ const failed = tools.filter((tool) => Boolean(tool.result?.isError)).length;
535
+ const thoughts = members.filter(hasThought).length;
536
+ const parts = [fg("muted", plural(tools.length, "tool call"))];
537
+ if (thoughts > 0) parts.push(fg("muted", plural(thoughts, "thought")));
538
+ if (failed > 0) parts.push(fg("error", `${failed} failed`));
539
+ if (running.length > 0) parts.push(fg("muted", toolLabel(running[running.length - 1])));
540
+ const icon = stateIcon(groupTheme, running.length > 0 ? "running" : failed > 0 ? "error" : "success");
541
+ const state = bold(fg("text", running.length > 0 ? "Running" : "Done"));
542
+ const row = `${icon} ${state} ${fg("dim", "(")}${parts.join(fg("dim", " · "))}${fg("dim", ")")}${toggleHint(groupTheme, "expand")}`;
543
+ return ["", visibleWidth(row) > width ? truncateToWidth(row, width, fg("dim", " …")) : row];
544
+ }
545
+
546
+ function treeRows(members: any[], width: number): { lines: string[]; owners: any[] } {
547
+ // Text-less assistant turns only contribute to the summary's thought count; their
548
+ // own rows (a hidden-thinking label) would read as noise between the tool rows.
549
+ const blocks = members
550
+ .filter(isToolComponent)
551
+ .map((member) => ({
552
+ member,
553
+ rows: (member.render(width) as string[]).filter((line) => stripAnsi(line).trim() !== "" && !isImageLine(line)),
554
+ }))
555
+ .filter((block) => block.rows.length > 0);
556
+ const lines: string[] = [];
557
+ // owners[i] is the tool component that line i belongs to (for click-to-expand).
558
+ const owners: any[] = [];
559
+ blocks.forEach(({ member, rows }, blockIndex) => {
560
+ const last = blockIndex === blocks.length - 1;
561
+ rows.forEach((line, lineIndex) => {
562
+ const guide = lineIndex === 0 ? (last ? "└─ " : "├─ ") : last ? " " : "│ ";
563
+ let content = line;
564
+ // A settled success shrinks to a small dot; errors, spinners and pending markers stay as they are.
565
+ if (lineIndex === 0 && stripAnsi(line).trimStart().startsWith(ICON_SUCCESS))
566
+ content = line.replace(ICON_SUCCESS, "•");
567
+ const row = ` ${fg("dim", guide)}${content.replace(LEADING_SPACE_RE, "$1")}`;
568
+ lines.push(visibleWidth(row) > width ? truncateToWidth(row, width, "") : row);
569
+ owners.push(member);
570
+ });
571
+ });
572
+ if (lines.length > 0) {
573
+ lines.push("");
574
+ owners.push(undefined);
575
+ }
576
+ return { lines, owners };
577
+ }
578
+
579
+ /** Mouse target for a folded group: a left click on a tool's rows toggles that tool, which unfolds the group. */
580
+ function groupComponent(owners: any[]): AnyComponent {
581
+ return {
582
+ render: () => [],
583
+ handleMouse(event: { type: string; button?: string; y: number }) {
584
+ if (event.type !== "click" || event.button !== "left") return undefined;
585
+ const tool = owners[event.y];
586
+ if (!tool?.result || typeof tool.setExpanded !== "function") return undefined;
587
+ tool.setExpanded(!tool.expanded);
588
+ return { handled: true };
589
+ },
590
+ };
591
+ }
592
+
593
+ function renderGrouped(container: any, width: number): string[] {
594
+ const children: any[] = container.children;
595
+ const lines: string[] = [];
596
+ const layout: Array<{ component: AnyComponent; height: number }> = [];
597
+ const push = (component: AnyComponent, rendered: string[]): void => {
598
+ layout.push({ component, height: rendered.length });
599
+ for (const line of rendered) lines.push(line);
600
+ };
601
+ let index = 0;
602
+ while (index < children.length) {
603
+ let end = index;
604
+ while (end < children.length && isGroupMember(children[end])) end++;
605
+ if (end === index) {
606
+ push(children[index], children[index].render(width));
607
+ index++;
608
+ continue;
609
+ }
610
+ const members = children.slice(index, end);
611
+ const tools = members.filter(isToolComponent);
612
+ if (tools.length < MIN_GROUPED_TOOLS || tools.some((tool) => tool.expanded)) {
613
+ for (const member of members) push(member, member.render(width));
614
+ } else {
615
+ const summary = summaryRow(members, width);
616
+ const tree = treeRows(members, width);
617
+ const owners = [...summary.map(() => undefined), ...tree.owners];
618
+ push(groupComponent(owners), [...summary, ...tree.lines]);
619
+ }
620
+ index = end;
621
+ }
622
+ container.mouseLayout = { width, children: layout };
623
+ return lines;
624
+ }
625
+
626
+ const CONTAINER_ORIGINAL_RENDER = Symbol.for("pi-pretty.claude-style.container-render");
627
+
628
+ /**
629
+ * Fold consecutive tool calls into `✓ Done (...)` groups by wrapping pi-tui's base
630
+ * `Container.render`. Pi nests its chat container (no stable index to look it up by),
631
+ * but only that container holds tool components as direct children, so the wrapper
632
+ * stays inert for every other container. Pass `false` to restore the original render.
633
+ */
634
+ export function installClaudeChatGrouping(enabled = true): void {
635
+ const proto = Container.prototype as unknown as Record<symbol, unknown> & { render: (width: number) => string[] };
636
+ const base = (proto[CONTAINER_ORIGINAL_RENDER] as ((width: number) => string[]) | undefined) ?? proto.render;
637
+ proto[CONTAINER_ORIGINAL_RENDER] = base;
638
+ if (!enabled) {
639
+ proto.render = base;
640
+ return;
641
+ }
642
+ proto.render = function renderChatWithToolGroups(this: { children: unknown[] }, width: number): string[] {
643
+ try {
644
+ if (this.children.some(isToolComponent)) return renderGrouped(this, width);
645
+ } catch {
646
+ // fall through to the original render
647
+ }
648
+ return base.call(this, width);
649
+ };
650
+ }
package/src/config.ts CHANGED
@@ -113,6 +113,8 @@ export interface PrettyConfig {
113
113
  cacheLimit?: number;
114
114
  workingIndicator?: WorkingIndicatorConfig;
115
115
  thinkingIndicator?: ThinkingIndicatorConfig;
116
+ /** "claudecode" renders collapsed tools as `✓ Name (args)` rows with grouping. */
117
+ toolStyle?: "default" | "claudecode";
116
118
  fff?: FffConfig;
117
119
  }
118
120
 
@@ -151,6 +153,7 @@ export function loadConfig(agentDir = getConfigDir()): PrettyConfig {
151
153
  if (background.tool || background.error) config.background = background;
152
154
  }
153
155
  if (typeof parsed.theme === "string" && parsed.theme.trim() !== "") config.theme = parsed.theme;
156
+ if (parsed.toolStyle === "default" || parsed.toolStyle === "claudecode") config.toolStyle = parsed.toolStyle;
154
157
  if (typeof parsed.icons === "string") config.icons = parsed.icons;
155
158
  const enableTools = normalizeToolList(parsed.enableTools);
156
159
  const disableTools = normalizeToolList(parsed.disableTools);