@oh-my-pi/pi-coding-agent 3.20.1 → 3.21.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 (94) hide show
  1. package/CHANGELOG.md +69 -9
  2. package/docs/custom-tools.md +3 -3
  3. package/docs/extensions.md +226 -220
  4. package/docs/hooks.md +2 -2
  5. package/docs/sdk.md +3 -3
  6. package/examples/custom-tools/README.md +2 -2
  7. package/examples/custom-tools/subagent/index.ts +1 -1
  8. package/examples/extensions/README.md +76 -74
  9. package/examples/extensions/todo.ts +2 -5
  10. package/examples/hooks/custom-compaction.ts +1 -1
  11. package/examples/hooks/handoff.ts +1 -1
  12. package/examples/hooks/qna.ts +1 -1
  13. package/examples/sdk/02-custom-model.ts +1 -1
  14. package/examples/sdk/12-full-control.ts +1 -1
  15. package/examples/sdk/README.md +1 -1
  16. package/package.json +5 -5
  17. package/src/cli/file-processor.ts +1 -1
  18. package/src/cli/list-models.ts +1 -1
  19. package/src/core/agent-session.ts +13 -2
  20. package/src/core/auth-storage.ts +1 -1
  21. package/src/core/compaction/branch-summarization.ts +2 -2
  22. package/src/core/compaction/compaction.ts +2 -2
  23. package/src/core/compaction/utils.ts +1 -1
  24. package/src/core/custom-tools/types.ts +1 -1
  25. package/src/core/extensions/runner.ts +1 -1
  26. package/src/core/extensions/types.ts +1 -1
  27. package/src/core/extensions/wrapper.ts +1 -1
  28. package/src/core/hooks/runner.ts +2 -2
  29. package/src/core/hooks/types.ts +1 -1
  30. package/src/core/messages.ts +1 -1
  31. package/src/core/model-registry.ts +1 -1
  32. package/src/core/model-resolver.ts +1 -1
  33. package/src/core/sdk.ts +33 -4
  34. package/src/core/session-manager.ts +11 -22
  35. package/src/core/settings-manager.ts +66 -1
  36. package/src/core/slash-commands.ts +12 -5
  37. package/src/core/system-prompt.ts +27 -3
  38. package/src/core/title-generator.ts +2 -2
  39. package/src/core/tools/ask.ts +88 -1
  40. package/src/core/tools/bash-interceptor.ts +7 -0
  41. package/src/core/tools/bash.ts +106 -0
  42. package/src/core/tools/edit-diff.ts +73 -24
  43. package/src/core/tools/edit.ts +214 -20
  44. package/src/core/tools/find.ts +155 -0
  45. package/src/core/tools/gemini-image.ts +279 -56
  46. package/src/core/tools/git.ts +4 -0
  47. package/src/core/tools/grep.ts +191 -0
  48. package/src/core/tools/index.ts +3 -6
  49. package/src/core/tools/ls.ts +134 -1
  50. package/src/core/tools/lsp/render.ts +34 -14
  51. package/src/core/tools/notebook.ts +110 -0
  52. package/src/core/tools/output.ts +179 -7
  53. package/src/core/tools/read.ts +122 -9
  54. package/src/core/tools/render-utils.ts +241 -0
  55. package/src/core/tools/renderers.ts +40 -828
  56. package/src/core/tools/review.ts +26 -7
  57. package/src/core/tools/rulebook.ts +3 -1
  58. package/src/core/tools/task/index.ts +18 -3
  59. package/src/core/tools/task/render.ts +5 -0
  60. package/src/core/tools/task/types.ts +1 -1
  61. package/src/core/tools/truncate.ts +27 -1
  62. package/src/core/tools/web-fetch.ts +23 -15
  63. package/src/core/tools/web-search/index.ts +130 -45
  64. package/src/core/tools/web-search/providers/anthropic.ts +7 -2
  65. package/src/core/tools/web-search/providers/exa.ts +2 -1
  66. package/src/core/tools/web-search/providers/perplexity.ts +6 -1
  67. package/src/core/tools/web-search/render.ts +5 -0
  68. package/src/core/tools/web-search/types.ts +13 -0
  69. package/src/core/tools/write.ts +90 -0
  70. package/src/core/voice.ts +1 -1
  71. package/src/main.ts +1 -1
  72. package/src/modes/interactive/components/assistant-message.ts +1 -1
  73. package/src/modes/interactive/components/custom-message.ts +1 -1
  74. package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
  75. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  76. package/src/modes/interactive/components/footer.ts +1 -1
  77. package/src/modes/interactive/components/hook-message.ts +1 -1
  78. package/src/modes/interactive/components/model-selector.ts +1 -1
  79. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  80. package/src/modes/interactive/components/settings-defs.ts +49 -0
  81. package/src/modes/interactive/components/status-line.ts +1 -1
  82. package/src/modes/interactive/components/tool-execution.ts +93 -538
  83. package/src/modes/interactive/interactive-mode.ts +19 -7
  84. package/src/modes/print-mode.ts +1 -1
  85. package/src/modes/rpc/rpc-client.ts +1 -1
  86. package/src/modes/rpc/rpc-types.ts +1 -1
  87. package/src/prompts/system-prompt.md +4 -0
  88. package/src/prompts/tools/gemini-image.md +5 -1
  89. package/src/prompts/tools/output.md +4 -0
  90. package/src/prompts/tools/web-fetch.md +1 -0
  91. package/src/prompts/tools/web-search.md +2 -0
  92. package/src/utils/image-convert.ts +8 -2
  93. package/src/utils/image-magick.ts +247 -0
  94. package/src/utils/image-resize.ts +53 -13
@@ -1,10 +1,24 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import nodePath from "node:path";
3
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
+ import type { Component } from "@oh-my-pi/pi-tui";
5
+ import { Text } from "@oh-my-pi/pi-tui";
4
6
  import { Type } from "@sinclair/typebox";
7
+ import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
8
+ import type { RenderResultOptions } from "../custom-tools/types";
5
9
  import { untilAborted } from "../utils";
6
10
  import { resolveToCwd } from "./path-utils";
7
- import { formatAge } from "./render-utils";
11
+ import {
12
+ formatAge,
13
+ formatBytes,
14
+ formatCount,
15
+ formatEmptyMessage,
16
+ formatExpandHint,
17
+ formatMeta,
18
+ formatMoreItems,
19
+ formatTruncationSuffix,
20
+ PREVIEW_LIMITS,
21
+ } from "./render-utils";
8
22
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate";
9
23
 
10
24
  const lsSchema = Type.Object({
@@ -147,3 +161,122 @@ export function createLsTool(cwd: string): AgentTool<typeof lsSchema> {
147
161
 
148
162
  /** Default ls tool using process.cwd() - for backwards compatibility */
149
163
  export const lsTool = createLsTool(process.cwd());
164
+
165
+ // =============================================================================
166
+ // TUI Renderer
167
+ // =============================================================================
168
+
169
+ interface LsRenderArgs {
170
+ path?: string;
171
+ limit?: number;
172
+ }
173
+
174
+ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
175
+
176
+ export const lsToolRenderer = {
177
+ renderCall(args: LsRenderArgs, uiTheme: Theme): Component {
178
+ const label = uiTheme.fg("toolTitle", uiTheme.bold("Ls"));
179
+ let text = `${label} ${uiTheme.fg("accent", args.path || ".")}`;
180
+
181
+ const meta: string[] = [];
182
+ if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
183
+ text += formatMeta(meta, uiTheme);
184
+
185
+ return new Text(text, 0, 0);
186
+ },
187
+
188
+ renderResult(
189
+ result: { content: Array<{ type: string; text?: string }>; details?: LsToolDetails },
190
+ { expanded }: RenderResultOptions,
191
+ uiTheme: Theme,
192
+ ): Component {
193
+ const details = result.details;
194
+ const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
195
+
196
+ if (
197
+ (!textContent || textContent.trim() === "" || textContent.trim() === "(empty directory)") &&
198
+ (!details?.entries || details.entries.length === 0)
199
+ ) {
200
+ return new Text(formatEmptyMessage("Empty directory", uiTheme), 0, 0);
201
+ }
202
+
203
+ let entries: string[] = details?.entries ? [...details.entries] : [];
204
+ if (entries.length === 0) {
205
+ const rawLines = textContent.split("\n").filter((l: string) => l.trim());
206
+ entries = rawLines.filter((line) => !/^\[.*\]$/.test(line.trim()));
207
+ }
208
+
209
+ if (entries.length === 0) {
210
+ return new Text(formatEmptyMessage("Empty directory", uiTheme), 0, 0);
211
+ }
212
+
213
+ let dirCount = details?.dirCount;
214
+ let fileCount = details?.fileCount;
215
+ if (dirCount === undefined || fileCount === undefined) {
216
+ dirCount = 0;
217
+ fileCount = 0;
218
+ for (const entry of entries) {
219
+ if (entry.endsWith("/")) {
220
+ dirCount += 1;
221
+ } else {
222
+ fileCount += 1;
223
+ }
224
+ }
225
+ }
226
+
227
+ const truncated = Boolean(details?.truncation?.truncated || details?.entryLimitReached);
228
+ const icon = truncated
229
+ ? uiTheme.styledSymbol("status.warning", "warning")
230
+ : uiTheme.styledSymbol("status.success", "success");
231
+
232
+ const summaryText = [formatCount("dir", dirCount ?? 0), formatCount("file", fileCount ?? 0)].join(
233
+ uiTheme.sep.dot,
234
+ );
235
+ const maxEntries = expanded ? entries.length : Math.min(entries.length, COLLAPSED_LIST_LIMIT);
236
+ const hasMoreEntries = entries.length > maxEntries;
237
+ const expandHint = formatExpandHint(expanded, hasMoreEntries, uiTheme);
238
+
239
+ let text = `${icon} ${uiTheme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, uiTheme)}${expandHint}`;
240
+
241
+ const truncationReasons: string[] = [];
242
+ if (details?.entryLimitReached) {
243
+ truncationReasons.push(`entry limit ${details.entryLimitReached}`);
244
+ }
245
+ if (details?.truncation?.truncated) {
246
+ truncationReasons.push(`output cap ${formatBytes(details.truncation.maxBytes)}`);
247
+ }
248
+
249
+ const hasTruncation = truncationReasons.length > 0;
250
+
251
+ for (let i = 0; i < maxEntries; i++) {
252
+ const entry = entries[i];
253
+ const isLast = i === maxEntries - 1 && !hasMoreEntries && !hasTruncation;
254
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
255
+ const isDir = entry.endsWith("/");
256
+ const entryPath = isDir ? entry.slice(0, -1) : entry;
257
+ const lang = isDir ? undefined : getLanguageFromPath(entryPath);
258
+ const entryIcon = isDir
259
+ ? uiTheme.fg("accent", uiTheme.icon.folder)
260
+ : uiTheme.fg("muted", uiTheme.getLangIcon(lang));
261
+ const entryColor = isDir ? "accent" : "toolOutput";
262
+ text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg(entryColor, entry)}`;
263
+ }
264
+
265
+ if (hasMoreEntries) {
266
+ const moreEntriesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last;
267
+ text += `\n ${uiTheme.fg("dim", moreEntriesBranch)} ${uiTheme.fg(
268
+ "muted",
269
+ formatMoreItems(entries.length - maxEntries, "entry", uiTheme),
270
+ )}`;
271
+ }
272
+
273
+ if (hasTruncation) {
274
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
275
+ "warning",
276
+ `truncated: ${truncationReasons.join(", ")}`,
277
+ )}`;
278
+ }
279
+
280
+ return new Text(text, 0, 0);
281
+ },
282
+ };
@@ -242,7 +242,10 @@ function renderDiagnostics(
242
242
  `[${item.severity}]`,
243
243
  )}`;
244
244
  if (item.message) {
245
- output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg("muted", truncate(item.message, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis))}`;
245
+ output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg(
246
+ "muted",
247
+ truncate(item.message, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis),
248
+ )}`;
246
249
  }
247
250
  }
248
251
  return new Text(output, 0, 0);
@@ -271,7 +274,10 @@ function renderDiagnostics(
271
274
  output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)}${message}`;
272
275
  }
273
276
  if (remaining > 0) {
274
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)}`;
277
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
278
+ "muted",
279
+ `${theme.format.ellipsis} ${remaining} more`,
280
+ )}`;
275
281
  }
276
282
 
277
283
  return new Text(output, 0, 0);
@@ -436,10 +442,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
436
442
  const isLast = isLastSibling(i);
437
443
  const branch = isLast ? theme.tree.last : theme.tree.branch;
438
444
  const detailPrefix = isLast ? " " : `${theme.tree.vertical} `;
439
- output += `\n${prefix}${theme.fg("dim", branch)} ${theme.fg("accent", sym.icon)} ${theme.fg(
440
- "accent",
441
- sym.name,
442
- )}`;
445
+ output += `\n${prefix}${theme.fg("dim", branch)} ${theme.fg("accent", sym.icon)} ${theme.fg("accent", sym.name)}`;
443
446
  output += `\n${prefix}${theme.fg("dim", detailPrefix)}${theme.fg("muted", `line ${sym.line}`)}`;
444
447
  }
445
448
  return new Text(output, 0, 0);
@@ -454,13 +457,16 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
454
457
  const sym = topLevel[i];
455
458
  const isLast = i === topLevel.length - 1 && topLevelCount <= 3;
456
459
  const branch = isLast ? theme.tree.last : theme.tree.branch;
457
- output += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", sym.icon)} ${theme.fg(
458
- "accent",
459
- sym.name,
460
- )} ${theme.fg("muted", `line ${sym.line}`)}`;
460
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", sym.icon)} ${theme.fg("accent", sym.name)} ${theme.fg(
461
+ "muted",
462
+ `line ${sym.line}`,
463
+ )}`;
461
464
  }
462
465
  if (topLevelCount > 3) {
463
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${topLevelCount - 3} more`)}`;
466
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
467
+ "muted",
468
+ `${theme.format.ellipsis} ${topLevelCount - 3} more`,
469
+ )}`;
464
470
  }
465
471
 
466
472
  return new Text(output, 0, 0);
@@ -496,17 +502,26 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
496
502
 
497
503
  const firstLine = lines[0] || "No output";
498
504
  const expandHint = formatExpandHint(false, lines.length > 1, theme);
499
- let output = `${icon} ${theme.fg("dim", truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis))}${expandHint}`;
505
+ let output = `${icon} ${theme.fg(
506
+ "dim",
507
+ truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis),
508
+ )}${expandHint}`;
500
509
 
501
510
  if (lines.length > 1) {
502
511
  const previewLines = lines.slice(1, 4);
503
512
  for (let i = 0; i < previewLines.length; i++) {
504
513
  const isLast = i === previewLines.length - 1 && lines.length <= 4;
505
514
  const branch = isLast ? theme.tree.last : theme.tree.branch;
506
- output += `\n ${theme.fg("dim", branch)} ${theme.fg("dim", truncate(previewLines[i].trim(), TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis))}`;
515
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg(
516
+ "dim",
517
+ truncate(previewLines[i].trim(), TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis),
518
+ )}`;
507
519
  }
508
520
  if (lines.length > 4) {
509
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(lines.length - 4, "line", theme))}`;
521
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
522
+ "muted",
523
+ formatMoreItems(lines.length - 4, "line", theme),
524
+ )}`;
510
525
  }
511
526
  }
512
527
 
@@ -550,3 +565,8 @@ function severityToColor(severity: string): "error" | "warning" | "accent" | "di
550
565
  return "dim";
551
566
  }
552
567
  }
568
+
569
+ export const lspToolRenderer = {
570
+ renderCall,
571
+ renderResult,
572
+ };
@@ -1,7 +1,19 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type { Component } from "@oh-my-pi/pi-tui";
3
+ import { Text } from "@oh-my-pi/pi-tui";
2
4
  import { Type } from "@sinclair/typebox";
5
+ import type { Theme } from "../../modes/interactive/theme/theme";
6
+ import type { RenderResultOptions } from "../custom-tools/types";
3
7
  import { untilAborted } from "../utils";
4
8
  import { resolveToCwd } from "./path-utils";
9
+ import {
10
+ formatCount,
11
+ formatErrorMessage,
12
+ formatExpandHint,
13
+ formatMeta,
14
+ formatMoreItems,
15
+ PREVIEW_LIMITS,
16
+ } from "./render-utils";
5
17
 
6
18
  const notebookSchema = Type.Object({
7
19
  action: Type.Union([Type.Literal("edit"), Type.Literal("insert"), Type.Literal("delete")], {
@@ -180,3 +192,101 @@ export function createNotebookTool(cwd: string): AgentTool<typeof notebookSchema
180
192
 
181
193
  /** Default notebook tool using process.cwd() */
182
194
  export const notebookTool = createNotebookTool(process.cwd());
195
+
196
+ // =============================================================================
197
+ // TUI Renderer
198
+ // =============================================================================
199
+
200
+ interface NotebookRenderArgs {
201
+ action: string;
202
+ notebookPath: string;
203
+ cellNumber?: number;
204
+ cellType?: string;
205
+ content?: string;
206
+ }
207
+
208
+ const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
209
+
210
+ function normalizeCellLines(lines: string[]): string[] {
211
+ return lines.map((line) => (line.endsWith("\n") ? line.slice(0, -1) : line));
212
+ }
213
+
214
+ function renderCellPreview(lines: string[], expanded: boolean, uiTheme: Theme): string {
215
+ const normalized = normalizeCellLines(lines);
216
+ if (normalized.length === 0) {
217
+ return `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "(empty cell)")}`;
218
+ }
219
+
220
+ const maxLines = expanded ? normalized.length : Math.min(normalized.length, COLLAPSED_TEXT_LIMIT);
221
+ let text = "";
222
+
223
+ for (let i = 0; i < maxLines; i++) {
224
+ const isLast = i === maxLines - 1 && (expanded || normalized.length <= maxLines);
225
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
226
+ const line = normalized[i];
227
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("toolOutput", line)}`;
228
+ }
229
+
230
+ const remaining = normalized.length - maxLines;
231
+ if (remaining > 0) {
232
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
233
+ "muted",
234
+ formatMoreItems(remaining, "line", uiTheme),
235
+ )}`;
236
+ }
237
+
238
+ return text;
239
+ }
240
+
241
+ export const notebookToolRenderer = {
242
+ renderCall(args: NotebookRenderArgs, uiTheme: Theme): Component {
243
+ const label = uiTheme.fg("toolTitle", uiTheme.bold("Notebook"));
244
+ let text = `${label} ${uiTheme.fg("accent", args.action || "?")}`;
245
+
246
+ const meta: string[] = [];
247
+ meta.push(`in ${args.notebookPath || "?"}`);
248
+ if (args.cellNumber !== undefined) meta.push(`cell:${args.cellNumber}`);
249
+ if (args.cellType) meta.push(`type:${args.cellType}`);
250
+
251
+ text += formatMeta(meta, uiTheme);
252
+
253
+ return new Text(text, 0, 0);
254
+ },
255
+
256
+ renderResult(
257
+ result: { content: Array<{ type: string; text?: string }>; details?: NotebookToolDetails },
258
+ { expanded }: RenderResultOptions,
259
+ uiTheme: Theme,
260
+ ): Component {
261
+ const content = result.content?.[0];
262
+ if (content?.type === "text" && content.text?.startsWith("Error:")) {
263
+ return new Text(formatErrorMessage(content.text, uiTheme), 0, 0);
264
+ }
265
+
266
+ const details = result.details;
267
+ const action = details?.action ?? "edit";
268
+ const cellIndex = details?.cellIndex;
269
+ const cellType = details?.cellType;
270
+ const totalCells = details?.totalCells;
271
+ const cellSource = details?.cellSource;
272
+ const lineCount = cellSource?.length;
273
+ const canExpand = cellSource !== undefined && cellSource.length > COLLAPSED_TEXT_LIMIT;
274
+
275
+ const icon = uiTheme.styledSymbol("status.success", "success");
276
+ const actionLabel = action === "insert" ? "Inserted" : action === "delete" ? "Deleted" : "Edited";
277
+ const cellLabel = cellType || "cell";
278
+ const summaryParts = [`${actionLabel} ${cellLabel} at index ${cellIndex ?? "?"}`];
279
+ if (lineCount !== undefined) summaryParts.push(formatCount("line", lineCount));
280
+ if (totalCells !== undefined) summaryParts.push(`${totalCells} total`);
281
+ const summaryText = summaryParts.join(uiTheme.sep.dot);
282
+
283
+ const expandHint = formatExpandHint(expanded, canExpand, uiTheme);
284
+ let text = `${icon} ${uiTheme.fg("dim", summaryText)}${expandHint}`;
285
+
286
+ if (cellSource) {
287
+ text += renderCellPreview(cellSource, expanded, uiTheme);
288
+ }
289
+
290
+ return new Text(text, 0, 0);
291
+ },
292
+ };
@@ -6,11 +6,24 @@
6
6
 
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
+ import type { TextContent } from "@mariozechner/pi-ai";
9
10
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
10
- import type { TextContent } from "@oh-my-pi/pi-ai";
11
+ import type { Component } from "@oh-my-pi/pi-tui";
12
+ import { Text } from "@oh-my-pi/pi-tui";
11
13
  import { Type } from "@sinclair/typebox";
14
+ import type { Theme } from "../../modes/interactive/theme/theme";
12
15
  import outputDescription from "../../prompts/tools/output.md" with { type: "text" };
16
+ import type { RenderResultOptions } from "../custom-tools/types";
13
17
  import type { SessionContext } from "./index";
18
+ import {
19
+ formatCount,
20
+ formatEmptyMessage,
21
+ formatExpandHint,
22
+ formatMeta,
23
+ formatMoreItems,
24
+ TRUNCATE_LENGTHS,
25
+ truncate,
26
+ } from "./render-utils";
14
27
  import { getArtifactsDir } from "./task/artifacts";
15
28
 
16
29
  const outputSchema = Type.Object({
@@ -23,6 +36,18 @@ const outputSchema = Type.Object({
23
36
  description: "Output format: raw (default), json (structured), stripped (no ANSI)",
24
37
  }),
25
38
  ),
39
+ offset: Type.Optional(
40
+ Type.Number({
41
+ description: "Line number to start reading from (1-indexed)",
42
+ minimum: 1,
43
+ }),
44
+ ),
45
+ limit: Type.Optional(
46
+ Type.Number({
47
+ description: "Maximum number of lines to read",
48
+ minimum: 1,
49
+ }),
50
+ ),
26
51
  });
27
52
 
28
53
  /** Metadata for a single output file */
@@ -31,6 +56,12 @@ interface OutputProvenance {
31
56
  index: number;
32
57
  }
33
58
 
59
+ interface OutputRange {
60
+ startLine: number;
61
+ endLine: number;
62
+ totalLines: number;
63
+ }
64
+
34
65
  interface OutputEntry {
35
66
  id: string;
36
67
  path: string;
@@ -38,6 +69,7 @@ interface OutputEntry {
38
69
  charCount: number;
39
70
  provenance?: OutputProvenance;
40
71
  previewLines?: string[];
72
+ range?: OutputRange;
41
73
  }
42
74
 
43
75
  export interface OutputToolDetails {
@@ -99,7 +131,7 @@ export function createOutputTool(
99
131
  parameters: outputSchema,
100
132
  execute: async (
101
133
  _toolCallId: string,
102
- params: { ids: string[]; format?: "raw" | "json" | "stripped" },
134
+ params: { ids: string[]; format?: "raw" | "json" | "stripped"; offset?: number; limit?: number },
103
135
  ): Promise<{ content: TextContent[]; details: OutputToolDetails }> => {
104
136
  const sessionFile = sessionContext?.getSessionFile();
105
137
 
@@ -131,15 +163,37 @@ export function createOutputTool(
131
163
  continue;
132
164
  }
133
165
 
134
- const content = fs.readFileSync(outputPath, "utf-8");
135
- outputContentById.set(id, content);
166
+ const rawContent = fs.readFileSync(outputPath, "utf-8");
167
+ const rawLines = rawContent.split("\n");
168
+ const totalLines = rawLines.length;
169
+ const totalChars = rawContent.length;
170
+
171
+ let selectedContent = rawContent;
172
+ let range: OutputRange | undefined;
173
+
174
+ if (params.offset !== undefined || params.limit !== undefined) {
175
+ const startLine = Math.max(1, params.offset ?? 1);
176
+ if (startLine > totalLines) {
177
+ throw new Error(
178
+ `Offset ${params.offset ?? startLine} is beyond end of output (${totalLines} lines) for ${id}`,
179
+ );
180
+ }
181
+ const effectiveLimit = params.limit ?? totalLines - startLine + 1;
182
+ const endLine = Math.min(totalLines, startLine + effectiveLimit - 1);
183
+ const selectedLines = rawLines.slice(startLine - 1, endLine);
184
+ selectedContent = selectedLines.join("\n");
185
+ range = { startLine, endLine, totalLines };
186
+ }
187
+
188
+ outputContentById.set(id, selectedContent);
136
189
  outputs.push({
137
190
  id,
138
191
  path: outputPath,
139
- lineCount: content.split("\n").length,
140
- charCount: content.length,
192
+ lineCount: totalLines,
193
+ charCount: totalChars,
141
194
  provenance: parseOutputProvenance(id),
142
- previewLines: extractPreviewLines(content, 4),
195
+ previewLines: extractPreviewLines(selectedContent, 4),
196
+ range,
143
197
  });
144
198
  }
145
199
 
@@ -167,6 +221,7 @@ export function createOutputTool(
167
221
  charCount: o.charCount,
168
222
  provenance: o.provenance,
169
223
  previewLines: o.previewLines,
224
+ range: o.range,
170
225
  content: outputContentById.get(o.id) ?? "",
171
226
  }));
172
227
  contentText = JSON.stringify(jsonData, null, 2);
@@ -177,6 +232,10 @@ export function createOutputTool(
177
232
  if (format === "stripped") {
178
233
  content = stripAnsi(content);
179
234
  }
235
+ if (o.range && o.range.endLine < o.range.totalLines) {
236
+ const nextOffset = o.range.endLine + 1;
237
+ content += `\n\n[Showing lines ${o.range.startLine}-${o.range.endLine} of ${o.range.totalLines}. Use offset=${nextOffset} to continue]`;
238
+ }
180
239
  // Add header for multiple outputs
181
240
  if (outputs.length > 1) {
182
241
  return `=== ${o.id} (${o.lineCount} lines, ${formatBytes(o.charCount)}) ===\n${content}`;
@@ -196,3 +255,116 @@ export function createOutputTool(
196
255
 
197
256
  /** Default output tool using process.cwd() - for backwards compatibility */
198
257
  export const outputTool = createOutputTool(process.cwd());
258
+
259
+ // =============================================================================
260
+ // TUI Renderer
261
+ // =============================================================================
262
+
263
+ interface OutputRenderArgs {
264
+ ids: string[];
265
+ format?: "raw" | "json" | "stripped";
266
+ offset?: number;
267
+ limit?: number;
268
+ }
269
+
270
+ type OutputEntryItem = OutputToolDetails["outputs"][number];
271
+
272
+ function formatOutputMeta(entry: OutputEntryItem, uiTheme: Theme): string {
273
+ const metaParts: string[] = [];
274
+ if (entry.range) {
275
+ metaParts.push(`lines ${entry.range.startLine}-${entry.range.endLine} of ${entry.range.totalLines}`);
276
+ } else {
277
+ metaParts.push(formatCount("line", entry.lineCount));
278
+ }
279
+ metaParts.push(formatBytes(entry.charCount));
280
+ if (entry.provenance) {
281
+ metaParts.push(`agent ${entry.provenance.agent}(${entry.provenance.index})`);
282
+ }
283
+ return uiTheme.fg("dim", metaParts.join(uiTheme.sep.dot));
284
+ }
285
+
286
+ export const outputToolRenderer = {
287
+ renderCall(args: OutputRenderArgs, uiTheme: Theme): Component {
288
+ const ids = args.ids?.join(", ") ?? "?";
289
+ const label = uiTheme.fg("toolTitle", uiTheme.bold("Output"));
290
+ let text = `${label} ${uiTheme.fg("accent", ids)}`;
291
+
292
+ const meta: string[] = [];
293
+ if (args.format && args.format !== "raw") meta.push(`format:${args.format}`);
294
+ if (args.offset !== undefined) meta.push(`offset:${args.offset}`);
295
+ if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
296
+ text += formatMeta(meta, uiTheme);
297
+
298
+ return new Text(text, 0, 0);
299
+ },
300
+
301
+ renderResult(
302
+ result: { content: Array<{ type: string; text?: string }>; details?: OutputToolDetails },
303
+ { expanded }: RenderResultOptions,
304
+ uiTheme: Theme,
305
+ ): Component {
306
+ const details = result.details;
307
+
308
+ if (details?.notFound?.length) {
309
+ const icon = uiTheme.styledSymbol("status.error", "error");
310
+ let text = `${icon} ${uiTheme.fg("error", `Error: Not found: ${details.notFound.join(", ")}`)}`;
311
+ if (details.availableIds?.length) {
312
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
313
+ "muted",
314
+ `Available: ${details.availableIds.join(", ")}`,
315
+ )}`;
316
+ } else {
317
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
318
+ "muted",
319
+ "No outputs available in current session",
320
+ )}`;
321
+ }
322
+ return new Text(text, 0, 0);
323
+ }
324
+
325
+ const outputs = details?.outputs ?? [];
326
+
327
+ if (outputs.length === 0) {
328
+ const textContent = result.content?.find((c) => c.type === "text")?.text;
329
+ return new Text(formatEmptyMessage(textContent || "No outputs", uiTheme), 0, 0);
330
+ }
331
+
332
+ const icon = uiTheme.styledSymbol("status.success", "success");
333
+ const summary = `read ${formatCount("output", outputs.length)}`;
334
+ const previewLimit = expanded ? 3 : 1;
335
+ const maxOutputs = expanded ? outputs.length : Math.min(outputs.length, 5);
336
+ const hasMoreOutputs = outputs.length > maxOutputs;
337
+ const hasMorePreview = outputs.some((o) => (o.previewLines?.length ?? 0) > previewLimit);
338
+ const expandHint = formatExpandHint(expanded, hasMoreOutputs || hasMorePreview, uiTheme);
339
+ let text = `${icon} ${uiTheme.fg("dim", summary)}${expandHint}`;
340
+
341
+ for (let i = 0; i < maxOutputs; i++) {
342
+ const o = outputs[i];
343
+ const isLast = i === maxOutputs - 1 && !hasMoreOutputs;
344
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
345
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", o.id)} ${formatOutputMeta(o, uiTheme)}`;
346
+
347
+ const previewLines = o.previewLines ?? [];
348
+ const shownPreview = previewLines.slice(0, previewLimit);
349
+ if (shownPreview.length > 0) {
350
+ const childPrefix = isLast ? " " : ` ${uiTheme.fg("dim", uiTheme.tree.vertical)} `;
351
+ for (const line of shownPreview) {
352
+ const previewText = truncate(line, TRUNCATE_LENGTHS.CONTENT, uiTheme.format.ellipsis);
353
+ text += `\n${childPrefix}${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
354
+ "muted",
355
+ "preview:",
356
+ )} ${uiTheme.fg("toolOutput", previewText)}`;
357
+ }
358
+ }
359
+ }
360
+
361
+ if (hasMoreOutputs) {
362
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
363
+ "muted",
364
+ formatMoreItems(outputs.length - maxOutputs, "output", uiTheme),
365
+ )}`;
366
+ }
367
+
368
+ return new Text(text, 0, 0);
369
+ },
370
+ };