@agi-fans/dsh-tui 0.21.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 (243) hide show
  1. package/lib/CHANGELOG.md +158 -1
  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 +16 -0
  12. package/lib/chrome/main-screen-renderer.js +53 -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/secret-mask.d.ts +35 -0
  20. package/lib/chrome/secret-mask.js +180 -0
  21. package/lib/chrome/secret-mask.js.map +1 -0
  22. package/lib/chrome/status-config.d.ts +161 -51
  23. package/lib/chrome/status-config.js +237 -126
  24. package/lib/chrome/status-config.js.map +1 -1
  25. package/lib/chrome/status-line.d.ts +3 -2
  26. package/lib/chrome/status-line.js +105 -63
  27. package/lib/chrome/status-line.js.map +1 -1
  28. package/lib/chrome/terminal-colors.d.ts +85 -0
  29. package/lib/chrome/terminal-colors.js +230 -0
  30. package/lib/chrome/terminal-colors.js.map +1 -0
  31. package/lib/chrome/terminal-images.d.ts +9 -1
  32. package/lib/chrome/terminal-images.js +27 -4
  33. package/lib/chrome/terminal-images.js.map +1 -1
  34. package/lib/chrome/theme.d.ts +32 -9
  35. package/lib/chrome/theme.js +595 -498
  36. package/lib/chrome/theme.js.map +1 -1
  37. package/lib/chrome/tool-renderers.js +69 -13
  38. package/lib/chrome/tool-renderers.js.map +1 -1
  39. package/lib/chrome/tools-list.d.ts +1 -4
  40. package/lib/chrome/tools-list.js +1 -41
  41. package/lib/chrome/tools-list.js.map +1 -1
  42. package/lib/chrome/welcome-tips.d.ts +20 -8
  43. package/lib/chrome/welcome-tips.js +22 -42
  44. package/lib/chrome/welcome-tips.js.map +1 -1
  45. package/lib/commands/auth-ids.d.ts +25 -0
  46. package/lib/commands/auth-ids.js +27 -0
  47. package/lib/commands/auth-ids.js.map +1 -0
  48. package/lib/commands/auth-input.d.ts +34 -0
  49. package/lib/commands/auth-input.js +96 -0
  50. package/lib/commands/auth-input.js.map +1 -0
  51. package/lib/commands/auth-providers.d.ts +39 -0
  52. package/lib/commands/auth-providers.js +125 -0
  53. package/lib/commands/auth-providers.js.map +1 -0
  54. package/lib/commands/auth.d.ts +12 -20
  55. package/lib/commands/auth.js +42 -229
  56. package/lib/commands/auth.js.map +1 -1
  57. package/lib/commands/btw.d.ts +13 -1
  58. package/lib/commands/btw.js +37 -104
  59. package/lib/commands/btw.js.map +1 -1
  60. package/lib/commands/diff.js +1 -96
  61. package/lib/commands/diff.js.map +1 -1
  62. package/lib/commands/export.d.ts +1 -1
  63. package/lib/commands/export.js +3 -27
  64. package/lib/commands/export.js.map +1 -1
  65. package/lib/commands/files.d.ts +17 -1
  66. package/lib/commands/files.js +67 -107
  67. package/lib/commands/files.js.map +1 -1
  68. package/lib/commands/jobs.d.ts +19 -4
  69. package/lib/commands/jobs.js +50 -7
  70. package/lib/commands/jobs.js.map +1 -1
  71. package/lib/commands/model.js +108 -76
  72. package/lib/commands/model.js.map +1 -1
  73. package/lib/commands/permission.js +15 -16
  74. package/lib/commands/permission.js.map +1 -1
  75. package/lib/commands/plugins.js +133 -16
  76. package/lib/commands/plugins.js.map +1 -1
  77. package/lib/commands/schedule.d.ts +10 -0
  78. package/lib/commands/schedule.js +45 -14
  79. package/lib/commands/schedule.js.map +1 -1
  80. package/lib/commands/session-configuration.js +2 -10
  81. package/lib/commands/session-configuration.js.map +1 -1
  82. package/lib/commands/session.js +14 -38
  83. package/lib/commands/session.js.map +1 -1
  84. package/lib/commands/terminal.d.ts +14 -8
  85. package/lib/commands/terminal.js +19 -178
  86. package/lib/commands/terminal.js.map +1 -1
  87. package/lib/commands/trajectory.js +4 -1
  88. package/lib/commands/trajectory.js.map +1 -1
  89. package/lib/definition.d.ts +122 -22
  90. package/lib/definition.js.map +1 -1
  91. package/lib/index.d.ts +1 -0
  92. package/lib/index.js +1 -0
  93. package/lib/index.js.map +1 -1
  94. package/lib/input/actions.d.ts +51 -0
  95. package/lib/input/actions.js +120 -0
  96. package/lib/input/actions.js.map +1 -0
  97. package/lib/input/editor.d.ts +0 -10
  98. package/lib/input/editor.js +11 -9
  99. package/lib/input/editor.js.map +1 -1
  100. package/lib/input/image-paste.d.ts +19 -1
  101. package/lib/input/image-paste.js +82 -1
  102. package/lib/input/image-paste.js.map +1 -1
  103. package/lib/input/keybindings-config.d.ts +12 -5
  104. package/lib/input/keybindings-config.js +30 -57
  105. package/lib/input/keybindings-config.js.map +1 -1
  106. package/lib/input/keys.d.ts +16 -0
  107. package/lib/input/keys.js +38 -3
  108. package/lib/input/keys.js.map +1 -1
  109. package/lib/input/wheel-scroll.d.ts +46 -0
  110. package/lib/input/wheel-scroll.js +69 -0
  111. package/lib/input/wheel-scroll.js.map +1 -0
  112. package/lib/prompt-intents.d.ts +182 -0
  113. package/lib/prompt-intents.js +127 -0
  114. package/lib/prompt-intents.js.map +1 -0
  115. package/lib/runtime/cancellable.d.ts +75 -0
  116. package/lib/runtime/cancellable.js +76 -0
  117. package/lib/runtime/cancellable.js.map +1 -0
  118. package/lib/runtime/composer-images.js +5 -4
  119. package/lib/runtime/composer-images.js.map +1 -1
  120. package/lib/runtime/composer-shell.d.ts +17 -2
  121. package/lib/runtime/composer-shell.js +109 -43
  122. package/lib/runtime/composer-shell.js.map +1 -1
  123. package/lib/runtime/file-recovery-review.d.ts +0 -1
  124. package/lib/runtime/file-recovery-review.js +29 -30
  125. package/lib/runtime/file-recovery-review.js.map +1 -1
  126. package/lib/runtime/file-recovery-store.js +5 -1
  127. package/lib/runtime/file-recovery-store.js.map +1 -1
  128. package/lib/runtime/file-review.js +1 -1
  129. package/lib/runtime/file-review.js.map +1 -1
  130. package/lib/runtime/hook-settings.d.ts +2 -2
  131. package/lib/runtime/plain-tui.d.ts +2 -0
  132. package/lib/runtime/plain-tui.js +7 -1
  133. package/lib/runtime/plain-tui.js.map +1 -1
  134. package/lib/runtime/plugin-settings.d.ts +1 -1
  135. package/lib/runtime/plugin-settings.js +10 -8
  136. package/lib/runtime/plugin-settings.js.map +1 -1
  137. package/lib/runtime/provider-local.d.ts +57 -15
  138. package/lib/runtime/provider-local.js +1112 -471
  139. package/lib/runtime/provider-local.js.map +1 -1
  140. package/lib/runtime/runner.js +5 -11
  141. package/lib/runtime/runner.js.map +1 -1
  142. package/lib/runtime/settings-preferences.js +1 -1
  143. package/lib/runtime/settings-preferences.js.map +1 -1
  144. package/lib/runtime/startup-notices.js +2 -0
  145. package/lib/runtime/startup-notices.js.map +1 -1
  146. package/lib/runtime/terminal-modes.d.ts +47 -0
  147. package/lib/runtime/terminal-modes.js +86 -0
  148. package/lib/runtime/terminal-modes.js.map +1 -0
  149. package/lib/runtime/terminal-notifications.d.ts +3 -5
  150. package/lib/runtime/terminal-notifications.js +4 -8
  151. package/lib/runtime/terminal-notifications.js.map +1 -1
  152. package/lib/runtime/terminal-probe.d.ts +36 -0
  153. package/lib/runtime/terminal-probe.js +37 -0
  154. package/lib/runtime/terminal-probe.js.map +1 -0
  155. package/lib/runtime/terminal-session.d.ts +57 -0
  156. package/lib/runtime/terminal-session.js +164 -0
  157. package/lib/runtime/terminal-session.js.map +1 -0
  158. package/lib/session/interaction-adapter.js +7 -8
  159. package/lib/session/interaction-adapter.js.map +1 -1
  160. package/lib/session/message-queue.js +1 -1
  161. package/lib/session/message-queue.js.map +1 -1
  162. package/lib/session/package-metadata.d.ts +8 -0
  163. package/lib/session/package-metadata.js +8 -0
  164. package/lib/session/package-metadata.js.map +1 -1
  165. package/lib/session/session-controller.d.ts +10 -11
  166. package/lib/session/session-controller.js +95 -13
  167. package/lib/session/session-controller.js.map +1 -1
  168. package/lib/session/session-tree.d.ts +2 -0
  169. package/lib/session/session-tree.js +2 -0
  170. package/lib/session/session-tree.js.map +1 -1
  171. package/lib/session/tui-settings.d.ts +40 -11
  172. package/lib/session/tui-settings.js +73 -34
  173. package/lib/session/tui-settings.js.map +1 -1
  174. package/lib/views/autocomplete.d.ts +18 -2
  175. package/lib/views/autocomplete.js +51 -38
  176. package/lib/views/autocomplete.js.map +1 -1
  177. package/lib/views/copy-targets.d.ts +12 -13
  178. package/lib/views/copy-targets.js +16 -46
  179. package/lib/views/copy-targets.js.map +1 -1
  180. package/lib/views/document-reader.js +18 -5
  181. package/lib/views/document-reader.js.map +1 -1
  182. package/lib/views/event-views.js +42 -6
  183. package/lib/views/event-views.js.map +1 -1
  184. package/lib/views/history-search.js +15 -21
  185. package/lib/views/history-search.js.map +1 -1
  186. package/lib/views/hotkeys.d.ts +7 -1
  187. package/lib/views/hotkeys.js +49 -25
  188. package/lib/views/hotkeys.js.map +1 -1
  189. package/lib/views/list-navigation.d.ts +62 -0
  190. package/lib/views/list-navigation.js +73 -0
  191. package/lib/views/list-navigation.js.map +1 -0
  192. package/lib/views/list-view.d.ts +99 -0
  193. package/lib/views/list-view.js +165 -0
  194. package/lib/views/list-view.js.map +1 -0
  195. package/lib/views/prompt-selector.d.ts +31 -3
  196. package/lib/views/prompt-selector.js +249 -125
  197. package/lib/views/prompt-selector.js.map +1 -1
  198. package/lib/views/session-files.d.ts +1 -2
  199. package/lib/views/session-files.js +1 -11
  200. package/lib/views/session-files.js.map +1 -1
  201. package/lib/views/settings-list.d.ts +10 -8
  202. package/lib/views/settings-list.js +116 -283
  203. package/lib/views/settings-list.js.map +1 -1
  204. package/lib/views/trajectory-timeline.d.ts +72 -0
  205. package/lib/views/trajectory-timeline.js +133 -0
  206. package/lib/views/trajectory-timeline.js.map +1 -0
  207. package/lib/views/trajectory.d.ts +15 -4
  208. package/lib/views/trajectory.js +557 -123
  209. package/lib/views/trajectory.js.map +1 -1
  210. package/lib/views/transcript-render.d.ts +95 -25
  211. package/lib/views/transcript-render.js +501 -167
  212. package/lib/views/transcript-render.js.map +1 -1
  213. package/lib/views/transcript-search.d.ts +39 -6
  214. package/lib/views/transcript-search.js +46 -11
  215. package/lib/views/transcript-search.js.map +1 -1
  216. package/lib/views/transcript-types.d.ts +63 -5
  217. package/lib/views/transcript-types.js +23 -11
  218. package/lib/views/transcript-types.js.map +1 -1
  219. package/package.json +67 -86
  220. package/lib/commands/advisor.d.ts +0 -15
  221. package/lib/commands/advisor.js +0 -200
  222. package/lib/commands/advisor.js.map +0 -1
  223. package/lib/commands/loop.d.ts +0 -52
  224. package/lib/commands/loop.js +0 -307
  225. package/lib/commands/loop.js.map +0 -1
  226. package/lib/commands/rate.d.ts +0 -33
  227. package/lib/commands/rate.js +0 -212
  228. package/lib/commands/rate.js.map +0 -1
  229. package/lib/commands/steer.d.ts +0 -5
  230. package/lib/commands/steer.js +0 -24
  231. package/lib/commands/steer.js.map +0 -1
  232. package/lib/commands/worktree.d.ts +0 -5
  233. package/lib/commands/worktree.js +0 -103
  234. package/lib/commands/worktree.js.map +0 -1
  235. package/lib/runtime/terminal-reader.d.ts +0 -11
  236. package/lib/runtime/terminal-reader.js +0 -87
  237. package/lib/runtime/terminal-reader.js.map +0 -1
  238. package/lib/views/agent-hub.d.ts +0 -32
  239. package/lib/views/agent-hub.js +0 -209
  240. package/lib/views/agent-hub.js.map +0 -1
  241. package/lib/views/copy-selector.d.ts +0 -54
  242. package/lib/views/copy-selector.js +0 -170
  243. 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,102 +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
- const options = filteredPromptOptions(state.request, input);
242
- const visibleChecked = new Set(options.flatMap((option, index) => state.checked.has(state.request.options?.indexOf(option) ?? -1) ? [index] : []));
243
- if (pageHeight < 10 || width < 16) {
244
- return renderPromptSelector({ ...state, request: { ...state.request, options }, checked: visibleChecked }, theme, width, input, inputCursor, Math.max(1, pageHeight - 8));
245
- }
246
- const selected = Math.max(0, Math.min(state.selected, Math.max(0, options.length - 1)));
247
- const compact = state.request.optionLayout === 'compact';
248
- const actionHints = (state.request.actions ?? []).map(action => `${action.key} ${action.label}`).join(' · ');
249
- const hints = wrapText(formatOverlayHint([HOTKEY_TEXT, HOTKEY_NAVIGATE, ...(state.request.multiSelect === true ? [HOTKEY_TOGGLE] : []), HOTKEY_SELECT, HOTKEY_CANCEL])
250
- + (actionHints === '' ? '' : ' · ' + actionHints), width - 4).slice(0, Math.max(1, Math.floor((height - 6) / 2)));
251
- const fixedRows = (compact ? 9 : 11) + hints.length - 1;
252
- const visibleCount = compact
253
- ? Math.max(1, pageHeight - fixedRows)
254
- : Math.max(1, Math.floor((pageHeight - fixedRows) / 4));
255
- const { start, end } = promptSelectorVisibleRange(options.length, selected, visibleCount);
256
- const detail = state.request.detail === undefined || state.request.detail === ''
257
- ? ''
258
- : ' ' + theme.fg('muted', `(${state.request.detail})`);
259
- const heading = theme.bold(state.request.title) + detail;
260
- const lines = compact
261
- ? [
262
- pageTop(theme, width, appName),
263
- pageRow(theme, ' ' + heading, width),
264
- pageDivider(theme, width),
265
- ]
266
- : [
267
- pageTop(theme, width, appName),
268
- pageRow(theme, '', width),
269
- pageRow(theme, ' ' + heading, width),
270
- pageRow(theme, '', width),
271
- pageDivider(theme, width),
272
- ];
273
- const searchPrefix = theme.fg('dim', '> ');
274
- const searchText = truncateToWidth(input, Math.max(1, width - 8));
275
- const searchRow = lines.length;
276
- lines.push(pageRow(theme, searchPrefix + searchText, width), pageRow(theme, '', width));
277
- if (options.length === 0) {
278
- // The generic line is a fallback only: this selector also serves approvals
279
- // and questions, whose empty states have nothing to do with sessions.
280
- const fallback = state.request.emptyText ?? 'No matching options.';
281
- const empty = input === '' ? fallback : `No match for “${input}”.`;
282
- lines.push(pageRow(theme, ' ' + theme.fg('muted', empty), width));
283
- }
284
- else {
285
- for (let index = start; index < end; index += 1) {
286
- const option = options[index];
287
- if (option === undefined)
288
- continue;
289
- if (compact) {
290
- lines.push(pageRow(theme, optionRow(option, index, { ...state, selected, checked: visibleChecked }, theme, Math.max(1, width - 6)), width));
291
- continue;
292
- }
293
- const active = index === selected;
294
- const marker = active ? theme.fg('accent', SYMBOL.cursor + ' ') : ' ';
295
- const check = state.request.multiSelect === true ? visibleChecked.has(index) ? '[x] ' : '[ ] ' : '';
296
- const label = active ? theme.bold(option.label) : option.label;
297
- lines.push(pageRow(theme, marker + check + label, width));
298
- lines.push(pageRow(theme, ' ' + theme.fg('dim', option.preview ?? ''), width));
299
- const badge = optionBadge(option, theme);
300
- const description = option.description === undefined ? '' : theme.fg('dim', option.description);
301
- const separator = description !== '' && badge !== '' ? theme.fg('dim', ' · ') : '';
302
- 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()) });
303
286
  }
304
287
  }
305
- const footerRows = 3 + hints.length;
306
- const targetBeforeFooter = Math.max(0, pageHeight - footerRows);
307
- if (lines.length > targetBeforeFooter)
308
- lines.length = targetBeforeFooter;
309
- while (lines.length < targetBeforeFooter)
310
- lines.push(pageRow(theme, '', width));
311
- const position = options.length === 0 ? '' : `${selected + 1}/${options.length}`;
312
- 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));
313
- const cursorColumn = Math.min(Math.max(1, width - 3), 4 + visibleWidth(input.slice(0, inputCursor)));
314
- return {
315
- lines,
316
- cursor: { row: searchRow, column: cursorColumn },
317
- };
288
+ return matches;
318
289
  }
319
- const documentCache = new WeakMap();
320
- 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) {
321
314
  let cache = documentCache.get(request);
322
315
  if (cache === undefined) {
323
316
  cache = new Map();
@@ -333,6 +326,21 @@ function documentRows(request, theme, width) {
333
326
  cache.set(key, lines);
334
327
  return lines;
335
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
+ }
336
344
  /** Render a bounded, scrollable Markdown document with fixed actions. */
337
345
  function documentActionRows(state, theme, width, pageHeight) {
338
346
  const actionRows = [];
@@ -368,7 +376,7 @@ export function renderImagePreviewPage(state, theme, width, height, appName, pro
368
376
  pageRow(theme, theme.fg('muted', image.description), width), pageDivider(theme, width)];
369
377
  const top = lines.length;
370
378
  const canOpen = state.request.options?.some(option => option.value === 'open') === true;
371
- 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. '
372
380
  + (canOpen ? 'Use Open to view the original.' : 'This attachment has no local original to open.'), width - 8) : [];
373
381
  for (let at = 0; at < bodyRows; at++)
374
382
  lines.push(pageRow(theme, ' ' + (fallback[at] ?? ''), width));
@@ -386,9 +394,9 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
386
394
  }
387
395
  const feedback = state.feedback === true;
388
396
  const actionRows = documentActionRows(state, theme, width, pageHeight);
389
- const readerInput = source !== undefined && state.documentInput !== undefined;
397
+ const readerInput = state.documentInput !== undefined;
390
398
  const customHints = (state.request.actions ?? []).map(action => `${action.key.toUpperCase()} ${action.label}`);
391
- let readerHints = source === undefined ? [] : wrapText(readerInput ? 'Enter apply · Esc cancel'
399
+ let readerHints = source === undefined ? [] : wrapTextStable(readerInput ? 'Enter apply · Esc cancel'
392
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' : '')
393
401
  + ' · ' + ['Tab/←→ actions', 'Enter activate', ...customHints, 'Esc back'].join(' · '), width - 4);
394
402
  const maxHints = Math.max(1, Math.min(4, pageHeight - 8 - actionRows.length));
@@ -411,6 +419,7 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
411
419
  const visible = document.slice(start, start + bodyRows);
412
420
  const query = state.documentQuery ?? '';
413
421
  const matches = source === undefined ? [] : documentMatches(source, query);
422
+ const textMatches = source === undefined ? findInRows(document, query) : [];
414
423
  const matching = new Set(matches);
415
424
  if (layout !== undefined)
416
425
  for (let at = 0; at < visible.length; at++) {
@@ -419,6 +428,15 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
419
428
  visible[at] = theme.inverse(layout.gutterWidth === 0 ? row : '›' + row.slice(1));
420
429
  }
421
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
+ }
422
440
  while (visible.length < bodyRows)
423
441
  visible.push('');
424
442
  const subject = state.request.presentation === 'document' ? 'document' : 'plan';
@@ -455,6 +473,19 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
455
473
  };
456
474
  }
457
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
+ }
458
489
  if (source !== undefined) {
459
490
  const row = layout?.sourceRows[start] ?? 0;
460
491
  const model = documentModel(source);
@@ -476,7 +507,11 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
476
507
  ? 'Tab/←→ select · Enter activate · ' + state.request.actions.map(action => `${action.key.toUpperCase()} ${action.label}`).join(' · ') + ' · Esc back'
477
508
  : formatOverlayHint([HOTKEY_PAGE, HOTKEY_NAVIGATE_TAB, HOTKEY_SELECT, HOTKEY_CANCEL])
478
509
  + (state.request.presentation === 'plan-review' ? ' · Ctrl+X editor' : '');
479
- lines.push(...(source === undefined ? [`[${shortcuts}]`] : readerHints).map(hint => pageRow(theme, theme.fg('dim', hint), width)));
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)));
480
515
  lines.push(pageBottom(theme, width));
481
516
  }
482
517
  return {
@@ -488,9 +523,16 @@ export function renderPlanReviewPage(state, theme, width, height, input, inputCu
488
523
  cursorVisible: feedback || readerInput,
489
524
  };
490
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
+ }
491
532
  /** Render the prompt card and its answer editor as one bottom-of-screen overlay. */
492
533
  export function renderPromptSelector(state, theme, width, input, inputCursor, maxVisible = PROMPT_SELECTOR_MAX_VISIBLE) {
493
534
  const options = filteredPromptOptions(state.request, input, state.collapsed);
535
+ const filtering = input !== '';
494
536
  const contentWidth = Math.max(1, width - 4);
495
537
  const body = [state.request.question];
496
538
  if (state.request.wait !== undefined) {
@@ -503,22 +545,65 @@ export function renderPromptSelector(state, theme, width, input, inputCursor, ma
503
545
  body.push('', state.request.detail);
504
546
  if (options.length > 0) {
505
547
  const { start, end } = promptSelectorVisibleRange(options.length, state.selected, maxVisible);
506
- 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))));
507
549
  if (options.length > maxVisible) {
508
550
  body.push(theme.fg('dim', ` ${state.selected + 1}/${options.length} · scroll for more`));
509
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.')}`));
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);
510
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;
511
594
  const submit = state.request.submitLabel?.trim()
512
595
  || (options.length === 0 ? 'answer' : state.request.multiSelect === true ? 'confirm' : 'select');
513
596
  // The request supplies the submit verb; the key itself comes from the catalog.
514
597
  const select = `${formatHotkeyKeys(HOTKEY_SELECT)} ${submit}`;
515
598
  const navigation = state.request.presentation === 'progress'
516
599
  ? formatOverlayHint([HOTKEY_CANCEL, HOTKEY_CANCEL_CTRL], 'catalog')
517
- : options.length === 0
518
- ? `${select} · ${formatOverlayHint([HOTKEY_CANCEL])}`
519
- : state.request.multiSelect === true
520
- ? `${formatOverlayHint([HOTKEY_NAVIGATE])} · ${formatOverlayHint([HOTKEY_TOGGLE])} · ${select} · ${formatOverlayHint([HOTKEY_CANCEL])}`
521
- : `${formatOverlayHint([HOTKEY_NAVIGATE])} · ${select} · ${formatOverlayHint([HOTKEY_CANCEL])}`;
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])}`;
522
607
  const dismiss = state.request.dismissLabel;
523
608
  const actionHints = (state.request.actions ?? []).map(action => `${action.key} ${action.label}`);
524
609
  const hints = [
@@ -535,16 +620,50 @@ export function renderPromptSelector(state, theme, width, input, inputCursor, ma
535
620
  applyBg: false,
536
621
  }, theme);
537
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.
538
626
  return {
539
627
  lines: card,
540
- // Keep the terminal caret on body padding; the visible selector glyph
541
- // owns focus while no text editor is present.
542
- cursor: { row: Math.min(1, Math.max(0, card.length - 1)), column: 1 },
543
- 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,
544
632
  };
545
633
  }
546
634
  const secret = state.request.secret === true;
547
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
+ }
548
667
  const editor = renderEditor({
549
668
  width,
550
669
  input: displayInput,
@@ -583,12 +702,14 @@ export function togglePromptSelection(state, query = '') {
583
702
  }
584
703
  /** Resolve the selected labels in stable option order. */
585
704
  export function selectedPromptAnswer(state) {
586
- const options = state.request.presentation === 'fullscreen-tree'
705
+ const options = state.request.presentation === 'tree'
587
706
  ? filteredPromptOptions(state.request, '', state.collapsed) : state.request.options ?? [];
588
707
  if (options.length === 0)
589
708
  return null;
590
709
  if (state.request.multiSelect !== true) {
591
710
  const option = options[state.selected];
711
+ if (option?.disabled === true)
712
+ return null;
592
713
  return option?.value ?? option?.label ?? null;
593
714
  }
594
715
  const labels = options.flatMap((option, index) => state.checked.has(index) ? [option.value ?? option.label] : []);
@@ -597,6 +718,9 @@ export function selectedPromptAnswer(state) {
597
718
  /** Resolve the selected answer after applying a full-screen selector query. */
598
719
  export function selectedFilteredPromptAnswer(state, query) {
599
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;
600
724
  return option?.value ?? option?.label ?? null;
601
725
  }
602
726
  //# sourceMappingURL=prompt-selector.js.map