@linxiraos/pi-tui 1.2.1 → 1.3.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 (280) hide show
  1. package/CHANGELOG.md +245 -224
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/app-keybindings.d.ts +18 -8
  5. package/dist/types/apps/git/diff-pane.d.ts +5 -0
  6. package/dist/types/apps/git/git-tui.d.ts +16 -2
  7. package/dist/types/apps/git/state.d.ts +11 -0
  8. package/dist/types/autocomplete.d.ts +2 -0
  9. package/dist/types/charts/chart-svg.d.ts +7 -13
  10. package/dist/types/chat/advisor-message.d.ts +4 -1
  11. package/dist/types/chat/assistant-message.d.ts +9 -4
  12. package/dist/types/chat/figure-markdown.d.ts +7 -2
  13. package/dist/types/chat/messages.d.ts +3 -1
  14. package/dist/types/chat/read-tool-group.d.ts +2 -2
  15. package/dist/types/chat/table-chart.d.ts +11 -15
  16. package/dist/types/chat/tool-execution.d.ts +32 -20
  17. package/dist/types/chat/user-message.d.ts +3 -2
  18. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  19. package/dist/types/chrome/diff.d.ts +2 -3
  20. package/dist/types/chrome/status-notice.d.ts +2 -0
  21. package/dist/types/chrome/transcript-container.d.ts +2 -0
  22. package/dist/types/components/image.d.ts +2 -0
  23. package/dist/types/components/markdown.d.ts +49 -3
  24. package/dist/types/components/text.d.ts +2 -0
  25. package/dist/types/debug-server.d.ts +2 -0
  26. package/dist/types/keybinding-matchers.d.ts +6 -0
  27. package/dist/types/multiplexers/tmux.d.ts +1 -0
  28. package/dist/types/multiplexers/types.d.ts +6 -0
  29. package/dist/types/native/backend.d.ts +5 -4
  30. package/dist/types/native/blobs.d.ts +24 -12
  31. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  32. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  33. package/dist/types/overlays/approval-dialog.d.ts +79 -0
  34. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  35. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  36. package/dist/types/overlays/hook-editor.d.ts +7 -2
  37. package/dist/types/overlays/hook-input.d.ts +12 -4
  38. package/dist/types/overlays/job-tree.d.ts +50 -0
  39. package/dist/types/overlays/jobs-panel.d.ts +25 -11
  40. package/dist/types/overlays/login-dialog.d.ts +8 -3
  41. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  42. package/dist/types/overlays/model-browser.d.ts +6 -1
  43. package/dist/types/overlays/model-picker.d.ts +2 -0
  44. package/dist/types/overlays/pause-screen.d.ts +5 -0
  45. package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
  46. package/dist/types/overlays/report-panel.d.ts +5 -1
  47. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  48. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  49. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  50. package/dist/types/overlays/session-selector.d.ts +3 -1
  51. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  52. package/dist/types/prompt/composer.d.ts +23 -20
  53. package/dist/types/prompt/custom-editor.d.ts +20 -10
  54. package/dist/types/prompt/image-references.d.ts +22 -0
  55. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  56. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  57. package/dist/types/prompt/prose-gate.d.ts +3 -0
  58. package/dist/types/prompt/queued-messages.d.ts +1 -1
  59. package/dist/types/prompt/typing-word.d.ts +25 -0
  60. package/dist/types/prompt/welcome.d.ts +26 -6
  61. package/dist/types/render/hanging-wrap.d.ts +7 -0
  62. package/dist/types/render/hyperlink.d.ts +2 -0
  63. package/dist/types/render/render-utils.d.ts +9 -1
  64. package/dist/types/render/utils.d.ts +1 -1
  65. package/dist/types/setup/scenes/splash.d.ts +6 -5
  66. package/dist/types/status-line/context-usage.d.ts +4 -1
  67. package/dist/types/status-line/host.d.ts +6 -4
  68. package/dist/types/status-line/startup.d.ts +6 -6
  69. package/dist/types/status-line/types.d.ts +3 -0
  70. package/dist/types/stdin-buffer.d.ts +7 -0
  71. package/dist/types/terminal-capabilities.d.ts +16 -1
  72. package/dist/types/terminal-multiplexer.d.ts +7 -0
  73. package/dist/types/terminal.d.ts +8 -4
  74. package/dist/types/theme/symbols.d.ts +1 -1
  75. package/dist/types/theme/theme.d.ts +11 -0
  76. package/dist/types/thinking.d.ts +5 -0
  77. package/dist/types/tmux.d.ts +39 -4
  78. package/dist/types/tools/ast-edit.d.ts +2 -2
  79. package/dist/types/tools/ast-grep.d.ts +2 -2
  80. package/dist/types/tools/bash.d.ts +6 -1
  81. package/dist/types/tools/edit.d.ts +2 -0
  82. package/dist/types/tools/eval.d.ts +53 -4
  83. package/dist/types/tools/find.d.ts +14 -4
  84. package/dist/types/tools/glob.d.ts +3 -3
  85. package/dist/types/tools/grep.d.ts +2 -2
  86. package/dist/types/tools/grouped-file-output.d.ts +9 -4
  87. package/dist/types/tools/native-view.d.ts +1 -1
  88. package/dist/types/tools/read.d.ts +32 -10
  89. package/dist/types/tools/renderer.d.ts +2 -0
  90. package/dist/types/tools/task.d.ts +31 -0
  91. package/dist/types/tools/web-search-types.d.ts +1 -0
  92. package/dist/types/tools/write.d.ts +2 -0
  93. package/dist/types/tui.d.ts +20 -3
  94. package/package.json +10 -10
  95. package/src/app-keybindings.d.ts +18 -8
  96. package/src/app-keybindings.ts +20 -8
  97. package/src/apps/autoresearch-dashboard.ts +444 -27
  98. package/src/apps/debug/raw-sse.ts +2 -2
  99. package/src/apps/git/diff-pane.ts +39 -41
  100. package/src/apps/git/git-tui.ts +80 -14
  101. package/src/apps/git/sidebar.ts +10 -1
  102. package/src/apps/git/state.ts +12 -0
  103. package/src/autocomplete.d.ts +2 -0
  104. package/src/autocomplete.ts +21 -19
  105. package/src/bracketed-paste.ts +9 -2
  106. package/src/charts/chart-svg.d.ts +7 -13
  107. package/src/charts/chart-svg.ts +19 -22
  108. package/src/charts/table-data.ts +17 -6
  109. package/src/chat/advisor-message.d.ts +4 -1
  110. package/src/chat/advisor-message.ts +77 -24
  111. package/src/chat/assistant-message.d.ts +9 -4
  112. package/src/chat/assistant-message.ts +127 -31
  113. package/src/chat/bash-execution.ts +7 -2
  114. package/src/chat/figure-markdown.d.ts +7 -2
  115. package/src/chat/figure-markdown.ts +10 -3
  116. package/src/chat/image-loading.ts +9 -6
  117. package/src/chat/messages.d.ts +3 -1
  118. package/src/chat/messages.ts +4 -1
  119. package/src/chat/read-tool-group.d.ts +2 -2
  120. package/src/chat/read-tool-group.ts +97 -42
  121. package/src/chat/svg-figure.ts +3 -1
  122. package/src/chat/svg-source.ts +6 -3
  123. package/src/chat/table-chart.d.ts +11 -15
  124. package/src/chat/table-chart.ts +24 -57
  125. package/src/chat/tool-execution.d.ts +32 -20
  126. package/src/chat/tool-execution.ts +103 -36
  127. package/src/chat/user-message.d.ts +3 -2
  128. package/src/chat/user-message.ts +54 -34
  129. package/src/chrome/countdown-timer.ts +3 -3
  130. package/src/chrome/diff.d.ts +2 -3
  131. package/src/chrome/diff.ts +4 -9
  132. package/src/chrome/status-notice.ts +5 -0
  133. package/src/chrome/transcript-container.d.ts +2 -0
  134. package/src/chrome/transcript-container.ts +212 -28
  135. package/src/components/editor.ts +12 -12
  136. package/src/components/image.d.ts +2 -0
  137. package/src/components/image.ts +54 -20
  138. package/src/components/markdown.d.ts +49 -3
  139. package/src/components/markdown.ts +482 -12
  140. package/src/components/settings-list.ts +2 -4
  141. package/src/components/text.d.ts +2 -0
  142. package/src/components/text.ts +11 -1
  143. package/src/debug-server.d.ts +2 -0
  144. package/src/debug-server.ts +51 -69
  145. package/src/hotkeys-markdown.ts +2 -0
  146. package/src/keybinding-matchers.d.ts +6 -0
  147. package/src/keybinding-matchers.ts +19 -0
  148. package/src/multiplexers/tmux.d.ts +1 -0
  149. package/src/multiplexers/tmux.ts +3 -0
  150. package/src/multiplexers/types.d.ts +6 -0
  151. package/src/multiplexers/types.ts +6 -0
  152. package/src/native/backend.d.ts +5 -4
  153. package/src/native/backend.ts +13 -5
  154. package/src/native/blobs.d.ts +24 -12
  155. package/src/native/blobs.ts +56 -7
  156. package/src/native/reconcile.ts +43 -21
  157. package/src/overlays/advisor-config.ts +259 -8
  158. package/src/overlays/agent-hub-projection.d.ts +10 -5
  159. package/src/overlays/agent-hub-projection.ts +63 -12
  160. package/src/overlays/annotation-overlay.ts +422 -168
  161. package/src/overlays/approval-dialog.ts +389 -0
  162. package/src/overlays/ask-dialog.ts +244 -105
  163. package/src/overlays/btw-history-panel.ts +208 -147
  164. package/src/overlays/extensions/extension-list.ts +4 -6
  165. package/src/overlays/hook-editor.ts +90 -32
  166. package/src/overlays/hook-input.ts +105 -19
  167. package/src/overlays/job-tree.ts +177 -0
  168. package/src/overlays/jobs-panel.ts +222 -142
  169. package/src/overlays/login-dialog.ts +201 -91
  170. package/src/overlays/mcp-add-wizard.ts +562 -279
  171. package/src/overlays/model-browser.ts +32 -7
  172. package/src/overlays/model-hub.ts +4 -10
  173. package/src/overlays/model-picker.ts +58 -21
  174. package/src/overlays/pause-screen.ts +81 -23
  175. package/src/overlays/plan-review-overlay.ts +16 -0
  176. package/src/overlays/plugin-selector.ts +17 -1
  177. package/src/overlays/queue-mode-selector.ts +10 -0
  178. package/src/overlays/report-panel.ts +6 -2
  179. package/src/overlays/reset-usage-selector.ts +130 -21
  180. package/src/overlays/session-account-selector.ts +36 -0
  181. package/src/overlays/session-info-overlay.ts +199 -76
  182. package/src/overlays/session-selector.ts +15 -10
  183. package/src/overlays/settings-selector.ts +3 -0
  184. package/src/overlays/show-images-selector.ts +1 -0
  185. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  186. package/src/overlays/theme-selector.ts +6 -1
  187. package/src/overlays/thinking-selector.ts +8 -4
  188. package/src/overlays/tree-selector.ts +117 -2
  189. package/src/overlays/usage-dashboard.ts +498 -144
  190. package/src/prompt/composer-cache.ts +4 -1
  191. package/src/prompt/composer.ts +200 -74
  192. package/src/prompt/custom-editor.ts +168 -65
  193. package/src/prompt/image-references.d.ts +22 -0
  194. package/src/prompt/image-references.ts +79 -2
  195. package/src/prompt/internal-url-autocomplete.ts +1 -0
  196. package/src/prompt/macos-spelling.ts +28 -5
  197. package/src/prompt/prose-gate.ts +3 -0
  198. package/src/prompt/queued-messages.ts +9 -2
  199. package/src/prompt/tips.txt +1 -2
  200. package/src/prompt/typing-word.ts +59 -0
  201. package/src/prompt/welcome.ts +97 -45
  202. package/src/render/code-cell.ts +18 -5
  203. package/src/render/hanging-wrap.d.ts +7 -0
  204. package/src/render/hanging-wrap.ts +58 -0
  205. package/src/render/hyperlink.d.ts +2 -0
  206. package/src/render/hyperlink.ts +5 -0
  207. package/src/render/render-utils.d.ts +9 -1
  208. package/src/render/render-utils.ts +16 -2
  209. package/src/render/utils.d.ts +1 -1
  210. package/src/render/utils.ts +1 -1
  211. package/src/render/width-aware-text.ts +6 -5
  212. package/src/setup/scenes/splash.ts +22 -8
  213. package/src/status-line/component.ts +79 -11
  214. package/src/status-line/context-usage.ts +176 -73
  215. package/src/status-line/footer.ts +18 -35
  216. package/src/status-line/host.ts +6 -1
  217. package/src/status-line/segments.ts +16 -5
  218. package/src/status-line/startup.ts +6 -6
  219. package/src/status-line/types.ts +1 -0
  220. package/src/stdin-buffer.d.ts +7 -0
  221. package/src/stdin-buffer.ts +300 -62
  222. package/src/terminal-capabilities.d.ts +16 -1
  223. package/src/terminal-capabilities.ts +66 -28
  224. package/src/terminal-multiplexer.d.ts +7 -0
  225. package/src/terminal-multiplexer.ts +14 -0
  226. package/src/terminal.d.ts +8 -4
  227. package/src/terminal.ts +190 -63
  228. package/src/theme/symbols.d.ts +1 -1
  229. package/src/theme/symbols.ts +1 -0
  230. package/src/theme/theme-class.ts +1 -0
  231. package/src/theme/theme.d.ts +11 -0
  232. package/src/theme/theme.ts +46 -14
  233. package/src/thinking.d.ts +5 -0
  234. package/src/thinking.ts +9 -1
  235. package/src/tmux.d.ts +39 -4
  236. package/src/tmux.ts +110 -16
  237. package/src/tools/ask.ts +20 -9
  238. package/src/tools/ast-edit.d.ts +2 -2
  239. package/src/tools/ast-edit.ts +30 -7
  240. package/src/tools/ast-grep.d.ts +2 -2
  241. package/src/tools/ast-grep.ts +39 -35
  242. package/src/tools/bash.d.ts +6 -1
  243. package/src/tools/bash.ts +15 -9
  244. package/src/tools/debug.ts +2 -1
  245. package/src/tools/default-renderer.ts +3 -2
  246. package/src/tools/edit.d.ts +2 -0
  247. package/src/tools/edit.ts +100 -21
  248. package/src/tools/eval.d.ts +53 -4
  249. package/src/tools/eval.ts +173 -137
  250. package/src/tools/find.d.ts +14 -4
  251. package/src/tools/find.ts +71 -18
  252. package/src/tools/glob.d.ts +3 -3
  253. package/src/tools/glob.ts +51 -17
  254. package/src/tools/grep.d.ts +2 -2
  255. package/src/tools/grep.ts +99 -22
  256. package/src/tools/grouped-file-output.d.ts +9 -4
  257. package/src/tools/grouped-file-output.ts +21 -6
  258. package/src/tools/lsp.ts +30 -18
  259. package/src/tools/mcp.ts +2 -2
  260. package/src/tools/native-view.d.ts +1 -1
  261. package/src/tools/native-view.ts +2 -2
  262. package/src/tools/read.d.ts +32 -10
  263. package/src/tools/read.ts +89 -43
  264. package/src/tools/renderer.d.ts +2 -0
  265. package/src/tools/renderer.ts +2 -0
  266. package/src/tools/task.d.ts +31 -0
  267. package/src/tools/task.ts +40 -7
  268. package/src/tools/todo.ts +3 -2
  269. package/src/tools/wait.ts +16 -30
  270. package/src/tools/web-search-types.d.ts +1 -0
  271. package/src/tools/web-search-types.ts +1 -0
  272. package/src/tools/web-search.ts +2 -1
  273. package/src/tools/write.d.ts +2 -0
  274. package/src/tools/write.ts +36 -12
  275. package/src/tools/yield.ts +2 -2
  276. package/src/tui.d.ts +20 -3
  277. package/src/tui.ts +160 -43
  278. package/src/utils.ts +5 -3
  279. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  280. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
package/src/tools/find.ts CHANGED
@@ -27,11 +27,16 @@ import {
27
27
  import type { Theme, ThemeColor } from "../theme/theme";
28
28
  import type { Component } from "../tui";
29
29
  import type { OutputMeta } from "./output-meta";
30
- import type { NativeToolHead, NativeToolView, RenderResultOptions, ToolRenderer } from "./renderer";
30
+ import type {
31
+ NativeToolHead,
32
+ NativeToolView,
33
+ RenderResultContextOptions,
34
+ RenderResultOptions,
35
+ ToolRenderer,
36
+ } from "./renderer";
31
37
  import type { TspTone } from "@linxiraos/pi-wire";
32
38
  import { code, col, compact, keyed, node, span, text } from "../native/describe";
33
39
  import type { NativeChild, NativeNode } from "../native/node";
34
- import { getLanguageFromPath } from "../lang-from-path";
35
40
  import { fileHref, fileRow, inlineErrorView } from "./native-view";
36
41
  import { splitUrlScheme } from "./url-scheme-host";
37
42
 
@@ -57,12 +62,22 @@ export interface FindHit {
57
62
  truncated: boolean;
58
63
  }
59
64
 
65
+ /**
66
+ * How much of a search the judge actually covered. `unavailable`: every judge
67
+ * request failed, so an empty result is not evidence of absence. `partial`:
68
+ * some requests failed. `complete`: none failed (or none were needed).
69
+ */
70
+ export type FindCoverage = "complete" | "partial" | "unavailable";
71
+
60
72
  /** Search accounting reported alongside the hits. */
61
73
  export interface FindStats {
62
74
  /** Eligible files under the root. */
63
75
  listed: number;
64
76
  requests: number;
65
- errors: number;
77
+ /** Judge requests that failed outright; decides {@link FindCoverage}. */
78
+ failed: number;
79
+ /** Questions a successful response left without a usable answer. */
80
+ unanswered: number;
66
81
  /** Entries judged by name. */
67
82
  judged: number;
68
83
  /** Files whose content was read and sent. */
@@ -87,6 +102,7 @@ export interface FindToolDetails {
87
102
  /** Verified-passage probability at or above which a file is a hit. */
88
103
  threshold: number;
89
104
  hits: FindHit[];
105
+ coverage: FindCoverage;
90
106
  stats: FindStats;
91
107
  elapsedMs: number;
92
108
  /** Session cwd; hit paths are relative to it. */
@@ -129,13 +145,32 @@ function gauge(p: number, theme: Theme): string {
129
145
  });
130
146
  }
131
147
 
132
- function renderHit(hit: FindHit, rangeLimit: number, cwd: string | undefined, theme: Theme): string[] {
148
+ /** Raw metadata is used only for file link targets, never for displayed text. */
149
+ function findLinkTargets(details: FindToolDetails, rawDetails: unknown) {
150
+ const raw = rawDetails !== null && typeof rawDetails === "object" ? rawDetails : undefined;
151
+ const cwd = raw && "cwd" in raw && typeof raw.cwd === "string" ? raw.cwd : details.cwd;
152
+ const rawHits: unknown[] = raw && "hits" in raw && Array.isArray(raw.hits) ? raw.hits : [];
153
+ return {
154
+ cwd,
155
+ rel(hit: FindHit, index: number): string {
156
+ const candidate = rawHits[index];
157
+ return candidate !== null &&
158
+ typeof candidate === "object" &&
159
+ "rel" in candidate &&
160
+ typeof candidate.rel === "string"
161
+ ? candidate.rel
162
+ : hit.rel;
163
+ },
164
+ };
165
+ }
166
+
167
+ function renderHit(hit: FindHit, rangeLimit: number, cwd: string | undefined, rawRel: string, theme: Theme): string[] {
133
168
  // `scheme://` hits (e.g. virtual docs) are not files under `cwd`: link the
134
169
  // URL itself instead of joining it onto a filesystem base.
135
170
  const isUrlHit = splitUrlScheme(hit.rel) !== undefined;
136
171
  const link = (text: string, line?: number): string => {
137
172
  if (isUrlHit) return uriHyperlink(line === undefined ? hit.rel : `${hit.rel}:${line}`, text);
138
- const absPath = cwd === undefined ? undefined : path.join(cwd, hit.rel);
173
+ const absPath = cwd === undefined ? undefined : path.join(cwd, rawRel);
139
174
  return absPath === undefined ? text : fileHyperlink(absPath, text, { line });
140
175
  };
141
176
  const coverage = hit.truncated ? `${hit.linesSeen} lines judged, partial` : `${hit.linesSeen} lines judged`;
@@ -161,7 +196,7 @@ function scoreTone(p: number): TspTone {
161
196
  * One hit (§7.3 find): a 22px row of the score bar, the path and `12 lines
162
197
  * judged`, then its strongest ranges as numbered `code` (no path header).
163
198
  */
164
- function describeHit(hit: FindHit, rangeLimit: number, cwd: string | undefined): NativeNode {
199
+ function describeHit(hit: FindHit, rangeLimit: number, cwd: string | undefined, rawRel: string): NativeNode {
165
200
  const ranges = [...hit.ranges].sort((a, b) => b.p - a.p || a.start - b.start).slice(0, rangeLimit);
166
201
  const tone = scoreTone(hit.contentScore);
167
202
  const score = hit.contentScore.toFixed(2);
@@ -175,13 +210,17 @@ function describeHit(hit: FindHit, rangeLimit: number, cwd: string | undefined):
175
210
  "muted",
176
211
  ),
177
212
  ],
178
- href: isUrlHit || cwd === undefined ? undefined : fileHref(path.join(cwd, hit.rel)),
213
+ href: isUrlHit || cwd === undefined ? undefined : fileHref(path.join(cwd, rawRel)),
179
214
  key: "hit",
180
215
  });
181
- const lang = getLanguageFromPath(hit.rel);
182
216
  const snippets = ranges.map(range =>
183
217
  keyed(
184
- code(range.snippet.trimEnd(), { lang, start: range.start, numbers: true, title: range.p.toFixed(2) }),
218
+ code(range.snippet.trimEnd(), {
219
+ path: hit.rel,
220
+ start: range.start,
221
+ numbers: true,
222
+ title: range.p.toFixed(2),
223
+ }),
185
224
  `${range.start}-${range.end}`,
186
225
  ),
187
226
  );
@@ -222,7 +261,7 @@ export const findToolRenderer = {
222
261
 
223
262
  renderResult(
224
263
  result: { content: Array<{ type: string; text?: string }>; details?: FindToolDetails; isError?: boolean },
225
- options: RenderResultOptions,
264
+ options: RenderResultContextOptions,
226
265
  uiTheme: Theme,
227
266
  args?: FindRenderArgs,
228
267
  ): Component {
@@ -250,6 +289,7 @@ export const findToolRenderer = {
250
289
  }
251
290
 
252
291
  const { hits, stats, threshold } = details;
292
+ const linkTargets = findLinkTargets(details, options.renderContext?.rawDetails);
253
293
  const description = quoteQuery(details.query);
254
294
  const scope = details.scopePath === undefined ? [] : [`in ${details.scopePath}`];
255
295
  const meta = [
@@ -261,16 +301,21 @@ export const findToolRenderer = {
261
301
  `$${stats.cost.toFixed(4)}`,
262
302
  formatDuration(details.elapsedMs),
263
303
  ];
264
- if (stats.errors > 0) meta.push(uiTheme.fg("warning", `${stats.errors} failed`));
304
+ const failed = stats.failed > 0 ? `${stats.failed}/${stats.requests} requests failed` : undefined;
305
+ if (failed) meta.push(uiTheme.fg("warning", failed));
265
306
 
266
307
  if (hits.length === 0) {
267
308
  const emptyMeta = ["0 hits", ...scope, `$${stats.cost.toFixed(4)}`, formatDuration(details.elapsedMs)];
268
- if (stats.errors > 0) emptyMeta.push(uiTheme.fg("warning", `${stats.errors} failed`));
309
+ if (failed) emptyMeta.push(uiTheme.fg("warning", failed));
269
310
  const header = renderStatusLine(
270
311
  { icon: "warning", title: "Find", titleColor: "toolTitle", description, meta: emptyMeta },
271
312
  uiTheme,
272
313
  );
273
- const lines = [header, formatEmptyMessage("No relevant passages found", uiTheme)];
314
+ const empty =
315
+ details.coverage === "partial"
316
+ ? "No relevant passages found in the judged files"
317
+ : "No relevant passages found";
318
+ const lines = [header, formatEmptyMessage(empty, uiTheme)];
274
319
  for (const failure of stats.failures) lines.push(uiTheme.fg("warning", failure));
275
320
  return new Text(lines.join("\n"), 1, 0);
276
321
  }
@@ -296,8 +341,14 @@ export const findToolRenderer = {
296
341
  maxCollapsed: COLLAPSED_HITS,
297
342
  maxCollapsedLines: PREVIEW_LIMITS.EXPANDED_LINES,
298
343
  itemType: "hit",
299
- renderItem: hit =>
300
- renderHit(hit, expanded ? RANGES_EXPANDED : RANGES_COLLAPSED, details.cwd, uiTheme),
344
+ renderItem: (hit, context) =>
345
+ renderHit(
346
+ hit,
347
+ expanded ? RANGES_EXPANDED : RANGES_COLLAPSED,
348
+ linkTargets.cwd,
349
+ linkTargets.rel(hit, context.index),
350
+ uiTheme,
351
+ ),
301
352
  },
302
353
  uiTheme,
303
354
  );
@@ -329,7 +380,7 @@ export const findToolRenderer = {
329
380
  */
330
381
  describeResult(
331
382
  result: { content: Array<{ type: string; text?: string }>; details?: FindToolDetails; isError?: boolean },
332
- options: RenderResultOptions,
383
+ options: RenderResultContextOptions,
333
384
  args?: FindRenderArgs,
334
385
  ): NativeToolView {
335
386
  const details = result.details;
@@ -343,8 +394,10 @@ export const findToolRenderer = {
343
394
  };
344
395
  }
345
396
  const { hits, stats, threshold } = details;
397
+ const linkTargets = findLinkTargets(details, options.renderContext?.rawDetails);
346
398
  const scope = details.scopePath === undefined ? undefined : `in ${details.scopePath}`;
347
- const failed = stats.errors > 0 ? [span(`${stats.errors} failed`, "warning")] : undefined;
399
+ const failed =
400
+ stats.failed > 0 ? [span(`${stats.failed}/${stats.requests} requests failed`, "warning")] : undefined;
348
401
  const accounting = [
349
402
  `${stats.filesRead} files read`,
350
403
  `τ ${threshold.toFixed(2)}`,
@@ -382,7 +435,7 @@ export const findToolRenderer = {
382
435
  ),
383
436
  inline: true,
384
437
  body: compact<NativeChild>([
385
- ...shown.map(hit => describeHit(hit, rangeLimit, details.cwd)),
438
+ ...shown.map((hit, index) => describeHit(hit, rangeLimit, linkTargets.cwd, linkTargets.rel(hit, index))),
386
439
  ...failures,
387
440
  foot,
388
441
  ]),
@@ -1,5 +1,5 @@
1
1
  import type { Component } from "../tui";
2
- import type { NativeToolView, RenderResultOptions } from "./renderer";
2
+ import type { NativeToolView, RenderResultContextOptions, RenderResultOptions } from "./renderer";
3
3
  import { type Theme } from "../theme/theme";
4
4
  import type { OutputMeta } from "./output-meta";
5
5
  import type { TruncationResult } from "./streaming-output";
@@ -42,7 +42,7 @@ export declare const globToolRenderer: {
42
42
  }>;
43
43
  details?: GlobToolDetails;
44
44
  isError?: boolean;
45
- }, options: RenderResultOptions, uiTheme: Theme, args?: GlobRenderArgs): Component;
45
+ }, options: RenderResultContextOptions, uiTheme: Theme, args?: GlobRenderArgs): Component;
46
46
  describeCall(args: GlobRenderArgs): NativeToolView;
47
47
  /**
48
48
  * Inline (§7.3 glob): `Glob “pattern” 5 files`, then the files as rows
@@ -55,7 +55,7 @@ export declare const globToolRenderer: {
55
55
  }>;
56
56
  details?: GlobToolDetails;
57
57
  isError?: boolean;
58
- }, _options: RenderResultOptions, args?: GlobRenderArgs): NativeToolView;
58
+ }, options: RenderResultContextOptions, args?: GlobRenderArgs): NativeToolView;
59
59
  mergeCallAndResult: true;
60
60
  };
61
61
  export {};
package/src/tools/glob.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { Component } from "../tui";
2
2
  import { Text } from "../components/text";
3
- import type { NativeToolHead, NativeToolView, RenderResultOptions, ToolRenderer } from "./renderer";
3
+ import type {
4
+ NativeToolHead,
5
+ NativeToolView,
6
+ RenderResultContextOptions,
7
+ RenderResultOptions,
8
+ ToolRenderer,
9
+ } from "./renderer";
4
10
  import { col, compact, keyed, node, row, span, text } from "../native/describe";
5
11
  import type { NativeChild, NativeNode } from "../native/node";
6
12
  import { fileHref, fileRow, footnoteText, inlineErrorView, resultText } from "./native-view";
@@ -68,13 +74,33 @@ const NATIVE_LIST_MAX = 6;
68
74
  /** Collapsed clamp: the whole ≤ 6-row list, or about six rows of chips. */
69
75
  const NATIVE_PREVIEW_LINES = 8;
70
76
 
71
- /**
72
- * Native glob files: 22px path rows when there are few, else a wrap of
73
- * chips (glyph and name, the full path in the tooltip). Paths link when the
74
- * search `cwd` is known.
75
- */
76
- function describeGlobFiles(files: readonly string[], cwd: string | undefined): NativeNode {
77
- const links = files.map(file => (cwd && !file.endsWith("/") ? fileHref(path.resolve(cwd, file)) : undefined));
77
+ /** Use original filesystem paths for existing links, never for displayed labels or extra links. */
78
+ function globLinkPaths(
79
+ details: GlobToolDetails | undefined,
80
+ rawDetails: unknown,
81
+ ): { cwd: string | undefined; files: readonly unknown[] } {
82
+ const raw = rawDetails && typeof rawDetails === "object" ? rawDetails : undefined;
83
+ const rawCwd = raw && "cwd" in raw && typeof raw.cwd === "string" ? raw.cwd : undefined;
84
+ const rawFiles = raw && "files" in raw && Array.isArray(raw.files) ? raw.files : undefined;
85
+ const files = details?.files ?? [];
86
+ return {
87
+ cwd: details?.cwd ? (rawCwd ?? details.cwd) : undefined,
88
+ files: rawFiles?.length === files.length ? rawFiles : files,
89
+ };
90
+ }
91
+
92
+ /** Native glob files: path rows when few, otherwise wrapped chips; links require a known cwd. */
93
+ function describeGlobFiles(
94
+ files: readonly string[],
95
+ cwd: string | undefined,
96
+ rawFiles: readonly unknown[] = files,
97
+ ): NativeNode {
98
+ const links = files.map((file, i) => {
99
+ const rawFile = rawFiles[i];
100
+ return cwd && !file.endsWith("/")
101
+ ? fileHref(path.resolve(cwd, typeof rawFile === "string" ? rawFile : file))
102
+ : undefined;
103
+ });
78
104
  if (files.length <= NATIVE_LIST_MAX) {
79
105
  return col(
80
106
  files.map((file, i) => fileRow(file, { href: links[i] })),
@@ -130,11 +156,12 @@ export const globToolRenderer = {
130
156
 
131
157
  renderResult(
132
158
  result: { content: Array<{ type: string; text?: string }>; details?: GlobToolDetails; isError?: boolean },
133
- options: RenderResultOptions,
159
+ options: RenderResultContextOptions,
134
160
  uiTheme: Theme,
135
161
  args?: GlobRenderArgs,
136
162
  ): Component {
137
163
  const details = result.details;
164
+ const linkPaths = globLinkPaths(details, options.renderContext?.rawDetails);
138
165
 
139
166
  if (result.isError || details?.error) {
140
167
  const errorText = details?.error || result.content?.find(c => c.type === "text")?.text || "Unknown error";
@@ -249,14 +276,20 @@ export const globToolRenderer = {
249
276
  return createCachedComponent(
250
277
  () => options.expanded,
251
278
  width => {
252
- const cwd = details?.cwd;
279
+ const cwd = linkPaths.cwd;
253
280
  const fileLines = renderFileList(
254
281
  {
255
- files: files.map(entry => ({
256
- path: entry,
257
- isDirectory: entry.endsWith("/"),
258
- absPath: cwd && !entry.endsWith("/") ? path.resolve(cwd, entry) : undefined,
259
- })),
282
+ files: files.map((entry, index) => {
283
+ const rawFile = linkPaths.files[index];
284
+ return {
285
+ path: entry,
286
+ isDirectory: entry.endsWith("/"),
287
+ absPath:
288
+ cwd && !entry.endsWith("/")
289
+ ? path.resolve(cwd, typeof rawFile === "string" ? rawFile : entry)
290
+ : undefined,
291
+ };
292
+ }),
260
293
  expanded: options.expanded,
261
294
  maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
262
295
  hyperlinkFn: fileHyperlink,
@@ -278,10 +311,11 @@ export const globToolRenderer = {
278
311
  */
279
312
  describeResult(
280
313
  result: { content: Array<{ type: string; text?: string }>; details?: GlobToolDetails; isError?: boolean },
281
- _options: RenderResultOptions,
314
+ options: RenderResultContextOptions,
282
315
  args?: GlobRenderArgs,
283
316
  ): NativeToolView {
284
317
  const details = result.details;
318
+ const linkPaths = globLinkPaths(details, options.renderContext?.rawDetails);
285
319
  if (result.isError || details?.error) {
286
320
  return inlineErrorView(globNativeHead(args), details?.error || resultText(result) || "Unknown error");
287
321
  }
@@ -335,7 +369,7 @@ export const globToolRenderer = {
335
369
  : head,
336
370
  inline: true,
337
371
  body: compact<NativeChild>([
338
- describeGlobFiles(details.files ?? [], details.cwd),
372
+ describeGlobFiles(details.files ?? [], linkPaths.cwd, linkPaths.files),
339
373
  footnoteText(compact([reasons.length > 0 && `truncated: ${reasons.join(", ")}`, missingNote])),
340
374
  ]),
341
375
  preview: { lines: NATIVE_PREVIEW_LINES },
@@ -1,5 +1,5 @@
1
1
  import type { Component } from "../tui";
2
- import type { NativeToolView, RenderResultOptions } from "./renderer";
2
+ import type { NativeToolView, RenderResultContextOptions, RenderResultOptions } from "./renderer";
3
3
  import { type Theme } from "../theme/theme";
4
4
  import type { OutputMeta } from "./output-meta";
5
5
  import type { TruncationResult } from "./streaming-output";
@@ -58,7 +58,7 @@ export declare const grepToolRenderer: {
58
58
  }>;
59
59
  details?: GrepToolDetails;
60
60
  isError?: boolean;
61
- }, options: RenderResultOptions, uiTheme: Theme, args?: GrepRenderArgs): Component;
61
+ }, options: RenderResultContextOptions, uiTheme: Theme, args?: GrepRenderArgs): Component;
62
62
  describeCall(args: GrepRenderArgs): NativeToolView;
63
63
  /**
64
64
  * Inline (§7.3 grep): `Grep “pattern” 5 matches · 2 files in src`, then
package/src/tools/grep.ts CHANGED
@@ -1,7 +1,14 @@
1
+ import * as path from "node:path";
2
+ import { isRecord, sanitizeText } from "@linxiraos/pi-utils";
1
3
  import type { Component } from "../tui";
2
4
  import { Text } from "../components/text";
3
- import type { NativeToolHead, NativeToolView, RenderResultOptions, ToolRenderer } from "./renderer";
4
- import { getLanguageFromPath } from "../lang-from-path";
5
+ import type {
6
+ NativeToolHead,
7
+ NativeToolView,
8
+ RenderResultContextOptions,
9
+ RenderResultOptions,
10
+ ToolRenderer,
11
+ } from "./renderer";
5
12
  import { compact } from "../native/describe";
6
13
  import type { NativeChild } from "../native/node";
7
14
  import { footnoteText, inlineErrorView, resultText } from "./native-view";
@@ -89,10 +96,82 @@ const NATIVE_COLLAPSED_FILES = 2;
89
96
 
90
97
  const SEARCH_CODE_FRAME_LINE_RE = /^\s*\*?(\d+)│/;
91
98
 
92
- function searchScopeMeta(details: GrepToolDetails | undefined): string | undefined {
99
+ function searchScopeMeta(details: GrepToolDetails | undefined, searchPath?: string): string | undefined {
93
100
  if (!details?.scopePath) return undefined;
94
- const label = details.searchPath ? fileHyperlink(details.searchPath, details.scopePath) : details.scopePath;
95
- return tuiTextFmt("gpInLabelFmt", `in ${label}`, label);
101
+ const label = searchPath ? fileHyperlink(searchPath, details.scopePath) : details.scopePath;
102
+ return `in ${label}`;
103
+ }
104
+
105
+ function isStringArray(value: unknown): value is string[] {
106
+ return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
107
+ }
108
+
109
+ function isStringRecord(value: unknown): value is Record<string, string> {
110
+ if (!isRecord(value)) return false;
111
+ for (const key in value) {
112
+ if (typeof value[key] !== "string") return false;
113
+ }
114
+ return true;
115
+ }
116
+
117
+ function samePath(filePath: string): string {
118
+ return filePath;
119
+ }
120
+
121
+ /** Keep only path metadata from the original details; display text always comes from sanitized details. */
122
+ function grepLinkTargets(details: GrepToolDetails | undefined, rawDetails: unknown) {
123
+ if (details === rawDetails) {
124
+ return {
125
+ target: samePath,
126
+ searchPath: details?.searchPath,
127
+ fileScope: details?.searchPath,
128
+ displayTargets: details?.displayTargets,
129
+ };
130
+ }
131
+ const raw = typeof rawDetails === "object" && rawDetails !== null ? rawDetails : {};
132
+ const rawCwd = "cwd" in raw && typeof raw.cwd === "string" ? raw.cwd : undefined;
133
+ const rawSearchPath = "searchPath" in raw && typeof raw.searchPath === "string" ? raw.searchPath : undefined;
134
+ const rawFiles = "files" in raw && isStringArray(raw.files) ? raw.files : undefined;
135
+ const rawDisplayTargets =
136
+ "displayTargets" in raw && isStringRecord(raw.displayTargets) ? raw.displayTargets : undefined;
137
+ let displayTargets = details?.displayTargets;
138
+ if (rawDisplayTargets) {
139
+ displayTargets = {};
140
+ for (const label in rawDisplayTargets) {
141
+ displayTargets[sanitizeText(label)] = rawDisplayTargets[label]!;
142
+ }
143
+ }
144
+
145
+ const pathTargets = new Map<string, string>();
146
+ const displayBase = details?.cwd ?? details?.searchPath;
147
+ const rawBase = rawCwd ?? rawSearchPath ?? displayBase;
148
+ if (displayBase && rawBase && rawFiles && details?.files) {
149
+ for (let index = 0; index < Math.min(details.files.length, rawFiles.length); index++) {
150
+ const displayFile = path.resolve(displayBase, details.files[index]!);
151
+ const rawFile = path.resolve(rawBase, rawFiles[index]!);
152
+ pathTargets.set(displayFile, rawFile);
153
+ // Folded directory headers must retain the original path as well.
154
+ let displayDir = path.dirname(displayFile);
155
+ let rawDir = path.dirname(rawFile);
156
+ while (displayDir !== path.dirname(displayDir) && rawDir !== path.dirname(rawDir)) {
157
+ pathTargets.set(displayDir, rawDir);
158
+ displayDir = path.dirname(displayDir);
159
+ rawDir = path.dirname(rawDir);
160
+ }
161
+ }
162
+ }
163
+ const target = (displayPath: string): string => {
164
+ const known = pathTargets.get(displayPath);
165
+ if (known) return known;
166
+ if (rawCwd && details?.cwd) return path.resolve(rawCwd, path.relative(details.cwd, displayPath));
167
+ return displayPath;
168
+ };
169
+ return {
170
+ target,
171
+ searchPath: rawSearchPath ?? details?.searchPath,
172
+ fileScope: rawSearchPath ?? details?.searchPath,
173
+ displayTargets,
174
+ };
96
175
  }
97
176
 
98
177
  function linkUrlLikeSearchHeader(
@@ -134,6 +213,7 @@ function createSearchLineStyler(
134
213
  fileScope: string | undefined,
135
214
  uiTheme: Theme,
136
215
  displayTargets: Record<string, string> | undefined,
216
+ target: (displayPath: string) => string,
137
217
  ): (index: number) => string {
138
218
  const contexts = classifyGroupedLines(lines, headerBase, fileScope);
139
219
  // `classifyGroupedLines` can't resolve internal URLs (TUI-only), so track the
@@ -168,7 +248,7 @@ function createSearchLineStyler(
168
248
  let styled: string;
169
249
  if (ctx.kind === "dir") {
170
250
  const accent = uiTheme.fg("accent", line);
171
- styled = ctx.headerPath ? fileHyperlink(ctx.headerPath, accent) : accent;
251
+ styled = ctx.headerPath ? fileHyperlink(target(ctx.headerPath), accent) : accent;
172
252
  } else if (ctx.kind === "file") {
173
253
  const raw = urlRaw[index];
174
254
  if (raw !== undefined) {
@@ -176,12 +256,12 @@ function createSearchLineStyler(
176
256
  } else {
177
257
  // Root-level files keep the bright accent; nested file headers are dimmed.
178
258
  const tinted = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", line);
179
- styled = ctx.headerPath ? fileHyperlink(ctx.headerPath, tinted) : tinted;
259
+ styled = ctx.headerPath ? fileHyperlink(target(ctx.headerPath), tinted) : tinted;
180
260
  }
181
261
  } else {
182
262
  const tinted = uiTheme.fg("toolOutput", line);
183
263
  const lineNumber = parseSearchDisplayLineNumber(line);
184
- const filePath = ctx.filePath ?? urlFiles[index];
264
+ const filePath = ctx.filePath ? target(ctx.filePath) : urlFiles[index];
185
265
  styled = filePath && lineNumber !== undefined ? fileHyperlink(filePath, tinted, { line: lineNumber }) : tinted;
186
266
  }
187
267
  styledLines[index] = styled;
@@ -290,7 +370,7 @@ export const grepToolRenderer = {
290
370
 
291
371
  renderResult(
292
372
  result: { content: Array<{ type: string; text?: string }>; details?: GrepToolDetails; isError?: boolean },
293
- options: RenderResultOptions,
373
+ options: RenderResultContextOptions,
294
374
  uiTheme: Theme,
295
375
  args?: GrepRenderArgs,
296
376
  ): Component {
@@ -345,6 +425,7 @@ export const grepToolRenderer = {
345
425
  { paddingX: 1 },
346
426
  );
347
427
  }
428
+ const linkTargets = grepLinkTargets(details, options.renderContext?.rawDetails);
348
429
 
349
430
  const matchCount = details?.matchCount ?? 0;
350
431
  const fileCount = details?.fileCount ?? 0;
@@ -366,8 +447,8 @@ export const grepToolRenderer = {
366
447
  : undefined;
367
448
 
368
449
  if (matchCount === 0) {
369
- const meta = [tuiText("agrpZeroMatches", "0 matches")];
370
- const scopeMeta = searchScopeMeta(details);
450
+ const meta = ["0 matches"];
451
+ const scopeMeta = searchScopeMeta(details, linkTargets.searchPath);
371
452
  if (scopeMeta) meta.push(scopeMeta);
372
453
  const header = renderStatusLine(
373
454
  { icon: "warning", title: "Grep", titleColor: "toolTitle", description: args?.pattern, meta },
@@ -378,14 +459,9 @@ export const grepToolRenderer = {
378
459
  return new Text(lines.join("\n"), 1, 0);
379
460
  }
380
461
 
381
- const matchMeta =
382
- matchCount === 1
383
- ? tuiText("grepMatchOne", "1 match")
384
- : tuiTextFmt("grepMatchManyFmt", "%d matches", matchCount);
385
- const fileMeta =
386
- fileCount === 1 ? tuiText("grepFileOne", "1 file") : tuiTextFmt("grepFileManyFmt", "%d files", fileCount);
387
- const meta = [matchMeta, fileMeta];
388
- const scopeMeta = searchScopeMeta(details);
462
+ const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
463
+ const meta = [...summaryParts];
464
+ const scopeMeta = searchScopeMeta(details, linkTargets.searchPath);
389
465
  if (scopeMeta) meta.push(scopeMeta);
390
466
  if (truncated) meta.push(uiTheme.fg("warning", tuiText("grepTruncated", "truncated")));
391
467
  const description = args?.pattern ?? undefined;
@@ -410,9 +486,10 @@ export const grepToolRenderer = {
410
486
  const styleLine = createSearchLineStyler(
411
487
  allLines,
412
488
  details?.cwd ?? details?.searchPath,
413
- details?.searchPath,
489
+ linkTargets.fileScope,
414
490
  uiTheme,
415
- details?.displayTargets,
491
+ linkTargets.displayTargets,
492
+ linkTargets.target,
416
493
  );
417
494
  const matchGroups = groupLineIndicesByBlank(allLines);
418
495
 
@@ -493,8 +570,8 @@ export const grepToolRenderer = {
493
570
  inline: true,
494
571
  body: compact<NativeChild>([
495
572
  ...describeGroupedOutput(textContent.split("\n"), {
496
- lang: getLanguageFromPath,
497
573
  maxFiles: options.expanded ? undefined : NATIVE_COLLAPSED_FILES,
574
+ file: details?.files?.length === 1 ? details.files[0] : undefined,
498
575
  }),
499
576
  foot,
500
577
  ]),
@@ -110,10 +110,13 @@ export interface NumberedLine {
110
110
  * Numbered `code` for possibly non-contiguous source lines: one block per run
111
111
  * of consecutive line numbers (`start` = the run's first line, match lines
112
112
  * `mark`ed), with a `…` row wherever the numbers jump or an elision marker
113
- * (`n: null`) sits. No path header: the row or head above names the file.
113
+ * (`n: null`) sits. `path` is the file the lines come from: Tern picks their
114
+ * grammar from it and draws no header (the row or head above names the file).
115
+ * `wrap` soft-wraps long lines instead of scrolling the block sideways.
114
116
  */
115
117
  export declare function numberedCode(lines: readonly NumberedLine[], opts?: {
116
- lang?: string;
118
+ path?: string;
119
+ wrap?: boolean;
117
120
  }): NativeNode[];
118
121
  /**
119
122
  * Describe grouped search output (grep / ast-grep / ast-edit display content)
@@ -121,9 +124,11 @@ export declare function numberedCode(lines: readonly NumberedLine[], opts?: {
121
124
  * header's suffix or the match count as a chip), then its lines as
122
125
  * {@link numberedCode} with match lines marked and `…` between non-adjacent
123
126
  * runs. Unnumbered body lines stay plain `code`. `maxFiles` keeps only the
124
- * first files (the caller says how many were left out).
127
+ * first files (the caller says how many were left out). `file` owns the lines
128
+ * before any header: a single-file search prints none, yet its lines get the
129
+ * same file row as a directory search's.
125
130
  */
126
131
  export declare function describeGroupedOutput(lines: readonly string[], options?: {
127
- lang?: (path: string) => string | undefined;
128
132
  maxFiles?: number;
133
+ file?: string;
129
134
  }): NativeNode[];
@@ -314,9 +314,14 @@ export interface NumberedLine {
314
314
  * Numbered `code` for possibly non-contiguous source lines: one block per run
315
315
  * of consecutive line numbers (`start` = the run's first line, match lines
316
316
  * `mark`ed), with a `…` row wherever the numbers jump or an elision marker
317
- * (`n: null`) sits. No path header: the row or head above names the file.
317
+ * (`n: null`) sits. `path` is the file the lines come from: Tern picks their
318
+ * grammar from it and draws no header (the row or head above names the file).
319
+ * `wrap` soft-wraps long lines instead of scrolling the block sideways.
318
320
  */
319
- export function numberedCode(lines: readonly NumberedLine[], opts: { lang?: string } = {}): NativeNode[] {
321
+ export function numberedCode(
322
+ lines: readonly NumberedLine[],
323
+ opts: { path?: string; wrap?: boolean } = {},
324
+ ): NativeNode[] {
320
325
  const out: NativeNode[] = [];
321
326
  let run: { start: number; text: string[]; marks: { line: number; tone: TspTone }[] } | undefined;
322
327
  const flush = () => {
@@ -328,7 +333,8 @@ export function numberedCode(lines: readonly NumberedLine[], opts: { lang?: stri
328
333
  code(run.text.join("\n"), {
329
334
  start: run.start,
330
335
  numbers: true,
331
- lang: opts.lang,
336
+ path: opts.path,
337
+ wrap: opts.wrap,
332
338
  marks: run.marks.length > 0 ? run.marks : undefined,
333
339
  }),
334
340
  `c${run.start}`,
@@ -356,22 +362,26 @@ export function numberedCode(lines: readonly NumberedLine[], opts: { lang?: stri
356
362
  * header's suffix or the match count as a chip), then its lines as
357
363
  * {@link numberedCode} with match lines marked and `…` between non-adjacent
358
364
  * runs. Unnumbered body lines stay plain `code`. `maxFiles` keeps only the
359
- * first files (the caller says how many were left out).
365
+ * first files (the caller says how many were left out). `file` owns the lines
366
+ * before any header: a single-file search prints none, yet its lines get the
367
+ * same file row as a directory search's.
360
368
  */
361
369
  export function describeGroupedOutput(
362
370
  lines: readonly string[],
363
- options: { lang?: (path: string) => string | undefined; maxFiles?: number } = {},
371
+ options: { maxFiles?: number; file?: string } = {},
364
372
  ): NativeNode[] {
365
373
  const files: NativeNode[] = [];
366
374
  const loose: NativeNode[] = [];
367
375
  let current: { path: string; suffix?: string; children: NativeNode[]; marks: number } | undefined;
368
376
  let numbered: NumberedLine[] = [];
369
377
  let plain: string[] = [];
378
+ // `options.file` until the first line or header claims it.
379
+ let headless = options.file;
370
380
 
371
381
  const flushLines = () => {
372
382
  const target = current?.children ?? loose;
373
383
  if (numbered.length > 0) {
374
- target.push(...numberedCode(numbered, { lang: current ? options.lang?.(current.path) : undefined }));
384
+ target.push(...numberedCode(numbered, { path: current?.path }));
375
385
  numbered = [];
376
386
  }
377
387
  if (plain.length > 0) {
@@ -397,11 +407,16 @@ export function describeGroupedOutput(
397
407
 
398
408
  for (const event of walkGroupedOutput(lines)) {
399
409
  if (event.kind !== "line") {
410
+ headless = undefined;
400
411
  flushFile();
401
412
  if (options.maxFiles !== undefined && files.length >= options.maxFiles) break;
402
413
  if (event.kind === "file") current = { path: event.path, suffix: event.suffix, children: [], marks: 0 };
403
414
  continue;
404
415
  }
416
+ if (headless !== undefined) {
417
+ current = { path: headless, children: [], marks: 0 };
418
+ headless = undefined;
419
+ }
405
420
  if (GROUPED_GAP_LINE_RE.test(event.text)) {
406
421
  numbered.push({ n: null, text: "" });
407
422
  continue;