pi-compact-tools 0.10.1 → 0.11.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.
@@ -1,7 +1,5 @@
1
1
  import type {
2
2
  AgentToolResult,
3
- BashToolDetails,
4
- BashToolInput,
5
3
  ExtensionAPI,
6
4
  Theme,
7
5
  ToolDefinition,
@@ -17,17 +15,8 @@ import {
17
15
  createReadToolDefinition,
18
16
  createWriteToolDefinition,
19
17
  } from "@earendil-works/pi-coding-agent";
20
- import type { Component, Container } from "@earendil-works/pi-tui";
21
- import {
22
- classifyCallStatus,
23
- formatDurationMs,
24
- indicatorGlyph,
25
- indicatorStrength,
26
- indicatorTone,
27
- normalizeLineEndings,
28
- type RowStatus,
29
- } from "./compact-tools-core.ts";
30
- import { colorizeRgb, interpolateRgb } from "./compact-tools-color.ts";
18
+ import type { Component } from "@earendil-works/pi-tui";
19
+ import { classifyCallStatus, formatDurationMs, normalizeLineEndings, type RowStatus } from "./compact-tools-core.ts";
31
20
  import { loadConfig } from "./compact-tools-config.ts";
32
21
  import { installToolRowPatch, setRowResolver, type RowRenderers, type ToolRow } from "./compact-tools-custom.ts";
33
22
  import {
@@ -47,7 +36,9 @@ import {
47
36
  } from "./compact-tools-invocation.ts";
48
37
  import {
49
38
  CachedContainer,
39
+ claudeRows,
50
40
  limitComponentLines,
41
+ prefixedLines,
51
42
  prefixedText,
52
43
  railComponent,
53
44
  renderArguments,
@@ -57,7 +48,9 @@ import {
57
48
  renderToolCall,
58
49
  styleMultiline,
59
50
  } from "./compact-tools-layout.ts";
60
- import { chromePainter, indicatorPulse } from "./compact-tools-palette.ts";
51
+ import { CLAUDE_DIFF_CONTEXT_LINES, CLAUDE_OUTPUT_ROWS, CLAUDE_WRITE_ROWS, claudeFailure, claudeOutcome, claudeTitle } from "./compact-tools-claude.ts";
52
+ import { ToolGroupController } from "./compact-tools-grouping.ts";
53
+ import { chromePainter, paintIndicator } from "./compact-tools-palette.ts";
61
54
  import { ProgressController } from "./compact-tools-progress.ts";
62
55
  import { showReleaseNotice } from "./compact-tools-release.ts";
63
56
  import { ToolRuntime } from "./compact-tools-runtime.ts";
@@ -70,17 +63,34 @@ import {
70
63
  type CompactToolName,
71
64
  type RenderContext,
72
65
  type RowState,
73
- type ShellToolName,
74
66
  type ToolArgs,
75
67
  } from "./compact-tools-types.ts";
76
68
 
77
69
  /** Context lines kept around each change while an edit result is collapsed. */
78
70
  const PREVIEW_DIFF_CONTEXT_LINES = 1;
71
+ /** Claude Code sets what a call returned under the "⎿" rather than beside a rail. */
72
+ const CLAUDE_INDENT = " ";
79
73
 
80
74
  const runtime = new ToolRuntime();
81
75
  const registeredTools = new Set<CompactToolName>();
82
76
  let registeredConfiguration: string | undefined;
83
77
 
78
+ /** How a tool's row is drawn: a file tool's result is code, a shell's is output, a custom tool's is its author's. */
79
+ type RowKind = "file" | "shell" | "custom";
80
+ type AuthorResultRenderer = (expanded: boolean) => Component | undefined;
81
+
82
+ function rowKind(name: string): RowKind {
83
+ return name === "bash" || name === "powershell" ? "shell" : SUPPORTED_TOOL_SET.has(name) ? "file" : "custom";
84
+ }
85
+
86
+ function isClaude(): boolean {
87
+ return runtime.config.style === "claude";
88
+ }
89
+
90
+ function pathArgument(args: ToolArgs): string {
91
+ return typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : "";
92
+ }
93
+
84
94
  function callStatus(ctx: RenderContext, state: RowState): RowStatus {
85
95
  // write/edit arguments can stream for much longer than their eventual filesystem
86
96
  // operation. Treat that active tool-call phase as running so every built-in row
@@ -89,16 +99,9 @@ function callStatus(ctx: RenderContext, state: RowState): RowStatus {
89
99
  return classifyCallStatus(ctx.isError, active, state.endedAt !== undefined);
90
100
  }
91
101
 
92
- function renderIndicator(theme: Theme, ctx: RenderContext, status: RowStatus): string {
93
- const frame = runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate());
94
- const glyph = indicatorGlyph(status, frame);
95
- if (status === "running") {
96
- const pulse = indicatorPulse(theme);
97
- if (pulse) {
98
- return colorizeRgb(theme, interpolateRgb(pulse.from, pulse.to, indicatorStrength(status, frame)), glyph);
99
- }
100
- }
101
- return theme.fg(indicatorTone(status, frame), glyph);
102
+ function renderIndicator(theme: Theme, ctx: RenderContext, state: RowState): string {
103
+ const status = callStatus(ctx, state);
104
+ return paintIndicator(theme, status, runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate()));
102
105
  }
103
106
 
104
107
  function formatDuration(state: RowState): string | undefined {
@@ -106,9 +109,9 @@ function formatDuration(state: RowState): string | undefined {
106
109
  return formatDurationMs((state.endedAt ?? Date.now()) - state.startedAt);
107
110
  }
108
111
 
109
- function canReuseResult<TDetails, TArgs>(
112
+ function canReuseResult<TArgs>(
110
113
  state: RowState,
111
- result: AgentToolResult<TDetails>,
114
+ result: AgentToolResult<unknown>,
112
115
  options: ToolRenderResultOptions,
113
116
  ctx: RenderContext<TArgs>,
114
117
  ): ctx is RenderContext<TArgs> & { lastComponent: Component } {
@@ -122,18 +125,13 @@ function canReuseResult<TDetails, TArgs>(
122
125
  && state.lastResultConfigRevision === state.configRevision;
123
126
  }
124
127
 
125
- function rememberResult<TDetails, TArgs>(
126
- state: RowState,
127
- result: AgentToolResult<TDetails>,
128
- options: ToolRenderResultOptions,
129
- ctx: RenderContext<TArgs>,
130
- ): void {
128
+ function rememberResult(state: RowState, result: AgentToolResult<unknown>, options: ToolRenderResultOptions, isError: boolean): void {
131
129
  state.lastResultContent = result.content;
132
130
  state.lastResultDetails = result.details;
133
131
  state.lastResultPartial = options.isPartial;
134
132
  state.lastResultExpanded = state.expanded;
135
133
  state.lastResultPreview = state.preview;
136
- state.lastResultError = ctx.isError;
134
+ state.lastResultError = isError;
137
135
  state.lastResultConfigRevision = state.configRevision;
138
136
  }
139
137
 
@@ -142,7 +140,6 @@ function renderControls(
142
140
  state: RowState,
143
141
  running: boolean,
144
142
  isError: boolean,
145
- lineSummary?: string,
146
143
  failureReason?: string,
147
144
  changes?: { added: number; removed: number },
148
145
  ): Component {
@@ -161,45 +158,53 @@ function renderControls(
161
158
  + theme.fg("toolDiffRemoved", `-${changes.removed}`) + chrome(")");
162
159
  }
163
160
  // A failed call produced no result worth counting; "(0 lines)" would only mislead.
164
- else if (lineSummary && !running && !isError) details += chrome(` (${lineSummary})`);
161
+ else if (state.resultLineSummary && !running && !isError) details += chrome(` (${state.resultLineSummary})`);
165
162
  return prefixedText(details, chrome(" └ "), " ");
166
163
  }
167
164
 
168
- /**
169
- * A failed row whose output is hidden gets its reason on the status line. When
170
- * the output shows, expanded or as a preview, it already says why, and repeating
171
- * it there only doubles the error.
172
- */
173
- function hiddenFailureReason(name: string, state: RowState, isError: boolean, output: string): string | undefined {
174
- return isError && !state.expanded && !state.preview ? summarizeFailure(name, output) : undefined;
175
- }
176
-
177
- function renderCallTitle(name: string, theme: Theme, ctx: RenderContext, state: RowState): string {
178
- return `${renderIndicator(theme, ctx, callStatus(ctx, state))} ${theme.fg("toolTitle", theme.bold(name))}`;
165
+ /** Arguments a one-line summary cannot show: objects, or lists of them. */
166
+ function hasNestedArguments(args: ToolArgs): boolean {
167
+ return Object.values(args).some((value) => typeof value === "object" && value !== null
168
+ && (!Array.isArray(value) || value.some((item) => typeof item === "object" && item !== null)));
179
169
  }
180
170
 
181
- function renderFileCall(
182
- definition: BuiltInDefinition,
183
- args: ToolArgs,
184
- theme: Theme,
185
- ctx: RenderContext,
186
- ): Container {
187
- const name = definition.name as CompactToolName;
171
+ /** The call line: a status dot, the tool, and what it was asked, then any arguments the line leaves out. */
172
+ function renderRowCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderContext): Component {
188
173
  const state = runtime.syncRow(ctx, ctx.state.endedAt === undefined);
189
174
  runtime.syncExpansion(state, ctx.expanded, name);
190
- const callDetails = getCallDetails(name, args);
191
- const details = callDetails
192
- ? styleMultiline(callDetails, (line) => theme.fg("toolOutput", line))
193
- : undefined;
175
+ const kind = rowKind(name);
194
176
  const container = new CachedContainer();
195
- container.addChild(renderToolCall(renderCallTitle(name, theme, ctx, state), details, theme));
196
- const arguments_ = getArgumentDetails(name, args);
197
- if (Object.keys(arguments_).length > 0) container.addChild(renderArguments(arguments_, theme));
177
+ if (isClaude()) {
178
+ // Claude Code's call line: `⦁ Bash(npm test)`, `⦁ Update(src/app.ts)`.
179
+ const { label, argument } = claudeTitle(name, args);
180
+ const title = `${renderIndicator(theme, ctx, state)} ${theme.fg("toolTitle", theme.bold(label))}`
181
+ + (argument ? theme.fg("toolOutput", `(${argument})`) : "");
182
+ // Collapsed, a long command keeps to one row; opened, it shows in full.
183
+ container.addChild(state.expanded ? renderToolCall(title, undefined, theme) : prefixedLines(title, " ", " ", true));
184
+ // The title already lists plain arguments; only nested ones need the full view.
185
+ if (kind === "custom" && state.expanded && hasNestedArguments(args)) container.addChild(renderArguments(args, theme));
186
+ return container;
187
+ }
188
+ const title = `${renderIndicator(theme, ctx, state)} ${theme.fg("toolTitle", theme.bold(name))}`;
189
+ let details: string | undefined;
190
+ if (kind === "shell") {
191
+ const command = normalizeLineEndings(typeof args.command === "string" ? args.command : "");
192
+ details = state.expanded
193
+ ? styleMultiline(command || "…", (line) => theme.fg("toolOutput", line))
194
+ : theme.fg("toolOutput", summarizeShellCommand(name, command));
195
+ } else {
196
+ const summary = kind === "file" ? getCallDetails(name, args) : summarizeCustomArguments(args);
197
+ details = summary ? styleMultiline(summary, (line) => theme.fg("toolOutput", line)) : undefined;
198
+ }
199
+ container.addChild(renderToolCall(title, details, theme));
200
+ const extra = kind === "file" ? getArgumentDetails(name, args) : kind === "custom" && state.expanded ? args : {};
201
+ if (Object.keys(extra).length > 0) container.addChild(renderArguments(extra, theme));
198
202
  return container;
199
203
  }
200
204
 
205
+ /** A result body as code where it is code: numbered file text, or an edit's diff. */
201
206
  function renderFileBody(
202
- name: CompactToolName,
207
+ name: string,
203
208
  args: ToolArgs,
204
209
  result: AgentToolResult<unknown>,
205
210
  output: string,
@@ -208,13 +213,10 @@ function renderFileBody(
208
213
  isError: boolean,
209
214
  ): Component | undefined {
210
215
  if (isError) return renderOutput(output, theme, isError);
211
- const path = typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : "";
212
- if (name === "edit") {
213
- const diff = getEditDiff(result);
214
- const component = diff
215
- ? renderCodeDiff(getEditPatch(result), diff, path, theme, expanded ? {} : { contextLines: PREVIEW_DIFF_CONTEXT_LINES })
216
- : undefined;
217
- return component ?? renderOutput(output, theme, isError);
216
+ const path = pathArgument(args);
217
+ if (name === "edit" && getEditDiff(result)) {
218
+ return renderCodeDiff(getEditPatch(result), getEditDiff(result), path, theme,
219
+ expanded ? {} : { contextLines: PREVIEW_DIFF_CONTEXT_LINES }) ?? renderOutput(output, theme, isError);
218
220
  }
219
221
  if (name === "read" && isReadTextResult(result)) {
220
222
  const { body, footer } = splitReadFooter(output);
@@ -225,155 +227,166 @@ function renderFileBody(
225
227
  return renderOutput(output, theme, isError);
226
228
  }
227
229
 
228
- function appendFileResult(
229
- container: Container,
230
- name: CompactToolName,
231
- state: RowState,
230
+ function renderRowBody(
231
+ name: string,
232
232
  args: ToolArgs,
233
233
  result: AgentToolResult<unknown>,
234
234
  output: string,
235
+ expanded: boolean,
235
236
  theme: Theme,
236
237
  isError: boolean,
237
- ): void {
238
- if (!state.expanded && !state.preview) return;
239
- const component = renderFileBody(name, args, result, output, state.expanded === true, theme, isError);
240
- if (!component) return;
241
- container.addChild(state.expanded ? component : limitComponentLines(component, runtime.config.previewLines, theme));
238
+ author: AuthorResultRenderer | undefined,
239
+ ): Component | undefined {
240
+ if (rowKind(name) !== "custom") return renderFileBody(name, args, result, output, expanded, theme, isError);
241
+ if (author) {
242
+ try {
243
+ const component = author(expanded);
244
+ if (component) return railComponent(component, theme);
245
+ } catch {
246
+ // A failing third-party renderer degrades to the plain text result.
247
+ }
248
+ }
249
+ return renderOutput(output, theme, isError);
242
250
  }
243
251
 
244
- function renderFileResult(
245
- definition: BuiltInDefinition,
246
- result: AgentToolResult<unknown>,
247
- options: ToolRenderResultOptions,
248
- theme: Theme,
249
- ctx: RenderContext,
250
- ): Component {
251
- const name = definition.name as CompactToolName;
252
- const state = runtime.syncRow(ctx, options.isPartial, !options.isPartial);
253
- runtime.syncExpansion(state, ctx.expanded, name);
254
- if (canReuseResult(state, result, options, ctx)) return ctx.lastComponent;
255
- const output = getFileOutput(name, ctx.args, result, ctx.isError);
256
- const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
257
- runtime.setResultAvailable(state, name, hasEditDiff || output.length > 0);
258
- const container = new CachedContainer();
259
- appendFileResult(container, name, state, ctx.args, result, output, theme, ctx.isError);
260
- if (!options.isPartial && !state.resultLineSummaryComputed) {
261
- state.resultLineSummary = formatResultLineSummary(name, ctx.args, result, output);
262
- state.resultLineSummaryComputed = true;
263
- }
264
- container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, state.resultLineSummary,
265
- hiddenFailureReason(name, state, ctx.isError, output), name === "edit" ? countEditChanges(result) : undefined));
266
- rememberResult(state, result, options, ctx);
267
- return container;
252
+ function claudeMoreLines(theme: Theme, hidden: number): string {
253
+ return chromePainter(theme)(`… +${hidden} ${hidden === 1 ? "line" : "lines"} (ctrl+o to expand)`);
268
254
  }
269
255
 
270
- function renderShellCall(
271
- name: ShellToolName,
272
- args: BashToolInput,
273
- theme: Theme,
274
- ctx: RenderContext<BashToolInput>,
275
- ): Component {
276
- const state = runtime.syncRow(ctx, ctx.state.endedAt === undefined);
277
- runtime.syncExpansion(state, ctx.expanded, name);
278
- const command = normalizeLineEndings(args.command ?? "");
279
- const details = state.expanded
280
- ? styleMultiline(command || "…", (line) => theme.fg("toolOutput", line))
281
- : theme.fg("toolOutput", summarizeShellCommand(name, command));
282
- return renderToolCall(renderCallTitle(name, theme, ctx, state), details, theme);
256
+ /** Output previewed in rows the way Claude Code previews it, all of it once opened. */
257
+ function claudeOutput(output: string, expanded: boolean, theme: Theme, isError: boolean, empty: string, firstPrefix: string): Component {
258
+ const normalized = normalizeLineEndings(output).trimEnd();
259
+ // Pi's bash reports an empty run as "(no output)"; Claude Code dims its own words for it.
260
+ const lines = !normalized || normalized === "(no output)"
261
+ ? [chromePainter(theme)(empty)]
262
+ : normalized.split("\n").map((line) => theme.fg(isError ? "error" : "toolOutput", line));
263
+ return claudeRows(lines, firstPrefix, CLAUDE_INDENT, expanded ? undefined : CLAUDE_OUTPUT_ROWS,
264
+ (hidden) => claudeMoreLines(theme, hidden));
283
265
  }
284
266
 
285
- function renderShellResult(
286
- name: ShellToolName,
287
- result: AgentToolResult<BashToolDetails | undefined>,
288
- options: ToolRenderResultOptions,
267
+ /**
268
+ * What Claude Code draws beneath a finished call: an edit's every hunk, a write's
269
+ * first rows, the author's view of a custom tool once opened, and otherwise
270
+ * nothing until the row is opened.
271
+ */
272
+ function claudeBody(
273
+ name: string,
274
+ args: ToolArgs,
275
+ result: AgentToolResult<unknown>,
276
+ output: string,
277
+ expanded: boolean,
289
278
  theme: Theme,
290
- ctx: RenderContext<BashToolInput>,
291
- ): Component {
292
- const state = runtime.syncRow(ctx, options.isPartial, !options.isPartial);
293
- runtime.syncExpansion(state, ctx.expanded, name);
294
- if (canReuseResult(state, result, options, ctx)) return ctx.lastComponent;
295
- const output = getTextResult(result);
296
- const failureReason = hiddenFailureReason(name, state, ctx.isError, output);
297
- runtime.setResultAvailable(state, name, output.length > 0);
298
- if (!options.isPartial && !state.resultLineSummaryComputed) {
299
- state.resultLineSummary = formatResultLineSummary(name, ctx.args, result, output);
300
- state.resultLineSummaryComputed = true;
301
- }
302
- if (!state.expanded && !state.preview) {
303
- const controls = renderControls(theme, state, options.isPartial, ctx.isError, state.resultLineSummary, failureReason);
304
- rememberResult(state, result, options, ctx);
305
- return controls;
279
+ author: AuthorResultRenderer | undefined,
280
+ ): Component | undefined {
281
+ const path = pathArgument(args);
282
+ if (name === "edit") {
283
+ return renderCodeDiff(getEditPatch(result), getEditDiff(result), path, theme,
284
+ { contextLines: CLAUDE_DIFF_CONTEXT_LINES, rail: CLAUDE_INDENT });
306
285
  }
307
- const container = new CachedContainer();
308
- const component = renderOutput(output, theme, ctx.isError);
309
- if (component) {
310
- container.addChild(state.expanded ? component : limitComponentLines(component, runtime.config.previewLines, theme));
286
+ if (name === "write") {
287
+ const component = renderCodeView(output, path, theme, { rail: CLAUDE_INDENT });
288
+ return component && !expanded
289
+ ? limitComponentLines(component, CLAUDE_WRITE_ROWS, theme, (hidden) => CLAUDE_INDENT + claudeMoreLines(theme, hidden))
290
+ : component;
311
291
  }
312
- container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, state.resultLineSummary, failureReason));
313
- rememberResult(state, result, options, ctx);
314
- return container;
292
+ // A command's output is already the result line itself.
293
+ const kind = rowKind(name);
294
+ if (!expanded || kind === "shell") return undefined;
295
+ if (kind === "custom") return author ? renderRowBody(name, args, result, output, true, theme, false, author) : undefined;
296
+ return renderFileBody(name, args, result, output, true, theme, false);
315
297
  }
316
298
 
317
- function renderCustomCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderContext): Component {
318
- const state = runtime.syncRow(ctx, ctx.state.endedAt === undefined);
319
- runtime.syncExpansion(state, ctx.expanded, name);
320
- const summary = summarizeCustomArguments(args);
321
- const container = new CachedContainer();
322
- container.addChild(renderToolCall(
323
- renderCallTitle(name, theme, ctx, state),
324
- summary ? theme.fg("toolOutput", summary) : undefined,
325
- theme,
326
- ));
327
- if (state.expanded && Object.keys(args).length > 0) container.addChild(renderArguments(args, theme));
328
- return container;
329
- }
330
-
331
- type AuthorResultRenderer = (expanded: boolean) => Component | undefined;
332
-
333
- function renderCustomBody(
334
- output: string,
335
- expanded: boolean,
299
+ /**
300
+ * Claude Code's result block: a "└" line saying what happened, or the output
301
+ * itself for commands and custom tools, then any diff or code beneath it.
302
+ */
303
+ function renderClaudeResult(
304
+ name: string,
305
+ args: ToolArgs,
306
+ result: AgentToolResult<unknown>,
307
+ options: ToolRenderResultOptions,
336
308
  theme: Theme,
337
309
  isError: boolean,
338
- renderAuthorResult: AuthorResultRenderer | undefined,
339
- ): Component | undefined {
340
- if (renderAuthorResult) {
341
- try {
342
- const component = renderAuthorResult(expanded);
343
- if (component) return railComponent(component, theme);
344
- } catch {
345
- // A failing third-party renderer degrades to the plain text result.
310
+ expanded: boolean,
311
+ output: string,
312
+ author: AuthorResultRenderer | undefined,
313
+ ): Component {
314
+ const chrome = chromePainter(theme);
315
+ const container = new CachedContainer();
316
+ // One line under the call; collapsed it keeps to one row.
317
+ const line = (text: string) => prefixedLines(text, chrome(" └ "), CLAUDE_INDENT, !expanded);
318
+ if (options.isPartial) {
319
+ container.addChild(line(chrome("Running…")));
320
+ return container;
321
+ }
322
+ if (isError) {
323
+ const failure = claudeFailure(name, output);
324
+ // A command's output says why beneath its exit code; another tool's message is
325
+ // shortened to one line, and a click shows it whole in its place.
326
+ if (failure.detail) {
327
+ container.addChild(line(theme.fg("error", failure.headline)));
328
+ container.addChild(claudeOutput(failure.detail, expanded, theme, true, "", CLAUDE_INDENT));
329
+ } else if (expanded && output.trim()) {
330
+ container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(" └ ")));
331
+ } else {
332
+ container.addChild(line(theme.fg("error", failure.headline)));
346
333
  }
334
+ return container;
347
335
  }
348
- return renderOutput(output, theme, isError);
336
+ const body = claudeBody(name, args, result, output, expanded, theme, author);
337
+ // The author's view already shows a custom tool's output, so the line above it only counts it.
338
+ const outcome = body && rowKind(name) === "custom"
339
+ ? formatResultLineSummary(name, args, result, output) ?? "Done"
340
+ : claudeOutcome(name, args, result, output, (value) => theme.bold(value));
341
+ if (outcome !== undefined) container.addChild(line(theme.fg("toolOutput", outcome)));
342
+ else container.addChild(claudeOutput(output, expanded, theme, false, rowKind(name) === "shell" ? "(No output)" : "(No content)", chrome(" └ ")));
343
+ if (body) container.addChild(body);
344
+ return container;
349
345
  }
350
346
 
351
- function renderCustomResult(
347
+ /**
348
+ * The result under a call: a preview or the whole of it when shown, and a status
349
+ * line with how long it took and how much it returned. An unchanged result reuses
350
+ * the component it drew last, so a large output is laid out once.
351
+ */
352
+ function renderRowResult(
352
353
  name: string,
353
354
  result: AgentToolResult<unknown>,
354
355
  options: ToolRenderResultOptions,
355
356
  theme: Theme,
356
357
  ctx: RenderContext,
357
- renderAuthorResult: AuthorResultRenderer | undefined,
358
+ author?: AuthorResultRenderer,
358
359
  ): Component {
359
360
  const state = runtime.syncRow(ctx, options.isPartial, !options.isPartial);
360
361
  runtime.syncExpansion(state, ctx.expanded, name);
361
362
  if (canReuseResult(state, result, options, ctx)) return ctx.lastComponent;
362
- const output = getTextResult(result);
363
- runtime.setResultAvailable(state, name, output.length > 0 || renderAuthorResult !== undefined);
364
- if (!options.isPartial && !state.resultLineSummaryComputed) {
365
- state.resultLineSummary = output ? formatResultLineSummary(name, ctx.args, result, output) : undefined;
366
- state.resultLineSummaryComputed = true;
367
- }
368
- const container = new CachedContainer();
369
- if (state.expanded || state.preview) {
370
- const body = renderCustomBody(output, state.expanded === true, theme, ctx.isError, renderAuthorResult);
371
- if (body) container.addChild(state.expanded ? body : limitComponentLines(body, runtime.config.previewLines, theme));
363
+ const kind = rowKind(name);
364
+ const output = kind === "file" ? getFileOutput(name, ctx.args, result, ctx.isError) : getTextResult(result);
365
+ const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
366
+ runtime.setResultAvailable(state, name, hasEditDiff || output.length > 0 || author !== undefined);
367
+ let component: Component;
368
+ if (isClaude()) {
369
+ component = renderClaudeResult(name, ctx.args, result, options, theme, ctx.isError, state.expanded === true, output, author);
370
+ } else {
371
+ if (!options.isPartial && !state.resultLineSummaryComputed) {
372
+ // A custom tool with no text has nothing to count.
373
+ state.resultLineSummary = kind === "custom" && !output ? undefined : formatResultLineSummary(name, ctx.args, result, output);
374
+ state.resultLineSummaryComputed = true;
375
+ }
376
+ const container = new CachedContainer();
377
+ if (state.expanded || state.preview) {
378
+ const body = renderRowBody(name, ctx.args, result, output, state.expanded === true, theme, ctx.isError, author);
379
+ if (body) container.addChild(state.expanded ? body : limitComponentLines(body, runtime.config.previewLines, theme));
380
+ }
381
+ // A failed row whose output is hidden gets its reason on the status line. When
382
+ // the output shows, it already says why, and repeating it only doubles the error.
383
+ const failureReason = ctx.isError && !state.expanded && !state.preview ? summarizeFailure(name, output) : undefined;
384
+ container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, failureReason,
385
+ name === "edit" ? countEditChanges(result) : undefined));
386
+ component = container;
372
387
  }
373
- const failureReason = hiddenFailureReason(name, state, ctx.isError, output);
374
- container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, state.resultLineSummary, failureReason));
375
- rememberResult(state, result, options, ctx);
376
- return container;
388
+ rememberResult(state, result, options, ctx.isError);
389
+ return component;
377
390
  }
378
391
 
379
392
  /**
@@ -388,7 +401,7 @@ function createCustomRenderers(name: string, author: ToolDefinition<any, any, an
388
401
  let authorResult: Component | undefined;
389
402
  const own = (ctx: RenderContext<any>): RenderContext => ({ ...ctx, args: ctx.args ?? {}, state });
390
403
  return {
391
- renderCall: (args, theme, ctx) => renderCustomCall(name, (args ?? {}) as ToolArgs, theme, own(ctx)),
404
+ renderCall: (args, theme, ctx) => renderRowCall(name, (args ?? {}) as ToolArgs, theme, own(ctx)),
392
405
  renderResult: (result, options, theme, ctx) => {
393
406
  const authorRenderer = author?.renderResult;
394
407
  const renderAuthorResult = authorRenderer
@@ -397,7 +410,7 @@ function createCustomRenderers(name: string, author: ToolDefinition<any, any, an
397
410
  return authorResult;
398
411
  }
399
412
  : undefined;
400
- return renderCustomResult(name, result, options, theme, own(ctx), renderAuthorResult);
413
+ return renderRowResult(name, result, options, theme, own(ctx), renderAuthorResult);
401
414
  },
402
415
  };
403
416
  }
@@ -405,7 +418,7 @@ function createCustomRenderers(name: string, author: ToolDefinition<any, any, an
405
418
  function resolveCustomRow(row: ToolRow): RowRenderers | undefined {
406
419
  const customTools = runtime.config.custom_tools;
407
420
  // Built-ins are governed by `tools`: ones left out keep Pi's own renderer.
408
- if (!customTools.enabled || SUPPORTED_TOOL_SET.has(row.toolName)) return undefined;
421
+ if (runtime.config.style === "off" || !customTools.enabled || SUPPORTED_TOOL_SET.has(row.toolName)) return undefined;
409
422
  if (customTools.exclude.includes(row.toolName)) return undefined;
410
423
  return createCustomRenderers(row.toolName, row.toolDefinition);
411
424
  }
@@ -421,54 +434,32 @@ const toolFactories: Record<CompactToolName, (cwd: string) => BuiltInDefinition>
421
434
  ls: createLsToolDefinition,
422
435
  };
423
436
 
424
- function createBuiltInDefinition(name: CompactToolName, cwd: string): BuiltInDefinition {
425
- return toolFactories[name](cwd);
426
- }
427
-
428
- function registerFileTool(pi: ExtensionAPI, definition: BuiltInDefinition): void {
437
+ function registerCompactTool(pi: ExtensionAPI, definition: BuiltInDefinition): void {
429
438
  pi.registerTool({
430
439
  ...definition,
431
440
  execute: runtime.createTimedExecute(definition),
432
441
  renderShell: "self",
433
- renderCall: (args: ToolArgs, theme: Theme, ctx: RenderContext) => renderFileCall(definition, args, theme, ctx),
434
- renderResult: (
435
- result: AgentToolResult<unknown>,
436
- options: ToolRenderResultOptions,
437
- theme: Theme,
438
- ctx: RenderContext,
439
- ) => renderFileResult(definition, result, options, theme, ctx),
442
+ renderCall: (args: ToolArgs, theme: Theme, ctx: RenderContext) => renderRowCall(definition.name, args, theme, ctx),
443
+ renderResult: (result: AgentToolResult<unknown>, options: ToolRenderResultOptions, theme: Theme, ctx: RenderContext) =>
444
+ renderRowResult(definition.name, result, options, theme, ctx),
440
445
  } as ToolDefinition<any, any, RowState>);
441
446
  }
442
447
 
443
- function registerShellTool(pi: ExtensionAPI, definition: BuiltInDefinition): void {
444
- const name = definition.name as ShellToolName;
445
- pi.registerTool({
446
- ...definition,
447
- execute: runtime.createTimedExecute(definition),
448
- renderShell: "self",
449
- renderCall: (args: BashToolInput, theme: Theme, ctx: RenderContext<BashToolInput>) =>
450
- renderShellCall(name, args, theme, ctx),
451
- renderResult: (
452
- result: AgentToolResult<BashToolDetails | undefined>,
453
- options: ToolRenderResultOptions,
454
- theme: Theme,
455
- ctx: RenderContext<BashToolInput>,
456
- ) => renderShellResult(name, result, options, theme, ctx),
457
- } as ToolDefinition<any, BashToolDetails | undefined, RowState>);
448
+ /** Off leaves every tool with Pi's own renderer. */
449
+ function compactedTools(): CompactToolName[] {
450
+ return runtime.config.style === "off" ? [] : runtime.config.tools;
458
451
  }
459
452
 
460
453
  function registerTools(pi: ExtensionAPI, cwd: string): void {
461
- const enabledTools = new Set(runtime.config.tools);
454
+ const enabledTools = new Set(compactedTools());
462
455
  for (const name of registeredTools) {
463
456
  // Pi exposes no unregister API, so restoring a disabled tool means re-registering
464
457
  // the built-in definition to release this extension's renderers.
465
- if (!enabledTools.has(name)) pi.registerTool(createBuiltInDefinition(name, cwd));
458
+ if (!enabledTools.has(name)) pi.registerTool(toolFactories[name](cwd));
466
459
  }
467
460
  registeredTools.clear();
468
- for (const name of runtime.config.tools) {
469
- const definition = createBuiltInDefinition(name, cwd);
470
- if (name === "bash" || name === "powershell") registerShellTool(pi, definition);
471
- else registerFileTool(pi, definition);
461
+ for (const name of compactedTools()) {
462
+ registerCompactTool(pi, toolFactories[name](cwd));
472
463
  registeredTools.add(name);
473
464
  }
474
465
  }
@@ -496,6 +487,7 @@ export default function compactTools(pi: ExtensionAPI): void {
496
487
  const progress = new ProgressController(pi);
497
488
  const viewport = new ViewportKeeper();
498
489
  const silent = new SilentModeController(pi, showReleaseNotice);
490
+ const groups = new ToolGroupController(runtime);
499
491
  const customRowsAvailable = installToolRowPatch();
500
492
  setRowResolver(resolveCustomRow);
501
493
  let customRowsWarned = false;
@@ -505,12 +497,13 @@ export default function compactTools(pi: ExtensionAPI): void {
505
497
  pi.on("session_start", (event, ctx) => {
506
498
  if (event.reason !== "reload") runtime.clearTimings();
507
499
  configure(pi, ctx.cwd, ctx.isProjectTrusted());
508
- if (ctx.mode === "tui") {
500
+ if (ctx.mode === "tui" && runtime.config.style !== "off") {
509
501
  // First, so its input listener sees relayout keys before the thinking controller consumes them.
510
502
  viewport.bind(ctx);
511
503
  progress.bind(ctx);
512
504
  thinkingCycle.bind(ctx);
513
505
  silent.bind(ctx, runtime.config.mode, event.reason === "reload");
506
+ groups.bind(ctx);
514
507
  if (!silent.isEnabled()) showReleaseNotice(ctx);
515
508
  if (runtime.config.custom_tools.enabled && !customRowsAvailable && !customRowsWarned) {
516
509
  customRowsWarned = true;
@@ -521,6 +514,7 @@ export default function compactTools(pi: ExtensionAPI): void {
521
514
  progress.dispose();
522
515
  thinkingCycle.dispose();
523
516
  silent.dispose();
517
+ groups.dispose();
524
518
  }
525
519
  });
526
520
  pi.on("tool_execution_start", (event) => runtime.noteExecutionStart(event.toolCallId));
@@ -529,6 +523,7 @@ export default function compactTools(pi: ExtensionAPI): void {
529
523
  thinkingCycle.dispose();
530
524
  progress.dispose();
531
525
  silent.dispose();
526
+ groups.dispose();
532
527
  viewport.dispose();
533
528
  runtime.reset(event.reason !== "reload");
534
529
  });