@agi-fans/dsh-tui 0.20.0 → 0.22.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 (319) hide show
  1. package/lib/CHANGELOG.md +249 -3
  2. package/lib/chrome/box.d.ts +47 -16
  3. package/lib/chrome/box.js +172 -130
  4. package/lib/chrome/box.js.map +1 -1
  5. package/lib/chrome/code-highlight.d.ts +15 -0
  6. package/lib/chrome/code-highlight.js +60 -4
  7. package/lib/chrome/code-highlight.js.map +1 -1
  8. package/lib/chrome/command-output.d.ts +29 -2
  9. package/lib/chrome/command-output.js +100 -4
  10. package/lib/chrome/command-output.js.map +1 -1
  11. package/lib/chrome/main-screen-renderer.d.ts +18 -0
  12. package/lib/chrome/main-screen-renderer.js +58 -5
  13. package/lib/chrome/main-screen-renderer.js.map +1 -1
  14. package/lib/chrome/markdown.js +30 -10
  15. package/lib/chrome/markdown.js.map +1 -1
  16. package/lib/chrome/relative-time.d.ts +17 -0
  17. package/lib/chrome/relative-time.js +36 -0
  18. package/lib/chrome/relative-time.js.map +1 -1
  19. package/lib/chrome/renderer.d.ts +9 -0
  20. package/lib/chrome/renderer.js +1 -1
  21. package/lib/chrome/renderer.js.map +1 -1
  22. package/lib/chrome/secret-mask.d.ts +35 -0
  23. package/lib/chrome/secret-mask.js +180 -0
  24. package/lib/chrome/secret-mask.js.map +1 -0
  25. package/lib/chrome/status-config.d.ts +161 -51
  26. package/lib/chrome/status-config.js +237 -126
  27. package/lib/chrome/status-config.js.map +1 -1
  28. package/lib/chrome/status-line.d.ts +3 -2
  29. package/lib/chrome/status-line.js +105 -63
  30. package/lib/chrome/status-line.js.map +1 -1
  31. package/lib/chrome/terminal-colors.d.ts +85 -0
  32. package/lib/chrome/terminal-colors.js +230 -0
  33. package/lib/chrome/terminal-colors.js.map +1 -0
  34. package/lib/chrome/terminal-images.d.ts +9 -1
  35. package/lib/chrome/terminal-images.js +27 -4
  36. package/lib/chrome/terminal-images.js.map +1 -1
  37. package/lib/chrome/theme.d.ts +32 -9
  38. package/lib/chrome/theme.js +616 -520
  39. package/lib/chrome/theme.js.map +1 -1
  40. package/lib/chrome/tool-args.d.ts +1 -1
  41. package/lib/chrome/tool-args.js +3 -1
  42. package/lib/chrome/tool-args.js.map +1 -1
  43. package/lib/chrome/tool-renderers.d.ts +2 -0
  44. package/lib/chrome/tool-renderers.js +69 -3
  45. package/lib/chrome/tool-renderers.js.map +1 -1
  46. package/lib/chrome/tools-list.d.ts +1 -4
  47. package/lib/chrome/tools-list.js +1 -41
  48. package/lib/chrome/tools-list.js.map +1 -1
  49. package/lib/chrome/welcome-tips.d.ts +20 -7
  50. package/lib/chrome/welcome-tips.js +22 -41
  51. package/lib/chrome/welcome-tips.js.map +1 -1
  52. package/lib/commands/auth-ids.d.ts +25 -0
  53. package/lib/commands/auth-ids.js +27 -0
  54. package/lib/commands/auth-ids.js.map +1 -0
  55. package/lib/commands/auth-input.d.ts +34 -0
  56. package/lib/commands/auth-input.js +96 -0
  57. package/lib/commands/auth-input.js.map +1 -0
  58. package/lib/commands/auth-providers.d.ts +39 -0
  59. package/lib/commands/auth-providers.js +125 -0
  60. package/lib/commands/auth-providers.js.map +1 -0
  61. package/lib/commands/auth.d.ts +16 -22
  62. package/lib/commands/auth.js +346 -299
  63. package/lib/commands/auth.js.map +1 -1
  64. package/lib/commands/btw.d.ts +27 -0
  65. package/lib/commands/btw.js +59 -0
  66. package/lib/commands/btw.js.map +1 -0
  67. package/lib/commands/diff.js +30 -5
  68. package/lib/commands/diff.js.map +1 -1
  69. package/lib/commands/export.d.ts +1 -1
  70. package/lib/commands/export.js +5 -3
  71. package/lib/commands/export.js.map +1 -1
  72. package/lib/commands/files.d.ts +17 -1
  73. package/lib/commands/files.js +69 -61
  74. package/lib/commands/files.js.map +1 -1
  75. package/lib/commands/integrations.js +18 -6
  76. package/lib/commands/integrations.js.map +1 -1
  77. package/lib/commands/jobs.d.ts +19 -4
  78. package/lib/commands/jobs.js +50 -7
  79. package/lib/commands/jobs.js.map +1 -1
  80. package/lib/commands/model.js +109 -76
  81. package/lib/commands/model.js.map +1 -1
  82. package/lib/commands/permission.js +46 -13
  83. package/lib/commands/permission.js.map +1 -1
  84. package/lib/commands/plugins.js +196 -42
  85. package/lib/commands/plugins.js.map +1 -1
  86. package/lib/commands/schedule.d.ts +29 -0
  87. package/lib/commands/schedule.js +228 -0
  88. package/lib/commands/schedule.js.map +1 -0
  89. package/lib/commands/session-configuration.d.ts +1 -1
  90. package/lib/commands/session-configuration.js +29 -28
  91. package/lib/commands/session-configuration.js.map +1 -1
  92. package/lib/commands/session.js +75 -80
  93. package/lib/commands/session.js.map +1 -1
  94. package/lib/commands/terminal.d.ts +24 -4
  95. package/lib/commands/terminal.js +38 -94
  96. package/lib/commands/terminal.js.map +1 -1
  97. package/lib/commands/trajectory.js +4 -1
  98. package/lib/commands/trajectory.js.map +1 -1
  99. package/lib/definition.d.ts +150 -11
  100. package/lib/index.d.ts +1 -0
  101. package/lib/index.js +1 -0
  102. package/lib/index.js.map +1 -1
  103. package/lib/input/actions.d.ts +51 -0
  104. package/lib/input/actions.js +120 -0
  105. package/lib/input/actions.js.map +1 -0
  106. package/lib/input/editor-discovery.js +2 -2
  107. package/lib/input/editor-discovery.js.map +1 -1
  108. package/lib/input/editor.d.ts +0 -10
  109. package/lib/input/editor.js +11 -9
  110. package/lib/input/editor.js.map +1 -1
  111. package/lib/input/image-paste.d.ts +19 -1
  112. package/lib/input/image-paste.js +82 -1
  113. package/lib/input/image-paste.js.map +1 -1
  114. package/lib/input/keybindings-config.d.ts +15 -4
  115. package/lib/input/keybindings-config.js +57 -35
  116. package/lib/input/keybindings-config.js.map +1 -1
  117. package/lib/input/keys.d.ts +18 -0
  118. package/lib/input/keys.js +48 -3
  119. package/lib/input/keys.js.map +1 -1
  120. package/lib/input/wheel-scroll.d.ts +46 -0
  121. package/lib/input/wheel-scroll.js +69 -0
  122. package/lib/input/wheel-scroll.js.map +1 -0
  123. package/lib/prompt-intents.d.ts +182 -0
  124. package/lib/prompt-intents.js +127 -0
  125. package/lib/prompt-intents.js.map +1 -0
  126. package/lib/runtime/agent-profile.d.ts +5 -0
  127. package/lib/runtime/agent-profile.js +12 -0
  128. package/lib/runtime/agent-profile.js.map +1 -1
  129. package/lib/runtime/approval-rules.d.ts +59 -0
  130. package/lib/runtime/approval-rules.js +179 -0
  131. package/lib/runtime/approval-rules.js.map +1 -0
  132. package/lib/runtime/btw-policy.d.ts +7 -0
  133. package/lib/runtime/btw-policy.js +18 -0
  134. package/lib/runtime/btw-policy.js.map +1 -0
  135. package/lib/runtime/btw.d.ts +47 -0
  136. package/lib/runtime/btw.js +200 -0
  137. package/lib/runtime/btw.js.map +1 -0
  138. package/lib/runtime/cancellable.d.ts +75 -0
  139. package/lib/runtime/cancellable.js +76 -0
  140. package/lib/runtime/cancellable.js.map +1 -0
  141. package/lib/runtime/composer-images.js +5 -4
  142. package/lib/runtime/composer-images.js.map +1 -1
  143. package/lib/runtime/composer-shell.d.ts +30 -0
  144. package/lib/runtime/composer-shell.js +150 -0
  145. package/lib/runtime/composer-shell.js.map +1 -0
  146. package/lib/runtime/file-recovery-local.d.ts +73 -0
  147. package/lib/runtime/file-recovery-local.js +351 -0
  148. package/lib/runtime/file-recovery-local.js.map +1 -0
  149. package/lib/runtime/file-recovery-recording.d.ts +23 -0
  150. package/lib/runtime/file-recovery-recording.js +131 -0
  151. package/lib/runtime/file-recovery-recording.js.map +1 -0
  152. package/lib/runtime/file-recovery-review.d.ts +3 -0
  153. package/lib/runtime/file-recovery-review.js +130 -0
  154. package/lib/runtime/file-recovery-review.js.map +1 -0
  155. package/lib/runtime/file-recovery-store.d.ts +83 -0
  156. package/lib/runtime/file-recovery-store.js +329 -0
  157. package/lib/runtime/file-recovery-store.js.map +1 -0
  158. package/lib/runtime/file-recovery-worker.d.ts +22 -0
  159. package/lib/runtime/file-recovery-worker.js +153 -0
  160. package/lib/runtime/file-recovery-worker.js.map +1 -0
  161. package/lib/runtime/file-review.d.ts +6 -3
  162. package/lib/runtime/file-review.js +44 -7
  163. package/lib/runtime/file-review.js.map +1 -1
  164. package/lib/runtime/herdr-agent.d.ts +4 -13
  165. package/lib/runtime/herdr-agent.js +19 -63
  166. package/lib/runtime/herdr-agent.js.map +1 -1
  167. package/lib/runtime/plain-tui.d.ts +2 -0
  168. package/lib/runtime/plain-tui.js +7 -1
  169. package/lib/runtime/plain-tui.js.map +1 -1
  170. package/lib/runtime/plugin-settings.d.ts +25 -2
  171. package/lib/runtime/plugin-settings.js +329 -31
  172. package/lib/runtime/plugin-settings.js.map +1 -1
  173. package/lib/runtime/provider-local.d.ts +81 -24
  174. package/lib/runtime/provider-local.js +1807 -608
  175. package/lib/runtime/provider-local.js.map +1 -1
  176. package/lib/runtime/runner.js +52 -12
  177. package/lib/runtime/runner.js.map +1 -1
  178. package/lib/runtime/session-archive.d.ts +6 -0
  179. package/lib/runtime/session-archive.js +129 -3
  180. package/lib/runtime/session-archive.js.map +1 -1
  181. package/lib/runtime/session-runtime.js +1 -1
  182. package/lib/runtime/session-runtime.js.map +1 -1
  183. package/lib/runtime/settings-preferences.d.ts +5 -0
  184. package/lib/runtime/settings-preferences.js +34 -0
  185. package/lib/runtime/settings-preferences.js.map +1 -0
  186. package/lib/runtime/startup-notices.js +2 -0
  187. package/lib/runtime/startup-notices.js.map +1 -1
  188. package/lib/runtime/terminal-modes.d.ts +47 -0
  189. package/lib/runtime/terminal-modes.js +86 -0
  190. package/lib/runtime/terminal-modes.js.map +1 -0
  191. package/lib/runtime/terminal-notifications.d.ts +3 -5
  192. package/lib/runtime/terminal-notifications.js +4 -8
  193. package/lib/runtime/terminal-notifications.js.map +1 -1
  194. package/lib/runtime/terminal-probe.d.ts +36 -0
  195. package/lib/runtime/terminal-probe.js +37 -0
  196. package/lib/runtime/terminal-probe.js.map +1 -0
  197. package/lib/runtime/terminal-session.d.ts +57 -0
  198. package/lib/runtime/terminal-session.js +164 -0
  199. package/lib/runtime/terminal-session.js.map +1 -0
  200. package/lib/runtime/terminal-state.d.ts +29 -0
  201. package/lib/runtime/terminal-state.js +62 -0
  202. package/lib/runtime/terminal-state.js.map +1 -0
  203. package/lib/runtime/tool-presentation.js +50 -14
  204. package/lib/runtime/tool-presentation.js.map +1 -1
  205. package/lib/session/external-agents.d.ts +5 -0
  206. package/lib/session/external-agents.js +27 -0
  207. package/lib/session/external-agents.js.map +1 -0
  208. package/lib/session/feature-toggles.d.ts +1 -1
  209. package/lib/session/feature-toggles.js +1 -1
  210. package/lib/session/interaction-adapter.d.ts +1 -6
  211. package/lib/session/interaction-adapter.js +95 -18
  212. package/lib/session/interaction-adapter.js.map +1 -1
  213. package/lib/session/message-queue.js +1 -1
  214. package/lib/session/message-queue.js.map +1 -1
  215. package/lib/session/model-readiness.d.ts +16 -0
  216. package/lib/session/model-readiness.js +43 -0
  217. package/lib/session/model-readiness.js.map +1 -0
  218. package/lib/session/package-metadata.d.ts +8 -0
  219. package/lib/session/package-metadata.js +8 -0
  220. package/lib/session/package-metadata.js.map +1 -1
  221. package/lib/session/project-context.d.ts +5 -0
  222. package/lib/session/project-context.js +36 -0
  223. package/lib/session/project-context.js.map +1 -1
  224. package/lib/session/session-controller.d.ts +10 -13
  225. package/lib/session/session-controller.js +183 -98
  226. package/lib/session/session-controller.js.map +1 -1
  227. package/lib/session/session-library.js +2 -0
  228. package/lib/session/session-library.js.map +1 -1
  229. package/lib/session/session-tree.d.ts +3 -0
  230. package/lib/session/session-tree.js +3 -1
  231. package/lib/session/session-tree.js.map +1 -1
  232. package/lib/session/subagent-roster.d.ts +2 -1
  233. package/lib/session/subagent-roster.js +24 -3
  234. package/lib/session/subagent-roster.js.map +1 -1
  235. package/lib/session/subagent-tracker.d.ts +6 -2
  236. package/lib/session/subagent-tracker.js +49 -2
  237. package/lib/session/subagent-tracker.js.map +1 -1
  238. package/lib/session/tui-settings.d.ts +47 -13
  239. package/lib/session/tui-settings.js +76 -34
  240. package/lib/session/tui-settings.js.map +1 -1
  241. package/lib/session/working-directory.d.ts +4 -0
  242. package/lib/session/working-directory.js +4 -0
  243. package/lib/session/working-directory.js.map +1 -0
  244. package/lib/session/worktrees.d.ts +13 -0
  245. package/lib/session/worktrees.js +49 -0
  246. package/lib/session/worktrees.js.map +1 -0
  247. package/lib/views/approval-rule.d.ts +3 -0
  248. package/lib/views/approval-rule.js +4 -0
  249. package/lib/views/approval-rule.js.map +1 -0
  250. package/lib/views/at-complete.d.ts +8 -0
  251. package/lib/views/at-complete.js +64 -2
  252. package/lib/views/at-complete.js.map +1 -1
  253. package/lib/views/autocomplete.d.ts +22 -2
  254. package/lib/views/autocomplete.js +70 -40
  255. package/lib/views/autocomplete.js.map +1 -1
  256. package/lib/views/copy-targets.d.ts +13 -14
  257. package/lib/views/copy-targets.js +42 -54
  258. package/lib/views/copy-targets.js.map +1 -1
  259. package/lib/views/document-reader.js +18 -5
  260. package/lib/views/document-reader.js.map +1 -1
  261. package/lib/views/event-views.d.ts +1 -1
  262. package/lib/views/event-views.js +60 -6
  263. package/lib/views/event-views.js.map +1 -1
  264. package/lib/views/file-actions.d.ts +12 -0
  265. package/lib/views/file-actions.js +72 -0
  266. package/lib/views/file-actions.js.map +1 -0
  267. package/lib/views/history-search.js +15 -21
  268. package/lib/views/history-search.js.map +1 -1
  269. package/lib/views/hotkeys.d.ts +8 -1
  270. package/lib/views/hotkeys.js +53 -26
  271. package/lib/views/hotkeys.js.map +1 -1
  272. package/lib/views/list-navigation.d.ts +62 -0
  273. package/lib/views/list-navigation.js +73 -0
  274. package/lib/views/list-navigation.js.map +1 -0
  275. package/lib/views/list-view.d.ts +99 -0
  276. package/lib/views/list-view.js +165 -0
  277. package/lib/views/list-view.js.map +1 -0
  278. package/lib/views/prompt-selector.d.ts +34 -4
  279. package/lib/views/prompt-selector.js +270 -134
  280. package/lib/views/prompt-selector.js.map +1 -1
  281. package/lib/views/session-files.d.ts +1 -2
  282. package/lib/views/session-files.js +1 -11
  283. package/lib/views/session-files.js.map +1 -1
  284. package/lib/views/settings-list.d.ts +51 -10
  285. package/lib/views/settings-list.js +267 -315
  286. package/lib/views/settings-list.js.map +1 -1
  287. package/lib/views/trajectory-timeline.d.ts +72 -0
  288. package/lib/views/trajectory-timeline.js +133 -0
  289. package/lib/views/trajectory-timeline.js.map +1 -0
  290. package/lib/views/trajectory.d.ts +15 -4
  291. package/lib/views/trajectory.js +557 -123
  292. package/lib/views/trajectory.js.map +1 -1
  293. package/lib/views/transcript-export.js +9 -0
  294. package/lib/views/transcript-export.js.map +1 -1
  295. package/lib/views/transcript-render.d.ts +97 -25
  296. package/lib/views/transcript-render.js +542 -170
  297. package/lib/views/transcript-render.js.map +1 -1
  298. package/lib/views/transcript-search.d.ts +39 -6
  299. package/lib/views/transcript-search.js +46 -11
  300. package/lib/views/transcript-search.js.map +1 -1
  301. package/lib/views/transcript-types.d.ts +63 -5
  302. package/lib/views/transcript-types.js +23 -11
  303. package/lib/views/transcript-types.js.map +1 -1
  304. package/package.json +95 -65
  305. package/lib/commands/loop.d.ts +0 -52
  306. package/lib/commands/loop.js +0 -307
  307. package/lib/commands/loop.js.map +0 -1
  308. package/lib/commands/steer.d.ts +0 -5
  309. package/lib/commands/steer.js +0 -24
  310. package/lib/commands/steer.js.map +0 -1
  311. package/lib/runtime/terminal-reader.d.ts +0 -11
  312. package/lib/runtime/terminal-reader.js +0 -87
  313. package/lib/runtime/terminal-reader.js.map +0 -1
  314. package/lib/views/agent-hub.d.ts +0 -32
  315. package/lib/views/agent-hub.js +0 -206
  316. package/lib/views/agent-hub.js.map +0 -1
  317. package/lib/views/copy-selector.d.ts +0 -51
  318. package/lib/views/copy-selector.js +0 -139
  319. package/lib/views/copy-selector.js.map +0 -1
@@ -6,8 +6,9 @@ import { rankSearchResults } from "../input/fuzzy-search.js";
6
6
  import { renderEditor, renderFramedBlock } from "../chrome/box.js";
7
7
  import { renderMarkdown } from "../chrome/markdown.js";
8
8
  import { BOX, SYMBOL } from "../chrome/theme.js";
9
- import { padToWidth, truncateToWidth, visibleWidth, wrapText } from "../chrome/width.js";
9
+ import { padToWidth, sliceCells, stripAnsi, truncateToWidth, visibleWidth, wrapTextStable } from "../chrome/width.js";
10
10
  import { formatHotkeyKeys, formatOverlayHint } from "./hotkey-format.js";
11
+ import { renderListRow } from "./list-view.js";
11
12
  /** Maximum option rows retained in the prompt overlay before it windows. */
12
13
  export const PROMPT_SELECTOR_MAX_VISIBLE = 10;
13
14
  // Every key the prompt handlers accept, covering the inline card, the full-screen
@@ -52,7 +53,7 @@ export function maskPromptSecret(input) {
52
53
  }
53
54
  /** Options matching the current full-screen selector query. */
54
55
  export function filteredPromptOptions(request, query, collapsed) {
55
- if (request.presentation === 'fullscreen-tree')
56
+ if (request.presentation === 'tree')
56
57
  return promptTreeRows(request, collapsed, query).map(row => row.option);
57
58
  const options = request.options ?? [];
58
59
  if (request.filterable !== true)
@@ -80,11 +81,17 @@ function treePreview(option, theme, width) {
80
81
  cache = new Map();
81
82
  previewCache.set(option, cache);
82
83
  }
83
- const key = `${width}:${theme.name}:${theme.colors}:${theme.trueColor}`;
84
+ const key = `${width}:${theme.key}`;
84
85
  const cached = cache.get(key);
85
86
  if (cached !== undefined)
86
87
  return cached;
87
- const lines = renderMarkdown(option.preview ?? option.label, theme, width);
88
+ const source = option.preview ?? option.label;
89
+ // One default for one field: plain text unless the caller says markdown. The
90
+ // tree page used to render markdown unconditionally while the card rendered
91
+ // text, so the same `preview` meant two things depending on the surface.
92
+ const lines = option.previewFormat === 'markdown'
93
+ ? renderMarkdown(source, theme, width)
94
+ : wrapTextStable(source, width);
88
95
  if (cache.size >= 4)
89
96
  cache.clear();
90
97
  cache.set(key, lines);
@@ -93,7 +100,7 @@ function treePreview(option, theme, width) {
93
100
  function treePageLayout(request, width, height) {
94
101
  const actions = (request.actions ?? []).map(item => `${item.key} ${item.label}`).join(' · ');
95
102
  const hint = `↑↓ · ←→ fold${width >= 90 ? ' · type search' : ''}${actions === '' ? '' : ` · ${actions}`}${width >= 90 ? ' · Ctrl+↑↓ preview' : ''}`;
96
- const hints = wrapText(hint, width - 4).slice(0, Math.max(1, Math.floor((height - 6) / 2)));
103
+ const hints = wrapTextStable(hint, width - 4).slice(0, Math.max(1, Math.floor((height - 6) / 2)));
97
104
  const middle = height - 6 - hints.length;
98
105
  const listHeight = width >= 90 ? middle : Math.max(1, Math.floor((middle - 1) / 2));
99
106
  const previewHeight = width >= 90 ? middle : middle - listHeight - 1;
@@ -121,11 +128,31 @@ export function renderPromptTreePage(state, theme, width, height, input, inputCu
121
128
  const row = rows[at];
122
129
  const marker = at === selected ? theme.fg('accent', SYMBOL.cursor + ' ') : ' ';
123
130
  const fold = row.expandable ? state.collapsed?.has(row.option.value ?? row.option.label) && input === '' ? '▸ ' : '▾ ' : ' ';
124
- const badge = row.option.badge?.label === undefined ? '' : `[${row.option.badge.label}] `;
125
- const text = badge + row.option.label;
126
131
  const prefixLimit = Math.max(1, Math.floor(listWidth / 3));
127
132
  const prefix = row.prefix.length <= prefixLimit ? row.prefix : '…' + row.prefix.slice(-(prefixLimit - 1));
128
- list.push(fit(marker + theme.fg('dim', prefix + fold) + (at === selected ? theme.fg('accent', text) : text), listWidth));
133
+ const head = marker + theme.fg('dim', prefix + fold);
134
+ /**
135
+ * A row says everything the option knows.
136
+ *
137
+ * The card mode already drew the badge in its tone and the description in a
138
+ * column; this page drew neither, so a tree opened full screen lost the
139
+ * agent's phase colour and its elapsed time — the two things that tell two
140
+ * similar-looking nodes apart.
141
+ */
142
+ const badge = row.option.badge === undefined
143
+ ? ''
144
+ : theme.fg(row.option.badge.tone ?? 'muted', `[${row.option.badge.label}] `);
145
+ const label = badge + row.option.label;
146
+ // A heading is focusable but not an answer, so it never takes the cursor colour.
147
+ const text = row.option.disabled === true
148
+ ? theme.fg('muted', label)
149
+ : at === selected ? theme.fg('accent', label) : theme.fg('text', label);
150
+ const used = visibleWidth(head) + visibleWidth(label);
151
+ const room = listWidth - used - 2;
152
+ const description = row.option.description === undefined || row.option.description === '' || room < 8
153
+ ? ''
154
+ : theme.fg('dim', ' ' + truncateToWidth(row.option.description, room));
155
+ list.push(fit(head + text + description, listWidth));
129
156
  }
130
157
  if (rows.length === 0)
131
158
  list.push(fit(theme.fg('dim', state.request.emptyText ?? 'No matching conversation turns.'), listWidth));
@@ -175,26 +202,43 @@ export function movePromptTree(state, direction, query) {
175
202
  }
176
203
  /** Start on a matching node instead of a context-only ancestor. */
177
204
  export function searchPromptSelection(state, query) {
178
- const selected = state.request.presentation === 'fullscreen-tree'
205
+ const selected = state.request.presentation === 'tree'
179
206
  ? Math.max(0, promptTreeRows(state.request, state.collapsed, query).findIndex(row => row.matched)) : 0;
180
207
  return { ...state, selected, previewScroll: 0 };
181
208
  }
182
- /** Visible option window centered around the selected row. */
183
209
  export function promptSelectorVisibleRange(count, selected, maxVisible = PROMPT_SELECTOR_MAX_VISIBLE) {
184
210
  const max = Math.max(1, maxVisible);
185
211
  const index = Math.max(0, Math.min(selected, Math.max(0, count - 1)));
186
212
  const start = Math.max(0, Math.min(index - Math.floor(max / 2), Math.max(0, count - max)));
187
213
  return { start, end: Math.min(count, start + max) };
188
214
  }
189
- function optionRow(option, index, state, theme, width) {
190
- const active = index === state.selected;
191
- const cursor = active ? theme.fg('accent', SYMBOL.cursor + ' ') : ' ';
215
+ function optionRow(option, index, state, theme, width,
216
+ /**
217
+ * The option's index in the request, when the visible row is a filtered one.
218
+ *
219
+ * Checked state is keyed by the request's order, not the filtered list's: a
220
+ * query that hides the row above must not move every checkbox below it.
221
+ */
222
+ identity = index) {
223
+ // The multi-select marker is part of the label so the shared row keeps one
224
+ // cursor cell and one description gap for every prompt, checked or not.
225
+ if (option.disabled === true) {
226
+ // Same reason as the tree: focusable, dimmed, and not a value.
227
+ const row = { label: option.label, ...(option.description === undefined ? {} : { description: option.description }), secondary: true };
228
+ return renderListRow(row, theme, Math.max(1, width), { selected: index === state.selected });
229
+ }
230
+ const checked = state.checked.has(identity);
192
231
  const marker = state.request.multiSelect === true
193
- ? theme.fg(state.checked.has(index) ? 'success' : 'dim', state.checked.has(index) ? '[x] ' : '[ ] ')
232
+ ? theme.fg(checked ? 'success' : 'dim', checked ? '[x] ' : '[ ] ')
194
233
  : '';
195
- const label = active ? theme.bold(theme.fg('accent', option.label)) : option.label;
196
- const description = option.description === undefined ? '' : theme.fg('dim', ' — ' + option.description);
197
- return truncateToWidth(cursor + marker + label + description, Math.max(1, width));
234
+ const row = {
235
+ label: marker + option.label,
236
+ ...(option.description === undefined ? {} : { description: option.description }),
237
+ // A badge is part of what the row says — a session's status, a command's
238
+ // kind. The paged list drew it, so the card must draw it too.
239
+ ...(option.badge?.label === undefined ? {} : { value: theme.fg(option.badge.tone ?? 'muted', option.badge.label) }),
240
+ };
241
+ return renderListRow(row, theme, Math.max(1, width), { selected: index === state.selected });
198
242
  }
199
243
  function fit(text, width) {
200
244
  if (width <= 0)
@@ -222,100 +266,51 @@ function pageDivider(theme, width) {
222
266
  function pageBottom(theme, width) {
223
267
  return pageBorder(theme, BOX.bottomLeft + BOX.horizontal.repeat(Math.max(0, width - 2)) + BOX.bottomRight);
224
268
  }
225
- function optionBadge(option, theme) {
226
- const badge = option.badge;
227
- if (badge === undefined)
228
- return '';
229
- const icon = badge.tone === 'success'
230
- ? SYMBOL.success
231
- : badge.tone === 'error'
232
- ? SYMBOL.error
233
- : badge.tone === 'warning'
234
- ? SYMBOL.warning
235
- : SYMBOL.done;
236
- return theme.fg(badge.tone === 'muted' ? 'dim' : badge.tone, `${icon} ${badge.label}`);
237
- }
238
- /** Render an OMP-style full-height searchable selector with one outer frame. */
239
- export function renderPromptSelectorPage(state, theme, width, height, input, inputCursor, appName) {
240
- const pageHeight = Math.max(1, height);
241
- if (pageHeight < 10 || width < 16) {
242
- return renderPromptSelector(state, theme, width, input, inputCursor, Math.max(1, pageHeight - 8));
243
- }
244
- const options = filteredPromptOptions(state.request, input);
245
- const selected = Math.max(0, Math.min(state.selected, Math.max(0, options.length - 1)));
246
- const compact = state.request.optionLayout === 'compact';
247
- const actionHints = (state.request.actions ?? []).map(action => `${action.key} ${action.label}`).join(' · ');
248
- const hints = wrapText(formatOverlayHint([HOTKEY_TEXT, HOTKEY_NAVIGATE, HOTKEY_SELECT, HOTKEY_CANCEL])
249
- + (actionHints === '' ? '' : ' · ' + actionHints), width - 4).slice(0, Math.max(1, Math.floor((height - 6) / 2)));
250
- const fixedRows = (compact ? 9 : 11) + hints.length - 1;
251
- const visibleCount = compact
252
- ? Math.max(1, pageHeight - fixedRows)
253
- : Math.max(1, Math.floor((pageHeight - fixedRows) / 4));
254
- const { start, end } = promptSelectorVisibleRange(options.length, selected, visibleCount);
255
- const detail = state.request.detail === undefined || state.request.detail === ''
256
- ? ''
257
- : ' ' + theme.fg('muted', `(${state.request.detail})`);
258
- const heading = theme.bold(state.request.title) + detail;
259
- const lines = compact
260
- ? [
261
- pageTop(theme, width, appName),
262
- pageRow(theme, ' ' + heading, width),
263
- pageDivider(theme, width),
264
- ]
265
- : [
266
- pageTop(theme, width, appName),
267
- pageRow(theme, '', width),
268
- pageRow(theme, ' ' + heading, width),
269
- pageRow(theme, '', width),
270
- pageDivider(theme, width),
271
- ];
272
- const searchPrefix = theme.fg('dim', '> ');
273
- const searchText = truncateToWidth(input, Math.max(1, width - 8));
274
- const searchRow = lines.length;
275
- lines.push(pageRow(theme, searchPrefix + searchText, width), pageRow(theme, '', width));
276
- if (options.length === 0) {
277
- // The generic line is a fallback only: this selector also serves approvals
278
- // and questions, whose empty states have nothing to do with sessions.
279
- const fallback = state.request.emptyText ?? 'No matching options.';
280
- const empty = input === '' ? fallback : `No match for “${input}”.`;
281
- lines.push(pageRow(theme, ' ' + theme.fg('muted', empty), width));
282
- }
283
- else {
284
- for (let index = start; index < end; index += 1) {
285
- const option = options[index];
286
- if (option === undefined)
287
- continue;
288
- if (compact) {
289
- lines.push(pageRow(theme, optionRow(option, index, { ...state, selected }, theme, Math.max(1, width - 6)), width));
290
- continue;
291
- }
292
- const active = index === selected;
293
- const marker = active ? theme.fg('accent', SYMBOL.cursor + ' ') : ' ';
294
- const label = active ? theme.bold(option.label) : option.label;
295
- lines.push(pageRow(theme, marker + label, width));
296
- lines.push(pageRow(theme, ' ' + theme.fg('dim', option.preview ?? ''), width));
297
- const badge = optionBadge(option, theme);
298
- const description = option.description === undefined ? '' : theme.fg('dim', option.description);
299
- const separator = description !== '' && badge !== '' ? theme.fg('dim', ' · ') : '';
300
- lines.push(pageRow(theme, ' ' + description + separator + badge, width), pageRow(theme, '', width));
269
+ const documentCache = new WeakMap();
270
+ /**
271
+ * Case-insensitive find over rows the reader can already see.
272
+ *
273
+ * A rendered page has no source model to search, so the rows are the document.
274
+ * Offsets are display cells, not string indexes, so CJK and emoji highlights land
275
+ * on the glyphs the reader pointed at rather than shearing them.
276
+ */
277
+ export function findInRows(rows, query) {
278
+ const needle = query.trim().toLowerCase();
279
+ if (needle === '')
280
+ return [];
281
+ const matches = [];
282
+ for (const [row, line] of rows.entries()) {
283
+ const plain = stripAnsi(line).toLowerCase();
284
+ for (let at = plain.indexOf(needle); at >= 0; at = plain.indexOf(needle, at + needle.length)) {
285
+ matches.push({ row, start: visibleWidth(stripAnsi(line).slice(0, at)), width: visibleWidth(query.trim()) });
301
286
  }
302
287
  }
303
- const footerRows = 3 + hints.length;
304
- const targetBeforeFooter = Math.max(0, pageHeight - footerRows);
305
- if (lines.length > targetBeforeFooter)
306
- lines.length = targetBeforeFooter;
307
- while (lines.length < targetBeforeFooter)
308
- lines.push(pageRow(theme, '', width));
309
- const position = options.length === 0 ? '' : `${selected + 1}/${options.length}`;
310
- lines.push(pageRow(theme, theme.fg('dim', position.trim()), width), ...hints.map(hint => pageRow(theme, theme.fg('dim', hint), width)), pageRow(theme, '', width), pageBottom(theme, width));
311
- const cursorColumn = Math.min(Math.max(1, width - 3), 4 + visibleWidth(input.slice(0, inputCursor)));
312
- return {
313
- lines,
314
- cursor: { row: searchRow, column: cursorColumn },
315
- };
288
+ return matches;
316
289
  }
317
- const documentCache = new WeakMap();
318
- function documentRows(request, theme, width) {
290
+ /** Invert the matched cells of one styled row. */
291
+ export function highlightMatches(line, spans, theme) {
292
+ if (spans.length === 0)
293
+ return line;
294
+ const plain = stripAnsi(line);
295
+ const total = visibleWidth(plain);
296
+ const ordered = [...spans].sort((left, right) => left.start - right.start);
297
+ let out = '';
298
+ let at = 0;
299
+ for (const span of ordered) {
300
+ const start = Math.max(at, Math.min(span.start, total));
301
+ const end = Math.min(total, Math.max(start, span.start + span.width));
302
+ if (start > at)
303
+ out += sliceCells(line, at, start - at);
304
+ if (end > start)
305
+ out += theme.inverse(sliceCells(line, start, end - start));
306
+ at = end;
307
+ }
308
+ if (at < total)
309
+ out += sliceCells(line, at, total - at);
310
+ return out;
311
+ }
312
+ /** The rows a rendered document shows, shared with the find that searches them. */
313
+ export function documentRows(request, theme, width) {
319
314
  let cache = documentCache.get(request);
320
315
  if (cache === undefined) {
321
316
  cache = new Map();
@@ -331,6 +326,21 @@ function documentRows(request, theme, width) {
331
326
  cache.set(key, lines);
332
327
  return lines;
333
328
  }
329
+ /**
330
+ * Does this prompt need the whole screen?
331
+ *
332
+ * Only a tree does. Length is not the question — a long list is still a list, and
333
+ * the card windows its rows, filters by typing, and draws the selected row's
334
+ * preview — so counting rows was the wrong variable. Choosing is a different act
335
+ * from reading, and a choice belongs beside the conversation it interrupted.
336
+ */
337
+ export function promptNeedsPage(request) {
338
+ // A list is a choice, and a choice belongs beside the conversation whatever its
339
+ // length: the card windows its rows, filters by typing, and shows the selected
340
+ // row's preview. Counting rows was the wrong variable — a long list is still a
341
+ // list. Only a tree has structure the card cannot show.
342
+ return request.presentation === 'tree';
343
+ }
334
344
  /** Render a bounded, scrollable Markdown document with fixed actions. */
335
345
  function documentActionRows(state, theme, width, pageHeight) {
336
346
  const actionRows = [];
@@ -366,7 +376,7 @@ export function renderImagePreviewPage(state, theme, width, height, appName, pro
366
376
  pageRow(theme, theme.fg('muted', image.description), width), pageDivider(theme, width)];
367
377
  const top = lines.length;
368
378
  const canOpen = state.request.options?.some(option => option.value === 'open') === true;
369
- const fallback = protocol === undefined ? wrapText('Inline images are unavailable in this terminal. '
379
+ const fallback = protocol === undefined ? wrapTextStable('Inline images are unavailable in this terminal. '
370
380
  + (canOpen ? 'Use Open to view the original.' : 'This attachment has no local original to open.'), width - 8) : [];
371
381
  for (let at = 0; at < bodyRows; at++)
372
382
  lines.push(pageRow(theme, ' ' + (fallback[at] ?? ''), width));
@@ -384,10 +394,11 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
384
394
  }
385
395
  const feedback = state.feedback === true;
386
396
  const actionRows = documentActionRows(state, theme, width, pageHeight);
387
- const readerInput = source !== undefined && state.documentInput !== undefined;
388
- let readerHints = source === undefined ? [] : wrapText(readerInput ? 'Enter apply · Esc cancel'
389
- : 'W wrap · Alt+←→ pan · ↑↓/PgUp/PgDn scroll · ' + (state.request.documentTail === true ? 'Home start · End follow' : 'Home/End edges') + ' · / find · Ctrl+N/P match · G line' + (source.diff ? ' · [/] hunk' : '')
390
- + ' · Tab/←→ actions · Enter activate · ' + (state.request.actions ?? []).map(action => `${action.key.toUpperCase()} ${action.label}`).join(' · ') + ' · Esc back', width - 4);
397
+ const readerInput = state.documentInput !== undefined;
398
+ const customHints = (state.request.actions ?? []).map(action => `${action.key.toUpperCase()} ${action.label}`);
399
+ let readerHints = source === undefined ? [] : wrapTextStable(readerInput ? 'Enter apply · Esc cancel'
400
+ : (state.request.interrupt === undefined ? '' : 'Ctrl+C interrupt · ') + 'W wrap · Alt+←→ pan · ↑↓/PgUp/PgDn scroll · ' + (state.request.documentTail === true ? 'Home start · End follow' : 'Home/End edges') + ' · / find · Ctrl+N/P match · G line' + (source.diff ? ' · [/] hunk' : '')
401
+ + ' · ' + ['Tab/←→ actions', 'Enter activate', ...customHints, 'Esc back'].join(' · '), width - 4);
391
402
  const maxHints = Math.max(1, Math.min(4, pageHeight - 8 - actionRows.length));
392
403
  if (readerHints.length > maxHints)
393
404
  readerHints = [...readerHints.slice(0, maxHints - 1), 'Tab actions · Enter · Esc back'];
@@ -408,6 +419,7 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
408
419
  const visible = document.slice(start, start + bodyRows);
409
420
  const query = state.documentQuery ?? '';
410
421
  const matches = source === undefined ? [] : documentMatches(source, query);
422
+ const textMatches = source === undefined ? findInRows(document, query) : [];
411
423
  const matching = new Set(matches);
412
424
  if (layout !== undefined)
413
425
  for (let at = 0; at < visible.length; at++) {
@@ -416,6 +428,15 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
416
428
  visible[at] = theme.inverse(layout.gutterWidth === 0 ? row : '›' + row.slice(1));
417
429
  }
418
430
  }
431
+ if (textMatches.length > 0) {
432
+ // Paint the hits themselves, so the reader sees the word, not just the row.
433
+ for (const match of textMatches) {
434
+ const at = match.row - start;
435
+ if (at < 0 || at >= visible.length)
436
+ continue;
437
+ visible[at] = highlightMatches(visible[at] ?? '', [match], theme);
438
+ }
439
+ }
419
440
  while (visible.length < bodyRows)
420
441
  visible.push('');
421
442
  const subject = state.request.presentation === 'document' ? 'document' : 'plan';
@@ -444,7 +465,7 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
444
465
  const inputRow = lines.length;
445
466
  lines.push(pageRow(theme, ' ' + prefix + value, width));
446
467
  // "empty Enter keeps planning" is prose, but it names the key through the catalog.
447
- lines.push(pageRow(theme, theme.fg('dim', `[${formatOverlayHint([HOTKEY_SUBMIT, HOTKEY_BACK, HOTKEY_CANCEL_CTRL])} · empty ${formatHotkeyKeys(HOTKEY_SUBMIT)} keeps planning]`), width));
468
+ lines.push(pageRow(theme, theme.fg('dim', `[${formatOverlayHint([HOTKEY_SUBMIT, HOTKEY_BACK, HOTKEY_CANCEL_CTRL])} · Ctrl+X editor · empty ${formatHotkeyKeys(HOTKEY_SUBMIT)} keeps planning]`), width));
448
469
  lines.push(pageBottom(theme, width));
449
470
  cursor = {
450
471
  row: inputRow,
@@ -452,6 +473,19 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
452
473
  };
453
474
  }
454
475
  else {
476
+ if (source === undefined && (readerInput || query !== '')) {
477
+ // A rendered page has no line numbers to cite, so the status is the find
478
+ // itself: the query, how many hits it has, and where the reader is in them.
479
+ const index = textMatches.findIndex(match => match.row === state.documentTarget);
480
+ const hits = textMatches.length === 0
481
+ ? 'No match'
482
+ : `${index < 0 ? 1 : index + 1}/${textMatches.length} matching rows`;
483
+ const label = state.documentInput === 'search' ? 'Find: ' : '';
484
+ const value = input.replace(/\r?\n/gu, ' ');
485
+ const status = readerInput ? label + value : `${hits} · “${query}”`;
486
+ cursor = { row: lines.length, column: Math.min(width - 3, 2 + visibleWidth(label + value.slice(0, inputCursor))) };
487
+ lines.push(pageRow(theme, theme.fg('muted', status), width));
488
+ }
455
489
  if (source !== undefined) {
456
490
  const row = layout?.sourceRows[start] ?? 0;
457
491
  const model = documentModel(source);
@@ -471,8 +505,13 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
471
505
  lines.push(...actionRows.map(row => pageRow(theme, ' ' + row, width)));
472
506
  const shortcuts = state.request.presentation === 'document' && state.request.actions?.length
473
507
  ? 'Tab/←→ select · Enter activate · ' + state.request.actions.map(action => `${action.key.toUpperCase()} ${action.label}`).join(' · ') + ' · Esc back'
474
- : formatOverlayHint([HOTKEY_PAGE, HOTKEY_NAVIGATE_TAB, HOTKEY_SELECT, HOTKEY_CANCEL]);
475
- lines.push(...(source === undefined ? [`[${shortcuts}]`] : readerHints).map(hint => pageRow(theme, theme.fg('dim', hint), width)));
508
+ : formatOverlayHint([HOTKEY_PAGE, HOTKEY_NAVIGATE_TAB, HOTKEY_SELECT, HOTKEY_CANCEL])
509
+ + (state.request.presentation === 'plan-review' ? ' · Ctrl+X editor' : '');
510
+ // A page long enough to need scrolling is a page someone will want to search.
511
+ const pageHint = source === undefined && state.request.presentation === 'document'
512
+ ? `[${shortcuts} · / find · Ctrl+N/P match]`
513
+ : `[${shortcuts}]`;
514
+ lines.push(...(source === undefined ? [pageHint] : readerHints).map(hint => pageRow(theme, theme.fg('dim', hint), width)));
476
515
  lines.push(pageBottom(theme, width));
477
516
  }
478
517
  return {
@@ -484,9 +523,16 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
484
523
  cursorVisible: feedback || readerInput,
485
524
  };
486
525
  }
526
+ /** Rows of the selected option's preview the card shows before it truncates. */
527
+ const CARD_PREVIEW_LINES = 3;
528
+ /** The reader's query as it reads in a sentence, quoted so blanks are visible. */
529
+ function quote(text) {
530
+ return `“${text}”`;
531
+ }
487
532
  /** Render the prompt card and its answer editor as one bottom-of-screen overlay. */
488
533
  export function renderPromptSelector(state, theme, width, input, inputCursor, maxVisible = PROMPT_SELECTOR_MAX_VISIBLE) {
489
534
  const options = filteredPromptOptions(state.request, input, state.collapsed);
535
+ const filtering = input !== '';
490
536
  const contentWidth = Math.max(1, width - 4);
491
537
  const body = [state.request.question];
492
538
  if (state.request.wait !== undefined) {
@@ -499,24 +545,71 @@ export function renderPromptSelector(state, theme, width, input, inputCursor, ma
499
545
  body.push('', state.request.detail);
500
546
  if (options.length > 0) {
501
547
  const { start, end } = promptSelectorVisibleRange(options.length, state.selected, maxVisible);
502
- body.push('', ...options.slice(start, end).map((option, offset) => optionRow(option, start + offset, state, theme, contentWidth)));
548
+ body.push('', ...options.slice(start, end).map((option, offset) => optionRow(option, start + offset, state, theme, contentWidth, (state.request.options ?? []).indexOf(option))));
503
549
  if (options.length > maxVisible) {
504
550
  body.push(theme.fg('dim', ` ${state.selected + 1}/${options.length} · scroll for more`));
505
551
  }
552
+ // The paged list draws the selected row's preview beside the rows; the card
553
+ // gives it three lines underneath, so demoting a previewed list keeps the
554
+ // content it was being browsed for.
555
+ const preview = options[state.selected]?.preview;
556
+ if (preview !== undefined && preview !== '') {
557
+ const wrapped = state.request.options?.[state.selected]?.previewFormat === 'markdown'
558
+ ? renderMarkdown(preview, theme, contentWidth)
559
+ : wrapTextStable(preview, contentWidth);
560
+ body.push('', ...wrapped.slice(0, CARD_PREVIEW_LINES).map(line => theme.fg('dim', ' ' + line)));
561
+ if (wrapped.length > CARD_PREVIEW_LINES) {
562
+ body.push(theme.fg('dim', ` … ${wrapped.length - CARD_PREVIEW_LINES} more lines`));
563
+ }
564
+ }
565
+ }
566
+ else {
567
+ // A filter that matches nothing has to say so. An empty frame reads as a
568
+ // broken list, and the reader cannot tell a typo from an empty transcript.
569
+ body.push('', theme.fg('dim', ` ${state.request.emptyText
570
+ ?? (filtering ? `No match for ${quote(input)}.` : 'Nothing to choose.')}`));
506
571
  }
572
+ /**
573
+ * The query, drawn where the reader types it.
574
+ *
575
+ * Every filterable card filters on the composer text, and the paged list used
576
+ * to print it — so demoting a list to a card made the query invisible: the rows
577
+ * narrowed while the reader's own words appeared nowhere, and a card with no
578
+ * hits looked broken rather than empty.
579
+ */
580
+ const filterRow = state.request.filterable === true && state.request.presentation !== 'progress'
581
+ ? body.length
582
+ : undefined;
583
+ if (filterRow !== undefined) {
584
+ // The query and what it kept belong on one line: the reader typed one thing
585
+ // and the list answered with a count.
586
+ const count = filtering ? theme.fg('dim', ` · ${options.length === 0 ? 'no match' : `${state.selected + 1}/${options.length}`}`) : '';
587
+ body.push('', input === ''
588
+ ? theme.fg('muted', ' ⌕ type to filter') + count
589
+ : theme.fg('text', ' ⌕ ') + input + count);
590
+ }
591
+ // A request that has options but no match has nothing to submit: advertising
592
+ // `enter select` there promises a key the handler swallows.
593
+ const exhausted = options.length === 0 && (state.request.options?.length ?? 0) > 0;
507
594
  const submit = state.request.submitLabel?.trim()
508
595
  || (options.length === 0 ? 'answer' : state.request.multiSelect === true ? 'confirm' : 'select');
509
596
  // The request supplies the submit verb; the key itself comes from the catalog.
510
597
  const select = `${formatHotkeyKeys(HOTKEY_SELECT)} ${submit}`;
511
- const navigation = options.length === 0
512
- ? `${select} · ${formatOverlayHint([HOTKEY_CANCEL])}`
513
- : state.request.multiSelect === true
514
- ? `${formatOverlayHint([HOTKEY_NAVIGATE])} · ${formatOverlayHint([HOTKEY_TOGGLE])} · ${select} · ${formatOverlayHint([HOTKEY_CANCEL])}`
515
- : `${formatOverlayHint([HOTKEY_NAVIGATE])} · ${select} · ${formatOverlayHint([HOTKEY_CANCEL])}`;
598
+ const navigation = state.request.presentation === 'progress'
599
+ ? formatOverlayHint([HOTKEY_CANCEL, HOTKEY_CANCEL_CTRL], 'catalog')
600
+ : exhausted
601
+ ? formatOverlayHint([HOTKEY_CANCEL])
602
+ : options.length === 0
603
+ ? `${select} · ${formatOverlayHint([HOTKEY_CANCEL])}`
604
+ : state.request.multiSelect === true
605
+ ? `${formatOverlayHint([HOTKEY_NAVIGATE])} · ${formatOverlayHint([HOTKEY_TOGGLE])} · ${select} · ${formatOverlayHint([HOTKEY_CANCEL])}`
606
+ : `${formatOverlayHint([HOTKEY_NAVIGATE])} · ${select} · ${formatOverlayHint([HOTKEY_CANCEL])}`;
516
607
  const dismiss = state.request.dismissLabel;
608
+ const actionHints = (state.request.actions ?? []).map(action => `${action.key} ${action.label}`);
517
609
  const hints = [
518
610
  dismiss === undefined ? navigation : navigation.replace(formatOverlayHint([HOTKEY_CANCEL]), `${formatHotkeyKeys(HOTKEY_CANCEL)} ${dismiss.toLowerCase()}`),
519
611
  ...(state.request.skippable === true ? [formatOverlayHint([HOTKEY_SKIP])] : []),
612
+ ...actionHints,
520
613
  ];
521
614
  body.push('', theme.fg('dim', hints.join(' · ')));
522
615
  const card = renderFramedBlock({
@@ -526,17 +619,51 @@ export function renderPromptSelector(state, theme, width, input, inputCursor, ma
526
619
  width,
527
620
  applyBg: false,
528
621
  }, theme);
529
- if (state.request.allowCustom === false) {
622
+ if (state.request.allowCustom === false || state.request.presentation === 'progress') {
623
+ // With a filter line the caret belongs at its end, where the next character
624
+ // will land; without one it stays on body padding, because the selector glyph
625
+ // owns focus while no text editor is present.
530
626
  return {
531
627
  lines: card,
532
- // Keep the terminal caret on body padding; the visible selector glyph
533
- // owns focus while no text editor is present.
534
- cursor: { row: Math.min(1, Math.max(0, card.length - 1)), column: 1 },
535
- cursorVisible: false,
628
+ cursor: filterRow === undefined
629
+ ? { row: Math.min(1, Math.max(0, card.length - 1)), column: 1 }
630
+ : { row: filterRow + 2, column: 4 + visibleWidth(input) },
631
+ cursorVisible: filterRow !== undefined,
536
632
  };
537
633
  }
538
634
  const secret = state.request.secret === true;
539
635
  const displayInput = secret ? maskPromptSecret(input) : input;
636
+ /**
637
+ * A question that is only text is the composer, not a card above it.
638
+ *
639
+ * This path used to draw a framed question and then a second framed editor
640
+ * under it: two borders that each looked like the place to type, two hint
641
+ * lines saying what Enter does, and a label reading `answer` that named
642
+ * nothing the reader had not already been told. A modal question is an
643
+ * interaction state the composer can carry itself — label in the top rule,
644
+ * question as the placeholder, keys in the bottom rule — which is also how pi
645
+ * mounts a dialog, in the editor's own slot.
646
+ *
647
+ * A prompt that also offers options keeps the card: a list plus a text field
648
+ * genuinely is two things. So does one that carries supporting copy, and one
649
+ * counting down a deadline — both need rows the bottom rule cannot give them.
650
+ */
651
+ const detail = state.request.detail ?? '';
652
+ if (options.length === 0 && detail === '' && state.request.wait === undefined) {
653
+ const label = secret ? `${state.request.title} · hidden` : state.request.title;
654
+ const editor = renderEditor({
655
+ width,
656
+ input: displayInput,
657
+ inputCursor,
658
+ status: ' ' + theme.fg('accent', label) + ' ',
659
+ ...(state.request.question === '' ? {} : { inlineHint: state.request.question }),
660
+ // The keys live on the bottom rule so the frame says how to answer without
661
+ // spending a row on it.
662
+ ...(hints.length === 0 ? {} : { note: hints.join(' · ') }),
663
+ border: 'accent',
664
+ }, theme);
665
+ return { lines: editor.lines, cursor: editor.cursor, cursorVisible: true };
666
+ }
540
667
  const editor = renderEditor({
541
668
  width,
542
669
  input: displayInput,
@@ -559,32 +686,41 @@ export function movePromptSelection(state, next, count = state.request.options?.
559
686
  return selected === state.selected ? state : { ...state, selected, previewScroll: 0 };
560
687
  }
561
688
  /** Toggle the active row for a multi-select prompt. */
562
- export function togglePromptSelection(state) {
563
- if (state.request.multiSelect !== true || state.request.options?.[state.selected] === undefined)
689
+ export function togglePromptSelection(state, query = '') {
690
+ const option = filteredPromptOptions(state.request, query)[state.selected];
691
+ if (state.request.multiSelect !== true || option === undefined)
692
+ return state;
693
+ const index = state.request.options?.indexOf(option) ?? -1;
694
+ if (index < 0)
564
695
  return state;
565
696
  const checked = new Set(state.checked);
566
- if (checked.has(state.selected))
567
- checked.delete(state.selected);
697
+ if (checked.has(index))
698
+ checked.delete(index);
568
699
  else
569
- checked.add(state.selected);
700
+ checked.add(index);
570
701
  return { ...state, checked };
571
702
  }
572
703
  /** Resolve the selected labels in stable option order. */
573
704
  export function selectedPromptAnswer(state) {
574
- const options = state.request.presentation === 'fullscreen-tree'
705
+ const options = state.request.presentation === 'tree'
575
706
  ? filteredPromptOptions(state.request, '', state.collapsed) : state.request.options ?? [];
576
707
  if (options.length === 0)
577
708
  return null;
578
709
  if (state.request.multiSelect !== true) {
579
710
  const option = options[state.selected];
711
+ if (option?.disabled === true)
712
+ return null;
580
713
  return option?.value ?? option?.label ?? null;
581
714
  }
582
715
  const labels = options.flatMap((option, index) => state.checked.has(index) ? [option.value ?? option.label] : []);
583
- return labels.length === 0 ? null : labels.join(', ');
716
+ return labels.length === 0 ? state.request.allowEmptySelection === true ? '' : null : labels.join(', ');
584
717
  }
585
718
  /** Resolve the selected answer after applying a full-screen selector query. */
586
719
  export function selectedFilteredPromptAnswer(state, query) {
587
720
  const option = filteredPromptOptions(state.request, query, state.collapsed)[state.selected];
721
+ // A disabled row is focusable but carries no answer, filtered or not.
722
+ if (option?.disabled === true)
723
+ return null;
588
724
  return option?.value ?? option?.label ?? null;
589
725
  }
590
726
  //# sourceMappingURL=prompt-selector.js.map