@robota-sdk/agent-ui-terminal 3.0.0-beta.81

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 (328) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +27 -0
  3. package/dist/node/index.cjs +32 -0
  4. package/dist/node/index.d.cts +1066 -0
  5. package/dist/node/index.d.cts.map +1 -0
  6. package/dist/node/index.d.ts +1066 -0
  7. package/dist/node/index.d.ts.map +1 -0
  8. package/dist/node/index.js +33 -0
  9. package/dist/node/index.js.map +1 -0
  10. package/package.json +93 -0
  11. package/src/App.tsx +146 -0
  12. package/src/AppPresentation.tsx +190 -0
  13. package/src/AppView.tsx +26 -0
  14. package/src/BackgroundTaskPanel.tsx +73 -0
  15. package/src/CjkTextInput.tsx +336 -0
  16. package/src/ConfirmPrompt.tsx +140 -0
  17. package/src/ContextWarningBanner.tsx +45 -0
  18. package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
  19. package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
  20. package/src/HistorySearchOverlay.tsx +150 -0
  21. package/src/InputArea.tsx +338 -0
  22. package/src/ListPicker.tsx +158 -0
  23. package/src/MenuSelect.tsx +159 -0
  24. package/src/MessageList.tsx +308 -0
  25. package/src/MultiSelectList.tsx +294 -0
  26. package/src/PendingActionPrompt.tsx +121 -0
  27. package/src/PermissionPrompt.tsx +186 -0
  28. package/src/PluginTUI.tsx +258 -0
  29. package/src/RoleLabel.tsx +72 -0
  30. package/src/SafeText.tsx +66 -0
  31. package/src/SessionEventNotices.tsx +27 -0
  32. package/src/SessionPicker.tsx +72 -0
  33. package/src/SessionStatusBar.tsx +76 -0
  34. package/src/SlashAutocomplete.tsx +154 -0
  35. package/src/StatusBar.tsx +284 -0
  36. package/src/StreamingIndicator.tsx +145 -0
  37. package/src/SupervisedSessionView.tsx +455 -0
  38. package/src/TextPrompt.tsx +112 -0
  39. package/src/ThemePicker.tsx +366 -0
  40. package/src/ToolCommandOutput.tsx +48 -0
  41. package/src/ToolDiffBlock.tsx +44 -0
  42. package/src/TransportTUI.tsx +166 -0
  43. package/src/TuiInteractionChannel.ts +567 -0
  44. package/src/UpdateNotice.tsx +18 -0
  45. package/src/UsageSummaryEntry.tsx +49 -0
  46. package/src/WaveText.tsx +70 -0
  47. package/src/__tests__/ListPicker.test.tsx +188 -0
  48. package/src/__tests__/MenuSelect.test.tsx +127 -0
  49. package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
  50. package/src/__tests__/PluginTUI.test.tsx +167 -0
  51. package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
  52. package/src/__tests__/SupervisedSessionView-render.test.tsx +24 -0
  53. package/src/__tests__/SupervisedSessionView.test.tsx +629 -0
  54. package/src/__tests__/TextPrompt.test.tsx +112 -0
  55. package/src/__tests__/TransportTUI.test.tsx +124 -0
  56. package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
  57. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +284 -0
  58. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +642 -0
  59. package/src/__tests__/UpdateNotice.test.tsx +15 -0
  60. package/src/__tests__/abort-after-permission.test.tsx +160 -0
  61. package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
  62. package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
  63. package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
  64. package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
  65. package/src/__tests__/background-task-panel.test.tsx +98 -0
  66. package/src/__tests__/background-task-row-format.test.ts +152 -0
  67. package/src/__tests__/channel-factory-integration.test.ts +150 -0
  68. package/src/__tests__/cjk-defer-submit.test.tsx +67 -0
  69. package/src/__tests__/cjk-fallback-render.test.tsx +78 -0
  70. package/src/__tests__/cjk-text-input-flow.test.ts +186 -0
  71. package/src/__tests__/cjk-text-input.test.ts +191 -0
  72. package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
  73. package/src/__tests__/command-output-summary.test.ts +95 -0
  74. package/src/__tests__/command-result-handler.test.ts +171 -0
  75. package/src/__tests__/compact-event-bridge.test.ts +20 -0
  76. package/src/__tests__/confirm-permission-flow.test.ts +149 -0
  77. package/src/__tests__/confirm-prompt.test.tsx +103 -0
  78. package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
  79. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +45 -0
  80. package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
  81. package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
  82. package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
  83. package/src/__tests__/external-prompt-notice.test.tsx +100 -0
  84. package/src/__tests__/fixtures/command-handoff-driver.tsx +111 -0
  85. package/src/__tests__/fixtures/fake-editor.sh +7 -0
  86. package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
  87. package/src/__tests__/fork-attach.test.tsx +235 -0
  88. package/src/__tests__/helpers/terminal-profiles.ts +152 -0
  89. package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
  90. package/src/__tests__/history-clear-broadcast.test.ts +66 -0
  91. package/src/__tests__/history-search-overlay.test.tsx +329 -0
  92. package/src/__tests__/humanize-tool-name.test.ts +30 -0
  93. package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
  94. package/src/__tests__/input-area-flow.test.ts +199 -0
  95. package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
  96. package/src/__tests__/interaction-channel-charter.test.ts +13 -0
  97. package/src/__tests__/key-hint-consistency.test.tsx +249 -0
  98. package/src/__tests__/key-hint-footer.test.tsx +69 -0
  99. package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
  100. package/src/__tests__/message-list-rendering.test.tsx +473 -0
  101. package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
  102. package/src/__tests__/output-style-reaches-session.test.ts +35 -0
  103. package/src/__tests__/palette-consistency.test.ts +330 -0
  104. package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
  105. package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
  106. package/src/__tests__/product-display-name.test.tsx +79 -0
  107. package/src/__tests__/prompt-queue.test.tsx +260 -0
  108. package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
  109. package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
  110. package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
  111. package/src/__tests__/pty/behavior-2437-git.ptytest.ts +342 -0
  112. package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
  113. package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
  114. package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
  115. package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
  116. package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
  117. package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
  118. package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
  119. package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +291 -0
  120. package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +165 -0
  121. package/src/__tests__/pty/ime-cursor.ptytest.ts +186 -0
  122. package/src/__tests__/pty/isolated-home.ts +64 -0
  123. package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
  124. package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
  125. package/src/__tests__/pty/pty-driver.ts +153 -0
  126. package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
  127. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +134 -0
  128. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +138 -0
  129. package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
  130. package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
  131. package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
  132. package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
  133. package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
  134. package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
  135. package/src/__tests__/pty/spawn-pty.test.ts +120 -0
  136. package/src/__tests__/pty/spawn-pty.ts +229 -0
  137. package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
  138. package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
  139. package/src/__tests__/pty-driver-dispose.test.ts +43 -0
  140. package/src/__tests__/real-cursor-positioning.test.tsx +263 -0
  141. package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
  142. package/src/__tests__/render-channel-options.test.ts +201 -0
  143. package/src/__tests__/render-lifecycle.test.ts +36 -0
  144. package/src/__tests__/render-markdown.test.ts +164 -0
  145. package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
  146. package/src/__tests__/safe-text-boundary.test.tsx +75 -0
  147. package/src/__tests__/screen-reader-input.test.tsx +106 -0
  148. package/src/__tests__/screen-reader-labels.test.ts +91 -0
  149. package/src/__tests__/screen-reader-menus.test.tsx +200 -0
  150. package/src/__tests__/screen-reader-pacing.test.ts +230 -0
  151. package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
  152. package/src/__tests__/screen-reader-render-options.test.ts +262 -0
  153. package/src/__tests__/screen-reader-stdout.test.ts +322 -0
  154. package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
  155. package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
  156. package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
  157. package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
  158. package/src/__tests__/selection-flow.test.ts +61 -0
  159. package/src/__tests__/session-event-notices.test.tsx +31 -0
  160. package/src/__tests__/session-init-poller.test.ts +102 -0
  161. package/src/__tests__/session-switch-channel.test.tsx +470 -0
  162. package/src/__tests__/shutdown-signal.test.ts +34 -0
  163. package/src/__tests__/status-activity.test.ts +71 -0
  164. package/src/__tests__/status-bar.test.tsx +282 -0
  165. package/src/__tests__/status-glyph.test.ts +30 -0
  166. package/src/__tests__/streaming-indicator.test.tsx +177 -0
  167. package/src/__tests__/terminal-capabilities.test.ts +153 -0
  168. package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
  169. package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
  170. package/src/__tests__/text-prompt-flow.test.ts +77 -0
  171. package/src/__tests__/theme-picker.test.tsx +345 -0
  172. package/src/__tests__/transport-boundary.test.ts +11 -0
  173. package/src/__tests__/tui-app-channel-port.test.ts +31 -0
  174. package/src/__tests__/tui-channel-init-failure.test.ts +62 -0
  175. package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
  176. package/src/__tests__/tui-interaction-queues.test.ts +139 -0
  177. package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
  178. package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
  179. package/src/__tests__/tui-state-manager.test.ts +451 -0
  180. package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
  181. package/src/__tests__/wave-text.test.tsx +133 -0
  182. package/src/app-banner.tsx +42 -0
  183. package/src/app-static-items.ts +35 -0
  184. package/src/app-view-model.ts +136 -0
  185. package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
  186. package/src/attention/__tests__/attention-tracker.test.ts +103 -0
  187. package/src/attention/__tests__/countdown.test.ts +15 -0
  188. package/src/attention/__tests__/focus-input-filter.test.ts +92 -0
  189. package/src/attention/__tests__/interval-recap.test.ts +49 -0
  190. package/src/attention/attention-coordinator.ts +71 -0
  191. package/src/attention/attention-tracker.ts +124 -0
  192. package/src/attention/countdown.ts +24 -0
  193. package/src/attention/focus-input-filter.ts +131 -0
  194. package/src/attention/interval-recap.ts +148 -0
  195. package/src/attention/time-units.ts +6 -0
  196. package/src/attention-bell.ts +79 -0
  197. package/src/attributed-user-echo.ts +30 -0
  198. package/src/background-task-row-format.ts +99 -0
  199. package/src/command-interaction.ts +8 -0
  200. package/src/command-output-summary.ts +129 -0
  201. package/src/create-default-tui-cli-adapter.ts +45 -0
  202. package/src/execution-workspace-switcher-selection.ts +118 -0
  203. package/src/execution-workspace-view-model.ts +139 -0
  204. package/src/external-prompt-notice.tsx +38 -0
  205. package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
  206. package/src/flows/__tests__/defer-submit.test.ts +87 -0
  207. package/src/flows/__tests__/real-cursor-flow.test.ts +124 -0
  208. package/src/flows/background-focus-flow.ts +44 -0
  209. package/src/flows/cjk-cursor-motion.ts +75 -0
  210. package/src/flows/cjk-text-deletion.ts +53 -0
  211. package/src/flows/cjk-text-input-flow.ts +248 -0
  212. package/src/flows/cjk-text-input-types.ts +24 -0
  213. package/src/flows/confirm-prompt-flow.ts +90 -0
  214. package/src/flows/defer-submit.ts +67 -0
  215. package/src/flows/fork-attach-flow.ts +62 -0
  216. package/src/flows/input-area-flow.ts +188 -0
  217. package/src/flows/permission-prompt-flow.ts +108 -0
  218. package/src/flows/real-cursor-flow.ts +83 -0
  219. package/src/flows/selection-flow.ts +205 -0
  220. package/src/flows/session-init-poller.ts +76 -0
  221. package/src/flows/text-prompt-flow.ts +93 -0
  222. package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
  223. package/src/history-search/history-search-flow.ts +94 -0
  224. package/src/history-search/useHistorySearch.ts +336 -0
  225. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +74 -0
  226. package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
  227. package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
  228. package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
  229. package/src/hooks/command-result-handler.ts +44 -0
  230. package/src/hooks/side-effects-types.ts +45 -0
  231. package/src/hooks/useAppController.ts +267 -0
  232. package/src/hooks/useAppInputBindings.ts +145 -0
  233. package/src/hooks/useAppInteractionState.ts +81 -0
  234. package/src/hooks/useAppLifecycleState.ts +81 -0
  235. package/src/hooks/useAppOverlays.ts +74 -0
  236. package/src/hooks/useAppScreenState.ts +81 -0
  237. package/src/hooks/useAppSubmissionState.ts +45 -0
  238. package/src/hooks/useAppThemeState.ts +157 -0
  239. package/src/hooks/useAppWorkspaceState.ts +149 -0
  240. package/src/hooks/useAppearanceSettings.ts +30 -0
  241. package/src/hooks/useAutocomplete.ts +101 -0
  242. package/src/hooks/useBackgroundPanel.ts +58 -0
  243. package/src/hooks/useCountdownTick.ts +24 -0
  244. package/src/hooks/useExecutionDetailPage.ts +69 -0
  245. package/src/hooks/useForkAttach.ts +60 -0
  246. package/src/hooks/useInputAreaHistorySearch.ts +72 -0
  247. package/src/hooks/useInputAreaKeys.ts +114 -0
  248. package/src/hooks/useNumberedSelection.ts +76 -0
  249. package/src/hooks/usePluginCallbacks.ts +38 -0
  250. package/src/hooks/usePluginScreenData.ts +85 -0
  251. package/src/hooks/useRealCursorPosition.ts +125 -0
  252. package/src/hooks/useRenderMarkdown.ts +38 -0
  253. package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
  254. package/src/hooks/useSideEffects.ts +144 -0
  255. package/src/hooks/useStatusLineSettings.ts +50 -0
  256. package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
  257. package/src/hooks/useTuiChannel.ts +129 -0
  258. package/src/humanize-tool-name.ts +51 -0
  259. package/src/index.ts +56 -0
  260. package/src/input-area-rules.tsx +71 -0
  261. package/src/json-value.ts +18 -0
  262. package/src/key-hint-footer.tsx +54 -0
  263. package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
  264. package/src/keybindings/__tests__/node-keybindings-source.test.ts +111 -0
  265. package/src/keybindings/keybinding-catalogue.ts +171 -0
  266. package/src/keybindings/keybinding-registry.ts +303 -0
  267. package/src/keybindings/keybinding-syntax.ts +155 -0
  268. package/src/keybindings/keybindings-context.tsx +124 -0
  269. package/src/keybindings/node-keybindings-source.ts +189 -0
  270. package/src/numbered-list.tsx +113 -0
  271. package/src/plugin-tui-handlers.ts +163 -0
  272. package/src/product-display-name-context.tsx +30 -0
  273. package/src/render-markdown.ts +209 -0
  274. package/src/render.tsx +544 -0
  275. package/src/sanitize-terminal-text.ts +171 -0
  276. package/src/screen-reader-announcement.ts +52 -0
  277. package/src/screen-reader-context.tsx +32 -0
  278. package/src/screen-reader-labels.ts +64 -0
  279. package/src/screen-reader-pacing-context.tsx +47 -0
  280. package/src/screen-reader-pacing.ts +178 -0
  281. package/src/screen-reader-stdout.ts +328 -0
  282. package/src/shutdown-signal.ts +34 -0
  283. package/src/status-activity.ts +70 -0
  284. package/src/status-glyph.ts +78 -0
  285. package/src/terminal-capabilities-context.tsx +27 -0
  286. package/src/terminal-capabilities.ts +77 -0
  287. package/src/terminal-focus-reporting.ts +50 -0
  288. package/src/terminal-handoff-controller.ts +98 -0
  289. package/src/terminal-marks.ts +63 -0
  290. package/src/theme/__tests__/built-in-themes.test.ts +117 -0
  291. package/src/theme/__tests__/color-vision.test.ts +82 -0
  292. package/src/theme/__tests__/theme-context.test.tsx +146 -0
  293. package/src/theme/__tests__/theme-document.test.ts +222 -0
  294. package/src/theme/__tests__/theme-registry.test.ts +138 -0
  295. package/src/theme/__tests__/theme-styles.test.ts +233 -0
  296. package/src/theme/built-in-themes.ts +342 -0
  297. package/src/theme/color-vision.ts +171 -0
  298. package/src/theme/index.ts +45 -0
  299. package/src/theme/theme-context.tsx +81 -0
  300. package/src/theme/theme-contracts.ts +139 -0
  301. package/src/theme/theme-document.ts +278 -0
  302. package/src/theme/theme-registry.ts +127 -0
  303. package/src/theme/theme-styles.ts +177 -0
  304. package/src/tool-summary-status.ts +59 -0
  305. package/src/tui-app-channel-port.ts +83 -0
  306. package/src/tui-channel-lifecycle-coordinator.ts +149 -0
  307. package/src/tui-channel-options.ts +172 -0
  308. package/src/tui-cli-adapter-context.tsx +13 -0
  309. package/src/tui-cli-adapter.ts +24 -0
  310. package/src/tui-interaction-queues.ts +163 -0
  311. package/src/tui-session-binding.ts +52 -0
  312. package/src/tui-session-event-projector.ts +140 -0
  313. package/src/tui-session-events.ts +82 -0
  314. package/src/tui-session-notice-store.ts +43 -0
  315. package/src/tui-session-options.ts +118 -0
  316. package/src/tui-state-manager.ts +327 -0
  317. package/src/types.ts +19 -0
  318. package/src/use-terminal-title.ts +29 -0
  319. package/src/utils/__tests__/edit-diff.test.ts +426 -0
  320. package/src/utils/__tests__/paste-detection.test.ts +116 -0
  321. package/src/utils/__tests__/paste-labels.test.ts +46 -0
  322. package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
  323. package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
  324. package/src/utils/edit-diff.ts +151 -0
  325. package/src/utils/input-top-border.ts +19 -0
  326. package/src/utils/paste-labels.ts +9 -0
  327. package/src/utils/tool-call-extractor.ts +92 -0
  328. package/src/utils/tool-diff-summary.ts +75 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Multi-select checklist (CMD-004): arrow-key navigation, Space toggles, Enter confirms once the
3
+ * selection count is within [minSelect, maxSelect], Esc cancels. Used by PendingActionPrompt for an
4
+ * IActionRequest whose `maxSelect` > 1.
5
+ *
6
+ * Note: unlike ListPicker, this renders all options without a scroll viewport — `IActionRequest`'s
7
+ * optional `maxVisible` hint is not yet honored here (multi-select lists are typically short). Adding a
8
+ * viewport is a follow-up if long multi-select lists appear.
9
+ */
10
+
11
+ import { Box } from 'ink';
12
+ import React, { useState } from 'react';
13
+
14
+ import { useNumberedSelection } from './hooks/useNumberedSelection.js';
15
+ import {
16
+ KeyHintFooter,
17
+ SELECTION_INDICATOR,
18
+ SELECTION_INDICATOR_NONE,
19
+ type IKeyHint,
20
+ } from './key-hint-footer.js';
21
+ import { useKeybindingActions, useKeybindingHints } from './keybindings/keybindings-context.js';
22
+ import { NumberedSelectionPrompt, numberedRowPrefix } from './numbered-list.js';
23
+ import { Text } from './SafeText.js';
24
+ import { useScreenReader } from './screen-reader-context.js';
25
+ import { usePalette } from './theme/index.js';
26
+
27
+ import type { IActionOption } from '@robota-sdk/agent-core';
28
+
29
+ /**
30
+ * Footer hints for the multi-select checklist. The Enter hint carries a dynamic `(min N)` segment
31
+ * until the selection is confirmable.
32
+ */
33
+ export function getMultiSelectFooterHints(input: {
34
+ canConfirm: boolean;
35
+ minSelect: number;
36
+ }): readonly IKeyHint[] {
37
+ return [
38
+ { keys: '↑↓', label: 'Navigate' },
39
+ { keys: 'Space', label: 'Toggle' },
40
+ { keys: 'Enter', label: input.canConfirm ? 'Confirm' : `Confirm (min ${input.minSelect})` },
41
+ { keys: 'Esc', label: 'Cancel' },
42
+ ];
43
+ }
44
+
45
+ /**
46
+ * One checklist row. The mode swaps the position cue — a spoken number for the `> ` cursor and its
47
+ * colour — and nothing else: the checkbox is what says selected, in both.
48
+ */
49
+ function ChecklistRow({
50
+ label,
51
+ index,
52
+ isCursor,
53
+ isChecked,
54
+ screenReader,
55
+ }: {
56
+ label: string;
57
+ index: number;
58
+ isCursor: boolean;
59
+ isChecked: boolean;
60
+ screenReader: boolean;
61
+ }): React.ReactElement {
62
+ const palette = usePalette();
63
+ const box = isChecked ? '[x] ' : '[ ] ';
64
+ if (screenReader) {
65
+ return (
66
+ <Text>
67
+ {numberedRowPrefix(index)}
68
+ {box}
69
+ {label}
70
+ </Text>
71
+ );
72
+ }
73
+ return (
74
+ <Text color={isCursor ? palette.text.accent : undefined}>
75
+ {isCursor ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
76
+ {box}
77
+ {label}
78
+ </Text>
79
+ );
80
+ }
81
+
82
+ export interface IMultiSelectListProps {
83
+ title: string;
84
+ description?: string;
85
+ options: readonly IActionOption[];
86
+ minSelect: number;
87
+ maxSelect: number;
88
+ defaultValues?: readonly string[];
89
+ onConfirm: (values: string[]) => void;
90
+ onCancel: () => void;
91
+ }
92
+
93
+ /** The arrow-key driver: cursor motion, Space toggles, Enter confirms. Inactive in the mode. */
94
+ function useChecklistArrowKeys(inputs: {
95
+ active: boolean;
96
+ itemCount: number;
97
+ setCursor: React.Dispatch<React.SetStateAction<number>>;
98
+ onToggleAtCursor: () => void;
99
+ onConfirm: () => void;
100
+ onCancel: () => void;
101
+ }): void {
102
+ const { active, itemCount, setCursor } = inputs;
103
+ useKeybindingActions(
104
+ 'multi-select',
105
+ (actions) => {
106
+ for (const action of actions) {
107
+ if (action === 'previous') {
108
+ setCursor((c) => (c <= 0 ? itemCount - 1 : c - 1));
109
+ } else if (action === 'next') {
110
+ setCursor((c) => (c >= itemCount - 1 ? 0 : c + 1));
111
+ } else if (action === 'toggle') {
112
+ inputs.onToggleAtCursor();
113
+ } else if (action === 'confirm') {
114
+ inputs.onConfirm();
115
+ } else if (action === 'cancel') {
116
+ inputs.onCancel();
117
+ }
118
+ }
119
+ },
120
+ { isActive: active },
121
+ );
122
+ }
123
+
124
+ /** How the checklist asks for an answer: a spoken range in the mode, the key hints otherwise. */
125
+ function ChecklistAnswer(props: {
126
+ screenReader: boolean;
127
+ itemCount: number;
128
+ numbered: { buffer: string; invalid: boolean };
129
+ canConfirm: boolean;
130
+ minSelect: number;
131
+ }): React.ReactElement {
132
+ const hints = useKeybindingHints('multi-select', [
133
+ [['previous', 'next'], 'Navigate'],
134
+ ['toggle', 'Toggle'],
135
+ ['confirm', props.canConfirm ? 'Confirm' : `Confirm (min ${props.minSelect})`],
136
+ ['cancel', 'Cancel'],
137
+ ]);
138
+ if (props.screenReader) {
139
+ return (
140
+ <NumberedSelectionPrompt
141
+ itemCount={props.itemCount}
142
+ cancellable
143
+ buffer={props.numbered.buffer}
144
+ invalid={props.numbered.invalid}
145
+ />
146
+ );
147
+ }
148
+ return <KeyHintFooter hints={hints} />;
149
+ }
150
+
151
+ /** The rendered checklist: the rows, and whichever answer affordance the mode calls for. */
152
+ function ChecklistView(props: {
153
+ title: string;
154
+ description?: string;
155
+ options: readonly IActionOption[];
156
+ cursor: number;
157
+ selected: ReadonlySet<string>;
158
+ screenReader: boolean;
159
+ canConfirm: boolean;
160
+ minSelect: number;
161
+ numbered: { buffer: string; invalid: boolean };
162
+ }): React.ReactElement {
163
+ const palette = usePalette();
164
+ const { title, description, options, cursor, selected, screenReader, numbered } = props;
165
+ return (
166
+ <Box
167
+ flexDirection="column"
168
+ {...(screenReader
169
+ ? {}
170
+ : { borderStyle: 'round' as const, borderColor: palette.border.attention })}
171
+ paddingX={1}
172
+ >
173
+ <Text color={palette.text.warning} bold>
174
+ {title}
175
+ </Text>
176
+ {description !== undefined && description.length > 0 && <Text dimColor>{description}</Text>}
177
+ {options.map((option, index) => (
178
+ <ChecklistRow
179
+ key={option.value}
180
+ label={option.label}
181
+ index={index}
182
+ isCursor={index === cursor}
183
+ isChecked={selected.has(option.value)}
184
+ screenReader={screenReader}
185
+ />
186
+ ))}
187
+ <ChecklistAnswer
188
+ screenReader={screenReader}
189
+ itemCount={options.length}
190
+ numbered={numbered}
191
+ canConfirm={props.canConfirm}
192
+ minSelect={props.minSelect}
193
+ />
194
+ </Box>
195
+ );
196
+ }
197
+
198
+ /** The checklist's own state: which row the cursor is on, what is ticked, and how it commits. */
199
+ function useChecklistSelection(inputs: {
200
+ options: readonly IActionOption[];
201
+ minSelect: number;
202
+ maxSelect: number;
203
+ defaultValues?: readonly string[];
204
+ onConfirm: (values: string[]) => void;
205
+ }): {
206
+ cursor: number;
207
+ setCursor: React.Dispatch<React.SetStateAction<number>>;
208
+ selected: ReadonlySet<string>;
209
+ toggleAt: (index: number) => void;
210
+ confirmIfAllowed: () => void;
211
+ } {
212
+ const { options, minSelect, maxSelect, defaultValues, onConfirm } = inputs;
213
+ const [cursor, setCursor] = useState(0);
214
+ const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set(defaultValues ?? []));
215
+
216
+ const toggleAt = (index: number): void => {
217
+ const option = options[index];
218
+ if (option === undefined) return;
219
+ setSelected((prev) => {
220
+ const next = new Set(prev);
221
+ if (next.has(option.value)) {
222
+ next.delete(option.value);
223
+ } else if (next.size < maxSelect) {
224
+ next.add(option.value);
225
+ }
226
+ return next;
227
+ });
228
+ };
229
+ const confirmIfAllowed = (): void => {
230
+ if (selected.size >= minSelect) onConfirm([...selected]);
231
+ };
232
+
233
+ return { cursor, setCursor, selected, toggleAt, confirmIfAllowed };
234
+ }
235
+
236
+ export default function MultiSelectList({
237
+ title,
238
+ description,
239
+ options,
240
+ minSelect,
241
+ maxSelect,
242
+ defaultValues,
243
+ onConfirm,
244
+ onCancel,
245
+ }: IMultiSelectListProps): React.ReactElement {
246
+ // CLI-2004: the checklist keeps its own reducer (Space toggles, Enter confirms) — what the mode
247
+ // changes is the ROW: a spoken number replaces the `> ` cursor, and typing that number toggles it.
248
+ const screenReader = useScreenReader();
249
+ const { cursor, setCursor, selected, toggleAt, confirmIfAllowed } = useChecklistSelection({
250
+ options,
251
+ minSelect,
252
+ maxSelect,
253
+ defaultValues,
254
+ onConfirm,
255
+ });
256
+
257
+ // CLI-2004: the checklist answers by number in the mode, through the SAME reducer every other
258
+ // numbered menu uses — `multi` is what keeps it open after a toggle and makes an empty Enter the
259
+ // commit. A local digit branch would have capped it at nine rows and skipped the shared prompt.
260
+ const numbered = useNumberedSelection({
261
+ enabled: screenReader,
262
+ itemCount: options.length,
263
+ cancellable: true,
264
+ multi: true,
265
+ onSelect: toggleAt,
266
+ onConfirm: confirmIfAllowed,
267
+ onCancel,
268
+ });
269
+
270
+ useChecklistArrowKeys({
271
+ active: !screenReader,
272
+ itemCount: options.length,
273
+ setCursor,
274
+ onToggleAtCursor: () => toggleAt(cursor),
275
+ onConfirm: confirmIfAllowed,
276
+ onCancel,
277
+ });
278
+
279
+ const canConfirm = selected.size >= minSelect;
280
+
281
+ return (
282
+ <ChecklistView
283
+ title={title}
284
+ {...(description !== undefined ? { description } : {})}
285
+ options={options}
286
+ cursor={cursor}
287
+ selected={selected}
288
+ screenReader={screenReader}
289
+ canConfirm={canConfirm}
290
+ minSelect={minSelect}
291
+ numbered={numbered}
292
+ />
293
+ );
294
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * PendingActionPrompt (CMD-004) — the TUI renderer for the unified `IActionRequest`. It derives the
3
+ * presentation from the request's fields (not a kind switch):
4
+ * - no options → free-text (TextPrompt, honoring `masked`/`placeholder`/`allowEmpty`)
5
+ * - options, `maxSelect` > 1 → multi-select checklist (MultiSelectList)
6
+ * - options, `maxSelect` <= 1 → single-select list (ListPicker); with `allowFreeText`, a synthetic
7
+ * "Type a custom answer…" entry switches to a text field
8
+ *
9
+ * It always resolves the caller's `onAnswer` with a `TActionResponse` (answer or cancelled).
10
+ */
11
+
12
+ import { Box } from 'ink';
13
+ import React, { useState } from 'react';
14
+
15
+ import { SELECTION_INDICATOR, SELECTION_INDICATOR_NONE } from './key-hint-footer.js';
16
+ import ListPicker from './ListPicker.js';
17
+ import MultiSelectList from './MultiSelectList.js';
18
+ import { Text } from './SafeText.js';
19
+ import TextPrompt from './TextPrompt.js';
20
+ import { usePalette } from './theme/index.js';
21
+
22
+ import type { IActionOption, IActionRequest, TActionResponse } from '@robota-sdk/agent-core';
23
+
24
+ interface IPendingActionPromptProps {
25
+ request: IActionRequest;
26
+ onAnswer: (response: TActionResponse) => void;
27
+ }
28
+
29
+ /**
30
+ * Synthetic "type a custom answer" entry for a single-select with free text. Routed by object identity
31
+ * (reference equality), not by a magic value — so it can never collide with a real option's `value`.
32
+ */
33
+ const FREE_TEXT_OPTION: IActionOption = { value: '', label: 'Type a custom answer…' };
34
+
35
+ export default function PendingActionPrompt({
36
+ request,
37
+ onAnswer,
38
+ }: IPendingActionPromptProps): React.ReactElement {
39
+ const palette = usePalette();
40
+ const [freeTextMode, setFreeTextMode] = useState(false);
41
+
42
+ const options = request.options ?? [];
43
+ const hasOptions = options.length > 0;
44
+ const maxSelect = request.maxSelect ?? 1;
45
+ const cancel = (): void => onAnswer({ type: 'cancelled' });
46
+
47
+ // Pure free-text (no options) — or the user chose "type a custom answer" on a single-select.
48
+ if (!hasOptions || freeTextMode) {
49
+ return (
50
+ <TextPrompt
51
+ key={`text:${request.id}`}
52
+ title={request.title}
53
+ description={request.description}
54
+ placeholder={request.placeholder}
55
+ allowEmpty={request.allowEmpty}
56
+ masked={request.masked}
57
+ onSubmit={(value) => onAnswer({ type: 'answer', values: [], text: value })}
58
+ onCancel={freeTextMode ? () => setFreeTextMode(false) : cancel}
59
+ />
60
+ );
61
+ }
62
+
63
+ // Multi-select.
64
+ if (maxSelect > 1) {
65
+ return (
66
+ <MultiSelectList
67
+ title={request.title}
68
+ description={request.description}
69
+ options={options}
70
+ minSelect={request.minSelect ?? 1}
71
+ maxSelect={maxSelect}
72
+ defaultValues={request.default?.values}
73
+ onConfirm={(values) => onAnswer({ type: 'answer', values })}
74
+ onCancel={cancel}
75
+ />
76
+ );
77
+ }
78
+
79
+ // Single-select, optionally with a "type your own" entry (routed by identity, see FREE_TEXT_OPTION).
80
+ const pickerItems: IActionOption[] = request.allowFreeText
81
+ ? [...options, FREE_TEXT_OPTION]
82
+ : [...options];
83
+ // BEHAVIOR-2437: a request's declared default is where the highlight STARTS — for a write
84
+ // confirmation built with `defaultYes: false`, a bare Enter must answer No, not Yes.
85
+ const defaultValue = request.default?.values?.[0];
86
+ const initialIndex =
87
+ defaultValue === undefined
88
+ ? 0
89
+ : Math.max(
90
+ 0,
91
+ pickerItems.findIndex((option) => option.value === defaultValue),
92
+ );
93
+
94
+ return (
95
+ <Box flexDirection="column">
96
+ <Text bold>{request.title}</Text>
97
+ {request.description !== undefined && request.description.length > 0 && (
98
+ <Text dimColor>{request.description}</Text>
99
+ )}
100
+ <ListPicker<IActionOption>
101
+ items={pickerItems}
102
+ initialIndex={initialIndex}
103
+ maxVisible={request.maxVisible}
104
+ renderItem={(option, isSelected) => (
105
+ <Text color={isSelected ? palette.text.accent : undefined}>
106
+ {isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
107
+ {option.label}
108
+ </Text>
109
+ )}
110
+ onSelect={(option) => {
111
+ if (option === FREE_TEXT_OPTION) {
112
+ setFreeTextMode(true);
113
+ } else {
114
+ onAnswer({ type: 'answer', values: [option.value] });
115
+ }
116
+ }}
117
+ onCancel={cancel}
118
+ />
119
+ </Box>
120
+ );
121
+ }
@@ -0,0 +1,186 @@
1
+ import { consentScopeFor } from '@robota-sdk/agent-framework';
2
+ import { Box } from 'ink';
3
+ import React from 'react';
4
+
5
+ import {
6
+ applyPermissionPromptInput,
7
+ permissionPromptOptionsFor,
8
+ type TPermissionPromptInputAction,
9
+ } from './flows/permission-prompt-flow.js';
10
+ import { createSelectionFlowState, type ISelectionFlowState } from './flows/selection-flow.js';
11
+ import { useNumberedSelection } from './hooks/useNumberedSelection.js';
12
+ import {
13
+ KeyHintFooter,
14
+ SELECTION_INDICATOR,
15
+ SELECTION_INDICATOR_NONE,
16
+ type IKeyHint,
17
+ } from './key-hint-footer.js';
18
+ import { useKeybindingActions, useKeybindingHints } from './keybindings/keybindings-context.js';
19
+ import { NumberedList } from './numbered-list.js';
20
+ import { Text } from './SafeText.js';
21
+ import { useScreenReader } from './screen-reader-context.js';
22
+ import { SCREEN_READER_LABELS } from './screen-reader-labels.js';
23
+ import { usePalette } from './theme/index.js';
24
+
25
+ import type { IPendingPermissionRequest } from './types.js';
26
+ import type { TToolArgs } from '@robota-sdk/agent-core';
27
+
28
+ interface IProps {
29
+ request: IPendingPermissionRequest;
30
+ }
31
+
32
+ /**
33
+ * Footer for the permission prompt — identical to the confirm prompt's (same horizontal row, same
34
+ * reducer). Esc is deliberately suppressed by the flow (`escape: false` — a permission ask must
35
+ * resolve explicitly; Esc-dismissal would be an implicit deny), so the footer omits it.
36
+ */
37
+ export const PERMISSION_PROMPT_FOOTER_HINTS: readonly IKeyHint[] = [
38
+ { keys: '←→', label: 'Navigate' },
39
+ { keys: 'Enter', label: 'Confirm' },
40
+ ];
41
+
42
+ function formatArgs(args: TToolArgs): string {
43
+ const entries = Object.entries(args);
44
+ if (entries.length === 0) return '(no arguments)';
45
+ return entries
46
+ .map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
47
+ .join(', ');
48
+ }
49
+
50
+ export default function PermissionPrompt({ request }: IProps): React.ReactElement {
51
+ const palette = usePalette();
52
+ const canPersistProjectPermission = request.canPersistProjectPermission !== false;
53
+ const [state, setState] = React.useState<ISelectionFlowState>(() => createSelectionFlowState());
54
+ const stateRef = React.useRef(state);
55
+ const prevRequestRef = React.useRef(request);
56
+
57
+ if (prevRequestRef.current !== request) {
58
+ prevRequestRef.current = request;
59
+ const nextState = createSelectionFlowState();
60
+ stateRef.current = nextState;
61
+ setState(nextState);
62
+ }
63
+
64
+ const applyAction = React.useCallback(
65
+ (action: TPermissionPromptInputAction): void => {
66
+ const result = applyPermissionPromptInput(
67
+ stateRef.current,
68
+ action,
69
+ canPersistProjectPermission,
70
+ );
71
+ stateRef.current = result.state;
72
+ setState(result.state);
73
+ if (result.effect.type === 'resolve') {
74
+ request.resolve(result.effect.decision);
75
+ }
76
+ },
77
+ [request, canPersistProjectPermission],
78
+ );
79
+
80
+ const screenReader = useScreenReader();
81
+ const options = permissionPromptOptionsFor(
82
+ consentScopeFor(request.toolName, request.toolArgs),
83
+ canPersistProjectPermission,
84
+ );
85
+ const numbered = useNumberedSelection({
86
+ enabled: screenReader,
87
+ itemCount: options.length,
88
+ onSelect: (index) => {
89
+ const result = applyPermissionPromptInput(
90
+ stateRef.current,
91
+ {
92
+ type: 'shortcut',
93
+ index,
94
+ },
95
+ canPersistProjectPermission,
96
+ );
97
+ stateRef.current = result.state;
98
+ setState(result.state);
99
+ if (result.effect.type === 'resolve') request.resolve(result.effect.decision);
100
+ },
101
+ });
102
+
103
+ useKeybindingActions(
104
+ 'permission-prompt',
105
+ (actions) => {
106
+ const shortcutIndex = {
107
+ 'allow-once': 0,
108
+ 'allow-session': 1,
109
+ 'allow-project': 2,
110
+ deny: 3,
111
+ } as const;
112
+ for (const action of actions) {
113
+ if (action in shortcutIndex) {
114
+ applyAction({
115
+ type: 'shortcut',
116
+ index: shortcutIndex[action as keyof typeof shortcutIndex],
117
+ });
118
+ } else if (action === 'previous' || action === 'next' || action === 'confirm') {
119
+ applyAction(action === 'confirm' ? 'select' : action);
120
+ }
121
+ }
122
+ },
123
+ { isActive: !screenReader },
124
+ );
125
+ const footerHints = useKeybindingHints('permission-prompt', [
126
+ [['previous', 'next'], 'Navigate'],
127
+ ['confirm', 'Confirm'],
128
+ ]);
129
+
130
+ if (screenReader) {
131
+ return (
132
+ <NumberedList
133
+ title={SCREEN_READER_LABELS.permissionRequired}
134
+ description={`${request.toolName} — ${formatArgs(request.toolArgs)}${
135
+ request.requestedByPeer !== undefined
136
+ ? ` (requested by another session: ${request.requestedByPeer})`
137
+ : ''
138
+ }`}
139
+ options={options}
140
+ buffer={numbered.buffer}
141
+ invalid={numbered.invalid}
142
+ />
143
+ );
144
+ }
145
+
146
+ return (
147
+ <Box
148
+ flexDirection="column"
149
+ borderStyle="round"
150
+ borderColor={palette.border.attention}
151
+ paddingX={1}
152
+ >
153
+ <Text color={palette.text.warning} bold>
154
+ [Permission Required]
155
+ </Text>
156
+ <Text>
157
+ Tool:{' '}
158
+ <Text color={palette.text.accent} bold>
159
+ {request.toolName}
160
+ </Text>
161
+ </Text>
162
+ <Text dimColor> {formatArgs(request.toolArgs)}</Text>
163
+ {request.requestedByPeer !== undefined ? (
164
+ <Text color={palette.text.warning}>
165
+ Requested by another session: {request.requestedByPeer}
166
+ </Text>
167
+ ) : null}
168
+ {/* Issue #2351: the "always" options carry the consent scope, so they no longer fit one row
169
+ of the prompt box — a row wrapped `Allow [y]` across two lines. One option per line. */}
170
+ <Box marginTop={1} flexDirection="column">
171
+ {options.map((opt, i) => (
172
+ <Box key={opt}>
173
+ <Text
174
+ color={i === state.selectedIndex ? palette.text.accent : undefined}
175
+ bold={i === state.selectedIndex}
176
+ >
177
+ {i === state.selectedIndex ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
178
+ {opt}
179
+ </Text>
180
+ </Box>
181
+ ))}
182
+ </Box>
183
+ <KeyHintFooter hints={footerHints} />
184
+ </Box>
185
+ );
186
+ }