@oh-my-pi/pi-coding-agent 3.15.1 → 3.20.1

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 (129) hide show
  1. package/CHANGELOG.md +60 -0
  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/find.ts +7 -1
  62. package/src/core/tools/gemini-image.ts +361 -0
  63. package/src/core/tools/git.ts +216 -0
  64. package/src/core/tools/index.ts +28 -15
  65. package/src/core/tools/ls.ts +9 -2
  66. package/src/core/tools/lsp/config.ts +5 -4
  67. package/src/core/tools/lsp/index.ts +17 -12
  68. package/src/core/tools/lsp/render.ts +39 -47
  69. package/src/core/tools/read.ts +66 -29
  70. package/src/core/tools/render-utils.ts +268 -0
  71. package/src/core/tools/renderers.ts +243 -225
  72. package/src/core/tools/task/discovery.ts +2 -2
  73. package/src/core/tools/task/executor.ts +66 -58
  74. package/src/core/tools/task/index.ts +29 -10
  75. package/src/core/tools/task/model-resolver.ts +8 -13
  76. package/src/core/tools/task/omp-command.ts +24 -0
  77. package/src/core/tools/task/render.ts +37 -62
  78. package/src/core/tools/task/types.ts +3 -0
  79. package/src/core/tools/web-fetch.ts +29 -28
  80. package/src/core/tools/web-search/index.ts +6 -5
  81. package/src/core/tools/web-search/providers/exa.ts +6 -5
  82. package/src/core/tools/web-search/render.ts +66 -111
  83. package/src/core/voice-controller.ts +135 -0
  84. package/src/core/voice-supervisor.ts +1003 -0
  85. package/src/core/voice.ts +308 -0
  86. package/src/discovery/builtin.ts +75 -1
  87. package/src/discovery/claude.ts +47 -1
  88. package/src/discovery/codex.ts +54 -2
  89. package/src/discovery/gemini.ts +55 -2
  90. package/src/discovery/helpers.ts +100 -1
  91. package/src/discovery/index.ts +2 -0
  92. package/src/index.ts +14 -9
  93. package/src/lib/worktree/collapse.ts +179 -0
  94. package/src/lib/worktree/constants.ts +14 -0
  95. package/src/lib/worktree/errors.ts +23 -0
  96. package/src/lib/worktree/git.ts +110 -0
  97. package/src/lib/worktree/index.ts +23 -0
  98. package/src/lib/worktree/operations.ts +216 -0
  99. package/src/lib/worktree/session.ts +114 -0
  100. package/src/lib/worktree/stats.ts +67 -0
  101. package/src/main.ts +61 -37
  102. package/src/migrations.ts +37 -7
  103. package/src/modes/interactive/components/bash-execution.ts +6 -4
  104. package/src/modes/interactive/components/custom-editor.ts +55 -0
  105. package/src/modes/interactive/components/custom-message.ts +95 -0
  106. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  107. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  108. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  109. package/src/modes/interactive/components/extensions/types.ts +1 -0
  110. package/src/modes/interactive/components/footer.ts +324 -0
  111. package/src/modes/interactive/components/hook-selector.ts +3 -3
  112. package/src/modes/interactive/components/model-selector.ts +7 -6
  113. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  114. package/src/modes/interactive/components/settings-defs.ts +55 -6
  115. package/src/modes/interactive/components/status-line.ts +45 -37
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +643 -113
  118. package/src/modes/interactive/theme/defaults/index.ts +16 -16
  119. package/src/modes/print-mode.ts +14 -72
  120. package/src/modes/rpc/rpc-client.ts +23 -9
  121. package/src/modes/rpc/rpc-mode.ts +137 -125
  122. package/src/modes/rpc/rpc-types.ts +46 -24
  123. package/src/prompts/task.md +1 -0
  124. package/src/prompts/tools/gemini-image.md +4 -0
  125. package/src/prompts/tools/git.md +9 -0
  126. package/src/prompts/voice-summary.md +12 -0
  127. package/src/utils/image-convert.ts +26 -0
  128. package/src/utils/image-resize.ts +215 -0
  129. package/src/utils/shell-snapshot.ts +22 -20
@@ -1,21 +1,21 @@
1
1
  import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
2
2
  import type { CustomToolContext } from "../custom-tools/types";
3
- import type { HookUIContext } from "../hooks/types";
3
+ import type { ExtensionUIContext } from "../extensions/types";
4
4
 
5
5
  declare module "@oh-my-pi/pi-agent-core" {
6
6
  interface AgentToolContext extends CustomToolContext {
7
- ui?: HookUIContext;
7
+ ui?: ExtensionUIContext;
8
8
  hasUI?: boolean;
9
9
  }
10
10
  }
11
11
 
12
12
  export interface ToolContextStore {
13
13
  getContext(): AgentToolContext;
14
- setUIContext(uiContext: HookUIContext, hasUI: boolean): void;
14
+ setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
15
15
  }
16
16
 
17
17
  export function createToolContextStore(getBaseContext: () => CustomToolContext): ToolContextStore {
18
- let uiContext: HookUIContext | undefined;
18
+ let uiContext: ExtensionUIContext | undefined;
19
19
  let hasUI = false;
20
20
 
21
21
  return {
@@ -4,6 +4,8 @@
4
4
  * Client for interacting with Exa MCP servers via JSON-RPC 2.0 over HTTPS.
5
5
  */
6
6
 
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
7
9
  import type { TSchema } from "@sinclair/typebox";
8
10
  import type { CustomTool } from "../../custom-tools/types";
9
11
  import { logger } from "../../logger";
@@ -26,14 +28,13 @@ export async function findApiKey(): Promise<string | null> {
26
28
 
27
29
  // Try loading from .env files in cwd and home
28
30
  const cwd = process.cwd();
29
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "~";
31
+ const home = homedir();
30
32
 
31
33
  for (const dir of [cwd, home]) {
32
34
  const envPath = `${dir}/.env`;
33
35
  try {
34
- const file = Bun.file(envPath);
35
- if (await file.exists()) {
36
- const content = await file.text();
36
+ if (existsSync(envPath)) {
37
+ const content = readFileSync(envPath, "utf-8");
37
38
  const match = content.match(/^EXA_API_KEY=(.+)$/m);
38
39
  if (match?.[1]) {
39
40
  return match[1].trim().replace(/^["']|["']$/g, "");
@@ -9,29 +9,37 @@ import { Text } from "@oh-my-pi/pi-tui";
9
9
  import type { Theme } from "../../../modes/interactive/theme/theme";
10
10
  import type { RenderResultOptions } from "../../custom-tools/types";
11
11
  import { logger } from "../../logger";
12
+ import {
13
+ formatCount,
14
+ formatExpandHint,
15
+ formatMoreItems,
16
+ getDomain,
17
+ getPreviewLines,
18
+ getStyledStatusIcon,
19
+ PREVIEW_LIMITS,
20
+ TRUNCATE_LENGTHS,
21
+ truncate,
22
+ } from "../render-utils";
12
23
  import type { ExaRenderDetails } from "./types";
13
24
 
14
- /** Truncate text to max length with ellipsis */
15
- function truncate(text: string, maxLen: number, ellipsis: string): string {
16
- if (text.length <= maxLen) return text;
17
- const sliceLen = Math.max(0, maxLen - ellipsis.length);
18
- return `${text.slice(0, sliceLen)}${ellipsis}`;
25
+ const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.COLLAPSED_LINES;
26
+ const COLLAPSED_PREVIEW_LINE_LEN = TRUNCATE_LENGTHS.LONG;
27
+ const EXPANDED_TEXT_LINES = 5;
28
+ const EXPANDED_TEXT_LINE_LEN = 90;
29
+ const MAX_TITLE_LEN = TRUNCATE_LENGTHS.TITLE;
30
+ const MAX_HIGHLIGHT_LEN = TRUNCATE_LENGTHS.CONTENT;
31
+
32
+ function renderErrorMessage(message: string, theme: Theme): Text {
33
+ const clean = message.replace(/^Error:\s*/, "").trim();
34
+ return new Text(
35
+ `${getStyledStatusIcon("error", theme)} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`,
36
+ 0,
37
+ 0,
38
+ );
19
39
  }
20
40
 
21
- /** Extract domain from URL */
22
- function getDomain(url: string): string {
23
- try {
24
- const u = new URL(url);
25
- return u.hostname.replace(/^www\./, "");
26
- } catch {
27
- return url;
28
- }
29
- }
30
-
31
- /** Get first N lines of text as preview */
32
- function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] {
33
- const lines = text.split("\n").filter((l) => l.trim());
34
- return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis));
41
+ function renderEmptyMessage(message: string, theme: Theme): Text {
42
+ return new Text(`${getStyledStatusIcon("warning", theme)} ${theme.fg("muted", message)}`, 0, 0);
35
43
  }
36
44
 
37
45
  /** Render Exa result with tree-based layout */
@@ -43,26 +51,42 @@ export function renderExaResult(
43
51
  const { expanded } = options;
44
52
  const details = result.details;
45
53
 
46
- // Handle error case
47
54
  if (details?.error) {
48
55
  logger.error("Exa render error", { error: details.error, toolName: details.toolName });
49
- return new Text(uiTheme.fg("error", `Error: ${details.error}`), 0, 0);
56
+ return renderErrorMessage(details.error, uiTheme);
50
57
  }
51
58
 
52
59
  const response = details?.response;
53
60
  if (!response) {
54
- // Non-search response: show raw result
55
61
  if (details?.raw) {
56
62
  const rawText = typeof details.raw === "string" ? details.raw : JSON.stringify(details.raw, null, 2);
57
- const preview = expanded ? rawText : truncate(rawText, 200, uiTheme.format.ellipsis);
58
- const toolLabel = details?.toolName ?? "Exa";
59
- return new Text(
60
- `${uiTheme.fg("success", uiTheme.format.bullet)} ${uiTheme.fg("toolTitle", toolLabel)}\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${preview}`,
61
- 0,
62
- 0,
63
- );
63
+ const rawLines = rawText.split("\n").filter((l) => l.trim());
64
+ const maxLines = expanded ? rawLines.length : Math.min(rawLines.length, COLLAPSED_PREVIEW_LINES);
65
+ const displayLines = rawLines.slice(0, maxLines);
66
+ const remaining = rawLines.length - maxLines;
67
+ const expandHint = formatExpandHint(expanded, remaining > 0, uiTheme);
68
+
69
+ let text = `${getStyledStatusIcon("info", uiTheme)} ${uiTheme.fg("dim", "Raw response")}${expandHint}`;
70
+
71
+ for (let i = 0; i < displayLines.length; i++) {
72
+ const isLast = i === displayLines.length - 1 && remaining === 0;
73
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
74
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg(
75
+ "toolOutput",
76
+ truncate(displayLines[i], COLLAPSED_PREVIEW_LINE_LEN, uiTheme.format.ellipsis),
77
+ )}`;
78
+ }
79
+
80
+ if (remaining > 0) {
81
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
82
+ "muted",
83
+ formatMoreItems(remaining, "line", uiTheme),
84
+ )}`;
85
+ }
86
+
87
+ return new Text(text, 0, 0);
64
88
  }
65
- return new Text(uiTheme.fg("error", "No response data"), 0, 0);
89
+ return renderEmptyMessage("No response data", uiTheme);
66
90
  }
67
91
 
68
92
  const results = response.results ?? [];
@@ -70,118 +94,135 @@ export function renderExaResult(
70
94
  const cost = response.costDollars?.total;
71
95
  const time = response.searchTime;
72
96
 
73
- // Build header: Exa Search · N results · $X.XX · Xs
74
- const icon =
75
- resultCount > 0 ? uiTheme.fg("success", uiTheme.format.bullet) : uiTheme.fg("warning", uiTheme.format.bullet);
76
- const expandHint = expanded ? "" : uiTheme.fg("dim", " (Ctrl+O for full results)");
77
- const toolLabel = details?.toolName ?? "Exa Search";
97
+ const icon = getStyledStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme);
78
98
 
79
- let headerParts = `${icon} ${uiTheme.fg("toolTitle", toolLabel)}${uiTheme.sep.dot}${uiTheme.fg(
80
- "dim",
81
- `${resultCount} result${resultCount !== 1 ? "s" : ""}`,
82
- )}`;
99
+ const metaParts = [formatCount("result", resultCount)];
100
+ if (cost !== undefined) metaParts.push(`cost:$${cost.toFixed(4)}`);
101
+ if (time !== undefined) metaParts.push(`time:${time.toFixed(2)}s`);
102
+ const summaryText = metaParts.join(uiTheme.sep.dot);
83
103
 
84
- if (cost !== undefined) {
85
- headerParts += `${uiTheme.sep.dot}${uiTheme.fg("muted", `$${cost.toFixed(4)}`)}`;
86
- }
87
- if (time !== undefined) {
88
- headerParts += `${uiTheme.sep.dot}${uiTheme.fg("muted", `${time.toFixed(2)}s`)}`;
104
+ let hasMorePreview = false;
105
+ if (!expanded && resultCount > 0) {
106
+ const previewText = results[0].text ?? results[0].title ?? "";
107
+ const totalLines = previewText.split("\n").filter((l) => l.trim()).length;
108
+ hasMorePreview = totalLines > COLLAPSED_PREVIEW_LINES || resultCount > 1;
89
109
  }
110
+ const expandHint = formatExpandHint(expanded, hasMorePreview, uiTheme);
90
111
 
91
- let text = headerParts + expandHint;
112
+ let text = `${icon} ${uiTheme.fg("dim", summaryText)}${expandHint}`;
92
113
 
93
114
  if (!expanded) {
94
- // Collapsed view: show 3-line preview from first result
95
- if (resultCount > 0) {
96
- const first = results[0];
97
- const previewText = first.text ?? first.title ?? "";
98
- const previewLines = getPreviewLines(previewText, 3, 100, uiTheme.format.ellipsis);
99
-
100
- for (const line of previewLines) {
101
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${uiTheme.fg("dim", line)}`;
102
- }
115
+ if (resultCount === 0) {
116
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "No results")}`;
117
+ return new Text(text, 0, 0);
118
+ }
119
+
120
+ const first = results[0];
121
+ const previewText = first.text ?? first.title ?? "";
122
+ const previewLines = previewText
123
+ ? getPreviewLines(previewText, COLLAPSED_PREVIEW_LINES, COLLAPSED_PREVIEW_LINE_LEN, uiTheme.format.ellipsis)
124
+ : [];
125
+ const safePreviewLines = previewLines.length > 0 ? previewLines : ["No preview text"];
126
+ const totalLines = previewText.split("\n").filter((l) => l.trim()).length;
127
+ const remainingLines = Math.max(0, totalLines - previewLines.length);
128
+ const extraItems: string[] = [];
129
+ if (remainingLines > 0) {
130
+ extraItems.push(formatMoreItems(remainingLines, "line", uiTheme));
131
+ }
132
+ if (resultCount > 1) {
133
+ extraItems.push(formatMoreItems(resultCount - 1, "result", uiTheme));
134
+ }
135
+
136
+ for (let i = 0; i < safePreviewLines.length; i++) {
137
+ const isLast = i === safePreviewLines.length - 1 && extraItems.length === 0;
138
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
139
+ const line = safePreviewLines[i];
140
+ const color = line === "No preview text" ? "muted" : "toolOutput";
141
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg(color, line)}`;
142
+ }
143
+
144
+ for (let i = 0; i < extraItems.length; i++) {
145
+ const isLast = i === extraItems.length - 1;
146
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
147
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("muted", extraItems[i])}`;
148
+ }
103
149
 
104
- const totalLines = previewText.split("\n").filter((l) => l.trim()).length;
105
- if (totalLines > 3) {
106
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${uiTheme.fg(
150
+ return new Text(text, 0, 0);
151
+ }
152
+
153
+ if (resultCount === 0) {
154
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "No results")}`;
155
+ return new Text(text, 0, 0);
156
+ }
157
+
158
+ for (let i = 0; i < results.length; i++) {
159
+ const res = results[i];
160
+ const isLast = i === results.length - 1;
161
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
162
+ const cont = isLast ? " " : uiTheme.tree.vertical;
163
+
164
+ const title = truncate(res.title ?? "Untitled", MAX_TITLE_LEN, uiTheme.format.ellipsis);
165
+ const domain = res.url ? getDomain(res.url) : "";
166
+ const domainPart = domain ? uiTheme.fg("dim", ` (${domain})`) : "";
167
+
168
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", title)}${domainPart}`;
169
+
170
+ if (res.url) {
171
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
172
+ "mdLinkUrl",
173
+ res.url,
174
+ )}`;
175
+ }
176
+
177
+ if (res.author) {
178
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
179
+ "muted",
180
+ `Author: ${res.author}`,
181
+ )}`;
182
+ }
183
+
184
+ if (res.publishedDate) {
185
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
186
+ "muted",
187
+ `Published: ${res.publishedDate}`,
188
+ )}`;
189
+ }
190
+
191
+ if (res.text) {
192
+ const textLines = res.text.split("\n").filter((l) => l.trim());
193
+ const displayLines = textLines.slice(0, EXPANDED_TEXT_LINES);
194
+ for (const line of displayLines) {
195
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
196
+ "toolOutput",
197
+ truncate(line.trim(), EXPANDED_TEXT_LINE_LEN, uiTheme.format.ellipsis),
198
+ )}`;
199
+ }
200
+ if (textLines.length > EXPANDED_TEXT_LINES) {
201
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
107
202
  "muted",
108
- `${uiTheme.format.ellipsis} ${totalLines - 3} more lines`,
203
+ formatMoreItems(textLines.length - EXPANDED_TEXT_LINES, "line", uiTheme),
109
204
  )}`;
110
205
  }
206
+ }
111
207
 
112
- if (resultCount > 1) {
113
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
208
+ if (res.highlights?.length) {
209
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
210
+ "accent",
211
+ "Highlights",
212
+ )}`;
213
+ const maxHighlights = Math.min(res.highlights.length, 3);
214
+ for (let j = 0; j < maxHighlights; j++) {
215
+ const h = res.highlights[j];
216
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
114
217
  "muted",
115
- `${resultCount - 1} more result${resultCount !== 2 ? "s" : ""}`,
218
+ `${uiTheme.format.dash} ${truncate(h, MAX_HIGHLIGHT_LEN, uiTheme.format.ellipsis)}`,
116
219
  )}`;
117
220
  }
118
- }
119
- } else {
120
- // Expanded view: full results tree
121
- if (resultCount > 0) {
122
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)}`;
123
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("accent", "Results")}`;
124
-
125
- for (let i = 0; i < results.length; i++) {
126
- const res = results[i];
127
- const isLast = i === results.length - 1;
128
- const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
129
- const cont = isLast ? " " : uiTheme.tree.vertical;
130
-
131
- // Title + domain
132
- const title = truncate(res.title ?? "Untitled", 60, uiTheme.format.ellipsis);
133
- const domain = res.url ? getDomain(res.url) : "";
134
- const domainPart = domain ? uiTheme.fg("dim", ` (${domain})`) : "";
135
-
136
- text += `\n ${uiTheme.fg("dim", " ")} ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", title)}${domainPart}`;
137
-
138
- // URL
139
- if (res.url) {
140
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("mdLinkUrl", res.url)}`;
141
- }
142
-
143
- // Author
144
- if (res.author) {
145
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("muted", `Author: ${res.author}`)}`;
146
- }
147
-
148
- // Published date
149
- if (res.publishedDate) {
150
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("muted", `Published: ${res.publishedDate}`)}`;
151
- }
152
-
153
- // Text content
154
- if (res.text) {
155
- const textLines = res.text.split("\n").filter((l) => l.trim());
156
- const displayLines = textLines.slice(0, 5); // Show first 5 lines
157
- for (const line of displayLines) {
158
- text += `\n ${uiTheme.fg("dim", cont)} ${truncate(line.trim(), 90, uiTheme.format.ellipsis)}`;
159
- }
160
- if (textLines.length > 5) {
161
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg(
162
- "muted",
163
- `${uiTheme.format.ellipsis} ${textLines.length - 5} more lines`,
164
- )}`;
165
- }
166
- }
167
-
168
- // Highlights
169
- if (res.highlights?.length) {
170
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("accent", "Highlights:")}`;
171
- for (let j = 0; j < Math.min(res.highlights.length, 3); j++) {
172
- const h = res.highlights[j];
173
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg(
174
- "muted",
175
- `${uiTheme.format.bullet} ${truncate(h, 80, uiTheme.format.ellipsis)}`,
176
- )}`;
177
- }
178
- if (res.highlights.length > 3) {
179
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg(
180
- "muted",
181
- `${uiTheme.format.ellipsis} ${res.highlights.length - 3} more`,
182
- )}`;
183
- }
184
- }
221
+ if (res.highlights.length > maxHighlights) {
222
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
223
+ "muted",
224
+ formatMoreItems(res.highlights.length - maxHighlights, "highlight", uiTheme),
225
+ )}`;
185
226
  }
186
227
  }
187
228
  }
@@ -191,10 +232,14 @@ export function renderExaResult(
191
232
 
192
233
  /** Render Exa call (query/args preview) */
193
234
  export function renderExaCall(args: Record<string, unknown>, toolName: string, uiTheme: Theme): Component {
194
- const query = typeof args.query === "string" ? truncate(args.query, 80, uiTheme.format.ellipsis) : "";
235
+ const toolLabel = toolName || "Exa Search";
236
+ const query = typeof args.query === "string" ? truncate(args.query, 80, uiTheme.format.ellipsis) : "?";
195
237
  const numResults = typeof args.num_results === "number" ? args.num_results : undefined;
196
- const detail = numResults ? uiTheme.fg("dim", ` (${numResults} results)`) : "";
197
238
 
198
- const text = `${uiTheme.fg("toolTitle", toolName)} ${uiTheme.fg("muted", query)}${detail}`;
239
+ let text = `${uiTheme.fg("toolTitle", toolLabel)} ${uiTheme.fg("accent", query)}`;
240
+ if (numResults !== undefined) {
241
+ text += ` ${uiTheme.fg("muted", `results:${numResults}`)}`;
242
+ }
243
+
199
244
  return new Text(text, 0, 0);
200
245
  }
@@ -83,8 +83,14 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
83
83
  const shouldSortByMtime = sortByMtime ?? false;
84
84
 
85
85
  // Build fd arguments
86
+ // When pattern contains path separators (e.g. "reports/**"), use --full-path
87
+ // so fd matches against the full path, not just the filename.
88
+ // Also prepend **/ to anchor the pattern at any depth in the search path.
89
+ const hasPathSeparator = pattern.includes("/") || pattern.includes("\\");
90
+ const effectivePattern = hasPathSeparator && !pattern.startsWith("**/") ? `**/${pattern}` : pattern;
86
91
  const args: string[] = [
87
92
  "--glob", // Use glob pattern
93
+ ...(hasPathSeparator ? ["--full-path"] : []),
88
94
  "--color=never", // No ANSI colors
89
95
  "--max-results",
90
96
  String(effectiveLimit),
@@ -127,7 +133,7 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
127
133
  }
128
134
 
129
135
  // Pattern and path
130
- args.push(pattern, searchPath);
136
+ args.push(effectivePattern, searchPath);
131
137
 
132
138
  // Run fd
133
139
  const result = Bun.spawnSync([fdPath, ...args], {