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

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 (348) hide show
  1. package/CHANGELOG.md +1204 -0
  2. package/LICENSE +661 -0
  3. package/README.md +50 -0
  4. package/dist/node/index.d.ts +1224 -0
  5. package/dist/node/index.d.ts.map +1 -0
  6. package/dist/node/index.js +33 -0
  7. package/dist/node/index.js.map +1 -0
  8. package/package.json +88 -0
  9. package/src/App.tsx +157 -0
  10. package/src/AppPresentation.tsx +190 -0
  11. package/src/AppView.tsx +26 -0
  12. package/src/BackgroundTaskPanel.tsx +73 -0
  13. package/src/CjkTextInput.tsx +336 -0
  14. package/src/ConfirmPrompt.tsx +140 -0
  15. package/src/ContextWarningBanner.tsx +45 -0
  16. package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
  17. package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
  18. package/src/HistorySearchOverlay.tsx +150 -0
  19. package/src/InputArea.tsx +338 -0
  20. package/src/ListPicker.tsx +158 -0
  21. package/src/MenuSelect.tsx +159 -0
  22. package/src/MessageList.tsx +308 -0
  23. package/src/MultiSelectList.tsx +294 -0
  24. package/src/PendingActionPrompt.tsx +121 -0
  25. package/src/PermissionPrompt.tsx +236 -0
  26. package/src/PluginTUI.tsx +258 -0
  27. package/src/RoleLabel.tsx +72 -0
  28. package/src/SafeText.tsx +66 -0
  29. package/src/SessionEventNotices.tsx +27 -0
  30. package/src/SessionPicker.tsx +92 -0
  31. package/src/SessionStatusBar.tsx +80 -0
  32. package/src/SlashAutocomplete.tsx +154 -0
  33. package/src/StatusBar.tsx +308 -0
  34. package/src/StreamingIndicator.tsx +145 -0
  35. package/src/SupervisedSessionView.tsx +872 -0
  36. package/src/TextPrompt.tsx +112 -0
  37. package/src/ThemePicker.tsx +366 -0
  38. package/src/ToolCommandOutput.tsx +48 -0
  39. package/src/ToolDiffBlock.tsx +44 -0
  40. package/src/TransportTUI.tsx +166 -0
  41. package/src/TuiInteractionChannel.ts +530 -0
  42. package/src/UpdateNotice.tsx +18 -0
  43. package/src/UsageSummaryEntry.tsx +49 -0
  44. package/src/WaveText.tsx +70 -0
  45. package/src/__tests__/ListPicker.test.tsx +188 -0
  46. package/src/__tests__/MenuSelect.test.tsx +127 -0
  47. package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
  48. package/src/__tests__/PluginTUI.test.tsx +167 -0
  49. package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
  50. package/src/__tests__/SupervisedSessionView-render.test.tsx +52 -0
  51. package/src/__tests__/SupervisedSessionView.test.tsx +1112 -0
  52. package/src/__tests__/TextPrompt.test.tsx +112 -0
  53. package/src/__tests__/TransportTUI.test.tsx +124 -0
  54. package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
  55. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +294 -0
  56. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +650 -0
  57. package/src/__tests__/UpdateNotice.test.tsx +15 -0
  58. package/src/__tests__/__fixtures__/korean-text.json +17 -0
  59. package/src/__tests__/abort-after-permission.test.tsx +160 -0
  60. package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
  61. package/src/__tests__/attached-app-session-switch.test.tsx +317 -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__/checkpoint-store-per-channel.test.ts +81 -0
  69. package/src/__tests__/cjk-defer-submit.test.tsx +68 -0
  70. package/src/__tests__/cjk-fallback-render.test.tsx +89 -0
  71. package/src/__tests__/cjk-text-input-flow.test.ts +187 -0
  72. package/src/__tests__/cjk-text-input.test.ts +196 -0
  73. package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
  74. package/src/__tests__/command-output-summary.test.ts +95 -0
  75. package/src/__tests__/command-result-handler.test.ts +171 -0
  76. package/src/__tests__/compact-event-bridge.test.ts +20 -0
  77. package/src/__tests__/confirm-permission-flow.test.ts +149 -0
  78. package/src/__tests__/confirm-prompt.test.tsx +103 -0
  79. package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
  80. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +47 -0
  81. package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
  82. package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
  83. package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
  84. package/src/__tests__/external-prompt-notice.test.tsx +100 -0
  85. package/src/__tests__/fixtures/command-handoff-driver.tsx +114 -0
  86. package/src/__tests__/fixtures/fake-editor.sh +7 -0
  87. package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
  88. package/src/__tests__/fork-attach.test.tsx +235 -0
  89. package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
  90. package/src/__tests__/helpers/terminal-profiles.ts +152 -0
  91. package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
  92. package/src/__tests__/history-clear-broadcast.test.ts +66 -0
  93. package/src/__tests__/history-search-overlay.test.tsx +329 -0
  94. package/src/__tests__/humanize-tool-name.test.ts +30 -0
  95. package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
  96. package/src/__tests__/input-area-flow.test.ts +199 -0
  97. package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
  98. package/src/__tests__/interaction-channel-charter.test.ts +13 -0
  99. package/src/__tests__/key-hint-consistency.test.tsx +249 -0
  100. package/src/__tests__/key-hint-footer.test.tsx +69 -0
  101. package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
  102. package/src/__tests__/message-list-rendering.test.tsx +474 -0
  103. package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
  104. package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
  105. package/src/__tests__/output-style-reaches-session.test.ts +35 -0
  106. package/src/__tests__/palette-consistency.test.ts +329 -0
  107. package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
  108. package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
  109. package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
  110. package/src/__tests__/permission-prompt-requester.test.tsx +34 -0
  111. package/src/__tests__/product-display-name.test.tsx +79 -0
  112. package/src/__tests__/prompt-queue.test.tsx +260 -0
  113. package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
  114. package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
  115. package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
  116. package/src/__tests__/pty/behavior-2437-git.ptytest.ts +350 -0
  117. package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
  118. package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
  119. package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
  120. package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
  121. package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
  122. package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
  123. package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
  124. package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +320 -0
  125. package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +211 -0
  126. package/src/__tests__/pty/ime-cursor.ptytest.ts +187 -0
  127. package/src/__tests__/pty/isolated-home.ts +97 -0
  128. package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
  129. package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
  130. package/src/__tests__/pty/pty-driver.ts +155 -0
  131. package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
  132. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +135 -0
  133. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +140 -0
  134. package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
  135. package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
  136. package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
  137. package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
  138. package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
  139. package/src/__tests__/pty/session-attach.ptytest.ts +182 -0
  140. package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
  141. package/src/__tests__/pty/spawn-pty.test.ts +120 -0
  142. package/src/__tests__/pty/spawn-pty.ts +229 -0
  143. package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
  144. package/src/__tests__/pty/trust-prompt.ptytest.ts +134 -0
  145. package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
  146. package/src/__tests__/pty-driver-dispose.test.ts +43 -0
  147. package/src/__tests__/real-cursor-positioning.test.tsx +264 -0
  148. package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
  149. package/src/__tests__/render-channel-options.test.ts +201 -0
  150. package/src/__tests__/render-lifecycle.test.ts +36 -0
  151. package/src/__tests__/render-markdown.test.ts +164 -0
  152. package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
  153. package/src/__tests__/safe-text-boundary.test.tsx +75 -0
  154. package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
  155. package/src/__tests__/screen-reader-input.test.tsx +106 -0
  156. package/src/__tests__/screen-reader-labels.test.ts +91 -0
  157. package/src/__tests__/screen-reader-menus.test.tsx +200 -0
  158. package/src/__tests__/screen-reader-pacing.test.ts +230 -0
  159. package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
  160. package/src/__tests__/screen-reader-render-options.test.ts +262 -0
  161. package/src/__tests__/screen-reader-stdout.test.ts +322 -0
  162. package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
  163. package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
  164. package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
  165. package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
  166. package/src/__tests__/selection-flow.test.ts +61 -0
  167. package/src/__tests__/session-event-notices.test.tsx +31 -0
  168. package/src/__tests__/session-init-poller.test.ts +102 -0
  169. package/src/__tests__/session-picker.test.tsx +93 -0
  170. package/src/__tests__/session-switch-channel.test.tsx +470 -0
  171. package/src/__tests__/shutdown-signal.test.ts +34 -0
  172. package/src/__tests__/status-activity.test.ts +71 -0
  173. package/src/__tests__/status-bar.test.tsx +290 -0
  174. package/src/__tests__/status-glyph.test.ts +30 -0
  175. package/src/__tests__/streaming-indicator.test.tsx +178 -0
  176. package/src/__tests__/terminal-capabilities.test.ts +153 -0
  177. package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
  178. package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
  179. package/src/__tests__/text-prompt-flow.test.ts +77 -0
  180. package/src/__tests__/theme-picker.test.tsx +345 -0
  181. package/src/__tests__/transport-boundary.test.ts +11 -0
  182. package/src/__tests__/tui-app-channel-port.test.ts +31 -0
  183. package/src/__tests__/tui-channel-init-failure.test.ts +70 -0
  184. package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
  185. package/src/__tests__/tui-interaction-queues.test.ts +149 -0
  186. package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
  187. package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
  188. package/src/__tests__/tui-state-manager.test.ts +482 -0
  189. package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
  190. package/src/__tests__/wave-text.test.tsx +133 -0
  191. package/src/__tests__/wire-tui-channel.test.ts +1457 -0
  192. package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
  193. package/src/app-banner.tsx +42 -0
  194. package/src/app-static-items.ts +35 -0
  195. package/src/app-view-model.ts +140 -0
  196. package/src/attached-session-connection.ts +14 -0
  197. package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
  198. package/src/attention/__tests__/attention-tracker.test.ts +103 -0
  199. package/src/attention/__tests__/countdown.test.ts +15 -0
  200. package/src/attention/__tests__/focus-input-filter.test.ts +93 -0
  201. package/src/attention/__tests__/interval-recap.test.ts +49 -0
  202. package/src/attention/attention-coordinator.ts +71 -0
  203. package/src/attention/attention-tracker.ts +124 -0
  204. package/src/attention/countdown.ts +24 -0
  205. package/src/attention/focus-input-filter.ts +131 -0
  206. package/src/attention/interval-recap.ts +148 -0
  207. package/src/attention/time-units.ts +6 -0
  208. package/src/attention-bell.ts +79 -0
  209. package/src/attributed-user-echo.ts +30 -0
  210. package/src/background-task-row-format.ts +99 -0
  211. package/src/command-interaction.ts +8 -0
  212. package/src/command-output-summary.ts +129 -0
  213. package/src/create-default-tui-cli-adapter.ts +45 -0
  214. package/src/execution-workspace-switcher-selection.ts +118 -0
  215. package/src/execution-workspace-view-model.ts +139 -0
  216. package/src/external-prompt-notice.tsx +38 -0
  217. package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
  218. package/src/flows/__tests__/defer-submit.test.ts +88 -0
  219. package/src/flows/__tests__/real-cursor-flow.test.ts +139 -0
  220. package/src/flows/background-focus-flow.ts +44 -0
  221. package/src/flows/cjk-cursor-motion.ts +75 -0
  222. package/src/flows/cjk-text-deletion.ts +53 -0
  223. package/src/flows/cjk-text-input-flow.ts +248 -0
  224. package/src/flows/cjk-text-input-types.ts +24 -0
  225. package/src/flows/confirm-prompt-flow.ts +90 -0
  226. package/src/flows/defer-submit.ts +67 -0
  227. package/src/flows/fork-attach-flow.ts +62 -0
  228. package/src/flows/input-area-flow.ts +188 -0
  229. package/src/flows/permission-prompt-flow.ts +108 -0
  230. package/src/flows/real-cursor-flow.ts +83 -0
  231. package/src/flows/selection-flow.ts +205 -0
  232. package/src/flows/session-init-poller.ts +76 -0
  233. package/src/flows/text-prompt-flow.ts +93 -0
  234. package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
  235. package/src/history-search/history-search-flow.ts +94 -0
  236. package/src/history-search/useHistorySearch.ts +336 -0
  237. package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
  238. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +87 -0
  239. package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
  240. package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
  241. package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
  242. package/src/hooks/command-result-handler.ts +44 -0
  243. package/src/hooks/side-effects-types.ts +45 -0
  244. package/src/hooks/useAppController.ts +271 -0
  245. package/src/hooks/useAppInputBindings.ts +163 -0
  246. package/src/hooks/useAppInteractionState.ts +84 -0
  247. package/src/hooks/useAppLifecycleState.ts +81 -0
  248. package/src/hooks/useAppOverlays.ts +90 -0
  249. package/src/hooks/useAppScreenState.ts +81 -0
  250. package/src/hooks/useAppSubmissionState.ts +45 -0
  251. package/src/hooks/useAppThemeState.ts +157 -0
  252. package/src/hooks/useAppWorkspaceState.ts +149 -0
  253. package/src/hooks/useAppearanceSettings.ts +30 -0
  254. package/src/hooks/useAutocomplete.ts +101 -0
  255. package/src/hooks/useBackgroundPanel.ts +58 -0
  256. package/src/hooks/useCountdownTick.ts +24 -0
  257. package/src/hooks/useExecutionDetailPage.ts +69 -0
  258. package/src/hooks/useForkAttach.ts +60 -0
  259. package/src/hooks/useInputAreaHistorySearch.ts +72 -0
  260. package/src/hooks/useInputAreaKeys.ts +114 -0
  261. package/src/hooks/useNumberedSelection.ts +93 -0
  262. package/src/hooks/usePluginCallbacks.ts +38 -0
  263. package/src/hooks/usePluginScreenData.ts +85 -0
  264. package/src/hooks/useRealCursorPosition.ts +125 -0
  265. package/src/hooks/useRenderMarkdown.ts +38 -0
  266. package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
  267. package/src/hooks/useSideEffects.ts +144 -0
  268. package/src/hooks/useStatusLineSettings.ts +50 -0
  269. package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
  270. package/src/hooks/useTuiChannel.ts +141 -0
  271. package/src/humanize-tool-name.ts +51 -0
  272. package/src/index.ts +72 -0
  273. package/src/input-area-rules.tsx +71 -0
  274. package/src/json-value.ts +18 -0
  275. package/src/key-hint-footer.tsx +54 -0
  276. package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
  277. package/src/keybindings/__tests__/node-keybindings-source.test.ts +118 -0
  278. package/src/keybindings/keybinding-catalogue.ts +171 -0
  279. package/src/keybindings/keybinding-registry.ts +303 -0
  280. package/src/keybindings/keybinding-syntax.ts +155 -0
  281. package/src/keybindings/keybindings-context.tsx +124 -0
  282. package/src/keybindings/node-keybindings-source.ts +189 -0
  283. package/src/numbered-list.tsx +113 -0
  284. package/src/plugin-tui-handlers.ts +163 -0
  285. package/src/product-display-name-context.tsx +30 -0
  286. package/src/render-markdown.ts +209 -0
  287. package/src/render.tsx +746 -0
  288. package/src/sanitize-terminal-text.ts +171 -0
  289. package/src/screen-reader-announcement.ts +52 -0
  290. package/src/screen-reader-context.tsx +32 -0
  291. package/src/screen-reader-labels.ts +64 -0
  292. package/src/screen-reader-pacing-context.tsx +47 -0
  293. package/src/screen-reader-pacing.ts +178 -0
  294. package/src/screen-reader-stdout.ts +328 -0
  295. package/src/short-session-id.ts +9 -0
  296. package/src/shutdown-signal.ts +34 -0
  297. package/src/slash-command-input.ts +11 -0
  298. package/src/status-activity.ts +70 -0
  299. package/src/status-glyph.ts +78 -0
  300. package/src/terminal-capabilities-context.tsx +27 -0
  301. package/src/terminal-capabilities.ts +77 -0
  302. package/src/terminal-focus-reporting.ts +50 -0
  303. package/src/terminal-handoff-controller.ts +98 -0
  304. package/src/terminal-marks.ts +63 -0
  305. package/src/theme/__tests__/built-in-themes.test.ts +117 -0
  306. package/src/theme/__tests__/color-vision.test.ts +82 -0
  307. package/src/theme/__tests__/theme-context.test.tsx +146 -0
  308. package/src/theme/__tests__/theme-document.test.ts +222 -0
  309. package/src/theme/__tests__/theme-registry.test.ts +138 -0
  310. package/src/theme/__tests__/theme-styles.test.ts +233 -0
  311. package/src/theme/built-in-themes.ts +342 -0
  312. package/src/theme/color-vision.ts +171 -0
  313. package/src/theme/index.ts +45 -0
  314. package/src/theme/theme-context.tsx +81 -0
  315. package/src/theme/theme-contracts.ts +139 -0
  316. package/src/theme/theme-document.ts +278 -0
  317. package/src/theme/theme-registry.ts +127 -0
  318. package/src/theme/theme-styles.ts +177 -0
  319. package/src/tool-summary-status.ts +59 -0
  320. package/src/tui-app-channel-port.ts +111 -0
  321. package/src/tui-channel-lifecycle-coordinator.ts +149 -0
  322. package/src/tui-channel-options.ts +184 -0
  323. package/src/tui-cli-adapter-context.tsx +13 -0
  324. package/src/tui-cli-adapter.ts +24 -0
  325. package/src/tui-interaction-queues.ts +171 -0
  326. package/src/tui-session-binding.ts +52 -0
  327. package/src/tui-session-event-projector.ts +149 -0
  328. package/src/tui-session-events.ts +89 -0
  329. package/src/tui-session-notice-store.ts +43 -0
  330. package/src/tui-session-options.ts +134 -0
  331. package/src/tui-state-manager.ts +347 -0
  332. package/src/types.ts +21 -0
  333. package/src/use-terminal-title.ts +29 -0
  334. package/src/utils/__tests__/edit-diff.test.ts +426 -0
  335. package/src/utils/__tests__/paste-detection.test.ts +116 -0
  336. package/src/utils/__tests__/paste-labels.test.ts +46 -0
  337. package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
  338. package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
  339. package/src/utils/edit-diff.ts +151 -0
  340. package/src/utils/input-top-border.ts +19 -0
  341. package/src/utils/paste-labels.ts +9 -0
  342. package/src/utils/tool-call-extractor.ts +92 -0
  343. package/src/utils/tool-diff-summary.ts +75 -0
  344. package/src/waiting-loop-stop-notice.ts +23 -0
  345. package/src/wire-history-sync.ts +159 -0
  346. package/src/wire-tui-channel.ts +963 -0
  347. package/src/wire-tui-client-commands.ts +142 -0
  348. package/src/wire-tui-projection.ts +136 -0
@@ -0,0 +1,1112 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import { ScreenReaderProvider } from '../screen-reader-context.js';
6
+ import SupervisedSessionView, { type ISupervisedViewRow } from '../SupervisedSessionView.js';
7
+
8
+ const FIRST: ISupervisedViewRow = {
9
+ id: '8bf9bc27-d773-4e88-b88f-f7a43e9eb1f4',
10
+ liveness: 'alive',
11
+ control: 'available',
12
+ activity: 'working',
13
+ generation: 'firstGenerationValue01',
14
+ };
15
+ const SECOND: ISupervisedViewRow = {
16
+ id: 'fe2c7f72-ecb3-4a05-9bb1-2563ec80e615',
17
+ liveness: 'dead',
18
+ control: 'unavailable',
19
+ activity: 'unknown',
20
+ };
21
+ const THIRD: ISupervisedViewRow = {
22
+ id: '4fa26e15-b17d-4908-afed-a156f8a8a17c',
23
+ liveness: 'alive',
24
+ control: 'available',
25
+ activity: 'idle',
26
+ generation: 'thirdGenerationValue01',
27
+ };
28
+
29
+ const QUESTION = {
30
+ folder: '/work/repo',
31
+ loads: ['Project sources (metadata only; content not read):', ' [file] state/settings.json — settings'],
32
+ };
33
+
34
+ describe('supervised session view', () => {
35
+ it('shows a linked PR only while verified and opens it only on explicit keypress', async () => {
36
+ const url = 'https://github.com/team/repo/pull/123';
37
+ const onOpenPr = vi.fn(async () => undefined);
38
+ const row = { ...FIRST, pr: { url, host: 'github.com', number: 123, kind: 'pull' as const } };
39
+ const view = render(<SupervisedSessionView loadRows={async () => [row]} onOpenPr={onOpenPr} />);
40
+ try {
41
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('github.com #123'));
42
+ expect(view.lastFrame()).toContain(url);
43
+ expect(onOpenPr).not.toHaveBeenCalled();
44
+ view.stdin.write('o');
45
+ await vi.waitFor(() => expect(onOpenPr).toHaveBeenCalledExactlyOnceWith(FIRST.id, url, FIRST.generation));
46
+ } finally {
47
+ view.unmount();
48
+ }
49
+ });
50
+
51
+ it('hides stale PR links and refuses open when discovery fails', async () => {
52
+ const url = 'https://github.com/team/repo/pull/123';
53
+ let fail = false;
54
+ const onOpenPr = vi.fn(async () => undefined);
55
+ const view = render(
56
+ <SupervisedSessionView
57
+ refreshMs={20}
58
+ onOpenPr={onOpenPr}
59
+ loadRows={async () => {
60
+ if (fail) throw new Error('offline');
61
+ return [
62
+ { ...FIRST, pr: { url, host: 'github.com', number: 123, kind: 'pull' as const } },
63
+ ];
64
+ }}
65
+ />,
66
+ );
67
+ try {
68
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(url));
69
+ fail = true;
70
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('discovery unavailable'));
71
+ expect(view.lastFrame()).not.toContain(url);
72
+ view.stdin.write('o');
73
+ expect(onOpenPr).not.toHaveBeenCalled();
74
+ } finally {
75
+ view.unmount();
76
+ }
77
+ });
78
+
79
+ it('refreshes a replaced or cleared association without retaining the old URL', async () => {
80
+ const first = 'https://github.com/team/repo/pull/123';
81
+ const next = 'https://git.example.org/team/repo/-/merge_requests/24';
82
+ let association: ISupervisedViewRow['pr'] = {
83
+ url: first,
84
+ host: 'github.com',
85
+ number: 123,
86
+ kind: 'pull',
87
+ };
88
+ const view = render(
89
+ <SupervisedSessionView
90
+ refreshMs={20}
91
+ loadRows={async () => [{ ...FIRST, ...(association ? { pr: association } : {}) }]}
92
+ />,
93
+ );
94
+ try {
95
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(first));
96
+ association = { url: next, host: 'git.example.org', number: 24, kind: 'merge-request' };
97
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(next));
98
+ expect(view.lastFrame()).not.toContain(first);
99
+ association = undefined;
100
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain(next));
101
+ } finally {
102
+ view.unmount();
103
+ }
104
+ });
105
+ it('toggles verified directory grouping without losing selection or inventing unknown paths', async () => {
106
+ const rows = [
107
+ { ...FIRST, cwd: '/projects/alpha' },
108
+ { ...THIRD, cwd: '/projects/beta' },
109
+ SECOND,
110
+ ];
111
+ const view = render(<SupervisedSessionView loadRows={async () => rows} />);
112
+ try {
113
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
114
+ expect(view.lastFrame()).not.toContain('/projects/alpha');
115
+ view.stdin.write('g');
116
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1: alpha — /projects/alpha'));
117
+ expect(view.lastFrame()).toContain('Dir 2: beta — /projects/beta');
118
+ expect(view.lastFrame()).toContain('Directory: unverified');
119
+ expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`);
120
+ view.stdin.write('g');
121
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain('/projects/alpha'));
122
+ expect(view.lastFrame()).toContain('working:');
123
+ } finally {
124
+ view.unmount();
125
+ }
126
+ });
127
+
128
+ it('announces directory groups and their selection in screen-reader mode', async () => {
129
+ const view = render(
130
+ <ScreenReaderProvider enabled>
131
+ <SupervisedSessionView
132
+ loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, SECOND]}
133
+ />
134
+ </ScreenReaderProvider>,
135
+ );
136
+ try {
137
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
138
+ view.stdin.write('g');
139
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1: alpha — /projects/alpha'));
140
+ expect(view.lastFrame()).toContain('Directory: unverified');
141
+ expect(view.lastFrame()).toContain('Enter selection (1-2)');
142
+ view.stdin.write('?');
143
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('g Group state/dir'));
144
+ } finally {
145
+ view.unmount();
146
+ }
147
+ });
148
+
149
+ it('keeps long directory headings within a narrow terminal viewport', async () => {
150
+ const view = render(
151
+ <SupervisedSessionView
152
+ loadRows={async () => [
153
+ { ...FIRST, cwd: `/projects/${'very-long-directory-name-'.repeat(8)}` },
154
+ ]}
155
+ />,
156
+ );
157
+ try {
158
+ Object.defineProperty(view.stdout, 'columns', { value: 20 });
159
+ view.stdout.emit('resize');
160
+ await vi.waitFor(() =>
161
+ expect(view.lastFrame()).toContain(`Selected ${FIRST.id.slice(0, 8)}`),
162
+ );
163
+ view.stdin.write('g');
164
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1:'));
165
+ expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
166
+ } finally {
167
+ view.unmount();
168
+ }
169
+ });
170
+
171
+ it('distinguishes directory groups with a shared prefix in a narrow terminal', async () => {
172
+ const view = render(
173
+ <SupervisedSessionView
174
+ loadRows={async () => [
175
+ { ...FIRST, cwd: '/projects/alpha' },
176
+ { ...THIRD, cwd: '/projects/beta' },
177
+ ]}
178
+ />,
179
+ );
180
+ try {
181
+ Object.defineProperty(view.stdout, 'columns', { value: 20 });
182
+ view.stdout.emit('resize');
183
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('2 supervised'));
184
+ view.stdin.write('g');
185
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1: alpha'));
186
+ expect(view.lastFrame()).toContain('Dir 2: beta');
187
+ } finally {
188
+ view.unmount();
189
+ }
190
+ });
191
+
192
+ it('shows differing parent directories when project basenames match at 20 columns', async () => {
193
+ const view = render(
194
+ <SupervisedSessionView
195
+ loadRows={async () => [
196
+ { ...FIRST, cwd: '/projects/alpha/app' },
197
+ { ...THIRD, cwd: '/projects/beta/app' },
198
+ ]}
199
+ />,
200
+ );
201
+ try {
202
+ Object.defineProperty(view.stdout, 'columns', { value: 20 });
203
+ view.stdout.emit('resize');
204
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('2 supervised'));
205
+ view.stdin.write('g');
206
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('alpha/app'));
207
+ expect(view.lastFrame()).toContain('beta/app');
208
+ } finally {
209
+ view.unmount();
210
+ }
211
+ });
212
+
213
+ it('keeps the differing path portion visible when long parent names share a prefix', async () => {
214
+ const view = render(
215
+ <SupervisedSessionView
216
+ loadRows={async () => [
217
+ { ...FIRST, cwd: '/projects/alpha-very-long-parent/app' },
218
+ { ...THIRD, cwd: '/projects/alpha-very-long-pardon/app' },
219
+ ]}
220
+ />,
221
+ );
222
+ try {
223
+ Object.defineProperty(view.stdout, 'columns', { value: 20 });
224
+ view.stdout.emit('resize');
225
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('2 supervised'));
226
+ view.stdin.write('g');
227
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('ent/app'));
228
+ expect(view.lastFrame()).toContain('don/app');
229
+ } finally {
230
+ view.unmount();
231
+ }
232
+ });
233
+
234
+ it('keeps the distinguishing wide character visible in a 20-column terminal', async () => {
235
+ const view = render(
236
+ <SupervisedSessionView
237
+ loadRows={async () => [
238
+ { ...FIRST, cwd: '/projects/あいうえおかきくけこ甲' },
239
+ { ...THIRD, cwd: '/projects/あいうえおかきくけこ乙' },
240
+ ]}
241
+ />,
242
+ );
243
+ try {
244
+ Object.defineProperty(view.stdout, 'columns', { value: 20 });
245
+ view.stdout.emit('resize');
246
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('2 supervised'));
247
+ view.stdin.write('g');
248
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('甲'));
249
+ expect(view.lastFrame()).toContain('乙');
250
+ expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
251
+ } finally {
252
+ view.unmount();
253
+ }
254
+ });
255
+
256
+ it('escapes layout controls in verified directory headings', async () => {
257
+ const view = render(
258
+ <SupervisedSessionView
259
+ loadRows={async () => [{ ...FIRST, cwd: '/projects/line\nbreak/app' }]}
260
+ />,
261
+ );
262
+ try {
263
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
264
+ view.stdin.write('g');
265
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1:'));
266
+ expect(view.lastFrame()).toContain('line\\u{a}break/app');
267
+ expect(view.lastFrame()).not.toContain('line\nbreak/app');
268
+ expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
269
+ } finally {
270
+ view.unmount();
271
+ }
272
+ });
273
+
274
+ it('clears a pending screen-reader number before directory regrouping', async () => {
275
+ const view = render(
276
+ <ScreenReaderProvider enabled>
277
+ <SupervisedSessionView
278
+ loadRows={async () => [
279
+ { ...FIRST, cwd: '/projects/z' },
280
+ { ...THIRD, cwd: '/projects/a' },
281
+ ]}
282
+ />
283
+ </ScreenReaderProvider>,
284
+ );
285
+ try {
286
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
287
+ view.stdin.write('1');
288
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Escape to cancel 1'));
289
+ view.stdin.write('g');
290
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1: projects/a'));
291
+ expect(view.lastFrame()).not.toContain('Escape to cancel 1');
292
+ view.stdin.write('\r');
293
+ expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`);
294
+ } finally {
295
+ view.unmount();
296
+ }
297
+ });
298
+
299
+ it('filters by an observed group without treating dead or unverified rows as idle', async () => {
300
+ const view = render(
301
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND, THIRD]} stateFilter="idle" />,
302
+ );
303
+ try {
304
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
305
+ expect(view.lastFrame()).toContain('State: idle');
306
+ expect(view.lastFrame()).not.toContain(FIRST.id);
307
+ expect(view.lastFrame()).not.toContain(SECOND.id);
308
+ } finally {
309
+ view.unmount();
310
+ }
311
+ });
312
+
313
+ it('shows an observed loop eligibility countdown without changing idle into a process state', async () => {
314
+ const nextLoopAt = new Date(Date.now() + 1_200).toISOString();
315
+ const view = render(
316
+ <SupervisedSessionView
317
+ loadRows={async () => [{ ...THIRD, nextLoopAt }]}
318
+ stateFilter="idle"
319
+ refreshMs={100}
320
+ />,
321
+ );
322
+ try {
323
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible in'));
324
+ expect(view.lastFrame()).toContain('activity idle');
325
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible now'), {
326
+ timeout: 3_000,
327
+ });
328
+ } finally {
329
+ view.unmount();
330
+ }
331
+ });
332
+
333
+ it('includes the loop eligibility label in screen-reader output', async () => {
334
+ const nextLoopAt = new Date(Date.now() + 60_000).toISOString();
335
+ const view = render(
336
+ <ScreenReaderProvider enabled>
337
+ <SupervisedSessionView loadRows={async () => [{ ...THIRD, nextLoopAt }]} />
338
+ </ScreenReaderProvider>,
339
+ );
340
+ try {
341
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible in'));
342
+ expect(view.lastFrame()).toContain('activity idle');
343
+ } finally {
344
+ view.unmount();
345
+ }
346
+ });
347
+
348
+ it('never renders loaded rows without a selection', async () => {
349
+ const view = render(
350
+ <SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />,
351
+ );
352
+ try {
353
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
354
+ const withRows = view.frames.filter((frame) => frame.includes('Morning review'));
355
+ expect(withRows.length).toBeGreaterThan(0);
356
+ for (const frame of withRows) expect(frame).toContain(`Selected ${THIRD.id}`);
357
+ } finally {
358
+ view.unmount();
359
+ }
360
+ });
361
+
362
+ it('shows a verified human name while preserving the exact selected ID', async () => {
363
+ const view = render(
364
+ <SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />,
365
+ );
366
+ try {
367
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
368
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
369
+ expect(view.lastFrame()).toContain('activity idle');
370
+ } finally {
371
+ view.unmount();
372
+ }
373
+ });
374
+
375
+ it('starts a new background session without closing the view', async () => {
376
+ const start = vi.fn(async () => SECOND.id);
377
+ const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onStart={start} />);
378
+ try {
379
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
380
+ view.stdin.write('n');
381
+ await vi.waitFor(() => expect(start).toHaveBeenCalledTimes(1));
382
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Started ${SECOND.id}`));
383
+ expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`);
384
+ view.stdin.write('?');
385
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('n New session'));
386
+ } finally {
387
+ view.unmount();
388
+ }
389
+ });
390
+
391
+ it('asks about a folder not trusted yet, and starts as the answer says (#3268)', async () => {
392
+ const start = vi.fn(async () => SECOND.id);
393
+ const view = render(
394
+ <SupervisedSessionView
395
+ loadRows={async () => [FIRST]}
396
+ onStart={start}
397
+ startTrustQuestion={async () => QUESTION}
398
+ />,
399
+ );
400
+ try {
401
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
402
+ view.stdin.write('n');
403
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
404
+ // It names the folder and what trusting it would load.
405
+ expect(view.lastFrame()).toContain('Not trusted: /work/repo');
406
+ expect(view.lastFrame()).toContain('state/settings.json');
407
+ expect(start).not.toHaveBeenCalled();
408
+ // n cancels: nothing starts.
409
+ view.stdin.write('n');
410
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain('r Start Restricted'));
411
+ expect(start).not.toHaveBeenCalled();
412
+ view.stdin.write('n');
413
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
414
+ view.stdin.write('r');
415
+ await vi.waitFor(() => expect(start).toHaveBeenLastCalledWith('restricted'));
416
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Started ${SECOND.id}`));
417
+ view.stdin.write('n');
418
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
419
+ view.stdin.write('y');
420
+ await vi.waitFor(() => expect(start).toHaveBeenLastCalledWith('trust'));
421
+ } finally {
422
+ view.unmount();
423
+ }
424
+ });
425
+
426
+ it.each(['alpha-agent', 'beta-agent'] as const)(
427
+ 'shows the configured trust-status command when fitting a long question for %s',
428
+ async (cliName) => {
429
+ const loads = Array.from({ length: 32 }, (_, index) => ` [absent] source-${index} — a source`);
430
+ const view = render(
431
+ <SupervisedSessionView
432
+ loadRows={async () => [FIRST]}
433
+ onStart={async () => SECOND.id}
434
+ startTrustQuestion={async () => ({ folder: '/work/repo', loads })}
435
+ trustStatusCommand={`${cliName} trust status`}
436
+ />,
437
+ );
438
+ try {
439
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
440
+ view.stdin.write('n');
441
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
442
+ const frame = view.lastFrame() ?? '';
443
+ const lines = frame.split('\n');
444
+ expect(lines.length).toBeLessThanOrEqual(24);
445
+ expect(lines).toContain('Not trusted: /work/repo');
446
+ expect(lines).toContain('y Trust and start / r Start Restricted / n Cancel');
447
+ expect(frame).toMatch(new RegExp(`… \\d+ more — see ${cliName} trust status`));
448
+ expect(frame).toContain('source-0 — a source');
449
+ } finally {
450
+ view.unmount();
451
+ }
452
+ },
453
+ );
454
+
455
+ it('cancels the trust question on Escape in screen-reader mode, and keeps the view open', async () => {
456
+ const start = vi.fn(async () => SECOND.id);
457
+ const view = render(
458
+ <ScreenReaderProvider enabled>
459
+ <SupervisedSessionView
460
+ loadRows={async () => [FIRST]}
461
+ onStart={start}
462
+ startTrustQuestion={async () => QUESTION}
463
+ />
464
+ </ScreenReaderProvider>,
465
+ );
466
+ try {
467
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
468
+ view.stdin.write('n');
469
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
470
+ view.stdin.write('\u001b');
471
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain('r Start Restricted'));
472
+ expect(view.lastFrame()).toContain(FIRST.id);
473
+ expect(start).not.toHaveBeenCalled();
474
+ } finally {
475
+ view.unmount();
476
+ }
477
+ });
478
+
479
+ it('starts at once where the folder needs no answer', async () => {
480
+ const start = vi.fn(async () => SECOND.id);
481
+ const view = render(
482
+ <SupervisedSessionView
483
+ loadRows={async () => [FIRST]}
484
+ onStart={start}
485
+ startTrustQuestion={async () => undefined}
486
+ />,
487
+ );
488
+ try {
489
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
490
+ view.stdin.write('n');
491
+ await vi.waitFor(() => expect(start).toHaveBeenCalledExactlyOnceWith(undefined));
492
+ expect(view.lastFrame()).not.toContain('r Start Restricted');
493
+ } finally {
494
+ view.unmount();
495
+ }
496
+ });
497
+
498
+ it('keeps stop cancellation separate from starting a session', async () => {
499
+ const start = vi.fn(async () => SECOND.id);
500
+ const view = render(
501
+ <SupervisedSessionView
502
+ loadRows={async () => [FIRST]}
503
+ onStart={start}
504
+ onStop={async () => undefined}
505
+ />,
506
+ );
507
+ try {
508
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
509
+ view.stdin.write('s');
510
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
511
+ view.stdin.write('n');
512
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain('y Yes / n No'));
513
+ expect(start).not.toHaveBeenCalled();
514
+ } finally {
515
+ view.unmount();
516
+ }
517
+ });
518
+
519
+ it('can start from an empty view and hides private launch errors', async () => {
520
+ const start = vi
521
+ .fn()
522
+ .mockRejectedValueOnce(new Error('/private/token-path'))
523
+ .mockResolvedValueOnce(SECOND.id);
524
+ const view = render(<SupervisedSessionView loadRows={async () => []} onStart={start} />);
525
+ try {
526
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('No supervised sessions'));
527
+ view.stdin.write('n');
528
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Start failed'));
529
+ expect(view.lastFrame()).not.toContain('/private/token-path');
530
+ view.stdin.write('n');
531
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Started ${SECOND.id}`));
532
+ expect(start).toHaveBeenCalledTimes(2);
533
+ } finally {
534
+ view.unmount();
535
+ }
536
+ });
537
+
538
+ it('does not stop a row that leaves the selected state while confirmation is open', async () => {
539
+ let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
540
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
541
+ finishRefresh = resolve;
542
+ });
543
+ const loadRows = vi
544
+ .fn()
545
+ .mockResolvedValueOnce([THIRD])
546
+ .mockImplementation(() => refresh);
547
+ const stop = vi.fn(async () => undefined);
548
+ const view = render(
549
+ <SupervisedSessionView
550
+ loadRows={loadRows}
551
+ onStop={stop}
552
+ stateFilter="idle"
553
+ refreshMs={100}
554
+ />,
555
+ );
556
+ try {
557
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
558
+ view.stdin.write('s');
559
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
560
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
561
+ finishRefresh?.([{ ...THIRD, activity: 'working' }]);
562
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('0 supervised session(s)'));
563
+ view.stdin.write('y');
564
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
565
+ expect(stop).not.toHaveBeenCalled();
566
+ } finally {
567
+ view.unmount();
568
+ }
569
+ });
570
+
571
+ it('lists every active key in help even in a narrow, crowded terminal', async () => {
572
+ const rows = Array.from({ length: 18 }, (_, index) => ({
573
+ ...FIRST,
574
+ id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
575
+ }));
576
+ const view = render(
577
+ <SupervisedSessionView
578
+ loadRows={async () => rows}
579
+ onStart={async () => SECOND.id}
580
+ onAttach={vi.fn()}
581
+ onOpenPr={async () => undefined}
582
+ />,
583
+ );
584
+ try {
585
+ Object.defineProperty(view.stdout, 'columns', { value: 20 });
586
+ view.stdout.emit('resize');
587
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('18 supervised'));
588
+ view.stdin.write('?');
589
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Keys:'));
590
+ for (const key of [
591
+ '↑/↓ Select',
592
+ 's Request stop',
593
+ 'g Group state/dir',
594
+ 'n New session',
595
+ 'a Attach (drive)',
596
+ 'p Peek (read only)',
597
+ 'o Open linked PR',
598
+ 'y Confirm',
599
+ 'n/Esc Cancel',
600
+ 'q/Esc/Ctrl+C Close',
601
+ '? Toggle help',
602
+ ]) {
603
+ expect(view.lastFrame()).toContain(key);
604
+ }
605
+ expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
606
+ } finally {
607
+ view.unmount();
608
+ }
609
+ });
610
+
611
+ it('announces an active directory filter without printing its path', async () => {
612
+ const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} filteredByCwd />);
613
+ try {
614
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
615
+ expect(view.lastFrame()).toContain('Background sessions in selected directory');
616
+ expect(view.lastFrame()).not.toContain('/private/project');
617
+ } finally {
618
+ view.unmount();
619
+ }
620
+ });
621
+
622
+ it('shows a navigable global background list without inventing completion or attach', async () => {
623
+ const view = render(
624
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND]} refreshMs={100} />,
625
+ );
626
+ try {
627
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
628
+ expect(view.lastFrame()).toContain('working');
629
+ expect(view.lastFrame()).toContain('dead');
630
+ expect(view.lastFrame()).not.toMatch(/completed|failed|restartable|attach/i);
631
+ view.stdin.write('\x1B[B');
632
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
633
+ } finally {
634
+ view.unmount();
635
+ }
636
+ });
637
+
638
+ it('never overlaps refreshes and aborts an in-flight probe when closed', async () => {
639
+ let resolveSecond: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
640
+ const pending = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
641
+ resolveSecond = resolve;
642
+ });
643
+ const signals: AbortSignal[] = [];
644
+ const loadRows = vi.fn((signal: AbortSignal) => {
645
+ signals.push(signal);
646
+ return signals.length === 1 ? Promise.resolve([FIRST]) : pending;
647
+ });
648
+ const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={20} />);
649
+ try {
650
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
651
+ await new Promise<void>((resolve) => setTimeout(resolve, 80));
652
+ expect(loadRows).toHaveBeenCalledTimes(2);
653
+ view.stdin.write('q');
654
+ await vi.waitFor(() => expect(signals[1]?.aborted).toBe(true));
655
+ resolveSecond?.([SECOND]);
656
+ await new Promise<void>((resolve) => setTimeout(resolve, 40));
657
+ expect(loadRows).toHaveBeenCalledTimes(2);
658
+ } finally {
659
+ view.unmount();
660
+ }
661
+ });
662
+
663
+ it('keeps the selected session when activity changes reorder the groups', async () => {
664
+ // The reordering refresh is released only after the selection moved, so the test cannot race
665
+ // the refresh timer against the keypress.
666
+ let releaseReorder: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
667
+ const reordered = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
668
+ releaseReorder = resolve;
669
+ });
670
+ const loadRows = vi
671
+ .fn()
672
+ .mockResolvedValueOnce([FIRST, SECOND])
673
+ .mockImplementation(() => reordered);
674
+ const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={20} />);
675
+ try {
676
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
677
+ view.stdin.write('\x1B[B');
678
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
679
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
680
+ releaseReorder?.([
681
+ { ...FIRST, liveness: 'dead', control: 'unavailable', activity: 'unknown' },
682
+ { ...SECOND, liveness: 'alive', control: 'available', activity: 'working', generation: 'secondGenerationValue1' },
683
+ ]);
684
+ // Wait for the reordered list itself: SECOND listed under `working:`, FIRST under `dead:`.
685
+ const rowLine = (lines: readonly string[], id: string): number =>
686
+ lines.findIndex((line) => line.includes(id) && !line.startsWith('Selected'));
687
+ await vi.waitFor(() => {
688
+ const lines = (view.lastFrame() ?? '').split('\n');
689
+ const working = lines.indexOf('working:');
690
+ const dead = lines.indexOf('dead:');
691
+ expect(working).toBeGreaterThanOrEqual(0);
692
+ expect(rowLine(lines, SECOND.id)).toBe(working + 1);
693
+ expect(rowLine(lines, FIRST.id)).toBe(dead + 1);
694
+ });
695
+ expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`);
696
+ } finally {
697
+ view.unmount();
698
+ }
699
+ });
700
+
701
+ it('uses spoken state words and numbered selection in screen-reader mode', async () => {
702
+ const view = render(
703
+ <ScreenReaderProvider enabled>
704
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND]} refreshMs={100} />
705
+ </ScreenReaderProvider>,
706
+ );
707
+ try {
708
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('1.'));
709
+ expect(view.lastFrame()).toContain('working');
710
+ expect(view.lastFrame()).toContain('dead');
711
+ expect(view.lastFrame()).toContain('Enter selection (1-2)');
712
+ view.stdin.write('?');
713
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Number+Enter Select'));
714
+ expect(view.lastFrame()).not.toContain('↑/↓ Select');
715
+ view.stdin.write('2');
716
+ view.stdin.write('\r');
717
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
718
+ view.stdin.write('1');
719
+ view.stdin.write('\r');
720
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
721
+ } finally {
722
+ view.unmount();
723
+ }
724
+ });
725
+
726
+ it('keeps grouped rows and controls within a 24-line terminal', async () => {
727
+ const activities = ['needs-input', 'working', 'idle', 'unknown'] as const;
728
+ const rows: ISupervisedViewRow[] = Array.from({ length: 17 }, (_, index) => ({
729
+ id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
730
+ liveness: index === 16 ? 'dead' : 'alive',
731
+ control: index === 15 ? 'unavailable' : 'available',
732
+ activity: activities[index % activities.length]!,
733
+ }));
734
+ const view = render(<SupervisedSessionView loadRows={async () => rows} refreshMs={100} />);
735
+ try {
736
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('17 supervised session(s)'));
737
+ const frame = view.lastFrame() ?? '';
738
+ expect(frame.split('\n').length).toBeLessThanOrEqual(24);
739
+ expect(frame).toContain('more below');
740
+ expect(frame).toContain('↑↓ Navigate');
741
+ } finally {
742
+ view.unmount();
743
+ }
744
+ });
745
+
746
+ it('keeps stop confirmation and controls visible in a narrow 24-line terminal', async () => {
747
+ const rows: ISupervisedViewRow[] = Array.from({ length: 22 }, (_, index) => ({
748
+ ...FIRST,
749
+ id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
750
+ }));
751
+ const view = render(
752
+ <SupervisedSessionView loadRows={async () => rows} onStop={async () => undefined} />,
753
+ );
754
+ try {
755
+ Object.defineProperty(view.stdout, 'columns', { value: 40 });
756
+ view.stdout.emit('resize');
757
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Selected'));
758
+ view.stdin.write('s');
759
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Stop …00000000?'));
760
+ expect(view.lastFrame()).toContain('y Yes / n No');
761
+ const frame = view.lastFrame() ?? '';
762
+ expect(frame.split('\n').length).toBeLessThanOrEqual(24);
763
+ expect(frame).toContain('Confirm stop or cancel before closing.');
764
+ } finally {
765
+ view.unmount();
766
+ }
767
+ });
768
+
769
+ it('identifies the selected stop target even at 20 columns with matching UUID prefixes', async () => {
770
+ const similar = { ...FIRST, id: `${FIRST.id.slice(0, -8)}ffffffff` };
771
+ const view = render(
772
+ <SupervisedSessionView
773
+ loadRows={async () => [FIRST, similar]}
774
+ onStop={async () => undefined}
775
+ />,
776
+ );
777
+ try {
778
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
779
+ view.stdin.write('\x1B[B');
780
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${similar.id}`));
781
+ Object.defineProperty(view.stdout, 'columns', { value: 20 });
782
+ view.stdout.emit('resize');
783
+ view.stdin.write('s');
784
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Stop …ffffffff?'));
785
+ expect(view.lastFrame()).toContain('y Yes / n No');
786
+ expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
787
+ } finally {
788
+ view.unmount();
789
+ }
790
+ });
791
+
792
+ it('requires confirmation and stops only the selected owner-controllable row', async () => {
793
+ let finishStop: (() => void) | undefined;
794
+ const stop = vi.fn(
795
+ () =>
796
+ new Promise<void>((resolve) => {
797
+ finishStop = resolve;
798
+ }),
799
+ );
800
+ const view = render(
801
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />,
802
+ );
803
+ try {
804
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
805
+ view.stdin.write('s');
806
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
807
+ expect(stop).not.toHaveBeenCalled();
808
+ view.stdin.write('n');
809
+ expect(stop).not.toHaveBeenCalled();
810
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain('y Yes / n No'));
811
+ view.stdin.write('s');
812
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
813
+ view.stdin.write('y');
814
+ await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(FIRST.id, FIRST.generation));
815
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Stopping'));
816
+ expect(view.lastFrame()).toContain('Stop in progress; wait for result.');
817
+ expect(view.lastFrame()).not.toContain('q/Esc Close');
818
+ view.stdin.write('q');
819
+ view.stdin.write('\x1B');
820
+ view.stdin.write('\x03');
821
+ await new Promise<void>((resolve) => setTimeout(resolve, 20));
822
+ expect(view.lastFrame()).toContain('Stopping');
823
+ finishStop?.();
824
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Stopped ${FIRST.id}`));
825
+ } finally {
826
+ view.unmount();
827
+ }
828
+ });
829
+
830
+ it('refuses a stop when polling removes control while confirmation is open', async () => {
831
+ let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
832
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
833
+ finishRefresh = resolve;
834
+ });
835
+ const loadRows = vi
836
+ .fn()
837
+ .mockResolvedValueOnce([FIRST])
838
+ .mockImplementation(() => refresh);
839
+ const stop = vi.fn(async () => undefined);
840
+ const view = render(
841
+ <SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />,
842
+ );
843
+ try {
844
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
845
+ view.stdin.write('s');
846
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
847
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
848
+ finishRefresh?.([{ ...FIRST, control: 'unavailable', activity: 'unknown' }]);
849
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('control unavailable'));
850
+ view.stdin.write('y');
851
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
852
+ expect(stop).not.toHaveBeenCalled();
853
+ } finally {
854
+ view.unmount();
855
+ }
856
+ });
857
+
858
+ it('never offers stop for unavailable rows and leaves failures visible', async () => {
859
+ const stop = vi.fn(async () => {
860
+ throw new Error('private path must not be shown');
861
+ });
862
+ const view = render(
863
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />,
864
+ );
865
+ try {
866
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
867
+ view.stdin.write('\x1B[B');
868
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
869
+ view.stdin.write('s');
870
+ expect(stop).not.toHaveBeenCalled();
871
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
872
+ view.stdin.write('\x1B[A');
873
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
874
+ view.stdin.write('s');
875
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
876
+ view.stdin.write('y');
877
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Stop failed'));
878
+ expect(view.lastFrame()).toContain(FIRST.id);
879
+ expect(view.lastFrame()).not.toContain('private path');
880
+ } finally {
881
+ view.unmount();
882
+ }
883
+ });
884
+
885
+ it('selects when a typed number and Enter arrive in one input chunk', async () => {
886
+ const view = render(
887
+ <ScreenReaderProvider enabled>
888
+ <SupervisedSessionView loadRows={async () => [FIRST, THIRD]} />
889
+ </ScreenReaderProvider>,
890
+ );
891
+ try {
892
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
893
+ view.stdin.write('2\r');
894
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
895
+ } finally {
896
+ view.unmount();
897
+ }
898
+ });
899
+
900
+ it('uses the same confirmed stop action after screen-reader selection', async () => {
901
+ const stop = vi.fn(async () => undefined);
902
+ const view = render(
903
+ <ScreenReaderProvider enabled>
904
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND, THIRD]} onStop={stop} />
905
+ </ScreenReaderProvider>,
906
+ );
907
+ try {
908
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
909
+ view.stdin.write('2');
910
+ view.stdin.write('\r');
911
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
912
+ view.stdin.write('s');
913
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Stop ${THIRD.id}?`));
914
+ expect(view.lastFrame()).not.toContain('Enter selection');
915
+ view.stdin.write('y');
916
+ await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(THIRD.id, THIRD.generation));
917
+ } finally {
918
+ view.unmount();
919
+ }
920
+ });
921
+ it('refuses a stop when the session restarts under the same id while confirmation is open', async () => {
922
+ let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
923
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
924
+ finishRefresh = resolve;
925
+ });
926
+ const loadRows = vi
927
+ .fn()
928
+ .mockResolvedValueOnce([FIRST])
929
+ .mockImplementation(() => refresh);
930
+ const stop = vi.fn(async () => undefined);
931
+ const view = render(
932
+ <SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />,
933
+ );
934
+ try {
935
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
936
+ view.stdin.write('s');
937
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
938
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
939
+ finishRefresh?.([{ ...FIRST, generation: 'restartedGeneration001' }]);
940
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(3));
941
+ view.stdin.write('y');
942
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
943
+ expect(stop).not.toHaveBeenCalled();
944
+ } finally {
945
+ view.unmount();
946
+ }
947
+ });
948
+
949
+ it('never offers stop or PR open for a row without a verified generation', async () => {
950
+ const stop = vi.fn(async () => undefined);
951
+ const onOpenPr = vi.fn(async () => undefined);
952
+ const { generation: _generation, ...unbound } = FIRST;
953
+ const row: ISupervisedViewRow = {
954
+ ...unbound,
955
+ name: 'Unbound name',
956
+ pr: { url: 'https://github.com/team/repo/pull/9', host: 'github.com', number: 9, kind: 'pull' },
957
+ };
958
+ const view = render(
959
+ <SupervisedSessionView loadRows={async () => [row]} onStop={stop} onOpenPr={onOpenPr} />,
960
+ );
961
+ try {
962
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
963
+ expect(view.lastFrame()).toContain('unverified:');
964
+ expect(view.lastFrame()).not.toContain('Unbound name');
965
+ expect(view.lastFrame()).not.toContain('github.com');
966
+ view.stdin.write('s');
967
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
968
+ expect(view.lastFrame()).not.toContain('y Yes / n No');
969
+ view.stdin.write('o');
970
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('No verified PR link'));
971
+ expect(stop).not.toHaveBeenCalled();
972
+ expect(onOpenPr).not.toHaveBeenCalled();
973
+ } finally {
974
+ view.unmount();
975
+ }
976
+ });
977
+ it('attaches or peeks at a verified row only after a confirmation bound to its generation', async () => {
978
+ for (const [key, mode, wording] of [
979
+ ['a', 'drive', 'drive (send prompts, answer its questions)'],
980
+ ['p', 'observe', 'observe (read only)'],
981
+ ] as const) {
982
+ const onAttach = vi.fn();
983
+ const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />);
984
+ try {
985
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
986
+ expect(view.lastFrame()).toContain('a Attach');
987
+ expect(view.lastFrame()).toContain('p Peek');
988
+ view.stdin.write(key);
989
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(wording));
990
+ expect(view.lastFrame()).toContain('y Yes / n No');
991
+ view.stdin.write('n');
992
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain('y Yes / n No'));
993
+ expect(onAttach).not.toHaveBeenCalled();
994
+ view.stdin.write(key);
995
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
996
+ view.stdin.write('y');
997
+ await vi.waitFor(() =>
998
+ expect(onAttach).toHaveBeenCalledExactlyOnceWith({
999
+ id: FIRST.id, generation: FIRST.generation, mode, groupByDirectory: false,
1000
+ }),
1001
+ );
1002
+ } finally {
1003
+ view.unmount();
1004
+ }
1005
+ }
1006
+ });
1007
+
1008
+ it('refuses an attach when the row restarts or leaves control while confirmation is open', async () => {
1009
+ for (const replacement of [
1010
+ { ...FIRST, generation: 'restartedGeneration001' },
1011
+ { ...FIRST, control: 'unavailable' as const, activity: 'unknown' as const },
1012
+ ]) {
1013
+ let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
1014
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
1015
+ finishRefresh = resolve;
1016
+ });
1017
+ const loadRows = vi.fn().mockResolvedValueOnce([FIRST]).mockImplementation(() => refresh);
1018
+ const onAttach = vi.fn();
1019
+ const view = render(<SupervisedSessionView loadRows={loadRows} onAttach={onAttach} refreshMs={100} />);
1020
+ try {
1021
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
1022
+ view.stdin.write('a');
1023
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
1024
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
1025
+ finishRefresh?.([replacement]);
1026
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(3));
1027
+ view.stdin.write('y');
1028
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be attached to'));
1029
+ expect(onAttach).not.toHaveBeenCalled();
1030
+ } finally {
1031
+ view.unmount();
1032
+ }
1033
+ }
1034
+ });
1035
+
1036
+ it('never offers attach or peek on dead, unknown or unverified rows', async () => {
1037
+ const { generation: _generation, ...unbound } = FIRST;
1038
+ for (const row of [
1039
+ SECOND,
1040
+ { ...FIRST, liveness: 'unknown' as const, control: 'unavailable' as const, activity: 'unknown' as const },
1041
+ unbound,
1042
+ ]) {
1043
+ const onAttach = vi.fn();
1044
+ const view = render(<SupervisedSessionView loadRows={async () => [row]} onAttach={onAttach} />);
1045
+ try {
1046
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${row.id}`));
1047
+ expect(view.lastFrame()).not.toContain('a Attach');
1048
+ expect(view.lastFrame()).not.toContain('p Peek');
1049
+ for (const key of ['a', 'p']) {
1050
+ view.stdin.write(key);
1051
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be attached to'));
1052
+ expect(view.lastFrame()).not.toContain('y Yes / n No');
1053
+ }
1054
+ expect(onAttach).not.toHaveBeenCalled();
1055
+ } finally {
1056
+ view.unmount();
1057
+ }
1058
+ }
1059
+ });
1060
+
1061
+ it('names the whole session id and the role in screen-reader mode', async () => {
1062
+ const onAttach = vi.fn();
1063
+ const view = render(
1064
+ <ScreenReaderProvider enabled>
1065
+ <SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />
1066
+ </ScreenReaderProvider>,
1067
+ );
1068
+ try {
1069
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
1070
+ expect(view.lastFrame()).toContain('a Attach; p Peek;');
1071
+ view.stdin.write('p');
1072
+ await vi.waitFor(() =>
1073
+ expect(view.lastFrame()).toContain(`Peek at ${FIRST.id} to observe (read only)? y Yes / n No`),
1074
+ );
1075
+ } finally {
1076
+ view.unmount();
1077
+ }
1078
+ });
1079
+ it('reopens on the row and grouping it was left with', async () => {
1080
+ const view = render(
1081
+ <SupervisedSessionView
1082
+ loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, { ...THIRD, cwd: '/projects/beta' }]}
1083
+ initialSelectedId={THIRD.id}
1084
+ initialGroupByDirectory
1085
+ />,
1086
+ );
1087
+ try {
1088
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
1089
+ expect(view.lastFrame()).toContain('Dir 1: alpha — /projects/alpha');
1090
+ } finally {
1091
+ view.unmount();
1092
+ }
1093
+ });
1094
+
1095
+ it('hands back the grouping with a confirmed attach', async () => {
1096
+ const onAttach = vi.fn();
1097
+ const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />);
1098
+ try {
1099
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
1100
+ view.stdin.write('g');
1101
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Directory: unverified'));
1102
+ view.stdin.write('p');
1103
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
1104
+ view.stdin.write('y');
1105
+ await vi.waitFor(() => expect(onAttach).toHaveBeenCalledExactlyOnceWith({
1106
+ id: FIRST.id, generation: FIRST.generation, mode: 'observe', groupByDirectory: true,
1107
+ }));
1108
+ } finally {
1109
+ view.unmount();
1110
+ }
1111
+ });
1112
+ });