@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,158 @@
1
+ /**
2
+ * Generic list picker with arrow-key navigation and viewport scrolling.
3
+ * Renders items via a user-supplied renderItem function.
4
+ * Shows a limited number of items at a time; scrolls as the cursor moves.
5
+ */
6
+
7
+ import { Box } from 'ink';
8
+ import React, { useState, useRef, useCallback } from 'react';
9
+
10
+ import {
11
+ applySelectionInput,
12
+ createSelectionFlowState,
13
+ normalizeSelectionState,
14
+ type ISelectionFlowState,
15
+ type TSelectionInputAction,
16
+ } from './flows/selection-flow.js';
17
+ import { useNumberedSelection } from './hooks/useNumberedSelection.js';
18
+ import { KeyHintFooter, type IKeyHint } from './key-hint-footer.js';
19
+ import { useKeybindingActions, useKeybindingHints } from './keybindings/keybindings-context.js';
20
+ import { formatNumberedSelectionPrompt, numberedRowPrefix } from './numbered-list.js';
21
+ import { Text } from './SafeText.js';
22
+ import { useScreenReader } from './screen-reader-context.js';
23
+
24
+ /** Default number of visible items */
25
+ const DEFAULT_MAX_VISIBLE = 3;
26
+
27
+ export interface IListPickerProps<T> {
28
+ /** Items to display in the list */
29
+ items: T[];
30
+ /** Render function for each item — receives the item and whether it is currently selected */
31
+ renderItem: (item: T, isSelected: boolean) => React.ReactElement;
32
+ /** Called when the user presses Enter on the highlighted item */
33
+ onSelect: (item: T) => void;
34
+ /** Called when the user presses Escape */
35
+ onCancel: () => void;
36
+ /** Maximum number of items visible at once (default: 10) */
37
+ maxVisible?: number;
38
+ /** Key-hint footer shown below the list (default: navigate/select/cancel; `[]` suppresses it) */
39
+ footerHints?: readonly IKeyHint[];
40
+ /** The item highlighted first (default: the first). Out-of-range values fall back to the first. */
41
+ initialIndex?: number;
42
+ }
43
+
44
+ /** Default affordance footer — Enter always selects and Esc always cancels. */
45
+ export const LIST_PICKER_DEFAULT_FOOTER_HINTS: readonly IKeyHint[] = [
46
+ { keys: '↑↓', label: 'Navigate' },
47
+ { keys: 'Enter', label: 'Select' },
48
+ { keys: 'Esc', label: 'Cancel' },
49
+ ];
50
+
51
+ export default function ListPicker<T>({
52
+ items,
53
+ renderItem,
54
+ onSelect,
55
+ onCancel,
56
+ maxVisible = DEFAULT_MAX_VISIBLE,
57
+ footerHints = LIST_PICKER_DEFAULT_FOOTER_HINTS,
58
+ initialIndex = 0,
59
+ }: IListPickerProps<T>): React.ReactElement {
60
+ const [state, setState] = useState<ISelectionFlowState>(() => {
61
+ const index = initialIndex >= 0 && initialIndex < items.length ? initialIndex : 0;
62
+ const seeded = createSelectionFlowState(index);
63
+ // Keep the seeded row on screen when it sits past the first window.
64
+ return { ...seeded, scrollOffset: Math.max(0, index - maxVisible + 1) };
65
+ });
66
+ const stateRef = useRef(state);
67
+ const applyAction = useCallback(
68
+ (action: TSelectionInputAction): void => {
69
+ const result = applySelectionInput(stateRef.current, action, {
70
+ itemCount: items.length,
71
+ maxVisible,
72
+ });
73
+ stateRef.current = result.state;
74
+ setState(result.state);
75
+ if (result.effect.type === 'cancel') {
76
+ onCancel();
77
+ } else if (result.effect.type === 'select') {
78
+ const item = items[result.effect.index];
79
+ if (item !== undefined) {
80
+ onSelect(item);
81
+ }
82
+ }
83
+ },
84
+ [items, maxVisible, onCancel, onSelect],
85
+ );
86
+
87
+ // CLI-2004: the viewport is a sighted affordance — `↑ N more above` tells a reader nothing about
88
+ // where it is. The mode lists every item with its number and asks for one.
89
+ const screenReader = useScreenReader();
90
+ const numbered = useNumberedSelection({
91
+ enabled: screenReader && items.length > 0,
92
+ itemCount: items.length,
93
+ cancellable: true,
94
+ onSelect: (index) => {
95
+ const item = items[index];
96
+ if (item !== undefined) onSelect(item);
97
+ },
98
+ onCancel,
99
+ });
100
+
101
+ useKeybindingActions('list-picker', (actions) => actions.forEach(applyAction), {
102
+ isActive: !screenReader,
103
+ });
104
+ const effectiveFooterHints = useKeybindingHints('list-picker', [
105
+ [['previous', 'next'], 'Navigate'],
106
+ ['select', 'Select'],
107
+ ['cancel', 'Cancel'],
108
+ ]);
109
+
110
+ if (items.length === 0) {
111
+ return <Box />;
112
+ }
113
+
114
+ if (screenReader) {
115
+ const prompt = formatNumberedSelectionPrompt(items.length, true);
116
+ return (
117
+ <Box flexDirection="column">
118
+ {items.map((item, index) => (
119
+ <Box key={index}>
120
+ <Text>{numberedRowPrefix(index)}</Text>
121
+ {renderItem(item, false)}
122
+ </Box>
123
+ ))}
124
+ <Text>
125
+ {prompt}
126
+ {numbered.buffer.length > 0 ? ` ${numbered.buffer}` : ''}
127
+ </Text>
128
+ {numbered.invalid && <Text>{prompt}</Text>}
129
+ </Box>
130
+ );
131
+ }
132
+
133
+ const normalizedState = normalizeSelectionState(state, { itemCount: items.length, maxVisible });
134
+ if (normalizedState !== state) {
135
+ stateRef.current = normalizedState;
136
+ }
137
+ const { selectedIndex, scrollOffset } = normalizedState;
138
+ const visibleItems = items.slice(scrollOffset, scrollOffset + maxVisible);
139
+ const hasMore = scrollOffset + maxVisible < items.length;
140
+ const hasLess = scrollOffset > 0;
141
+
142
+ return (
143
+ <Box flexDirection="column">
144
+ {hasLess && <Text dimColor> ↑ {scrollOffset} more above</Text>}
145
+ {visibleItems.map((item, index) => (
146
+ <Box key={scrollOffset + index} marginBottom={1}>
147
+ {renderItem(item, scrollOffset + index === selectedIndex)}
148
+ </Box>
149
+ ))}
150
+ {hasMore && <Text dimColor> ↓ {items.length - scrollOffset - maxVisible} more below</Text>}
151
+ <KeyHintFooter
152
+ hints={
153
+ footerHints === LIST_PICKER_DEFAULT_FOOTER_HINTS ? effectiveFooterHints : footerHints
154
+ }
155
+ />
156
+ </Box>
157
+ );
158
+ }
@@ -0,0 +1,159 @@
1
+ import { Box } from 'ink';
2
+ import React, { useState, useCallback, useRef } from 'react';
3
+
4
+ import {
5
+ applySelectionInput,
6
+ createSelectionFlowState,
7
+ normalizeSelectionState,
8
+ type ISelectionFlowState,
9
+ type TSelectionInputAction,
10
+ } 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 { usePalette } from './theme/index.js';
23
+
24
+ /** Footer for the interactive menu state. */
25
+ export const MENU_SELECT_FOOTER_HINTS: readonly IKeyHint[] = [
26
+ { keys: '↑↓', label: 'Navigate' },
27
+ { keys: 'Enter', label: 'Select' },
28
+ { keys: 'Esc', label: 'Back' },
29
+ ];
30
+
31
+ /** Footer for the error state — Esc (cancel) is the only active key. */
32
+ export const MENU_SELECT_ERROR_FOOTER_HINTS: readonly IKeyHint[] = [{ keys: 'Esc', label: 'Back' }];
33
+
34
+ export interface IMenuSelectItem {
35
+ label: string;
36
+ value: string;
37
+ hint?: string;
38
+ }
39
+
40
+ interface IProps {
41
+ title: string;
42
+ items: IMenuSelectItem[];
43
+ onSelect: (value: string) => void;
44
+ onBack: () => void;
45
+ loading?: boolean;
46
+ error?: string;
47
+ }
48
+
49
+ export default function MenuSelect({
50
+ title,
51
+ items,
52
+ onSelect,
53
+ onBack,
54
+ loading,
55
+ error,
56
+ }: IProps): React.ReactElement {
57
+ const palette = usePalette();
58
+ const [state, setState] = useState<ISelectionFlowState>(() => createSelectionFlowState());
59
+ const stateRef = useRef(state);
60
+ const isEnabled = !loading && !error;
61
+ const screenReader = useScreenReader();
62
+ const numbered = useNumberedSelection({
63
+ enabled: screenReader && isEnabled,
64
+ itemCount: items.length,
65
+ cancellable: true,
66
+ onSelect: (index) => {
67
+ const item = items[index];
68
+ if (item !== undefined) onSelect(item.value);
69
+ },
70
+ onCancel: onBack,
71
+ });
72
+ const applyAction = useCallback(
73
+ (action: TSelectionInputAction): void => {
74
+ const result = applySelectionInput(stateRef.current, action, {
75
+ itemCount: items.length,
76
+ enabled: isEnabled,
77
+ });
78
+ stateRef.current = result.state;
79
+ setState(result.state);
80
+ if (result.effect.type === 'cancel') {
81
+ onBack();
82
+ } else if (result.effect.type === 'select') {
83
+ const item = items[result.effect.index];
84
+ if (item !== undefined) {
85
+ onSelect(item.value);
86
+ }
87
+ }
88
+ },
89
+ [isEnabled, items, onBack, onSelect],
90
+ );
91
+
92
+ useKeybindingActions(
93
+ 'menu-select',
94
+ (actions) => actions.forEach((action) => applyAction(action === 'back' ? 'cancel' : action)),
95
+ { isActive: !screenReader },
96
+ );
97
+ const menuHints = useKeybindingHints('menu-select', [
98
+ [['previous', 'next'], 'Navigate'],
99
+ ['select', 'Select'],
100
+ ['back', 'Back'],
101
+ ]);
102
+ const errorHints = useKeybindingHints('menu-select', [['back', 'Back']]);
103
+
104
+ const normalizedState = normalizeSelectionState(state, { itemCount: items.length });
105
+ if (normalizedState !== state) {
106
+ stateRef.current = normalizedState;
107
+ }
108
+ const selected = normalizedState.selectedIndex;
109
+
110
+ if (screenReader) {
111
+ return (
112
+ <NumberedList
113
+ title={title}
114
+ options={loading ? [] : error ? [] : items.map((item) => item.label)}
115
+ description={loading ? 'Loading...' : error}
116
+ cancellable
117
+ buffer={numbered.buffer}
118
+ invalid={numbered.invalid}
119
+ />
120
+ );
121
+ }
122
+
123
+ return (
124
+ <Box
125
+ flexDirection="column"
126
+ borderStyle="round"
127
+ borderColor={palette.border.attention}
128
+ paddingX={1}
129
+ >
130
+ <Text color={palette.text.warning} bold>
131
+ {title}
132
+ </Text>
133
+ {loading && (
134
+ <Box marginTop={1}>
135
+ <Text dimColor>Loading...</Text>
136
+ </Box>
137
+ )}
138
+ {error && (
139
+ <Box marginTop={1}>
140
+ <Text color={palette.text.error}>{error}</Text>
141
+ </Box>
142
+ )}
143
+ {!loading && !error && (
144
+ <Box flexDirection="column" marginTop={1}>
145
+ {items.map((item, i) => (
146
+ <Box key={item.value}>
147
+ <Text color={i === selected ? palette.text.accent : undefined} bold={i === selected}>
148
+ {i === selected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
149
+ {item.label}
150
+ </Text>
151
+ {item.hint && <Text dimColor> {item.hint}</Text>}
152
+ </Box>
153
+ ))}
154
+ </Box>
155
+ )}
156
+ <KeyHintFooter hints={loading ? [] : error ? errorHints : menuHints} />
157
+ </Box>
158
+ );
159
+ }
@@ -0,0 +1,308 @@
1
+ import { isToolMessage, isAssistantMessage } from '@robota-sdk/agent-core';
2
+ import { Box } from 'ink';
3
+ import React from 'react';
4
+
5
+ import { formatCommandOutputSummary } from './command-output-summary.js';
6
+ import { useRenderMarkdown } from './hooks/useRenderMarkdown.js';
7
+ import { humanizeToolName } from './humanize-tool-name.js';
8
+ import { useModelCommandToolPrefix } from './product-display-name-context.js';
9
+ import { RoleLabel } from './RoleLabel.js';
10
+ import { RenderedText, Text } from './SafeText.js';
11
+ import { sanitizeTerminalText } from './sanitize-terminal-text.js';
12
+ import { useScreenReader } from './screen-reader-context.js';
13
+ import { SCREEN_READER_LABELS, type TScreenReaderLabelKind } from './screen-reader-labels.js';
14
+ import { statusGlyphColor } from './status-glyph.js';
15
+ import { usePalette } from './theme/index.js';
16
+ import { getToolSummaryLabel, toolSummaryStatusKind } from './tool-summary-status.js';
17
+ import ToolCommandOutput from './ToolCommandOutput.js';
18
+ import ToolDiffBlock from './ToolDiffBlock.js';
19
+ import UsageSummaryEntry from './UsageSummaryEntry.js';
20
+
21
+ import type { TToolSummaryItem } from './tool-summary-status.js';
22
+ import type { IToolCallSummary } from './utils/tool-call-extractor.js';
23
+ import type { IHistoryEntry, TUniversalMessage, TUniversalValue } from '@robota-sdk/agent-core';
24
+
25
+ interface IProps {
26
+ history: IHistoryEntry[];
27
+ }
28
+
29
+ /**
30
+ * CLI-2004: one transcript label. In screen-reader mode it speaks the shared vocabulary and drops
31
+ * the colour — the searchable word IS the cue there — and outside it renders exactly as before.
32
+ */
33
+ function EntryLabel({
34
+ kind,
35
+ text,
36
+ color,
37
+ }: {
38
+ kind: TScreenReaderLabelKind;
39
+ text: string;
40
+ color: string;
41
+ }): React.ReactElement {
42
+ if (useScreenReader()) return <Text>{SCREEN_READER_LABELS[kind]} </Text>;
43
+ return (
44
+ <Text color={color} bold>
45
+ {text}
46
+ </Text>
47
+ );
48
+ }
49
+
50
+ function ToolMessage({ message }: { message: TUniversalMessage }): React.ReactElement {
51
+ const palette = usePalette();
52
+ const modelCommandToolPrefix = useModelCommandToolPrefix();
53
+ if (!isToolMessage(message)) {
54
+ return <></>;
55
+ }
56
+ const toolName = message.name;
57
+ const content = message.content;
58
+
59
+ // Try to parse structured tool summaries (with diff info)
60
+ let summaries: IToolCallSummary[] | null = null;
61
+ try {
62
+ const parsed = JSON.parse(content) as IToolCallSummary[];
63
+ if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0].line === 'string') {
64
+ summaries = parsed as IToolCallSummary[];
65
+ }
66
+ } catch {
67
+ // Not JSON — fall back to plain text lines
68
+ }
69
+
70
+ if (summaries) {
71
+ return (
72
+ <Box flexDirection="column" marginBottom={1}>
73
+ <Box>
74
+ <EntryLabel kind="tool" text="Tool: " color={palette.text.emphasis} />
75
+ {toolName && (
76
+ <Text color={palette.text.emphasis} dimColor>
77
+ [{humanizeToolName(toolName, modelCommandToolPrefix)}]
78
+ </Text>
79
+ )}
80
+ </Box>
81
+ <Text> </Text>
82
+ {summaries.map((s, i) => (
83
+ <Box key={i} flexDirection="column">
84
+ <Text color={palette.text.success}>
85
+ {' '}
86
+ {'✓'} {sanitizeTerminalText(s.line)}
87
+ </Text>
88
+ {s.diffLines && s.diffLines.length > 0 && (
89
+ <ToolDiffBlock file={s.diffFile} lines={s.diffLines} />
90
+ )}
91
+ </Box>
92
+ ))}
93
+ </Box>
94
+ );
95
+ }
96
+
97
+ // Fallback: plain text lines
98
+ const lines = content.split('\n').filter((l) => l.trim());
99
+ return (
100
+ <Box flexDirection="column" marginBottom={1}>
101
+ <Box>
102
+ <EntryLabel kind="tool" text="Tool: " color={palette.text.emphasis} />
103
+ {toolName && (
104
+ <Text color={palette.text.emphasis} dimColor>
105
+ [{sanitizeTerminalText(toolName)}]
106
+ </Text>
107
+ )}
108
+ </Box>
109
+ <Text> </Text>
110
+ {lines.map((line, i) => (
111
+ <Text key={i} color={palette.text.success}>
112
+ {' '}
113
+ {'✓'} {sanitizeTerminalText(line)}
114
+ </Text>
115
+ ))}
116
+ </Box>
117
+ );
118
+ }
119
+
120
+ /** ERR-001 G2: a failed turn renders as a styled error block, not a plain system note. */
121
+ function ErrorEntryBlock({ message }: { message: TUniversalMessage }): React.ReactElement {
122
+ const palette = usePalette();
123
+ // SEC-019: an error message is untrusted text — it carries provider responses, tool stderr and
124
+ // file contents — and it reaches `<Text>` without going through the markdown renderer, so it does
125
+ // not inherit that path's sanitization.
126
+ const content = sanitizeTerminalText((message.content ?? '').replace(/^Error:\s*/, ''));
127
+ return (
128
+ <Box flexDirection="column" marginBottom={1}>
129
+ <Box>
130
+ <EntryLabel kind="error" text="✖ Error: " color={palette.text.error} />
131
+ </Box>
132
+ <Box marginLeft={2} flexDirection="column">
133
+ <Text color={palette.text.error} wrap="wrap">
134
+ {content}
135
+ </Text>
136
+ <Text dimColor wrap="wrap">
137
+ The session is still alive — type your next prompt when ready.
138
+ </Text>
139
+ </Box>
140
+ </Box>
141
+ );
142
+ }
143
+
144
+ const MessageItem = React.memo(function MessageItem({
145
+ message,
146
+ }: {
147
+ message: TUniversalMessage;
148
+ }): React.ReactElement {
149
+ // Every hook this component uses runs BEFORE the early returns: a message whose role or kind
150
+ // changes in place would otherwise render a different NUMBER of hooks and React would throw.
151
+ const renderMarkdown = useRenderMarkdown();
152
+
153
+ if (isToolMessage(message)) {
154
+ return <ToolMessage message={message} />;
155
+ }
156
+
157
+ if (message.role === 'system' && message.metadata?.kind === 'error') {
158
+ return <ErrorEntryBlock message={message} />;
159
+ }
160
+
161
+ const content = message.content ?? '';
162
+ const isInterrupted = message.state === 'interrupted';
163
+
164
+ return (
165
+ <Box flexDirection="column" marginBottom={1}>
166
+ <Box>
167
+ <RoleLabel
168
+ role={message.role}
169
+ {...(typeof message.metadata?.driverId === 'string'
170
+ ? { driverId: message.metadata.driverId }
171
+ : {})}
172
+ />
173
+ </Box>
174
+ <Text> </Text>
175
+ <Box marginLeft={2}>
176
+ {isAssistantMessage(message) ? (
177
+ // `renderMarkdown` sanitizes its input and then styles it; the SGR in its output is ours.
178
+ <RenderedText wrap="wrap">
179
+ {renderMarkdown(content + (isInterrupted ? '\n\n_(interrupted)_' : ''))}
180
+ </RenderedText>
181
+ ) : (
182
+ <Text wrap="wrap">{content}</Text>
183
+ )}
184
+ </Box>
185
+ </Box>
186
+ );
187
+ });
188
+
189
+ function ToolSummaryEntry({ entry }: { entry: IHistoryEntry }): React.ReactElement {
190
+ const palette = usePalette();
191
+ const modelCommandToolPrefix = useModelCommandToolPrefix();
192
+ const data = entry.data as
193
+ | {
194
+ summary?: string;
195
+ tools?: TToolSummaryItem[];
196
+ }
197
+ | undefined;
198
+ const tools = data?.tools;
199
+ const lines = data?.summary?.split('\n') ?? [];
200
+
201
+ if (tools && tools.length > 0) {
202
+ return (
203
+ <Box flexDirection="column" marginBottom={1}>
204
+ <Box>
205
+ <EntryLabel kind="tool" text="Tool: " color={palette.text.emphasis} />
206
+ </Box>
207
+ <Text> </Text>
208
+ {tools.map((tool, i) => {
209
+ const kind = toolSummaryStatusKind(
210
+ tool,
211
+ formatCommandOutputSummary(tool)?.status === 'error',
212
+ );
213
+ return (
214
+ <Box key={i} flexDirection="column">
215
+ <Text color={statusGlyphColor(palette, kind)}>
216
+ {' '}
217
+ {getToolSummaryLabel(tool, kind, modelCommandToolPrefix)}
218
+ </Text>
219
+ <ToolCommandOutput tool={tool} />
220
+ {tool.diffLines && tool.diffLines.length > 0 && (
221
+ <ToolDiffBlock file={tool.diffFile} lines={tool.diffLines} />
222
+ )}
223
+ </Box>
224
+ );
225
+ })}
226
+ </Box>
227
+ );
228
+ }
229
+
230
+ return (
231
+ <Box flexDirection="column" marginBottom={1}>
232
+ <Box>
233
+ <EntryLabel kind="tool" text="Tool: " color={palette.text.emphasis} />
234
+ </Box>
235
+ <Text> </Text>
236
+ {lines.map((line, i) => (
237
+ <Text key={i} color={palette.text.success}>
238
+ {' '}
239
+ {sanitizeTerminalText(line)}
240
+ </Text>
241
+ ))}
242
+ </Box>
243
+ );
244
+ }
245
+
246
+ function EventEntry({ entry }: { entry: IHistoryEntry }): React.ReactElement {
247
+ const palette = usePalette();
248
+ const eventData = entry.data as Record<string, TUniversalValue> | undefined;
249
+ // SEC-019: an event message is BUILT by a neutral package from untrusted parts — a skill name that
250
+ // may come from a plugin, a memory topic the model chose — and interpolated into a sentence. The
251
+ // formatter cannot sanitize for a terminal (the same string reaches the GUI, where escapes are not
252
+ // the threat and stripping them would be arbitrary), so this render site is the boundary.
253
+ const text =
254
+ typeof eventData?.message === 'string'
255
+ ? eventData.message
256
+ : typeof eventData?.content === 'string'
257
+ ? eventData.content
258
+ : undefined;
259
+ // An event without a message is a record kept for persistence or telemetry (a provider-call trace,
260
+ // a usage observation), not a notice; printing its type name only adds noise to the transcript.
261
+ if (text === undefined) return <></>;
262
+ const eventMessage = sanitizeTerminalText(text);
263
+
264
+ return (
265
+ <Box flexDirection="column" marginBottom={1}>
266
+ <Box>
267
+ <EntryLabel kind="warning" text="System: " color={palette.text.warning} />
268
+ </Box>
269
+ <Text> </Text>
270
+ <Box marginLeft={2}>
271
+ <Text wrap="wrap">{eventMessage}</Text>
272
+ </Box>
273
+ </Box>
274
+ );
275
+ }
276
+
277
+ export function EntryItem({ entry }: { entry: IHistoryEntry }): React.ReactElement {
278
+ if (entry.category === 'chat') {
279
+ const message = entry.data as TUniversalMessage;
280
+ return <MessageItem message={message} />;
281
+ }
282
+
283
+ if (entry.type === 'tool-summary') {
284
+ return <ToolSummaryEntry entry={entry} />;
285
+ }
286
+
287
+ if (entry.type === 'usage-summary') {
288
+ return <UsageSummaryEntry entry={entry} />;
289
+ }
290
+
291
+ // tool-start/tool-end are recorded in history for persistence but not rendered
292
+ // (StreamingIndicator shows them during streaming, tool-summary shows them after)
293
+ if (entry.type === 'tool-start' || entry.type === 'tool-end') {
294
+ return <></>;
295
+ }
296
+
297
+ return <EventEntry entry={entry} />;
298
+ }
299
+
300
+ export default function MessageList({ history }: IProps): React.ReactElement {
301
+ return (
302
+ <Box flexDirection="column">
303
+ {history.map((entry) => (
304
+ <EntryItem key={entry.id} entry={entry} />
305
+ ))}
306
+ </Box>
307
+ );
308
+ }