@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.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 (176) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  4. package/dist/types/commit/analysis/summary.d.ts +2 -2
  5. package/dist/types/commit/changelog/generate.d.ts +2 -2
  6. package/dist/types/commit/changelog/index.d.ts +2 -2
  7. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  8. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  10. package/dist/types/commit/model-selection.d.ts +10 -4
  11. package/dist/types/config/api-key-resolver.d.ts +34 -0
  12. package/dist/types/config/model-registry.d.ts +17 -1
  13. package/dist/types/config/settings-schema.d.ts +9 -0
  14. package/dist/types/dap/config.d.ts +14 -1
  15. package/dist/types/dap/types.d.ts +10 -0
  16. package/dist/types/lsp/utils.d.ts +3 -2
  17. package/dist/types/modes/components/chat-block.d.ts +64 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  19. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  20. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  21. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  22. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  23. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +0 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  27. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  28. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  29. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  30. package/dist/types/modes/interactive-mode.d.ts +15 -5
  31. package/dist/types/modes/theme/theme.d.ts +1 -1
  32. package/dist/types/modes/types.d.ts +18 -5
  33. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  34. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  35. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  36. package/dist/types/sdk.d.ts +2 -0
  37. package/dist/types/session/agent-session.d.ts +21 -0
  38. package/dist/types/session/messages.d.ts +12 -0
  39. package/dist/types/session/session-manager.d.ts +3 -1
  40. package/dist/types/slash-commands/types.d.ts +4 -6
  41. package/dist/types/task/executor.d.ts +7 -0
  42. package/dist/types/task/index.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +3 -2
  44. package/dist/types/tools/archive-reader.d.ts +5 -0
  45. package/dist/types/tools/ast-edit.d.ts +3 -0
  46. package/dist/types/tools/ast-grep.d.ts +3 -0
  47. package/dist/types/tools/bash.d.ts +1 -0
  48. package/dist/types/tools/find.d.ts +8 -4
  49. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  50. package/dist/types/tools/memory-render.d.ts +4 -1
  51. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  52. package/dist/types/tools/render-utils.d.ts +5 -9
  53. package/dist/types/tools/search.d.ts +4 -0
  54. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  55. package/dist/types/tools/todo.d.ts +3 -2
  56. package/dist/types/tools/write.d.ts +3 -0
  57. package/dist/types/tui/output-block.d.ts +16 -4
  58. package/dist/types/tui/status-line.d.ts +3 -0
  59. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  60. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  61. package/package.json +9 -9
  62. package/src/auto-thinking/classifier.ts +5 -1
  63. package/src/cli/dry-balance-cli.ts +52 -17
  64. package/src/cli/gallery-cli.ts +4 -1
  65. package/src/cli/gallery-fixtures/misc.ts +29 -0
  66. package/src/commit/analysis/conventional.ts +2 -2
  67. package/src/commit/analysis/summary.ts +2 -2
  68. package/src/commit/changelog/generate.ts +2 -2
  69. package/src/commit/changelog/index.ts +2 -2
  70. package/src/commit/map-reduce/index.ts +3 -3
  71. package/src/commit/map-reduce/map-phase.ts +2 -2
  72. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  73. package/src/commit/model-selection.ts +33 -9
  74. package/src/commit/pipeline.ts +4 -4
  75. package/src/config/api-key-resolver.ts +58 -0
  76. package/src/config/model-registry.ts +25 -2
  77. package/src/config/settings-schema.ts +10 -0
  78. package/src/config/settings.ts +20 -2
  79. package/src/dap/config.ts +41 -2
  80. package/src/dap/defaults.json +1 -0
  81. package/src/dap/session.ts +1 -0
  82. package/src/dap/types.ts +10 -0
  83. package/src/debug/index.ts +40 -54
  84. package/src/edit/renderer.ts +82 -78
  85. package/src/eval/__tests__/llm-bridge.test.ts +90 -31
  86. package/src/eval/llm-bridge.ts +8 -3
  87. package/src/goals/tools/goal-tool.ts +36 -26
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/lsp/utils.ts +3 -2
  90. package/src/main.ts +9 -7
  91. package/src/memories/index.ts +12 -5
  92. package/src/mnemopi/backend.ts +5 -1
  93. package/src/modes/acp/acp-agent.ts +33 -26
  94. package/src/modes/components/assistant-message.ts +2 -9
  95. package/src/modes/components/chat-block.ts +111 -0
  96. package/src/modes/components/copy-selector.ts +1 -44
  97. package/src/modes/components/custom-editor.ts +23 -0
  98. package/src/modes/components/custom-message.ts +1 -3
  99. package/src/modes/components/execution-shared.ts +1 -2
  100. package/src/modes/components/hook-message.ts +1 -3
  101. package/src/modes/components/overlay-box.ts +108 -0
  102. package/src/modes/components/plan-review-overlay.ts +799 -0
  103. package/src/modes/components/plan-toc.ts +138 -0
  104. package/src/modes/components/read-tool-group.ts +20 -4
  105. package/src/modes/components/skill-message.ts +0 -1
  106. package/src/modes/components/tips.txt +1 -0
  107. package/src/modes/components/todo-reminder.ts +0 -2
  108. package/src/modes/components/tool-execution.ts +68 -88
  109. package/src/modes/components/transcript-container.ts +84 -24
  110. package/src/modes/components/user-message.ts +1 -2
  111. package/src/modes/controllers/command-controller-shared.ts +7 -6
  112. package/src/modes/controllers/command-controller.ts +57 -55
  113. package/src/modes/controllers/event-controller.ts +41 -40
  114. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  115. package/src/modes/controllers/input-controller.ts +124 -119
  116. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  117. package/src/modes/controllers/selector-controller.ts +23 -25
  118. package/src/modes/controllers/streaming-reveal.ts +212 -0
  119. package/src/modes/controllers/tan-command-controller.ts +173 -0
  120. package/src/modes/interactive-mode.ts +169 -94
  121. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  122. package/src/modes/theme/theme-schema.json +1 -1
  123. package/src/modes/theme/theme.ts +8 -4
  124. package/src/modes/types.ts +18 -7
  125. package/src/modes/utils/copy-targets.ts +133 -27
  126. package/src/modes/utils/ui-helpers.ts +44 -46
  127. package/src/plan-mode/approved-plan.ts +66 -43
  128. package/src/plan-mode/plan-protection.ts +4 -4
  129. package/src/prompts/system/background-tan-dispatch.md +8 -0
  130. package/src/prompts/system/plan-mode-active.md +67 -58
  131. package/src/prompts/system/plan-mode-approved.md +1 -1
  132. package/src/sdk.ts +11 -37
  133. package/src/session/agent-session.ts +82 -6
  134. package/src/session/messages.ts +26 -0
  135. package/src/session/session-manager.ts +13 -5
  136. package/src/slash-commands/builtin-registry.ts +36 -9
  137. package/src/slash-commands/types.ts +4 -6
  138. package/src/task/executor.ts +5 -2
  139. package/src/task/index.ts +4 -0
  140. package/src/task/render.ts +212 -147
  141. package/src/tools/archive-reader.ts +64 -0
  142. package/src/tools/ask.ts +119 -164
  143. package/src/tools/ast-edit.ts +98 -71
  144. package/src/tools/ast-grep.ts +37 -43
  145. package/src/tools/bash.ts +50 -6
  146. package/src/tools/debug.ts +20 -8
  147. package/src/tools/fetch.ts +297 -7
  148. package/src/tools/find.ts +44 -30
  149. package/src/tools/gh-renderer.ts +81 -42
  150. package/src/tools/grouped-file-output.ts +272 -48
  151. package/src/tools/image-gen.ts +150 -103
  152. package/src/tools/inspect-image-renderer.ts +63 -41
  153. package/src/tools/inspect-image.ts +8 -1
  154. package/src/tools/job.ts +3 -4
  155. package/src/tools/memory-render.ts +4 -1
  156. package/src/tools/plan-mode-guard.ts +21 -39
  157. package/src/tools/read.ts +23 -16
  158. package/src/tools/render-utils.ts +21 -37
  159. package/src/tools/resolve.ts +14 -0
  160. package/src/tools/search-tool-bm25.ts +36 -23
  161. package/src/tools/search.ts +80 -78
  162. package/src/tools/sqlite-reader.ts +9 -12
  163. package/src/tools/todo.ts +118 -52
  164. package/src/tools/write.ts +81 -62
  165. package/src/tui/output-block.ts +60 -13
  166. package/src/tui/status-line.ts +5 -1
  167. package/src/utils/commit-message-generator.ts +9 -1
  168. package/src/utils/enhanced-paste.ts +202 -0
  169. package/src/utils/title-generator.ts +2 -1
  170. package/src/web/search/providers/anthropic.ts +25 -19
  171. package/src/web/search/providers/exa.ts +11 -3
  172. package/src/web/search/providers/kimi.ts +28 -17
  173. package/src/web/search/providers/parallel.ts +35 -24
  174. package/src/web/search/providers/synthetic.ts +8 -6
  175. package/src/web/search/providers/tavily.ts +9 -8
  176. package/src/web/search/providers/zai.ts +8 -6
package/src/tools/read.ts CHANGED
@@ -34,7 +34,7 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
34
34
  import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
35
35
  import { convertFileWithMarkit } from "../utils/markit";
36
36
  import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
37
- import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader";
37
+ import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "./archive-reader";
38
38
  import {
39
39
  type ConflictEntry,
40
40
  type ConflictScope,
@@ -1154,17 +1154,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1154
1154
  const limitedEntries = listLimit.items;
1155
1155
  const limitMeta = listLimit.meta;
1156
1156
 
1157
- const results: string[] = [];
1158
- for (const entry of limitedEntries) {
1157
+ for (let index = 0; index < limitedEntries.length; index++) {
1159
1158
  throwIfAborted(signal);
1160
- if (entry.isDirectory) {
1161
- results.push(`${entry.name}/`);
1162
- continue;
1163
- }
1164
-
1165
- const sizeSuffix = entry.size > 0 ? ` (${formatBytes(entry.size)})` : "";
1166
- results.push(`${entry.name}${sizeSuffix}`);
1167
1159
  }
1160
+ const results = formatArchiveEntryLines(limitedEntries);
1168
1161
 
1169
1162
  const output = results.length > 0 ? results.join("\n") : "(empty archive directory)";
1170
1163
  const text = prependSuffixResolutionNotice(output, details.suffixResolution);
@@ -2337,10 +2330,20 @@ function firstReadSelectorLine(sel: string | undefined): number | undefined {
2337
2330
  }
2338
2331
  }
2339
2332
 
2333
+ /** Absolute fs path the read result actually resolved to, used as the OSC 8 link
2334
+ * target when the structured `resolvedPath` isn't set (the common plain-file and
2335
+ * image reads only record the path in `meta.source`). URL/internal sources are
2336
+ * not fs paths, so only `type: "path"` qualifies. */
2337
+ function readSourceFsPath(details: ReadToolDetails | undefined): string | undefined {
2338
+ const source = details?.meta?.source;
2339
+ return source?.type === "path" ? source.value : undefined;
2340
+ }
2341
+
2340
2342
  function formatReadPathLink(
2341
2343
  rawPath: string,
2342
2344
  options: {
2343
2345
  resolvedPath?: string;
2346
+ sourcePath?: string;
2344
2347
  suffixResolution?: { from: string; to: string };
2345
2348
  offset?: number;
2346
2349
  fallbackLabel?: string;
@@ -2352,7 +2355,7 @@ function formatReadPathLink(
2352
2355
  const plainDisplayPath = options.suffixResolution
2353
2356
  ? shortenPath(options.suffixResolution.to)
2354
2357
  : shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath);
2355
- const target = options.resolvedPath ?? tryResolveInternalUrlSync(basePath);
2358
+ const target = options.resolvedPath ?? options.sourcePath ?? tryResolveInternalUrlSync(basePath);
2356
2359
  const line = firstReadSelectorLine(split.sel) ?? options.offset;
2357
2360
  const linkOptions = line !== undefined ? { line } : undefined;
2358
2361
  const displayPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
@@ -2403,7 +2406,9 @@ export const readToolRenderer = {
2403
2406
  const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
2404
2407
  const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, "");
2405
2408
  const rawPath = args?.file_path || args?.path || "";
2406
- const filePath = formatReadPathLink(rawPath, { offset: args?.offset }) || shortenPath(rawPath);
2409
+ const filePath =
2410
+ formatReadPathLink(rawPath, { offset: args?.offset, sourcePath: readSourceFsPath(result.details) }) ||
2411
+ shortenPath(rawPath);
2407
2412
  let title = filePath ? `Read ${filePath}` : "Read";
2408
2413
  if (args?.offset !== undefined || args?.limit !== undefined) {
2409
2414
  const startLine = args.offset ?? 1;
@@ -2454,6 +2459,7 @@ export const readToolRenderer = {
2454
2459
  const suffix = details?.suffixResolution;
2455
2460
  const displayPath = formatReadPathLink(rawPath, {
2456
2461
  resolvedPath: details?.resolvedPath,
2462
+ sourcePath: readSourceFsPath(details),
2457
2463
  suffixResolution: suffix,
2458
2464
  fallbackLabel: "image",
2459
2465
  });
@@ -2486,12 +2492,13 @@ export const readToolRenderer = {
2486
2492
  }
2487
2493
 
2488
2494
  const suffix = details?.suffixResolution;
2489
- // resolvedPath is the absolute fs path for fs-backed reads (regular files plus
2490
- // local:// / memory:// / skill:// / artifact:// resources). Fall back to a sync
2491
- // resolver for fs-backed internal URLs so the title is clickable even before the
2492
- // result lands or if the handler didn't populate resolvedPath.
2495
+ // resolvedPath is the absolute fs path when a read resolved/corrected the
2496
+ // input (suffix match, internal URL, archive/sqlite/notebook); plain file
2497
+ // reads only record the absolute path in meta.source, so fall back to that
2498
+ // (and then to a sync internal-URL resolver) to keep the title clickable.
2493
2499
  const displayPath = formatReadPathLink(rawPath, {
2494
2500
  resolvedPath: details?.resolvedPath,
2501
+ sourcePath: readSourceFsPath(details),
2495
2502
  suffixResolution: suffix,
2496
2503
  offset: args?.offset,
2497
2504
  });
@@ -10,8 +10,9 @@ import * as path from "node:path";
10
10
  import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
11
11
  import type { Ellipsis } from "@oh-my-pi/pi-natives";
12
12
  import type { Component } from "@oh-my-pi/pi-tui";
13
- import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
13
+ import { getKeybindings, replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
14
14
  import { pluralize } from "@oh-my-pi/pi-utils";
15
+ import { formatKeyHints, type KeyId } from "../config/keybindings";
15
16
  import { settings } from "../config/settings";
16
17
  import type { Theme } from "../modes/theme/theme";
17
18
  import { Hasher } from "../tui/utils";
@@ -75,8 +76,16 @@ export const TRUNCATE_LENGTHS = {
75
76
  SHORT: 40,
76
77
  } as const;
77
78
 
78
- /** Standard expand hint text */
79
- export const EXPAND_HINT = "(Ctrl+O for more)";
79
+ /** Keybinding action that toggles tool-output expansion. */
80
+ const EXPAND_ACTION = "app.tools.expand";
81
+ /** Fallback key when no binding is resolvable (e.g. outside an interactive session). */
82
+ const DEFAULT_EXPAND_KEY: KeyId = "ctrl+o";
83
+
84
+ /** Human-readable key currently bound to tool-output expansion, e.g. `Ctrl+O`. */
85
+ export function expandKeyHint(): string {
86
+ const keys = getKeybindings().getKeys(EXPAND_ACTION);
87
+ return formatKeyHints(keys.length > 0 ? keys : [DEFAULT_EXPAND_KEY]);
88
+ }
80
89
 
81
90
  // =============================================================================
82
91
  // Text Truncation Utilities
@@ -150,7 +159,7 @@ export function formatStatusIcon(status: ToolUIStatus, theme: Theme, spinnerFram
150
159
  export function formatExpandHint(theme: Theme, expanded?: boolean, hasMore?: boolean): string {
151
160
  if (expanded) return "";
152
161
  if (hasMore === false) return "";
153
- return theme.fg("dim", wrapBrackets(EXPAND_HINT, theme));
162
+ return theme.fg("dim", wrapBrackets(`${expandKeyHint()}: Expand`, theme));
154
163
  }
155
164
 
156
165
  /**
@@ -736,36 +745,6 @@ export function capParseErrors(
736
745
  // Renderer helpers shared by search / find / ast tools
737
746
  // =============================================================================
738
747
 
739
- /**
740
- * Group `rawLines` by blank-line separators, mirroring the historical search /
741
- * ast-grep / ast-edit renderer behavior: if any blank line is present, splits on
742
- * runs of blank lines; otherwise collapses non-empty lines into a single group.
743
- */
744
- export function splitGroupsByBlankLine(rawLines: string[]): string[][] {
745
- const hasSeparators = rawLines.some(line => line.trim().length === 0);
746
- const groups: string[][] = [];
747
- if (hasSeparators) {
748
- let current: string[] = [];
749
- for (const line of rawLines) {
750
- if (line.trim().length === 0) {
751
- if (current.length > 0) {
752
- groups.push(current);
753
- current = [];
754
- }
755
- continue;
756
- }
757
- current.push(line);
758
- }
759
- if (current.length > 0) groups.push(current);
760
- } else {
761
- const nonEmpty = rawLines.filter(line => line.trim().length > 0);
762
- if (nonEmpty.length > 0) {
763
- groups.push(nonEmpty);
764
- }
765
- }
766
- return groups;
767
- }
768
-
769
748
  /**
770
749
  * Standard width+expand keyed render cache used by every search-style tool
771
750
  * renderer. `compute` re-runs only when the cache key changes; the returned
@@ -774,6 +753,7 @@ export function splitGroupsByBlankLine(rawLines: string[]): string[][] {
774
753
  export function createCachedComponent(
775
754
  getExpanded: () => boolean,
776
755
  compute: (width: number, expanded: boolean) => string[],
756
+ options: { paddingX?: number } = {},
777
757
  ): Component {
778
758
  let cached: { key: bigint; lines: string[] } | undefined;
779
759
  return {
@@ -781,9 +761,13 @@ export function createCachedComponent(
781
761
  const expanded = getExpanded();
782
762
  const key = new Hasher().bool(expanded).u32(width).digest();
783
763
  if (cached?.key === key) return cached.lines;
784
- const lines = compute(width, expanded);
785
- cached = { key, lines };
786
- return lines;
764
+ const paddingX = Math.max(0, options.paddingX ?? 0);
765
+ const innerWidth = Math.max(1, width - paddingX * 2);
766
+ const lines = compute(innerWidth, expanded);
767
+ const pad = paddingX === 0 ? "" : " ".repeat(paddingX);
768
+ const paddedLines = paddingX === 0 ? lines : lines.map(line => `${pad}${line}${pad}`);
769
+ cached = { key, lines: paddedLines };
770
+ return paddedLines;
787
771
  },
788
772
  invalidate() {
789
773
  cached = undefined;
@@ -187,6 +187,20 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
187
187
  return untilAborted(signal, async () => {
188
188
  const invoker = this.session.peekQueueInvoker?.() ?? this.session.peekStandingResolveHandler?.();
189
189
  if (!invoker) {
190
+ // `discard` is a request to cancel/abort a staged action. When nothing is
191
+ // pending, the desired end-state (no staged change) already holds, so honor
192
+ // it as a successful cancellation instead of surfacing a hard error to the
193
+ // model. `apply` still errors — there is nothing to apply.
194
+ if (params.action === "discard") {
195
+ return {
196
+ content: [{ type: "text" as const, text: "Nothing to discard; no pending action remains." }],
197
+ details: {
198
+ action: "discard",
199
+ reason: params.reason,
200
+ ...(params.extra != null ? { extra: params.extra } : {}),
201
+ },
202
+ };
203
+ }
190
204
  throw new ToolError("No pending action to resolve. Nothing to apply or discard.");
191
205
  }
192
206
  const result = (await invoker(params)) as AgentToolResult<ResolveToolDetails>;
@@ -16,9 +16,9 @@ import {
16
16
  searchDiscoverableTools,
17
17
  summarizeDiscoverableTools,
18
18
  } from "../tool-discovery/tool-index";
19
- import { renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
19
+ import { framedBlock, renderStatusLine, truncateToWidth } from "../tui";
20
20
  import type { ToolSession } from ".";
21
- import { formatCount, replaceTabs, TRUNCATE_LENGTHS } from "./render-utils";
21
+ import { formatCount, formatExpandHint, formatMoreItems, replaceTabs, TRUNCATE_LENGTHS } from "./render-utils";
22
22
  import { ToolError } from "./tool-errors";
23
23
 
24
24
  const DEFAULT_LIMIT = 8;
@@ -171,6 +171,25 @@ function renderMatchLines(match: SearchToolBm25Match, theme: Theme): string[] {
171
171
  return lines;
172
172
  }
173
173
 
174
+ function renderMatchBullets(tools: SearchToolBm25Match[], expanded: boolean, theme: Theme): string[] {
175
+ const shown = expanded ? tools.length : Math.min(tools.length, COLLAPSED_MATCH_LIMIT);
176
+ const bullet = theme.fg("dim", theme.format.bullet);
177
+ const lines: string[] = [];
178
+ for (let i = 0; i < shown; i++) {
179
+ const itemLines = renderMatchLines(tools[i]!, theme);
180
+ lines.push(`${bullet} ${itemLines[0]}`);
181
+ for (let j = 1; j < itemLines.length; j++) {
182
+ lines.push(` ${itemLines[j]}`);
183
+ }
184
+ }
185
+ const remaining = tools.length - shown;
186
+ if (remaining > 0) {
187
+ const hint = formatExpandHint(theme, expanded, true);
188
+ lines.push(`${theme.fg("muted", formatMoreItems(remaining, "tool"))}${hint ? ` ${hint}` : ""}`);
189
+ }
190
+ return lines;
191
+ }
192
+
174
193
  function renderFallbackResult(text: string, theme: Theme): Component {
175
194
  const header = renderStatusLine({ icon: "warning", title: TOOL_DISCOVERY_TITLE }, theme);
176
195
  const bodyLines = (text || "Tool discovery completed")
@@ -271,14 +290,11 @@ export const searchToolBm25Renderer = {
271
290
  renderCall(args: SearchToolBm25Params, _options: RenderResultOptions, uiTheme: Theme): Component {
272
291
  const query = typeof args.query === "string" ? replaceTabs(args.query.trim()) : "";
273
292
  const meta = args.limit ? [`limit:${args.limit}`] : [];
274
- return new Text(
275
- renderStatusLine(
276
- { icon: "pending", title: TOOL_DISCOVERY_TITLE, description: query || "(empty query)", meta },
277
- uiTheme,
278
- ),
279
- 0,
280
- 0,
293
+ const header = renderStatusLine(
294
+ { icon: "pending", title: TOOL_DISCOVERY_TITLE, description: query || "(empty query)", meta },
295
+ uiTheme,
281
296
  );
297
+ return new Text(header, 0, 0);
282
298
  },
283
299
 
284
300
  renderResult(
@@ -305,7 +321,9 @@ export const searchToolBm25Renderer = {
305
321
  const safeQuery = replaceTabs(details.query);
306
322
  const header = renderStatusLine(
307
323
  {
308
- icon: details.tools.length > 0 ? "success" : "warning",
324
+ ...(details.tools.length > 0
325
+ ? { iconOverride: uiTheme.fg("accent", uiTheme.symbol("icon.search")) }
326
+ : { icon: "warning" as const }),
309
327
  title: TOOL_DISCOVERY_TITLE,
310
328
  description: truncateToWidth(safeQuery, MATCH_LABEL_LEN),
311
329
  meta,
@@ -318,19 +336,14 @@ export const searchToolBm25Renderer = {
318
336
  return new Text(`${header}\n${uiTheme.fg("muted", emptyMessage)}`, 0, 0);
319
337
  }
320
338
 
321
- const lines = [header];
322
- const treeLines = renderTreeList(
323
- {
324
- items: details.tools,
325
- expanded: options.expanded,
326
- maxCollapsed: COLLAPSED_MATCH_LIMIT,
327
- itemType: "tool",
328
- renderItem: match => renderMatchLines(match, uiTheme),
329
- },
330
- uiTheme,
331
- );
332
- lines.push(...treeLines);
333
- return new Text(lines.join("\n"), 0, 0);
339
+ return framedBlock(uiTheme, width => ({
340
+ header,
341
+ sections: [{ lines: renderMatchBullets(details.tools, options.expanded ?? false, uiTheme) }],
342
+ state: "success",
343
+ borderColor: "borderMuted",
344
+ applyBg: false,
345
+ width,
346
+ }));
334
347
  },
335
348
 
336
349
  mergeCallAndResult: true,
@@ -36,7 +36,7 @@ import {
36
36
  parseArchivePathCandidates,
37
37
  } from "./archive-reader";
38
38
  import { createFileRecorder, formatResultPath } from "./file-recorder";
39
- import { formatGroupedFiles } from "./grouped-file-output";
39
+ import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output";
40
40
  import { formatMatchLine } from "./match-line-format";
41
41
  import type { OutputMeta } from "./output-meta";
42
42
  import {
@@ -61,7 +61,6 @@ import {
61
61
  formatMoreItems,
62
62
  PREVIEW_LIMITS,
63
63
  replaceTabs,
64
- splitGroupsByBlankLine,
65
64
  } from "./render-utils";
66
65
  import { ToolError } from "./tool-errors";
67
66
  import { toolResult } from "./tool-result";
@@ -287,7 +286,6 @@ interface IndexedContentLines {
287
286
  starts: number[];
288
287
  }
289
288
 
290
- const INTERNAL_URL_DISPLAY_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
291
289
  const OMP_ROOT_URL_RE = /^omp:\/\/(?:\/?|docs\/?)$/i;
292
290
 
293
291
  function normalizeSearchLine(line: string): string {
@@ -623,6 +621,10 @@ export interface SearchToolDetails {
623
621
  /** Absolute base directory used during search. Used by the renderer to resolve
624
622
  * display-relative paths to absolute paths for OSC 8 hyperlinks. */
625
623
  searchPath?: string;
624
+ /** Session cwd at search time. The renderer resolves the display-relative
625
+ * (cwd-relative) header/match paths against this for OSC 8 hyperlinks;
626
+ * `searchPath` is the scope label target, not the display-path base. */
627
+ cwd?: string;
626
628
  /** User-supplied paths whose base directory was missing on disk. The tool
627
629
  * skipped these and continued with the surviving entries; surfaced as a
628
630
  * non-fatal warning in the renderer and in the model-facing text. */
@@ -1005,6 +1007,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
1005
1007
  const details: SearchToolDetails = {
1006
1008
  scopePath,
1007
1009
  searchPath,
1010
+ cwd: this.session.cwd,
1008
1011
  matchCount: 0,
1009
1012
  fileCount: 0,
1010
1013
  files: [],
@@ -1131,6 +1134,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
1131
1134
  const details: SearchToolDetails = {
1132
1135
  scopePath,
1133
1136
  searchPath,
1137
+ cwd: this.session.cwd,
1134
1138
  matchCount: selectedMatches.length,
1135
1139
  fileCount: fileList.length,
1136
1140
  files: fileList,
@@ -1210,69 +1214,49 @@ function isSearchMatchLine(line: string): boolean {
1210
1214
  }
1211
1215
 
1212
1216
  function isSearchHeaderLine(line: string): boolean {
1213
- return line.startsWith("# ") || line.startsWith("## ");
1217
+ return /^#+ /.test(line);
1214
1218
  }
1215
1219
 
1216
- function renderSearchDisplayGroup(
1217
- group: string[],
1218
- searchBase: string | undefined,
1220
+ const URL_HEADER_PREFIX_RE = /^#+\s+/;
1221
+
1222
+ function renderSearchDisplayLines(
1223
+ lines: readonly string[],
1224
+ headerBase: string | undefined,
1225
+ fileScope: string | undefined,
1219
1226
  uiTheme: Theme,
1220
1227
  ): RenderedSearchLine[] {
1221
- // Track directory/file context within a group so headers and code-frame
1222
- // lines link to the backing file, with line-specific links for matches.
1223
- let contextDir = searchBase ?? "";
1224
- const hasFileHeader = group.some(line => line.startsWith("# "));
1225
- let currentFilePath: string | undefined = hasFileHeader ? undefined : searchBase;
1226
- return group.map(line => {
1227
- if (line.startsWith("## ")) {
1228
- // Strip optional ` (suffix)` and `#hash` before resolving.
1229
- const fileName = line
1230
- .slice(3)
1231
- .trimEnd()
1232
- .replace(/\s+\([^)]*\)\s*$/, "")
1233
- .replace(/#[0-9a-f]+$/, "");
1234
- const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
1235
- currentFilePath = absPath;
1236
- const styled = uiTheme.fg("dim", line);
1237
- return { raw: line, styled: absPath ? fileHyperlink(absPath, styled) : styled };
1228
+ const contexts = classifyGroupedLines(lines, headerBase, fileScope);
1229
+ // `classifyGroupedLines` can't resolve internal URLs (TUI-only), so track the
1230
+ // resolved URL target here and use it for the body lines that follow.
1231
+ let urlFile: string | undefined;
1232
+ return lines.map((line, index) => {
1233
+ const ctx = contexts[index]!;
1234
+ if (ctx.kind === "dir") {
1235
+ urlFile = undefined;
1236
+ const styled = uiTheme.fg("accent", line);
1237
+ return { raw: line, styled: ctx.headerPath ? fileHyperlink(ctx.headerPath, styled) : styled };
1238
1238
  }
1239
- if (line.startsWith("# ")) {
1240
- const raw = line
1241
- .slice(2)
1242
- .trimEnd()
1243
- .replace(/\s+\([^)]*\)\s*$/, "");
1244
- if (INTERNAL_URL_DISPLAY_RE.test(raw)) {
1245
- contextDir = "";
1246
- const styled = uiTheme.fg("accent", line);
1247
- const linked = linkUrlLikeSearchHeader(raw, styled);
1248
- currentFilePath = linked.absPath;
1239
+ if (ctx.kind === "file") {
1240
+ if (ctx.isUrl) {
1241
+ const raw = line
1242
+ .replace(URL_HEADER_PREFIX_RE, "")
1243
+ .trimEnd()
1244
+ .replace(/\s+\([^)]*\)\s*$/, "");
1245
+ const linked = linkUrlLikeSearchHeader(raw, uiTheme.fg("accent", line));
1246
+ urlFile = linked.absPath;
1249
1247
  return { raw: line, styled: linked.line };
1250
1248
  }
1251
- const isDirectory = raw.endsWith("/");
1252
- const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, "");
1253
- if (isDirectory) {
1254
- const absPath = searchBase ? (name === "." ? searchBase : path.join(searchBase, name)) : undefined;
1255
- if (absPath) {
1256
- contextDir = absPath;
1257
- }
1258
- currentFilePath = undefined;
1259
- const styled = uiTheme.fg("accent", line);
1260
- return { raw: line, styled: absPath ? fileHyperlink(absPath, styled) : styled };
1261
- }
1262
- // Root-level file emitted by formatGroupedFiles when the directory is `.`.
1263
- const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
1264
- currentFilePath = absPath;
1265
- const styled = uiTheme.fg("accent", line);
1266
- return { raw: line, styled: absPath ? fileHyperlink(absPath, styled) : styled };
1249
+ urlFile = undefined;
1250
+ // Root-level files keep the bright accent; nested file headers are dimmed.
1251
+ const styled = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", line);
1252
+ return { raw: line, styled: ctx.headerPath ? fileHyperlink(ctx.headerPath, styled) : styled };
1267
1253
  }
1268
1254
  const styled = uiTheme.fg("toolOutput", line);
1269
1255
  const lineNumber = parseSearchDisplayLineNumber(line);
1256
+ const filePath = ctx.filePath ?? urlFile;
1270
1257
  return {
1271
1258
  raw: line,
1272
- styled:
1273
- currentFilePath && lineNumber !== undefined
1274
- ? fileHyperlink(currentFilePath, styled, { line: lineNumber })
1275
- : styled,
1259
+ styled: filePath && lineNumber !== undefined ? fileHyperlink(filePath, styled, { line: lineNumber }) : styled,
1276
1260
  };
1277
1261
  });
1278
1262
  }
@@ -1288,19 +1272,15 @@ function countPreviewMatches(lines: readonly RenderedSearchLine[], hasMarkedMatc
1288
1272
  }
1289
1273
 
1290
1274
  function renderBudgetedSearchGroups(
1291
- groups: string[][],
1275
+ groups: RenderedSearchLine[][],
1292
1276
  maxLines: number,
1293
1277
  matchCount: number,
1294
- searchBase: string | undefined,
1295
1278
  uiTheme: Theme,
1296
1279
  compact: boolean,
1297
1280
  ): string[] {
1298
1281
  if (maxLines <= 0) return [];
1299
1282
  const renderedGroups = groups
1300
- .map(group => {
1301
- const rendered = renderSearchDisplayGroup(group, searchBase, uiTheme);
1302
- return compact ? compactSearchPreviewGroup(rendered) : rendered;
1303
- })
1283
+ .map(group => (compact ? compactSearchPreviewGroup(group) : group))
1304
1284
  .filter(group => group.length > 0);
1305
1285
  if (renderedGroups.length === 0) return [];
1306
1286
 
@@ -1352,6 +1332,10 @@ function renderBudgetedSearchGroups(
1352
1332
  return lines;
1353
1333
  }
1354
1334
 
1335
+ function searchStatusIcon(uiTheme: Theme): string {
1336
+ return uiTheme.fg("toolTitle", uiTheme.symbol("icon.search"));
1337
+ }
1338
+
1355
1339
  export const searchToolRenderer = {
1356
1340
  inline: true,
1357
1341
  renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
@@ -1363,10 +1347,10 @@ export const searchToolRenderer = {
1363
1347
  if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
1364
1348
 
1365
1349
  const text = renderStatusLine(
1366
- { icon: "pending", title: "Search", description: args.pattern || "?", meta },
1350
+ { icon: "pending", title: "Search", titleColor: "toolTitle", description: args.pattern || "?", meta },
1367
1351
  uiTheme,
1368
1352
  );
1369
- return new Text(text, 0, 0);
1353
+ return new Text(text, 1, 0);
1370
1354
  },
1371
1355
 
1372
1356
  renderResult(
@@ -1379,7 +1363,7 @@ export const searchToolRenderer = {
1379
1363
 
1380
1364
  if (result.isError || details?.error) {
1381
1365
  const errorText = details?.error || result.content?.find(c => c.type === "text")?.text || "Unknown error";
1382
- return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
1366
+ return new Text(formatErrorMessage(errorText, uiTheme), 1, 0);
1383
1367
  }
1384
1368
 
1385
1369
  const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined;
@@ -1387,12 +1371,18 @@ export const searchToolRenderer = {
1387
1371
  if (!hasDetailedData) {
1388
1372
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text;
1389
1373
  if (!textContent || textContent === "No matches found") {
1390
- return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0);
1374
+ return new Text(formatEmptyMessage("No matches found", uiTheme), 1, 0);
1391
1375
  }
1392
1376
  const lines = textContent.split("\n").filter(line => line.trim() !== "");
1393
1377
  const description = args?.pattern ?? undefined;
1394
1378
  const header = renderStatusLine(
1395
- { icon: "success", title: "Search", description, meta: [formatCount("item", lines.length)] },
1379
+ {
1380
+ iconOverride: searchStatusIcon(uiTheme),
1381
+ title: "Search",
1382
+ titleColor: "toolTitle",
1383
+ description,
1384
+ meta: [formatCount("item", lines.length)],
1385
+ },
1396
1386
  uiTheme,
1397
1387
  );
1398
1388
  return createCachedComponent(
@@ -1411,6 +1401,7 @@ export const searchToolRenderer = {
1411
1401
  );
1412
1402
  return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
1413
1403
  },
1404
+ { paddingX: 1 },
1414
1405
  );
1415
1406
  }
1416
1407
 
@@ -1431,12 +1422,12 @@ export const searchToolRenderer = {
1431
1422
  const scopeMeta = searchScopeMeta(details);
1432
1423
  if (scopeMeta) meta.push(scopeMeta);
1433
1424
  const header = renderStatusLine(
1434
- { icon: "warning", title: "Search", description: args?.pattern, meta },
1425
+ { icon: "warning", title: "Search", titleColor: "toolTitle", description: args?.pattern, meta },
1435
1426
  uiTheme,
1436
1427
  );
1437
1428
  const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
1438
1429
  if (missingNote) lines.push(missingNote);
1439
- return new Text(lines.join("\n"), 0, 0);
1430
+ return new Text(lines.join("\n"), 1, 0);
1440
1431
  }
1441
1432
 
1442
1433
  const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
@@ -1446,12 +1437,30 @@ export const searchToolRenderer = {
1446
1437
  if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
1447
1438
  const description = args?.pattern ?? undefined;
1448
1439
  const header = renderStatusLine(
1449
- { icon: truncated ? "warning" : "success", title: "Search", description, meta },
1440
+ {
1441
+ ...(truncated ? { icon: "warning" as const } : { iconOverride: searchStatusIcon(uiTheme) }),
1442
+ title: "Search",
1443
+ titleColor: "toolTitle",
1444
+ description,
1445
+ meta,
1446
+ },
1450
1447
  uiTheme,
1451
1448
  );
1452
1449
 
1453
1450
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
1454
- const matchGroups = splitGroupsByBlankLine(textContent.split("\n"));
1451
+ const allLines = textContent.split("\n");
1452
+ // Resolve hyperlinks once over the whole output so a nested directory stack
1453
+ // reconstructs correctly across blank-line group boundaries.
1454
+ // Header/match display paths are cwd-relative, so resolve them against cwd
1455
+ // (falling back to searchPath for legacy results that predate `cwd`); the
1456
+ // scoped file's absolute path seeds body lines in single-file searches.
1457
+ const renderedLines = renderSearchDisplayLines(
1458
+ allLines,
1459
+ details?.cwd ?? details?.searchPath,
1460
+ details?.searchPath,
1461
+ uiTheme,
1462
+ );
1463
+ const matchGroups = groupLineIndicesByBlank(allLines).map(indices => indices.map(i => renderedLines[i]!));
1455
1464
 
1456
1465
  const extraLines: string[] = [];
1457
1466
  if (missingNote) extraLines.push(missingNote);
@@ -1463,17 +1472,10 @@ export const searchToolRenderer = {
1463
1472
  (options.expanded ? EXPANDED_TEXT_LIMIT : COLLAPSED_TEXT_LIMIT) - extraLines.length,
1464
1473
  0,
1465
1474
  );
1466
- const searchBase = details?.searchPath;
1467
- const matchLines = renderBudgetedSearchGroups(
1468
- matchGroups,
1469
- budget,
1470
- matchCount,
1471
- searchBase,
1472
- uiTheme,
1473
- !options.expanded,
1474
- );
1475
+ const matchLines = renderBudgetedSearchGroups(matchGroups, budget, matchCount, uiTheme, !options.expanded);
1475
1476
  return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
1476
1477
  },
1478
+ { paddingX: 1 },
1477
1479
  );
1478
1480
  },
1479
1481
  mergeCallAndResult: true,
@@ -5,6 +5,14 @@ import { ToolError } from "./tool-errors";
5
5
  const SQLITE_MAGIC = new Uint8Array([
6
6
  0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00,
7
7
  ]);
8
+
9
+ export function looksLikeSqlite(bytes: Uint8Array): boolean {
10
+ if (bytes.byteLength < SQLITE_MAGIC.byteLength) return false;
11
+ for (const [index, byte] of SQLITE_MAGIC.entries()) {
12
+ if (bytes[index] !== byte) return false;
13
+ }
14
+ return true;
15
+ }
8
16
  const SQLITE_PATH_PATTERN = /\.(?:sqlite3?|db3?)(?=(?::|\?|$))/gi;
9
17
  const DEFAULT_QUERY_LIMIT = 20;
10
18
  const DEFAULT_SCHEMA_SAMPLE_LIMIT = 5;
@@ -443,18 +451,7 @@ export function parseSqlitePathCandidates(filePath: string): SqlitePathCandidate
443
451
 
444
452
  export async function isSqliteFile(absolutePath: string): Promise<boolean> {
445
453
  try {
446
- const bytes = await Bun.file(absolutePath).slice(0, SQLITE_MAGIC.byteLength).bytes();
447
- if (bytes.length !== SQLITE_MAGIC.byteLength) {
448
- return false;
449
- }
450
-
451
- for (const [index, byte] of SQLITE_MAGIC.entries()) {
452
- if (bytes[index] !== byte) {
453
- return false;
454
- }
455
- }
456
-
457
- return true;
454
+ return looksLikeSqlite(await Bun.file(absolutePath).slice(0, SQLITE_MAGIC.byteLength).bytes());
458
455
  } catch {
459
456
  return false;
460
457
  }