@oh-my-pi/pi-coding-agent 3.15.1 → 3.20.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 (127) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/gemini-image.ts +361 -0
  62. package/src/core/tools/git.ts +216 -0
  63. package/src/core/tools/index.ts +28 -15
  64. package/src/core/tools/lsp/config.ts +5 -4
  65. package/src/core/tools/lsp/index.ts +17 -12
  66. package/src/core/tools/lsp/render.ts +39 -47
  67. package/src/core/tools/read.ts +66 -29
  68. package/src/core/tools/render-utils.ts +268 -0
  69. package/src/core/tools/renderers.ts +243 -225
  70. package/src/core/tools/task/discovery.ts +2 -2
  71. package/src/core/tools/task/executor.ts +66 -58
  72. package/src/core/tools/task/index.ts +29 -10
  73. package/src/core/tools/task/model-resolver.ts +8 -13
  74. package/src/core/tools/task/omp-command.ts +24 -0
  75. package/src/core/tools/task/render.ts +35 -60
  76. package/src/core/tools/task/types.ts +3 -0
  77. package/src/core/tools/web-fetch.ts +29 -28
  78. package/src/core/tools/web-search/index.ts +6 -5
  79. package/src/core/tools/web-search/providers/exa.ts +6 -5
  80. package/src/core/tools/web-search/render.ts +66 -111
  81. package/src/core/voice-controller.ts +135 -0
  82. package/src/core/voice-supervisor.ts +1003 -0
  83. package/src/core/voice.ts +308 -0
  84. package/src/discovery/builtin.ts +75 -1
  85. package/src/discovery/claude.ts +47 -1
  86. package/src/discovery/codex.ts +54 -2
  87. package/src/discovery/gemini.ts +55 -2
  88. package/src/discovery/helpers.ts +100 -1
  89. package/src/discovery/index.ts +2 -0
  90. package/src/index.ts +14 -9
  91. package/src/lib/worktree/collapse.ts +179 -0
  92. package/src/lib/worktree/constants.ts +14 -0
  93. package/src/lib/worktree/errors.ts +23 -0
  94. package/src/lib/worktree/git.ts +110 -0
  95. package/src/lib/worktree/index.ts +23 -0
  96. package/src/lib/worktree/operations.ts +216 -0
  97. package/src/lib/worktree/session.ts +114 -0
  98. package/src/lib/worktree/stats.ts +67 -0
  99. package/src/main.ts +61 -37
  100. package/src/migrations.ts +37 -7
  101. package/src/modes/interactive/components/bash-execution.ts +6 -4
  102. package/src/modes/interactive/components/custom-editor.ts +55 -0
  103. package/src/modes/interactive/components/custom-message.ts +95 -0
  104. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  105. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  106. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  107. package/src/modes/interactive/components/extensions/types.ts +1 -0
  108. package/src/modes/interactive/components/footer.ts +324 -0
  109. package/src/modes/interactive/components/hook-selector.ts +3 -3
  110. package/src/modes/interactive/components/model-selector.ts +7 -6
  111. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  112. package/src/modes/interactive/components/settings-defs.ts +55 -6
  113. package/src/modes/interactive/components/status-line.ts +45 -37
  114. package/src/modes/interactive/components/tool-execution.ts +95 -23
  115. package/src/modes/interactive/interactive-mode.ts +643 -113
  116. package/src/modes/interactive/theme/defaults/index.ts +16 -16
  117. package/src/modes/print-mode.ts +14 -72
  118. package/src/modes/rpc/rpc-client.ts +23 -9
  119. package/src/modes/rpc/rpc-mode.ts +137 -125
  120. package/src/modes/rpc/rpc-types.ts +46 -24
  121. package/src/prompts/task.md +1 -0
  122. package/src/prompts/tools/gemini-image.md +4 -0
  123. package/src/prompts/tools/git.md +9 -0
  124. package/src/prompts/voice-summary.md +12 -0
  125. package/src/utils/image-convert.ts +26 -0
  126. package/src/utils/image-resize.ts +215 -0
  127. package/src/utils/shell-snapshot.ts +22 -20
@@ -6,7 +6,7 @@
6
6
 
7
7
  import type { Component } from "@oh-my-pi/pi-tui";
8
8
  import { Text } from "@oh-my-pi/pi-tui";
9
- import type { Theme } from "../../modes/interactive/theme/theme";
9
+ import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
10
10
  import type { RenderResultOptions } from "../custom-tools/types";
11
11
  import type { AskToolDetails } from "./ask";
12
12
  import type { FindToolDetails } from "./find";
@@ -16,6 +16,15 @@ import { renderCall as renderLspCall, renderResult as renderLspResult } from "./
16
16
  import type { LspToolDetails } from "./lsp/types";
17
17
  import type { NotebookToolDetails } from "./notebook";
18
18
  import type { OutputToolDetails } from "./output";
19
+ import {
20
+ formatBytes,
21
+ formatCount,
22
+ formatExpandHint,
23
+ formatMoreItems,
24
+ PREVIEW_LIMITS,
25
+ TRUNCATE_LENGTHS,
26
+ truncate,
27
+ } from "./render-utils";
19
28
  import { renderCall as renderTaskCall, renderResult as renderTaskResult } from "./task/render";
20
29
  import type { TaskToolDetails } from "./task/types";
21
30
  import { renderWebFetchCall, renderWebFetchResult, type WebFetchToolDetails } from "./web-fetch";
@@ -32,6 +41,34 @@ interface ToolRenderer<TArgs = any, TDetails = any> {
32
41
  ): Component;
33
42
  }
34
43
 
44
+ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
45
+ const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
46
+
47
+ function formatMeta(meta: string[], theme: Theme): string {
48
+ return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
49
+ }
50
+
51
+ function formatScope(scopePath: string | undefined, theme: Theme): string {
52
+ return scopePath ? ` ${theme.fg("muted", `in ${scopePath}`)}` : "";
53
+ }
54
+
55
+ function formatTruncationSuffix(truncated: boolean, theme: Theme): string {
56
+ return truncated ? theme.fg("warning", " (truncated)") : "";
57
+ }
58
+
59
+ function renderErrorMessage(_toolLabel: string, message: string, theme: Theme): Text {
60
+ const clean = message.replace(/^Error:\s*/, "").trim();
61
+ return new Text(
62
+ `${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`,
63
+ 0,
64
+ 0,
65
+ );
66
+ }
67
+
68
+ function renderEmptyMessage(_toolLabel: string, message: string, theme: Theme): Text {
69
+ return new Text(`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`, 0, 0);
70
+ }
71
+
35
72
  // ============================================================================
36
73
  // Grep Renderer
37
74
  // ============================================================================
@@ -52,58 +89,55 @@ interface GrepArgs {
52
89
 
53
90
  const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
54
91
  renderCall(args, theme) {
55
- let text = theme.fg("toolTitle", theme.bold("grep "));
56
- text += theme.fg("accent", args.pattern || "?");
92
+ const label = theme.fg("toolTitle", theme.bold("Grep"));
93
+ let text = `${label} ${theme.fg("accent", args.pattern || "?")}`;
57
94
 
58
95
  const meta: string[] = [];
59
- if (args.path) meta.push(args.path);
96
+ if (args.path) meta.push(`in ${args.path}`);
60
97
  if (args.glob) meta.push(`glob:${args.glob}`);
61
98
  if (args.type) meta.push(`type:${args.type}`);
62
- if (args.outputMode && args.outputMode !== "files_with_matches") meta.push(args.outputMode);
99
+ if (args.outputMode && args.outputMode !== "files_with_matches") meta.push(`mode:${args.outputMode}`);
63
100
  if (args.caseSensitive) {
64
- meta.push("--case-sensitive");
101
+ meta.push("case:sensitive");
65
102
  } else if (args.ignoreCase) {
66
- meta.push("-i");
103
+ meta.push("case:insensitive");
67
104
  }
105
+ if (args.literal) meta.push("literal");
68
106
  if (args.multiline) meta.push("multiline");
107
+ if (args.context !== undefined) meta.push(`context:${args.context}`);
108
+ if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
69
109
 
70
- if (meta.length > 0) {
71
- text += ` ${theme.fg("muted", meta.join(" "))}`;
72
- }
110
+ text += formatMeta(meta, theme);
73
111
 
74
112
  return new Text(text, 0, 0);
75
113
  },
76
114
 
77
115
  renderResult(result, { expanded }, theme) {
116
+ const label = "Grep";
78
117
  const details = result.details;
79
118
 
80
- // Error case
81
119
  if (details?.error) {
82
- return new Text(`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", details.error)}`, 0, 0);
120
+ return renderErrorMessage(label, details.error, theme);
83
121
  }
84
122
 
85
- // Check for detailed rendering data - fall back to structured output if not available
86
123
  const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined;
87
124
 
88
125
  if (!hasDetailedData) {
89
126
  const textContent = result.content?.find((c) => c.type === "text")?.text;
90
127
  if (!textContent || textContent === "No matches found") {
91
- return new Text(
92
- `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No matches found")}`,
93
- 0,
94
- 0,
95
- );
128
+ return renderEmptyMessage(label, "No matches found", theme);
96
129
  }
97
130
 
98
131
  const lines = textContent.split("\n").filter((line) => line.trim() !== "");
99
- const maxLines = expanded ? lines.length : 10;
132
+ const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_TEXT_LIMIT);
100
133
  const displayLines = lines.slice(0, maxLines);
101
134
  const remaining = lines.length - maxLines;
135
+ const hasMore = remaining > 0;
102
136
 
103
- let text = `${theme.styledSymbol("status.success", "success")} ${theme.fg("toolTitle", "grep")} ${theme.fg(
104
- "dim",
105
- `${lines.length} item${lines.length !== 1 ? "s" : ""}`,
106
- )}`;
137
+ const icon = theme.styledSymbol("status.success", "success");
138
+ const summary = formatCount("item", lines.length);
139
+ const expandHint = formatExpandHint(expanded, hasMore, theme);
140
+ let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
107
141
 
108
142
  for (let i = 0; i < displayLines.length; i++) {
109
143
  const isLast = i === displayLines.length - 1 && remaining === 0;
@@ -114,9 +148,10 @@ const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
114
148
  if (remaining > 0) {
115
149
  text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
116
150
  "muted",
117
- `${theme.format.ellipsis} ${remaining} more items`,
151
+ formatMoreItems(remaining, "item", theme),
118
152
  )}`;
119
153
  }
154
+
120
155
  return new Text(text, 0, 0);
121
156
  }
122
157
 
@@ -126,33 +161,29 @@ const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
126
161
  const truncated = details?.truncated ?? details?.truncation?.truncated ?? false;
127
162
  const files = details?.files ?? [];
128
163
 
129
- // No matches
130
164
  if (matchCount === 0) {
131
- return new Text(
132
- `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No matches found")}`,
133
- 0,
134
- 0,
135
- );
165
+ return renderEmptyMessage(label, "No matches found", theme);
136
166
  }
137
167
 
138
- // Build summary
139
168
  const icon = theme.styledSymbol("status.success", "success");
140
- let summary: string;
141
- if (mode === "files_with_matches") {
142
- summary = `${fileCount} file${fileCount !== 1 ? "s" : ""}`;
143
- } else if (mode === "count") {
144
- summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`;
145
- } else {
146
- summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`;
147
- }
169
+ const summaryParts =
170
+ mode === "files_with_matches"
171
+ ? [formatCount("file", fileCount)]
172
+ : [formatCount("match", matchCount), formatCount("file", fileCount)];
173
+ const summaryText = summaryParts.join(theme.sep.dot);
174
+ const scopeLabel = formatScope(details?.scopePath, theme);
148
175
 
149
- if (truncated) {
150
- summary += theme.fg("warning", " (truncated)");
151
- }
176
+ const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length
177
+ ? details.fileMatches.map((entry) => ({ path: entry.path, count: entry.count }))
178
+ : files.map((path) => ({ path }));
179
+ const maxFiles = expanded ? fileEntries.length : Math.min(fileEntries.length, COLLAPSED_LIST_LIMIT);
180
+ const hasMoreFiles = fileEntries.length > maxFiles;
181
+ const expandHint = formatExpandHint(expanded, hasMoreFiles, theme);
152
182
 
153
- const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
154
- const scopeLabel = details?.scopePath ? ` ${theme.fg("muted", `in ${details.scopePath}`)}` : "";
155
- let text = `${icon} ${theme.fg("toolTitle", "grep")} ${theme.fg("dim", summary)}${scopeLabel}${expandHint}`;
183
+ let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(
184
+ truncated,
185
+ theme,
186
+ )}${scopeLabel}${expandHint}`;
156
187
 
157
188
  const truncationReasons: string[] = [];
158
189
  if (details?.matchLimitReached) {
@@ -168,33 +199,36 @@ const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
168
199
  truncationReasons.push("line length");
169
200
  }
170
201
 
171
- const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length
172
- ? details.fileMatches.map((entry) => ({ path: entry.path, count: entry.count }))
173
- : files.map((path) => ({ path }));
202
+ const hasTruncation = truncationReasons.length > 0;
174
203
 
175
- // Show file tree if we have files
176
204
  if (fileEntries.length > 0) {
177
- const maxFiles = expanded ? fileEntries.length : Math.min(fileEntries.length, 8);
178
205
  for (let i = 0; i < maxFiles; i++) {
179
206
  const entry = fileEntries[i];
180
- const isLast = i === maxFiles - 1 && (expanded || fileEntries.length <= 8);
207
+ const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation;
181
208
  const branch = isLast ? theme.tree.last : theme.tree.branch;
209
+ const isDir = entry.path.endsWith("/");
210
+ const entryPath = isDir ? entry.path.slice(0, -1) : entry.path;
211
+ const lang = isDir ? undefined : getLanguageFromPath(entryPath);
212
+ const entryIcon = isDir
213
+ ? theme.fg("accent", theme.icon.folder)
214
+ : theme.fg("muted", theme.getLangIcon(lang));
182
215
  const countLabel =
183
216
  entry.count !== undefined
184
217
  ? ` ${theme.fg("dim", `(${entry.count} match${entry.count !== 1 ? "es" : ""})`)}`
185
218
  : "";
186
- text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", entry.path)}${countLabel}`;
219
+ text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg("accent", entry.path)}${countLabel}`;
187
220
  }
188
221
 
189
- if (!expanded && fileEntries.length > 8) {
190
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
222
+ if (hasMoreFiles) {
223
+ const moreFilesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
224
+ text += `\n ${theme.fg("dim", moreFilesBranch)} ${theme.fg(
191
225
  "muted",
192
- `${theme.format.ellipsis} ${fileEntries.length - 8} more files`,
226
+ formatMoreItems(fileEntries.length - maxFiles, "file", theme),
193
227
  )}`;
194
228
  }
195
229
  }
196
230
 
197
- if (truncationReasons.length > 0) {
231
+ if (hasTruncation) {
198
232
  text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
199
233
  "warning",
200
234
  `truncated: ${truncationReasons.join(", ")}`,
@@ -220,54 +254,48 @@ interface FindArgs {
220
254
 
221
255
  const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
222
256
  renderCall(args, theme) {
223
- let text = theme.fg("toolTitle", theme.bold("find "));
224
- text += theme.fg("accent", args.pattern || "*");
257
+ const label = theme.fg("toolTitle", theme.bold("Find"));
258
+ let text = `${label} ${theme.fg("accent", args.pattern || "*")}`;
225
259
 
226
260
  const meta: string[] = [];
227
- if (args.path) meta.push(args.path);
261
+ if (args.path) meta.push(`in ${args.path}`);
228
262
  if (args.type && args.type !== "all") meta.push(`type:${args.type}`);
229
- if (args.hidden) meta.push("--hidden");
263
+ if (args.hidden) meta.push("hidden");
264
+ if (args.sortByMtime) meta.push("sort:mtime");
265
+ if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
230
266
 
231
- if (meta.length > 0) {
232
- text += ` ${theme.fg("muted", meta.join(" "))}`;
233
- }
267
+ text += formatMeta(meta, theme);
234
268
 
235
269
  return new Text(text, 0, 0);
236
270
  },
237
271
 
238
272
  renderResult(result, { expanded }, theme) {
273
+ const label = "Find";
239
274
  const details = result.details;
240
275
 
241
- // Error case
242
276
  if (details?.error) {
243
- return new Text(`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", details.error)}`, 0, 0);
277
+ return renderErrorMessage(label, details.error, theme);
244
278
  }
245
279
 
246
- // Check for detailed rendering data - fall back to parsing raw output if not available
247
280
  const hasDetailedData = details?.fileCount !== undefined;
248
-
249
- // Get text content for fallback or to extract file list
250
281
  const textContent = result.content?.find((c) => c.type === "text")?.text;
251
282
 
252
283
  if (!hasDetailedData) {
253
284
  if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") {
254
- return new Text(
255
- `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No files found")}`,
256
- 0,
257
- 0,
258
- );
285
+ return renderEmptyMessage(label, "No files found", theme);
259
286
  }
260
287
 
261
- // Parse the raw output as file list
262
288
  const lines = textContent.split("\n").filter((l) => l.trim());
263
- const maxLines = expanded ? lines.length : Math.min(lines.length, 8);
289
+ const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_LIST_LIMIT);
264
290
  const displayLines = lines.slice(0, maxLines);
265
291
  const remaining = lines.length - maxLines;
292
+ const hasMore = remaining > 0;
293
+
294
+ const icon = theme.styledSymbol("status.success", "success");
295
+ const summary = formatCount("file", lines.length);
296
+ const expandHint = formatExpandHint(expanded, hasMore, theme);
297
+ let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
266
298
 
267
- let text = `${theme.styledSymbol("status.success", "success")} ${theme.fg("toolTitle", "find")} ${theme.fg(
268
- "dim",
269
- `${lines.length} file${lines.length !== 1 ? "s" : ""}`,
270
- )}`;
271
299
  for (let i = 0; i < displayLines.length; i++) {
272
300
  const isLast = i === displayLines.length - 1 && remaining === 0;
273
301
  const branch = isLast ? theme.tree.last : theme.tree.branch;
@@ -276,7 +304,7 @@ const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
276
304
  if (remaining > 0) {
277
305
  text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
278
306
  "muted",
279
- `${theme.format.ellipsis} ${remaining} more files`,
307
+ formatMoreItems(remaining, "file", theme),
280
308
  )}`;
281
309
  }
282
310
  return new Text(text, 0, 0);
@@ -286,26 +314,21 @@ const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
286
314
  const truncated = details?.truncated ?? details?.truncation?.truncated ?? false;
287
315
  const files = details?.files ?? [];
288
316
 
289
- // No matches
290
317
  if (fileCount === 0) {
291
- return new Text(
292
- `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No files found")}`,
293
- 0,
294
- 0,
295
- );
318
+ return renderEmptyMessage(label, "No files found", theme);
296
319
  }
297
320
 
298
- // Build summary
299
321
  const icon = theme.styledSymbol("status.success", "success");
300
- let summary = `${fileCount} file${fileCount !== 1 ? "s" : ""}`;
301
-
302
- if (truncated) {
303
- summary += theme.fg("warning", " (truncated)");
304
- }
322
+ const summaryText = formatCount("file", fileCount);
323
+ const scopeLabel = formatScope(details?.scopePath, theme);
324
+ const maxFiles = expanded ? files.length : Math.min(files.length, COLLAPSED_LIST_LIMIT);
325
+ const hasMoreFiles = files.length > maxFiles;
326
+ const expandHint = formatExpandHint(expanded, hasMoreFiles, theme);
305
327
 
306
- const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
307
- const scopeLabel = details?.scopePath ? ` ${theme.fg("muted", `in ${details.scopePath}`)}` : "";
308
- let text = `${icon} ${theme.fg("toolTitle", "find")} ${theme.fg("dim", summary)}${scopeLabel}${expandHint}`;
328
+ let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(
329
+ truncated,
330
+ theme,
331
+ )}${scopeLabel}${expandHint}`;
309
332
 
310
333
  const truncationReasons: string[] = [];
311
334
  if (details?.resultLimitReached) {
@@ -315,24 +338,32 @@ const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
315
338
  truncationReasons.push("size limit");
316
339
  }
317
340
 
318
- // Show file tree if we have files
341
+ const hasTruncation = truncationReasons.length > 0;
342
+
319
343
  if (files.length > 0) {
320
- const maxFiles = expanded ? files.length : Math.min(files.length, 8);
321
344
  for (let i = 0; i < maxFiles; i++) {
322
- const isLast = i === maxFiles - 1 && (expanded || files.length <= 8);
345
+ const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation;
323
346
  const branch = isLast ? theme.tree.last : theme.tree.branch;
324
- text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", files[i])}`;
347
+ const entry = files[i];
348
+ const isDir = entry.endsWith("/");
349
+ const entryPath = isDir ? entry.slice(0, -1) : entry;
350
+ const lang = isDir ? undefined : getLanguageFromPath(entryPath);
351
+ const entryIcon = isDir
352
+ ? theme.fg("accent", theme.icon.folder)
353
+ : theme.fg("muted", theme.getLangIcon(lang));
354
+ text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg("accent", entry)}`;
325
355
  }
326
356
 
327
- if (!expanded && files.length > 8) {
328
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
357
+ if (hasMoreFiles) {
358
+ const moreFilesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
359
+ text += `\n ${theme.fg("dim", moreFilesBranch)} ${theme.fg(
329
360
  "muted",
330
- `${theme.format.ellipsis} ${files.length - 8} more files`,
361
+ formatMoreItems(files.length - maxFiles, "file", theme),
331
362
  )}`;
332
363
  }
333
364
  }
334
365
 
335
- if (truncationReasons.length > 0) {
366
+ if (hasTruncation) {
336
367
  text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
337
368
  "warning",
338
369
  `truncated: ${truncationReasons.join(", ")}`,
@@ -365,7 +396,7 @@ function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): st
365
396
  return `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", "(empty cell)")}`;
366
397
  }
367
398
 
368
- const maxLines = expanded ? normalized.length : Math.min(normalized.length, 6);
399
+ const maxLines = expanded ? normalized.length : Math.min(normalized.length, COLLAPSED_TEXT_LIMIT);
369
400
  let text = "";
370
401
 
371
402
  for (let i = 0; i < maxLines; i++) {
@@ -377,10 +408,7 @@ function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): st
377
408
 
378
409
  const remaining = normalized.length - maxLines;
379
410
  if (remaining > 0) {
380
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
381
- "muted",
382
- `${theme.format.ellipsis} ${remaining} more lines`,
383
- )}`;
411
+ text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, "line", theme))}`;
384
412
  }
385
413
 
386
414
  return text;
@@ -388,28 +416,26 @@ function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): st
388
416
 
389
417
  const notebookRenderer: ToolRenderer<NotebookArgs, NotebookToolDetails> = {
390
418
  renderCall(args, theme) {
391
- let text = theme.fg("toolTitle", theme.bold("notebook "));
392
- text += theme.fg("accent", args.action || "?");
419
+ const label = theme.fg("toolTitle", theme.bold("Notebook"));
420
+ let text = `${label} ${theme.fg("accent", args.action || "?")}`;
393
421
 
394
422
  const meta: string[] = [];
395
- meta.push(args.notebookPath || "?");
423
+ meta.push(`in ${args.notebookPath || "?"}`);
396
424
  if (args.cellNumber !== undefined) meta.push(`cell:${args.cellNumber}`);
397
- if (args.cellType) meta.push(args.cellType);
425
+ if (args.cellType) meta.push(`type:${args.cellType}`);
398
426
 
399
- if (meta.length > 0) {
400
- text += ` ${theme.fg("muted", meta.join(" "))}`;
401
- }
427
+ text += formatMeta(meta, theme);
402
428
 
403
429
  return new Text(text, 0, 0);
404
430
  },
405
431
 
406
432
  renderResult(result, { expanded }, theme) {
433
+ const label = "Notebook";
407
434
  const details = result.details;
408
435
 
409
- // Error case - check for error in content
410
436
  const content = result.content?.[0];
411
437
  if (content?.type === "text" && content.text?.startsWith("Error:")) {
412
- return new Text(`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", content.text)}`, 0, 0);
438
+ return renderErrorMessage(label, content.text, theme);
413
439
  }
414
440
 
415
441
  const action = details?.action ?? "edit";
@@ -418,33 +444,18 @@ const notebookRenderer: ToolRenderer<NotebookArgs, NotebookToolDetails> = {
418
444
  const totalCells = details?.totalCells;
419
445
  const cellSource = details?.cellSource;
420
446
  const lineCount = cellSource?.length;
421
- const canExpand = cellSource !== undefined && cellSource.length > 6;
447
+ const canExpand = cellSource !== undefined && cellSource.length > COLLAPSED_TEXT_LIMIT;
422
448
 
423
- // Build summary
424
449
  const icon = theme.styledSymbol("status.success", "success");
425
- let summary: string;
426
-
427
- switch (action) {
428
- case "insert":
429
- summary = `Inserted ${cellType || "cell"} at index ${cellIndex}`;
430
- break;
431
- case "delete":
432
- summary = `Deleted cell at index ${cellIndex}`;
433
- break;
434
- default:
435
- summary = `Edited ${cellType || "cell"} at index ${cellIndex}`;
436
- }
437
-
438
- if (lineCount !== undefined) {
439
- summary += ` (${lineCount} line${lineCount !== 1 ? "s" : ""})`;
440
- }
441
-
442
- if (totalCells !== undefined) {
443
- summary += ` (${totalCells} total)`;
444
- }
450
+ const actionLabel = action === "insert" ? "Inserted" : action === "delete" ? "Deleted" : "Edited";
451
+ const cellLabel = cellType || "cell";
452
+ const summaryParts = [`${actionLabel} ${cellLabel} at index ${cellIndex ?? "?"}`];
453
+ if (lineCount !== undefined) summaryParts.push(formatCount("line", lineCount));
454
+ if (totalCells !== undefined) summaryParts.push(`${totalCells} total`);
455
+ const summaryText = summaryParts.join(theme.sep.dot);
445
456
 
446
- const expandHint = !expanded && canExpand ? theme.fg("dim", " (Ctrl+O to expand)") : "";
447
- let text = `${icon} ${theme.fg("toolTitle", "notebook")} ${theme.fg("dim", summary)}${expandHint}`;
457
+ const expandHint = formatExpandHint(expanded, canExpand, theme);
458
+ let text = `${icon} ${theme.fg("dim", summaryText)}${expandHint}`;
448
459
 
449
460
  if (cellSource) {
450
461
  text += renderCellPreview(cellSource, expanded, theme);
@@ -467,15 +478,26 @@ interface AskArgs {
467
478
  const askRenderer: ToolRenderer<AskArgs, AskToolDetails> = {
468
479
  renderCall(args, theme) {
469
480
  if (!args.question) {
470
- return new Text(theme.fg("error", "ask: no question provided"), 0, 0);
481
+ return renderErrorMessage("Ask", "No question provided", theme);
471
482
  }
472
483
 
473
- const multiTag = args.multi ? theme.fg("muted", " [multi-select]") : "";
474
- let text = theme.fg("toolTitle", "? ") + theme.fg("accent", args.question) + multiTag;
484
+ const label = theme.fg("toolTitle", theme.bold("Ask"));
485
+ let text = `${label} ${theme.fg("accent", args.question)}`;
486
+
487
+ const meta: string[] = [];
488
+ if (args.multi) meta.push("multi");
489
+ if (args.options?.length) meta.push(`options:${args.options.length}`);
490
+ text += formatMeta(meta, theme);
475
491
 
476
492
  if (args.options?.length) {
477
- for (const opt of args.options) {
478
- text += `\n${theme.fg("dim", ` ${theme.checkbox.unchecked} `)}${theme.fg("muted", opt.label)}`;
493
+ for (let i = 0; i < args.options.length; i++) {
494
+ const opt = args.options[i];
495
+ const isLast = i === args.options.length - 1;
496
+ const branch = isLast ? theme.tree.last : theme.tree.branch;
497
+ text += `\n ${theme.fg("dim", branch)} ${theme.fg(
498
+ "dim",
499
+ theme.checkbox.unchecked,
500
+ )} ${theme.fg("muted", opt.label)}`;
479
501
  }
480
502
  }
481
503
 
@@ -494,7 +516,7 @@ const askRenderer: ToolRenderer<AskArgs, AskToolDetails> = {
494
516
  ? theme.styledSymbol("status.success", "success")
495
517
  : theme.styledSymbol("status.warning", "warning");
496
518
 
497
- let text = `${statusIcon} ${theme.fg("toolTitle", "ask")} ${theme.fg("accent", details.question)}`;
519
+ let text = `${statusIcon} ${theme.fg("accent", details.question)}`;
498
520
 
499
521
  if (details.customInput) {
500
522
  text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.styledSymbol(
@@ -552,23 +574,10 @@ interface OutputArgs {
552
574
  format?: "raw" | "json" | "stripped";
553
575
  }
554
576
 
555
- /** Format byte count for display */
556
- function formatBytes(bytes: number): string {
557
- if (bytes < 1024) return `${bytes}B`;
558
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
559
- return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
560
- }
561
-
562
- function truncateLine(text: string, maxLen: number, ellipsis: string): string {
563
- if (text.length <= maxLen) return text;
564
- const sliceLen = Math.max(0, maxLen - ellipsis.length);
565
- return `${text.slice(0, sliceLen)}${ellipsis}`;
566
- }
567
-
568
577
  type OutputEntry = OutputToolDetails["outputs"][number];
569
578
 
570
579
  function formatOutputMeta(entry: OutputEntry, theme: Theme): string {
571
- const metaParts = [`${entry.lineCount} lines, ${formatBytes(entry.charCount)}`];
580
+ const metaParts = [formatCount("line", entry.lineCount), formatBytes(entry.charCount)];
572
581
  if (entry.provenance) {
573
582
  metaParts.push(`agent ${entry.provenance.agent}(${entry.provenance.index})`);
574
583
  }
@@ -578,48 +587,56 @@ function formatOutputMeta(entry: OutputEntry, theme: Theme): string {
578
587
  const outputRenderer: ToolRenderer<OutputArgs, OutputToolDetails> = {
579
588
  renderCall(args, theme) {
580
589
  const ids = args.ids?.join(", ") ?? "?";
581
- const label = theme.fg("toolTitle", theme.bold("output"));
582
- const format = args.format && args.format !== "raw" ? theme.fg("muted", ` (${args.format})`) : "";
583
- return new Text(`${label} ${theme.fg("dim", ids)}${format}`, 0, 0);
590
+ const label = theme.fg("toolTitle", theme.bold("Output"));
591
+ let text = `${label} ${theme.fg("accent", ids)}`;
592
+
593
+ const meta: string[] = [];
594
+ if (args.format && args.format !== "raw") meta.push(`format:${args.format}`);
595
+ text += formatMeta(meta, theme);
596
+
597
+ return new Text(text, 0, 0);
584
598
  },
585
599
 
586
600
  renderResult(result, { expanded }, theme) {
601
+ const label = "Output";
587
602
  const details = result.details;
588
603
 
589
- // Error case: some IDs not found
590
604
  if (details?.notFound?.length) {
591
- let text = `${theme.styledSymbol("status.error", "error")} Not found: ${details.notFound.join(", ")}`;
605
+ const icon = theme.styledSymbol("status.error", "error");
606
+ let text = `${icon} ${theme.fg("error", `Error: Not found: ${details.notFound.join(", ")}`)}`;
592
607
  if (details.availableIds?.length) {
593
- text += `\n${theme.fg("dim", "Available:")} ${details.availableIds.join(", ")}`;
608
+ text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
609
+ "muted",
610
+ `Available: ${details.availableIds.join(", ")}`,
611
+ )}`;
594
612
  } else {
595
- text += `\n${theme.fg("dim", "No outputs available in current session")}`;
613
+ text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
614
+ "muted",
615
+ "No outputs available in current session",
616
+ )}`;
596
617
  }
597
618
  return new Text(text, 0, 0);
598
619
  }
599
620
 
600
621
  const outputs = details?.outputs ?? [];
601
622
 
602
- // No session case
603
623
  if (outputs.length === 0) {
604
624
  const textContent = result.content?.find((c) => c.type === "text")?.text;
605
- return new Text(
606
- `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", textContent || "No outputs")}`,
607
- 0,
608
- 0,
609
- );
625
+ return renderEmptyMessage(label, textContent || "No outputs", theme);
610
626
  }
611
627
 
612
- // Success: summary + tree display
613
- const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
614
628
  const icon = theme.styledSymbol("status.success", "success");
615
- const summary = `read ${outputs.length} output${outputs.length !== 1 ? "s" : ""}`;
616
- let text = `${icon} ${theme.fg("toolTitle", "output")} ${theme.fg("dim", summary)}${expandHint}`;
617
-
629
+ const summary = `read ${formatCount("output", outputs.length)}`;
618
630
  const previewLimit = expanded ? 3 : 1;
619
631
  const maxOutputs = expanded ? outputs.length : Math.min(outputs.length, 5);
632
+ const hasMoreOutputs = outputs.length > maxOutputs;
633
+ const hasMorePreview = outputs.some((o) => (o.previewLines?.length ?? 0) > previewLimit);
634
+ const expandHint = formatExpandHint(expanded, hasMoreOutputs || hasMorePreview, theme);
635
+ let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
636
+
620
637
  for (let i = 0; i < maxOutputs; i++) {
621
638
  const o = outputs[i];
622
- const isLast = i === maxOutputs - 1 && (expanded || outputs.length <= 5);
639
+ const isLast = i === maxOutputs - 1 && !hasMoreOutputs;
623
640
  const branch = isLast ? theme.tree.last : theme.tree.branch;
624
641
  text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", o.id)} ${formatOutputMeta(o, theme)}`;
625
642
 
@@ -628,19 +645,19 @@ const outputRenderer: ToolRenderer<OutputArgs, OutputToolDetails> = {
628
645
  if (shownPreview.length > 0) {
629
646
  const childPrefix = isLast ? " " : ` ${theme.fg("dim", theme.tree.vertical)} `;
630
647
  for (const line of shownPreview) {
631
- const previewText = truncateLine(line, 80, theme.format.ellipsis);
632
- text += `\n${childPrefix}${theme.fg("dim", theme.tree.hook)} ${theme.fg("muted", "preview:")} ${theme.fg(
633
- "toolOutput",
634
- previewText,
635
- )}`;
648
+ const previewText = truncate(line, TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis);
649
+ text += `\n${childPrefix}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
650
+ "muted",
651
+ "preview:",
652
+ )} ${theme.fg("toolOutput", previewText)}`;
636
653
  }
637
654
  }
638
655
  }
639
656
 
640
- if (!expanded && outputs.length > 5) {
657
+ if (hasMoreOutputs) {
641
658
  text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
642
659
  "muted",
643
- `${theme.format.ellipsis} ${outputs.length - 5} more outputs`,
660
+ formatMoreItems(outputs.length - maxOutputs, "output", theme),
644
661
  )}`;
645
662
  }
646
663
 
@@ -668,27 +685,26 @@ interface LsArgs {
668
685
 
669
686
  const lsRenderer: ToolRenderer<LsArgs, LsToolDetails> = {
670
687
  renderCall(args, theme) {
671
- let text = theme.fg("toolTitle", theme.bold("ls "));
672
- text += theme.fg("accent", args.path || ".");
673
- if (args.limit !== undefined) {
674
- text += ` ${theme.fg("muted", `(limit ${args.limit})`)}`;
675
- }
688
+ const label = theme.fg("toolTitle", theme.bold("Ls"));
689
+ let text = `${label} ${theme.fg("accent", args.path || ".")}`;
690
+
691
+ const meta: string[] = [];
692
+ if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
693
+ text += formatMeta(meta, theme);
694
+
676
695
  return new Text(text, 0, 0);
677
696
  },
678
697
 
679
698
  renderResult(result, { expanded }, theme) {
699
+ const label = "Ls";
680
700
  const details = result.details;
681
- const textContent = result.content?.find((c: any) => c.type === "text")?.text ?? "";
701
+ const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
682
702
 
683
703
  if (
684
704
  (!textContent || textContent.trim() === "" || textContent.trim() === "(empty directory)") &&
685
705
  (!details?.entries || details.entries.length === 0)
686
706
  ) {
687
- return new Text(
688
- `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "Empty directory")}`,
689
- 0,
690
- 0,
691
- );
707
+ return renderEmptyMessage(label, "Empty directory", theme);
692
708
  }
693
709
 
694
710
  let entries: string[] = details?.entries ? [...details.entries] : [];
@@ -698,11 +714,7 @@ const lsRenderer: ToolRenderer<LsArgs, LsToolDetails> = {
698
714
  }
699
715
 
700
716
  if (entries.length === 0) {
701
- return new Text(
702
- `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "Empty directory")}`,
703
- 0,
704
- 0,
705
- );
717
+ return renderEmptyMessage(label, "Empty directory", theme);
706
718
  }
707
719
 
708
720
  let dirCount = details?.dirCount;
@@ -724,41 +736,47 @@ const lsRenderer: ToolRenderer<LsArgs, LsToolDetails> = {
724
736
  ? theme.styledSymbol("status.warning", "warning")
725
737
  : theme.styledSymbol("status.success", "success");
726
738
 
727
- const dirLabel = `${dirCount} dir${dirCount !== 1 ? "s" : ""}`;
728
- const fileLabel = `${fileCount} file${fileCount !== 1 ? "s" : ""}`;
729
- let text = `${icon} ${theme.fg("toolTitle", "ls")} ${theme.fg("dim", `${dirLabel}, ${fileLabel}`)}`;
739
+ const summaryText = [formatCount("dir", dirCount ?? 0), formatCount("file", fileCount ?? 0)].join(theme.sep.dot);
740
+ const maxEntries = expanded ? entries.length : Math.min(entries.length, COLLAPSED_LIST_LIMIT);
741
+ const hasMoreEntries = entries.length > maxEntries;
742
+ const expandHint = formatExpandHint(expanded, hasMoreEntries, theme);
730
743
 
731
- if (truncated) {
732
- const reasonParts: string[] = [];
733
- if (details?.entryLimitReached) {
734
- reasonParts.push(`entry limit ${details.entryLimitReached}`);
735
- }
736
- if (details?.truncation?.truncated) {
737
- reasonParts.push(`output cap ${formatBytes(details.truncation.maxBytes)}`);
738
- }
739
- const reasonText = reasonParts.length > 0 ? `truncated: ${reasonParts.join(", ")}` : "truncated";
740
- text += ` ${theme.fg("warning", `(${reasonText})`)}`;
741
- }
744
+ let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, theme)}${expandHint}`;
742
745
 
743
- if (!expanded) {
744
- text += `\n${theme.fg("dim", `${theme.nav.expand} Ctrl+O to expand list`)}`;
746
+ const truncationReasons: string[] = [];
747
+ if (details?.entryLimitReached) {
748
+ truncationReasons.push(`entry limit ${details.entryLimitReached}`);
745
749
  }
750
+ if (details?.truncation?.truncated) {
751
+ truncationReasons.push(`output cap ${formatBytes(details.truncation.maxBytes)}`);
752
+ }
753
+
754
+ const hasTruncation = truncationReasons.length > 0;
746
755
 
747
- const maxEntries = expanded ? entries.length : Math.min(entries.length, 12);
748
756
  for (let i = 0; i < maxEntries; i++) {
749
757
  const entry = entries[i];
750
- const isLast = i === maxEntries - 1 && (expanded || entries.length <= 12);
758
+ const isLast = i === maxEntries - 1 && !hasMoreEntries && !hasTruncation;
751
759
  const branch = isLast ? theme.tree.last : theme.tree.branch;
752
760
  const isDir = entry.endsWith("/");
753
- const entryIcon = isDir ? theme.fg("accent", theme.icon.folder) : theme.fg("muted", theme.icon.file);
761
+ const entryPath = isDir ? entry.slice(0, -1) : entry;
762
+ const lang = isDir ? undefined : getLanguageFromPath(entryPath);
763
+ const entryIcon = isDir ? theme.fg("accent", theme.icon.folder) : theme.fg("muted", theme.getLangIcon(lang));
754
764
  const entryColor = isDir ? "accent" : "toolOutput";
755
765
  text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg(entryColor, entry)}`;
756
766
  }
757
767
 
758
- if (!expanded && entries.length > 12) {
759
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
768
+ if (hasMoreEntries) {
769
+ const moreEntriesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
770
+ text += `\n ${theme.fg("dim", moreEntriesBranch)} ${theme.fg(
760
771
  "muted",
761
- `${theme.format.ellipsis} ${entries.length - 12} more entries`,
772
+ formatMoreItems(entries.length - maxEntries, "entry", theme),
773
+ )}`;
774
+ }
775
+
776
+ if (hasTruncation) {
777
+ text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
778
+ "warning",
779
+ `truncated: ${truncationReasons.join(", ")}`,
762
780
  )}`;
763
781
  }
764
782