@oh-my-pi/pi-coding-agent 8.0.16 → 8.1.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 (166) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/package.json +14 -11
  3. package/scripts/generate-wasm-b64.ts +24 -0
  4. package/src/capability/context-file.ts +1 -1
  5. package/src/capability/extension-module.ts +1 -1
  6. package/src/capability/extension.ts +1 -1
  7. package/src/capability/hook.ts +1 -1
  8. package/src/capability/instruction.ts +1 -1
  9. package/src/capability/mcp.ts +1 -1
  10. package/src/capability/prompt.ts +1 -1
  11. package/src/capability/rule.ts +1 -1
  12. package/src/capability/settings.ts +1 -1
  13. package/src/capability/skill.ts +1 -1
  14. package/src/capability/slash-command.ts +1 -1
  15. package/src/capability/ssh.ts +1 -1
  16. package/src/capability/system-prompt.ts +1 -1
  17. package/src/capability/tool.ts +1 -1
  18. package/src/cli/args.ts +1 -1
  19. package/src/cli/plugin-cli.ts +1 -5
  20. package/src/commit/agentic/agent.ts +309 -0
  21. package/src/commit/agentic/fallback.ts +96 -0
  22. package/src/commit/agentic/index.ts +359 -0
  23. package/src/commit/agentic/prompts/analyze-file.md +22 -0
  24. package/src/commit/agentic/prompts/session-user.md +26 -0
  25. package/src/commit/agentic/prompts/split-confirm.md +1 -0
  26. package/src/commit/agentic/prompts/system.md +40 -0
  27. package/src/commit/agentic/state.ts +74 -0
  28. package/src/commit/agentic/tools/analyze-file.ts +131 -0
  29. package/src/commit/agentic/tools/git-file-diff.ts +194 -0
  30. package/src/commit/agentic/tools/git-hunk.ts +50 -0
  31. package/src/commit/agentic/tools/git-overview.ts +84 -0
  32. package/src/commit/agentic/tools/index.ts +56 -0
  33. package/src/commit/agentic/tools/propose-changelog.ts +128 -0
  34. package/src/commit/agentic/tools/propose-commit.ts +154 -0
  35. package/src/commit/agentic/tools/recent-commits.ts +81 -0
  36. package/src/commit/agentic/tools/split-commit.ts +284 -0
  37. package/src/commit/agentic/topo-sort.ts +44 -0
  38. package/src/commit/agentic/trivial.ts +51 -0
  39. package/src/commit/agentic/validation.ts +200 -0
  40. package/src/commit/analysis/conventional.ts +169 -0
  41. package/src/commit/analysis/index.ts +4 -0
  42. package/src/commit/analysis/scope.ts +242 -0
  43. package/src/commit/analysis/summary.ts +114 -0
  44. package/src/commit/analysis/validation.ts +66 -0
  45. package/src/commit/changelog/detect.ts +36 -0
  46. package/src/commit/changelog/generate.ts +112 -0
  47. package/src/commit/changelog/index.ts +233 -0
  48. package/src/commit/changelog/parse.ts +44 -0
  49. package/src/commit/cli.ts +93 -0
  50. package/src/commit/git/diff.ts +148 -0
  51. package/src/commit/git/errors.ts +11 -0
  52. package/src/commit/git/index.ts +217 -0
  53. package/src/commit/git/operations.ts +53 -0
  54. package/src/commit/index.ts +5 -0
  55. package/src/commit/map-reduce/.map-phase.ts.kate-swp +0 -0
  56. package/src/commit/map-reduce/index.ts +63 -0
  57. package/src/commit/map-reduce/map-phase.ts +193 -0
  58. package/src/commit/map-reduce/reduce-phase.ts +147 -0
  59. package/src/commit/map-reduce/utils.ts +9 -0
  60. package/src/commit/message.ts +11 -0
  61. package/src/commit/model-selection.ts +84 -0
  62. package/src/commit/pipeline.ts +242 -0
  63. package/src/commit/prompts/analysis-system.md +155 -0
  64. package/src/commit/prompts/analysis-user.md +41 -0
  65. package/src/commit/prompts/changelog-system.md +56 -0
  66. package/src/commit/prompts/changelog-user.md +19 -0
  67. package/src/commit/prompts/file-observer-system.md +26 -0
  68. package/src/commit/prompts/file-observer-user.md +9 -0
  69. package/src/commit/prompts/reduce-system.md +60 -0
  70. package/src/commit/prompts/reduce-user.md +17 -0
  71. package/src/commit/prompts/summary-retry.md +4 -0
  72. package/src/commit/prompts/summary-system.md +52 -0
  73. package/src/commit/prompts/summary-user.md +13 -0
  74. package/src/commit/prompts/types-description.md +2 -0
  75. package/src/commit/types.ts +109 -0
  76. package/src/commit/utils/exclusions.ts +42 -0
  77. package/src/config/file-lock.ts +111 -0
  78. package/src/config/model-registry.ts +16 -7
  79. package/src/config/settings-manager.ts +115 -40
  80. package/src/config.ts +5 -5
  81. package/src/discovery/agents-md.ts +1 -1
  82. package/src/discovery/builtin.ts +1 -1
  83. package/src/discovery/claude.ts +1 -1
  84. package/src/discovery/cline.ts +1 -1
  85. package/src/discovery/codex.ts +1 -1
  86. package/src/discovery/cursor.ts +1 -1
  87. package/src/discovery/gemini.ts +1 -1
  88. package/src/discovery/github.ts +1 -1
  89. package/src/discovery/index.ts +11 -11
  90. package/src/discovery/mcp-json.ts +1 -1
  91. package/src/discovery/ssh.ts +1 -1
  92. package/src/discovery/vscode.ts +1 -1
  93. package/src/discovery/windsurf.ts +1 -1
  94. package/src/extensibility/custom-commands/loader.ts +1 -1
  95. package/src/extensibility/custom-commands/types.ts +1 -1
  96. package/src/extensibility/custom-tools/loader.ts +1 -1
  97. package/src/extensibility/custom-tools/types.ts +1 -1
  98. package/src/extensibility/extensions/loader.ts +1 -1
  99. package/src/extensibility/extensions/types.ts +1 -1
  100. package/src/extensibility/hooks/loader.ts +1 -1
  101. package/src/extensibility/hooks/types.ts +3 -3
  102. package/src/index.ts +10 -10
  103. package/src/ipy/executor.ts +97 -1
  104. package/src/lsp/index.ts +1 -1
  105. package/src/lsp/render.ts +90 -46
  106. package/src/main.ts +16 -3
  107. package/src/mcp/loader.ts +3 -3
  108. package/src/migrations.ts +3 -3
  109. package/src/modes/components/assistant-message.ts +29 -1
  110. package/src/modes/components/tool-execution.ts +5 -3
  111. package/src/modes/components/tree-selector.ts +1 -1
  112. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  113. package/src/modes/controllers/selector-controller.ts +1 -1
  114. package/src/modes/interactive-mode.ts +5 -3
  115. package/src/modes/rpc/rpc-client.ts +1 -1
  116. package/src/modes/rpc/rpc-mode.ts +1 -4
  117. package/src/modes/rpc/rpc-types.ts +1 -1
  118. package/src/modes/theme/mermaid-cache.ts +89 -0
  119. package/src/modes/theme/theme.ts +2 -0
  120. package/src/modes/types.ts +2 -2
  121. package/src/patch/index.ts +3 -9
  122. package/src/patch/shared.ts +33 -5
  123. package/src/prompts/tools/task.md +2 -0
  124. package/src/sdk.ts +60 -22
  125. package/src/session/agent-session.ts +3 -3
  126. package/src/session/agent-storage.ts +32 -28
  127. package/src/session/artifacts.ts +24 -1
  128. package/src/session/auth-storage.ts +25 -10
  129. package/src/session/storage-migration.ts +12 -53
  130. package/src/system-prompt.ts +2 -2
  131. package/src/task/.executor.ts.kate-swp +0 -0
  132. package/src/task/executor.ts +1 -1
  133. package/src/task/index.ts +10 -1
  134. package/src/task/output-manager.ts +94 -0
  135. package/src/task/render.ts +7 -12
  136. package/src/task/worker.ts +1 -1
  137. package/src/tools/ask.ts +35 -13
  138. package/src/tools/bash.ts +80 -87
  139. package/src/tools/calculator.ts +42 -40
  140. package/src/tools/complete.ts +1 -1
  141. package/src/tools/fetch.ts +67 -104
  142. package/src/tools/find.ts +83 -86
  143. package/src/tools/grep.ts +80 -96
  144. package/src/tools/index.ts +10 -7
  145. package/src/tools/ls.ts +39 -65
  146. package/src/tools/notebook.ts +48 -64
  147. package/src/tools/output-utils.ts +1 -1
  148. package/src/tools/python.ts +71 -183
  149. package/src/tools/read.ts +74 -15
  150. package/src/tools/render-utils.ts +1 -15
  151. package/src/tools/ssh.ts +43 -24
  152. package/src/tools/todo-write.ts +27 -15
  153. package/src/tools/write.ts +93 -64
  154. package/src/tui/code-cell.ts +115 -0
  155. package/src/tui/file-list.ts +48 -0
  156. package/src/tui/index.ts +11 -0
  157. package/src/tui/output-block.ts +73 -0
  158. package/src/tui/status-line.ts +40 -0
  159. package/src/tui/tree-list.ts +56 -0
  160. package/src/tui/types.ts +17 -0
  161. package/src/tui/utils.ts +49 -0
  162. package/src/vendor/photon/photon_rs_bg.wasm.b64.js +1 -0
  163. package/src/web/search/auth.ts +1 -1
  164. package/src/web/search/index.ts +1 -1
  165. package/src/web/search/render.ts +119 -163
  166. package/tsconfig.json +0 -42
package/src/tools/bash.ts CHANGED
@@ -8,15 +8,15 @@ import type { Theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
8
8
  import bashDescription from "@oh-my-pi/pi-coding-agent/prompts/tools/bash.md" with { type: "text" };
9
9
  import type { OutputMeta } from "@oh-my-pi/pi-coding-agent/tools/output-meta";
10
10
  import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
11
+ import { renderOutputBlock, renderStatusLine } from "@oh-my-pi/pi-coding-agent/tui";
11
12
  import type { Component } from "@oh-my-pi/pi-tui";
12
- import { Text, truncateToWidth } from "@oh-my-pi/pi-tui";
13
+ import { Text } from "@oh-my-pi/pi-tui";
13
14
  import { Type } from "@sinclair/typebox";
14
-
15
+ import type { ToolSession } from ".";
15
16
  import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor";
16
- import type { ToolSession } from "./index";
17
17
  import { allocateOutputArtifact, createTailBuffer } from "./output-utils";
18
18
  import { resolveToCwd } from "./path-utils";
19
- import { ToolUIKit } from "./render-utils";
19
+ import { formatBytes, wrapBrackets } from "./render-utils";
20
20
  import { toolResult } from "./tool-result";
21
21
  import { DEFAULT_MAX_BYTES } from "./truncate";
22
22
 
@@ -158,35 +158,36 @@ interface BashRenderContext {
158
158
  timeout?: number;
159
159
  }
160
160
 
161
+ function formatBashCommand(args: BashRenderArgs, uiTheme: Theme): string {
162
+ const command = args.command || uiTheme.format.ellipsis;
163
+ const prompt = "$";
164
+ const cwd = process.cwd();
165
+ let displayWorkdir = args.cwd;
166
+
167
+ if (displayWorkdir) {
168
+ const resolvedCwd = resolve(cwd);
169
+ const resolvedWorkdir = resolve(displayWorkdir);
170
+ if (resolvedWorkdir === resolvedCwd) {
171
+ displayWorkdir = undefined;
172
+ } else {
173
+ const relativePath = relative(resolvedCwd, resolvedWorkdir);
174
+ const isWithinCwd = relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${sep}`);
175
+ if (isWithinCwd) {
176
+ displayWorkdir = relativePath;
177
+ }
178
+ }
179
+ }
180
+
181
+ return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${command}` : `${prompt} ${command}`;
182
+ }
183
+
161
184
  // Preview line limit when not expanded (matches tool-execution behavior)
162
185
  export const BASH_PREVIEW_LINES = 10;
163
186
 
164
187
  export const bashToolRenderer = {
165
188
  renderCall(args: BashRenderArgs, uiTheme: Theme): Component {
166
- const ui = new ToolUIKit(uiTheme);
167
- const command = args.command || uiTheme.format.ellipsis;
168
- const prompt = uiTheme.fg("accent", "$");
169
- const cwd = process.cwd();
170
- let displayWorkdir = args.cwd;
171
-
172
- if (displayWorkdir) {
173
- const resolvedCwd = resolve(cwd);
174
- const resolvedWorkdir = resolve(displayWorkdir);
175
- if (resolvedWorkdir === resolvedCwd) {
176
- displayWorkdir = undefined;
177
- } else {
178
- const relativePath = relative(resolvedCwd, resolvedWorkdir);
179
- const isWithinCwd = relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${sep}`);
180
- if (isWithinCwd) {
181
- displayWorkdir = relativePath;
182
- }
183
- }
184
- }
185
-
186
- const cmdText = displayWorkdir
187
- ? `${prompt} ${uiTheme.fg("dim", `cd ${displayWorkdir} &&`)} ${command}`
188
- : `${prompt} ${command}`;
189
- const text = ui.title(cmdText);
189
+ const cmdText = formatBashCommand(args, uiTheme);
190
+ const text = renderStatusLine({ icon: "pending", title: "Bash", description: cmdText }, uiTheme);
190
191
  return new Text(text, 0, 0);
191
192
  },
192
193
 
@@ -194,19 +195,23 @@ export const bashToolRenderer = {
194
195
  result: {
195
196
  content: Array<{ type: string; text?: string }>;
196
197
  details?: BashToolDetails;
198
+ isError?: boolean;
197
199
  },
198
200
  options: RenderResultOptions & { renderContext?: BashRenderContext },
199
201
  uiTheme: Theme,
202
+ args?: BashRenderArgs,
200
203
  ): Component {
201
- const ui = new ToolUIKit(uiTheme);
204
+ const cmdText = args ? formatBashCommand(args, uiTheme) : undefined;
205
+ const isError = result.isError === true;
206
+ const header = renderStatusLine({ icon: isError ? "error" : "success", title: "Bash" }, uiTheme);
202
207
  const { renderContext } = options;
203
208
  const details = result.details;
204
209
  const expanded = renderContext?.expanded ?? options.expanded;
205
210
  const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
206
211
 
207
212
  // Get output from context (preferred) or fall back to result content
208
- const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trim();
209
- const displayOutput = output;
213
+ const output = renderContext?.output ?? result.content?.find((c) => c.type === "text")?.text ?? "";
214
+ const displayOutput = output.trimEnd();
210
215
  const showingFullOutput = expanded && renderContext?.isFullOutput === true;
211
216
 
212
217
  // Build truncation warning lines (static, doesn't depend on width)
@@ -214,7 +219,10 @@ export const bashToolRenderer = {
214
219
  const timeoutSeconds = renderContext?.timeout;
215
220
  const timeoutLine =
216
221
  typeof timeoutSeconds === "number"
217
- ? uiTheme.fg("dim", ui.wrapBrackets(`Timeout: ${timeoutSeconds}s`))
222
+ ? uiTheme.fg(
223
+ "dim",
224
+ `${uiTheme.format.bracketLeft}Timeout: ${timeoutSeconds}s${uiTheme.format.bracketRight}`,
225
+ )
218
226
  : undefined;
219
227
  let warningLine: string | undefined;
220
228
  if (truncation && !showingFullOutput) {
@@ -226,72 +234,57 @@ export const bashToolRenderer = {
226
234
  warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
227
235
  } else {
228
236
  warnings.push(
229
- `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.outputBytes)} limit)`,
237
+ `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.outputBytes)} limit)`,
230
238
  );
231
239
  }
232
240
  if (warnings.length > 0) {
233
- warningLine = uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". ")));
241
+ warningLine = uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme));
234
242
  }
235
243
  }
236
244
 
237
- if (!displayOutput) {
238
- // No output - just show warning if any
239
- const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
240
- return new Text(lines.join("\n"), 0, 0);
241
- }
242
-
243
- if (expanded) {
244
- // Show all lines when expanded
245
- const styledOutput = displayOutput
246
- .split("\n")
247
- .map((line) => uiTheme.fg("toolOutput", line))
248
- .join("\n");
249
- const lines = [styledOutput, timeoutLine, warningLine].filter(Boolean) as string[];
250
- return new Text(lines.join("\n"), 0, 0);
251
- }
252
-
253
- // Collapsed: use width-aware caching component
254
- const styledOutput = displayOutput
255
- .split("\n")
256
- .map((line) => uiTheme.fg("toolOutput", line))
257
- .join("\n");
258
- const textContent = `\n${styledOutput}`;
259
-
260
- let cachedWidth: number | undefined;
261
- let cachedLines: string[] | undefined;
262
- let cachedSkipped: number | undefined;
263
-
264
245
  return {
265
246
  render: (width: number): string[] => {
266
- if (cachedLines === undefined || cachedWidth !== width) {
267
- const result = truncateToVisualLines(textContent, previewLines, width);
268
- cachedLines = result.visualLines;
269
- cachedSkipped = result.skippedCount;
270
- cachedWidth = width;
271
- }
272
247
  const outputLines: string[] = [];
273
- if (cachedSkipped && cachedSkipped > 0) {
274
- outputLines.push("");
275
- const skippedLine = uiTheme.fg(
276
- "dim",
277
- `${uiTheme.format.ellipsis} (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`,
278
- );
279
- outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
280
- }
281
- outputLines.push(...cachedLines);
282
- if (timeoutLine) {
283
- outputLines.push(truncateToWidth(timeoutLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
248
+ const hasOutput = displayOutput.trim().length > 0;
249
+ if (hasOutput) {
250
+ if (expanded) {
251
+ outputLines.push(...displayOutput.split("\n").map((line) => uiTheme.fg("toolOutput", line)));
252
+ } else {
253
+ const styledOutput = displayOutput
254
+ .split("\n")
255
+ .map((line) => uiTheme.fg("toolOutput", line))
256
+ .join("\n");
257
+ const textContent = styledOutput;
258
+ const result = truncateToVisualLines(textContent, previewLines, width);
259
+ if (result.skippedCount > 0) {
260
+ outputLines.push(
261
+ uiTheme.fg(
262
+ "dim",
263
+ `${uiTheme.format.ellipsis} (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (ctrl+o to expand)`,
264
+ ),
265
+ );
266
+ }
267
+ outputLines.push(...result.visualLines);
268
+ }
284
269
  }
285
- if (warningLine) {
286
- outputLines.push(truncateToWidth(warningLine, width, uiTheme.fg("warning", uiTheme.format.ellipsis)));
287
- }
288
- return outputLines;
289
- },
290
- invalidate: () => {
291
- cachedWidth = undefined;
292
- cachedLines = undefined;
293
- cachedSkipped = undefined;
270
+ if (timeoutLine) outputLines.push(timeoutLine);
271
+ if (warningLine) outputLines.push(warningLine);
272
+
273
+ return renderOutputBlock(
274
+ {
275
+ header,
276
+ state: isError ? "error" : "success",
277
+ sections: [
278
+ { lines: cmdText ? [uiTheme.fg("dim", cmdText)] : [] },
279
+ { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
280
+ ],
281
+ width,
282
+ },
283
+ uiTheme,
284
+ );
294
285
  },
286
+ invalidate: () => {},
295
287
  };
296
288
  },
289
+ mergeCallAndResult: true,
297
290
  };
@@ -3,17 +3,16 @@ import { renderPromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-te
3
3
  import type { RenderResultOptions } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types";
4
4
  import type { Theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
5
5
  import calculatorDescription from "@oh-my-pi/pi-coding-agent/prompts/tools/calculator.md" with { type: "text" };
6
+ import { renderStatusLine, renderTreeList } from "@oh-my-pi/pi-coding-agent/tui";
6
7
  import type { Component } from "@oh-my-pi/pi-tui";
7
8
  import { Text } from "@oh-my-pi/pi-tui";
8
9
  import { untilAborted } from "@oh-my-pi/pi-utils";
9
10
  import { Type } from "@sinclair/typebox";
10
- import type { ToolSession } from "./index";
11
+ import type { ToolSession } from ".";
11
12
  import {
12
13
  formatCount,
13
14
  formatEmptyMessage,
14
- formatExpandHint,
15
- formatMeta,
16
- formatMoreItems,
15
+ formatErrorMessage,
17
16
  PREVIEW_LIMITS,
18
17
  TRUNCATE_LENGTHS,
19
18
  truncate,
@@ -453,16 +452,11 @@ export const calculatorToolRenderer = {
453
452
  * Format: "Calc <expression> (N calcs)"
454
453
  */
455
454
  renderCall(args: CalculatorRenderArgs, uiTheme: Theme): Component {
456
- const label = uiTheme.fg("toolTitle", uiTheme.bold("Calc"));
457
455
  const count = args.calculations?.length ?? 0;
458
456
  const firstExpression = args.calculations?.[0]?.expression;
459
- let text = label;
460
- if (firstExpression) {
461
- text += ` ${uiTheme.fg("accent", truncate(firstExpression, TRUNCATE_LENGTHS.TITLE, "..."))}`;
462
- }
463
- const meta: string[] = [];
464
- if (count > 0) meta.push(formatCount("calc", count));
465
- text += formatMeta(meta, uiTheme);
457
+ const description = firstExpression ? truncate(firstExpression, TRUNCATE_LENGTHS.TITLE, "...") : undefined;
458
+ const meta = count > 0 ? [formatCount("calc", count)] : [];
459
+ const text = renderStatusLine({ icon: "pending", title: "Calc", description, meta }, uiTheme);
466
460
  return new Text(text, 0, 0);
467
461
  },
468
462
 
@@ -471,46 +465,54 @@ export const calculatorToolRenderer = {
471
465
  * Collapsed mode shows first N items with expand hint; expanded shows all.
472
466
  */
473
467
  renderResult(
474
- result: { content: Array<{ type: string; text?: string }>; details?: CalculatorToolDetails },
468
+ result: { content: Array<{ type: string; text?: string }>; details?: CalculatorToolDetails; isError?: boolean },
475
469
  { expanded }: RenderResultOptions,
476
470
  uiTheme: Theme,
471
+ args?: CalculatorRenderArgs,
477
472
  ): Component {
478
473
  const details = result.details;
479
474
  const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
475
+ if (result.isError) {
476
+ const header = renderStatusLine({ icon: "error", title: "Calc" }, uiTheme);
477
+ return new Text([header, formatErrorMessage(textContent, uiTheme)].join("\n"), 0, 0);
478
+ }
480
479
 
481
480
  // Prefer structured details; fall back to parsing text content
482
- let outputs = details?.results?.map((entry) => entry.output) ?? [];
481
+ let outputs = details?.results?.map((entry) => `${entry.expression} = ${entry.output}`) ?? [];
483
482
  if (outputs.length === 0 && textContent.trim()) {
484
- outputs = textContent.split("\n").filter((line) => line.trim().length > 0);
483
+ const rawOutputs = textContent.split("\n").filter((line) => line.trim().length > 0);
484
+ const expressions = args?.calculations?.map((calc) => calc.expression) ?? [];
485
+ if (expressions.length === rawOutputs.length && expressions.length > 0) {
486
+ outputs = rawOutputs.map((output, index) => `${expressions[index]} = ${output}`);
487
+ } else {
488
+ outputs = rawOutputs;
489
+ }
485
490
  }
486
491
 
487
492
  if (outputs.length === 0) {
488
- return new Text(formatEmptyMessage("No results", uiTheme), 0, 0);
489
- }
490
-
491
- // Limit visible items in collapsed mode
492
- const maxItems = expanded ? outputs.length : Math.min(outputs.length, COLLAPSED_LIST_LIMIT);
493
- const hasMore = outputs.length > maxItems;
494
- const icon = uiTheme.styledSymbol("status.success", "success");
495
- const summary = uiTheme.fg("dim", formatCount("result", outputs.length));
496
- const expandHint = formatExpandHint(uiTheme, expanded, hasMore);
497
- let text = `${icon} ${summary}${expandHint}`;
498
-
499
- // Render each result as a tree branch
500
- for (let i = 0; i < maxItems; i += 1) {
501
- const isLast = i === maxItems - 1 && !hasMore;
502
- const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
503
- text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("toolOutput", outputs[i])}`;
504
- }
505
-
506
- // Show overflow indicator for collapsed mode
507
- if (hasMore) {
508
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
509
- "muted",
510
- formatMoreItems(outputs.length - maxItems, "result", uiTheme),
511
- )}`;
493
+ const header = renderStatusLine({ icon: "warning", title: "Calc" }, uiTheme);
494
+ return new Text([header, formatEmptyMessage("No results", uiTheme)].join("\n"), 0, 0);
512
495
  }
513
496
 
514
- return new Text(text, 0, 0);
497
+ const description = args?.calculations?.[0]?.expression
498
+ ? truncate(args.calculations[0].expression, TRUNCATE_LENGTHS.TITLE, "...")
499
+ : undefined;
500
+ const header = renderStatusLine(
501
+ { icon: "success", title: "Calc", description, meta: [formatCount("result", outputs.length)] },
502
+ uiTheme,
503
+ );
504
+ const lines = renderTreeList(
505
+ {
506
+ items: outputs,
507
+ expanded,
508
+ maxCollapsed: COLLAPSED_LIST_LIMIT,
509
+ itemType: "result",
510
+ renderItem: (output) => uiTheme.fg("toolOutput", output),
511
+ },
512
+ uiTheme,
513
+ );
514
+
515
+ return new Text([header, ...lines].join("\n"), 0, 0);
515
516
  },
517
+ mergeCallAndResult: true,
516
518
  };
@@ -10,7 +10,7 @@ import { subprocessToolRegistry } from "@oh-my-pi/pi-coding-agent/task/subproces
10
10
  import type { Static, TObject } from "@sinclair/typebox";
11
11
  import { Type } from "@sinclair/typebox";
12
12
  import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
13
- import type { ToolSession } from "./index";
13
+ import type { ToolSession } from ".";
14
14
  import { jtdToJsonSchema } from "./jtd-to-json-schema";
15
15
 
16
16
  export interface CompleteDetails {
@@ -7,8 +7,9 @@ import { type Theme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
7
7
  import fetchDescription from "@oh-my-pi/pi-coding-agent/prompts/tools/fetch.md" with { type: "text" };
8
8
  import type { OutputMeta } from "@oh-my-pi/pi-coding-agent/tools/output-meta";
9
9
  import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
10
+ import { renderOutputBlock, renderStatusLine } from "@oh-my-pi/pi-coding-agent/tui";
10
11
  import { ensureTool } from "@oh-my-pi/pi-coding-agent/utils/tools-manager";
11
- import { specialHandlers } from "@oh-my-pi/pi-coding-agent/web/scrapers/index";
12
+ import { specialHandlers } from "@oh-my-pi/pi-coding-agent/web/scrapers";
12
13
  import type { RenderResult } from "@oh-my-pi/pi-coding-agent/web/scrapers/types";
13
14
  import { finalizeOutput, loadPage, MAX_OUTPUT_CHARS } from "@oh-my-pi/pi-coding-agent/web/scrapers/types";
14
15
  import { convertWithMarkitdown, fetchBinary } from "@oh-my-pi/pi-coding-agent/web/scrapers/utils";
@@ -18,7 +19,7 @@ import { ptree } from "@oh-my-pi/pi-utils";
18
19
  import { type Static, Type } from "@sinclair/typebox";
19
20
  import { nanoid } from "nanoid";
20
21
  import { parse as parseHtml } from "node-html-parser";
21
- import type { ToolSession } from "./index";
22
+ import type { ToolSession } from ".";
22
23
  import { applyListLimit } from "./list-limit";
23
24
  import { formatExpandHint } from "./render-utils";
24
25
  import { toolResult } from "./tool-result";
@@ -993,8 +994,11 @@ export function renderFetchCall(
993
994
  ): Component {
994
995
  const domain = getDomain(args.url);
995
996
  const path = truncate(args.url.replace(/^https?:\/\/[^/]+/, ""), 50, uiTheme.format.ellipsis);
996
- const icon = uiTheme.styledSymbol("status.pending", "muted");
997
- const text = `${icon} ${uiTheme.fg("toolTitle", "Fetch")} ${uiTheme.fg("accent", domain)}${uiTheme.fg("dim", path)}`;
997
+ const description = `${domain}${path ? ` ${path}` : ""}`.trim();
998
+ const meta: string[] = [];
999
+ if (args.raw) meta.push("raw");
1000
+ if (args.timeout !== undefined) meta.push(`timeout:${args.timeout}s`);
1001
+ const text = renderStatusLine({ icon: "pending", title: "Fetch", description, meta }, uiTheme);
998
1002
  return new Text(text, 0, 0);
999
1003
  }
1000
1004
 
@@ -1012,20 +1016,22 @@ export function renderFetchResult(
1012
1016
  }
1013
1017
 
1014
1018
  const domain = getDomain(details.finalUrl);
1019
+ const path = truncate(details.finalUrl.replace(/^https?:\/\/[^/]+/, ""), 50, uiTheme.format.ellipsis);
1015
1020
  const hasRedirect = details.url !== details.finalUrl;
1016
1021
  const hasNotes = details.notes.length > 0;
1017
1022
  const truncation = details.meta?.truncation;
1018
1023
  const truncated = Boolean(details.truncated || truncation);
1019
- const statusIcon = truncated
1020
- ? uiTheme.styledSymbol("status.warning", "warning")
1021
- : uiTheme.styledSymbol("status.success", "success");
1022
- const expandHint = formatExpandHint(uiTheme, expanded);
1023
- const expandSuffix = expandHint ? ` ${expandHint}` : "";
1024
- let text = `${statusIcon} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandSuffix}`;
1025
-
1026
- // Get content text
1024
+
1025
+ const header = renderStatusLine(
1026
+ {
1027
+ icon: truncated ? "warning" : "success",
1028
+ title: "Fetch",
1029
+ description: `${domain}${path ? ` ${path}` : ""}`,
1030
+ },
1031
+ uiTheme,
1032
+ );
1033
+
1027
1034
  const contentText = result.content[0]?.text ?? "";
1028
- // Extract just the content part (after the --- separator)
1029
1035
  const contentBody = contentText.includes("---\n\n")
1030
1036
  ? contentText.split("---\n\n").slice(1).join("---\n\n")
1031
1037
  : contentText;
@@ -1033,104 +1039,61 @@ export function renderFetchResult(
1033
1039
  const charCount = contentBody.trim().length;
1034
1040
  const contentLines = contentBody.split("\n").filter((l) => l.trim());
1035
1041
 
1036
- if (!expanded) {
1037
- // Collapsed view: metadata + preview
1038
- const metaLines: string[] = [
1039
- `${uiTheme.fg("muted", "Content-Type:")} ${details.contentType || "unknown"}`,
1040
- `${uiTheme.fg("muted", "Method:")} ${details.method}`,
1041
- ];
1042
- if (hasRedirect) {
1043
- metaLines.push(`${uiTheme.fg("muted", "Final URL:")} ${uiTheme.fg("mdLinkUrl", details.finalUrl)}`);
1044
- }
1045
- if (truncated) {
1046
- metaLines.push(uiTheme.fg("warning", `${uiTheme.status.warning} Output truncated`));
1047
- if (truncation?.artifactId) {
1048
- metaLines.push(uiTheme.fg("warning", `Full output: artifact://${truncation.artifactId}`));
1049
- }
1050
- }
1051
- if (hasNotes) {
1052
- metaLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`);
1053
- }
1054
-
1055
- const previewLimit = 3;
1056
- const previewList = applyListLimit(contentLines, { headLimit: previewLimit });
1057
- const previewLines = previewList.items.map((line) => truncate(line.trim(), 100, uiTheme.format.ellipsis));
1058
- const detailLines: string[] = [...metaLines];
1059
-
1060
- if (previewLines.length === 0) {
1061
- detailLines.push(uiTheme.fg("dim", "(no content)"));
1062
- } else {
1063
- for (const line of previewLines) {
1064
- detailLines.push(uiTheme.fg("dim", line));
1065
- }
1066
- }
1067
-
1068
- const remaining = Math.max(0, contentLines.length - previewLines.length);
1069
- if (remaining > 0) {
1070
- detailLines.push(uiTheme.fg("muted", `${uiTheme.format.ellipsis} ${remaining} more lines`));
1071
- } else {
1072
- const lineLabel = `${lineCount} line${lineCount === 1 ? "" : "s"}`;
1073
- detailLines.push(uiTheme.fg("muted", `${lineLabel}${uiTheme.sep.dot}${charCount} chars`));
1074
- }
1075
-
1076
- for (let i = 0; i < detailLines.length; i++) {
1077
- const isLast = i === detailLines.length - 1;
1078
- const branch = isLast ? uiTheme.tree.last : uiTheme.tree.vertical;
1079
- text += `\n ${uiTheme.fg("dim", branch)} ${detailLines[i]}`;
1080
- }
1081
- } else {
1082
- // Expanded view: structured metadata + bounded content preview
1083
- const metaLines: string[] = [
1084
- `${uiTheme.fg("muted", "Content-Type:")} ${details.contentType || "unknown"}`,
1085
- `${uiTheme.fg("muted", "Method:")} ${details.method}`,
1086
- ];
1087
- if (hasRedirect) {
1088
- metaLines.push(`${uiTheme.fg("muted", "Final URL:")} ${uiTheme.fg("mdLinkUrl", details.finalUrl)}`);
1089
- }
1090
- const lineLabel = `${lineCount} line${lineCount === 1 ? "" : "s"}`;
1091
- metaLines.push(`${uiTheme.fg("muted", "Lines:")} ${lineLabel}`);
1092
- metaLines.push(`${uiTheme.fg("muted", "Chars:")} ${charCount}`);
1093
- if (truncated) {
1094
- metaLines.push(uiTheme.fg("warning", `${uiTheme.status.warning} Output truncated`));
1095
- if (truncation?.artifactId) {
1096
- metaLines.push(uiTheme.fg("warning", `Full output: artifact://${truncation.artifactId}`));
1097
- }
1098
- }
1099
- if (hasNotes) {
1100
- metaLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`);
1101
- }
1102
-
1103
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.branch)} ${uiTheme.fg("accent", "Metadata")}`;
1104
- for (let i = 0; i < metaLines.length; i++) {
1105
- const isLast = i === metaLines.length - 1;
1106
- const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
1107
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${uiTheme.fg("dim", branch)} ${metaLines[i]}`;
1108
- }
1109
-
1110
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("accent", "Content Preview")}`;
1111
- const previewLimit = 12;
1112
- const previewList = applyListLimit(contentLines, { headLimit: previewLimit });
1113
- const previewLines = previewList.items.map((line) => truncate(line.trim(), 120, uiTheme.format.ellipsis));
1114
- const remaining = Math.max(0, contentLines.length - previewLines.length);
1115
- const contentPrefix = uiTheme.fg("dim", " ");
1116
-
1117
- if (previewLines.length === 0) {
1118
- text += `\n ${contentPrefix} ${uiTheme.fg("dim", "(no content)")}`;
1119
- } else {
1120
- for (const line of previewLines) {
1121
- text += `\n ${contentPrefix} ${uiTheme.fg("dim", line)}`;
1122
- }
1042
+ const metadataLines: string[] = [
1043
+ `${uiTheme.fg("muted", "Content-Type:")} ${details.contentType || "unknown"}`,
1044
+ `${uiTheme.fg("muted", "Method:")} ${details.method}`,
1045
+ ];
1046
+ if (hasRedirect) {
1047
+ metadataLines.push(`${uiTheme.fg("muted", "Final URL:")} ${uiTheme.fg("mdLinkUrl", details.finalUrl)}`);
1048
+ }
1049
+ const lineLabel = `${lineCount} line${lineCount === 1 ? "" : "s"}`;
1050
+ metadataLines.push(`${uiTheme.fg("muted", "Lines:")} ${lineLabel}`);
1051
+ metadataLines.push(`${uiTheme.fg("muted", "Chars:")} ${charCount}`);
1052
+ if (truncated) {
1053
+ metadataLines.push(uiTheme.fg("warning", `${uiTheme.status.warning} Output truncated`));
1054
+ if (truncation?.artifactId) {
1055
+ metadataLines.push(uiTheme.fg("warning", `Full output: artifact://${truncation.artifactId}`));
1123
1056
  }
1057
+ }
1058
+ if (hasNotes) {
1059
+ metadataLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`);
1060
+ }
1124
1061
 
1125
- if (remaining > 0) {
1126
- text += `\n ${contentPrefix} ${uiTheme.fg("muted", `${uiTheme.format.ellipsis} ${remaining} more lines`)}`;
1127
- }
1062
+ const previewLimit = expanded ? 12 : 3;
1063
+ const previewList = applyListLimit(contentLines, { headLimit: previewLimit });
1064
+ const previewLines = previewList.items.map((line) => truncate(line.trimEnd(), 120, uiTheme.format.ellipsis));
1065
+ const remaining = Math.max(0, contentLines.length - previewLines.length);
1066
+ const contentPreviewLines =
1067
+ previewLines.length > 0
1068
+ ? previewLines.map((line) => uiTheme.fg("dim", line))
1069
+ : [uiTheme.fg("dim", "(no content)")];
1070
+ if (remaining > 0) {
1071
+ const hint = formatExpandHint(uiTheme, expanded, true);
1072
+ contentPreviewLines.push(
1073
+ uiTheme.fg("muted", `${uiTheme.format.ellipsis} ${remaining} more lines${hint ? ` ${hint}` : ""}`),
1074
+ );
1128
1075
  }
1129
1076
 
1130
- return new Text(text, 0, 0);
1077
+ return {
1078
+ render: (width: number) =>
1079
+ renderOutputBlock(
1080
+ {
1081
+ header,
1082
+ state: truncated ? "warning" : "success",
1083
+ sections: [
1084
+ { label: uiTheme.fg("toolTitle", "Metadata"), lines: metadataLines },
1085
+ { label: uiTheme.fg("toolTitle", "Content Preview"), lines: contentPreviewLines },
1086
+ ],
1087
+ width,
1088
+ },
1089
+ uiTheme,
1090
+ ),
1091
+ invalidate: () => {},
1092
+ };
1131
1093
  }
1132
1094
 
1133
1095
  export const fetchToolRenderer = {
1134
1096
  renderCall: renderFetchCall,
1135
1097
  renderResult: renderFetchResult,
1098
+ mergeCallAndResult: true,
1136
1099
  };