aicodeman 1.45.0 → 1.46.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 (283) hide show
  1. package/README.md +26 -6
  2. package/dist/agent-codex-identity.d.ts +26 -0
  3. package/dist/agent-codex-identity.d.ts.map +1 -0
  4. package/dist/agent-codex-identity.js +253 -0
  5. package/dist/agent-codex-identity.js.map +1 -0
  6. package/dist/agent-inspection.d.ts +32 -0
  7. package/dist/agent-inspection.d.ts.map +1 -0
  8. package/dist/agent-inspection.js +196 -0
  9. package/dist/agent-inspection.js.map +1 -0
  10. package/dist/agent-staging.d.ts +130 -0
  11. package/dist/agent-staging.d.ts.map +1 -0
  12. package/dist/agent-staging.js +312 -0
  13. package/dist/agent-staging.js.map +1 -0
  14. package/dist/cli-agent.d.ts +31 -2
  15. package/dist/cli-agent.d.ts.map +1 -1
  16. package/dist/cli-agent.js +279 -34
  17. package/dist/cli-agent.js.map +1 -1
  18. package/dist/cli-history.d.ts +20 -0
  19. package/dist/cli-history.d.ts.map +1 -0
  20. package/dist/cli-history.js +61 -0
  21. package/dist/cli-history.js.map +1 -0
  22. package/dist/cli.d.ts.map +1 -1
  23. package/dist/cli.js +6 -1
  24. package/dist/cli.js.map +1 -1
  25. package/dist/codex-rollout-format.d.ts +66 -0
  26. package/dist/codex-rollout-format.d.ts.map +1 -0
  27. package/dist/codex-rollout-format.js +111 -0
  28. package/dist/codex-rollout-format.js.map +1 -0
  29. package/dist/codex-transcript.d.ts +3 -0
  30. package/dist/codex-transcript.d.ts.map +1 -1
  31. package/dist/codex-transcript.js +13 -41
  32. package/dist/codex-transcript.js.map +1 -1
  33. package/dist/config/cli-registry/schema.d.ts +12 -7
  34. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  35. package/dist/config/cli-registry/schema.js +26 -0
  36. package/dist/config/cli-registry/schema.js.map +1 -1
  37. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  38. package/dist/config/cli-registry/stock.js +40 -1
  39. package/dist/config/cli-registry/stock.js.map +1 -1
  40. package/dist/config/cli-registry/types.d.ts +57 -0
  41. package/dist/config/cli-registry/types.d.ts.map +1 -1
  42. package/dist/config/server-timing.d.ts +6 -0
  43. package/dist/config/server-timing.d.ts.map +1 -1
  44. package/dist/config/server-timing.js +9 -0
  45. package/dist/config/server-timing.js.map +1 -1
  46. package/dist/daemon-control.d.ts +1 -0
  47. package/dist/daemon-control.d.ts.map +1 -1
  48. package/dist/daemon-control.js +2 -0
  49. package/dist/daemon-control.js.map +1 -1
  50. package/dist/deepseek-transcript.d.ts +9 -7
  51. package/dist/deepseek-transcript.d.ts.map +1 -1
  52. package/dist/deepseek-transcript.js +9 -7
  53. package/dist/deepseek-transcript.js.map +1 -1
  54. package/dist/docker-hosts.d.ts +8 -0
  55. package/dist/docker-hosts.d.ts.map +1 -1
  56. package/dist/docker-hosts.js +29 -0
  57. package/dist/docker-hosts.js.map +1 -1
  58. package/dist/git-fetch-scheduler.d.ts +124 -0
  59. package/dist/git-fetch-scheduler.d.ts.map +1 -0
  60. package/dist/git-fetch-scheduler.js +249 -0
  61. package/dist/git-fetch-scheduler.js.map +1 -0
  62. package/dist/git-fetch.d.ts +241 -0
  63. package/dist/git-fetch.d.ts.map +1 -0
  64. package/dist/git-fetch.js +392 -0
  65. package/dist/git-fetch.js.map +1 -0
  66. package/dist/git-workspace-status.d.ts +36 -1
  67. package/dist/git-workspace-status.d.ts.map +1 -1
  68. package/dist/git-workspace-status.js +42 -5
  69. package/dist/git-workspace-status.js.map +1 -1
  70. package/dist/hooks-config.d.ts +14 -2
  71. package/dist/hooks-config.d.ts.map +1 -1
  72. package/dist/hooks-config.js +39 -3
  73. package/dist/hooks-config.js.map +1 -1
  74. package/dist/local-history.d.ts +71 -0
  75. package/dist/local-history.d.ts.map +1 -0
  76. package/dist/local-history.js +750 -0
  77. package/dist/local-history.js.map +1 -0
  78. package/dist/mcp-server.d.ts.map +1 -1
  79. package/dist/mcp-server.js +2 -2
  80. package/dist/mcp-server.js.map +1 -1
  81. package/dist/mux-interface.d.ts +5 -2
  82. package/dist/mux-interface.d.ts.map +1 -1
  83. package/dist/prompt-cache-keepwarm.d.ts.map +1 -1
  84. package/dist/prompt-cache-keepwarm.js +5 -3
  85. package/dist/prompt-cache-keepwarm.js.map +1 -1
  86. package/dist/session.d.ts +113 -0
  87. package/dist/session.d.ts.map +1 -1
  88. package/dist/session.js +341 -13
  89. package/dist/session.js.map +1 -1
  90. package/dist/terminal-input-classifier.d.ts +8 -0
  91. package/dist/terminal-input-classifier.d.ts.map +1 -0
  92. package/dist/terminal-input-classifier.js +60 -0
  93. package/dist/terminal-input-classifier.js.map +1 -0
  94. package/dist/tmux-manager.d.ts +23 -1
  95. package/dist/tmux-manager.d.ts.map +1 -1
  96. package/dist/tmux-manager.js +54 -6
  97. package/dist/tmux-manager.js.map +1 -1
  98. package/dist/types/session.d.ts +15 -0
  99. package/dist/types/session.d.ts.map +1 -1
  100. package/dist/types/session.js.map +1 -1
  101. package/dist/web/network-auth-policy.d.ts +13 -3
  102. package/dist/web/network-auth-policy.d.ts.map +1 -1
  103. package/dist/web/network-auth-policy.js +15 -3
  104. package/dist/web/network-auth-policy.js.map +1 -1
  105. package/dist/web/ports/infra-port.d.ts +2 -0
  106. package/dist/web/ports/infra-port.d.ts.map +1 -1
  107. package/dist/web/public/admin-ui.js.gz +0 -0
  108. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  109. package/dist/web/public/app.3093527f.js +43 -0
  110. package/dist/web/public/app.3093527f.js.br +0 -0
  111. package/dist/web/public/app.3093527f.js.gz +0 -0
  112. package/dist/web/public/approvals-ui.js.gz +0 -0
  113. package/dist/web/public/{constants.0b3cdde9.js → constants.affcdde6.js} +611 -12
  114. package/dist/web/public/constants.affcdde6.js.br +0 -0
  115. package/dist/web/public/constants.affcdde6.js.gz +0 -0
  116. package/dist/web/public/cron-ui.js.gz +0 -0
  117. package/dist/web/public/entrance-animations.js.gz +0 -0
  118. package/dist/web/public/git-status-ui.js +88 -5
  119. package/dist/web/public/git-status-ui.js.br +0 -0
  120. package/dist/web/public/git-status-ui.js.gz +0 -0
  121. package/dist/web/public/header-divider.eb2d2463.js.gz +0 -0
  122. package/dist/web/public/home-sessions.js.gz +0 -0
  123. package/dist/web/public/host-wake-ui.js.gz +0 -0
  124. package/dist/web/public/i18n.70265257.js +2 -0
  125. package/dist/web/public/i18n.70265257.js.br +0 -0
  126. package/dist/web/public/i18n.70265257.js.gz +0 -0
  127. package/dist/web/public/image-input.941386e7.js.gz +0 -0
  128. package/dist/web/public/index.html +107 -16
  129. package/dist/web/public/index.html.br +0 -0
  130. package/dist/web/public/index.html.gz +0 -0
  131. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  132. package/dist/web/public/keyboard-accessory.4e8b20ac.js.gz +0 -0
  133. package/dist/web/public/{mobile-handlers.1c0cbcd8.js → mobile-handlers.f7c3f692.js} +22 -0
  134. package/dist/web/public/mobile-handlers.f7c3f692.js.br +0 -0
  135. package/dist/web/public/mobile-handlers.f7c3f692.js.gz +0 -0
  136. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  137. package/dist/web/public/mobile-overview.js.gz +0 -0
  138. package/dist/web/public/mobile.1bbd2d67.css +1 -0
  139. package/dist/web/public/mobile.1bbd2d67.css.br +0 -0
  140. package/dist/web/public/mobile.1bbd2d67.css.gz +0 -0
  141. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  142. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  143. package/dist/web/public/{panels-ui.78c3defb.js → panels-ui.4a56ee63.js} +1 -1
  144. package/dist/web/public/panels-ui.4a56ee63.js.br +0 -0
  145. package/dist/web/public/panels-ui.4a56ee63.js.gz +0 -0
  146. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  147. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  148. package/dist/web/public/readmymind-ui.js.gz +0 -0
  149. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  150. package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
  151. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  152. package/dist/web/public/session-lineage.js.gz +0 -0
  153. package/dist/web/public/{session-ui.ea6b1994.js → session-ui.186f9419.js} +8 -8
  154. package/dist/web/public/session-ui.186f9419.js.br +0 -0
  155. package/dist/web/public/session-ui.186f9419.js.gz +0 -0
  156. package/dist/web/public/{settings-ui.085dd202.js → settings-ui.a191154e.js} +11 -11
  157. package/dist/web/public/settings-ui.a191154e.js.br +0 -0
  158. package/dist/web/public/settings-ui.a191154e.js.gz +0 -0
  159. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  160. package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
  161. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  162. package/dist/web/public/styles.7238eae8.css +1 -0
  163. package/dist/web/public/styles.7238eae8.css.br +0 -0
  164. package/dist/web/public/styles.7238eae8.css.gz +0 -0
  165. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  166. package/dist/web/public/sw.js +2 -2
  167. package/dist/web/public/sw.js.br +0 -0
  168. package/dist/web/public/sw.js.gz +0 -0
  169. package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
  170. package/dist/web/public/{tab-rail-resize.7157e0bd.js → tab-rail-resize.b5cdceaf.js} +1 -1
  171. package/dist/web/public/tab-rail-resize.b5cdceaf.js.br +0 -0
  172. package/dist/web/public/tab-rail-resize.b5cdceaf.js.gz +0 -0
  173. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  174. package/dist/web/public/terminal-split.js.gz +0 -0
  175. package/dist/web/public/terminal-tile.js +2 -0
  176. package/dist/web/public/terminal-tile.js.br +0 -0
  177. package/dist/web/public/terminal-tile.js.gz +0 -0
  178. package/dist/web/public/terminal-ui.7e787829.js +4 -0
  179. package/dist/web/public/terminal-ui.7e787829.js.br +0 -0
  180. package/dist/web/public/terminal-ui.7e787829.js.gz +0 -0
  181. package/dist/web/public/tile-grid.js.gz +0 -0
  182. package/dist/web/public/ultracode-panel.js.gz +0 -0
  183. package/dist/web/public/ultracode-windows.js.gz +0 -0
  184. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  185. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  186. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  187. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  188. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  189. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  190. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  191. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  192. package/dist/web/public/vendor/{xterm-predictive-echo.ae5e3d5d.js → xterm-predictive-echo.276b11d9.js} +1 -1
  193. package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.br +0 -0
  194. package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.gz +0 -0
  195. package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js +5 -0
  196. package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.br +0 -0
  197. package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.gz +0 -0
  198. package/dist/web/public/vendor/xterm.css.gz +0 -0
  199. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  200. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  201. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  202. package/dist/web/public/webview-tabs.js.gz +0 -0
  203. package/dist/web/public/whats-new.json +9 -9
  204. package/dist/web/routes/agent-session-routes.d.ts +20 -0
  205. package/dist/web/routes/agent-session-routes.d.ts.map +1 -0
  206. package/dist/web/routes/agent-session-routes.js +160 -0
  207. package/dist/web/routes/agent-session-routes.js.map +1 -0
  208. package/dist/web/routes/case-routes.d.ts.map +1 -1
  209. package/dist/web/routes/case-routes.js +32 -26
  210. package/dist/web/routes/case-routes.js.map +1 -1
  211. package/dist/web/routes/config-backup-routes.js +2 -2
  212. package/dist/web/routes/config-backup-routes.js.map +1 -1
  213. package/dist/web/routes/git-status-routes.d.ts +21 -7
  214. package/dist/web/routes/git-status-routes.d.ts.map +1 -1
  215. package/dist/web/routes/git-status-routes.js +78 -12
  216. package/dist/web/routes/git-status-routes.js.map +1 -1
  217. package/dist/web/routes/index.d.ts +1 -0
  218. package/dist/web/routes/index.d.ts.map +1 -1
  219. package/dist/web/routes/index.js +1 -0
  220. package/dist/web/routes/index.js.map +1 -1
  221. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  222. package/dist/web/routes/ralph-routes.js +7 -16
  223. package/dist/web/routes/ralph-routes.js.map +1 -1
  224. package/dist/web/routes/session-routes.d.ts.map +1 -1
  225. package/dist/web/routes/session-routes.js +96 -28
  226. package/dist/web/routes/session-routes.js.map +1 -1
  227. package/dist/web/routes/system-routes.d.ts.map +1 -1
  228. package/dist/web/routes/system-routes.js +84 -57
  229. package/dist/web/routes/system-routes.js.map +1 -1
  230. package/dist/web/routes/ws-routes.d.ts +5 -0
  231. package/dist/web/routes/ws-routes.d.ts.map +1 -1
  232. package/dist/web/routes/ws-routes.js +84 -47
  233. package/dist/web/routes/ws-routes.js.map +1 -1
  234. package/dist/web/schemas.d.ts +30 -7
  235. package/dist/web/schemas.d.ts.map +1 -1
  236. package/dist/web/schemas.js +40 -1
  237. package/dist/web/schemas.js.map +1 -1
  238. package/dist/web/server.d.ts +5 -2
  239. package/dist/web/server.d.ts.map +1 -1
  240. package/dist/web/server.js +84 -14
  241. package/dist/web/server.js.map +1 -1
  242. package/dist/web/settings-writer.d.ts +47 -0
  243. package/dist/web/settings-writer.d.ts.map +1 -0
  244. package/dist/web/settings-writer.js +110 -0
  245. package/dist/web/settings-writer.js.map +1 -0
  246. package/package.json +1 -1
  247. package/skills/codeman/SKILL.md +25 -6
  248. package/skills/codeman/reference/history.md +84 -0
  249. package/skills/codeman/reference/inspection-and-staging.md +152 -0
  250. package/skills/codeman/reference/messaging.md +1 -1
  251. package/skills/codeman/reference/verbs.md +18 -3
  252. package/dist/web/public/app.42d0abae.js +0 -43
  253. package/dist/web/public/app.42d0abae.js.br +0 -0
  254. package/dist/web/public/app.42d0abae.js.gz +0 -0
  255. package/dist/web/public/constants.0b3cdde9.js.br +0 -0
  256. package/dist/web/public/constants.0b3cdde9.js.gz +0 -0
  257. package/dist/web/public/i18n.1e68220d.js +0 -2
  258. package/dist/web/public/i18n.1e68220d.js.br +0 -0
  259. package/dist/web/public/i18n.1e68220d.js.gz +0 -0
  260. package/dist/web/public/mobile-handlers.1c0cbcd8.js.br +0 -0
  261. package/dist/web/public/mobile-handlers.1c0cbcd8.js.gz +0 -0
  262. package/dist/web/public/mobile.12fb18de.css +0 -1
  263. package/dist/web/public/mobile.12fb18de.css.br +0 -0
  264. package/dist/web/public/mobile.12fb18de.css.gz +0 -0
  265. package/dist/web/public/panels-ui.78c3defb.js.br +0 -0
  266. package/dist/web/public/panels-ui.78c3defb.js.gz +0 -0
  267. package/dist/web/public/session-ui.ea6b1994.js.br +0 -0
  268. package/dist/web/public/session-ui.ea6b1994.js.gz +0 -0
  269. package/dist/web/public/settings-ui.085dd202.js.br +0 -0
  270. package/dist/web/public/settings-ui.085dd202.js.gz +0 -0
  271. package/dist/web/public/styles.3b59b41d.css +0 -1
  272. package/dist/web/public/styles.3b59b41d.css.br +0 -0
  273. package/dist/web/public/styles.3b59b41d.css.gz +0 -0
  274. package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
  275. package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
  276. package/dist/web/public/terminal-ui.fb2a27f5.js +0 -2
  277. package/dist/web/public/terminal-ui.fb2a27f5.js.br +0 -0
  278. package/dist/web/public/terminal-ui.fb2a27f5.js.gz +0 -0
  279. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.br +0 -0
  280. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  281. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +0 -4
  282. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
  283. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
@@ -2165,12 +2165,15 @@ const RELATIVE_FILE_PATH_LINK_PATTERN =
2165
2165
  const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
2166
2166
 
2167
2167
  /**
2168
- * Every linkable file path in `text`, in order: absolute (rooted) paths first,
2169
- * then `~/` and relative ones, then folder paths — each only where it overlaps
2170
- * neither an earlier one nor a URL.
2168
+ * Every linkable file path in `text`, sorted by position. Spaced candidates
2169
+ * reserve their whole span first, even when unlinkable; rooted, `~/`, relative
2170
+ * and folder detectors then skip reserved spans, earlier matches and URLs.
2171
2171
  *
2172
2172
  * The one entry point for both consumers (terminal link provider, response
2173
- * viewer), so a path form is clickable in both or in neither.
2173
+ * viewer). The viewer carries a leading bold label into the text it passes
2174
+ * (spacedFilePathOnLine judges it, as for the same line in the terminal) and
2175
+ * reads a text node that follows another inline element as mid-line; paths
2176
+ * split across inline elements are still detected per text node.
2174
2177
  *
2175
2178
  * @param {string} text
2176
2179
  * @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
@@ -2184,32 +2187,154 @@ function findFilePathLinks(text) {
2184
2187
  // `?img=b.png` belong to the URL link, not to a second, overlapping file link.
2185
2188
  // (`file:///home/x.png` is not one of them: its path is the file.)
2186
2189
  const urls = [];
2187
- const urlPattern = /\bhttps?:\/\/\S+/gi;
2190
+ const webUrlPattern = /\bhttps?:\/\/\S+/gi;
2188
2191
  let match;
2189
- while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
2192
+ while ((match = webUrlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
2190
2193
  const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
2191
2194
 
2195
+ // A path with spaces is taken whole, or the span it covers links nothing
2196
+ // (spacedFilePathOnLine). Either way no detector below may cut a fragment out of it.
2197
+ const reserved = [];
2198
+ let lineStart = 0;
2199
+ for (const line of value.split('\n')) {
2200
+ const spaced = spacedFilePathOnLine(line);
2201
+ if (spaced && !inUrl(lineStart + spaced.index, spaced.path.length)) {
2202
+ const index = lineStart + spaced.index;
2203
+ reserved.push({ index, path: spaced.path });
2204
+ if (spaced.link) found.push({ path: spaced.path, index });
2205
+ }
2206
+ lineStart += line.length + 1;
2207
+ }
2208
+ const isReserved = (index, length) =>
2209
+ reserved.some((r) => index < r.index + r.path.length && r.index < index + length);
2210
+
2192
2211
  const absolute = absoluteFilePathPattern();
2193
2212
  while ((match = absolute.exec(value)) !== null) {
2194
- if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
2213
+ if (!inUrl(match.index, match[1].length) && !isReserved(match.index, match[1].length)) {
2214
+ found.push({ path: match[1], index: match.index });
2215
+ }
2195
2216
  }
2196
2217
  const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
2197
2218
  while ((match = relative.exec(value)) !== null) {
2198
2219
  const path = match[2];
2199
2220
  const index = match.index + match[1].length;
2200
- if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
2221
+ if (overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue;
2201
2222
  found.push({ path, index });
2202
2223
  }
2203
2224
  const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
2204
2225
  while ((match = folders.exec(value)) !== null) {
2205
2226
  const path = folderLinkPath(match[2]);
2206
2227
  const index = match.index + match[1].length;
2207
- if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
2228
+ if (!path || overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue;
2208
2229
  found.push({ path, index, folder: true });
2209
2230
  }
2210
2231
  return found.sort((a, b) => a.index - b.index);
2211
2232
  }
2212
2233
 
2234
+ /** Labels that explicitly identify a path, for {@link spacedFilePathOnLine}. */
2235
+ const SPACED_FILE_PATH_LABEL_PATTERN =
2236
+ /^(?:path|files?|saved (?:to|as)|wrote|written to|output|created|location|attachment):[ \t]*$/i;
2237
+
2238
+ /**
2239
+ * A file path WITH SPACES on one line, or null.
2240
+ *
2241
+ * Every path pattern ends a path at whitespace, so `/mnt/c/OneDrive - Contoso/a.docx`
2242
+ * linked `/mnt/c/OneDrive` plus a relative `Contoso/a.docx`, two links to the
2243
+ * wrong targets. A space is only safe inside a path where the line itself bounds
2244
+ * the path, so a spaced path is recognised in exactly these shapes:
2245
+ *
2246
+ * /mnt/c/My Docs/report.docx the whole line is the path
2247
+ * - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`)
2248
+ * Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a path label (SPACED_FILE_PATH_LABEL_PATTERN)
2249
+ *
2250
+ * An absolute path may stand alone or follow a marker. Any label, before an
2251
+ * absolute, relative or `~/` path, must be an explicit path label
2252
+ * (SPACED_FILE_PATH_LABEL_PATTERN), with an optional marker before it: generic
2253
+ * labels (`Error:`, `Note:`) and bare bullets also introduce prose, so a relative
2254
+ * path needs a path label to be taken spaced. One trailing sentence mark is left
2255
+ * out, and a leading run of `./` or `../` is not a segment. The candidate must read as ONE path:
2256
+ * it ends on a known extension, a rooted one has a directory before its first
2257
+ * space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`,
2258
+ * and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new
2259
+ * `/`, `~/`, `./` or `../` path, no earlier word ends in `:`, is an arrow or has
2260
+ * no letter or digit (a prompt glyph such as `❯` or `$`; a bare `-` is kept), and
2261
+ * a run of spaces (a column gap) is refused, so two paths on a line are never
2262
+ * merged. A relative candidate whose only `/` word is its last is prose after the
2263
+ * label (`Wrote: 120 lines to src/foo.ts`) and is left alone, which also splits
2264
+ * `Saved to: My Docs/plan.md` as master does. An earlier word that is a lowercase
2265
+ * connective or verb (SPACED_PATH_PROSE_WORD: `and`, `in`, `has`, `contains`, ...)
2266
+ * or a bare `=` makes the candidate a sentence (`/tmp/out has the logs in run.log`),
2267
+ * so a name such as `Pros and Cons.md` splits as master does. A command line is
2268
+ * refused: an executable under `bin/`, `.bin/`, `scripts/` or `tools/` as the
2269
+ * first word, any earlier word ending in `$` (a shell prompt, `/home/me/proj$ cat a.md`,
2270
+ * `/mnt/c/My Docs$ ls a.md`), or any `-x`/`--x` flag word.
2271
+ *
2272
+ * A candidate that is one path but not a linkable one (an unknown root such as
2273
+ * `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last
2274
+ * word still holds a `/`: its span links nothing, rather than the fragment after
2275
+ * its last space. Otherwise (`/usr/bin/env node build.js`) the line is left to
2276
+ * the ordinary detectors.
2277
+ *
2278
+ * Linear: one bounded prefix match, then string scans.
2279
+ *
2280
+ * @param {string} line One line, no newline.
2281
+ * @returns {{path: string, index: number, link: boolean} | null}
2282
+ */
2283
+ function spacedFilePathOnLine(line) {
2284
+ const text = String(line || '');
2285
+ const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text);
2286
+ const index = prefix[0].length;
2287
+ const path = text.slice(index).trimEnd().replace(/[.,;:!?]$/, '');
2288
+ if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null;
2289
+ // After a label, only a path label introduces a path; with no label, only an
2290
+ // absolute path may stand alone. `Error: /a/b contains c.md` is prose.
2291
+ if (prefix[2] ? !SPACED_FILE_PATH_LABEL_PATTERN.test(prefix[2]) : !path.startsWith('/')) return null;
2292
+ if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null;
2293
+ // A run of spaces is a column gap (`git status`, `ls -l`), not a name.
2294
+ if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://') || path.includes(' ')) return null;
2295
+ const words = path.split(' ');
2296
+ // A rooted candidate must have a directory before its first space: `/usr/bin/env node a.js`
2297
+ // or `/review the changes in src/a.ts` is a command, not a spaced path.
2298
+ if (path.startsWith('/') && !/^\/[^/]+\//.test(words[0])) return null;
2299
+ // A command whose executable sits under a known root (`/opt/homebrew/bin/node a.js`,
2300
+ // `/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`, `/home/me/proj/scripts/deploy prod.yml`)
2301
+ // is not a path either.
2302
+ if (/\/(?:\.?bin|scripts?|tools)\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) {
2303
+ return null;
2304
+ }
2305
+ for (const word of words.slice(0, -1)) {
2306
+ if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null;
2307
+ // A shell prompt that prints its working directory in full (`/home/me/proj$ cat
2308
+ // notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too, and so is
2309
+ // one whose directory has a space in it (`/mnt/c/My Docs$ ls notes.md`): the `$`
2310
+ // can end any earlier word, not only the first.
2311
+ if (word.endsWith('$')) return null;
2312
+ // A word with no letter or digit (a prompt glyph `❯` or `$`, a `✓`) is never
2313
+ // part of a file name, apart from a bare `-` (`OneDrive - Contoso`).
2314
+ if (word !== '-' && !/[\p{L}\p{N}]/u.test(word)) return null;
2315
+ // `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix.
2316
+ if (/:\d/.test(word)) return null;
2317
+ // An extension anywhere in an earlier word (`a.md,`, `a.ts#L12`) ends a path there.
2318
+ if (PATH_TOKEN_HAS_EXTENSION.test(word)) return null;
2319
+ // A connective or verb joins a path to prose (`/tmp/out has the logs in run.log`,
2320
+ // `Files: src/components and utils/x.ts`), so the candidate is a sentence.
2321
+ if (SPACED_PATH_PROSE_WORD.test(word)) return null;
2322
+ }
2323
+ if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null;
2324
+ const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/');
2325
+ const segments = path.replace(/^(?:~?\/|(?:\.\.?\/)+)/, '').split('/');
2326
+ const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..');
2327
+ // A path label vouches that a path follows, not for the words before it: when
2328
+ // only the last word holds a `/` (`Wrote: 120 lines to src/foo.ts`), the spaces
2329
+ // are prose, so a relative candidate is left to the ordinary detectors.
2330
+ const firstSlash = words.findIndex((word) => word.includes('/'));
2331
+ if (link && firstSlash === words.length - 1 && !path.startsWith('/')) return null;
2332
+ // Reserve an unlinkable span only when it still reads as a path to its end;
2333
+ // otherwise leave the line to the ordinary detectors.
2334
+ if (!link && !words[words.length - 1].includes('/')) return null;
2335
+ return { path, index, link };
2336
+ }
2337
+
2213
2338
  /**
2214
2339
  * Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
2215
2340
  * (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
@@ -2344,8 +2469,16 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
2344
2469
  const continuesPrevious = (r) => {
2345
2470
  if (r <= 0) return false;
2346
2471
  if (lineAt(r)?.isWrapped) return true;
2472
+ // A divider row never continues the row above, even when that row reaches
2473
+ // the right edge: a URL ending at the edge must not absorb `────` below it.
2474
+ if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(lineAt(r)?.translateToString(true) || '')) return false;
2347
2475
  const prev = lineAt(r - 1);
2348
- return !!prev && (prev.translateToString(true) || '').length >= width;
2476
+ const prevText = prev?.translateToString(true) || '';
2477
+ // A full-width table divider is a row boundary, not a hard-wrapped line.
2478
+ // Treating it as a continuation glues the divider onto the row below and
2479
+ // hides that row's wrapped URL from the link provider.
2480
+ if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(prevText)) return false;
2481
+ return prevText.length >= width;
2349
2482
  };
2350
2483
 
2351
2484
  let startRow = row;
@@ -2396,6 +2529,16 @@ const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTER
2396
2529
  const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
2397
2530
  const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
2398
2531
  const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
2532
+ /**
2533
+ * Lowercase connectives and verbs that put a spaced candidate in a sentence, for
2534
+ * {@link spacedFilePathOnLine}. `at` stays out (`Screenshot 2026-10-24 at 10.00.00.png`)
2535
+ * and so does `of` (`Copy of report.docx`); a name using a listed word
2536
+ * (`Pros and Cons.md`) splits as it did before spaced paths were linked.
2537
+ */
2538
+ const SPACED_PATH_PROSE_WORD =
2539
+ /^(?:and|or|to|in|into|from|the|is|are|was|were|has|have|had|contains|holds|see|then|vs|via|as)$/;
2540
+ /** A rooted path's start (`/home/`, `/mnt/`, …), for {@link spacedFilePathOnLine}. */
2541
+ const SPACED_PATH_ROOT = new RegExp(`^\\/(?:${FILE_LINK_ROOT_GROUP})\\/`);
2399
2542
  /** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
2400
2543
  const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
2401
2544
  /** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
@@ -2519,6 +2662,395 @@ function terminalPathLine(buffer, row, cols, maxRows) {
2519
2662
  return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
2520
2663
  }
2521
2664
 
2665
+ /**
2666
+ * Web URLs in terminal text, with the trimming the link provider applies.
2667
+ *
2668
+ * A single `&` is part of the URL (query separator); `&&` is the shell operator
2669
+ * and ends it. `)` is kept INSIDE a URL when it closes a `(` that is also inside
2670
+ * it (`…/Team-Space(QA)Archive/…` used to link only as far as `(QA`), and an
2671
+ * unmatched `)` ends the URL, which keeps the closing paren of `(https://…)` and
2672
+ * `[text](https://…)` out of the link. Trailing sentence punctuation is trimmed.
2673
+ *
2674
+ * @param {string} text
2675
+ * @returns {Array<{url: string, startIndex: number, endIndex: number, rawEnd: number}>}
2676
+ * `endIndex` is exclusive; `rawEnd` is where the match ran to before the
2677
+ * paren/punctuation trim (the wrap-continuation test needs it).
2678
+ */
2679
+ function findTerminalUrls(text) {
2680
+ // Named `urlPattern` on purpose: test/link-provider-regex.test.ts extracts it
2681
+ // by name to prove it stays linear-time on hostile lines.
2682
+ const urlPattern = /https?:\/\/(?:[^\s"'<>|;&\]\x00-\x1f]|&(?!&))+/g;
2683
+ const urls = [];
2684
+ let match;
2685
+ while ((match = urlPattern.exec(String(text || ''))) !== null) {
2686
+ const raw = match[0];
2687
+ let end = raw.length;
2688
+ let depth = 0;
2689
+ for (let i = 0; i < raw.length; i++) {
2690
+ if (raw[i] === '(') depth++;
2691
+ else if (raw[i] === ')') {
2692
+ if (depth === 0) {
2693
+ end = i;
2694
+ break;
2695
+ }
2696
+ depth--;
2697
+ }
2698
+ }
2699
+ const url = raw.slice(0, end).replace(/[.,;:!?&]+$/, '');
2700
+ if (!/:\/\/[^/]/.test(url)) continue;
2701
+ urls.push({ url, startIndex: match.index, endIndex: match.index + url.length, rawEnd: match.index + raw.length });
2702
+ }
2703
+ return urls;
2704
+ }
2705
+
2706
+ /**
2707
+ * The row span a URL covers when the CLI wrapped it with its OWN newlines at a
2708
+ * point short of the terminal width.
2709
+ *
2710
+ * {@link terminalLogicalLine} joins soft wraps and full-width hard wraps. A CLI
2711
+ * that word-wraps inside a table cell or a paragraph (codex's markdown tables,
2712
+ * for one) breaks a long URL at `/`, `?`, `-` … well before the right edge and
2713
+ * indents the rest to the cell's column, so neither rule fires and each row
2714
+ * linked a fragment. A row continues the URL above it only when ALL hold:
2715
+ * - the URL fragment ends on a character a wrapper breaks after
2716
+ * (`/ ? & = - _ % # + ~`); a following table column may be excluded;
2717
+ * - the next row is indented, but no further right than the column the URL
2718
+ * started in (a table cell or list item continuation, not unrelated text),
2719
+ * and no column gap lies between its indent and the URL on the first row
2720
+ * (that would be the next record of a table);
2721
+ * - that row's first token looks like URL material (contains one of
2722
+ * `% & = / ? . - _`), is not a bare number/version or list marker,
2723
+ * and is not itself a new `scheme://`, a shell prompt (`user@host:`),
2724
+ * a home path (`~/`), an email address, an HTTP status line, a label
2725
+ * ending in `:`, a measurement or clock time (`45%`, `12.3ms`,
2726
+ * `10:00:00`), or a `name=value` field under a URL ending in `/`;
2727
+ * without `% & = / ?`, the token must finish its cell (row end or a
2728
+ * gap of at least two spaces) and must not be code with a call paren
2729
+ * or a word ending a sentence (`Done.`).
2730
+ * The last rule is what stops prose such as "…/files/" + "then run it" from
2731
+ * being glued into one link.
2732
+ *
2733
+ * Bounded by `maxRows`, like the logical line. Pure: reads rows only.
2734
+ *
2735
+ * @param {{getLine: (row: number) => any, length: number}} buffer
2736
+ * @param {number} row 0-based absolute row being examined.
2737
+ * @param {number} cols Terminal width.
2738
+ * @param {number} [maxRows]
2739
+ * @returns {{startRow: number, endRow: number, firstRowUrlEnd?: number} | null}
2740
+ * `firstRowUrlEnd` is where the URL's cell ends on the first row when
2741
+ * other table columns follow it and that row does not soft-wrap; null
2742
+ * means the row is not part of a URL continued this way.
2743
+ */
2744
+ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) {
2745
+ const bound = Math.max(1, maxRows || 12);
2746
+ const here = terminalLogicalLine(buffer, row, cols, bound);
2747
+ if (!here) return null;
2748
+
2749
+ const chainFrom = (start) => {
2750
+ const first = terminalLogicalLine(buffer, start, cols, bound);
2751
+ if (!first || first.startRow !== start) return null;
2752
+ let text = first.text.replace(/\s+$/, '');
2753
+ let endRow = first.endRow;
2754
+ let urlStartCol = -1;
2755
+ let firstRowUrlEnd = null;
2756
+ let extended = false;
2757
+ while (endRow - start + 1 < bound) {
2758
+ const urls = findTerminalUrls(text);
2759
+ const last = urls[urls.length - 1];
2760
+ // Only on a one-row first line: firstRowUrlEnd cuts the first PHYSICAL
2761
+ // row (terminalLinkLine), so a column sitting on the soft-wrapped part of
2762
+ // the URL's row would stay in the text and take the next row with it.
2763
+ const trailingColumns =
2764
+ first.startRow === first.endRow &&
2765
+ endRow === first.endRow &&
2766
+ last &&
2767
+ last.rawEnd < text.length &&
2768
+ /^ {2,}\S/.test(text.slice(last.rawEnd));
2769
+ const urlText = trailingColumns ? text.slice(0, last.rawEnd) : text;
2770
+ if (!last || last.rawEnd !== urlText.length || !/[/?&=\-_%#+~]$/.test(urlText)) break;
2771
+ if (urlStartCol < 0) urlStartCol = first.offsetToCell(last.startIndex).col;
2772
+ const next = buffer.getLine(endRow + 1);
2773
+ if (!next || next.isWrapped) break;
2774
+ const nextText = next.translateToString(true) || '';
2775
+ const indent = nextText.length - nextText.replace(/^\s+/, '').length;
2776
+ const token = nextText.slice(indent).split(/\s/, 1)[0];
2777
+ // A hard continuation is indented (a table cell, a list item, Claude's
2778
+ // two-space paragraph indent); a column-0 row is a new line of output.
2779
+ if (!token || !indent || indent > urlStartCol || /^(?:\d+[.)]|[-*•])(?:\s|$)/.test(nextText.slice(indent)))
2780
+ break;
2781
+ // When other table columns follow the URL, the next row must align with
2782
+ // the start of its cell. A new numbered row must not become URL text.
2783
+ if (
2784
+ trailingColumns &&
2785
+ (!indent ||
2786
+ first.text[indent - 1] !== ' ' ||
2787
+ first.text[indent] === ' ' ||
2788
+ / {2,}/.test(first.text.slice(indent, last.startIndex)))
2789
+ )
2790
+ break;
2791
+ // The next row must start inside the URL's own cell: a column gap between
2792
+ // its indent and the URL on the first row means it opens an earlier
2793
+ // column (the next record of a table), not the rest of the URL.
2794
+ if (/ {2,}/.test(first.text.slice(indent, last.startIndex).trimStart())) break;
2795
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break;
2796
+ // A shell prompt (`user@host:~/dir$`), a home path and an HTTP status line
2797
+ // are never the rest of a URL, however much URL punctuation they carry.
2798
+ if (/^[\w.-]+@[\w.-]+:|^~\/|^HTTP\/\d/i.test(token)) break;
2799
+ // Nor is a token that is a whole email address.
2800
+ if (/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(token)) break;
2801
+ if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break;
2802
+ // A label ends with a colon (`Failure/Error:`, `prometheus.io/scrape:`);
2803
+ // the rest of a URL does not.
2804
+ if (token.endsWith(':')) break;
2805
+ // After a `/`, a `name=value` token is the next assignment or log field
2806
+ // (`API_KEY=...`, `level=info`), not the URL: a query starts with `?`.
2807
+ if (urlText.endsWith('/') && /^[A-Za-z_][\w.-]*=/.test(token)) break;
2808
+ // Nor is a measurement or a clock time: `45%` and `45%|####` progress,
2809
+ // `12.3MB/45.0MB`, `12.3ms`, `10:00:00` (`%7B` stays percent-encoding).
2810
+ if (/^\d+(?:\.\d+)?(?:%(?![0-9A-Fa-f]{2})|(?:ms|s|[kKMGT]i?B)(?![\w-]))|^\d{1,2}:\d{2}/.test(token)) break;
2811
+ // Hyphens, underscores and dots also occur in prose. Without stronger
2812
+ // URL punctuation, the token must finish its cell, not introduce a sentence.
2813
+ const rest = nextText.slice(indent + token.length);
2814
+ if (!/[%&=/?]/.test(token)) {
2815
+ // Code (`client.get()`, `expect(x).toBe(y)`) is not a URL piece, and a
2816
+ // word that ends a sentence (`ready.`, `Done.`) is prose. A wiki title
2817
+ // (`Mercury_(planet)`) and a file name before its period
2818
+ // (`install.html.`) still continue the URL.
2819
+ if (/[^_]\(/.test(token)) break;
2820
+ if (/[.,;!]$/.test(token) && !/\.[A-Za-z][A-Za-z0-9]{1,4}[.,;!]+$/.test(token)) break;
2821
+ if (rest.trim() !== '' && !/^ {2,}/.test(rest)) break;
2822
+ }
2823
+ const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound);
2824
+ if (!nextLogical) break;
2825
+ if (trailingColumns) firstRowUrlEnd = last.rawEnd;
2826
+ text = urlText + nextLogical.text.slice(indent).replace(/\s+$/, '');
2827
+ endRow = nextLogical.endRow;
2828
+ extended = true;
2829
+ }
2830
+ return { startRow: start, endRow, extended, firstRowUrlEnd };
2831
+ };
2832
+
2833
+ // Walk up through earlier logical lines: the nearest one whose URL chain
2834
+ // reaches this row owns it.
2835
+ let start = here.startRow;
2836
+ while (start >= 0 && here.startRow - start < bound) {
2837
+ const chain = chainFrom(start);
2838
+ if (chain && chain.extended && chain.endRow >= here.startRow) {
2839
+ return {
2840
+ startRow: chain.startRow,
2841
+ endRow: chain.endRow,
2842
+ ...(chain.firstRowUrlEnd === null ? {} : { firstRowUrlEnd: chain.firstRowUrlEnd }),
2843
+ };
2844
+ }
2845
+ if (start === 0) break;
2846
+ const above = terminalLogicalLine(buffer, start - 1, cols, bound);
2847
+ if (!above) break;
2848
+ start = above.startRow;
2849
+ }
2850
+ return null;
2851
+ }
2852
+
2853
+ /**
2854
+ * The text the link provider scans for `row`: {@link terminalPathLine}, widened
2855
+ * to the rows of a URL the CLI wrapped short of the edge
2856
+ * ({@link terminalUrlContinuationExtent}). Same shape as terminalPathLine.
2857
+ *
2858
+ * Over a widened span every row is read trimmed (except one a soft wrap
2859
+ * continues from, which fills the width), a HARD continuation's indent is
2860
+ * dropped (the cell mapping keeps the cells), and the first row stops at the
2861
+ * URL's cell when other table columns follow it, so those columns never sit
2862
+ * between the URL's pieces. Those columns still link: they are moved after the
2863
+ * stitched rows as a segment of their own, mapped back to their cells.
2864
+ *
2865
+ * @returns Same shape as {@link terminalPathLine}; null when the row does not exist.
2866
+ */
2867
+ function terminalLinkLine(buffer, row, cols, maxRows) {
2868
+ const base = terminalPathLine(buffer, row, cols, maxRows);
2869
+ if (!base) return null;
2870
+ const extent = terminalUrlContinuationExtent(buffer, row, cols, maxRows);
2871
+ if (!extent || (extent.startRow >= base.startRow && extent.endRow <= base.endRow)) return base;
2872
+ const startRow = Math.min(extent.startRow, base.startRow);
2873
+ const endRow = Math.max(extent.endRow, base.endRow);
2874
+ const segments = [];
2875
+ let text = '';
2876
+ let columnsAfterUrl = '';
2877
+ for (let r = startRow; r <= endRow; r++) {
2878
+ const line = buffer.getLine(r);
2879
+ if (!line) break;
2880
+ const next = r < endRow ? buffer.getLine(r + 1) : null;
2881
+ let rowText = line.translateToString(!(next && next.isWrapped)) || '';
2882
+ if (r === extent.startRow && extent.firstRowUrlEnd != null) {
2883
+ columnsAfterUrl = rowText.slice(extent.firstRowUrlEnd);
2884
+ rowText = rowText.slice(0, extent.firstRowUrlEnd);
2885
+ }
2886
+ let colStart = 0;
2887
+ if (r > startRow && !line.isWrapped) {
2888
+ colStart = rowText.length - rowText.replace(/^\s+/, '').length;
2889
+ rowText = rowText.slice(colStart);
2890
+ }
2891
+ segments.push({ row: r, textStart: text.length, colStart });
2892
+ text += rowText;
2893
+ }
2894
+ const lastRow = segments[segments.length - 1].row;
2895
+ // The columns cut off the URL's first row (a path or date column of a table)
2896
+ // are scanned after the stitched rows, so they keep their links on that row.
2897
+ // They open with the cell gap, which keeps them apart from the URL; the one
2898
+ // space before them belongs to the last row, so an end offset there still
2899
+ // maps onto that row and never onto their first cell.
2900
+ if (columnsAfterUrl.trim()) {
2901
+ text += ' ';
2902
+ segments.push({ row: extent.startRow, textStart: text.length, colStart: extent.firstRowUrlEnd });
2903
+ text += columnsAfterUrl;
2904
+ }
2905
+ const offsetToCell = (offset) => {
2906
+ for (let i = segments.length - 1; i >= 0; i--) {
2907
+ const seg = segments[i];
2908
+ if (offset >= seg.textStart || i === 0) return { row: seg.row, col: seg.colStart + (offset - seg.textStart) };
2909
+ }
2910
+ return { row: startRow, col: offset };
2911
+ };
2912
+ // The cut row has two segments: the one starting at or left of the cell owns it.
2913
+ const cellToOffset = (targetRow, targetCol) => {
2914
+ let seg = null;
2915
+ for (const s of segments) if (s.row === targetRow && (!seg || s.colStart <= targetCol)) seg = s;
2916
+ return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1;
2917
+ };
2918
+ return { startRow, endRow: lastRow, text, offsetToCell, cellToOffset };
2919
+ }
2920
+
2921
+ /**
2922
+ * Absolute file paths the CLI word-wrapped INSIDE a table cell (codex markdown
2923
+ * tables), returned as one link per path with a segment per row.
2924
+ *
2925
+ * Before screenshot (/tmp/x/docs/review/ After screenshot (/tmp/x/review/BLDMESH-
2926
+ * BLDMESH-243/before-overview.png) 243/after-overview.png)
2927
+ *
2928
+ * Neither fragment matches FILE_PATH_LINK_PATTERN alone (the first has no
2929
+ * extension, the second no absolute root), and a linear stitch of the rows
2930
+ * cannot serve a two-column table: each column's continuation sits in the same
2931
+ * next row. So each fragment is followed DOWN ITS OWN CELL: the cell's left
2932
+ * edge is where its text starts after the previous ≥2-space column gap, and the
2933
+ * continuation is the token starting at exactly that column on the next row.
2934
+ * A fragment qualifies only when it is the last thing in its cell on that row
2935
+ * (row end or a ≥2-space gap follows) and ends on `/` or `-`, the only places
2936
+ * a wrapper breaks a path; a cell that ends inside a name (`/home/u/proj` over
2937
+ * `config.yaml`) is a whole value, never the first piece of the next row's. A
2938
+ * continuation row must be blank to the left of the cell (a wrapper wraps every
2939
+ * column of one table row together), unless a path in a left column continued
2940
+ * on it too: a label there (`Entry`, `│ Log │`) is the next record. Every
2941
+ * continuation must pass pathContinuation and its cell must be no wider than the
2942
+ * preceding cell's text, so independent listing rows and longer prose never
2943
+ * displace links.
2944
+ *
2945
+ * Pure: reads rows only. Cells are taken one per string character (xterm rows
2946
+ * with wide glyphs would shift columns; such rows simply fail the alignment).
2947
+ *
2948
+ * @param {{getLine: (row: number) => any, length: number}} buffer
2949
+ * @param {number} row 0-based absolute row being examined.
2950
+ * @param {number} [maxRows] Bound on rows one path may span.
2951
+ * @returns {Array<{path: string, segments: Array<{row: number, startCol: number, endCol: number}>}>}
2952
+ * Paths with a segment on `row`; `endCol` is exclusive, columns 0-based.
2953
+ */
2954
+ function terminalCellWrappedPaths(buffer, row, maxRows) {
2955
+ const bound = Math.max(2, maxRows || 4);
2956
+ const rootPattern = new RegExp(`\\/(?:${FILE_LINK_ROOT_GROUP})\\/`, 'g');
2957
+ // Box-drawing verticals are cell borders (Claude Code draws tables with them).
2958
+ const isPathChar = (ch) => !!ch && !/[\s"'<>|;&\x00-\x1f│┃║]/.test(ch);
2959
+ const rowText = (r) => {
2960
+ const line = buffer.getLine(r);
2961
+ return line ? (line.translateToString(true) || '').replace(/\s+$/, '') : null;
2962
+ };
2963
+ // A fragment is the last text in its cell on this row when the row ends, a
2964
+ // column gap (≥2 spaces) follows it, or a cell border does.
2965
+ const endsCell = (text, end) => end >= text.length || /^(?: {2,}| *[│┃║|])/.test(text.slice(end));
2966
+
2967
+ const fromStart = (startRow) => {
2968
+ const found = [];
2969
+ const text = rowText(startRow);
2970
+ if (!text) return found;
2971
+ rootPattern.lastIndex = 0;
2972
+ let m;
2973
+ while ((m = rootPattern.exec(text)) !== null) {
2974
+ const start = m.index;
2975
+ if (start > 0 && !/[\s(\[{"'`<]/.test(text[start - 1])) continue;
2976
+ let end = start;
2977
+ while (end < text.length && isPathChar(text[end])) end++;
2978
+ rootPattern.lastIndex = Math.max(end, start + 1);
2979
+ if (!endsCell(text, end)) continue;
2980
+ // Already a complete path on its own row: the ordinary detector owns it.
2981
+ const alone = absoluteFilePathPattern().exec(text.slice(start, end));
2982
+ if (alone && alone.index === 0 && alone[0].length === end - start) continue;
2983
+ // The cell's left edge: just past the nearest ≥2-space gap or cell border
2984
+ // (and the padding after it) before the path.
2985
+ const before = text.slice(0, start);
2986
+ let cellCol = before.length - before.replace(/^\s+/, '').length;
2987
+ const edge = /(?: {2,}|[│┃║|] *)(?=\S)/g;
2988
+ let e;
2989
+ const upToPath = text.slice(0, start + 1);
2990
+ while ((e = edge.exec(upToPath)) !== null) cellCol = e.index + e[0].length;
2991
+
2992
+ let joined = text.slice(start, end);
2993
+ let previousCellLength = end - cellCol;
2994
+ const segments = [{ row: startRow, startCol: start, endCol: end }];
2995
+ for (let r = startRow + 1; r < startRow + bound; r++) {
2996
+ const line = buffer.getLine(r);
2997
+ if (!line || line.isWrapped) break;
2998
+ const next = rowText(r);
2999
+ if (!next || !isPathChar(next[cellCol]) || (cellCol > 0 && next[cellCol - 1] !== ' ')) break;
3000
+ let tokenEnd = cellCol;
3001
+ while (tokenEnd < next.length && isPathChar(next[tokenEnd])) tokenEnd++;
3002
+ const token = next.slice(cellCol, tokenEnd);
3003
+ // Include prose after the token in this cell, but not another column.
3004
+ const cellText = next.slice(cellCol).split(/ {2,}|[│┃║|]/, 1)[0].trimEnd();
3005
+ // A wrapper breaks a path after `/` or `-`; a fragment that ends inside a
3006
+ // name (`/home/u/proj` over `config.yaml`) is a whole value, not a piece.
3007
+ // codex wraps table cells on Unicode line-break opportunities plus a
3008
+ // hyphen splitter, which break after `/` and `-` but never at `_` (UAX #14
3009
+ // class AL).
3010
+ if (!/[/-]$/.test(joined)) break;
3011
+ // A wrapper wraps every column of one table row together, so a row that
3012
+ // carries a cell's continuation is blank to its left unless a path in a
3013
+ // left column continued on that row too. A label there (`Entry`,
3014
+ // `│ Log │`) opens the next record, even under a directory ending in `/`
3015
+ // (`Workspace /home/u/proj/app/` over `Entry main.py`). A left column of
3016
+ // wrapped prose (a `Description | Path` table) fills that row as well, so
3017
+ // the path beside it is not joined and keeps its per-row fragment links:
3018
+ // the safe direction.
3019
+ // Known trade-off: with nothing to the left, a directory written with its
3020
+ // trailing slash over a relative path in the same column (`/home/u/proj/`
3021
+ // over `index.ts`) still joins, since it reads exactly like a wrap at `/`.
3022
+ if (
3023
+ /[^\s│┃║|]/.test(next.slice(0, cellCol)) &&
3024
+ !found.some((hit) => hit.segments.some((seg) => seg.row === r && seg.endCol <= cellCol))
3025
+ ) {
3026
+ break;
3027
+ }
3028
+ if (cellText.length > previousCellLength || !pathContinuation(joined, token)) break;
3029
+ const candidate = joined + token;
3030
+ const match = absoluteFilePathPattern().exec(candidate);
3031
+ if (match && match.index === 0 && match[0].length > joined.length) {
3032
+ segments.push({ row: r, startCol: cellCol, endCol: cellCol + match[0].length - joined.length });
3033
+ found.push({ path: match[0], segments });
3034
+ break;
3035
+ }
3036
+ if (!endsCell(next, tokenEnd)) break;
3037
+ joined = candidate;
3038
+ previousCellLength = cellText.length;
3039
+ segments.push({ row: r, startCol: cellCol, endCol: tokenEnd });
3040
+ }
3041
+ }
3042
+ return found;
3043
+ };
3044
+
3045
+ const results = [];
3046
+ for (let s = row; s >= 0 && row - s < bound; s--) {
3047
+ for (const hit of fromStart(s)) {
3048
+ if (hit.segments.some((seg) => seg.row === row)) results.push(hit);
3049
+ }
3050
+ }
3051
+ return results;
3052
+ }
3053
+
2522
3054
  // ═══════════════════════════════════════════════════════════════
2523
3055
  // Split-Pane Sessions — pure helpers (divider math, picker list)
2524
3056
  // ═══════════════════════════════════════════════════════════════
@@ -3349,7 +3881,9 @@ function clampTerminalDimensions(proposed) {
3349
3881
  * could reach it. Output visible, typing invisible, for as long as the claim
3350
3882
  * stayed hot. Width is the axis the wrap arithmetic depends on; rows only
3351
3883
  * decide how much is on screen at once, and keeping the local row count keeps
3352
- * the composer at the bottom of a viewport that scrolls.
3884
+ * the composer at the bottom of a viewport that scrolls. The one deliberate
3885
+ * screen taller than its box is this device's own rows held under the
3886
+ * on-screen keyboard, which keyboardClipPx keeps the composer in view for.
3353
3887
  *
3354
3888
  * @param {{cols: number, rows: number}|null} local - what xterm currently holds
3355
3889
  * @param {{cols: number, rows: number}|null} pty - what the server just reported
@@ -3361,6 +3895,62 @@ function reconcilePtyGeometry(local, pty) {
3361
3895
  return { adopt: true, cols: pty.cols };
3362
3896
  }
3363
3897
 
3898
+ /** Rows below the last content within which the cursor still counts (keyboardClipPx). */
3899
+ const KEYBOARD_CLIP_CURSOR_REACH = 2;
3900
+
3901
+ /**
3902
+ * How far to scroll the terminal SCREEN up while its rows are held under the
3903
+ * on-screen keyboard (setting `keyboardHoldsRows`).
3904
+ *
3905
+ * Every PTY height change makes Claude Code clear the screen and reprint only
3906
+ * the transcript tail that fits, so rows above that tail are lost from tmux and
3907
+ * older ones stay in history as stale copies. The keyboard opening and closing
3908
+ * was such a change each time. Instead the rows stay as they were and
3909
+ * `.xterm-screen` (taller than its box now) is translated up by this many px.
3910
+ *
3911
+ * This is the one deliberate exception to "a screen taller than its container
3912
+ * hides the input line" (see reconcilePtyGeometry): the offset below is what
3913
+ * keeps the live content in view.
3914
+ *
3915
+ * - Following live output: the last row with content sits at the bottom of the
3916
+ * box. A fresh shell prompting on row 1 shows the top; Claude's footer under
3917
+ * its composer stays visible. The cursor counts only when it is right below
3918
+ * that content (a prompt or composer line still empty): a TUI that parks its
3919
+ * cursor far below what it drew (codex's dialogs, with the cursor on the last
3920
+ * row of an otherwise blank screen) must not scroll its content out of view.
3921
+ * - Reading history: the offset follows the scroll position, so the top of the
3922
+ * scrollback is reachable and scrolling stays continuous.
3923
+ *
3924
+ * Recomputed on every render with no hysteresis; add some if a TUI's mid-redraw
3925
+ * frames ever make the view jitter.
3926
+ *
3927
+ * @param {{rows: number, cellHeight: number, viewHeight: number, cursorRow: number,
3928
+ * lastContentRow: number, following: boolean, viewportY: number, baseY: number}} input
3929
+ * lastContentRow: the last non-blank screen row, or -1 when the screen is blank
3930
+ * @returns {number} px, between 0 and the screen's overflow
3931
+ */
3932
+ function keyboardClipPx(input) {
3933
+ const { rows, cellHeight, viewHeight, cursorRow, lastContentRow, following, viewportY, baseY } = input;
3934
+ if (!(cellHeight > 0) || !(viewHeight > 0) || !(rows > 0)) return 0;
3935
+ const max = Math.max(0, rows * cellHeight - viewHeight);
3936
+ if (max === 0) return 0;
3937
+ if (!following && baseY > 0) return Math.round(max * Math.min(1, Math.max(0, viewportY / baseY)));
3938
+ const cursorCounts = lastContentRow < 0 || cursorRow <= lastContentRow + KEYBOARD_CLIP_CURSOR_REACH;
3939
+ const contentBottomRow = cursorCounts ? Math.max(cursorRow, lastContentRow) : lastContentRow;
3940
+ const bottom = (Math.min(rows - 1, Math.max(0, contentBottomRow)) + 1) * cellHeight;
3941
+ return Math.round(Math.min(max, Math.max(0, bottom - viewHeight)));
3942
+ }
3943
+
3944
+ /**
3945
+ * How many screen rows, from the top, are fully visible at a given offset: the
3946
+ * local-echo overlay grows upward from the last of them (`setRowLimit`).
3947
+ * @returns {number}
3948
+ */
3949
+ function keyboardVisibleRowLimit(clipPx, viewHeight, cellHeight) {
3950
+ if (!(cellHeight > 0)) return 0;
3951
+ return Math.max(1, Math.floor((clipPx + viewHeight) / cellHeight));
3952
+ }
3953
+
3364
3954
  /**
3365
3955
  * Which session does a dashboard URL's fragment ask for? Another page that
3366
3956
  * holds the dashboard's window, such as a task board, points it at
@@ -3440,7 +4030,14 @@ if (typeof window !== 'undefined') {
3440
4030
  rendersAsPage,
3441
4031
  HTML_PAGE_EXTENSIONS,
3442
4032
  };
3443
- window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
4033
+ window.CodemanTerminalLines = {
4034
+ terminalLogicalLine,
4035
+ terminalPathLine,
4036
+ terminalLinkLine,
4037
+ terminalUrlContinuationExtent,
4038
+ findTerminalUrls,
4039
+ terminalCellWrappedPaths,
4040
+ };
3444
4041
  window.CodemanUrlSession = { sessionIdFromFragment };
3445
4042
  window.CodemanSplitPane = {
3446
4043
  clampDividerPercent,
@@ -3491,6 +4088,8 @@ if (typeof window !== 'undefined') {
3491
4088
  window.CodemanTerminalGeometry = {
3492
4089
  clampTerminalDimensions,
3493
4090
  reconcilePtyGeometry,
4091
+ keyboardClipPx,
4092
+ keyboardVisibleRowLimit,
3494
4093
  TERMINAL_MIN_COLS,
3495
4094
  TERMINAL_MIN_ROWS,
3496
4095
  };