@linxiraos/pi-tui 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/CHANGELOG.md +188 -120
  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/transcript-container.d.ts +2 -0
  21. package/dist/types/components/image.d.ts +2 -0
  22. package/dist/types/components/markdown.d.ts +49 -3
  23. package/dist/types/components/text.d.ts +2 -0
  24. package/dist/types/debug-server.d.ts +2 -0
  25. package/dist/types/keybinding-matchers.d.ts +6 -0
  26. package/dist/types/multiplexers/tmux.d.ts +1 -0
  27. package/dist/types/multiplexers/types.d.ts +6 -0
  28. package/dist/types/native/backend.d.ts +5 -4
  29. package/dist/types/native/blobs.d.ts +24 -12
  30. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  31. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  32. package/dist/types/overlays/approval-dialog.d.ts +79 -0
  33. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  34. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  35. package/dist/types/overlays/hook-editor.d.ts +7 -2
  36. package/dist/types/overlays/hook-input.d.ts +12 -4
  37. package/dist/types/overlays/job-tree.d.ts +50 -0
  38. package/dist/types/overlays/jobs-panel.d.ts +25 -11
  39. package/dist/types/overlays/login-dialog.d.ts +8 -3
  40. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  41. package/dist/types/overlays/model-browser.d.ts +6 -1
  42. package/dist/types/overlays/model-picker.d.ts +2 -0
  43. package/dist/types/overlays/pause-screen.d.ts +5 -0
  44. package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
  45. package/dist/types/overlays/report-panel.d.ts +5 -1
  46. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  47. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  48. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  49. package/dist/types/overlays/session-selector.d.ts +3 -1
  50. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  51. package/dist/types/prompt/composer.d.ts +22 -26
  52. package/dist/types/prompt/custom-editor.d.ts +20 -10
  53. package/dist/types/prompt/image-references.d.ts +22 -0
  54. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  55. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  56. package/dist/types/prompt/prose-gate.d.ts +3 -0
  57. package/dist/types/prompt/queued-messages.d.ts +1 -1
  58. package/dist/types/prompt/typing-word.d.ts +25 -0
  59. package/dist/types/prompt/welcome.d.ts +35 -43
  60. package/dist/types/render/hanging-wrap.d.ts +7 -0
  61. package/dist/types/render/hyperlink.d.ts +2 -0
  62. package/dist/types/render/render-utils.d.ts +9 -1
  63. package/dist/types/render/utils.d.ts +1 -1
  64. package/dist/types/setup/scenes/splash.d.ts +6 -5
  65. package/dist/types/status-line/context-usage.d.ts +4 -1
  66. package/dist/types/status-line/host.d.ts +6 -4
  67. package/dist/types/status-line/startup.d.ts +6 -6
  68. package/dist/types/status-line/types.d.ts +3 -0
  69. package/dist/types/stdin-buffer.d.ts +7 -0
  70. package/dist/types/terminal-capabilities.d.ts +16 -1
  71. package/dist/types/terminal-multiplexer.d.ts +7 -0
  72. package/dist/types/terminal.d.ts +8 -4
  73. package/dist/types/theme/symbols.d.ts +1 -1
  74. package/dist/types/theme/theme.d.ts +11 -0
  75. package/dist/types/thinking.d.ts +5 -0
  76. package/dist/types/tmux.d.ts +39 -4
  77. package/dist/types/tools/ast-edit.d.ts +2 -2
  78. package/dist/types/tools/ast-grep.d.ts +2 -2
  79. package/dist/types/tools/bash.d.ts +6 -1
  80. package/dist/types/tools/edit.d.ts +2 -0
  81. package/dist/types/tools/eval.d.ts +53 -4
  82. package/dist/types/tools/find.d.ts +14 -4
  83. package/dist/types/tools/glob.d.ts +3 -3
  84. package/dist/types/tools/grep.d.ts +2 -2
  85. package/dist/types/tools/grouped-file-output.d.ts +9 -4
  86. package/dist/types/tools/native-view.d.ts +1 -1
  87. package/dist/types/tools/read.d.ts +32 -10
  88. package/dist/types/tools/renderer.d.ts +2 -0
  89. package/dist/types/tools/task.d.ts +31 -0
  90. package/dist/types/tools/write.d.ts +2 -0
  91. package/dist/types/tui.d.ts +20 -3
  92. package/package.json +10 -10
  93. package/src/app-keybindings.d.ts +18 -8
  94. package/src/app-keybindings.ts +20 -8
  95. package/src/apps/autoresearch-dashboard.ts +444 -27
  96. package/src/apps/debug/raw-sse.ts +2 -2
  97. package/src/apps/git/diff-pane.ts +39 -41
  98. package/src/apps/git/git-tui.ts +80 -14
  99. package/src/apps/git/sidebar.ts +10 -1
  100. package/src/apps/git/state.ts +12 -0
  101. package/src/autocomplete.d.ts +2 -0
  102. package/src/autocomplete.ts +21 -19
  103. package/src/bracketed-paste.ts +9 -2
  104. package/src/charts/chart-svg.d.ts +7 -13
  105. package/src/charts/chart-svg.ts +19 -22
  106. package/src/chat/advisor-message.d.ts +4 -1
  107. package/src/chat/advisor-message.ts +77 -24
  108. package/src/chat/assistant-message.d.ts +9 -4
  109. package/src/chat/assistant-message.ts +127 -31
  110. package/src/chat/bash-execution.ts +7 -2
  111. package/src/chat/figure-markdown.d.ts +7 -2
  112. package/src/chat/figure-markdown.ts +10 -3
  113. package/src/chat/image-loading.ts +9 -6
  114. package/src/chat/messages.d.ts +3 -1
  115. package/src/chat/messages.ts +4 -1
  116. package/src/chat/read-tool-group.d.ts +2 -2
  117. package/src/chat/read-tool-group.ts +97 -42
  118. package/src/chat/svg-figure.ts +3 -1
  119. package/src/chat/svg-source.ts +6 -3
  120. package/src/chat/table-chart.d.ts +11 -15
  121. package/src/chat/table-chart.ts +24 -57
  122. package/src/chat/tool-execution.d.ts +32 -20
  123. package/src/chat/tool-execution.ts +103 -36
  124. package/src/chat/user-message.d.ts +3 -2
  125. package/src/chat/user-message.ts +54 -34
  126. package/src/chrome/countdown-timer.ts +3 -3
  127. package/src/chrome/diff.d.ts +2 -3
  128. package/src/chrome/diff.ts +4 -9
  129. package/src/chrome/transcript-container.d.ts +2 -0
  130. package/src/chrome/transcript-container.ts +212 -28
  131. package/src/components/editor.ts +12 -12
  132. package/src/components/image.d.ts +2 -0
  133. package/src/components/image.ts +54 -20
  134. package/src/components/markdown.d.ts +49 -3
  135. package/src/components/markdown.ts +461 -11
  136. package/src/components/settings-list.ts +2 -4
  137. package/src/components/text.d.ts +2 -0
  138. package/src/components/text.ts +11 -1
  139. package/src/debug-server.d.ts +2 -0
  140. package/src/debug-server.ts +51 -69
  141. package/src/hotkeys-markdown.ts +2 -0
  142. package/src/keybinding-matchers.d.ts +6 -0
  143. package/src/keybinding-matchers.ts +19 -0
  144. package/src/multiplexers/tmux.d.ts +1 -0
  145. package/src/multiplexers/tmux.ts +3 -0
  146. package/src/multiplexers/types.d.ts +6 -0
  147. package/src/multiplexers/types.ts +6 -0
  148. package/src/native/backend.d.ts +5 -4
  149. package/src/native/backend.ts +13 -5
  150. package/src/native/blobs.d.ts +24 -12
  151. package/src/native/blobs.ts +56 -7
  152. package/src/native/reconcile.ts +43 -21
  153. package/src/overlays/advisor-config.ts +259 -8
  154. package/src/overlays/agent-hub-projection.d.ts +10 -5
  155. package/src/overlays/agent-hub-projection.ts +63 -12
  156. package/src/overlays/agent-hub.ts +2 -2
  157. package/src/overlays/annotation-overlay.ts +422 -168
  158. package/src/overlays/approval-dialog.ts +389 -0
  159. package/src/overlays/ask-dialog.ts +244 -105
  160. package/src/overlays/btw-history-panel.ts +208 -147
  161. package/src/overlays/extensions/extension-list.ts +4 -6
  162. package/src/overlays/hook-editor.ts +90 -32
  163. package/src/overlays/hook-input.ts +105 -19
  164. package/src/overlays/job-tree.ts +177 -0
  165. package/src/overlays/jobs-panel.ts +222 -142
  166. package/src/overlays/login-dialog.ts +201 -91
  167. package/src/overlays/mcp-add-wizard.ts +562 -279
  168. package/src/overlays/model-browser.ts +32 -7
  169. package/src/overlays/model-hub.ts +4 -10
  170. package/src/overlays/model-picker.ts +58 -21
  171. package/src/overlays/pause-screen.ts +81 -23
  172. package/src/overlays/plan-review-overlay.ts +16 -0
  173. package/src/overlays/plugin-selector.ts +17 -1
  174. package/src/overlays/plugin-settings.ts +8 -2
  175. package/src/overlays/queue-mode-selector.ts +10 -0
  176. package/src/overlays/report-panel.ts +6 -2
  177. package/src/overlays/reset-usage-selector.ts +130 -21
  178. package/src/overlays/session-account-selector.ts +36 -0
  179. package/src/overlays/session-info-overlay.ts +199 -76
  180. package/src/overlays/session-selector.ts +15 -10
  181. package/src/overlays/settings-selector.ts +3 -0
  182. package/src/overlays/show-images-selector.ts +1 -0
  183. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  184. package/src/overlays/theme-selector.ts +6 -1
  185. package/src/overlays/thinking-selector.ts +8 -4
  186. package/src/overlays/tree-selector.ts +117 -2
  187. package/src/overlays/usage-dashboard.ts +498 -144
  188. package/src/prompt/composer-cache.ts +4 -6
  189. package/src/prompt/composer.ts +196 -96
  190. package/src/prompt/custom-editor.ts +168 -65
  191. package/src/prompt/image-references.d.ts +22 -0
  192. package/src/prompt/image-references.ts +79 -2
  193. package/src/prompt/internal-url-autocomplete.ts +1 -0
  194. package/src/prompt/macos-spelling.ts +28 -5
  195. package/src/prompt/prose-gate.ts +3 -0
  196. package/src/prompt/queued-messages.ts +9 -2
  197. package/src/prompt/tips.txt +1 -2
  198. package/src/prompt/typing-word.ts +59 -0
  199. package/src/prompt/welcome.ts +256 -423
  200. package/src/render/code-cell.ts +18 -5
  201. package/src/render/hanging-wrap.d.ts +7 -0
  202. package/src/render/hanging-wrap.ts +58 -0
  203. package/src/render/hyperlink.d.ts +2 -0
  204. package/src/render/hyperlink.ts +5 -0
  205. package/src/render/render-utils.d.ts +9 -1
  206. package/src/render/render-utils.ts +16 -2
  207. package/src/render/utils.d.ts +1 -1
  208. package/src/render/utils.ts +1 -1
  209. package/src/render/width-aware-text.ts +6 -5
  210. package/src/setup/scenes/splash.ts +137 -60
  211. package/src/status-line/component.ts +23 -10
  212. package/src/status-line/context-usage.ts +176 -73
  213. package/src/status-line/footer.ts +18 -35
  214. package/src/status-line/host.ts +6 -1
  215. package/src/status-line/segments.ts +16 -5
  216. package/src/status-line/startup.ts +6 -6
  217. package/src/status-line/types.ts +1 -0
  218. package/src/stdin-buffer.d.ts +7 -0
  219. package/src/stdin-buffer.ts +11 -1
  220. package/src/terminal-capabilities.d.ts +16 -1
  221. package/src/terminal-capabilities.ts +66 -28
  222. package/src/terminal-multiplexer.d.ts +7 -0
  223. package/src/terminal-multiplexer.ts +14 -0
  224. package/src/terminal.d.ts +8 -4
  225. package/src/terminal.ts +160 -46
  226. package/src/theme/symbols.d.ts +1 -1
  227. package/src/theme/symbols.ts +1 -0
  228. package/src/theme/theme-class.ts +1 -0
  229. package/src/theme/theme.d.ts +11 -0
  230. package/src/theme/theme.ts +46 -14
  231. package/src/thinking.d.ts +5 -0
  232. package/src/thinking.ts +9 -1
  233. package/src/tmux.d.ts +39 -4
  234. package/src/tmux.ts +110 -16
  235. package/src/tools/ask.ts +20 -9
  236. package/src/tools/ast-edit.d.ts +2 -2
  237. package/src/tools/ast-edit.ts +30 -7
  238. package/src/tools/ast-grep.d.ts +2 -2
  239. package/src/tools/ast-grep.ts +39 -35
  240. package/src/tools/bash.d.ts +6 -1
  241. package/src/tools/bash.ts +15 -9
  242. package/src/tools/debug.ts +2 -1
  243. package/src/tools/default-renderer.ts +3 -2
  244. package/src/tools/edit.d.ts +2 -0
  245. package/src/tools/edit.ts +100 -21
  246. package/src/tools/eval.d.ts +53 -4
  247. package/src/tools/eval.ts +173 -137
  248. package/src/tools/find.d.ts +14 -4
  249. package/src/tools/find.ts +71 -18
  250. package/src/tools/glob.d.ts +3 -3
  251. package/src/tools/glob.ts +51 -17
  252. package/src/tools/grep.d.ts +2 -2
  253. package/src/tools/grep.ts +99 -22
  254. package/src/tools/grouped-file-output.d.ts +9 -4
  255. package/src/tools/grouped-file-output.ts +21 -6
  256. package/src/tools/lsp.ts +30 -18
  257. package/src/tools/mcp.ts +2 -2
  258. package/src/tools/native-view.d.ts +1 -1
  259. package/src/tools/native-view.ts +2 -2
  260. package/src/tools/read.d.ts +32 -10
  261. package/src/tools/read.ts +89 -43
  262. package/src/tools/renderer.d.ts +2 -0
  263. package/src/tools/renderer.ts +2 -0
  264. package/src/tools/task.d.ts +31 -0
  265. package/src/tools/task.ts +40 -7
  266. package/src/tools/todo.ts +3 -2
  267. package/src/tools/wait.ts +16 -30
  268. package/src/tools/web-search.ts +2 -1
  269. package/src/tools/write.d.ts +2 -0
  270. package/src/tools/write.ts +36 -12
  271. package/src/tools/yield.ts +2 -2
  272. package/src/tui.d.ts +20 -3
  273. package/src/tui.ts +140 -36
  274. package/src/utils.ts +5 -3
  275. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  276. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
package/src/tmux.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  export { wrapTmuxPassthrough } from "./multiplexers/tmux";
2
2
  /** Pass a control sequence through tmux, leaving direct-terminal output unchanged. */
3
3
  export declare function wrapTmuxPassthroughIfNeeded(payload: string, env?: NodeJS.ProcessEnv): string;
4
+ /**
5
+ * Emulator name candidates from a `#{client_termtype}<TAB>#{client_termname}`
6
+ * line, best first: the terminal-type reply (tmux 3.4+, XTVERSION), then the
7
+ * emulator the client's `TERM` names when it ships its own terminfo entry. Both
8
+ * are kept because the reply can name an unrecognized program (a nested tmux
9
+ * answers `tmux 3.4`) while `TERM` still identifies the emulator.
10
+ */
11
+ export declare function tmuxClientProgramsFromReport(report: string): string[];
4
12
  /**
5
13
  * Whether the terminal hosting this tmux session draws TSP programs natively:
6
14
  * it sets the session user option `@tern-tsp` while it watches the session.
@@ -12,10 +20,37 @@ export declare function tmuxSessionSpeaksTsp(env?: NodeJS.ProcessEnv): boolean;
12
20
  */
13
21
  export declare function setTmuxPaneApcTitles(on: boolean, env?: NodeJS.ProcessEnv): void;
14
22
  /**
15
- * Resolve the terminal emulator name recorded for this tmux client.
23
+ * Resolve the terminal emulator names recorded for this tmux client, best first.
16
24
  *
17
25
  * tmux overwrites pane identity variables with its own values, but retains the
18
- * attached client's terminal-type reply in `#{client_termtype}`. The local IPC
19
- * query runs once per process and degrades to `null` when unavailable.
26
+ * attached client's terminal-type reply in `#{client_termtype}` and its `TERM`
27
+ * in `#{client_termname}` (see {@link tmuxClientProgramsFromReport}). The local
28
+ * IPC query runs once per process and degrades to `[]` when unavailable.
29
+ */
30
+ export declare function resolveTmuxClientTerminalNames(env?: NodeJS.ProcessEnv): readonly string[];
31
+ /**
32
+ * Whether tmux draws SIXEL to the client attached to this session: its
33
+ * effective terminal description for that client carries `Sxl`.
34
+ *
35
+ * A tmux built with SIXEL answers every pane's DA1 with attribute 4 and draws a
36
+ * `SIXEL IMAGE (WxH)` text placeholder for clients without SIXEL, so the pane's
37
+ * DA1 says nothing about the outer terminal. tmux itself renders SIXEL only
38
+ * when the client's terminal has `Sxl`, whether from terminfo,
39
+ * `terminal-overrides`, or the `sixel` feature (DA1 or `terminal-features`),
40
+ * and `show-messages -T` reports that merged description. The TUI consults
41
+ * this before trusting the pane's DA1 attribute. Queried once per tmux
42
+ * session; `false` outside tmux, with no attached client, or when the query
43
+ * fails. Nested tmux usually reports no `Sxl`: the inner client's terminal is
44
+ * the outer tmux, whose DA1 starts with level 1, which tmux does not parse
45
+ * for features.
46
+ */
47
+ export declare function tmuxClientRendersSixel(env?: NodeJS.ProcessEnv): boolean;
48
+ /**
49
+ * Whether tmux forwards this pane's `\x1bPtmux;…\x1b\\` passthrough envelopes
50
+ * to the attached client. tmux 3.3+ defaults `allow-passthrough` to `off` and
51
+ * then silently drops every wrapped sequence, so Kitty graphics sent through
52
+ * the envelope never reach a Kitty-capable outer terminal. Resolves the
53
+ * effective pane value (pane → window → global). Queried once per pane;
54
+ * `false` outside tmux or when the query fails.
20
55
  */
21
- export declare function resolveTmuxClientTerminalName(env?: NodeJS.ProcessEnv): string | null;
56
+ export declare function tmuxPaneAllowsPassthrough(env?: NodeJS.ProcessEnv): boolean;
package/src/tmux.ts CHANGED
@@ -10,24 +10,55 @@ export function wrapTmuxPassthroughIfNeeded(payload: string, env: NodeJS.Process
10
10
  }
11
11
 
12
12
  const CLIENT_TERMTYPE_NAME = /^([A-Za-z][A-Za-z0-9._+-]*)(?=\s|\(|$)/u;
13
- const CLIENT_TERMTYPE_TIMEOUT_MS = 500;
14
- let cachedClientTerminalName: string | null | undefined;
13
+ // One `tmux display-message` round trip over the server socket, which answers in
14
+ // single-digit milliseconds when healthy. 500ms kept losing the race on a loaded
15
+ // machine (fork/exec of the tmux client alone can exceed it on a saturated box),
16
+ // and a lost race is indistinguishable from "not tmux": the process then falls
17
+ // back to the true-color profile for the whole session. 2s bounds the startup
18
+ // stall only when tmux is genuinely wedged.
19
+ const CLIENT_TERMINAL_TIMEOUT_MS = 2_000;
20
+ /** Client `TERM` values that name their emulator; tmux records this on every version. */
21
+ const CLIENT_TERMNAME_PROGRAMS: ReadonlyArray<readonly [RegExp, string]> = [
22
+ [/^xterm-kitty$/u, "kitty"],
23
+ [/^xterm-ghostty$/u, "ghostty"],
24
+ [/^wezterm$/u, "wezterm"],
25
+ ];
26
+ let cachedClientTerminalNames: readonly string[] | undefined;
15
27
 
16
- function queryTmuxClientTerminalName(env: NodeJS.ProcessEnv): string | null {
28
+ /**
29
+ * Emulator name candidates from a `#{client_termtype}<TAB>#{client_termname}`
30
+ * line, best first: the terminal-type reply (tmux 3.4+, XTVERSION), then the
31
+ * emulator the client's `TERM` names when it ships its own terminfo entry. Both
32
+ * are kept because the reply can name an unrecognized program (a nested tmux
33
+ * answers `tmux 3.4`) while `TERM` still identifies the emulator.
34
+ */
35
+ export function tmuxClientProgramsFromReport(report: string): string[] {
36
+ // Split before trimming: an empty terminal type leaves the line starting with the tab.
37
+ const [termtype = "", termname = ""] = report.split("\t");
38
+ const programs: string[] = [];
39
+ const program = CLIENT_TERMTYPE_NAME.exec(termtype.trim())?.[1];
40
+ if (program) programs.push(program);
41
+ const term = termname.trim().toLowerCase();
42
+ const termProgram = CLIENT_TERMNAME_PROGRAMS.find(([pattern]) => pattern.test(term))?.[1];
43
+ if (termProgram) programs.push(termProgram);
44
+ return programs;
45
+ }
46
+
47
+ function queryTmuxClientTerminalNames(env: NodeJS.ProcessEnv): readonly string[] {
17
48
  const tmux = $which("tmux", { PATH: env.PATH });
18
- if (!tmux) return null;
49
+ if (!tmux) return [];
19
50
  try {
20
- const result = Bun.spawnSync([tmux, "display-message", "-p", "#{client_termtype}"], {
51
+ const result = Bun.spawnSync([tmux, "display-message", "-p", "#{client_termtype}\t#{client_termname}"], {
21
52
  env,
22
53
  stdout: "pipe",
23
54
  stderr: "ignore",
24
- timeout: CLIENT_TERMTYPE_TIMEOUT_MS,
55
+ timeout: CLIENT_TERMINAL_TIMEOUT_MS,
25
56
  killSignal: "SIGKILL",
26
57
  });
27
- if (result.exitCode !== 0) return null;
28
- return CLIENT_TERMTYPE_NAME.exec(result.stdout.toString().trim())?.[1] ?? null;
58
+ if (result.exitCode !== 0) return [];
59
+ return tmuxClientProgramsFromReport(result.stdout.toString());
29
60
  } catch {
30
- return null;
61
+ return [];
31
62
  }
32
63
  }
33
64
 
@@ -75,14 +106,77 @@ export function setTmuxPaneApcTitles(on: boolean, env: NodeJS.ProcessEnv = Bun.e
75
106
  }
76
107
 
77
108
  /**
78
- * Resolve the terminal emulator name recorded for this tmux client.
109
+ * Resolve the terminal emulator names recorded for this tmux client, best first.
79
110
  *
80
111
  * tmux overwrites pane identity variables with its own values, but retains the
81
- * attached client's terminal-type reply in `#{client_termtype}`. The local IPC
82
- * query runs once per process and degrades to `null` when unavailable.
112
+ * attached client's terminal-type reply in `#{client_termtype}` and its `TERM`
113
+ * in `#{client_termname}` (see {@link tmuxClientProgramsFromReport}). The local
114
+ * IPC query runs once per process and degrades to `[]` when unavailable.
83
115
  */
84
- export function resolveTmuxClientTerminalName(env: NodeJS.ProcessEnv = Bun.env): string | null {
85
- if (!tmuxMultiplexer.isInside(env) || isBunTestRuntime()) return null;
86
- if (cachedClientTerminalName === undefined) cachedClientTerminalName = queryTmuxClientTerminalName(env);
87
- return cachedClientTerminalName;
116
+ export function resolveTmuxClientTerminalNames(env: NodeJS.ProcessEnv = Bun.env): readonly string[] {
117
+ if (!tmuxMultiplexer.isInside(env) || isBunTestRuntime()) return [];
118
+ cachedClientTerminalNames ??= queryTmuxClientTerminalNames(env);
119
+ return cachedClientTerminalNames;
120
+ }
121
+
122
+ const SIXEL_CAPABILITY = /^\s*\d+: Sxl: \(flag\) true$/mu;
123
+ let cachedClientSixel: { tmux: string; renders: boolean } | undefined;
124
+
125
+ function queryTmuxClientRendersSixel(env: NodeJS.ProcessEnv): boolean {
126
+ const client = runTmux(["display-message", "-p", "#{client_name}"], env)?.trim();
127
+ if (!client) return false;
128
+ return SIXEL_CAPABILITY.test(runTmux(["show-messages", "-T", "-t", client], env) ?? "");
129
+ }
130
+
131
+ /**
132
+ * Whether tmux draws SIXEL to the client attached to this session: its
133
+ * effective terminal description for that client carries `Sxl`.
134
+ *
135
+ * A tmux built with SIXEL answers every pane's DA1 with attribute 4 and draws a
136
+ * `SIXEL IMAGE (WxH)` text placeholder for clients without SIXEL, so the pane's
137
+ * DA1 says nothing about the outer terminal. tmux itself renders SIXEL only
138
+ * when the client's terminal has `Sxl`, whether from terminfo,
139
+ * `terminal-overrides`, or the `sixel` feature (DA1 or `terminal-features`),
140
+ * and `show-messages -T` reports that merged description. The TUI consults
141
+ * this before trusting the pane's DA1 attribute. Queried once per tmux
142
+ * session; `false` outside tmux, with no attached client, or when the query
143
+ * fails. Nested tmux usually reports no `Sxl`: the inner client's terminal is
144
+ * the outer tmux, whose DA1 starts with level 1, which tmux does not parse
145
+ * for features.
146
+ */
147
+ export function tmuxClientRendersSixel(env: NodeJS.ProcessEnv = Bun.env): boolean {
148
+ const tmux = env.TMUX;
149
+ if (!tmux) return false;
150
+ if (cachedClientSixel?.tmux !== tmux) cachedClientSixel = { tmux, renders: queryTmuxClientRendersSixel(env) };
151
+ return cachedClientSixel.renders;
152
+ }
153
+
154
+ let cachedPanePassthrough: { key: string; allowed: boolean } | undefined;
155
+
156
+ function queryTmuxPaneAllowsPassthrough(env: NodeJS.ProcessEnv): boolean {
157
+ const pane = env.TMUX_PANE;
158
+ const target = pane ? ["-t", pane] : [];
159
+ const value = runTmux(["show-options", "-Apqv", ...target, "allow-passthrough"], env)?.trim();
160
+ if (value === undefined) return false;
161
+ // tmux before 3.3 has no `allow-passthrough` option (`-q` prints nothing)
162
+ // and always forwards DCS passthrough.
163
+ return value === "" || value === "on" || value === "all";
164
+ }
165
+
166
+ /**
167
+ * Whether tmux forwards this pane's `\x1bPtmux;…\x1b\\` passthrough envelopes
168
+ * to the attached client. tmux 3.3+ defaults `allow-passthrough` to `off` and
169
+ * then silently drops every wrapped sequence, so Kitty graphics sent through
170
+ * the envelope never reach a Kitty-capable outer terminal. Resolves the
171
+ * effective pane value (pane → window → global). Queried once per pane;
172
+ * `false` outside tmux or when the query fails.
173
+ */
174
+ export function tmuxPaneAllowsPassthrough(env: NodeJS.ProcessEnv = Bun.env): boolean {
175
+ const tmux = env.TMUX;
176
+ if (!tmux) return false;
177
+ const key = `${tmux}\0${env.TMUX_PANE ?? ""}`;
178
+ if (cachedPanePassthrough?.key !== key) {
179
+ cachedPanePassthrough = { key, allowed: queryTmuxPaneAllowsPassthrough(env) };
180
+ }
181
+ return cachedPanePassthrough.allowed;
88
182
  }
package/src/tools/ask.ts CHANGED
@@ -23,6 +23,7 @@ import { getMarkdownTheme, type Theme } from "../theme/theme";
23
23
 
24
24
  import { outputBlockContentWidth, renderStatusLine } from "../render";
25
25
  import { framedToolCard } from "../render/tool-card";
26
+ import { wrapPrefixedLine } from "../render/hanging-wrap";
26
27
 
27
28
  import { formatErrorMessage, formatMeta, formatTitle, sanitizeCarriageReturns } from "../render/render-utils";
28
29
  import { tuiText, tuiTextFmt } from "../i18n";
@@ -203,20 +204,27 @@ export function optionMarker(uiTheme: Theme, multi: boolean | undefined, selecte
203
204
  return selected ? uiTheme.radio.selected : uiTheme.radio.unselected;
204
205
  }
205
206
 
207
+ function wrapOptionLabel(marker: string, label: string, width: number): string[] {
208
+ const prefix = ` ${marker} `;
209
+ return wrapPrefixedLine(prefix, " ".repeat(visibleWidth(prefix)), label, width);
210
+ }
211
+
206
212
  /** Render the offered options for a question form as flat marker bullets (no tree guides). */
207
213
  function renderQuestionOptionLines(
208
214
  uiTheme: Theme,
209
215
  mdTheme: MarkdownTheme,
210
216
  options: AskRenderOption[],
211
217
  multi: boolean | undefined,
218
+ width: number,
212
219
  ): string[] {
213
220
  const out: string[] = [];
214
221
  for (const opt of options) {
215
222
  const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
216
- out.push(` ${uiTheme.fg("dim", optionMarker(uiTheme, multi, false))} ${optLabel}`);
223
+ out.push(...wrapOptionLabel(uiTheme.fg("dim", optionMarker(uiTheme, multi, false)), optLabel, width));
217
224
  if (opt.description?.trim()) {
218
225
  const description = renderInlineMarkdown(opt.description.trim(), mdTheme, t => uiTheme.fg("dim", t));
219
- out.push(` ${uiTheme.fg("dim", "↳")} ${description}`);
226
+ const prefix = ` ${uiTheme.fg("dim", "↳")} `;
227
+ out.push(...wrapPrefixedLine(prefix, " ".repeat(visibleWidth(prefix)), description, width));
220
228
  }
221
229
  }
222
230
  return out;
@@ -261,6 +269,7 @@ function renderAnswerOptionLines(
261
269
  customInput: string | undefined,
262
270
  note: string | undefined,
263
271
  width: number,
272
+ contentWidth: number,
264
273
  selectedIndices?: ReadonlySet<number>,
265
274
  ): string[] {
266
275
  const selected = new Set(selectedOptions ?? []);
@@ -283,7 +292,7 @@ function renderAnswerOptionLines(
283
292
  const labelStyled = renderInlineMarkdown(label, mdTheme, t =>
284
293
  isSelected ? uiTheme.fg("toolOutput", t) : uiTheme.fg("muted", t),
285
294
  );
286
- out.push(` ${markerStyled} ${labelStyled}`);
295
+ out.push(...wrapOptionLabel(markerStyled, labelStyled, contentWidth));
287
296
  }
288
297
  if (customInput !== undefined) out.push(...renderCustomInputLines(uiTheme, customInput));
289
298
  if (note !== undefined) out.push(...renderNoteLines(uiTheme, note, width));
@@ -461,7 +470,7 @@ export const askToolRenderer = {
461
470
  ? tuiText("askQuestionOne", "1 question")
462
471
  : tuiTextFmt("askQuestionManyFmt", "%d questions", questions.length),
463
472
  )}`;
464
- return framedToolCard(uiTheme, ({ width }) => {
473
+ return framedToolCard(uiTheme, ({ width, contentWidth }) => {
465
474
  const sections = questions.map(q => {
466
475
  const meta: string[] = [];
467
476
  if (q.multi) meta.push(tuiText("askMultiMeta", "multi"));
@@ -470,7 +479,7 @@ export const askToolRenderer = {
470
479
  // md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
471
480
  const mdLines = md(q.question, width);
472
481
  const lines = q.options?.length
473
- ? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, q.options, q.multi)]
482
+ ? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, q.options, q.multi, contentWidth)]
474
483
  : mdLines;
475
484
  return { label: `${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, content: lines };
476
485
  });
@@ -496,11 +505,11 @@ export const askToolRenderer = {
496
505
  if (questionOptions?.length) meta.push(`options:${questionOptions.length}`);
497
506
  const header = `${label}${formatMeta(meta, uiTheme)}`;
498
507
  const multi = args.multi;
499
- return framedToolCard(uiTheme, ({ width }) => {
508
+ return framedToolCard(uiTheme, ({ width, contentWidth }) => {
500
509
  // md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
501
510
  const mdLines = md(question, width);
502
511
  const bodyLines = questionOptions?.length
503
- ? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, questionOptions, multi)]
512
+ ? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, questionOptions, multi, contentWidth)]
504
513
  : mdLines;
505
514
  return {
506
515
  header,
@@ -570,7 +579,7 @@ export const askToolRenderer = {
570
579
  },
571
580
  uiTheme,
572
581
  );
573
- return framedToolCard(uiTheme, ({ width }) => {
582
+ return framedToolCard(uiTheme, ({ width, contentWidth }) => {
574
583
  const rawResults = rawDetails.results ?? [];
575
584
  const sections = results.map((r, index) => {
576
585
  // Sanitizing preserves order and length, so raw indices align with `r`.
@@ -587,6 +596,7 @@ export const askToolRenderer = {
587
596
  r.customInput,
588
597
  r.note,
589
598
  width,
599
+ contentWidth,
590
600
  selectedIndicesFor(raw?.options, raw?.selectedOptions),
591
601
  ),
592
602
  ];
@@ -625,7 +635,7 @@ export const askToolRenderer = {
625
635
  const dCustom = details.customInput;
626
636
  const dNote = details.note;
627
637
  const dTimedOut = details.timedOut;
628
- return framedToolCard(uiTheme, ({ width }) => {
638
+ return framedToolCard(uiTheme, ({ width, contentWidth }) => {
629
639
  // md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
630
640
  const bodyLines = [
631
641
  ...md(question, width),
@@ -638,6 +648,7 @@ export const askToolRenderer = {
638
648
  dCustom,
639
649
  dNote,
640
650
  width,
651
+ contentWidth,
641
652
  selectedIndicesFor(rawDetails.options, rawDetails.selectedOptions),
642
653
  ),
643
654
  ];
@@ -1,7 +1,7 @@
1
1
  import type { Component } from "../tui";
2
2
  import type { Theme } from "../theme/theme";
3
3
  import type { OutputMeta } from "./output-meta";
4
- import type { NativeToolView, RenderResultOptions } from "./renderer";
4
+ import type { NativeToolView, RenderResultContextOptions, RenderResultOptions } from "./renderer";
5
5
  /** Display metadata returned by ast-edit. */
6
6
  export interface AstEditToolDetails {
7
7
  totalReplacements: number;
@@ -47,7 +47,7 @@ export declare const astEditToolRenderer: {
47
47
  }>;
48
48
  details?: AstEditToolDetails;
49
49
  isError?: boolean;
50
- }, options: RenderResultOptions, uiTheme: Theme, args?: AstEditRenderArgs): Component;
50
+ }, options: RenderResultContextOptions, uiTheme: Theme, args?: AstEditRenderArgs): Component;
51
51
  describeCall(args: AstEditRenderArgs): NativeToolView;
52
52
  describeResult(result: {
53
53
  content: Array<{
@@ -1,3 +1,4 @@
1
+ import { isRecord, sanitizeText } from "@linxiraos/pi-utils";
1
2
  import type { Component } from "../tui";
2
3
  import { Text } from "../components/text";
3
4
  import { replaceTabs } from "../utils";
@@ -14,11 +15,16 @@ import {
14
15
  } from "../render/render-utils";
15
16
  import { classifyGroupedLines, groupLineIndicesByBlank } from "./grouped-file-output";
16
17
  import type { OutputMeta } from "./output-meta";
17
- import type { NativeToolHead, NativeToolView, RenderResultOptions, ToolRenderer } from "./renderer";
18
+ import type {
19
+ NativeToolHead,
20
+ NativeToolView,
21
+ RenderResultContextOptions,
22
+ RenderResultOptions,
23
+ ToolRenderer,
24
+ } from "./renderer";
18
25
  import { code, compact, diff, node, span, text } from "../native/describe";
19
26
  import type { NativeChild, NativeNode } from "../native/node";
20
27
  import { diffStatsMeta, displayPath, errorText, fileDiffSection, noteText, resultText, statsText } from "./native-view";
21
- import { getLanguageFromPath } from "../lang-from-path";
22
28
 
23
29
  /** Display metadata returned by ast-edit. */
24
30
  export interface AstEditToolDetails {
@@ -148,9 +154,9 @@ function parseAstEditChanges(lines: readonly string[]): AstEditFileDiff[] {
148
154
  return files;
149
155
  }
150
156
 
151
- /** A file's proposed rewrite as a `diff`: highlighted by the path's language, no path header. */
157
+ /** A file's proposed rewrite as a `diff`: Tern highlights it by the file's grammar and draws no path header. */
152
158
  function astEditDiff(file: AstEditFileDiff): NativeNode {
153
- return diff(file.lines.join("\n"), { lang: getLanguageFromPath(file.path) });
159
+ return diff(file.lines.join("\n"), { path: file.path });
154
160
  }
155
161
 
156
162
  /** The single rewrite pattern (`target:"pattern"`), or the rewrite count, for the head. */
@@ -190,7 +196,7 @@ export const astEditToolRenderer = {
190
196
 
191
197
  renderResult(
192
198
  result: { content: Array<{ type: string; text?: string }>; details?: AstEditToolDetails; isError?: boolean },
193
- options: RenderResultOptions,
199
+ options: RenderResultContextOptions,
194
200
  uiTheme: Theme,
195
201
  args?: AstEditRenderArgs,
196
202
  ): Component {
@@ -245,6 +251,19 @@ export const astEditToolRenderer = {
245
251
  // Resolve hyperlinks over the whole output so nested directory headers
246
252
  // reconstruct across the blank-line groups the tree list collapses by.
247
253
  const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
254
+ // The display lines are sanitized; reconstruct link targets from the original
255
+ // grouped headers only when they still align with those display lines.
256
+ const rawDetails = options.renderContext?.rawDetails;
257
+ const rawContent =
258
+ isRecord(rawDetails) && typeof rawDetails.displayContent === "string" ? rawDetails.displayContent : undefined;
259
+ const rawLines = rawContent?.split("\n");
260
+ const rawCwd = isRecord(rawDetails) && typeof rawDetails.cwd === "string" ? rawDetails.cwd : undefined;
261
+ const rawSearchPath =
262
+ isRecord(rawDetails) && typeof rawDetails.searchPath === "string" ? rawDetails.searchPath : undefined;
263
+ const rawContexts =
264
+ rawContent !== undefined && rawLines?.length === allLines.length && sanitizeText(rawContent) === textContent
265
+ ? classifyGroupedLines(rawLines, rawCwd ?? rawSearchPath, rawSearchPath)
266
+ : undefined;
248
267
  // Style lazily: collapsed bodies show only the first groups.
249
268
  const styledLines: (string | undefined)[] = Array.from({ length: allLines.length }, () => undefined);
250
269
  const styleLine = (index: number): string => {
@@ -255,12 +274,16 @@ export const astEditToolRenderer = {
255
274
  // second vertical bar inside the frame border.
256
275
  const display = replaceTabs(allLines[index]!.replace("│", " "));
257
276
  let styled: string;
277
+ const linkPath =
278
+ ctx.headerPath && rawContexts?.[index]?.kind === ctx.kind && rawContexts[index]?.depth === ctx.depth
279
+ ? (rawContexts[index]?.headerPath ?? ctx.headerPath)
280
+ : ctx.headerPath;
258
281
  if (ctx.kind === "dir") {
259
282
  const accent = uiTheme.fg("accent", display);
260
- styled = ctx.headerPath ? fileHyperlink(ctx.headerPath, accent) : accent;
283
+ styled = linkPath ? fileHyperlink(linkPath, accent) : accent;
261
284
  } else if (ctx.kind === "file") {
262
285
  const tinted = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", display);
263
- styled = ctx.headerPath ? fileHyperlink(ctx.headerPath, tinted) : tinted;
286
+ styled = linkPath ? fileHyperlink(linkPath, tinted) : tinted;
264
287
  } else if (display.startsWith("+")) {
265
288
  styled = uiTheme.fg("toolDiffAdded", display);
266
289
  } else if (display.startsWith("-")) {
@@ -1,7 +1,7 @@
1
1
  import type { Component } from "../tui";
2
2
  import type { Theme } from "../theme/theme";
3
3
  import type { OutputMeta } from "./output-meta";
4
- import type { RenderResultOptions, NativeToolView } from "./renderer";
4
+ import type { NativeToolView, RenderResultContextOptions, RenderResultOptions } from "./renderer";
5
5
  /** Display metadata returned by ast-grep. */
6
6
  export interface AstGrepToolDetails {
7
7
  matchCount: number;
@@ -46,7 +46,7 @@ export declare const astGrepToolRenderer: {
46
46
  }>;
47
47
  details?: AstGrepToolDetails;
48
48
  isError?: boolean;
49
- }, options: RenderResultOptions, uiTheme: Theme, args?: AstGrepRenderArgs): Component;
49
+ }, options: RenderResultContextOptions, uiTheme: Theme, args?: AstGrepRenderArgs): Component;
50
50
  describeCall(args: AstGrepRenderArgs): NativeToolView;
51
51
  describeResult(result: {
52
52
  content: Array<{
@@ -1,3 +1,4 @@
1
+ import { isRecord, sanitizeText } from "@linxiraos/pi-utils";
1
2
  import type { Component } from "../tui";
2
3
  import { Text } from "../components/text";
3
4
  import type { Theme } from "../theme/theme";
@@ -13,13 +14,11 @@ import {
13
14
  toPathList,
14
15
  } from "../render/render-utils";
15
16
  import { classifyGroupedLines, describeGroupedOutput, groupLineIndicesByBlank } from "./grouped-file-output";
16
- import { getLanguageFromPath } from "../lang-from-path";
17
17
  import { code, compact, node, span } from "../native/describe";
18
18
  import type { NativeChild } from "../native/node";
19
19
  import { errorText, noteText, resultText, toolHead } from "./native-view";
20
20
  import type { OutputMeta } from "./output-meta";
21
- import { tuiText, tuiTextFmt } from "../i18n";
22
- import type { RenderResultOptions, ToolRenderer, NativeToolView } from "./renderer";
21
+ import type { NativeToolView, RenderResultContextOptions, RenderResultOptions, ToolRenderer } from "./renderer";
23
22
 
24
23
  /** Display metadata returned by ast-grep. */
25
24
  export interface AstGrepToolDetails {
@@ -61,8 +60,7 @@ export const astGrepToolRenderer = {
61
60
  renderCall(args: AstGrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
62
61
  const meta: string[] = [];
63
62
  const scopePaths = toPathList(args.path ?? args.paths);
64
- if (scopePaths.length)
65
- meta.push(tuiTextFmt("gpInLabelFmt", `in ${scopePaths.join(", ")}`, scopePaths.join(", ")));
63
+ if (scopePaths.length) meta.push(`in ${scopePaths.join(", ")}`);
66
64
  if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
67
65
 
68
66
  const description = args.pat ?? "?";
@@ -72,15 +70,14 @@ export const astGrepToolRenderer = {
72
70
 
73
71
  renderResult(
74
72
  result: { content: Array<{ type: string; text?: string }>; details?: AstGrepToolDetails; isError?: boolean },
75
- options: RenderResultOptions,
73
+ options: RenderResultContextOptions,
76
74
  uiTheme: Theme,
77
75
  args?: AstGrepRenderArgs,
78
76
  ): Component {
79
77
  const details = result.details;
80
78
 
81
79
  if (result.isError) {
82
- const errorText =
83
- result.content?.find(c => c.type === "text")?.text || tuiText("glUnknownError", "Unknown error");
80
+ const errorText = result.content?.find(c => c.type === "text")?.text || "Unknown error";
84
81
  return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
85
82
  }
86
83
 
@@ -91,33 +88,23 @@ export const astGrepToolRenderer = {
91
88
 
92
89
  if (matchCount === 0) {
93
90
  const description = args?.pat;
94
- const meta = [tuiText("agrpZeroMatches", "0 matches")];
95
- if (details?.scopePath) meta.push(tuiTextFmt("gpInLabelFmt", `in ${details.scopePath}`, details.scopePath));
96
- if (filesSearched > 0) meta.push(tuiTextFmt("agrpSearchedFmt", `searched ${filesSearched}`, filesSearched));
91
+ const meta = ["0 matches"];
92
+ if (details?.scopePath) meta.push(`in ${details.scopePath}`);
93
+ if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
97
94
  const header = renderStatusLine({ icon: "warning", title: "AST Grep", description, meta }, uiTheme);
98
- const lines = [header, formatEmptyMessage(tuiText("agrpNoMatches", "No matches found"), uiTheme)];
95
+ const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
99
96
  if (details?.parseErrors?.length) {
100
- lines.push(
101
- uiTheme.fg(
102
- "warning",
103
- tuiText("agrpMisScoped", "Query may be mis-scoped; narrow `path` before concluding absence"),
104
- ),
105
- );
97
+ lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `path` before concluding absence"));
106
98
  appendParseErrorsBulletList(lines, details.parseErrors, uiTheme, details.parseErrorsTotal);
107
99
  }
108
100
  return new Text(lines.join("\n"), 0, 0);
109
101
  }
110
102
 
111
- const matchMeta =
112
- matchCount === 1
113
- ? tuiText("agrpMatchOne", "1 match")
114
- : tuiTextFmt("agrpMatchManyFmt", "%d matches", matchCount);
115
- const fileMeta =
116
- fileCount === 1 ? tuiText("agrpFileOne", "1 file") : tuiTextFmt("agrpFileManyFmt", "%d files", fileCount);
117
- const meta = [matchMeta, fileMeta];
118
- if (details?.scopePath) meta.push(tuiTextFmt("gpInLabelFmt", `in ${details.scopePath}`, details.scopePath));
119
- meta.push(tuiTextFmt("agrpSearchedFmt", `searched ${filesSearched}`, filesSearched));
120
- if (limitReached) meta.push(uiTheme.fg("warning", tuiText("tLimitReached", "limit reached")));
103
+ const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
104
+ const meta = [...summaryParts];
105
+ if (details?.scopePath) meta.push(`in ${details.scopePath}`);
106
+ meta.push(`searched ${filesSearched}`);
107
+ if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
121
108
  const description = args?.pat;
122
109
  const header = renderStatusLine(
123
110
  {
@@ -136,6 +123,20 @@ export const astGrepToolRenderer = {
136
123
  // Resolve hyperlinks over the whole output so nested directory headers
137
124
  // reconstruct across the blank-line groups the tree list collapses by.
138
125
  const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
126
+ // The displayed lines are sanitized, but OSC 8 destinations must retain the
127
+ // original path bytes. Only substitute targets for headers already linked
128
+ // by the sanitized output; never let raw text create additional links.
129
+ const rawDetails = options.renderContext?.rawDetails;
130
+ const rawContent =
131
+ isRecord(rawDetails) && typeof rawDetails.displayContent === "string" ? rawDetails.displayContent : undefined;
132
+ const rawLines = rawContent?.split("\n");
133
+ const rawCwd = isRecord(rawDetails) && typeof rawDetails.cwd === "string" ? rawDetails.cwd : undefined;
134
+ const rawSearchPath =
135
+ isRecord(rawDetails) && typeof rawDetails.searchPath === "string" ? rawDetails.searchPath : undefined;
136
+ const rawContexts =
137
+ rawContent !== undefined && rawLines?.length === allLines.length && sanitizeText(rawContent) === textContent
138
+ ? classifyGroupedLines(rawLines, rawCwd ?? rawSearchPath, rawSearchPath)
139
+ : undefined;
139
140
  // Style lazily: the collapsed tree list only renders the first few groups,
140
141
  // so a large result never styles/hyperlinks rows nobody sees.
141
142
  const styledLines: (string | undefined)[] = Array.from({ length: allLines.length }, () => undefined);
@@ -144,13 +145,18 @@ export const astGrepToolRenderer = {
144
145
  if (cached !== undefined) return cached;
145
146
  const line = allLines[index]!;
146
147
  const ctx = contexts[index]!;
148
+ const rawContext = rawContexts?.[index];
149
+ const target =
150
+ ctx.headerPath && rawContext?.kind === ctx.kind && rawContext.depth === ctx.depth
151
+ ? (rawContext.headerPath ?? ctx.headerPath)
152
+ : ctx.headerPath;
147
153
  let styled: string;
148
154
  if (ctx.kind === "dir") {
149
155
  const accent = uiTheme.fg("accent", line);
150
- styled = ctx.headerPath ? fileHyperlink(ctx.headerPath, accent) : accent;
156
+ styled = target ? fileHyperlink(target, accent) : accent;
151
157
  } else if (ctx.kind === "file") {
152
158
  const tinted = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", line);
153
- styled = ctx.headerPath ? fileHyperlink(ctx.headerPath, tinted) : tinted;
159
+ styled = target ? fileHyperlink(target, tinted) : tinted;
154
160
  } else {
155
161
  styled = uiTheme.fg(line.startsWith(" meta:") ? "dim" : "toolOutput", line);
156
162
  }
@@ -174,9 +180,7 @@ export const astGrepToolRenderer = {
174
180
 
175
181
  const extraLines: string[] = [];
176
182
  if (limitReached) {
177
- extraLines.push(
178
- uiTheme.fg("warning", tuiText("agrpLimitNarrowPath", "limit reached; narrow path or increase limit")),
179
- );
183
+ extraLines.push(uiTheme.fg("warning", "limit reached; narrow path or use skip"));
180
184
  }
181
185
  if (details?.parseErrors?.length) {
182
186
  extraLines.push(
@@ -274,8 +278,8 @@ export const astGrepToolRenderer = {
274
278
  return {
275
279
  head,
276
280
  body: compact<NativeChild>([
277
- ...describeGroupedOutput(kept, { lang: getLanguageFromPath }),
278
- limitReached && noteText("limit reached; narrow path or increase limit", "warning"),
281
+ ...describeGroupedOutput(kept),
282
+ limitReached && noteText("limit reached; narrow path or use skip", "warning"),
279
283
  parseNote,
280
284
  ]),
281
285
  preview: { lines: COLLAPSED_MATCH_LIMIT },
@@ -18,6 +18,8 @@ export interface BashToolDetails {
18
18
  requestedTimeoutSeconds?: number;
19
19
  timeoutDisabled?: boolean;
20
20
  wallTimeMs?: number;
21
+ /** False when no wall-time footer was emitted; absent on older results. */
22
+ wallTimeNoticeEmitted?: boolean;
21
23
  /** Exit code of a command that ran to completion but failed (non-zero). */
22
24
  exitCode?: number;
23
25
  /** True when the command was killed by its timeout deadline (not a failure). */
@@ -31,12 +33,15 @@ export interface BashToolDetails {
31
33
  timedOut: boolean;
32
34
  pid?: number;
33
35
  };
36
+ /** Background job behind the call; `cancelled`: killed (`proc://<id>/kill`) before it finished. */
34
37
  async?: {
35
- state: "running" | "completed" | "failed";
38
+ state: "running" | "completed" | "failed" | "cancelled";
36
39
  jobId: string;
37
40
  type: "bash";
38
41
  };
39
42
  }
43
+ /** Minimum duration for a model-facing wall-time notice; shorter runs keep timing only in details. */
44
+ export declare const WALL_TIME_NOTICE_MIN_MS = 1000;
40
45
  /** Formats the model-facing command duration notice. */
41
46
  export declare function formatWallTimeNotice(wallTimeMs: number): string;
42
47
  /** Formats the model-facing command exit status notice. */