@oh-my-pi/pi-coding-agent 3.20.1 → 3.24.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 (123) hide show
  1. package/CHANGELOG.md +107 -8
  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 +50 -53
  6. package/examples/custom-tools/README.md +2 -17
  7. package/examples/extensions/README.md +76 -74
  8. package/examples/extensions/todo.ts +2 -5
  9. package/examples/hooks/custom-compaction.ts +2 -4
  10. package/examples/hooks/handoff.ts +1 -1
  11. package/examples/hooks/qna.ts +1 -1
  12. package/examples/sdk/02-custom-model.ts +1 -1
  13. package/examples/sdk/README.md +7 -11
  14. package/package.json +6 -6
  15. package/src/cli/args.ts +9 -6
  16. package/src/cli/file-processor.ts +1 -1
  17. package/src/cli/list-models.ts +1 -1
  18. package/src/core/agent-session.ts +16 -5
  19. package/src/core/auth-storage.ts +1 -1
  20. package/src/core/compaction/branch-summarization.ts +2 -2
  21. package/src/core/compaction/compaction.ts +2 -2
  22. package/src/core/compaction/utils.ts +1 -1
  23. package/src/core/custom-tools/types.ts +1 -1
  24. package/src/core/custom-tools/wrapper.ts +0 -1
  25. package/src/core/extensions/index.ts +1 -6
  26. package/src/core/extensions/runner.ts +1 -1
  27. package/src/core/extensions/types.ts +1 -1
  28. package/src/core/extensions/wrapper.ts +1 -8
  29. package/src/core/file-mentions.ts +5 -8
  30. package/src/core/hooks/runner.ts +2 -2
  31. package/src/core/hooks/types.ts +1 -1
  32. package/src/core/messages.ts +1 -1
  33. package/src/core/model-registry.ts +1 -1
  34. package/src/core/model-resolver.ts +1 -1
  35. package/src/core/sdk.ts +64 -105
  36. package/src/core/session-manager.ts +18 -22
  37. package/src/core/settings-manager.ts +66 -1
  38. package/src/core/slash-commands.ts +12 -5
  39. package/src/core/system-prompt.ts +49 -36
  40. package/src/core/title-generator.ts +2 -2
  41. package/src/core/tools/ask.ts +98 -4
  42. package/src/core/tools/bash-interceptor.ts +11 -4
  43. package/src/core/tools/bash.ts +121 -5
  44. package/src/core/tools/context.ts +7 -0
  45. package/src/core/tools/edit-diff.ts +73 -24
  46. package/src/core/tools/edit.ts +221 -34
  47. package/src/core/tools/exa/render.ts +4 -16
  48. package/src/core/tools/find.ts +149 -5
  49. package/src/core/tools/gemini-image.ts +279 -56
  50. package/src/core/tools/git.ts +17 -3
  51. package/src/core/tools/grep.ts +185 -5
  52. package/src/core/tools/index.test.ts +180 -0
  53. package/src/core/tools/index.ts +96 -242
  54. package/src/core/tools/ls.ts +133 -5
  55. package/src/core/tools/lsp/index.ts +32 -29
  56. package/src/core/tools/lsp/render.ts +21 -22
  57. package/src/core/tools/notebook.ts +112 -4
  58. package/src/core/tools/output.ts +175 -15
  59. package/src/core/tools/read.ts +127 -25
  60. package/src/core/tools/render-utils.ts +241 -0
  61. package/src/core/tools/renderers.ts +40 -828
  62. package/src/core/tools/review.ts +26 -25
  63. package/src/core/tools/rulebook.ts +11 -3
  64. package/src/core/tools/task/agents.ts +28 -7
  65. package/src/core/tools/task/discovery.ts +0 -6
  66. package/src/core/tools/task/executor.ts +264 -254
  67. package/src/core/tools/task/index.ts +48 -208
  68. package/src/core/tools/task/render.ts +26 -11
  69. package/src/core/tools/task/types.ts +7 -12
  70. package/src/core/tools/task/worker-protocol.ts +17 -0
  71. package/src/core/tools/task/worker.ts +238 -0
  72. package/src/core/tools/truncate.ts +27 -1
  73. package/src/core/tools/web-fetch.ts +25 -49
  74. package/src/core/tools/web-search/index.ts +132 -46
  75. package/src/core/tools/web-search/providers/anthropic.ts +7 -2
  76. package/src/core/tools/web-search/providers/exa.ts +2 -1
  77. package/src/core/tools/web-search/providers/perplexity.ts +6 -1
  78. package/src/core/tools/web-search/render.ts +6 -4
  79. package/src/core/tools/web-search/types.ts +13 -0
  80. package/src/core/tools/write.ts +96 -14
  81. package/src/core/voice.ts +1 -1
  82. package/src/discovery/helpers.test.ts +1 -1
  83. package/src/index.ts +5 -16
  84. package/src/main.ts +5 -5
  85. package/src/modes/interactive/components/assistant-message.ts +1 -1
  86. package/src/modes/interactive/components/custom-message.ts +1 -1
  87. package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
  88. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  89. package/src/modes/interactive/components/footer.ts +1 -1
  90. package/src/modes/interactive/components/hook-message.ts +1 -1
  91. package/src/modes/interactive/components/model-selector.ts +1 -1
  92. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  93. package/src/modes/interactive/components/settings-defs.ts +49 -0
  94. package/src/modes/interactive/components/status-line.ts +1 -1
  95. package/src/modes/interactive/components/tool-execution.ts +93 -538
  96. package/src/modes/interactive/interactive-mode.ts +19 -7
  97. package/src/modes/interactive/theme/theme.ts +4 -4
  98. package/src/modes/print-mode.ts +1 -1
  99. package/src/modes/rpc/rpc-client.ts +1 -1
  100. package/src/modes/rpc/rpc-types.ts +1 -1
  101. package/src/prompts/system-prompt.md +4 -0
  102. package/src/prompts/task.md +0 -7
  103. package/src/prompts/tools/gemini-image.md +5 -1
  104. package/src/prompts/tools/output.md +6 -2
  105. package/src/prompts/tools/task.md +68 -0
  106. package/src/prompts/tools/web-fetch.md +1 -0
  107. package/src/prompts/tools/web-search.md +2 -0
  108. package/src/utils/image-convert.ts +8 -2
  109. package/src/utils/image-magick.ts +247 -0
  110. package/src/utils/image-resize.ts +53 -13
  111. package/examples/custom-tools/question/index.ts +0 -84
  112. package/examples/custom-tools/subagent/README.md +0 -172
  113. package/examples/custom-tools/subagent/agents/planner.md +0 -37
  114. package/examples/custom-tools/subagent/agents/scout.md +0 -50
  115. package/examples/custom-tools/subagent/agents/worker.md +0 -24
  116. package/examples/custom-tools/subagent/agents.ts +0 -156
  117. package/examples/custom-tools/subagent/commands/implement-and-review.md +0 -10
  118. package/examples/custom-tools/subagent/commands/implement.md +0 -10
  119. package/examples/custom-tools/subagent/commands/scout-and-plan.md +0 -9
  120. package/examples/custom-tools/subagent/index.ts +0 -1002
  121. package/examples/sdk/05-tools.ts +0 -94
  122. package/examples/sdk/12-full-control.ts +0 -95
  123. package/src/prompts/browser.md +0 -71
@@ -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" };
13
- import type { SessionContext } from "./index";
16
+ import type { RenderResultOptions } from "../custom-tools/types";
17
+ import type { ToolSession } 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 {
@@ -88,10 +120,7 @@ function extractPreviewLines(content: string, maxLines: number): string[] {
88
120
  return preview;
89
121
  }
90
122
 
91
- export function createOutputTool(
92
- _cwd: string,
93
- sessionContext?: SessionContext,
94
- ): AgentTool<typeof outputSchema, OutputToolDetails> {
123
+ export function createOutputTool(session: ToolSession): AgentTool<typeof outputSchema, OutputToolDetails> {
95
124
  return {
96
125
  name: "output",
97
126
  label: "Output",
@@ -99,9 +128,9 @@ export function createOutputTool(
99
128
  parameters: outputSchema,
100
129
  execute: async (
101
130
  _toolCallId: string,
102
- params: { ids: string[]; format?: "raw" | "json" | "stripped" },
131
+ params: { ids: string[]; format?: "raw" | "json" | "stripped"; offset?: number; limit?: number },
103
132
  ): Promise<{ content: TextContent[]; details: OutputToolDetails }> => {
104
- const sessionFile = sessionContext?.getSessionFile();
133
+ const sessionFile = session.getSessionFile();
105
134
 
106
135
  if (!sessionFile) {
107
136
  return {
@@ -131,15 +160,37 @@ export function createOutputTool(
131
160
  continue;
132
161
  }
133
162
 
134
- const content = fs.readFileSync(outputPath, "utf-8");
135
- outputContentById.set(id, content);
163
+ const rawContent = fs.readFileSync(outputPath, "utf-8");
164
+ const rawLines = rawContent.split("\n");
165
+ const totalLines = rawLines.length;
166
+ const totalChars = rawContent.length;
167
+
168
+ let selectedContent = rawContent;
169
+ let range: OutputRange | undefined;
170
+
171
+ if (params.offset !== undefined || params.limit !== undefined) {
172
+ const startLine = Math.max(1, params.offset ?? 1);
173
+ if (startLine > totalLines) {
174
+ throw new Error(
175
+ `Offset ${params.offset ?? startLine} is beyond end of output (${totalLines} lines) for ${id}`,
176
+ );
177
+ }
178
+ const effectiveLimit = params.limit ?? totalLines - startLine + 1;
179
+ const endLine = Math.min(totalLines, startLine + effectiveLimit - 1);
180
+ const selectedLines = rawLines.slice(startLine - 1, endLine);
181
+ selectedContent = selectedLines.join("\n");
182
+ range = { startLine, endLine, totalLines };
183
+ }
184
+
185
+ outputContentById.set(id, selectedContent);
136
186
  outputs.push({
137
187
  id,
138
188
  path: outputPath,
139
- lineCount: content.split("\n").length,
140
- charCount: content.length,
189
+ lineCount: totalLines,
190
+ charCount: totalChars,
141
191
  provenance: parseOutputProvenance(id),
142
- previewLines: extractPreviewLines(content, 4),
192
+ previewLines: extractPreviewLines(selectedContent, 4),
193
+ range,
143
194
  });
144
195
  }
145
196
 
@@ -167,6 +218,7 @@ export function createOutputTool(
167
218
  charCount: o.charCount,
168
219
  provenance: o.provenance,
169
220
  previewLines: o.previewLines,
221
+ range: o.range,
170
222
  content: outputContentById.get(o.id) ?? "",
171
223
  }));
172
224
  contentText = JSON.stringify(jsonData, null, 2);
@@ -177,6 +229,10 @@ export function createOutputTool(
177
229
  if (format === "stripped") {
178
230
  content = stripAnsi(content);
179
231
  }
232
+ if (o.range && o.range.endLine < o.range.totalLines) {
233
+ const nextOffset = o.range.endLine + 1;
234
+ content += `\n\n[Showing lines ${o.range.startLine}-${o.range.endLine} of ${o.range.totalLines}. Use offset=${nextOffset} to continue]`;
235
+ }
180
236
  // Add header for multiple outputs
181
237
  if (outputs.length > 1) {
182
238
  return `=== ${o.id} (${o.lineCount} lines, ${formatBytes(o.charCount)}) ===\n${content}`;
@@ -194,5 +250,109 @@ export function createOutputTool(
194
250
  };
195
251
  }
196
252
 
197
- /** Default output tool using process.cwd() - for backwards compatibility */
198
- export const outputTool = createOutputTool(process.cwd());
253
+ // =============================================================================
254
+ // TUI Renderer
255
+ // =============================================================================
256
+
257
+ interface OutputRenderArgs {
258
+ ids: string[];
259
+ format?: "raw" | "json" | "stripped";
260
+ offset?: number;
261
+ limit?: number;
262
+ }
263
+
264
+ type OutputEntryItem = OutputToolDetails["outputs"][number];
265
+
266
+ function formatOutputMeta(entry: OutputEntryItem, uiTheme: Theme): string {
267
+ const metaParts: string[] = [];
268
+ if (entry.range) {
269
+ metaParts.push(`lines ${entry.range.startLine}-${entry.range.endLine} of ${entry.range.totalLines}`);
270
+ } else {
271
+ metaParts.push(formatCount("line", entry.lineCount));
272
+ }
273
+ metaParts.push(formatBytes(entry.charCount));
274
+ if (entry.provenance) {
275
+ metaParts.push(`agent ${entry.provenance.agent}(${entry.provenance.index})`);
276
+ }
277
+ return uiTheme.fg("dim", metaParts.join(uiTheme.sep.dot));
278
+ }
279
+
280
+ export const outputToolRenderer = {
281
+ renderCall(args: OutputRenderArgs, uiTheme: Theme): Component {
282
+ const ids = args.ids?.join(", ") ?? "?";
283
+ const label = uiTheme.fg("toolTitle", uiTheme.bold("Output"));
284
+ let text = `${label} ${uiTheme.fg("accent", ids)}`;
285
+
286
+ const meta: string[] = [];
287
+ if (args.format && args.format !== "raw") meta.push(`format:${args.format}`);
288
+ if (args.offset !== undefined) meta.push(`offset:${args.offset}`);
289
+ if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
290
+ text += formatMeta(meta, uiTheme);
291
+
292
+ return new Text(text, 0, 0);
293
+ },
294
+
295
+ renderResult(
296
+ result: { content: Array<{ type: string; text?: string }>; details?: OutputToolDetails },
297
+ { expanded }: RenderResultOptions,
298
+ uiTheme: Theme,
299
+ ): Component {
300
+ const details = result.details;
301
+
302
+ if (details?.notFound?.length) {
303
+ const icon = uiTheme.styledSymbol("status.error", "error");
304
+ let text = `${icon} ${uiTheme.fg("error", `Error: Not found: ${details.notFound.join(", ")}`)}`;
305
+ if (details.availableIds?.length) {
306
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", `Available: ${details.availableIds.join(", ")}`)}`;
307
+ } else {
308
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "No outputs available in current session")}`;
309
+ }
310
+ return new Text(text, 0, 0);
311
+ }
312
+
313
+ const outputs = details?.outputs ?? [];
314
+
315
+ if (outputs.length === 0) {
316
+ const textContent = result.content?.find((c) => c.type === "text")?.text;
317
+ return new Text(formatEmptyMessage(textContent || "No outputs", uiTheme), 0, 0);
318
+ }
319
+
320
+ const icon = uiTheme.styledSymbol("status.success", "success");
321
+ const summary = `read ${formatCount("output", outputs.length)}`;
322
+ const previewLimit = expanded ? 3 : 1;
323
+ const maxOutputs = expanded ? outputs.length : Math.min(outputs.length, 5);
324
+ const hasMoreOutputs = outputs.length > maxOutputs;
325
+ const hasMorePreview = outputs.some((o) => (o.previewLines?.length ?? 0) > previewLimit);
326
+ const expandHint = formatExpandHint(expanded, hasMoreOutputs || hasMorePreview, uiTheme);
327
+ let text = `${icon} ${uiTheme.fg("dim", summary)}${expandHint}`;
328
+
329
+ for (let i = 0; i < maxOutputs; i++) {
330
+ const o = outputs[i];
331
+ const isLast = i === maxOutputs - 1 && !hasMoreOutputs;
332
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
333
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", o.id)} ${formatOutputMeta(o, uiTheme)}`;
334
+
335
+ const previewLines = o.previewLines ?? [];
336
+ const shownPreview = previewLines.slice(0, previewLimit);
337
+ if (shownPreview.length > 0) {
338
+ const childPrefix = isLast ? " " : ` ${uiTheme.fg("dim", uiTheme.tree.vertical)} `;
339
+ for (const line of shownPreview) {
340
+ const previewText = truncate(line, TRUNCATE_LENGTHS.CONTENT, uiTheme.format.ellipsis);
341
+ text += `\n${childPrefix}${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
342
+ "muted",
343
+ "preview:",
344
+ )} ${uiTheme.fg("toolOutput", previewText)}`;
345
+ }
346
+ }
347
+ }
348
+
349
+ if (hasMoreOutputs) {
350
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
351
+ "muted",
352
+ formatMoreItems(outputs.length - maxOutputs, "output", uiTheme),
353
+ )}`;
354
+ }
355
+
356
+ return new Text(text, 0, 0);
357
+ },
358
+ };
@@ -1,17 +1,30 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
3
4
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
- import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
5
+ import type { Component } from "@oh-my-pi/pi-tui";
6
+ import { Text } from "@oh-my-pi/pi-tui";
5
7
  import { Type } from "@sinclair/typebox";
6
8
  import { globSync } from "glob";
9
+ import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme";
7
10
  import readDescription from "../../prompts/tools/read.md" with { type: "text" };
8
11
  import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
9
12
  import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
10
13
  import { ensureTool } from "../../utils/tools-manager";
14
+ import type { RenderResultOptions } from "../custom-tools/types";
15
+ import type { ToolSession } from "../sdk";
11
16
  import { untilAborted } from "../utils";
12
17
  import { createLsTool } from "./ls";
13
18
  import { resolveReadPath, resolveToCwd } from "./path-utils";
14
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate";
19
+ import { replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
20
+ import {
21
+ DEFAULT_MAX_BYTES,
22
+ DEFAULT_MAX_LINES,
23
+ formatSize,
24
+ type TruncationResult,
25
+ truncateHead,
26
+ truncateStringToBytesFromStart,
27
+ } from "./truncate";
15
28
 
16
29
  // Document types convertible via markitdown
17
30
  const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
@@ -328,14 +341,9 @@ export interface ReadToolDetails {
328
341
  redirectedTo?: "ls";
329
342
  }
330
343
 
331
- export interface ReadToolOptions {
332
- /** Whether to auto-resize images to 2000x2000 max. Default: true */
333
- autoResizeImages?: boolean;
334
- }
335
-
336
- export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
337
- const autoResizeImages = options?.autoResizeImages ?? true;
338
- const lsTool = createLsTool(cwd);
344
+ export function createReadTool(session: ToolSession): AgentTool<typeof readSchema> {
345
+ const autoResizeImages = session.settings?.getImageAutoResize() ?? true;
346
+ const lsTool = createLsTool(session);
339
347
  return {
340
348
  name: "read",
341
349
  label: "Read",
@@ -346,7 +354,7 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
346
354
  { path: readPath, offset, limit }: { path: string; offset?: number; limit?: number },
347
355
  signal?: AbortSignal,
348
356
  ) => {
349
- const absolutePath = resolveReadPath(readPath, cwd);
357
+ const absolutePath = resolveReadPath(readPath, session.cwd);
350
358
 
351
359
  return untilAborted(signal, async () => {
352
360
  let isDirectory = false;
@@ -366,14 +374,12 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
366
374
  }
367
375
  } catch (error) {
368
376
  if (isNotFoundError(error)) {
369
- const suggestions = await findReadPathSuggestions(readPath, cwd);
377
+ const suggestions = await findReadPathSuggestions(readPath, session.cwd);
370
378
  let message = `File not found: ${readPath}`;
371
379
 
372
380
  if (suggestions?.suggestions.length) {
373
381
  const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : "";
374
- message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions
375
- .map((match) => `- ${match}`)
376
- .join("\n")}`;
382
+ message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions.map((match) => `- ${match}`).join("\n")}`;
377
383
  if (suggestions.truncated) {
378
384
  message += `\n[Search truncated to first ${MAX_FUZZY_CANDIDATES} paths. Refine the path if the match isn't listed.]`;
379
385
  }
@@ -450,9 +456,7 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
450
456
  let outputText = truncation.content;
451
457
 
452
458
  if (truncation.truncated) {
453
- outputText += `\n\n[Document converted via markitdown. Output truncated to $formatSize(
454
- DEFAULT_MAX_BYTES,
455
- )]`;
459
+ outputText += `\n\n[Document converted via markitdown. Output truncated to ${formatSize(DEFAULT_MAX_BYTES)}]`;
456
460
  details = { truncation };
457
461
  }
458
462
 
@@ -498,11 +502,21 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
498
502
  let outputText: string;
499
503
 
500
504
  if (truncation.firstLineExceedsLimit) {
501
- // First line at offset exceeds 30KB - tell model to use bash
502
- const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
503
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(
504
- DEFAULT_MAX_BYTES,
505
- )} limit. Use bash: sed -n '${startLineDisplay}p' ${readPath} | head -c ${DEFAULT_MAX_BYTES}]`;
505
+ const firstLine = allLines[startLine] ?? "";
506
+ const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
507
+ const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES);
508
+ const shownSize = formatSize(snippet.bytes);
509
+
510
+ outputText = snippet.text;
511
+ if (outputText.length > 0) {
512
+ outputText += `\n\n[Line ${startLineDisplay} is ${formatSize(
513
+ firstLineBytes,
514
+ )}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Showing first ${shownSize} of the line.]`;
515
+ } else {
516
+ outputText = `[Line ${startLineDisplay} is ${formatSize(
517
+ firstLineBytes,
518
+ )}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`;
519
+ }
506
520
  details = { truncation };
507
521
  } else if (truncation.truncated) {
508
522
  // Truncation occurred - build actionable notice
@@ -540,5 +554,93 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
540
554
  };
541
555
  }
542
556
 
543
- /** Default read tool using process.cwd() - for backwards compatibility */
544
- export const readTool = createReadTool(process.cwd());
557
+ // =============================================================================
558
+ // TUI Renderer
559
+ // =============================================================================
560
+
561
+ interface ReadRenderArgs {
562
+ path?: string;
563
+ file_path?: string;
564
+ offset?: number;
565
+ limit?: number;
566
+ }
567
+
568
+ const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "tiff"]);
569
+ const BINARY_EXTENSIONS = new Set(["pdf", "zip", "tar", "gz", "exe", "dll", "so", "dylib", "wasm"]);
570
+
571
+ function getFileType(filePath: string): "image" | "binary" | "text" {
572
+ const ext = filePath.split(".").pop()?.toLowerCase();
573
+ if (!ext) return "text";
574
+ if (IMAGE_EXTENSIONS.has(ext)) return "image";
575
+ if (BINARY_EXTENSIONS.has(ext)) return "binary";
576
+ return "text";
577
+ }
578
+
579
+ export const readToolRenderer = {
580
+ renderCall(args: ReadRenderArgs, uiTheme: Theme): Component {
581
+ const rawPath = args.file_path || args.path || "";
582
+ const filePath = shortenPath(rawPath);
583
+ const offset = args.offset;
584
+ const limit = args.limit;
585
+
586
+ let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
587
+ if (offset !== undefined || limit !== undefined) {
588
+ const startLine = offset ?? 1;
589
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
590
+ pathDisplay += uiTheme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
591
+ }
592
+
593
+ const text = `${uiTheme.fg("toolTitle", uiTheme.bold("Read"))} ${pathDisplay}`;
594
+ return new Text(text, 0, 0);
595
+ },
596
+
597
+ renderResult(
598
+ result: { content: Array<{ type: string; text?: string }>; details?: ReadToolDetails },
599
+ { expanded }: RenderResultOptions,
600
+ uiTheme: Theme,
601
+ args?: ReadRenderArgs,
602
+ ): Component {
603
+ const rawPath = args?.file_path || args?.path || "";
604
+ const fileType = getFileType(rawPath);
605
+ const details = result.details;
606
+ const lines: string[] = [];
607
+
608
+ const output = result.content?.find((c) => c.type === "text")?.text ?? "";
609
+
610
+ if (fileType === "image") {
611
+ lines.push(uiTheme.fg("muted", "Image rendered below"));
612
+ } else if (fileType === "binary") {
613
+ // Binary files just show the header from renderCall
614
+ } else {
615
+ // Text file
616
+ const lang = getLanguageFromPath(rawPath);
617
+ const contentLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
618
+
619
+ if (expanded) {
620
+ lines.push(
621
+ ...contentLines.map((line: string) =>
622
+ lang ? replaceTabs(line) : uiTheme.fg("toolOutput", replaceTabs(line)),
623
+ ),
624
+ );
625
+ } else {
626
+ lines.push(uiTheme.fg("dim", `${uiTheme.nav.expand} Ctrl+O to show content`));
627
+ }
628
+
629
+ // Truncation warning
630
+ const truncation = details?.truncation;
631
+ if (truncation?.truncated) {
632
+ let warning: string;
633
+ if (truncation.firstLineExceedsLimit) {
634
+ warning = `First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`;
635
+ } else if (truncation.truncatedBy === "lines") {
636
+ warning = `Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`;
637
+ } else {
638
+ warning = `Truncated: ${truncation.outputLines} lines (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`;
639
+ }
640
+ lines.push(uiTheme.fg("warning", wrapBrackets(warning, uiTheme)));
641
+ }
642
+ }
643
+
644
+ return new Text(lines.join("\n"), 0, 0);
645
+ },
646
+ };