@linxiraos/pi-tui 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. package/CHANGELOG.md +245 -224
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/app-keybindings.d.ts +18 -8
  5. package/dist/types/apps/git/diff-pane.d.ts +5 -0
  6. package/dist/types/apps/git/git-tui.d.ts +16 -2
  7. package/dist/types/apps/git/state.d.ts +11 -0
  8. package/dist/types/autocomplete.d.ts +2 -0
  9. package/dist/types/charts/chart-svg.d.ts +7 -13
  10. package/dist/types/chat/advisor-message.d.ts +4 -1
  11. package/dist/types/chat/assistant-message.d.ts +9 -4
  12. package/dist/types/chat/figure-markdown.d.ts +7 -2
  13. package/dist/types/chat/messages.d.ts +3 -1
  14. package/dist/types/chat/read-tool-group.d.ts +2 -2
  15. package/dist/types/chat/table-chart.d.ts +11 -15
  16. package/dist/types/chat/tool-execution.d.ts +32 -20
  17. package/dist/types/chat/user-message.d.ts +3 -2
  18. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  19. package/dist/types/chrome/diff.d.ts +2 -3
  20. package/dist/types/chrome/status-notice.d.ts +2 -0
  21. package/dist/types/chrome/transcript-container.d.ts +2 -0
  22. package/dist/types/components/image.d.ts +2 -0
  23. package/dist/types/components/markdown.d.ts +49 -3
  24. package/dist/types/components/text.d.ts +2 -0
  25. package/dist/types/debug-server.d.ts +2 -0
  26. package/dist/types/keybinding-matchers.d.ts +6 -0
  27. package/dist/types/multiplexers/tmux.d.ts +1 -0
  28. package/dist/types/multiplexers/types.d.ts +6 -0
  29. package/dist/types/native/backend.d.ts +5 -4
  30. package/dist/types/native/blobs.d.ts +24 -12
  31. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  32. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  33. package/dist/types/overlays/approval-dialog.d.ts +79 -0
  34. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  35. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  36. package/dist/types/overlays/hook-editor.d.ts +7 -2
  37. package/dist/types/overlays/hook-input.d.ts +12 -4
  38. package/dist/types/overlays/job-tree.d.ts +50 -0
  39. package/dist/types/overlays/jobs-panel.d.ts +25 -11
  40. package/dist/types/overlays/login-dialog.d.ts +8 -3
  41. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  42. package/dist/types/overlays/model-browser.d.ts +6 -1
  43. package/dist/types/overlays/model-picker.d.ts +2 -0
  44. package/dist/types/overlays/pause-screen.d.ts +5 -0
  45. package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
  46. package/dist/types/overlays/report-panel.d.ts +5 -1
  47. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  48. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  49. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  50. package/dist/types/overlays/session-selector.d.ts +3 -1
  51. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  52. package/dist/types/prompt/composer.d.ts +23 -20
  53. package/dist/types/prompt/custom-editor.d.ts +20 -10
  54. package/dist/types/prompt/image-references.d.ts +22 -0
  55. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  56. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  57. package/dist/types/prompt/prose-gate.d.ts +3 -0
  58. package/dist/types/prompt/queued-messages.d.ts +1 -1
  59. package/dist/types/prompt/typing-word.d.ts +25 -0
  60. package/dist/types/prompt/welcome.d.ts +26 -6
  61. package/dist/types/render/hanging-wrap.d.ts +7 -0
  62. package/dist/types/render/hyperlink.d.ts +2 -0
  63. package/dist/types/render/render-utils.d.ts +9 -1
  64. package/dist/types/render/utils.d.ts +1 -1
  65. package/dist/types/setup/scenes/splash.d.ts +6 -5
  66. package/dist/types/status-line/context-usage.d.ts +4 -1
  67. package/dist/types/status-line/host.d.ts +6 -4
  68. package/dist/types/status-line/startup.d.ts +6 -6
  69. package/dist/types/status-line/types.d.ts +3 -0
  70. package/dist/types/stdin-buffer.d.ts +7 -0
  71. package/dist/types/terminal-capabilities.d.ts +16 -1
  72. package/dist/types/terminal-multiplexer.d.ts +7 -0
  73. package/dist/types/terminal.d.ts +8 -4
  74. package/dist/types/theme/symbols.d.ts +1 -1
  75. package/dist/types/theme/theme.d.ts +11 -0
  76. package/dist/types/thinking.d.ts +5 -0
  77. package/dist/types/tmux.d.ts +39 -4
  78. package/dist/types/tools/ast-edit.d.ts +2 -2
  79. package/dist/types/tools/ast-grep.d.ts +2 -2
  80. package/dist/types/tools/bash.d.ts +6 -1
  81. package/dist/types/tools/edit.d.ts +2 -0
  82. package/dist/types/tools/eval.d.ts +53 -4
  83. package/dist/types/tools/find.d.ts +14 -4
  84. package/dist/types/tools/glob.d.ts +3 -3
  85. package/dist/types/tools/grep.d.ts +2 -2
  86. package/dist/types/tools/grouped-file-output.d.ts +9 -4
  87. package/dist/types/tools/native-view.d.ts +1 -1
  88. package/dist/types/tools/read.d.ts +32 -10
  89. package/dist/types/tools/renderer.d.ts +2 -0
  90. package/dist/types/tools/task.d.ts +31 -0
  91. package/dist/types/tools/web-search-types.d.ts +1 -0
  92. package/dist/types/tools/write.d.ts +2 -0
  93. package/dist/types/tui.d.ts +20 -3
  94. package/package.json +10 -10
  95. package/src/app-keybindings.d.ts +18 -8
  96. package/src/app-keybindings.ts +20 -8
  97. package/src/apps/autoresearch-dashboard.ts +444 -27
  98. package/src/apps/debug/raw-sse.ts +2 -2
  99. package/src/apps/git/diff-pane.ts +39 -41
  100. package/src/apps/git/git-tui.ts +80 -14
  101. package/src/apps/git/sidebar.ts +10 -1
  102. package/src/apps/git/state.ts +12 -0
  103. package/src/autocomplete.d.ts +2 -0
  104. package/src/autocomplete.ts +21 -19
  105. package/src/bracketed-paste.ts +9 -2
  106. package/src/charts/chart-svg.d.ts +7 -13
  107. package/src/charts/chart-svg.ts +19 -22
  108. package/src/charts/table-data.ts +17 -6
  109. package/src/chat/advisor-message.d.ts +4 -1
  110. package/src/chat/advisor-message.ts +77 -24
  111. package/src/chat/assistant-message.d.ts +9 -4
  112. package/src/chat/assistant-message.ts +127 -31
  113. package/src/chat/bash-execution.ts +7 -2
  114. package/src/chat/figure-markdown.d.ts +7 -2
  115. package/src/chat/figure-markdown.ts +10 -3
  116. package/src/chat/image-loading.ts +9 -6
  117. package/src/chat/messages.d.ts +3 -1
  118. package/src/chat/messages.ts +4 -1
  119. package/src/chat/read-tool-group.d.ts +2 -2
  120. package/src/chat/read-tool-group.ts +97 -42
  121. package/src/chat/svg-figure.ts +3 -1
  122. package/src/chat/svg-source.ts +6 -3
  123. package/src/chat/table-chart.d.ts +11 -15
  124. package/src/chat/table-chart.ts +24 -57
  125. package/src/chat/tool-execution.d.ts +32 -20
  126. package/src/chat/tool-execution.ts +103 -36
  127. package/src/chat/user-message.d.ts +3 -2
  128. package/src/chat/user-message.ts +54 -34
  129. package/src/chrome/countdown-timer.ts +3 -3
  130. package/src/chrome/diff.d.ts +2 -3
  131. package/src/chrome/diff.ts +4 -9
  132. package/src/chrome/status-notice.ts +5 -0
  133. package/src/chrome/transcript-container.d.ts +2 -0
  134. package/src/chrome/transcript-container.ts +212 -28
  135. package/src/components/editor.ts +12 -12
  136. package/src/components/image.d.ts +2 -0
  137. package/src/components/image.ts +54 -20
  138. package/src/components/markdown.d.ts +49 -3
  139. package/src/components/markdown.ts +482 -12
  140. package/src/components/settings-list.ts +2 -4
  141. package/src/components/text.d.ts +2 -0
  142. package/src/components/text.ts +11 -1
  143. package/src/debug-server.d.ts +2 -0
  144. package/src/debug-server.ts +51 -69
  145. package/src/hotkeys-markdown.ts +2 -0
  146. package/src/keybinding-matchers.d.ts +6 -0
  147. package/src/keybinding-matchers.ts +19 -0
  148. package/src/multiplexers/tmux.d.ts +1 -0
  149. package/src/multiplexers/tmux.ts +3 -0
  150. package/src/multiplexers/types.d.ts +6 -0
  151. package/src/multiplexers/types.ts +6 -0
  152. package/src/native/backend.d.ts +5 -4
  153. package/src/native/backend.ts +13 -5
  154. package/src/native/blobs.d.ts +24 -12
  155. package/src/native/blobs.ts +56 -7
  156. package/src/native/reconcile.ts +43 -21
  157. package/src/overlays/advisor-config.ts +259 -8
  158. package/src/overlays/agent-hub-projection.d.ts +10 -5
  159. package/src/overlays/agent-hub-projection.ts +63 -12
  160. package/src/overlays/annotation-overlay.ts +422 -168
  161. package/src/overlays/approval-dialog.ts +389 -0
  162. package/src/overlays/ask-dialog.ts +244 -105
  163. package/src/overlays/btw-history-panel.ts +208 -147
  164. package/src/overlays/extensions/extension-list.ts +4 -6
  165. package/src/overlays/hook-editor.ts +90 -32
  166. package/src/overlays/hook-input.ts +105 -19
  167. package/src/overlays/job-tree.ts +177 -0
  168. package/src/overlays/jobs-panel.ts +222 -142
  169. package/src/overlays/login-dialog.ts +201 -91
  170. package/src/overlays/mcp-add-wizard.ts +562 -279
  171. package/src/overlays/model-browser.ts +32 -7
  172. package/src/overlays/model-hub.ts +4 -10
  173. package/src/overlays/model-picker.ts +58 -21
  174. package/src/overlays/pause-screen.ts +81 -23
  175. package/src/overlays/plan-review-overlay.ts +16 -0
  176. package/src/overlays/plugin-selector.ts +17 -1
  177. package/src/overlays/queue-mode-selector.ts +10 -0
  178. package/src/overlays/report-panel.ts +6 -2
  179. package/src/overlays/reset-usage-selector.ts +130 -21
  180. package/src/overlays/session-account-selector.ts +36 -0
  181. package/src/overlays/session-info-overlay.ts +199 -76
  182. package/src/overlays/session-selector.ts +15 -10
  183. package/src/overlays/settings-selector.ts +3 -0
  184. package/src/overlays/show-images-selector.ts +1 -0
  185. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  186. package/src/overlays/theme-selector.ts +6 -1
  187. package/src/overlays/thinking-selector.ts +8 -4
  188. package/src/overlays/tree-selector.ts +117 -2
  189. package/src/overlays/usage-dashboard.ts +498 -144
  190. package/src/prompt/composer-cache.ts +4 -1
  191. package/src/prompt/composer.ts +200 -74
  192. package/src/prompt/custom-editor.ts +168 -65
  193. package/src/prompt/image-references.d.ts +22 -0
  194. package/src/prompt/image-references.ts +79 -2
  195. package/src/prompt/internal-url-autocomplete.ts +1 -0
  196. package/src/prompt/macos-spelling.ts +28 -5
  197. package/src/prompt/prose-gate.ts +3 -0
  198. package/src/prompt/queued-messages.ts +9 -2
  199. package/src/prompt/tips.txt +1 -2
  200. package/src/prompt/typing-word.ts +59 -0
  201. package/src/prompt/welcome.ts +97 -45
  202. package/src/render/code-cell.ts +18 -5
  203. package/src/render/hanging-wrap.d.ts +7 -0
  204. package/src/render/hanging-wrap.ts +58 -0
  205. package/src/render/hyperlink.d.ts +2 -0
  206. package/src/render/hyperlink.ts +5 -0
  207. package/src/render/render-utils.d.ts +9 -1
  208. package/src/render/render-utils.ts +16 -2
  209. package/src/render/utils.d.ts +1 -1
  210. package/src/render/utils.ts +1 -1
  211. package/src/render/width-aware-text.ts +6 -5
  212. package/src/setup/scenes/splash.ts +22 -8
  213. package/src/status-line/component.ts +79 -11
  214. package/src/status-line/context-usage.ts +176 -73
  215. package/src/status-line/footer.ts +18 -35
  216. package/src/status-line/host.ts +6 -1
  217. package/src/status-line/segments.ts +16 -5
  218. package/src/status-line/startup.ts +6 -6
  219. package/src/status-line/types.ts +1 -0
  220. package/src/stdin-buffer.d.ts +7 -0
  221. package/src/stdin-buffer.ts +300 -62
  222. package/src/terminal-capabilities.d.ts +16 -1
  223. package/src/terminal-capabilities.ts +66 -28
  224. package/src/terminal-multiplexer.d.ts +7 -0
  225. package/src/terminal-multiplexer.ts +14 -0
  226. package/src/terminal.d.ts +8 -4
  227. package/src/terminal.ts +190 -63
  228. package/src/theme/symbols.d.ts +1 -1
  229. package/src/theme/symbols.ts +1 -0
  230. package/src/theme/theme-class.ts +1 -0
  231. package/src/theme/theme.d.ts +11 -0
  232. package/src/theme/theme.ts +46 -14
  233. package/src/thinking.d.ts +5 -0
  234. package/src/thinking.ts +9 -1
  235. package/src/tmux.d.ts +39 -4
  236. package/src/tmux.ts +110 -16
  237. package/src/tools/ask.ts +20 -9
  238. package/src/tools/ast-edit.d.ts +2 -2
  239. package/src/tools/ast-edit.ts +30 -7
  240. package/src/tools/ast-grep.d.ts +2 -2
  241. package/src/tools/ast-grep.ts +39 -35
  242. package/src/tools/bash.d.ts +6 -1
  243. package/src/tools/bash.ts +15 -9
  244. package/src/tools/debug.ts +2 -1
  245. package/src/tools/default-renderer.ts +3 -2
  246. package/src/tools/edit.d.ts +2 -0
  247. package/src/tools/edit.ts +100 -21
  248. package/src/tools/eval.d.ts +53 -4
  249. package/src/tools/eval.ts +173 -137
  250. package/src/tools/find.d.ts +14 -4
  251. package/src/tools/find.ts +71 -18
  252. package/src/tools/glob.d.ts +3 -3
  253. package/src/tools/glob.ts +51 -17
  254. package/src/tools/grep.d.ts +2 -2
  255. package/src/tools/grep.ts +99 -22
  256. package/src/tools/grouped-file-output.d.ts +9 -4
  257. package/src/tools/grouped-file-output.ts +21 -6
  258. package/src/tools/lsp.ts +30 -18
  259. package/src/tools/mcp.ts +2 -2
  260. package/src/tools/native-view.d.ts +1 -1
  261. package/src/tools/native-view.ts +2 -2
  262. package/src/tools/read.d.ts +32 -10
  263. package/src/tools/read.ts +89 -43
  264. package/src/tools/renderer.d.ts +2 -0
  265. package/src/tools/renderer.ts +2 -0
  266. package/src/tools/task.d.ts +31 -0
  267. package/src/tools/task.ts +40 -7
  268. package/src/tools/todo.ts +3 -2
  269. package/src/tools/wait.ts +16 -30
  270. package/src/tools/web-search-types.d.ts +1 -0
  271. package/src/tools/web-search-types.ts +1 -0
  272. package/src/tools/web-search.ts +2 -1
  273. package/src/tools/write.d.ts +2 -0
  274. package/src/tools/write.ts +36 -12
  275. package/src/tools/yield.ts +2 -2
  276. package/src/tui.d.ts +20 -3
  277. package/src/tui.ts +160 -43
  278. package/src/utils.ts +5 -3
  279. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  280. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
@@ -40,17 +40,10 @@ import {
40
40
  } from "../chrome/overlay-box";
41
41
  import { appExternalEditorKey, matchesAppExternalEditor } from "../keybinding-matchers";
42
42
  import type { KeyName } from "../key-hint-format";
43
- import { item, keyed, node, row as rowNode, span, text } from "../native/describe";
43
+ import type { TspSpan } from "@linxiraos/pi-wire";
44
+ import { col, item, keyed, node, row as rowNode, span, text } from "../native/describe";
44
45
  import { leafKey, type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
45
- import {
46
- escCloseButton,
47
- hintsRow,
48
- itemIndex,
49
- type NativeHint,
50
- noteSpans,
51
- overlayCard,
52
- selectList,
53
- } from "../native/overlay";
46
+ import { actionButton, hintsRow, itemIndex, type NativeHint, selectList } from "../native/overlay";
54
47
 
55
48
  function fit(text: string, width: number): string {
56
49
  if (width <= 0) return "";
@@ -158,6 +151,65 @@ function isTextSource(value: readonly ReviewDiffFile[] | TextReviewSource): valu
158
151
  return !Array.isArray(value);
159
152
  }
160
153
 
154
+ /** The native sheet: full-pane glass, titled by the review kind with what is under review after it. */
155
+ interface NativeSheet {
156
+ readonly role: "zeta.overlay.codeReview" | "zeta.overlay.annotateText";
157
+ readonly size: "full";
158
+ readonly head: TspSpan[];
159
+ }
160
+ const DIFF_SHEET_TITLE = "Code review";
161
+ const TEXT_SHEET_TITLE = "Annotate text";
162
+
163
+ const NOTE_PLACEHOLDER = "Write a note…";
164
+
165
+ type FileStatus = "added" | "deleted" | "renamed" | "modified";
166
+ const FILE_STATUS_ICON: Record<FileStatus, string> = {
167
+ added: "file-plus",
168
+ deleted: "trash",
169
+ renamed: "forward",
170
+ modified: "file",
171
+ };
172
+ const FILE_STATUS_TONE: Record<FileStatus, "success" | "error" | "info" | undefined> = {
173
+ added: "success",
174
+ deleted: "error",
175
+ renamed: "info",
176
+ modified: undefined,
177
+ };
178
+ const kFileStatus = Symbol("annotation.fileStatus");
179
+
180
+ /** How a file changed, read from its diff header (memoized on the frozen file). */
181
+ function fileStatus(file: ReviewDiffFile & { [kFileStatus]?: FileStatus }): FileStatus {
182
+ let status = file[kFileStatus];
183
+ if (status) return status;
184
+ const hunk = file.rawDiff.indexOf("\n@@");
185
+ const header = file.rawDiff.slice(0, hunk < 0 ? 4096 : hunk);
186
+ status = /^new file mode/m.test(header)
187
+ ? "added"
188
+ : /^deleted file mode/m.test(header)
189
+ ? "deleted"
190
+ : file.oldPath !== undefined && file.newPath !== undefined && file.oldPath !== file.newPath
191
+ ? "renamed"
192
+ : "modified";
193
+ file[kFileStatus] = status;
194
+ return status;
195
+ }
196
+
197
+ /** `src/ui/app.ts` → `{ dir: "src/ui", leaf: "app.ts" }`. */
198
+ function splitPath(path: string): { dir: string; leaf: string } {
199
+ const slash = path.lastIndexOf("/");
200
+ return slash < 0 ? { dir: "", leaf: path } : { dir: path.slice(0, slash), leaf: path.slice(slash + 1) };
201
+ }
202
+
203
+ /** `3 files`, `1 note`. */
204
+ function plural(count: number, noun: string): string {
205
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
206
+ }
207
+
208
+ /** A review mode line, muted, with its `backticked` refs as code chips. */
209
+ function modeSpans(mode: string): TspSpan[] {
210
+ return mode.split("`").flatMap((part, index) => (part ? [span(part, index % 2 === 1 ? "code" : "muted")] : []));
211
+ }
212
+
161
213
  /** A fullscreen, annotated diff picker that only relies on public OMP APIs. */
162
214
  export class AnnotationOverlay implements Focusable {
163
215
  focused = false;
@@ -196,6 +248,7 @@ export class AnnotationOverlay implements Focusable {
196
248
  #nativeRoot: NativeNode | undefined;
197
249
  /** Body the memoized root was built from. */
198
250
  #nativeRootBody: NativeBody | undefined;
251
+ #nativeSheet: NativeSheet | undefined;
199
252
 
200
253
  readonly #tui: TUI;
201
254
  readonly #theme: Theme;
@@ -285,6 +338,7 @@ export class AnnotationOverlay implements Focusable {
285
338
  });
286
339
  this.#editor.setBorderVisible(false);
287
340
  this.#editor.setPromptGutter("> ");
341
+ this.#editor.describePlaceholder = () => NOTE_PLACEHOLDER;
288
342
  this.#editor.setScrollbarVisible(true);
289
343
  this.#editor.onSubmit = value => this.#commitAnnotation(value);
290
344
  this.#resetSourceCursor();
@@ -1331,6 +1385,30 @@ export class AnnotationOverlay implements Focusable {
1331
1385
  }
1332
1386
  }
1333
1387
 
1388
+ /**
1389
+ * The terminal draws the frame: a full-pane glass sheet titled `Code review`
1390
+ * (or `Annotate text`), then what is under review as a quieter subtitle.
1391
+ */
1392
+ get nativeOverlay(): NativeSheet {
1393
+ if (!this.#nativeSheet) {
1394
+ const subtitle = this.#textSource
1395
+ ? [span(sanitizeStatusText(this.#textSource.label), "muted")]
1396
+ : modeSpans(sanitizeStatusText(this.#mode));
1397
+ this.#nativeSheet = {
1398
+ role: this.#textSource ? "zeta.overlay.annotateText" : "zeta.overlay.codeReview",
1399
+ size: "full",
1400
+ head: [span(this.#textSource ? TEXT_SHEET_TITLE : DIFF_SHEET_TITLE), ...subtitle],
1401
+ };
1402
+ }
1403
+ return this.#nativeSheet;
1404
+ }
1405
+
1406
+ /**
1407
+ * The sheet's body: the file rail (totals over the changed files) beside
1408
+ * the current file's diff (text: its numbered lines), the note composer
1409
+ * docked under the lines while a note is open, and a footer of key hints
1410
+ * and the actions.
1411
+ */
1334
1412
  describe(): NativeNode {
1335
1413
  // The terminal lays out the file sidebar; diffs always have one, text never does.
1336
1414
  this.#sidebarShown = !this.#textSource;
@@ -1338,6 +1416,7 @@ export class AnnotationOverlay implements Focusable {
1338
1416
  // Page size for PgUp/PgDn, matching the rendered body height.
1339
1417
  const terminalHeight = this.#tui.terminal.rows;
1340
1418
  this.#bodyHeight = Math.max(MIN_BODY_ROWS, terminalHeight - (this.#actions.length + 8));
1419
+ this.#editor.setMaxHeight(Math.max(1, Math.min(MAX_ANNOTATION_EDITOR_ROWS, terminalHeight - 12)));
1341
1420
  this.#editor.focused = this.focused && this.#annotating;
1342
1421
  const body = this.#describeBodyItems();
1343
1422
  const chooser = this.#annotationChooser;
@@ -1346,73 +1425,55 @@ export class AnnotationOverlay implements Focusable {
1346
1425
  this.#fileIndex,
1347
1426
  this.#sourceIndex,
1348
1427
  this.#actionIndex,
1428
+ this.#bodyHeight,
1349
1429
  this.#annotating ? `annotating:${this.#annotationScope}:${this.#editingAnnotationIndex}` : "",
1350
1430
  chooser ? `chooser:${chooser.selected}:${chooser.entries.join()}` : "",
1351
1431
  ].join("|");
1352
1432
  if (this.#nativeRoot && sig === this.#nativeSig && body === this.#nativeRootBody) return this.#nativeRoot;
1353
1433
 
1354
- const children: NativeChild[] = [];
1434
+ const editing = this.#annotating || chooser !== undefined;
1435
+ const rowCount = this.#textSource ? this.#textLines.length : this.#currentSourceRows().length;
1355
1436
  const lines = selectList("lines", body.items, {
1356
- selected: this.#focus === "diff" ? `l${this.#sourceIndex}` : null,
1357
- virtual: true,
1358
- empty: [span(this.#textSource ? "No text" : "No reviewable files", "dim")],
1359
- role: this.#textSource ? "zeta.overlay.annotateText.lines" : "zeta.overlay.codeReview.diff",
1360
- tone: this.#focus === "diff" ? "accent" : undefined,
1437
+ selected: rowCount > 0 ? `l${this.#sourceIndex}` : null,
1438
+ // Past 200 rows the terminal virtualizes the list itself (fixed one-line rows).
1439
+ max: { lines: this.#bodyHeight },
1440
+ empty: [span(this.#textSource ? "This text is empty" : "Nothing to review", "muted")],
1441
+ role: this.#textSource ? "zeta.review.text" : "zeta.review.diff",
1442
+ tone: this.#focus === "diff" && !editing ? "accent" : undefined,
1361
1443
  });
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");
1444
+ const pane: NativeChild[] = [this.#describePaneHead()];
1445
+ pane.push(lines);
1446
+ const composer = this.#describeComposer();
1447
+ if (composer) pane.push(composer);
1448
+ const main = node("col", { role: "zeta.review.pane", grow: 1, min: { w: 0 } }, pane, "pane");
1449
+ const children: NativeChild[] = [];
1366
1450
  if (this.#sidebarShown) {
1367
- const files = selectList(
1368
- "files",
1369
- this.#files.map((file, index) => {
1370
- const count = this.#annotationCount(index);
1371
- const value = [
1372
- span(`+${file.linesAdded}`, "ins"),
1373
- span("/", "dim"),
1374
- span(`-${file.linesRemoved}`, "del"),
1375
- ];
1376
- if (count) value.push(span(` ✎${count}`, "warning"));
1377
- return item(`f${index}`, { label: [span(displayFileLabel(file), "path")], value });
1378
- }),
1379
- {
1380
- selected: this.#files.length > 0 ? `f${this.#fileIndex}` : null,
1381
- role: "zeta.overlay.codeReview.files",
1382
- tone: this.#focus === "files" ? "accent" : undefined,
1383
- },
1384
- );
1385
- const sidebar = node("col", { max: { w: "34ch" }, shrink: 0 }, [files], "sidebar");
1386
- children.push(keyed(rowNode([sidebar, main], { gap: "md", align: "start" }), "split"));
1451
+ children.push(keyed(rowNode([this.#describeRail(editing), main], { role: "zeta.review.split" }), "split"));
1387
1452
  } else {
1388
1453
  children.push(main);
1389
1454
  }
1390
- children.push(keyed(text([span(sanitizeStatusText(this.#mode), "accent strong")]), "mode"));
1391
- children.push(
1392
- selectList(
1393
- "actions",
1394
- this.#actions.map((label, index) =>
1395
- item(`a${index}`, { label, disabled: this.#actionDisabled(index) || undefined }),
1396
- ),
1397
- { selected: `a${this.#actionIndex}`, tone: this.#focus === "actions" ? "accent" : undefined },
1398
- ),
1399
- );
1400
- children.push(...this.#describeFooter());
1455
+ children.push(this.#describeFooter(editing));
1401
1456
 
1402
1457
  this.#nativeSig = sig;
1403
1458
  this.#nativeRootBody = body;
1404
- this.#nativeRoot = overlayCard(
1405
- this.#textSource ? "zeta.overlay.annotateText" : "zeta.overlay.codeReview",
1406
- this.#textSource ? TEXT_OVERLAY_TITLE : OVERLAY_TITLE,
1407
- children,
1408
- );
1459
+ this.#nativeRoot = col(children);
1409
1460
  return this.#nativeRoot;
1410
1461
  }
1411
1462
 
1412
1463
  handleNativeEvent(event: NativeUiEvent): void {
1413
1464
  if (this.#finished || this.#externalOperation) return;
1414
- if (event.type === "action" && event.act === "close") {
1415
- this.#escape();
1465
+ if (event.type === "action") {
1466
+ if (event.act === "close") {
1467
+ this.#escape();
1468
+ return;
1469
+ }
1470
+ // A draft or the note chooser is open: finish or leave it first.
1471
+ if (this.#annotating || this.#annotationChooser) return;
1472
+ const index =
1473
+ event.act === "review" && !this.#textSource ? 0 : event.act === "paste" ? (this.#textSource ? 0 : 1) : -1;
1474
+ if (index < 0 || this.#actionDisabled(index)) return;
1475
+ this.#actionIndex = index;
1476
+ this.#confirmAction();
1416
1477
  return;
1417
1478
  }
1418
1479
  if (event.type !== "select" && event.type !== "activate") return;
@@ -1445,99 +1506,124 @@ export class AnnotationOverlay implements Focusable {
1445
1506
  this.#focus = "diff";
1446
1507
  this.#sourceIndex = index;
1447
1508
  this.#viewportDriven = false;
1448
- if (activate) this.#focus = "actions";
1449
- return;
1450
- }
1451
- case "actions": {
1452
- const index = itemIndex(event.item, "a");
1453
- if (index < 0 || index >= this.#actions.length || this.#actionDisabled(index)) return;
1454
- this.#focus = "actions";
1455
- this.#actionIndex = index;
1456
- if (activate) this.#confirmAction();
1509
+ // Double-clicking a line opens its note, like `a`.
1510
+ if (activate) this.#startAnnotation("line");
1457
1511
  return;
1458
1512
  }
1459
1513
  }
1460
1514
  }
1461
1515
 
1462
- /** One item per diff/text row; annotation callouts are disabled items before their row. */
1516
+ /**
1517
+ * One item per diff/text row; notes are disabled `warning` items under
1518
+ * the row they belong to (file and whole-text notes lead). Diff rows carry
1519
+ * their kind as the tone (`success` added, `error` removed, `info` hunk).
1520
+ */
1463
1521
  #describeBodyItems(): NativeBody {
1464
1522
  const cached = this.#nativeBody;
1465
1523
  if (cached && cached.fileIndex === this.#fileIndex && cached.rev === this.#annotationRev) return cached;
1466
1524
  const items: NativeNode[] = [];
1467
- const note = (key: string, label: string, body: string) =>
1525
+ const note = (key: string, body: string, scope?: string) =>
1468
1526
  items.push(
1469
1527
  item(key, {
1470
- label: noteSpans(label, sanitizeText(body)),
1528
+ label: [span(sanitizeStatusText(body))],
1529
+ value: scope ? [span(scope, "muted")] : undefined,
1530
+ icon: "pen",
1471
1531
  disabled: true,
1472
1532
  tone: "warning",
1473
- role: "zeta.overlay.codeReview.note",
1533
+ role: "zeta.review.note",
1474
1534
  }),
1475
1535
  );
1536
+ const notice = (key: string, label: string, icon: string) =>
1537
+ items.push(item(key, { label: [span(label, "muted")], icon, disabled: true, tone: "muted" }));
1476
1538
  if (this.#textSource) {
1477
1539
  for (const [index, entry] of this.#textAnnotations.entries()) {
1478
- if (entry.annotation.scope === "text") note(`n${index}`, "text note", entry.annotation.note);
1540
+ if (entry.annotation.scope === "text") note(`n${index}`, entry.annotation.note, "Whole text");
1479
1541
  }
1542
+ const mono = this.#textSource.kind !== "message" && this.#textSource.kind !== "prompt";
1543
+ const width = String(this.#textLines.length).length;
1480
1544
  for (const [sourceIndex, sourceLine] of this.#textLines.entries()) {
1545
+ items.push(
1546
+ item(`l${sourceIndex}`, {
1547
+ label: [span(replaceTabs(sanitizeText(sourceLine)), mono ? "mono" : undefined)],
1548
+ value: [span(String(sourceIndex + 1).padStart(width), "mono")],
1549
+ }),
1550
+ );
1481
1551
  for (const [index, entry] of this.#textAnnotations.entries()) {
1482
1552
  if (entry.annotation.scope === "line" && entry.sourceIndex === sourceIndex) {
1483
- note(`n${index}`, "note", entry.annotation.note);
1553
+ note(`n${index}`, entry.annotation.note);
1484
1554
  }
1485
1555
  }
1486
- items.push(item(`l${sourceIndex}`, { label: sanitizeText(sourceLine) }));
1487
1556
  }
1488
1557
  } else {
1489
1558
  const file = this.#currentFile();
1490
1559
  if (file) {
1491
1560
  for (const [index, entry] of this.#annotations.entries()) {
1492
1561
  if (entry.fileIndex === this.#fileIndex && entry.annotation.scope === "file") {
1493
- note(`n${index}`, "file note", entry.annotation.note);
1562
+ note(`n${index}`, entry.annotation.note, "File note");
1494
1563
  }
1495
1564
  }
1496
1565
  if (file.isBinary) {
1497
- items.push(
1498
- item("binary", { label: [span("Binary diff; no annotatable source rows", "dim")], disabled: true }),
1499
- );
1566
+ notice("binary", "Binary file: no lines to annotate, but a file note works", "binary");
1500
1567
  } else if (file.rows.length === 0) {
1501
- items.push(
1502
- item("empty", {
1503
- label: [span("No diff hunks; this may be a rename-only change", "dim")],
1504
- disabled: true,
1505
- }),
1568
+ notice("empty", "No hunks: this looks like a rename-only change", "forward");
1569
+ }
1570
+ let lineWidth = 1;
1571
+ for (const diffRow of file.isBinary ? [] : file.rows) {
1572
+ if (!isSourceRow(diffRow)) continue;
1573
+ lineWidth = Math.max(
1574
+ lineWidth,
1575
+ String(diffRow.oldLine ?? "").length,
1576
+ String(diffRow.newLine ?? "").length,
1506
1577
  );
1507
1578
  }
1579
+ // The old/new line gutter is the item's value (drawn first); rows without numbers keep its width.
1580
+ const blankGutter = [span(" ".repeat(lineWidth * 2 + 1), "mono")];
1508
1581
  let sourceIndex = 0;
1509
1582
  for (const [rowIndex, diffRow] of file.isBinary ? [] : file.rows.entries()) {
1510
1583
  if (!isSourceRow(diffRow)) {
1511
- const s = diffRow.kind === "hunk" ? "accent" : "dim";
1512
- items.push(item(`r${rowIndex}`, { label: [span(sanitizeText(diffRow.raw), s)], disabled: true }));
1584
+ if (diffRow.kind === "hunk") {
1585
+ const match = /^(@@[^@]*@@)\s?(.*)$/.exec(sanitizeText(diffRow.raw));
1586
+ const label = match
1587
+ ? [span(match[1]!, "mono"), span(match[2] ? ` ${replaceTabs(match[2])}` : "", "muted mono")]
1588
+ : [span(sanitizeText(diffRow.raw), "mono")];
1589
+ items.push(item(`r${rowIndex}`, { label, value: blankGutter, disabled: true, tone: "info" }));
1590
+ } else {
1591
+ items.push(
1592
+ item(`r${rowIndex}`, {
1593
+ label: [span("No newline at end of file", "muted")],
1594
+ value: blankGutter,
1595
+ disabled: true,
1596
+ tone: "muted",
1597
+ }),
1598
+ );
1599
+ }
1513
1600
  continue;
1514
1601
  }
1602
+ const added = diffRow.kind === "added";
1603
+ const removed = diffRow.kind === "removed";
1604
+ const oldCell = String((added ? undefined : diffRow.oldLine) ?? "").padStart(lineWidth);
1605
+ const newCell = String((removed ? undefined : diffRow.newLine) ?? "").padStart(lineWidth);
1606
+ const color: DiffColor = added ? "toolDiffAdded" : removed ? "toolDiffRemoved" : "toolDiffContext";
1607
+ items.push(
1608
+ item(`l${sourceIndex}`, {
1609
+ label: [
1610
+ span(added ? "+ " : removed ? "- " : " ", `${color} mono`),
1611
+ span(replaceTabs(sanitizeText(diffRow.content)), "mono"),
1612
+ ],
1613
+ value: [span(`${oldCell} ${newCell}`, "mono")],
1614
+ tone: added ? "success" : removed ? "error" : undefined,
1615
+ }),
1616
+ );
1617
+ // A line's notes sit under it, like review comments.
1515
1618
  for (const [index, entry] of this.#annotations.entries()) {
1516
1619
  if (
1517
1620
  entry.fileIndex === this.#fileIndex &&
1518
1621
  entry.sourceIndex === sourceIndex &&
1519
1622
  entry.annotation.scope === "line"
1520
1623
  ) {
1521
- note(`n${index}`, "note", entry.annotation.note);
1624
+ note(`n${index}`, entry.annotation.note);
1522
1625
  }
1523
1626
  }
1524
- const marker = diffRow.kind === "added" ? "+" : diffRow.kind === "removed" ? "-" : " ";
1525
- const lineNumber = diffRow.kind === "removed" ? diffRow.oldLine : (diffRow.newLine ?? diffRow.oldLine);
1526
- const color: DiffColor =
1527
- diffRow.kind === "added"
1528
- ? "toolDiffAdded"
1529
- : diffRow.kind === "removed"
1530
- ? "toolDiffRemoved"
1531
- : "toolDiffContext";
1532
- items.push(
1533
- item(`l${sourceIndex}`, {
1534
- label: [
1535
- span(marker, `${color} mono`),
1536
- span(`${lineNumber ?? ""} `, "num dim mono"),
1537
- span(sanitizeText(diffRow.content), `${color} mono`),
1538
- ],
1539
- }),
1540
- );
1541
1627
  sourceIndex++;
1542
1628
  }
1543
1629
  }
@@ -1546,93 +1632,261 @@ export class AnnotationOverlay implements Focusable {
1546
1632
  return this.#nativeBody;
1547
1633
  }
1548
1634
 
1549
- #describeHeader(): NativeNode {
1635
+ /** The file rail: the totals (file count, notes, `+a −r`) over the changed files. */
1636
+ #describeRail(editing: boolean): NativeNode {
1637
+ let added = 0;
1638
+ let removed = 0;
1639
+ for (const file of this.#files) {
1640
+ added += file.linesAdded;
1641
+ removed += file.linesRemoved;
1642
+ }
1643
+ const total: NativeChild[] = [text([span(plural(this.#files.length, "file"))], { grow: 1, wrap: "none" })];
1644
+ if (this.#annotations.length > 0) {
1645
+ total.push(
1646
+ text([span(plural(this.#annotations.length, "note"))], { role: "zeta.review.count", wrap: "none" }),
1647
+ );
1648
+ }
1649
+ total.push(
1650
+ text([span(`+${added}`, "toolDiffAdded mono"), span(" "), span(`−${removed}`, "toolDiffRemoved mono")], {
1651
+ wrap: "none",
1652
+ }),
1653
+ );
1654
+ const files = selectList(
1655
+ "files",
1656
+ this.#files.map((file, index) => {
1657
+ const { dir, leaf } = splitPath(displayFileLabel(file));
1658
+ const status = fileStatus(file);
1659
+ const count = this.#annotationCount(index);
1660
+ const value: TspSpan[] = [];
1661
+ if (count) value.push(span(`✎ ${count}`, "warning"), span(" "));
1662
+ if (file.isBinary) value.push(span("bin", "muted"));
1663
+ else
1664
+ value.push(
1665
+ span(`+${file.linesAdded}`, "toolDiffAdded mono"),
1666
+ span(" "),
1667
+ span(`−${file.linesRemoved}`, "toolDiffRemoved mono"),
1668
+ );
1669
+ return item(`f${index}`, {
1670
+ label: [span(sanitizeStatusText(leaf))],
1671
+ detail: dir ? [span(sanitizeStatusText(dir))] : undefined,
1672
+ value,
1673
+ icon: file.isBinary ? "binary" : FILE_STATUS_ICON[status],
1674
+ tone: FILE_STATUS_TONE[status],
1675
+ });
1676
+ }),
1677
+ {
1678
+ selected: this.#files.length > 0 ? `f${this.#fileIndex}` : null,
1679
+ max: { lines: this.#bodyHeight },
1680
+ empty: [span("No files", "muted")],
1681
+ role: "zeta.review.files",
1682
+ tone: this.#focus === "files" && !editing ? "accent" : undefined,
1683
+ },
1684
+ );
1685
+ return node(
1686
+ "col",
1687
+ { role: "zeta.review.rail" },
1688
+ [node("row", { role: "zeta.review.total", gap: "sm", align: "center" }, total, "total"), files],
1689
+ "rail",
1690
+ );
1691
+ }
1692
+
1693
+ /**
1694
+ * Over the lines: the current file's full path, how it changed, its note
1695
+ * count and `+a −r` (text: its line and note counts).
1696
+ */
1697
+ #describePaneHead(): NativeNode {
1698
+ const children: NativeChild[] = [];
1699
+ const props = { role: "zeta.review.head", gap: "sm", align: "center" } as const;
1550
1700
  if (this.#textSource) {
1551
- const count = this.#textAnnotations.length;
1552
- const spans = [span(sanitizeStatusText(this.#textSource.label), "strong")];
1553
- if (count) spans.push(span(` ✎${count}`, "dim"));
1554
- return keyed(text(spans, { truncate: "end" }), "header");
1701
+ children.push(text([span(plural(this.#textLines.length, "line"))], { grow: 1, wrap: "none" }));
1702
+ const notes = this.#textAnnotations.length;
1703
+ if (notes) children.push(text([span(plural(notes, "note"))], { role: "zeta.review.count", wrap: "none" }));
1704
+ return node("row", props, children, "head");
1555
1705
  }
1556
1706
  const file = this.#currentFile();
1557
- if (!file) return keyed(text([span("No reviewable files", "dim")]), "header");
1707
+ if (!file) return node("row", props, [text([span("No reviewable files", "muted")])], "head");
1708
+ const status = fileStatus(file);
1709
+ children.push(
1710
+ text([span(sanitizeStatusText(displayFileLabel(file)), "path")], { truncate: "middle", min: { w: 0 } }),
1711
+ );
1712
+ const what =
1713
+ status === "renamed" && file.oldPath
1714
+ ? `Renamed from ${sanitizeStatusText(file.oldPath)}`
1715
+ : status === "added"
1716
+ ? "New file"
1717
+ : status === "deleted"
1718
+ ? "Deleted"
1719
+ : file.isBinary
1720
+ ? "Binary"
1721
+ : undefined;
1722
+ if (what) {
1723
+ children.push(
1724
+ text([span(what)], {
1725
+ role: "zeta.review.status",
1726
+ tone: FILE_STATUS_TONE[status],
1727
+ truncate: "end",
1728
+ min: { w: 0 },
1729
+ }),
1730
+ );
1731
+ }
1732
+ children.push(node("spacer", { grow: 1 }));
1558
1733
  const count = this.#annotationCount(this.#fileIndex);
1559
- const spans = [
1560
- span(sanitizeStatusText(displayFileLabel(file)), "strong path"),
1561
- span(` +${file.linesAdded}`, "ins"),
1562
- span("/", "dim"),
1563
- span(`-${file.linesRemoved}`, "del"),
1564
- ];
1565
- if (count) spans.push(span(` ✎${count}`, "dim"));
1566
- return keyed(text(spans, { truncate: "middle" }), "header");
1734
+ if (count) children.push(text([span(plural(count, "note"))], { role: "zeta.review.count", wrap: "none" }));
1735
+ if (!file.isBinary) {
1736
+ children.push(
1737
+ text(
1738
+ [
1739
+ span(`+${file.linesAdded}`, "toolDiffAdded mono"),
1740
+ span(" "),
1741
+ span(`−${file.linesRemoved}`, "toolDiffRemoved mono"),
1742
+ ],
1743
+ { wrap: "none" },
1744
+ ),
1745
+ );
1746
+ }
1747
+ return node("row", props, children, "head");
1567
1748
  }
1568
1749
 
1569
- #describeFooter(): NativeChild[] {
1750
+ /** Docked under the lines: the note being written (what it is on, then the field) or the note chooser. */
1751
+ #describeComposer(): NativeNode | undefined {
1570
1752
  const chooser = this.#annotationChooser;
1571
1753
  if (chooser) {
1572
1754
  const entries = chooser.entries.map((index, i) => {
1573
1755
  const entry = chooser.kind === "text" ? this.#textAnnotations[index] : this.#annotations[index];
1574
- const location = !entry
1756
+ // Every candidate is on the current file (or text), so the place is just the line.
1757
+ const annotation = entry?.annotation;
1758
+ const location = !annotation
1575
1759
  ? ""
1576
- : "fileIndex" in entry
1577
- ? this.#annotationLocation(entry)
1578
- : this.#textAnnotationLocation(entry);
1760
+ : annotation.scope === "file"
1761
+ ? "File note"
1762
+ : annotation.scope === "text"
1763
+ ? "Whole text"
1764
+ : "line" in annotation
1765
+ ? `Line ${annotation.line}`
1766
+ : annotation.newLine !== undefined
1767
+ ? `Line ${annotation.newLine}`
1768
+ : `Removed line ${annotation.oldLine ?? ""}`.trim();
1579
1769
  return item(`c${i}`, {
1580
- label: sanitizeStatusText(entry?.annotation.note.split(/\r?\n/, 1)[0] ?? ""),
1581
- detail: location,
1770
+ label: sanitizeStatusText(annotation?.note.split(/\r?\n/, 1)[0] ?? ""),
1771
+ value: location,
1772
+ icon: "pen",
1582
1773
  });
1583
1774
  });
1584
- return [
1585
- keyed(text([span("Edit annotation", "accent strong")]), "chooserHead"),
1586
- selectList("chooser", entries, { selected: `c${chooser.selected}` }),
1587
- hintsRow(
1588
- [
1589
- this.#hint(["tui.select.up", "tui.select.down"], "choose"),
1590
- this.#hint("tui.select.confirm", "edit"),
1591
- this.#hint("tui.select.cancel", "cancel"),
1592
- ],
1593
- "chooserHints",
1594
- ),
1595
- ];
1775
+ return node(
1776
+ "col",
1777
+ { role: "zeta.review.composer" },
1778
+ [
1779
+ keyed(text([span("Edit which note?", "strong")]), "caption"),
1780
+ selectList("chooser", entries, { selected: `c${chooser.selected}`, tone: "accent" }),
1781
+ ],
1782
+ "chooser",
1783
+ );
1596
1784
  }
1597
- if (this.#annotating) {
1598
- const { action, location } = this.#annotationCaption();
1599
- return [
1600
- keyed(text([span(`${action} `, "dim"), span(location, "accent")], { truncate: "end" }), "annotateHead"),
1601
- this.#editor,
1602
- hintsRow(
1603
- [
1604
- this.#hint("tui.input.submit", "save"),
1605
- this.#hint("tui.input.newLine", "newline"),
1606
- this.#hint("tui.select.cancel", "cancel"),
1607
- { keys: [appExternalEditorKey()], label: "editor" },
1608
- ],
1609
- "annotateHints",
1785
+ if (!this.#annotating) return undefined;
1786
+ return node(
1787
+ "col",
1788
+ { role: "zeta.review.composer" },
1789
+ [keyed(text(this.#noteCaption(), { truncate: "end" }), "caption"), this.#editor],
1790
+ "composer",
1791
+ );
1792
+ }
1793
+
1794
+ /** Where the open note lands: `Note on line 42 in src/app.ts`, `Edit note on src/app.ts`, `Note on the whole text`. */
1795
+ #noteCaption(): TspSpan[] {
1796
+ const verb = span(this.#editingAnnotationIndex === undefined ? "Note on " : "Edit note on ", "muted");
1797
+ if (this.#textSource) {
1798
+ return this.#annotationScope === "text"
1799
+ ? [verb, span("the whole text", "strong")]
1800
+ : [verb, span(`line ${this.#sourceIndex + 1}`, "strong")];
1801
+ }
1802
+ const file = this.#currentFile();
1803
+ const path = file ? sanitizeStatusText(displayFileLabel(file)) : "file";
1804
+ if (this.#annotationScope === "file") return [verb, span(path, "path")];
1805
+ const source = this.#currentSourceRows()[this.#sourceIndex];
1806
+ const where =
1807
+ source?.kind === "removed"
1808
+ ? `removed line ${source.oldLine ?? ""}`
1809
+ : `line ${source?.newLine ?? source?.oldLine ?? ""}`;
1810
+ return [verb, span(where.trim(), "strong"), span(" in ", "muted"), span(path, "path")];
1811
+ }
1812
+
1813
+ /**
1814
+ * The footer: key hints for what has focus, then Cancel and the actions
1815
+ * (the primary last). Each button's key lives in its tooltip; the action
1816
+ * picked from the keyboard is marked `pick`, an unavailable one `drop`.
1817
+ */
1818
+ #describeFooter(editing: boolean): NativeNode {
1819
+ const pick = (index: number): "pick" | "drop" | undefined =>
1820
+ editing || this.#actionDisabled(index)
1821
+ ? "drop"
1822
+ : this.#focus === "actions" && this.#actionIndex === index
1823
+ ? "pick"
1824
+ : undefined;
1825
+ const marked = (button: NativeNode, mark: "pick" | "drop" | undefined): NativeNode =>
1826
+ mark ? ({ ...button, p: { ...button.p, mark } } as NativeNode) : button;
1827
+ const hasNotes = this.#textSource ? this.#textAnnotations.length > 0 : this.#annotations.length > 0;
1828
+ const pasteTitle = hasNotes ? PASTE_CODE_REVIEW_ACTION : "Add a note to paste it into the prompt";
1829
+ const buttons: NativeNode[] = [actionButton("Cancel", "close", { keys: "escape" })];
1830
+ if (this.#textSource) {
1831
+ buttons.push(
1832
+ marked(actionButton("Paste into prompt", "paste", { tone: "accent", title: pasteTitle }), pick(0)),
1833
+ );
1834
+ } else {
1835
+ buttons.push(
1836
+ marked(actionButton("Paste into prompt", "paste", { title: pasteTitle }), pick(1)),
1837
+ marked(
1838
+ actionButton("Review with LLM", "review", { tone: "accent", title: CONTINUE_CODE_REVIEW_ACTION }),
1839
+ pick(0),
1610
1840
  ),
1611
- ];
1841
+ );
1612
1842
  }
1843
+ return node(
1844
+ "row",
1845
+ { role: "zeta.actions", gap: "sm", align: "center" },
1846
+ [hintsRow(this.#footerHints()), ...buttons],
1847
+ "actions",
1848
+ );
1849
+ }
1850
+
1851
+ /** At most a handful of hints, for the region (or draft) that has the keys. */
1852
+ #footerHints(): (NativeHint | undefined)[] {
1613
1853
  const key = (keys: KeyName | readonly KeyName[], label: string): NativeHint => ({
1614
1854
  keys: typeof keys === "string" ? [keys] : keys,
1615
1855
  label,
1616
1856
  });
1617
1857
  const upDown = (label: string) => this.#hint(["tui.select.up", "tui.select.down"], label);
1618
- const editNote = key("e", "edit note");
1619
- const hints: (NativeHint | undefined)[] =
1620
- this.#focus === "files"
1621
- ? [upDown("file"), this.#hint("tui.select.confirm", "diff"), key(["a", "shift+a"], "file note"), editNote]
1622
- : this.#focus === "diff"
1623
- ? [
1624
- upDown("line"),
1625
- key("shift", "faster"),
1626
- this.#hint(["tui.select.pageUp", "tui.select.pageDown"], "page"),
1627
- key(["g", "shift+g"], "ends"),
1628
- key("a", "line note"),
1629
- key("shift+a", this.#textSource ? "text note" : "file note"),
1630
- editNote,
1631
- ]
1632
- : [upDown("select"), this.#hint("tui.select.confirm", "confirm")];
1633
- if (this.#callbacks.onExternalEditor) hints.push({ keys: [appExternalEditorKey()], label: "editor" });
1634
- hints.push(key("u", "undo"), key("tab", "regions"), this.#hint("tui.select.cancel", "cancel"));
1635
- return [hintsRow(hints)];
1858
+ if (this.#annotationChooser) {
1859
+ return [upDown("choose"), this.#hint("tui.select.confirm", "edit"), this.#hint("tui.select.cancel", "back")];
1860
+ }
1861
+ if (this.#annotating) {
1862
+ return [
1863
+ this.#hint("tui.input.submit", "save"),
1864
+ this.#hint("tui.input.newLine", "new line"),
1865
+ this.#hint("tui.select.cancel", "discard"),
1866
+ this.#callbacks.onAnnotationExternalEditor ? key(appExternalEditorKey(), "editor") : undefined,
1867
+ ];
1868
+ }
1869
+ const undo = this.#undoStack.length > 0 ? key("u", "undo") : undefined;
1870
+ if (this.#focus === "files") {
1871
+ return [
1872
+ upDown("file"),
1873
+ this.#hint("tui.select.confirm", "open"),
1874
+ key("a", "file note"),
1875
+ undo,
1876
+ key("tab", "pane"),
1877
+ ];
1878
+ }
1879
+ if (this.#focus === "diff") {
1880
+ return [
1881
+ upDown("line"),
1882
+ key("a", "note"),
1883
+ key("shift+a", this.#textSource ? "note on all" : "file note"),
1884
+ this.#annotationCandidates().length > 0 ? key("e", "edit note") : undefined,
1885
+ undo,
1886
+ key("tab", this.#textSource ? "actions" : "pane"),
1887
+ ];
1888
+ }
1889
+ return [upDown("choose"), this.#hint("tui.select.confirm", "run"), key("tab", "pane")];
1636
1890
  }
1637
1891
 
1638
1892
  /** Keycap hint for the primary keys this overlay's keybindings bind to `actions`. */