@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,188 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { Text } from 'ink';
4
+ import { describe, it, expect } from 'vitest';
5
+ import ListPicker from '../ListPicker.js';
6
+ import {
7
+ formatKeyHints,
8
+ SELECTION_INDICATOR,
9
+ SELECTION_INDICATOR_NONE,
10
+ } from '../key-hint-footer.js';
11
+
12
+ describe('ListPicker', () => {
13
+ it('renders all items with first selected by default', () => {
14
+ const items = ['Alpha', 'Beta', 'Gamma'];
15
+ const { lastFrame } = render(
16
+ <ListPicker
17
+ items={items}
18
+ renderItem={(item, isSelected) => (
19
+ <Text>
20
+ {isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
21
+ {item}
22
+ </Text>
23
+ )}
24
+ onSelect={() => {}}
25
+ onCancel={() => {}}
26
+ />,
27
+ );
28
+ const frame = lastFrame()!;
29
+ expect(frame).toContain('> Alpha');
30
+ expect(frame).toContain(' Beta');
31
+ expect(frame).toContain(' Gamma');
32
+ });
33
+
34
+ it('renders empty state when no items', () => {
35
+ const { lastFrame } = render(
36
+ <ListPicker
37
+ items={[]}
38
+ renderItem={(item, isSelected) => (
39
+ <Text>
40
+ {isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
41
+ {String(item)}
42
+ </Text>
43
+ )}
44
+ onSelect={() => {}}
45
+ onCancel={() => {}}
46
+ />,
47
+ );
48
+ // Should render without crashing
49
+ expect(lastFrame()).toBeDefined();
50
+ });
51
+
52
+ it('calls onSelect with item on Enter', () => {
53
+ let selected = '';
54
+ const items = ['Alpha', 'Beta'];
55
+ const { stdin } = render(
56
+ <ListPicker
57
+ items={items}
58
+ renderItem={(item, isSelected) => (
59
+ <Text>
60
+ {isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
61
+ {item}
62
+ </Text>
63
+ )}
64
+ onSelect={(item) => {
65
+ selected = item;
66
+ }}
67
+ onCancel={() => {}}
68
+ />,
69
+ );
70
+ stdin.write('\r');
71
+ expect(selected).toBe('Alpha');
72
+ });
73
+
74
+ it('navigates down with arrow key and selects', () => {
75
+ let selected = '';
76
+ const items = ['Alpha', 'Beta', 'Gamma'];
77
+ const { stdin } = render(
78
+ <ListPicker
79
+ items={items}
80
+ renderItem={(item, isSelected) => (
81
+ <Text>
82
+ {isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
83
+ {item}
84
+ </Text>
85
+ )}
86
+ onSelect={(item) => {
87
+ selected = item;
88
+ }}
89
+ onCancel={() => {}}
90
+ />,
91
+ );
92
+ stdin.write('\x1B[B'); // Down arrow
93
+ stdin.write('\r');
94
+ expect(selected).toBe('Beta');
95
+ });
96
+
97
+ it('calls onCancel on Escape', async () => {
98
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
99
+ let cancelled = false;
100
+ const { stdin } = render(
101
+ <ListPicker
102
+ items={['Alpha']}
103
+ renderItem={(item, isSelected) => (
104
+ <Text>
105
+ {isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
106
+ {item}
107
+ </Text>
108
+ )}
109
+ onSelect={() => {}}
110
+ onCancel={() => {
111
+ cancelled = true;
112
+ }}
113
+ />,
114
+ );
115
+ stdin.write('\x1B');
116
+ await delay(50);
117
+ expect(cancelled).toBe(true);
118
+ });
119
+
120
+ it('does not go above first item', () => {
121
+ const items = ['Alpha', 'Beta'];
122
+ const { stdin, lastFrame } = render(
123
+ <ListPicker
124
+ items={items}
125
+ renderItem={(item, isSelected) => (
126
+ <Text>
127
+ {isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
128
+ {item}
129
+ </Text>
130
+ )}
131
+ onSelect={() => {}}
132
+ onCancel={() => {}}
133
+ />,
134
+ );
135
+ stdin.write('\x1B[A'); // Up arrow (already at 0)
136
+ const frame = lastFrame()!;
137
+ expect(frame).toContain('> Alpha');
138
+ });
139
+
140
+ it('does not go below last item', () => {
141
+ let selected = '';
142
+ const items = ['Alpha', 'Beta'];
143
+ const { stdin } = render(
144
+ <ListPicker
145
+ items={items}
146
+ renderItem={(item, isSelected) => (
147
+ <Text>
148
+ {isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
149
+ {item}
150
+ </Text>
151
+ )}
152
+ onSelect={(item) => {
153
+ selected = item;
154
+ }}
155
+ onCancel={() => {}}
156
+ />,
157
+ );
158
+ stdin.write('\x1B[B'); // Down
159
+ stdin.write('\x1B[B'); // Down (should stay at Beta)
160
+ stdin.write('\x1B[B'); // Down (should stay at Beta)
161
+ stdin.write('\r');
162
+ expect(selected).toBe('Beta');
163
+ });
164
+
165
+ // SCREEN-005: default footer renders through the key-hint SSOT grammar.
166
+ it('renders the default footer in the shared key-hint grammar', () => {
167
+ const { lastFrame } = render(
168
+ <ListPicker
169
+ items={['Alpha']}
170
+ renderItem={(item, isSelected) => (
171
+ <Text>
172
+ {isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
173
+ {item}
174
+ </Text>
175
+ )}
176
+ onSelect={() => {}}
177
+ onCancel={() => {}}
178
+ />,
179
+ );
180
+ expect(lastFrame()!).toContain(
181
+ formatKeyHints([
182
+ { keys: '↑↓', label: 'Navigate' },
183
+ { keys: 'Enter', label: 'Select' },
184
+ { keys: 'Esc', label: 'Cancel' },
185
+ ]),
186
+ );
187
+ });
188
+ });
@@ -0,0 +1,127 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect } from 'vitest';
4
+ import MenuSelect from '../MenuSelect.js';
5
+ import { formatKeyHints } from '../key-hint-footer.js';
6
+
7
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
8
+
9
+ describe('MenuSelect', () => {
10
+ const items = [
11
+ { label: 'Option A', value: 'a' },
12
+ { label: 'Option B', value: 'b', hint: 'some hint' },
13
+ { label: 'Option C', value: 'c' },
14
+ ];
15
+
16
+ it('renders title and all items', () => {
17
+ const { lastFrame } = render(
18
+ <MenuSelect title="Test Menu" items={items} onSelect={() => {}} onBack={() => {}} />,
19
+ );
20
+ const frame = lastFrame()!;
21
+ expect(frame).toContain('Test Menu');
22
+ expect(frame).toContain('Option A');
23
+ expect(frame).toContain('Option B');
24
+ expect(frame).toContain('Option C');
25
+ });
26
+
27
+ it('renders hint text when provided', () => {
28
+ const { lastFrame } = render(
29
+ <MenuSelect title="Test" items={items} onSelect={() => {}} onBack={() => {}} />,
30
+ );
31
+ expect(lastFrame()!).toContain('some hint');
32
+ });
33
+
34
+ it('highlights first item by default with > prefix', () => {
35
+ const { lastFrame } = render(
36
+ <MenuSelect title="Test" items={items} onSelect={() => {}} onBack={() => {}} />,
37
+ );
38
+ expect(lastFrame()!).toContain('>');
39
+ });
40
+
41
+ it('shows loading state', () => {
42
+ const { lastFrame } = render(
43
+ <MenuSelect title="Test" items={[]} onSelect={() => {}} onBack={() => {}} loading />,
44
+ );
45
+ expect(lastFrame()!).toContain('Loading');
46
+ });
47
+
48
+ it('shows error state', () => {
49
+ const { lastFrame } = render(
50
+ <MenuSelect title="Test" items={[]} onSelect={() => {}} onBack={() => {}} error="Failed" />,
51
+ );
52
+ expect(lastFrame()!).toContain('Failed');
53
+ });
54
+
55
+ it('calls onSelect with value on Enter', () => {
56
+ let selected = '';
57
+ const { stdin } = render(
58
+ <MenuSelect
59
+ title="Test"
60
+ items={items}
61
+ onSelect={(v) => {
62
+ selected = v;
63
+ }}
64
+ onBack={() => {}}
65
+ />,
66
+ );
67
+ stdin.write('\r');
68
+ expect(selected).toBe('a');
69
+ });
70
+
71
+ it('calls onBack on Escape', async () => {
72
+ let backed = false;
73
+ const { stdin } = render(
74
+ <MenuSelect
75
+ title="Test"
76
+ items={items}
77
+ onSelect={() => {}}
78
+ onBack={() => {
79
+ backed = true;
80
+ }}
81
+ />,
82
+ );
83
+ stdin.write('\x1B');
84
+ await delay(50);
85
+ expect(backed).toBe(true);
86
+ });
87
+
88
+ it('navigates down with arrow key', () => {
89
+ let selected = '';
90
+ const { stdin } = render(
91
+ <MenuSelect
92
+ title="Test"
93
+ items={items}
94
+ onSelect={(v) => {
95
+ selected = v;
96
+ }}
97
+ onBack={() => {}}
98
+ />,
99
+ );
100
+ stdin.write('\x1B[B'); // Down arrow
101
+ stdin.write('\r');
102
+ expect(selected).toBe('b');
103
+ });
104
+
105
+ // SCREEN-005: footer + error-state hint render through the key-hint SSOT grammar.
106
+ it('renders the footer in the shared key-hint grammar', () => {
107
+ const { lastFrame } = render(
108
+ <MenuSelect title="Test" items={items} onSelect={() => {}} onBack={() => {}} />,
109
+ );
110
+ expect(lastFrame()!).toContain(
111
+ formatKeyHints([
112
+ { keys: '↑↓', label: 'Navigate' },
113
+ { keys: 'Enter', label: 'Select' },
114
+ { keys: 'Esc', label: 'Back' },
115
+ ]),
116
+ );
117
+ });
118
+
119
+ it('renders the error-state hint via the shared key-hint grammar', () => {
120
+ const { lastFrame } = render(
121
+ <MenuSelect title="Test" items={[]} onSelect={() => {}} onBack={() => {}} error="Failed" />,
122
+ );
123
+ const frame = lastFrame()!;
124
+ expect(frame).toContain(formatKeyHints([{ keys: 'Esc', label: 'Back' }]));
125
+ expect(frame).not.toContain('Press Esc to go back');
126
+ });
127
+ });
@@ -0,0 +1,193 @@
1
+ import { render } from 'ink-testing-library';
2
+ import React from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import PendingActionPrompt from '../PendingActionPrompt.js';
6
+ import { formatKeyHints } from '../key-hint-footer.js';
7
+
8
+ import type { IActionRequest } from '@robota-sdk/agent-core';
9
+
10
+ const delay = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 20));
11
+ const ESC = String.fromCharCode(27);
12
+ const DOWN = `${ESC}[B`;
13
+ const ENTER = '\r';
14
+
15
+ describe('PendingActionPrompt (CMD-004 unified action renderer)', () => {
16
+ it('single-select: renders options and answers with the chosen value', async () => {
17
+ const onAnswer = vi.fn();
18
+ const request: IActionRequest = {
19
+ id: 'mode',
20
+ title: 'Select mode',
21
+ options: [
22
+ { value: 'plan', label: 'Plan' },
23
+ { value: 'default', label: 'Default' },
24
+ ],
25
+ maxSelect: 1,
26
+ };
27
+ const { stdin, lastFrame } = render(
28
+ <PendingActionPrompt request={request} onAnswer={onAnswer} />,
29
+ );
30
+ expect(lastFrame()).toContain('Select mode');
31
+ expect(lastFrame()).toContain('Plan');
32
+
33
+ stdin.write(DOWN);
34
+ await delay();
35
+ stdin.write(ENTER);
36
+ await delay();
37
+ expect(onAnswer).toHaveBeenCalledWith({ type: 'answer', values: ['default'] });
38
+ });
39
+
40
+ it('single-select: the highlight starts on the request default — a No-default confirmation answers No on Enter (BEHAVIOR-2437)', async () => {
41
+ const onAnswer = vi.fn();
42
+ const request: IActionRequest = {
43
+ id: 'git-commit',
44
+ title: 'Commit 1 staged file(s)?',
45
+ options: [
46
+ { value: 'yes', label: 'Yes' },
47
+ { value: 'no', label: 'No' },
48
+ ],
49
+ maxSelect: 1,
50
+ default: { values: ['no'] },
51
+ };
52
+ const { stdin, lastFrame } = render(
53
+ <PendingActionPrompt request={request} onAnswer={onAnswer} />,
54
+ );
55
+ expect(lastFrame()).toContain('> No');
56
+ expect(lastFrame()).not.toContain('> Yes');
57
+
58
+ stdin.write(ENTER);
59
+ await delay();
60
+ expect(onAnswer).toHaveBeenCalledWith({ type: 'answer', values: ['no'] });
61
+ });
62
+
63
+ it('multi-select (maxSelect>1): toggles with Space and confirms with Enter', async () => {
64
+ const onAnswer = vi.fn();
65
+ const request: IActionRequest = {
66
+ id: 'tags',
67
+ title: 'Pick tags',
68
+ options: [
69
+ { value: 'a', label: 'Alpha' },
70
+ { value: 'b', label: 'Beta' },
71
+ { value: 'c', label: 'Gamma' },
72
+ ],
73
+ minSelect: 1,
74
+ maxSelect: 3,
75
+ };
76
+ const { stdin, lastFrame } = render(
77
+ <PendingActionPrompt request={request} onAnswer={onAnswer} />,
78
+ );
79
+ expect(lastFrame()).toContain('[ ] Alpha');
80
+
81
+ stdin.write(' ');
82
+ await delay();
83
+ expect(lastFrame()).toContain('[x] Alpha');
84
+ stdin.write(DOWN);
85
+ await delay();
86
+ stdin.write(' ');
87
+ await delay();
88
+ stdin.write(ENTER);
89
+ await delay();
90
+ expect(onAnswer).toHaveBeenCalledWith({ type: 'answer', values: ['a', 'b'] });
91
+ });
92
+
93
+ it('free-text with masked: hides input and answers with typed text', async () => {
94
+ const onAnswer = vi.fn();
95
+ const request: IActionRequest = {
96
+ id: 'apiKey',
97
+ title: 'Enter API key',
98
+ allowFreeText: true,
99
+ masked: true,
100
+ };
101
+ const { stdin, lastFrame } = render(
102
+ <PendingActionPrompt request={request} onAnswer={onAnswer} />,
103
+ );
104
+ expect(lastFrame()).toContain('Enter API key');
105
+
106
+ stdin.write('sk-secret');
107
+ await delay();
108
+ expect(lastFrame()).not.toContain('sk-secret');
109
+ expect(lastFrame()).toContain('*'.repeat('sk-secret'.length));
110
+ stdin.write(ENTER);
111
+ await delay();
112
+ expect(onAnswer).toHaveBeenCalledWith({ type: 'answer', values: [], text: 'sk-secret' });
113
+ });
114
+
115
+ it('Esc cancels the request (resolves cancelled)', async () => {
116
+ const onAnswer = vi.fn();
117
+ const request: IActionRequest = {
118
+ id: 'exit',
119
+ title: 'Exit?',
120
+ options: [
121
+ { value: 'yes', label: 'Yes' },
122
+ { value: 'no', label: 'No' },
123
+ ],
124
+ maxSelect: 1,
125
+ };
126
+ const { stdin } = render(<PendingActionPrompt request={request} onAnswer={onAnswer} />);
127
+ stdin.write(ESC);
128
+ await delay();
129
+ expect(onAnswer).toHaveBeenCalledWith({ type: 'cancelled' });
130
+ });
131
+
132
+ it('single-select + allowFreeText: "type a custom answer" opens text; Esc returns to picker (not cancel)', async () => {
133
+ const onAnswer = vi.fn();
134
+ const request: IActionRequest = {
135
+ id: 'provider',
136
+ title: 'Select provider',
137
+ options: [{ value: 'openai', label: 'OpenAI' }],
138
+ maxSelect: 1,
139
+ allowFreeText: true,
140
+ };
141
+ const { stdin, lastFrame } = render(
142
+ <PendingActionPrompt request={request} onAnswer={onAnswer} />,
143
+ );
144
+ expect(lastFrame()).toContain('Type a custom answer');
145
+
146
+ stdin.write(DOWN); // move to the synthetic "type a custom answer" entry
147
+ await delay();
148
+ stdin.write(ENTER); // enter free-text mode
149
+ await delay();
150
+ stdin.write(ESC); // Esc here returns to the picker, does NOT cancel the request
151
+ await delay();
152
+ expect(onAnswer).not.toHaveBeenCalled();
153
+ expect(lastFrame()).toContain('OpenAI'); // back at the picker
154
+ });
155
+
156
+ // SCREEN-005: multi-select footer goes through the key-hint SSOT, keeping the dynamic (min N)
157
+ // segment on the Enter hint until the selection is confirmable.
158
+ it('multi-select footer shows the (min N) segment until satisfiable, in the shared grammar', async () => {
159
+ const onAnswer = vi.fn();
160
+ const request: IActionRequest = {
161
+ id: 'tags',
162
+ title: 'Pick tags',
163
+ options: [
164
+ { value: 'a', label: 'Alpha' },
165
+ { value: 'b', label: 'Beta' },
166
+ ],
167
+ minSelect: 1,
168
+ maxSelect: 2,
169
+ };
170
+ const { stdin, lastFrame } = render(
171
+ <PendingActionPrompt request={request} onAnswer={onAnswer} />,
172
+ );
173
+ expect(lastFrame()).toContain(
174
+ formatKeyHints([
175
+ { keys: '↑↓', label: 'Navigate' },
176
+ { keys: 'Space', label: 'Toggle' },
177
+ { keys: 'Enter', label: 'Confirm (min 1)' },
178
+ { keys: 'Esc', label: 'Cancel' },
179
+ ]),
180
+ );
181
+
182
+ stdin.write(' '); // toggle Alpha → selection satisfiable → min segment drops
183
+ await delay();
184
+ expect(lastFrame()).toContain(
185
+ formatKeyHints([
186
+ { keys: '↑↓', label: 'Navigate' },
187
+ { keys: 'Space', label: 'Toggle' },
188
+ { keys: 'Enter', label: 'Confirm' },
189
+ { keys: 'Esc', label: 'Cancel' },
190
+ ]),
191
+ );
192
+ });
193
+ });
@@ -0,0 +1,167 @@
1
+ // packages/agent-cli/src/ui/__tests__/PluginTUI.test.tsx
2
+ import React from 'react';
3
+ import { render } from 'ink-testing-library';
4
+ import { describe, it, expect, vi } from 'vitest';
5
+ import PluginTUI from '../PluginTUI.js';
6
+ import type { ICommandPluginAdapter } from '@robota-sdk/agent-interface-command';
7
+
8
+ function mockCallbacks(): ICommandPluginAdapter {
9
+ return {
10
+ listInstalled: vi.fn().mockResolvedValue([]),
11
+ listAvailablePlugins: vi.fn().mockResolvedValue([]),
12
+ install: vi.fn().mockResolvedValue(undefined),
13
+ uninstall: vi.fn().mockResolvedValue(undefined),
14
+ enable: vi.fn().mockResolvedValue(undefined),
15
+ disable: vi.fn().mockResolvedValue(undefined),
16
+ marketplaceAdd: vi.fn().mockResolvedValue('test-marketplace'),
17
+ marketplaceRemove: vi.fn().mockResolvedValue(undefined),
18
+ marketplaceUpdate: vi.fn().mockResolvedValue(undefined),
19
+ marketplaceList: vi.fn().mockResolvedValue([]),
20
+ reloadPlugins: vi.fn().mockResolvedValue({ loadedPluginCount: 0 }),
21
+ };
22
+ }
23
+
24
+ describe('PluginTUI', () => {
25
+ it('renders main menu with Marketplace and Installed Plugins', () => {
26
+ const { lastFrame } = render(<PluginTUI callbacks={mockCallbacks()} onClose={() => {}} />);
27
+ const frame = lastFrame()!;
28
+ expect(frame).toContain('Marketplace');
29
+ expect(frame).toContain('Installed Plugins');
30
+ });
31
+
32
+ it('calls onClose when Escape on main menu', async () => {
33
+ let closed = false;
34
+ const { stdin } = render(
35
+ <PluginTUI
36
+ callbacks={mockCallbacks()}
37
+ onClose={() => {
38
+ closed = true;
39
+ }}
40
+ />,
41
+ );
42
+ stdin.write('\x1B');
43
+ await new Promise((r) => setTimeout(r, 50));
44
+ expect(closed).toBe(true);
45
+ });
46
+
47
+ it('navigates to installed plugins and shows actions', async () => {
48
+ const cbs = mockCallbacks();
49
+ cbs.listInstalled = vi
50
+ .fn()
51
+ .mockResolvedValue([{ name: 'my-plugin', description: 'A plugin', enabled: true }]);
52
+ const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
53
+ stdin.write('\x1B[B'); // Down to "Installed Plugins"
54
+ stdin.write('\r');
55
+ await new Promise((r) => setTimeout(r, 100));
56
+ expect(lastFrame()!).toContain('my-plugin');
57
+ });
58
+
59
+ it('navigates to marketplace list on Enter', async () => {
60
+ const cbs = mockCallbacks();
61
+ cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
62
+ const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
63
+ stdin.write('\r'); // Enter on "Marketplace"
64
+ await new Promise((r) => setTimeout(r, 100));
65
+ const frame = lastFrame()!;
66
+ expect(frame).toContain('Add Marketplace');
67
+ });
68
+
69
+ // Regression: arrow keys must work after navigating to a sub-screen (resolvedRef reset via key)
70
+ it('arrow keys work in marketplace list after navigating from main', async () => {
71
+ const cbs = mockCallbacks();
72
+ cbs.marketplaceList = vi.fn().mockResolvedValue([
73
+ { name: 'mp-a', type: 'github' },
74
+ { name: 'mp-b', type: 'git' },
75
+ ]);
76
+ const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
77
+ stdin.write('\r'); // Enter on "Marketplace"
78
+ await new Promise((r) => setTimeout(r, 100));
79
+ // Should be on marketplace-list with arrow keys working
80
+ stdin.write('\x1B[B'); // Down from "Add Marketplace" to "mp-a"
81
+ stdin.write('\x1B[B'); // Down to "mp-b"
82
+ await new Promise((r) => setTimeout(r, 50));
83
+ const frame = lastFrame()!;
84
+ // "mp-b" should be highlighted (has > prefix)
85
+ expect(frame).toContain('mp-b');
86
+ // Select mp-b → should navigate to marketplace-action
87
+ stdin.write('\r');
88
+ await new Promise((r) => setTimeout(r, 50));
89
+ expect(lastFrame()!).toContain('Browse plugins');
90
+ });
91
+
92
+ // Regression: installed plugin browse shows uninstall for already-installed plugins
93
+ it('selecting installed plugin in browse shows uninstall action', async () => {
94
+ const cbs = mockCallbacks();
95
+ cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
96
+ cbs.listAvailablePlugins = vi.fn().mockResolvedValue([
97
+ { name: 'my-plugin', description: 'A plugin', installed: true },
98
+ { name: 'new-plugin', description: 'Not installed', installed: false },
99
+ ]);
100
+ const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
101
+ // Main → Marketplace
102
+ stdin.write('\r');
103
+ await new Promise((r) => setTimeout(r, 100));
104
+ // Marketplace list → select test-mp
105
+ stdin.write('\x1B[B'); // Down to "test-mp"
106
+ stdin.write('\r');
107
+ await new Promise((r) => setTimeout(r, 50));
108
+ // Marketplace action → Browse plugins
109
+ stdin.write('\r');
110
+ await new Promise((r) => setTimeout(r, 100));
111
+ // Browse → select installed "my-plugin"
112
+ stdin.write('\r');
113
+ await new Promise((r) => setTimeout(r, 50));
114
+ // Should show uninstall action, not install scope
115
+ expect(lastFrame()!).toContain('Uninstall');
116
+ expect(lastFrame()!).not.toContain('User scope');
117
+ });
118
+
119
+ // Regression: uninstalled plugin in browse shows install scope selection
120
+ it('selecting uninstalled plugin in browse shows install scope', async () => {
121
+ const cbs = mockCallbacks();
122
+ cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
123
+ cbs.listAvailablePlugins = vi
124
+ .fn()
125
+ .mockResolvedValue([{ name: 'new-plugin', description: 'Not installed', installed: false }]);
126
+ const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
127
+ // Main → Marketplace
128
+ stdin.write('\r');
129
+ await new Promise((r) => setTimeout(r, 100));
130
+ // Marketplace list → select test-mp
131
+ stdin.write('\x1B[B');
132
+ stdin.write('\r');
133
+ await new Promise((r) => setTimeout(r, 50));
134
+ // Marketplace action → Browse plugins
135
+ stdin.write('\r');
136
+ await new Promise((r) => setTimeout(r, 100));
137
+ // Browse → select "new-plugin"
138
+ stdin.write('\r');
139
+ await new Promise((r) => setTimeout(r, 50));
140
+ // Should show install scope, not uninstall
141
+ expect(lastFrame()!).toContain('User scope');
142
+ expect(lastFrame()!).toContain('Project scope');
143
+ });
144
+
145
+ // Regression: install passes name@marketplace format to callback
146
+ it('install callback receives pluginId in name@marketplace format', async () => {
147
+ const cbs = mockCallbacks();
148
+ cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
149
+ cbs.listAvailablePlugins = vi
150
+ .fn()
151
+ .mockResolvedValue([{ name: 'new-plugin', description: 'A plugin', installed: false }]);
152
+ const { stdin } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
153
+ // Main → Marketplace → test-mp → Browse → new-plugin → User scope
154
+ stdin.write('\r');
155
+ await new Promise((r) => setTimeout(r, 100));
156
+ stdin.write('\x1B[B');
157
+ stdin.write('\r');
158
+ await new Promise((r) => setTimeout(r, 50));
159
+ stdin.write('\r'); // Browse plugins
160
+ await new Promise((r) => setTimeout(r, 100));
161
+ stdin.write('\r'); // new-plugin
162
+ await new Promise((r) => setTimeout(r, 50));
163
+ stdin.write('\r'); // User scope
164
+ await new Promise((r) => setTimeout(r, 100));
165
+ expect(cbs.install).toHaveBeenCalledWith('new-plugin@test-mp', 'user');
166
+ }, 15000);
167
+ });