@linxiraos/pi-tui 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/CHANGELOG.md +188 -125
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/app-keybindings.d.ts +18 -8
  5. package/dist/types/apps/git/diff-pane.d.ts +5 -0
  6. package/dist/types/apps/git/git-tui.d.ts +16 -2
  7. package/dist/types/apps/git/state.d.ts +11 -0
  8. package/dist/types/autocomplete.d.ts +2 -0
  9. package/dist/types/charts/chart-svg.d.ts +7 -13
  10. package/dist/types/chat/advisor-message.d.ts +4 -1
  11. package/dist/types/chat/assistant-message.d.ts +9 -4
  12. package/dist/types/chat/figure-markdown.d.ts +7 -2
  13. package/dist/types/chat/messages.d.ts +3 -1
  14. package/dist/types/chat/read-tool-group.d.ts +2 -2
  15. package/dist/types/chat/table-chart.d.ts +11 -15
  16. package/dist/types/chat/tool-execution.d.ts +32 -20
  17. package/dist/types/chat/user-message.d.ts +3 -2
  18. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  19. package/dist/types/chrome/diff.d.ts +2 -3
  20. package/dist/types/chrome/transcript-container.d.ts +2 -0
  21. package/dist/types/components/image.d.ts +2 -0
  22. package/dist/types/components/markdown.d.ts +49 -3
  23. package/dist/types/components/text.d.ts +2 -0
  24. package/dist/types/debug-server.d.ts +2 -0
  25. package/dist/types/keybinding-matchers.d.ts +6 -0
  26. package/dist/types/multiplexers/tmux.d.ts +1 -0
  27. package/dist/types/multiplexers/types.d.ts +6 -0
  28. package/dist/types/native/backend.d.ts +5 -4
  29. package/dist/types/native/blobs.d.ts +24 -12
  30. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  31. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  32. package/dist/types/overlays/approval-dialog.d.ts +79 -0
  33. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  34. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  35. package/dist/types/overlays/hook-editor.d.ts +7 -2
  36. package/dist/types/overlays/hook-input.d.ts +12 -4
  37. package/dist/types/overlays/job-tree.d.ts +50 -0
  38. package/dist/types/overlays/jobs-panel.d.ts +25 -11
  39. package/dist/types/overlays/login-dialog.d.ts +8 -3
  40. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  41. package/dist/types/overlays/model-browser.d.ts +6 -1
  42. package/dist/types/overlays/model-picker.d.ts +2 -0
  43. package/dist/types/overlays/pause-screen.d.ts +5 -0
  44. package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
  45. package/dist/types/overlays/report-panel.d.ts +5 -1
  46. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  47. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  48. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  49. package/dist/types/overlays/session-selector.d.ts +3 -1
  50. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  51. package/dist/types/prompt/composer.d.ts +21 -20
  52. package/dist/types/prompt/custom-editor.d.ts +20 -10
  53. package/dist/types/prompt/image-references.d.ts +22 -0
  54. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  55. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  56. package/dist/types/prompt/prose-gate.d.ts +3 -0
  57. package/dist/types/prompt/queued-messages.d.ts +1 -1
  58. package/dist/types/prompt/typing-word.d.ts +25 -0
  59. package/dist/types/prompt/welcome.d.ts +26 -6
  60. package/dist/types/render/hanging-wrap.d.ts +7 -0
  61. package/dist/types/render/hyperlink.d.ts +2 -0
  62. package/dist/types/render/render-utils.d.ts +9 -1
  63. package/dist/types/render/utils.d.ts +1 -1
  64. package/dist/types/setup/scenes/splash.d.ts +6 -5
  65. package/dist/types/status-line/context-usage.d.ts +4 -1
  66. package/dist/types/status-line/host.d.ts +6 -4
  67. package/dist/types/status-line/startup.d.ts +6 -6
  68. package/dist/types/status-line/types.d.ts +3 -0
  69. package/dist/types/stdin-buffer.d.ts +7 -0
  70. package/dist/types/terminal-capabilities.d.ts +16 -1
  71. package/dist/types/terminal-multiplexer.d.ts +7 -0
  72. package/dist/types/terminal.d.ts +8 -4
  73. package/dist/types/theme/symbols.d.ts +1 -1
  74. package/dist/types/theme/theme.d.ts +11 -0
  75. package/dist/types/thinking.d.ts +5 -0
  76. package/dist/types/tmux.d.ts +39 -4
  77. package/dist/types/tools/ast-edit.d.ts +2 -2
  78. package/dist/types/tools/ast-grep.d.ts +2 -2
  79. package/dist/types/tools/bash.d.ts +6 -1
  80. package/dist/types/tools/edit.d.ts +2 -0
  81. package/dist/types/tools/eval.d.ts +53 -4
  82. package/dist/types/tools/find.d.ts +14 -4
  83. package/dist/types/tools/glob.d.ts +3 -3
  84. package/dist/types/tools/grep.d.ts +2 -2
  85. package/dist/types/tools/grouped-file-output.d.ts +9 -4
  86. package/dist/types/tools/native-view.d.ts +1 -1
  87. package/dist/types/tools/read.d.ts +32 -10
  88. package/dist/types/tools/renderer.d.ts +2 -0
  89. package/dist/types/tools/task.d.ts +31 -0
  90. package/dist/types/tools/write.d.ts +2 -0
  91. package/dist/types/tui.d.ts +20 -3
  92. package/package.json +10 -10
  93. package/src/app-keybindings.d.ts +18 -8
  94. package/src/app-keybindings.ts +20 -8
  95. package/src/apps/autoresearch-dashboard.ts +444 -27
  96. package/src/apps/debug/raw-sse.ts +2 -2
  97. package/src/apps/git/diff-pane.ts +39 -41
  98. package/src/apps/git/git-tui.ts +80 -14
  99. package/src/apps/git/sidebar.ts +10 -1
  100. package/src/apps/git/state.ts +12 -0
  101. package/src/autocomplete.d.ts +2 -0
  102. package/src/autocomplete.ts +21 -19
  103. package/src/bracketed-paste.ts +9 -2
  104. package/src/charts/chart-svg.d.ts +7 -13
  105. package/src/charts/chart-svg.ts +19 -22
  106. package/src/chat/advisor-message.d.ts +4 -1
  107. package/src/chat/advisor-message.ts +77 -24
  108. package/src/chat/assistant-message.d.ts +9 -4
  109. package/src/chat/assistant-message.ts +127 -31
  110. package/src/chat/bash-execution.ts +7 -2
  111. package/src/chat/figure-markdown.d.ts +7 -2
  112. package/src/chat/figure-markdown.ts +10 -3
  113. package/src/chat/image-loading.ts +9 -6
  114. package/src/chat/messages.d.ts +3 -1
  115. package/src/chat/messages.ts +4 -1
  116. package/src/chat/read-tool-group.d.ts +2 -2
  117. package/src/chat/read-tool-group.ts +97 -42
  118. package/src/chat/svg-figure.ts +3 -1
  119. package/src/chat/svg-source.ts +6 -3
  120. package/src/chat/table-chart.d.ts +11 -15
  121. package/src/chat/table-chart.ts +24 -57
  122. package/src/chat/tool-execution.d.ts +32 -20
  123. package/src/chat/tool-execution.ts +103 -36
  124. package/src/chat/user-message.d.ts +3 -2
  125. package/src/chat/user-message.ts +54 -34
  126. package/src/chrome/countdown-timer.ts +3 -3
  127. package/src/chrome/diff.d.ts +2 -3
  128. package/src/chrome/diff.ts +4 -9
  129. package/src/chrome/transcript-container.d.ts +2 -0
  130. package/src/chrome/transcript-container.ts +212 -28
  131. package/src/components/editor.ts +12 -12
  132. package/src/components/image.d.ts +2 -0
  133. package/src/components/image.ts +54 -20
  134. package/src/components/markdown.d.ts +49 -3
  135. package/src/components/markdown.ts +461 -11
  136. package/src/components/settings-list.ts +2 -4
  137. package/src/components/text.d.ts +2 -0
  138. package/src/components/text.ts +11 -1
  139. package/src/debug-server.d.ts +2 -0
  140. package/src/debug-server.ts +51 -69
  141. package/src/hotkeys-markdown.ts +2 -0
  142. package/src/keybinding-matchers.d.ts +6 -0
  143. package/src/keybinding-matchers.ts +19 -0
  144. package/src/multiplexers/tmux.d.ts +1 -0
  145. package/src/multiplexers/tmux.ts +3 -0
  146. package/src/multiplexers/types.d.ts +6 -0
  147. package/src/multiplexers/types.ts +6 -0
  148. package/src/native/backend.d.ts +5 -4
  149. package/src/native/backend.ts +13 -5
  150. package/src/native/blobs.d.ts +24 -12
  151. package/src/native/blobs.ts +56 -7
  152. package/src/native/reconcile.ts +43 -21
  153. package/src/overlays/advisor-config.ts +259 -8
  154. package/src/overlays/agent-hub-projection.d.ts +10 -5
  155. package/src/overlays/agent-hub-projection.ts +63 -12
  156. package/src/overlays/annotation-overlay.ts +422 -168
  157. package/src/overlays/approval-dialog.ts +389 -0
  158. package/src/overlays/ask-dialog.ts +244 -105
  159. package/src/overlays/btw-history-panel.ts +208 -147
  160. package/src/overlays/extensions/extension-list.ts +4 -6
  161. package/src/overlays/hook-editor.ts +90 -32
  162. package/src/overlays/hook-input.ts +105 -19
  163. package/src/overlays/job-tree.ts +177 -0
  164. package/src/overlays/jobs-panel.ts +222 -142
  165. package/src/overlays/login-dialog.ts +201 -91
  166. package/src/overlays/mcp-add-wizard.ts +562 -279
  167. package/src/overlays/model-browser.ts +32 -7
  168. package/src/overlays/model-hub.ts +4 -10
  169. package/src/overlays/model-picker.ts +58 -21
  170. package/src/overlays/pause-screen.ts +81 -23
  171. package/src/overlays/plan-review-overlay.ts +16 -0
  172. package/src/overlays/plugin-selector.ts +17 -1
  173. package/src/overlays/queue-mode-selector.ts +10 -0
  174. package/src/overlays/report-panel.ts +6 -2
  175. package/src/overlays/reset-usage-selector.ts +130 -21
  176. package/src/overlays/session-account-selector.ts +36 -0
  177. package/src/overlays/session-info-overlay.ts +199 -76
  178. package/src/overlays/session-selector.ts +15 -10
  179. package/src/overlays/settings-selector.ts +3 -0
  180. package/src/overlays/show-images-selector.ts +1 -0
  181. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  182. package/src/overlays/theme-selector.ts +6 -1
  183. package/src/overlays/thinking-selector.ts +8 -4
  184. package/src/overlays/tree-selector.ts +117 -2
  185. package/src/overlays/usage-dashboard.ts +498 -144
  186. package/src/prompt/composer-cache.ts +1 -1
  187. package/src/prompt/composer.ts +194 -74
  188. package/src/prompt/custom-editor.ts +168 -65
  189. package/src/prompt/image-references.d.ts +22 -0
  190. package/src/prompt/image-references.ts +79 -2
  191. package/src/prompt/internal-url-autocomplete.ts +1 -0
  192. package/src/prompt/macos-spelling.ts +28 -5
  193. package/src/prompt/prose-gate.ts +3 -0
  194. package/src/prompt/queued-messages.ts +9 -2
  195. package/src/prompt/tips.txt +1 -2
  196. package/src/prompt/typing-word.ts +59 -0
  197. package/src/prompt/welcome.ts +97 -45
  198. package/src/render/code-cell.ts +18 -5
  199. package/src/render/hanging-wrap.d.ts +7 -0
  200. package/src/render/hanging-wrap.ts +58 -0
  201. package/src/render/hyperlink.d.ts +2 -0
  202. package/src/render/hyperlink.ts +5 -0
  203. package/src/render/render-utils.d.ts +9 -1
  204. package/src/render/render-utils.ts +16 -2
  205. package/src/render/utils.d.ts +1 -1
  206. package/src/render/utils.ts +1 -1
  207. package/src/render/width-aware-text.ts +6 -5
  208. package/src/setup/scenes/splash.ts +22 -8
  209. package/src/status-line/component.ts +23 -10
  210. package/src/status-line/context-usage.ts +176 -73
  211. package/src/status-line/footer.ts +18 -35
  212. package/src/status-line/host.ts +6 -1
  213. package/src/status-line/segments.ts +16 -5
  214. package/src/status-line/startup.ts +6 -6
  215. package/src/status-line/types.ts +1 -0
  216. package/src/stdin-buffer.d.ts +7 -0
  217. package/src/stdin-buffer.ts +11 -1
  218. package/src/terminal-capabilities.d.ts +16 -1
  219. package/src/terminal-capabilities.ts +66 -28
  220. package/src/terminal-multiplexer.d.ts +7 -0
  221. package/src/terminal-multiplexer.ts +14 -0
  222. package/src/terminal.d.ts +8 -4
  223. package/src/terminal.ts +160 -46
  224. package/src/theme/symbols.d.ts +1 -1
  225. package/src/theme/symbols.ts +1 -0
  226. package/src/theme/theme-class.ts +1 -0
  227. package/src/theme/theme.d.ts +11 -0
  228. package/src/theme/theme.ts +46 -14
  229. package/src/thinking.d.ts +5 -0
  230. package/src/thinking.ts +9 -1
  231. package/src/tmux.d.ts +39 -4
  232. package/src/tmux.ts +110 -16
  233. package/src/tools/ask.ts +20 -9
  234. package/src/tools/ast-edit.d.ts +2 -2
  235. package/src/tools/ast-edit.ts +30 -7
  236. package/src/tools/ast-grep.d.ts +2 -2
  237. package/src/tools/ast-grep.ts +39 -35
  238. package/src/tools/bash.d.ts +6 -1
  239. package/src/tools/bash.ts +15 -9
  240. package/src/tools/debug.ts +2 -1
  241. package/src/tools/default-renderer.ts +3 -2
  242. package/src/tools/edit.d.ts +2 -0
  243. package/src/tools/edit.ts +100 -21
  244. package/src/tools/eval.d.ts +53 -4
  245. package/src/tools/eval.ts +173 -137
  246. package/src/tools/find.d.ts +14 -4
  247. package/src/tools/find.ts +71 -18
  248. package/src/tools/glob.d.ts +3 -3
  249. package/src/tools/glob.ts +51 -17
  250. package/src/tools/grep.d.ts +2 -2
  251. package/src/tools/grep.ts +99 -22
  252. package/src/tools/grouped-file-output.d.ts +9 -4
  253. package/src/tools/grouped-file-output.ts +21 -6
  254. package/src/tools/lsp.ts +30 -18
  255. package/src/tools/mcp.ts +2 -2
  256. package/src/tools/native-view.d.ts +1 -1
  257. package/src/tools/native-view.ts +2 -2
  258. package/src/tools/read.d.ts +32 -10
  259. package/src/tools/read.ts +89 -43
  260. package/src/tools/renderer.d.ts +2 -0
  261. package/src/tools/renderer.ts +2 -0
  262. package/src/tools/task.d.ts +31 -0
  263. package/src/tools/task.ts +40 -7
  264. package/src/tools/todo.ts +3 -2
  265. package/src/tools/wait.ts +16 -30
  266. package/src/tools/web-search.ts +2 -1
  267. package/src/tools/write.d.ts +2 -0
  268. package/src/tools/write.ts +36 -12
  269. package/src/tools/yield.ts +2 -2
  270. package/src/tui.d.ts +20 -3
  271. package/src/tui.ts +140 -36
  272. package/src/utils.ts +5 -3
  273. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  274. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
@@ -37,7 +37,7 @@ import { clampSelection, contentRowWidth, padLinesToHeight, renderScrollableList
37
37
  import type { TspScrollBy, TspSpan } from "@linxiraos/pi-wire";
38
38
  import type { NativeChild, NativeNode, NativeScroll, NativeUiEvent } from "../native/node";
39
39
  import { col, md, node, span, text } from "../native/describe";
40
- import { actionBar, actionButton, actionHint, hintsRow, type NativeHint, statusHintsRow } from "../native/overlay";
40
+ import { actionBar, actionButton } from "../native/overlay";
41
41
 
42
42
  interface BtwHistoryPanelOptions {
43
43
  records: readonly BtwHistoryRecord[];
@@ -97,6 +97,25 @@ const STATUS: Record<BtwHistoryRecord["status"], { label: string; color: ThemeCo
97
97
  interrupted: { label: "Interrupted", color: "warning" },
98
98
  };
99
99
 
100
+ const SHEET = { role: "zeta.overlay.btwHistory", anchor: "center", size: "lg", head: "BTW history" } as const;
101
+ const EMPTY_SHEET = { role: "zeta.overlay.btwHistory", anchor: "center", size: "md", head: "BTW history" } as const;
102
+
103
+ /** A status worth naming next to an answer natively; an answered question needs none. */
104
+ function describeStatus(status: BtwHistoryTurn["status"]): TspSpan[] | undefined {
105
+ switch (status) {
106
+ case "running":
107
+ return [span("●", "accent", { fx: "pulse" }), span(" Answering", "accent")];
108
+ case "cancelled":
109
+ return [span("Cancelled", "warning")];
110
+ case "error":
111
+ return [span("Failed", "error")];
112
+ case "interrupted":
113
+ return [span("Interrupted", "warning")];
114
+ case "complete":
115
+ return undefined;
116
+ }
117
+ }
118
+
100
119
  /** Session-local side questions. Selecting or copying never promotes them into chat. */
101
120
  export class BtwHistoryPanel implements Component, Focusable {
102
121
  readonly #options: BtwHistoryPanelOptions;
@@ -283,6 +302,7 @@ export class BtwHistoryPanel implements Component, Focusable {
283
302
  #openComposer(record: BtwHistoryRecord): void {
284
303
  const input = new Input();
285
304
  input.prompt = theme.fg("accent", "Follow up: ");
305
+ input.placeholder = "Ask about this answer…";
286
306
  if (this.#options.spaceHoldKeys !== undefined) input.spaceHold.keys = this.#options.spaceHoldKeys;
287
307
  input.spaceHold.handler = this.#options.spaceHold?.(input);
288
308
  const composer: FollowUpComposer = { recordId: record.id, input, abortController: new AbortController() };
@@ -349,8 +369,9 @@ export class BtwHistoryPanel implements Component, Focusable {
349
369
  }
350
370
  handleInput(data: string): void {
351
371
  if (this.#composer) {
352
- // The input owns every key, including panel shortcuts and pasted text.
353
- this.#composer.input.handleInput(data);
372
+ // The input owns every key it handles, including panel shortcuts and
373
+ // pasted text; the line keys it ignores still scroll the answer above.
374
+ if (!this.#composer.input.handleInput(data)) this.#scrollDetail(data);
354
375
  this.#options.requestRender();
355
376
  return;
356
377
  }
@@ -397,33 +418,41 @@ export class BtwHistoryPanel implements Component, Focusable {
397
418
  else if (matchesKey(data, "home")) this.#select(0);
398
419
  else if (matchesKey(data, "end")) this.#select(this.#records.length - 1);
399
420
  else return;
400
- } else {
401
- const by: TspScrollBy | undefined = matchesSelectUp(data)
402
- ? "line-up"
403
- : matchesSelectDown(data)
404
- ? "line-down"
405
- : matchesSelectPageUp(data)
406
- ? "page-up"
407
- : matchesSelectPageDown(data)
408
- ? "page-down"
409
- : matchesKey(data, "home")
410
- ? "start"
411
- : matchesKey(data, "end")
412
- ? "end"
413
- : undefined;
414
- if (by === "line-up") this.#detail.scroll(-1);
415
- else if (by === "line-down") this.#detail.scroll(1);
416
- else if (by === "page-up") this.#detail.page(-1);
417
- else if (by === "page-down") this.#detail.page(1);
418
- else if (!this.#detail.handleScrollKey(data)) return;
419
- if (by) this.#scroll = { by, n: (this.#scroll?.n ?? 0) + 1 };
420
- this.#followLatest = matchesKey(data, "end");
421
+ } else if (!this.#scrollDetail(data)) {
422
+ return;
421
423
  }
422
424
  this.#options.requestRender();
423
425
  }
424
426
 
425
- /** A large glass sheet titled "BTW history". */
426
- readonly nativeOverlay = { role: "zeta.overlay.btwHistory", size: "lg", head: "BTW history" } as const;
427
+ /** Apply an answer-pane scroll key; a scroll stops following the newest row unless it is End. */
428
+ #scrollDetail(data: string): boolean {
429
+ const by: TspScrollBy | undefined = matchesSelectUp(data)
430
+ ? "line-up"
431
+ : matchesSelectDown(data)
432
+ ? "line-down"
433
+ : matchesSelectPageUp(data)
434
+ ? "page-up"
435
+ : matchesSelectPageDown(data)
436
+ ? "page-down"
437
+ : matchesKey(data, "home")
438
+ ? "start"
439
+ : matchesKey(data, "end")
440
+ ? "end"
441
+ : undefined;
442
+ if (by === "line-up") this.#detail.scroll(-1);
443
+ else if (by === "line-down") this.#detail.scroll(1);
444
+ else if (by === "page-up") this.#detail.page(-1);
445
+ else if (by === "page-down") this.#detail.page(1);
446
+ else if (!this.#detail.handleScrollKey(data)) return false;
447
+ if (by) this.#scroll = { by, n: (this.#scroll?.n ?? 0) + 1 };
448
+ this.#followLatest = matchesKey(data, "end");
449
+ return true;
450
+ }
451
+
452
+ /** A large glass sheet (the list beside the reader), palette-sized while there is nothing to list. */
453
+ get nativeOverlay(): NonNullable<Component["nativeOverlay"]> {
454
+ return this.#records.length > 0 ? SHEET : EMPTY_SHEET;
455
+ }
427
456
 
428
457
  describe(): NativeNode {
429
458
  const record = this.#selected();
@@ -445,55 +474,30 @@ export class BtwHistoryPanel implements Component, Focusable {
445
474
  ) {
446
475
  return memo.node;
447
476
  }
448
- // Plain columns, not `section`s: Tern draws a section as a foldable `##` heading,
449
- // and a flex item's min-content width (a long code line in the answer) would
450
- // crush the list pane. The answer pane may shrink to nothing; the list may not.
451
- // With one side question the list and the pane labels are noise: the answer alone.
452
- const showList = this.#records.length > 1;
453
- const detail = this.#describeDetail(record, copied);
454
- const answerPane: NativeNode = {
455
- ...node(
456
- "col",
457
- { gap: "sm", grow: 1, basis: 0, min: { w: 0 } },
458
- showList ? [text(this.#describeFocusLabel("answer"), { truncate: "end", lines: 1 }), detail] : [detail],
459
- "details",
460
- ),
461
- // Natively the sheet body is the scroller; arrow/page keys on the answer pane move it.
462
- scroll: this.#scroll,
463
- };
464
- const panes: NativeChild[] = showList
465
- ? [
466
- node(
467
- "col",
468
- { gap: "sm", shrink: 0, basis: 0.3, max: { w: "40ch" } },
469
- [text(this.#describeFocusLabel("list"), { truncate: "end", lines: 1 }), this.#describeList()],
470
- "history",
471
- ),
472
- answerPane,
473
- ]
474
- : [answerPane];
475
- const children: NativeChild[] = [node("row", { gap: "lg", align: "start" }, panes, "body")];
476
- if (composer) {
477
- composer.input.focused = this.#focused;
478
- const lines: NativeChild[] = [
479
- text([span(`Topic: ${sanitizeDisplayLine(record?.question ?? "")}`, "dim")], { truncate: "end" }),
480
- ];
481
- if (composer.notice) {
482
- lines.push(text([span(composer.notice, this.#followUpPending ? "dim" : "warning")], { wrap: "word" }));
483
- }
484
- lines.push(composer.input);
485
- children.push(node("col", undefined, lines, "composer"));
477
+ // The body row fills the sheet: the list and the reader scroll on their own,
478
+ // so the list stays put while a long answer scrolls. Plain columns, not
479
+ // `section`s (Tern draws those as foldable `##` headings). With one side
480
+ // question the list is noise: the reader alone.
481
+ const children: NativeChild[] = [];
482
+ if (record) {
483
+ const reader: NativeNode = {
484
+ ...node(
485
+ "col",
486
+ { role: "zeta.btw.reader", grow: 1, basis: 0, min: { w: 0 } },
487
+ [this.#describeThread(record)],
488
+ "details",
489
+ ),
490
+ // Arrow/page keys on the answer pane move its scroller.
491
+ scroll: this.#scroll,
492
+ };
493
+ const panes = this.#records.length > 1 ? [this.#describeList(), reader] : [reader];
494
+ children.push(node("row", { role: "zeta.btw.body" }, panes, "body"));
495
+ } else {
496
+ children.push(this.#describeEmpty());
486
497
  }
487
- children.push({ ...this.#describeFooter(record, canFollowUp, copied), key: "footer" });
488
- // Clicking Close always dismisses the sheet; the Esc keycap only shows where Esc does too.
489
- const running = record !== undefined && getBtwLatestTurn(record).status === "running";
490
- const escapeCloses = !composer && !(running && !this.#options.escapeHides);
491
- children.push({
492
- ...actionBar([null, actionButton("Close", "close", escapeCloses ? { keys: "escape" } : undefined)]),
493
- key: "actions",
494
- });
495
- // The sheet is the frame (`nativeOverlay`): a borderless column.
496
- const described = col(children, { gap: "md" });
498
+ children.push(this.#describeActions(record, canFollowUp, copied));
499
+ // The sheet is the frame (`nativeOverlay`); its last row is the pinned footer.
500
+ const described = col(children, { gap: "none" });
497
501
  this.#native = {
498
502
  records: this.#records,
499
503
  selectedId: this.#selectedId,
@@ -511,8 +515,31 @@ export class BtwHistoryPanel implements Component, Focusable {
511
515
  }
512
516
 
513
517
  handleNativeEvent(event: NativeUiEvent): void {
514
- if (event.type === "action" && event.act === "close") {
515
- this.#options.onClose();
518
+ if (event.type === "action") {
519
+ const record = this.#selected();
520
+ switch (event.act) {
521
+ case "close":
522
+ this.#options.onClose();
523
+ return;
524
+ case "copy-answer":
525
+ if (record && getBtwCopyText(record) !== undefined) this.#options.onCopy(record);
526
+ break;
527
+ case "follow-up":
528
+ if (record) this.openFollowUp(record.id);
529
+ break;
530
+ case "stop":
531
+ if (record && getBtwLatestTurn(record).status === "running") this.#options.onCancel(record);
532
+ break;
533
+ case "send":
534
+ this.#composer?.input.submit();
535
+ break;
536
+ case "discard":
537
+ this.#composer?.input.onEscape?.();
538
+ break;
539
+ default:
540
+ return;
541
+ }
542
+ this.#options.requestRender();
516
543
  return;
517
544
  }
518
545
  if ((event.type !== "select" && event.type !== "activate") || this.#composer) return;
@@ -525,96 +552,130 @@ export class BtwHistoryPanel implements Component, Focusable {
525
552
  this.#options.requestRender();
526
553
  }
527
554
 
528
- #describeFocusLabel(focus: "list" | "answer"): TspSpan[] {
529
- const label = focus === "list" ? `History (${this.#records.length})` : "Details";
530
- return this.#focus === focus ? [span(`${theme.nav.cursor} ${label}`, "accent strong")] : [span(label, "muted")];
531
- }
532
-
555
+ /**
556
+ * The questions, newest first, as two-line palette rows: the question, then
557
+ * its time, follow-up count and any status other than answered. While the
558
+ * reader has the keys the selection rests (`zeta.btw.list.idle`).
559
+ */
533
560
  #describeList(): NativeNode {
534
561
  const items = this.#records.map(record => {
535
- const status = STATUS[getBtwLatestTurn(record).status];
536
- return node(
537
- "item",
538
- {
539
- label: sanitizeDisplayLine(record.question),
540
- detail: [span(this.#timeFormat.format(record.createdAt), "dim")],
541
- value: [span(status.label, status.color)],
542
- },
543
- undefined,
544
- record.id,
545
- );
562
+ const followUps = record.followUps?.length ?? 0;
563
+ const detail: TspSpan[] = [span(this.#timeFormat.format(record.createdAt), "mono")];
564
+ if (followUps > 0) detail.push(span(` · ${followUps} follow-up${followUps === 1 ? "" : "s"}`));
565
+ const status = describeStatus(getBtwLatestTurn(record).status);
566
+ // The question is markdown: its `code` reads as mono, without the backticks.
567
+ const label = sanitizeDisplayLine(record.question)
568
+ .split(/`([^`]+)`/)
569
+ .flatMap((part, index) => (part ? [span(part, index % 2 === 1 ? "mono" : undefined)] : []));
570
+ return node("item", { label, detail, ...(status ? { value: status } : {}) }, undefined, record.id);
546
571
  });
572
+ const role = this.#focus === "list" ? "zeta.btw.list" : "zeta.btw.list.idle";
547
573
  return node(
548
- "list",
549
- {
550
- selected: this.#selectedId ?? null,
551
- empty: [span("No side questions yet. Use /btw QUESTION to start one.", "muted")],
552
- },
553
- items,
554
- "records",
574
+ "col",
575
+ { role: "zeta.btw.history", shrink: 0 },
576
+ [node("list", { role, selected: this.#selectedId ?? null }, items, "records")],
577
+ "history",
555
578
  );
556
579
  }
557
580
 
558
- #describeDetail(record: BtwHistoryRecord | undefined, copied: boolean): NativeNode {
559
- if (!record)
560
- return text([span("No side questions yet. Use /btw QUESTION to start one.", "muted")], { wrap: "word" });
561
- const children: NativeChild[] = [];
562
- if (copied)
563
- children.push(node("text", { spans: [span("✓ Copied to clipboard", "success")] }, undefined, "copied"));
581
+ /** The selected side question as a short document: the asked question titles each answer. */
582
+ #describeThread(record: BtwHistoryRecord): NativeNode {
564
583
  const turns = getBtwTurns(record);
565
- for (let index = 0; index < turns.length; index++) {
566
- if (index > 0) children.push(node("rule", undefined, undefined, `rule-${index}`));
567
- children.push(this.#describeTurn(turns[index]!, `turn-${index}`));
568
- }
569
- return col(children, { gap: "md" });
584
+ const children: NativeChild[] = turns.map((turn, index) => this.#describeTurn(turn, index, `turn-${index}`));
585
+ if (this.#composer) children.push(this.#describeComposer(this.#composer));
586
+ return node("col", { role: "zeta.btw.thread" }, children, "thread");
570
587
  }
571
588
 
572
- #describeTurn(turn: BtwHistoryTurn, key: string): NativeNode {
573
- const status = STATUS[turn.status];
574
- const answer = turn.answer.trim()
575
- ? md(sanitizeDisplayText(turn.answer), { stream: turn.status === "running" })
576
- : text([span(turn.status === "running" ? "Waiting for response…" : "No answer text.", "dim")]);
577
- // Plain labels, not `section`s (Tern draws those as foldable `##` headings);
578
- // the question stays markdown so its inline code and links render.
589
+ #describeTurn(turn: BtwHistoryTurn, index: number, key: string): NativeNode {
590
+ const meta: TspSpan[] =
591
+ index === 0
592
+ ? [span(this.#dateFormat.format(turn.createdAt))]
593
+ : [span("Follow-up · "), span(this.#timeFormat.format(turn.createdAt))];
594
+ const status = describeStatus(turn.status);
595
+ if (status) meta.push(span(" · "), ...status);
596
+ // The question stays markdown so its inline code and links render.
579
597
  const children: NativeChild[] = [
580
- text(
581
- [
582
- span("Question", "accent strong"),
583
- span(" · ", "dim"),
584
- span(status.label, status.color),
585
- span(` · ${this.#dateFormat.format(turn.createdAt)}`, "dim"),
586
- ],
587
- { wrap: "word" },
588
- ),
589
- md(sanitizeDisplayText(turn.question)),
590
- text([span("Answer", "accent strong")]),
591
- answer,
598
+ md(sanitizeDisplayText(turn.question), { role: index === 0 ? "zeta.btw.question" : "zeta.btw.followup" }),
599
+ text(meta, { role: "zeta.btw.meta", truncate: "end", lines: 1 }),
592
600
  ];
593
- if (turn.error) children.push(text([span(sanitizeErrorLine(turn.error), "error")], { wrap: "word" }));
594
- if (turn.status === "interrupted") children.push(text([span("Not resumed in this view.", "muted")]));
595
- return node("col", { gap: "sm" }, children, key);
601
+ // A failed turn with no text says only why it failed.
602
+ if (turn.answer.trim() || !turn.error) {
603
+ const answer = turn.answer.trim()
604
+ ? md(sanitizeDisplayText(turn.answer), { stream: turn.status === "running" })
605
+ : text([span(turn.status === "running" ? "Thinking…" : "No answer text.", "dim")], {
606
+ role: "zeta.btw.pending",
607
+ });
608
+ children.push(node("col", { role: "zeta.btw.answer" }, [answer], "answer"));
609
+ }
610
+ if (turn.error) {
611
+ children.push(text([span(sanitizeErrorLine(turn.error), "error")], { role: "zeta.btw.error", wrap: "word" }));
612
+ }
613
+ if (turn.status === "interrupted") {
614
+ children.push(text([span("Not resumed in this view.", "muted")], { role: "zeta.btw.note" }));
615
+ }
616
+ return node("col", { role: "zeta.btw.turn" }, children, key);
617
+ }
618
+
619
+ /** The follow-up field under the newest answer, where the conversation continues. */
620
+ #describeComposer(composer: FollowUpComposer): NativeNode {
621
+ composer.input.focused = this.#focused;
622
+ const children: NativeChild[] = [composer.input];
623
+ if (composer.notice) {
624
+ children.push(
625
+ text([span(composer.notice, this.#followUpPending ? "dim" : "warning")], {
626
+ role: "zeta.btw.notice",
627
+ wrap: "word",
628
+ }),
629
+ );
630
+ }
631
+ return node("col", { role: "zeta.btw.composer", gap: "sm" }, children, "composer");
632
+ }
633
+
634
+ #describeEmpty(): NativeNode {
635
+ return node(
636
+ "col",
637
+ { role: "zeta.btw.empty", align: "center", gap: "sm" },
638
+ [
639
+ text("No side questions yet", { role: "zeta.btw.empty.title" }),
640
+ text(
641
+ [
642
+ span("Ask one with "),
643
+ span("/btw <question>", "code"),
644
+ span(". The answer stays out of the main conversation."),
645
+ ],
646
+ { role: "zeta.btw.empty.hint", wrap: "word" },
647
+ ),
648
+ ],
649
+ "empty",
650
+ );
596
651
  }
597
652
 
598
- #describeFooter(record: BtwHistoryRecord | undefined, canFollowUp: boolean, copied: boolean): NativeNode {
653
+ /**
654
+ * The footer: what the selected answer allows on the left, Close and the one
655
+ * primary action (Follow up, or Send while composing) in the corner. Keys
656
+ * live in the buttons' tooltips.
657
+ */
658
+ #describeActions(record: BtwHistoryRecord | undefined, canFollowUp: boolean, copied: boolean): NativeNode {
599
659
  if (this.#composer) {
600
- return hintsRow([
601
- actionHint("tui.input.submit", this.#followUpPending ? "starting…" : "send"),
602
- actionHint("tui.select.cancel", "cancel"),
660
+ return actionBar([
661
+ null,
662
+ actionButton("Cancel", "discard", { keys: "escape" }),
663
+ actionButton(this.#followUpPending ? "Sending…" : "Send", "send", { keys: "enter", tone: "accent" }),
603
664
  ]);
604
665
  }
605
- const latest = record ? getBtwLatestTurn(record) : undefined;
606
- const running = latest?.status === "running";
607
- const hints: (NativeHint | undefined)[] = [
608
- actionHint("tui.select.cancel", running && !this.#options.escapeHides ? "cancel" : "close"),
609
- running && this.#options.escapeHides ? { keys: ["x"], label: "cancel" } : undefined,
610
- this.#records.length > 1 ? { keys: ["tab", "ctrl+/"], label: "switch pane" } : undefined,
611
- actionHint(["tui.select.up", "tui.select.down"], this.#focus === "list" ? "select" : "scroll"),
612
- ];
613
- if (canFollowUp) hints.push({ keys: ["f", "enter"], label: "follow up" });
666
+ const running = record !== undefined && getBtwLatestTurn(record).status === "running";
667
+ const hides = this.#options.escapeHides === true;
668
+ const buttons: (NativeNode | null)[] = [];
669
+ if (running) buttons.push(actionButton("Stop answer", "stop", { keys: hides ? "x" : "escape" }));
614
670
  if (record && getBtwCopyText(record) !== undefined) {
615
- hints.push({ keys: ["c"], label: copied ? "copy again" : "copy answer" });
671
+ buttons.push(actionButton(copied ? "Copy again" : "Copy answer", "copy-answer", { keys: "c" }));
672
+ if (copied) buttons.push(text([span("✓ Copied", "success")], { role: "zeta.btw.copied" }));
616
673
  }
617
- return copied ? statusHintsRow([span("✓ copied", "success")], hints) : hintsRow(hints);
674
+ buttons.push(null);
675
+ // Clicking Close always dismisses the sheet; its tooltip names Esc only where Esc does too.
676
+ buttons.push(actionButton("Close", "close", running && !hides ? undefined : { keys: "escape" }));
677
+ if (canFollowUp) buttons.push(actionButton("Follow up", "follow-up", { keys: "f", tone: "accent" }));
678
+ return actionBar(buttons);
618
679
  }
619
680
 
620
681
  #renderList(width: number, height: number): readonly string[] {
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import type { TspPickerGroup, TspPickerItem, TspProps, TspSpan, TspTone } from "@linxiraos/pi-wire";
9
9
  import type { Component } from "../../tui";
10
- import { formatKeyHint } from "../../app-keybindings";
11
10
  import { node, span, stableKey } from "../../native/describe";
12
11
  import type { NativeNode } from "../../native/node";
13
12
  import { pickerFuzzyHits, pickerQuery } from "../../native/picker";
@@ -500,11 +499,10 @@ export class ExtensionList implements Component {
500
499
  ]
501
500
  : undefined,
502
501
  facts: { kind: this.#getKindLabel(ext.kind) },
503
- disabled: effectivelyDisabled
504
- ? masterDisabled
505
- ? "Provider disabled"
506
- : `Disabled · ${formatKeyHint("space")} to enable`
507
- : undefined,
502
+ // Only a disabled provider makes the row inert. An extension switched
503
+ // off by its own toggle must stay activatable, or the click/dblclick
504
+ // that turned it off could never turn it back on.
505
+ disabled: masterDisabled ? "Provider disabled" : undefined,
508
506
  };
509
507
  }
510
508
 
@@ -30,12 +30,21 @@ import { OverlayPanel } from "../chrome/overlay-box";
30
30
  import { FormField } from "../components/form";
31
31
  import { formatKeyHint, formatKeyHints } from "../app-keybindings";
32
32
  import { boundKeys, editorKey, interruptKey } from "../chrome/keybinding-hints";
33
- import { node, span } from "../native/describe";
34
- import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
35
- import { hintsRow, type NativeHint, overlayCard } from "../native/overlay";
33
+ import { formatTooltipKey, type KeyName } from "../key-hint-format";
34
+ import { col, node, span } from "../native/describe";
35
+ import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
36
+ import { actionBar, actionButton } from "../native/overlay";
36
37
  import { plainText } from "../native/spans";
37
38
  import { sanitizeCarriageReturns } from "../render/render-utils";
38
39
 
40
+ /** Root role of the native sheet. */
41
+ const ROLE = "zeta.overlay.hook-editor";
42
+ /**
43
+ * Role prefix of its parts (`.head`, `.title`, `.detail`, `.eyebrow`, `.field`/`.line`); outside
44
+ * `zeta.overlay.*`, which Tern draws as a sheet of its own.
45
+ */
46
+ const PART = "zeta.hook-editor";
47
+
39
48
  /** Most title rows the terminal prompt shows, so a long question cannot push the input row off-screen. */
40
49
  const MAX_PROMPT_TITLE_ROWS = 3;
41
50
  /** Border (2) + padX (2) columns consumed by the panel chrome. */
@@ -109,7 +118,7 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
109
118
  #pendingPastes: { settled: boolean; text: string | undefined }[] = [];
110
119
  #submitQueued = false;
111
120
  #disposed = false;
112
- /** Detail lines, hints and the editor under the title; every part is fixed, so it is built once. */
121
+ /** Head, field and action bar; every part but the editor (which describes itself) is fixed, so it is built once. */
113
122
  #nativeRoot: NativeNode;
114
123
  /** Focus state mirrored to the nested editor during rendering. */
115
124
  focused = false;
@@ -128,7 +137,7 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
128
137
  const question = options?.question;
129
138
  const terminalTitle = question === undefined ? title : boundPromptTitle(`${title}: `, question);
130
139
  const [titleLine = "", ...detailLines] = terminalTitle.split("\n");
131
- super(titleLine, "zeta.overlay.hook-editor");
140
+ super(titleLine, ROLE);
132
141
 
133
142
  this.#tui = tui;
134
143
  this.#onSubmitCallback = onSubmit;
@@ -175,24 +184,24 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
175
184
  this.addChild(this.#field);
176
185
  this.addChild(new Spacer(1));
177
186
 
178
- const externalHint: NativeHint = {
179
- keys: boundKeys("app.editor.external", ["ctrl+g"]).slice(0, 1),
180
- label: "external editor",
181
- };
182
- const nativeHints: NativeHint[] = this.#promptStyle
183
- ? [
184
- { keys: ["enter", primaryFollowUpKey], label: "submit" },
185
- { keys: ["escape"], label: "cancel" },
186
- externalHint,
187
- ]
188
- : [
189
- { keys: followUpKeys, label: "submit" },
190
- { keys: boundKeys("app.interrupt", ["escape"]).slice(0, 1), label: "cancel" },
191
- externalHint,
192
- ];
193
- const nativeChildren: NativeChild[] = [];
187
+ // Native: the field drops its terminal frame for the sheet's, and says what to type while empty.
188
+ const fieldRole = this.#promptStyle ? `${PART}.line` : `${PART}.field`;
189
+ this.#editor.describeLayout = input => ({ role: fieldRole, children: [input], caret: "input" });
190
+ const placeholder = [span(this.#promptStyle ? "Type your answer" : "Type here")];
191
+ this.#editor.describePlaceholder = () => placeholder;
192
+
193
+ const head: NativeChild[] = [];
194
194
  if (question !== undefined) {
195
- nativeChildren.push(
195
+ // The question is the heading, as on the ask sheet; the prompt's own title names the answer above it.
196
+ head.push(
197
+ node(
198
+ "text",
199
+ { spans: [span(plainText(title))], role: `${PART}.eyebrow`, wrap: "none", truncate: "end" },
200
+ undefined,
201
+ "eyebrow",
202
+ ),
203
+ );
204
+ head.push(
196
205
  node(
197
206
  "md",
198
207
  { text: replaceTabs(sanitizeCarriageReturns(question)), role: "zeta.ask.question" },
@@ -200,30 +209,79 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
200
209
  "question",
201
210
  ),
202
211
  );
203
- } else if (detailLines.length > 0) {
204
- nativeChildren.push(
212
+ } else {
213
+ head.push(
205
214
  node(
206
215
  "text",
207
- { spans: [span(plainText(detailLines.join("\n")), "accent")], wrap: "word" },
216
+ { spans: [span(plainText(titleLine))], role: `${PART}.title`, wrap: "word" },
208
217
  undefined,
209
- "detail",
218
+ "title",
210
219
  ),
211
220
  );
221
+ if (detailLines.length > 0) {
222
+ head.push(
223
+ node(
224
+ "text",
225
+ { spans: [span(plainText(detailLines.join("\n")))], wrap: "word", role: `${PART}.detail` },
226
+ undefined,
227
+ "detail",
228
+ ),
229
+ );
230
+ }
212
231
  }
213
- nativeChildren.push(this.#editor, hintsRow(nativeHints));
214
- this.#nativeRoot = overlayCard(
215
- this.nativeRole,
216
- plainText(question === undefined ? titleLine : title),
217
- nativeChildren,
232
+
233
+ // Buttons say what they do; their keys live in the tooltips.
234
+ const tip = (clauses: readonly (readonly [string, KeyName | undefined])[]): string =>
235
+ clauses.map(([label, key]) => (key ? `${label} ${formatTooltipKey(key)}` : label)).join(" · ");
236
+ const submitKey: KeyName = this.#promptStyle ? "enter" : primaryFollowUpKey;
237
+ const newlineKey: KeyName = this.#promptStyle ? "shift+enter" : "enter";
238
+ const cancelKey = this.#promptStyle ? "escape" : (boundKeys("app.interrupt", ["escape"])[0] ?? "escape");
239
+ const buttons: (NativeNode | null)[] = [];
240
+ if (this.#externalEditor) {
241
+ const [externalKey] = boundKeys("app.editor.external", ["ctrl+g"]);
242
+ buttons.push(
243
+ actionButton("Open in editor", "external", {
244
+ ...(externalKey ? { keys: externalKey } : {}),
245
+ title: tip([["Edit in your external editor", externalKey]]),
246
+ }),
247
+ );
248
+ }
249
+ buttons.push(
250
+ null,
251
+ actionButton("Cancel", "cancel", { keys: cancelKey, title: tip([["Cancel", cancelKey]]) }),
252
+ actionButton("Submit", "submit", {
253
+ keys: submitKey,
254
+ tone: "accent",
255
+ title: tip([
256
+ ["Submit", submitKey],
257
+ ["New line", newlineKey],
258
+ ]),
259
+ }),
260
+ );
261
+
262
+ this.#nativeRoot = col(
263
+ [node("col", { role: `${PART}.head`, gap: "xs" }, head, "head"), this.#editor, actionBar(buttons)],
264
+ { role: ROLE },
218
265
  );
219
266
  }
220
267
 
221
- /** The editor (which describes itself) under the title and detail lines, with the key hints below. */
268
+ /**
269
+ * A sheet in the composer's place: the title (or, answering a question, the question under the
270
+ * prompt's name) over a hairline, the editor, and Open in editor · Cancel · Submit.
271
+ */
222
272
  override describe(_cx: DescribeContext): NativeNode {
223
273
  this.#field.focused = this.focused;
224
274
  return this.#nativeRoot;
225
275
  }
226
276
 
277
+ /** The action bar's buttons run what their keys run. */
278
+ handleNativeEvent(event: NativeUiEvent): void {
279
+ if (this.#disposed || event.type !== "action") return;
280
+ if (event.act === "submit") this.#submitCurrentText();
281
+ else if (event.act === "cancel") this.#cancel();
282
+ else if (event.act === "external") void this.#openExternalEditor();
283
+ }
284
+
227
285
  /** Keep the nested editor's software/hardware cursor mode aligned with the dialog focus target. */
228
286
  setUseTerminalCursor(useTerminalCursor: boolean): void {
229
287
  this.#field.setUseTerminalCursor(useTerminalCursor);