@linxiraos/pi-tui 1.1.27 → 1.2.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 (373) hide show
  1. package/CHANGELOG.md +47 -463
  2. package/README.md +7 -5
  3. package/dist/types/app-keybindings.d.ts +1 -1
  4. package/dist/types/apps/cleanse-board.d.ts +1 -1
  5. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  6. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  7. package/dist/types/apps/git/git-tui.d.ts +2 -2
  8. package/dist/types/apps/git/help.d.ts +3 -1
  9. package/dist/types/apps/if-bench-board.d.ts +2 -2
  10. package/dist/types/apps/setup-model-picker.d.ts +1 -1
  11. package/dist/types/autocomplete.d.ts +5 -2
  12. package/dist/types/charts/chart-plan.d.ts +674 -0
  13. package/dist/types/charts/chart-svg.d.ts +59 -0
  14. package/dist/types/charts/table-data.d.ts +175 -0
  15. package/dist/types/chat/assistant-message.d.ts +5 -0
  16. package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
  17. package/dist/types/chat/compaction-summary-message.d.ts +1 -0
  18. package/dist/types/chat/execution-shared.d.ts +6 -5
  19. package/dist/types/chat/fence-figure.d.ts +42 -0
  20. package/dist/types/chat/figure-markdown.d.ts +42 -0
  21. package/dist/types/chat/messages.d.ts +1 -1
  22. package/dist/types/chat/svg-figure.d.ts +33 -0
  23. package/dist/types/chat/svg-source.d.ts +49 -0
  24. package/dist/types/chat/table-chart.d.ts +89 -0
  25. package/dist/types/chat/tool-execution.d.ts +2 -0
  26. package/dist/types/chat/user-message.d.ts +15 -2
  27. package/dist/types/chrome/shared.d.ts +9 -0
  28. package/dist/types/chrome/transcript-container.d.ts +6 -0
  29. package/dist/types/components/box.d.ts +1 -0
  30. package/dist/types/components/disclosure.d.ts +1 -0
  31. package/dist/types/components/editor.d.ts +15 -4
  32. package/dist/types/components/form.d.ts +1 -0
  33. package/dist/types/components/image.d.ts +12 -44
  34. package/dist/types/components/key-value-list.d.ts +1 -0
  35. package/dist/types/components/layout/row.d.ts +1 -0
  36. package/dist/types/components/layout/stack.d.ts +1 -0
  37. package/dist/types/components/markdown.d.ts +36 -3
  38. package/dist/types/components/scroll-view.d.ts +1 -0
  39. package/dist/types/components/section.d.ts +1 -0
  40. package/dist/types/components/table.d.ts +1 -0
  41. package/dist/types/components/text.d.ts +1 -0
  42. package/dist/types/components/tree-view.d.ts +7 -0
  43. package/dist/types/components/truncated-text.d.ts +1 -0
  44. package/dist/types/fuzzy.d.ts +29 -1
  45. package/dist/types/key-hint-format.d.ts +1 -1
  46. package/dist/types/keybinding-matchers.d.ts +3 -0
  47. package/dist/types/keybindings.d.ts +1 -1
  48. package/dist/types/kitty-graphics.d.ts +2 -2
  49. package/dist/types/loop-watchdog.d.ts +8 -7
  50. package/dist/types/multiplexers/cmux.d.ts +16 -0
  51. package/dist/types/multiplexers/herdr.d.ts +26 -0
  52. package/dist/types/multiplexers/notify.d.ts +13 -0
  53. package/dist/types/multiplexers/orca.d.ts +12 -0
  54. package/dist/types/multiplexers/screen.d.ts +9 -0
  55. package/dist/types/multiplexers/tmux.d.ts +29 -0
  56. package/dist/types/multiplexers/types.d.ts +100 -0
  57. package/dist/types/multiplexers/wmux.d.ts +12 -0
  58. package/dist/types/multiplexers/zellij.d.ts +12 -0
  59. package/dist/types/native/backend.d.ts +3 -2
  60. package/dist/types/native/blobs.d.ts +7 -3
  61. package/dist/types/native/encode.d.ts +3 -4
  62. package/dist/types/native/overlay.d.ts +5 -0
  63. package/dist/types/overlays/agent-hub-projection.d.ts +6 -5
  64. package/dist/types/overlays/agent-hub-types.d.ts +9 -0
  65. package/dist/types/overlays/agent-hub.d.ts +2 -0
  66. package/dist/types/overlays/agent-transcript-viewer.d.ts +11 -2
  67. package/dist/types/overlays/agents-hub.d.ts +9 -0
  68. package/dist/types/overlays/annotation-overlay.d.ts +19 -2
  69. package/dist/types/overlays/annotation-types.d.ts +2 -0
  70. package/dist/types/overlays/ask-dialog.d.ts +5 -2
  71. package/dist/types/overlays/history-search.d.ts +10 -0
  72. package/dist/types/overlays/hook-selector.d.ts +9 -4
  73. package/dist/types/overlays/jobs-panel.d.ts +1 -7
  74. package/dist/types/overlays/model-browser.d.ts +54 -7
  75. package/dist/types/overlays/model-hub.d.ts +23 -0
  76. package/dist/types/overlays/oauth-selector.d.ts +0 -1
  77. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  78. package/dist/types/overlays/session-observer-registry.d.ts +2 -0
  79. package/dist/types/overlays/session-selector.d.ts +6 -2
  80. package/dist/types/overlays/settings-defs.d.ts +1 -0
  81. package/dist/types/overlays/usage-dashboard.d.ts +8 -4
  82. package/dist/types/overlays/usage-display.d.ts +3 -1
  83. package/dist/types/prompt/composer.d.ts +1 -1
  84. package/dist/types/prompt/custom-editor.d.ts +11 -8
  85. package/dist/types/prompt/image-source.d.ts +2 -1
  86. package/dist/types/prompt/interactive-context-helpers.d.ts +4 -1
  87. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  88. package/dist/types/prompt/welcome.d.ts +6 -1
  89. package/dist/types/render/composite.d.ts +2 -0
  90. package/dist/types/render/output-pane.d.ts +1 -0
  91. package/dist/types/render/render-utils.d.ts +1 -0
  92. package/dist/types/render/tool-card.d.ts +6 -0
  93. package/dist/types/render/width-aware-text.d.ts +2 -0
  94. package/dist/types/setup/scenes/types.d.ts +1 -0
  95. package/dist/types/status-line/component.d.ts +3 -0
  96. package/dist/types/status-line/host.d.ts +4 -1
  97. package/dist/types/status-line/segments.d.ts +2 -0
  98. package/dist/types/status-line/types.d.ts +14 -3
  99. package/dist/types/terminal-capabilities.d.ts +27 -35
  100. package/dist/types/terminal-multiplexer.d.ts +127 -14
  101. package/dist/types/terminal.d.ts +22 -6
  102. package/dist/types/theme/active-symbols.d.ts +1 -1
  103. package/dist/types/theme/schema.d.ts +11 -2
  104. package/dist/types/theme/symbols.d.ts +1 -1
  105. package/dist/types/theme/theme-class.d.ts +7 -0
  106. package/dist/types/theme/theme.d.ts +1 -1
  107. package/dist/types/theme/tui-adapters.d.ts +3 -0
  108. package/dist/types/tmux.d.ts +11 -4
  109. package/dist/types/tools/daemon.d.ts +1 -1
  110. package/dist/types/tools/eval.d.ts +22 -3
  111. package/dist/types/tools/glob.d.ts +4 -0
  112. package/dist/types/tools/native-view.d.ts +47 -0
  113. package/dist/types/tools/output-meta.d.ts +11 -1
  114. package/dist/types/tools/renderer.d.ts +37 -2
  115. package/dist/types/tools/streaming-output.d.ts +20 -1
  116. package/dist/types/tools/task.d.ts +0 -4
  117. package/dist/types/tools/write.d.ts +9 -2
  118. package/dist/types/tools/yield.d.ts +22 -0
  119. package/dist/types/tui.d.ts +31 -0
  120. package/dist/types/utils.d.ts +6 -0
  121. package/dist/types/vim.d.ts +19 -5
  122. package/package.json +13 -9
  123. package/src/app-keybindings.d.ts +1 -1
  124. package/src/app-keybindings.ts +1 -1
  125. package/src/apps/autoresearch-dashboard.ts +7 -3
  126. package/src/apps/cleanse-board.ts +2 -2
  127. package/src/apps/cleanse-picker.ts +1 -1
  128. package/src/apps/debug/log-viewer.ts +188 -72
  129. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  130. package/src/apps/debug/raw-sse.ts +39 -5
  131. package/src/apps/git/diff-pane.ts +26 -5
  132. package/src/apps/git/git-tui.ts +2 -2
  133. package/src/apps/git/help.ts +29 -20
  134. package/src/apps/if-bench-board.ts +2 -2
  135. package/src/apps/ps-top.ts +2 -2
  136. package/src/apps/setup-model-picker.ts +1 -1
  137. package/src/autocomplete.d.ts +5 -2
  138. package/src/autocomplete.ts +67 -43
  139. package/src/charts/chart-plan.d.ts +674 -0
  140. package/src/charts/chart-plan.ts +5081 -0
  141. package/src/charts/chart-svg.d.ts +59 -0
  142. package/src/charts/chart-svg.ts +4657 -0
  143. package/src/charts/table-data.d.ts +175 -0
  144. package/src/charts/table-data.ts +1478 -0
  145. package/src/chat/advisor-message.ts +11 -0
  146. package/src/chat/assistant-message.d.ts +5 -0
  147. package/src/chat/assistant-message.ts +219 -50
  148. package/src/chat/chat-transcript-builder.d.ts +2 -0
  149. package/src/chat/chat-transcript-builder.ts +5 -3
  150. package/src/chat/compaction-summary-message.d.ts +1 -0
  151. package/src/chat/compaction-summary-message.ts +8 -0
  152. package/src/chat/execution-shared.d.ts +6 -5
  153. package/src/chat/execution-shared.ts +42 -25
  154. package/src/chat/fence-figure.d.ts +42 -0
  155. package/src/chat/fence-figure.ts +80 -0
  156. package/src/chat/figure-markdown.d.ts +42 -0
  157. package/src/chat/figure-markdown.ts +174 -0
  158. package/src/chat/messages.d.ts +1 -1
  159. package/src/chat/messages.ts +1 -1
  160. package/src/chat/read-tool-group.ts +6 -4
  161. package/src/chat/skill-message.ts +6 -0
  162. package/src/chat/svg-figure.d.ts +33 -0
  163. package/src/chat/svg-figure.ts +399 -0
  164. package/src/chat/svg-source.d.ts +49 -0
  165. package/src/chat/svg-source.ts +188 -0
  166. package/src/chat/table-chart.d.ts +89 -0
  167. package/src/chat/table-chart.ts +294 -0
  168. package/src/chat/tool-execution.d.ts +2 -0
  169. package/src/chat/tool-execution.ts +80 -6
  170. package/src/chat/user-message.d.ts +15 -2
  171. package/src/chat/user-message.ts +55 -3
  172. package/src/chrome/shared.ts +53 -0
  173. package/src/chrome/transcript-container.d.ts +6 -0
  174. package/src/chrome/transcript-container.ts +93 -13
  175. package/src/components/box.d.ts +1 -0
  176. package/src/components/box.ts +5 -0
  177. package/src/components/disclosure.d.ts +1 -0
  178. package/src/components/disclosure.ts +8 -0
  179. package/src/components/editor.d.ts +15 -4
  180. package/src/components/editor.ts +294 -46
  181. package/src/components/form.d.ts +1 -0
  182. package/src/components/form.ts +8 -0
  183. package/src/components/image.d.ts +12 -44
  184. package/src/components/image.ts +47 -151
  185. package/src/components/key-value-list.d.ts +1 -0
  186. package/src/components/key-value-list.ts +4 -0
  187. package/src/components/layout/row.d.ts +1 -0
  188. package/src/components/layout/row.ts +7 -0
  189. package/src/components/layout/stack.d.ts +1 -0
  190. package/src/components/layout/stack.ts +7 -0
  191. package/src/components/markdown.d.ts +36 -3
  192. package/src/components/markdown.ts +433 -318
  193. package/src/components/scroll-view.d.ts +1 -0
  194. package/src/components/scroll-view.ts +5 -0
  195. package/src/components/section.d.ts +1 -0
  196. package/src/components/section.ts +5 -0
  197. package/src/components/settings-list.ts +2 -0
  198. package/src/components/table.d.ts +1 -0
  199. package/src/components/table.ts +4 -0
  200. package/src/components/text.d.ts +1 -0
  201. package/src/components/text.ts +7 -0
  202. package/src/components/tree-view.d.ts +7 -0
  203. package/src/components/tree-view.ts +38 -8
  204. package/src/components/truncated-text.d.ts +1 -0
  205. package/src/components/truncated-text.ts +5 -0
  206. package/src/desktop-notify.ts +1 -1
  207. package/src/fuzzy.d.ts +29 -1
  208. package/src/fuzzy.ts +115 -30
  209. package/src/key-hint-format.d.ts +1 -1
  210. package/src/key-hint-format.ts +1 -1
  211. package/src/keybinding-matchers.d.ts +3 -0
  212. package/src/keybinding-matchers.ts +6 -2
  213. package/src/keybindings.d.ts +1 -1
  214. package/src/keybindings.ts +1 -1
  215. package/src/kitty-graphics.d.ts +2 -2
  216. package/src/kitty-graphics.ts +4 -4
  217. package/src/loop-watchdog.d.ts +8 -7
  218. package/src/loop-watchdog.ts +18 -13
  219. package/src/multiplexers/cmux.d.ts +16 -0
  220. package/src/multiplexers/cmux.ts +29 -0
  221. package/src/multiplexers/herdr.d.ts +26 -0
  222. package/src/multiplexers/herdr.ts +64 -0
  223. package/src/multiplexers/notify.d.ts +13 -0
  224. package/src/multiplexers/notify.ts +24 -0
  225. package/src/multiplexers/orca.d.ts +12 -0
  226. package/src/multiplexers/orca.ts +16 -0
  227. package/src/multiplexers/screen.d.ts +9 -0
  228. package/src/multiplexers/screen.ts +15 -0
  229. package/src/multiplexers/tmux.d.ts +29 -0
  230. package/src/multiplexers/tmux.ts +40 -0
  231. package/src/multiplexers/types.d.ts +100 -0
  232. package/src/multiplexers/types.ts +110 -0
  233. package/src/multiplexers/wmux.d.ts +12 -0
  234. package/src/multiplexers/wmux.ts +16 -0
  235. package/src/multiplexers/zellij.d.ts +12 -0
  236. package/src/multiplexers/zellij.ts +24 -0
  237. package/src/native/backend.d.ts +3 -2
  238. package/src/native/backend.ts +174 -18
  239. package/src/native/blobs.d.ts +7 -3
  240. package/src/native/blobs.ts +47 -12
  241. package/src/native/encode.d.ts +3 -4
  242. package/src/native/encode.ts +58 -31
  243. package/src/native/overlay.d.ts +5 -0
  244. package/src/native/overlay.ts +13 -0
  245. package/src/native/reconcile.ts +4 -0
  246. package/src/overlays/agent-hub-projection.d.ts +6 -5
  247. package/src/overlays/agent-hub-projection.ts +124 -13
  248. package/src/overlays/agent-hub-types.d.ts +9 -0
  249. package/src/overlays/agent-hub-types.ts +6 -0
  250. package/src/overlays/agent-hub.ts +44 -29
  251. package/src/overlays/agent-transcript-viewer.ts +91 -23
  252. package/src/overlays/agents-hub.ts +28 -8
  253. package/src/overlays/annotation-overlay.ts +293 -90
  254. package/src/overlays/annotation-types.ts +2 -0
  255. package/src/overlays/ask-dialog.ts +7 -10
  256. package/src/overlays/btw-history-panel.ts +12 -1
  257. package/src/overlays/copy-selector.ts +30 -8
  258. package/src/overlays/extensions/state-manager.ts +52 -12
  259. package/src/overlays/history-search.ts +100 -7
  260. package/src/overlays/hook-selector.ts +12 -5
  261. package/src/overlays/jobs-panel.ts +28 -46
  262. package/src/overlays/model-browser.ts +341 -110
  263. package/src/overlays/model-hub.ts +300 -49
  264. package/src/overlays/move-overlay.ts +28 -14
  265. package/src/overlays/oauth-selector.ts +54 -70
  266. package/src/overlays/plan-review-overlay.ts +194 -70
  267. package/src/overlays/plugin-settings.ts +3 -3
  268. package/src/overlays/report-panel.ts +2 -2
  269. package/src/overlays/rewind-selector.ts +86 -17
  270. package/src/overlays/session-observer-registry.d.ts +2 -0
  271. package/src/overlays/session-observer-registry.ts +10 -2
  272. package/src/overlays/session-selector.ts +64 -26
  273. package/src/overlays/settings-defs.ts +4 -3
  274. package/src/overlays/settings-selector.ts +33 -8
  275. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  276. package/src/overlays/stats-notice.ts +1 -1
  277. package/src/overlays/tree-selector.ts +41 -20
  278. package/src/overlays/usage-dashboard.ts +100 -50
  279. package/src/overlays/usage-display.ts +10 -4
  280. package/src/prompt/attachment-chips.ts +15 -11
  281. package/src/prompt/composer-attachments.ts +6 -4
  282. package/src/prompt/composer.ts +4 -15
  283. package/src/prompt/custom-editor.ts +54 -26
  284. package/src/prompt/image-source.ts +2 -1
  285. package/src/prompt/interactive-context-helpers.ts +30 -18
  286. package/src/prompt/macos-spelling.ts +7 -0
  287. package/src/prompt/magic-keywords.ts +11 -2
  288. package/src/prompt/model-mention-autocomplete.ts +14 -14
  289. package/src/prompt/tips.txt +6 -5
  290. package/src/prompt/welcome.ts +55 -12
  291. package/src/prompt/word-completion.ts +38 -5
  292. package/src/render/composite.d.ts +2 -0
  293. package/src/render/composite.ts +64 -0
  294. package/src/render/output-pane.d.ts +1 -0
  295. package/src/render/output-pane.ts +6 -0
  296. package/src/render/render-utils.d.ts +1 -0
  297. package/src/render/render-utils.ts +11 -0
  298. package/src/render/tool-card.d.ts +6 -0
  299. package/src/render/tool-card.ts +15 -0
  300. package/src/render/width-aware-text.d.ts +2 -0
  301. package/src/render/width-aware-text.ts +5 -0
  302. package/src/setup/scenes/model.ts +2 -1
  303. package/src/setup/scenes/types.ts +1 -0
  304. package/src/status-line/component.ts +292 -78
  305. package/src/status-line/host.ts +4 -1
  306. package/src/status-line/segments.ts +40 -22
  307. package/src/status-line/types.ts +15 -3
  308. package/src/terminal-capabilities.d.ts +27 -35
  309. package/src/terminal-capabilities.ts +75 -189
  310. package/src/terminal-multiplexer.d.ts +127 -14
  311. package/src/terminal-multiplexer.ts +112 -33
  312. package/src/terminal.d.ts +22 -6
  313. package/src/terminal.ts +139 -37
  314. package/src/theme/active-symbols.d.ts +1 -1
  315. package/src/theme/active-symbols.ts +1 -1
  316. package/src/theme/schema-validation.ts +1 -0
  317. package/src/theme/schema.d.ts +11 -2
  318. package/src/theme/schema.ts +14 -1
  319. package/src/theme/session-color.ts +1 -7
  320. package/src/theme/symbols.d.ts +1 -1
  321. package/src/theme/symbols.ts +6 -1
  322. package/src/theme/theme-class.d.ts +7 -0
  323. package/src/theme/theme-class.ts +26 -1
  324. package/src/theme/theme-schema.json +5 -1
  325. package/src/theme/theme.d.ts +1 -1
  326. package/src/theme/theme.ts +8 -4
  327. package/src/theme/tui-adapters.d.ts +3 -0
  328. package/src/theme/tui-adapters.ts +68 -0
  329. package/src/tmux.d.ts +11 -4
  330. package/src/tmux.ts +47 -11
  331. package/src/tools/ast-edit.ts +59 -35
  332. package/src/tools/ast-grep.ts +35 -16
  333. package/src/tools/bash.ts +92 -42
  334. package/src/tools/daemon.d.ts +1 -1
  335. package/src/tools/daemon.ts +1 -1
  336. package/src/tools/default-renderer.ts +35 -19
  337. package/src/tools/edit.ts +142 -120
  338. package/src/tools/eval-format/index.ts +28 -2
  339. package/src/tools/eval-format/javascript.ts +7 -5
  340. package/src/tools/eval.d.ts +22 -3
  341. package/src/tools/eval.ts +364 -208
  342. package/src/tools/fetch.ts +5 -6
  343. package/src/tools/glob.d.ts +4 -0
  344. package/src/tools/glob.ts +33 -65
  345. package/src/tools/grep.ts +81 -48
  346. package/src/tools/index.ts +2 -0
  347. package/src/tools/lsp.ts +81 -59
  348. package/src/tools/mcp.ts +111 -68
  349. package/src/tools/native-view.d.ts +47 -0
  350. package/src/tools/native-view.ts +102 -1
  351. package/src/tools/output-meta.d.ts +11 -1
  352. package/src/tools/output-meta.ts +23 -3
  353. package/src/tools/read.ts +7 -5
  354. package/src/tools/renderer.d.ts +37 -2
  355. package/src/tools/renderer.ts +42 -2
  356. package/src/tools/streaming-output.d.ts +20 -1
  357. package/src/tools/streaming-output.ts +149 -50
  358. package/src/tools/task.d.ts +0 -4
  359. package/src/tools/task.ts +112 -59
  360. package/src/tools/todo.ts +119 -30
  361. package/src/tools/wait.ts +34 -24
  362. package/src/tools/web-search.ts +1 -1
  363. package/src/tools/write.d.ts +9 -2
  364. package/src/tools/write.ts +147 -95
  365. package/src/tools/yield.d.ts +22 -0
  366. package/src/tools/yield.ts +225 -0
  367. package/src/tui.d.ts +31 -0
  368. package/src/tui.ts +309 -160
  369. package/src/utils.d.ts +6 -0
  370. package/src/utils.ts +19 -0
  371. package/src/vim.d.ts +19 -5
  372. package/src/vim.ts +333 -18
  373. package/src/windows-input-mode.ts +8 -0
@@ -1,5 +1,5 @@
1
1
  import {
2
- type Component,
2
+ type Focusable,
3
3
  Editor,
4
4
  Ellipsis,
5
5
  matchesKey,
@@ -11,7 +11,9 @@ import {
11
11
  visibleWidth,
12
12
  wrapTextWithAnsi,
13
13
  } from "../index";
14
- import { appKey, editorKey } from "../chrome/keybinding-hints";
14
+ import { compositeLineAt } from "../render/composite";
15
+ import { wrapLiteralLine } from "../utils";
16
+ import { editorKey } from "../chrome/keybinding-hints";
15
17
  import { formatKeyHint, formatKeyHints, type KeybindingsManager } from "../app-keybindings";
16
18
  import type { Keybinding } from "../keybindings";
17
19
  import { sanitizeText } from "@linxiraos/pi-utils";
@@ -36,11 +38,19 @@ import {
36
38
  topBorder,
37
39
  topBorderSplit,
38
40
  } from "../chrome/overlay-box";
39
- import { matchesAppExternalEditor } from "../keybinding-matchers";
41
+ import { appExternalEditorKey, matchesAppExternalEditor } from "../keybinding-matchers";
40
42
  import type { KeyName } from "../key-hint-format";
41
43
  import { item, keyed, node, row as rowNode, span, text } from "../native/describe";
42
44
  import { leafKey, type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
43
- import { hintsRow, itemIndex, type NativeHint, noteSpans, overlayCard, selectList } from "../native/overlay";
45
+ import {
46
+ escCloseButton,
47
+ hintsRow,
48
+ itemIndex,
49
+ type NativeHint,
50
+ noteSpans,
51
+ overlayCard,
52
+ selectList,
53
+ } from "../native/overlay";
44
54
 
45
55
  function fit(text: string, width: number): string {
46
56
  if (width <= 0) return "";
@@ -60,6 +70,8 @@ export interface AnnotationOverlayCallbacks {
60
70
  onWarning?(message: string): void;
61
71
  /** Open the draft in the host's editor and invoke `commit` with its result. */
62
72
  onAnnotationExternalEditor?(draft: string, commit: (text: string | null) => void): void | Promise<void>;
73
+ /** Open the current source in the host's editor. The overlay stays open. */
74
+ onExternalEditor?(): void | Promise<void>;
63
75
  }
64
76
 
65
77
  export interface CodeReviewOverlayCallbacks extends AnnotationOverlayCallbacks {
@@ -94,7 +106,8 @@ interface CommittedTextAnnotation {
94
106
  interface RenderedDiffBody {
95
107
  lines: string[];
96
108
  renderedRowBySource: number[];
97
- selectedSourceLines: string[];
109
+ sourceIndexByRenderedRow: Array<number | undefined>;
110
+ selectedSourceLines: Array<string | undefined>;
98
111
  }
99
112
 
100
113
  interface RenderedBody {
@@ -136,7 +149,8 @@ function sanitizeStatusText(text: string): string {
136
149
  .trim();
137
150
  }
138
151
 
139
- function splitTextLines(text: string): string[] {
152
+ /** Splits a text source into the overlay's 1-based annotatable lines. */
153
+ export function splitTextLines(text: string): string[] {
140
154
  return text.split(/\r\n|\n|\r/);
141
155
  }
142
156
 
@@ -145,7 +159,8 @@ function isTextSource(value: readonly ReviewDiffFile[] | TextReviewSource): valu
145
159
  }
146
160
 
147
161
  /** A fullscreen, annotated diff picker that only relies on public OMP APIs. */
148
- export class AnnotationOverlay implements Component {
162
+ export class AnnotationOverlay implements Focusable {
163
+ focused = false;
149
164
  #scrollView: ScrollView;
150
165
  #editor: Editor;
151
166
  #focus: FocusRegion = "files";
@@ -159,6 +174,8 @@ export class AnnotationOverlay implements Component {
159
174
  #editingAnnotationIndex: number | undefined;
160
175
  #annotationChooser: AnnotationChooser | undefined;
161
176
  #externalOperation = false;
177
+ /** Set once {@link replaceTextSource} runs; the paste result then carries the edited text. */
178
+ #textSourceEdited = false;
162
179
  #finished = false;
163
180
  #annotations: CommittedAnnotation[] = [];
164
181
  #textAnnotations: CommittedTextAnnotation[] = [];
@@ -167,9 +184,11 @@ export class AnnotationOverlay implements Component {
167
184
  #actions: readonly string[] = CODE_REVIEW_ACTIONS;
168
185
  #textSource: TextReviewSource | undefined;
169
186
  #textLines: readonly string[] = [];
170
- #textViewportDriven = false;
171
- #textRenderedRowBySource: readonly number[] = [];
172
- #staticRenderedDiffBodies = new WeakMap<ReviewDiffFile, RenderedDiffBody>();
187
+ #viewportDriven = false;
188
+ /** Last viewport move went toward the end, so a viewport resting at its maximum offset selects the last visible row. */
189
+ #viewportTowardEnd = false;
190
+ #renderedRowBySource: readonly number[] = [];
191
+ #staticRenderedDiffBodies = new WeakMap<ReviewDiffFile, { width: number; body: RenderedDiffBody }>();
173
192
  /** Bumped on every annotation change (each one pushes an undo snapshot) and on undo. */
174
193
  #annotationRev = 0;
175
194
  #nativeBody: NativeBody | undefined;
@@ -266,15 +285,16 @@ export class AnnotationOverlay implements Component {
266
285
  });
267
286
  this.#editor.setBorderVisible(false);
268
287
  this.#editor.setPromptGutter("> ");
269
- this.#editor.setUseTerminalCursor(false);
270
- // Keep the editor's CURSOR_MARKER for terminal cursor placement, but
271
- // replace its visible end-of-input caret with a stable blank cell.
272
- this.#editor.cursorOverride = " ";
273
288
  this.#editor.setScrollbarVisible(true);
274
289
  this.#editor.onSubmit = value => this.#commitAnnotation(value);
275
290
  this.#resetSourceCursor();
276
291
  }
277
292
 
293
+ /** Keep the nested editor's cursor mode aligned with the overlay focus target. */
294
+ setUseTerminalCursor(useTerminalCursor: boolean): void {
295
+ this.#editor.setUseTerminalCursor(useTerminalCursor);
296
+ }
297
+
278
298
  invalidate(): void {
279
299
  this.#staticRenderedDiffBodies = new WeakMap();
280
300
  }
@@ -291,6 +311,61 @@ export class AnnotationOverlay implements Component {
291
311
  return this.#textAnnotations.map(entry => ({ ...entry.annotation }));
292
312
  }
293
313
 
314
+ /** Repo-relative path of the diff file under the cursor, if this is a code review. */
315
+ reviewFilePath(): string | undefined {
316
+ const file = this.#files[this.#fileIndex];
317
+ if (!file) return undefined;
318
+ return file.newPath ?? file.oldPath ?? file.path;
319
+ }
320
+
321
+ /** Current text-source bytes, including edits applied by {@link replaceTextSource}. */
322
+ textSourceText(): string | undefined {
323
+ return this.#textSource?.text;
324
+ }
325
+
326
+ /**
327
+ * Replace the text under review. Line notes whose quote still sits on the
328
+ * same row stay; a quote that moved to exactly one row is retargeted;
329
+ * every other line note is dropped. Returns how many were dropped.
330
+ */
331
+ replaceTextSource(text: string): number {
332
+ if (!this.#textSource) return 0;
333
+ this.#textSource = { ...this.#textSource, text };
334
+ this.#textSourceEdited = true;
335
+ this.#textLines = splitTextLines(text);
336
+ this.#viewportDriven = false;
337
+ this.#sourceIndex = Math.min(this.#sourceIndex, Math.max(0, this.#textLines.length - 1));
338
+ let dropped = 0;
339
+ const kept: CommittedTextAnnotation[] = [];
340
+ for (const entry of this.#textAnnotations) {
341
+ if (entry.annotation.scope !== "line") {
342
+ kept.push(entry);
343
+ continue;
344
+ }
345
+ const quote = entry.annotation.quote;
346
+ if (this.#textLines[entry.sourceIndex] === quote) {
347
+ kept.push(entry);
348
+ continue;
349
+ }
350
+ const matches = this.#textLines.flatMap((line, index) => (line === quote ? [index] : []));
351
+ const only = matches.length === 1 ? matches[0] : undefined;
352
+ if (only !== undefined) {
353
+ kept.push({
354
+ sourceIndex: only,
355
+ annotation: { ...entry.annotation, line: only + 1 },
356
+ });
357
+ continue;
358
+ }
359
+ dropped++;
360
+ }
361
+ this.#textAnnotations = kept;
362
+ // Earlier snapshots hold notes anchored to the old text; undo must not resurrect them.
363
+ this.#undoStack = [];
364
+ this.#annotationRev++;
365
+ this.#nativeBody = undefined;
366
+ return dropped;
367
+ }
368
+
294
369
  handleInput(data: string): void {
295
370
  if (this.#finished || this.#externalOperation) return;
296
371
  if (this.#annotationChooser) {
@@ -310,7 +385,11 @@ export class AnnotationOverlay implements Component {
310
385
  return;
311
386
  }
312
387
  if (this.#keybindings.matches(data, "tui.select.cancel")) {
313
- this.#finish(undefined);
388
+ this.#escape();
389
+ return;
390
+ }
391
+ if (this.#callbacks.onExternalEditor && matchesAppExternalEditor(data)) {
392
+ void this.#openSourceEditor();
314
393
  return;
315
394
  }
316
395
  if (data === "A" && (this.#focus === "files" || this.#focus === "diff")) {
@@ -362,6 +441,13 @@ export class AnnotationOverlay implements Component {
362
441
  }
363
442
  }
364
443
 
444
+ /** Esc, by state: leave the note chooser, drop the note draft, else close the review. */
445
+ #escape(): void {
446
+ if (this.#annotationChooser) this.#annotationChooser = undefined;
447
+ else if (this.#annotating) this.#cancelAnnotation();
448
+ else this.#finish(undefined);
449
+ }
450
+
365
451
  #cycleFocus(direction: number): void {
366
452
  const regions: FocusRegion[] = this.#sidebarShown ? ["files", "diff", "actions"] : ["diff", "actions"];
367
453
  const current = regions.indexOf(this.#focus);
@@ -417,34 +503,48 @@ export class AnnotationOverlay implements Component {
417
503
  return;
418
504
  }
419
505
  if (this.#keybindings.matches(data, "tui.select.pageUp")) {
420
- if (this.#textSource) {
421
- this.#scrollView.page(-1);
422
- this.#textViewportDriven = true;
423
- this.#syncTextCursorToViewport();
424
- } else {
506
+ if (this.#tui.nativeRendering && !this.#textSource) {
425
507
  this.#moveSourceCursor(-Math.max(1, this.#bodyHeight - 1));
508
+ } else {
509
+ this.#scrollView.page(-1);
510
+ this.#viewportDriven = true;
511
+ this.#viewportTowardEnd = false;
512
+ this.#syncCursorToViewport();
426
513
  }
427
514
  return;
428
515
  }
429
516
  if (this.#keybindings.matches(data, "tui.select.pageDown")) {
430
- if (this.#textSource) {
431
- this.#scrollView.page(1);
432
- this.#textViewportDriven = true;
433
- this.#syncTextCursorToViewport();
434
- } else {
517
+ if (this.#tui.nativeRendering && !this.#textSource) {
435
518
  this.#moveSourceCursor(Math.max(1, this.#bodyHeight - 1));
519
+ } else {
520
+ this.#scrollView.page(1);
521
+ this.#viewportDriven = true;
522
+ this.#viewportTowardEnd = true;
523
+ this.#syncCursorToViewport();
436
524
  }
437
525
  return;
438
526
  }
439
527
  if (data === "g" || matchesKey(data, "home")) {
440
528
  this.#sourceIndex = 0;
441
- this.#textViewportDriven = false;
529
+ if (this.#textSource || this.#tui.nativeRendering) {
530
+ this.#viewportDriven = false;
531
+ } else {
532
+ this.#scrollView.scrollToTop();
533
+ this.#viewportDriven = true;
534
+ this.#viewportTowardEnd = false;
535
+ }
442
536
  } else if (data === "G" || matchesKey(data, "end")) {
443
537
  this.#sourceIndex = Math.max(
444
538
  0,
445
539
  (this.#textSource ? this.#textLines.length : this.#currentSourceRows().length) - 1,
446
540
  );
447
- this.#textViewportDriven = false;
541
+ if (this.#textSource || this.#tui.nativeRendering) {
542
+ this.#viewportDriven = false;
543
+ } else {
544
+ this.#scrollView.scrollToBottom();
545
+ this.#viewportDriven = true;
546
+ this.#viewportTowardEnd = true;
547
+ }
448
548
  }
449
549
  }
450
550
 
@@ -478,6 +578,7 @@ export class AnnotationOverlay implements Component {
478
578
  this.#finish({
479
579
  action: "paste",
480
580
  annotations: this.getTextAnnotations(),
581
+ ...(this.#textSourceEdited ? { editedText: this.#textSource.text } : {}),
481
582
  });
482
583
  } else {
483
584
  this.#finish({
@@ -501,6 +602,7 @@ export class AnnotationOverlay implements Component {
501
602
 
502
603
  #resetSourceCursor(): void {
503
604
  this.#sourceIndex = 0;
605
+ this.#viewportDriven = false;
504
606
  }
505
607
 
506
608
  #currentFile(): ReviewDiffFile | undefined {
@@ -511,20 +613,20 @@ export class AnnotationOverlay implements Component {
511
613
  const file = this.#currentFile();
512
614
  return file ? file.rows.filter(isSourceRow) : [];
513
615
  }
514
- #syncTextCursorToViewport(): void {
515
- if (!this.#textSource || this.#textRenderedRowBySource.length === 0) return;
616
+ #syncCursorToViewport(): void {
617
+ if (this.#renderedRowBySource.length === 0) return;
516
618
  const viewportTop = this.#scrollView.getScrollOffset();
517
619
  const viewportBottom = viewportTop + Math.max(0, this.#bodyHeight - 1);
518
620
  let topSourceIndex = 0;
519
621
  let lastVisibleSourceIndex = 0;
520
- for (let index = 1; index < this.#textRenderedRowBySource.length; index++) {
521
- const renderedRow = this.#textRenderedRowBySource[index];
622
+ for (let index = 1; index < this.#renderedRowBySource.length; index++) {
623
+ const renderedRow = this.#renderedRowBySource[index];
522
624
  if (renderedRow === undefined || renderedRow > viewportBottom) break;
523
625
  lastVisibleSourceIndex = index;
524
626
  if (renderedRow <= viewportTop) topSourceIndex = index;
525
627
  }
526
- const atBottom =
527
- this.#scrollView.getMaxScrollOffset() > 0 && viewportTop === this.#scrollView.getMaxScrollOffset();
628
+ // A short pane has no scroll range, so paging toward the end must still reach the last row.
629
+ const atBottom = this.#viewportTowardEnd && viewportTop >= this.#scrollView.getMaxScrollOffset();
528
630
  this.#sourceIndex = atBottom ? lastVisibleSourceIndex : topSourceIndex;
529
631
  }
530
632
 
@@ -532,7 +634,7 @@ export class AnnotationOverlay implements Component {
532
634
  const rowCount = this.#textSource ? this.#textLines.length : this.#currentSourceRows().length;
533
635
  if (rowCount === 0) return;
534
636
  this.#sourceIndex = Math.max(0, Math.min(rowCount - 1, this.#sourceIndex + delta));
535
- if (this.#textSource) this.#textViewportDriven = false;
637
+ this.#viewportDriven = false;
536
638
  }
537
639
 
538
640
  #startAnnotation(scope: AnnotationScope, existingIndex?: number): void {
@@ -795,6 +897,22 @@ export class AnnotationOverlay implements Component {
795
897
  }
796
898
  }
797
899
 
900
+ async #openSourceEditor(): Promise<void> {
901
+ const openEditor = this.#callbacks.onExternalEditor;
902
+ if (this.#externalOperation || !openEditor) return;
903
+ this.#externalOperation = true;
904
+ try {
905
+ await openEditor();
906
+ } catch (error) {
907
+ this.#callbacks.onWarning?.(
908
+ `Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`,
909
+ );
910
+ } finally {
911
+ this.#externalOperation = false;
912
+ this.#tui.requestRender(true);
913
+ }
914
+ }
915
+
798
916
  #annotationCount(fileIndex: number): number {
799
917
  let count = 0;
800
918
  for (const entry of this.#annotations) if (entry.fileIndex === fileIndex) count++;
@@ -809,7 +927,7 @@ export class AnnotationOverlay implements Component {
809
927
  lines: [this.#theme.fg("dim", "No reviewable files")],
810
928
  renderedRowBySource: [],
811
929
  };
812
- const staticBody = this.#getStaticRenderedBody(file);
930
+ const staticBody = this.#getStaticRenderedBody(file, contentWidth);
813
931
  const lines: string[] = [];
814
932
  const renderedRowBySource: number[] = [];
815
933
 
@@ -821,12 +939,10 @@ export class AnnotationOverlay implements Component {
821
939
  }
822
940
  }
823
941
 
824
- let sourceIndex = 0;
825
942
  for (let staticRow = 0; staticRow < staticBody.lines.length; staticRow++) {
826
- const currentSourceIndex =
827
- staticBody.renderedRowBySource[sourceIndex] === staticRow ? sourceIndex++ : undefined;
943
+ const currentSourceIndex = staticBody.sourceIndexByRenderedRow[staticRow];
828
944
  let renderedLine = staticBody.lines[staticRow] ?? "";
829
- if (currentSourceIndex !== undefined) {
945
+ if (currentSourceIndex !== undefined && staticBody.renderedRowBySource[currentSourceIndex] === staticRow) {
830
946
  // Line notes belong immediately before their source row. This keeps
831
947
  // the selected row and all notes in a stable, readable order.
832
948
  for (const entry of this.#annotations) {
@@ -839,12 +955,10 @@ export class AnnotationOverlay implements Component {
839
955
  }
840
956
  }
841
957
  renderedRowBySource[currentSourceIndex] = lines.length;
842
- if (this.#focus === "diff" && currentSourceIndex === this.#sourceIndex) {
843
- renderedLine = this.#theme.bg(
844
- "selectedBg",
845
- fit(staticBody.selectedSourceLines[currentSourceIndex] ?? "", contentWidth),
846
- );
847
- }
958
+ }
959
+ if (this.#focus === "diff" && currentSourceIndex === this.#sourceIndex) {
960
+ renderedLine = staticBody.selectedSourceLines[staticRow] ?? renderedLine;
961
+ renderedLine = this.#theme.bg("selectedBg", fit(renderedLine, contentWidth));
848
962
  }
849
963
  lines.push(truncateToWidth(renderedLine, contentWidth));
850
964
  }
@@ -870,8 +984,7 @@ export class AnnotationOverlay implements Component {
870
984
  }
871
985
  renderedRowBySource[sourceIndex] = lines.length;
872
986
  const displayLine = replaceTabs(sanitizeText(sourceLine));
873
- const wrapped = wrapTextWithAnsi(displayLine, textWidth);
874
- const visualRows = wrapped.length > 0 ? wrapped : [""];
987
+ const visualRows = wrapLiteralLine(displayLine, textWidth);
875
988
  for (const [rowIndex, visualRow] of visualRows.entries()) {
876
989
  const selected = this.#focus === "diff" && sourceIndex === this.#sourceIndex;
877
990
  const gutter =
@@ -885,47 +998,63 @@ export class AnnotationOverlay implements Component {
885
998
  return { lines, renderedRowBySource };
886
999
  }
887
1000
 
888
- #getStaticRenderedBody(file: ReviewDiffFile): RenderedDiffBody {
1001
+ #getStaticRenderedBody(file: ReviewDiffFile, contentWidth: number): RenderedDiffBody {
1002
+ const normalizedWidth = Math.max(1, Math.floor(contentWidth));
889
1003
  const cached = this.#staticRenderedDiffBodies.get(file);
890
- if (cached) return cached;
1004
+ if (cached?.width === normalizedWidth) return cached.body;
891
1005
  const lines: string[] = [];
892
1006
  const renderedRowBySource: number[] = [];
893
- const selectedSourceLines: string[] = [];
1007
+ const sourceIndexByRenderedRow: Array<number | undefined> = [];
1008
+ const selectedSourceLines: Array<string | undefined> = [];
1009
+ const pushStaticLine = (line: string) => {
1010
+ lines.push(line);
1011
+ sourceIndexByRenderedRow.push(undefined);
1012
+ selectedSourceLines.push(undefined);
1013
+ };
894
1014
  if (file.isBinary) {
895
- lines.push(this.#theme.fg("dim", "Binary diff; no annotatable source rows"));
1015
+ pushStaticLine(this.#theme.fg("dim", "Binary diff; no annotatable source rows"));
896
1016
  } else if (file.rows.length === 0) {
897
- lines.push(this.#theme.fg("dim", "No diff hunks; this may be a rename-only change"));
1017
+ pushStaticLine(this.#theme.fg("dim", "No diff hunks; this may be a rename-only change"));
898
1018
  } else {
899
1019
  let sourceIndex = 0;
900
1020
  for (const diffRow of file.rows) {
901
1021
  if (!isSourceRow(diffRow)) {
902
- lines.push(
1022
+ pushStaticLine(
903
1023
  this.#theme.fg(diffRow.kind === "hunk" ? "accent" : "dim", replaceTabs(sanitizeText(diffRow.raw))),
904
1024
  );
905
1025
  continue;
906
1026
  }
907
- renderedRowBySource[sourceIndex] = lines.length;
908
1027
  const marker = diffRow.kind === "added" ? "+" : diffRow.kind === "removed" ? "-" : " ";
909
1028
  const lineNumber = diffRow.kind === "removed" ? diffRow.oldLine : (diffRow.newLine ?? diffRow.oldLine);
910
- const suffix = `${marker}${String(lineNumber ?? "").padStart(4)} ${replaceTabs(sanitizeText(diffRow.content))}`;
911
- const color: DiffColor =
1029
+ const prefix = `${marker}${String(lineNumber ?? "").padStart(4)} `;
1030
+ const prefixWidth = visibleWidth(prefix);
1031
+ const contentColor: DiffColor =
912
1032
  diffRow.kind === "added"
913
1033
  ? "toolDiffAdded"
914
1034
  : diffRow.kind === "removed"
915
1035
  ? "toolDiffRemoved"
916
1036
  : "toolDiffContext";
917
- const coloredSuffix = this.#theme.fg(color, suffix);
918
- const gutter = " ".repeat(SOURCE_SELECTION_GUTTER_WIDTH);
919
- selectedSourceLines[sourceIndex] = `${fit(
920
- `${this.#theme.nav.cursor} `,
921
- SOURCE_SELECTION_GUTTER_WIDTH,
922
- )}${coloredSuffix}`;
923
- lines.push(`${gutter}${coloredSuffix}`);
1037
+ const sourceContent = replaceTabs(sanitizeText(diffRow.content));
1038
+ const contentWidthForRow = Math.max(1, normalizedWidth - SOURCE_SELECTION_GUTTER_WIDTH - prefixWidth);
1039
+ const visualRows = wrapLiteralLine(sourceContent, contentWidthForRow);
1040
+ const firstRenderedRow = lines.length;
1041
+ renderedRowBySource[sourceIndex] = firstRenderedRow;
1042
+ for (const [rowIndex, visualRow] of visualRows.entries()) {
1043
+ const first = rowIndex === 0;
1044
+ const gutter = " ".repeat(SOURCE_SELECTION_GUTTER_WIDTH);
1045
+ const continuationPrefix = " ".repeat(prefixWidth);
1046
+ const prefixForRow = first ? this.#theme.fg(contentColor, prefix) : continuationPrefix;
1047
+ const selectedGutter = first ? fit(`${this.#theme.nav.cursor} `, SOURCE_SELECTION_GUTTER_WIDTH) : gutter;
1048
+ const styledRow = this.#theme.fg(contentColor, visualRow);
1049
+ lines.push(`${gutter}${prefixForRow}${styledRow}`);
1050
+ sourceIndexByRenderedRow.push(sourceIndex);
1051
+ selectedSourceLines.push(`${selectedGutter}${prefixForRow}${styledRow}`);
1052
+ }
924
1053
  sourceIndex++;
925
1054
  }
926
1055
  }
927
- const body = { lines, renderedRowBySource, selectedSourceLines };
928
- this.#staticRenderedDiffBodies.set(file, body);
1056
+ const body = { lines, renderedRowBySource, sourceIndexByRenderedRow, selectedSourceLines };
1057
+ this.#staticRenderedDiffBodies.set(file, { width: normalizedWidth, body });
929
1058
  return body;
930
1059
  }
931
1060
 
@@ -1027,6 +1156,7 @@ export class AnnotationOverlay implements Component {
1027
1156
  }
1028
1157
 
1029
1158
  #renderFooter(width: number, maxChooserRows = Number.POSITIVE_INFINITY): string[] {
1159
+ this.#editor.focused = this.focused && this.#annotating;
1030
1160
  if (this.#annotationChooser) {
1031
1161
  return this.#renderAnnotationChooser(width, maxChooserRows);
1032
1162
  }
@@ -1042,9 +1172,7 @@ export class AnnotationOverlay implements Component {
1042
1172
  `${editorKey("tui.input.newLine")} newline`,
1043
1173
  `${this.#key("tui.select.cancel")} cancel`,
1044
1174
  ];
1045
- const externalEditorKey = appKey(this.#keybindings, "app.editor.external");
1046
- if (externalEditorKey) hints.push(`${externalEditorKey} editor`);
1047
- this.#editor.focused = true;
1175
+ hints.push(`${formatKeyHint(appExternalEditorKey())} editor`);
1048
1176
  return [caption, ...this.#editor.render(width), this.#theme.fg("dim", hints.join(" · "))];
1049
1177
  }
1050
1178
  return [this.#theme.fg("dim", this.#helpText())];
@@ -1095,16 +1223,23 @@ export class AnnotationOverlay implements Component {
1095
1223
  : this.#focus === "diff"
1096
1224
  ? `${upDown} line · ${formatKeyHint("shift")} faster · ${this.#key("tui.select.pageUp")}/${this.#key("tui.select.pageDown")} · ${formatKeyHints(["g", "shift+g"])} ends · ${formatKeyHint("a")} line note · ${formatKeyHint("shift+a")} ${this.#textSource ? "text" : "file"} note · ${editNote}`
1097
1225
  : `${upDown} select · ${confirm} confirm`;
1098
- return `${focusHelp} · ${formatKeyHint("u")} undo · ${formatKeyHint("tab")} regions · ${this.#key("tui.select.cancel")} cancel`;
1226
+ const editorHint = this.#callbacks.onExternalEditor ? ` · ${formatKeyHint(appExternalEditorKey())} editor` : "";
1227
+ return `${focusHelp} · ${formatKeyHint("u")} undo · ${formatKeyHint("tab")} regions${editorHint} · ${this.#key("tui.select.cancel")} cancel`;
1099
1228
  }
1100
1229
 
1101
- #ensureCursorVisible(renderedRowBySource: readonly number[]): void {
1102
- if (this.#textSource && this.#textViewportDriven) return;
1230
+ #ensureCursorVisible(renderedRowBySource: readonly number[]): boolean {
1231
+ this.#renderedRowBySource = renderedRowBySource;
1232
+ if (this.#viewportDriven) {
1233
+ const sourceIndex = this.#sourceIndex;
1234
+ if (!this.#annotating && !this.#annotationChooser) this.#syncCursorToViewport();
1235
+ return this.#sourceIndex !== sourceIndex;
1236
+ }
1103
1237
  const rowIndex = renderedRowBySource[this.#sourceIndex];
1104
- if (rowIndex === undefined) return;
1238
+ if (rowIndex === undefined) return false;
1105
1239
  const offset = this.#scrollView.getScrollOffset();
1106
1240
  if (rowIndex < offset) this.#scrollView.setScrollOffset(rowIndex);
1107
1241
  else if (rowIndex >= offset + this.#bodyHeight) this.#scrollView.setScrollOffset(rowIndex - this.#bodyHeight + 1);
1242
+ return false;
1108
1243
  }
1109
1244
 
1110
1245
  #sidebarWidth(width: number): number {
@@ -1117,19 +1252,31 @@ export class AnnotationOverlay implements Component {
1117
1252
  );
1118
1253
  }
1119
1254
 
1255
+ #sidebarStart(rows: number): number {
1256
+ return Math.max(0, Math.min(this.#fileIndex - Math.floor(rows / 2), Math.max(0, this.#files.length - rows)));
1257
+ }
1258
+
1259
+ #renderSidebarBadge(file: ReviewDiffFile, count: number): string {
1260
+ const changes = ` +${file.linesAdded}/-${file.linesRemoved}`;
1261
+ const annotations = count ? ` ✎${count}` : "";
1262
+ return this.#theme.fg("dim", changes) + (annotations ? this.#theme.fg("warning", annotations) : "");
1263
+ }
1264
+
1265
+ #sidebarLabelWidth(file: ReviewDiffFile, count: number, width: number): number {
1266
+ const badge = this.#renderSidebarBadge(file, count);
1267
+ return Math.max(0, width - visibleWidth(badge) - 2);
1268
+ }
1269
+
1120
1270
  #renderSidebar(rows: number, width: number): string[] {
1121
- const start = Math.max(
1122
- 0,
1123
- Math.min(this.#fileIndex - Math.floor(rows / 2), Math.max(0, this.#files.length - rows)),
1124
- );
1271
+ const start = this.#sidebarStart(rows);
1125
1272
  return Array.from({ length: rows }, (_, rowIndex) => {
1126
1273
  const index = start + rowIndex;
1127
1274
  const file = this.#files[index];
1128
1275
  if (!file) return "";
1129
1276
  const selected = index === this.#fileIndex;
1130
1277
  const count = this.#annotationCount(index);
1131
- const badge = `${this.#theme.fg("dim", ` +${file.linesAdded}/-${file.linesRemoved}`)}${count ? this.#theme.fg("warning", ` ✎${count}`) : ""}`;
1132
- const available = Math.max(0, width - visibleWidth(badge) - 2);
1278
+ const badge = this.#renderSidebarBadge(file, count);
1279
+ const available = this.#sidebarLabelWidth(file, count, width);
1133
1280
  const label = truncateToWidth(sanitizeStatusText(displayFileLabel(file)), available, Ellipsis.Unicode);
1134
1281
  const cursor = selected ? (this.#focus === "files" ? "› " : "▎ ") : " ";
1135
1282
  const line = fit(`${cursor}${label}${badge}`, width);
@@ -1139,13 +1286,59 @@ export class AnnotationOverlay implements Component {
1139
1286
  });
1140
1287
  }
1141
1288
 
1289
+ #revealSelectedFile(output: string[], width: number): void {
1290
+ const file = this.#currentFile();
1291
+ if (
1292
+ !file ||
1293
+ !this.#sidebarShown ||
1294
+ this.#textSource ||
1295
+ this.#focus !== "files" ||
1296
+ this.#annotating ||
1297
+ this.#annotationChooser
1298
+ ) {
1299
+ return;
1300
+ }
1301
+
1302
+ const pathColumn = 4;
1303
+ const lineWidth = Math.max(0, width - pathColumn - 1);
1304
+ if (lineWidth === 0) return;
1305
+
1306
+ const selectedRow = 1 + this.#fileIndex - this.#sidebarStart(this.#bodyHeight + 1);
1307
+ const interiorTop = 1;
1308
+ const lastBodyRow = this.#bodyHeight + 1;
1309
+ const interiorRows = lastBodyRow - interiorTop + 1;
1310
+ if (selectedRow < interiorTop || selectedRow > lastBodyRow || interiorRows <= 0) return;
1311
+
1312
+ const label = replaceTabs(sanitizeStatusText(displayFileLabel(file)));
1313
+ if (!label) return;
1314
+ const count = this.#annotationCount(this.#fileIndex);
1315
+ if (visibleWidth(label) <= this.#sidebarLabelWidth(file, count, this.#sidebarWidth(width))) return;
1316
+
1317
+ let wrapped = wrapTextWithAnsi(label, lineWidth);
1318
+ if (wrapped.length > interiorRows) {
1319
+ wrapped = wrapped.slice(-interiorRows);
1320
+ wrapped[0] = truncateToWidth(`…${wrapped[0] ?? ""}`, lineWidth, Ellipsis.Omit);
1321
+ }
1322
+ const overlayTop = Math.max(interiorTop, Math.min(selectedRow, lastBodyRow - wrapped.length + 1));
1323
+ const overlayWidth = Math.min(lineWidth, Math.max(0, ...wrapped.map(visibleWidth)) + 1);
1324
+ for (const [index, line] of wrapped.entries()) {
1325
+ const highlighted = this.#theme.bg("selectedBg", this.#theme.bold(fit(line, overlayWidth)));
1326
+ const targetRow = overlayTop + index;
1327
+ const baseLine = output[targetRow];
1328
+ if (overlayWidth > 0 && baseLine !== undefined) {
1329
+ output[targetRow] = compositeLineAt(baseLine, highlighted, pathColumn, overlayWidth, width);
1330
+ }
1331
+ }
1332
+ }
1333
+
1142
1334
  describe(): NativeNode {
1143
1335
  // The terminal lays out the file sidebar; diffs always have one, text never does.
1144
1336
  this.#sidebarShown = !this.#textSource;
1145
1337
  if (!this.#sidebarShown && this.#focus === "files") this.#focus = "diff";
1146
1338
  // Page size for PgUp/PgDn, matching the rendered body height.
1147
- this.#bodyHeight = Math.max(MIN_BODY_ROWS, (process.stdout.rows || 40) - (this.#actions.length + 8));
1148
- this.#editor.focused = this.#annotating;
1339
+ const terminalHeight = this.#tui.terminal.rows;
1340
+ this.#bodyHeight = Math.max(MIN_BODY_ROWS, terminalHeight - (this.#actions.length + 8));
1341
+ this.#editor.focused = this.focused && this.#annotating;
1149
1342
  const body = this.#describeBodyItems();
1150
1343
  const chooser = this.#annotationChooser;
1151
1344
  const sig = [
@@ -1166,7 +1359,10 @@ export class AnnotationOverlay implements Component {
1166
1359
  role: this.#textSource ? "zeta.overlay.annotateText.lines" : "zeta.overlay.codeReview.diff",
1167
1360
  tone: this.#focus === "diff" ? "accent" : undefined,
1168
1361
  });
1169
- const main = node("col", { grow: 1 }, [this.#describeHeader(), lines], "main");
1362
+ // The header row ends in a clickable `esc` at the top right.
1363
+ const title = node("col", { grow: 1, min: { w: 0 } }, [this.#describeHeader()], "title");
1364
+ const head = keyed(rowNode([title, escCloseButton()], { gap: "md", align: "center" }), "head");
1365
+ const main = node("col", { grow: 1 }, [head, lines], "main");
1170
1366
  if (this.#sidebarShown) {
1171
1367
  const files = selectList(
1172
1368
  "files",
@@ -1215,6 +1411,10 @@ export class AnnotationOverlay implements Component {
1215
1411
 
1216
1412
  handleNativeEvent(event: NativeUiEvent): void {
1217
1413
  if (this.#finished || this.#externalOperation) return;
1414
+ if (event.type === "action" && event.act === "close") {
1415
+ this.#escape();
1416
+ return;
1417
+ }
1218
1418
  if (event.type !== "select" && event.type !== "activate") return;
1219
1419
  const activate = event.type === "activate";
1220
1420
  const leaf = leafKey(event.key);
@@ -1244,7 +1444,7 @@ export class AnnotationOverlay implements Component {
1244
1444
  if (index < 0 || index >= rowCount) return;
1245
1445
  this.#focus = "diff";
1246
1446
  this.#sourceIndex = index;
1247
- this.#textViewportDriven = false;
1447
+ this.#viewportDriven = false;
1248
1448
  if (activate) this.#focus = "actions";
1249
1449
  return;
1250
1450
  }
@@ -1404,7 +1604,7 @@ export class AnnotationOverlay implements Component {
1404
1604
  this.#hint("tui.input.submit", "save"),
1405
1605
  this.#hint("tui.input.newLine", "newline"),
1406
1606
  this.#hint("tui.select.cancel", "cancel"),
1407
- this.#hint("app.editor.external", "editor"),
1607
+ { keys: [appExternalEditorKey()], label: "editor" },
1408
1608
  ],
1409
1609
  "annotateHints",
1410
1610
  ),
@@ -1430,6 +1630,7 @@ export class AnnotationOverlay implements Component {
1430
1630
  editNote,
1431
1631
  ]
1432
1632
  : [upDown("select"), this.#hint("tui.select.confirm", "confirm")];
1633
+ if (this.#callbacks.onExternalEditor) hints.push({ keys: [appExternalEditorKey()], label: "editor" });
1433
1634
  hints.push(key("u", "undo"), key("tab", "regions"), this.#hint("tui.select.cancel", "cancel"));
1434
1635
  return [hintsRow(hints)];
1435
1636
  }
@@ -1445,14 +1646,14 @@ export class AnnotationOverlay implements Component {
1445
1646
  }
1446
1647
 
1447
1648
  render(width: number): readonly string[] {
1448
- const terminalHeight = process.stdout.rows || 40;
1649
+ const terminalHeight = this.#tui.terminal.rows;
1449
1650
  this.#sidebarShown = this.#textSource ? false : this.#canShowSidebar(width);
1450
1651
  if (!this.#sidebarShown && this.#focus === "files") this.#focus = "diff";
1451
1652
  const sidebarWidth = this.#sidebarShown ? this.#sidebarWidth(width) : 0;
1452
1653
  const innerWidth = Math.max(1, width - 4);
1453
1654
  const bodyWidth = this.#sidebarShown ? splitBodyWidth(width, sidebarWidth) : innerWidth;
1454
1655
  this.#editor.setMaxHeight(Math.max(1, Math.min(MAX_ANNOTATION_EDITOR_ROWS, terminalHeight - 12)));
1455
- this.#editor.focused = this.#annotating;
1656
+ this.#editor.focused = this.focused && this.#annotating;
1456
1657
  const chooserBaseRows = terminalHeight - (this.#actions.length + 7);
1457
1658
  const chooserFooterRows = this.#annotationChooser
1458
1659
  ? Math.max(0, Math.min(chooserBaseRows, Math.max(3, chooserBaseRows - MIN_BODY_ROWS)))
@@ -1463,14 +1664,15 @@ export class AnnotationOverlay implements Component {
1463
1664
  this.#bodyHeight = this.#annotationChooser
1464
1665
  ? Math.max(0, availableBodyRows)
1465
1666
  : Math.max(MIN_BODY_ROWS, availableBodyRows);
1466
- const renderedBody = this.#renderBody(this.#textSource ? Math.max(1, bodyWidth - 1) : bodyWidth);
1667
+ const bodyContentWidth = Math.max(1, bodyWidth - 1);
1668
+ let renderedBody = this.#renderBody(bodyContentWidth);
1467
1669
  this.#scrollView.setLines(renderedBody.lines);
1468
1670
  this.#scrollView.setHeight(this.#bodyHeight);
1469
- if (this.#textSource) {
1470
- this.#textRenderedRowBySource = renderedBody.renderedRowBySource;
1471
- if (this.#textViewportDriven) this.#syncTextCursorToViewport();
1671
+ if (this.#ensureCursorVisible(renderedBody.renderedRowBySource)) {
1672
+ renderedBody = this.#renderBody(bodyContentWidth);
1673
+ this.#scrollView.setLines(renderedBody.lines);
1674
+ this.#renderedRowBySource = renderedBody.renderedRowBySource;
1472
1675
  }
1473
- this.#ensureCursorVisible(renderedBody.renderedRowBySource);
1474
1676
  const body = this.#scrollView.render(bodyWidth);
1475
1677
  const output: string[] = [];
1476
1678
  if (this.#sidebarShown) {
@@ -1492,6 +1694,7 @@ export class AnnotationOverlay implements Component {
1492
1694
  output.push(divider(width));
1493
1695
  for (const footerLine of footer) output.push(row(footerLine, width));
1494
1696
  output.push(bottomBorder(width));
1697
+ this.#revealSelectedFile(output, width);
1495
1698
  return output;
1496
1699
  }
1497
1700
  }