@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,31 @@
1
+ import { render } from 'ink-testing-library';
2
+ import React from 'react';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import SessionEventNotices from '../SessionEventNotices.js';
6
+
7
+ describe('SessionEventNotices', () => {
8
+ it('renders deterministic plan, context, and branch notices', () => {
9
+ const { lastFrame } = render(
10
+ <SessionEventNotices
11
+ notices={[
12
+ { id: '1', event: 'plan_event', message: 'Plan plan approved' },
13
+ {
14
+ id: '2',
15
+ event: 'context_file_refreshed',
16
+ message: 'Context refreshed: AGENTS.md',
17
+ },
18
+ {
19
+ id: '3',
20
+ event: 'branch_event',
21
+ message: 'Branch branch switched: main @ turn-0001',
22
+ },
23
+ ]}
24
+ />,
25
+ );
26
+
27
+ expect(lastFrame()).toContain('Plan plan approved');
28
+ expect(lastFrame()).toContain('Context refreshed: AGENTS.md');
29
+ expect(lastFrame()).toContain('Branch branch switched: main @ turn-0001');
30
+ });
31
+ });
@@ -0,0 +1,102 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createSessionInitPoller } from '../flows/session-init-poller.js';
4
+
5
+ describe('createSessionInitPoller', () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ it('TC-04: calls onReady and stops once the check succeeds', () => {
15
+ const onReady = vi.fn();
16
+ const onFailure = vi.fn();
17
+ let ready = false;
18
+ const poller = createSessionInitPoller({
19
+ check: () => {
20
+ if (!ready) throw new Error('InteractiveSession not initialized. Call submit().');
21
+ },
22
+ intervalMs: 200,
23
+ timeoutMs: 15000,
24
+ onReady,
25
+ onFailure,
26
+ });
27
+ poller.start();
28
+ vi.advanceTimersByTime(600);
29
+ expect(onReady).not.toHaveBeenCalled();
30
+ ready = true;
31
+ vi.advanceTimersByTime(200);
32
+ expect(onReady).toHaveBeenCalledTimes(1);
33
+ vi.advanceTimersByTime(2000);
34
+ expect(onReady).toHaveBeenCalledTimes(1);
35
+ expect(onFailure).not.toHaveBeenCalled();
36
+ });
37
+
38
+ it('TC-04: benign not-initialized errors poll until timeout, then fail with timeout kind', () => {
39
+ const onReady = vi.fn();
40
+ const onFailure = vi.fn();
41
+ const poller = createSessionInitPoller({
42
+ check: () => {
43
+ throw new Error('InteractiveSession not initialized. Call submit().');
44
+ },
45
+ intervalMs: 200,
46
+ timeoutMs: 1000,
47
+ onReady,
48
+ onFailure,
49
+ });
50
+ poller.start();
51
+ vi.advanceTimersByTime(900);
52
+ expect(onFailure).not.toHaveBeenCalled();
53
+ vi.advanceTimersByTime(400);
54
+ expect(onFailure).toHaveBeenCalledTimes(1);
55
+ expect(onFailure.mock.calls[0]?.[0]).toMatchObject({ kind: 'timeout' });
56
+ expect(onReady).not.toHaveBeenCalled();
57
+ vi.advanceTimersByTime(2000);
58
+ expect(onFailure).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it('TC-04: a real error fails immediately with the error attached', () => {
62
+ const onFailure = vi.fn();
63
+ const poller = createSessionInitPoller({
64
+ check: () => {
65
+ throw new Error('ENOENT: session store unreadable');
66
+ },
67
+ intervalMs: 200,
68
+ timeoutMs: 15000,
69
+ onReady: vi.fn(),
70
+ onFailure,
71
+ });
72
+ poller.start();
73
+ vi.advanceTimersByTime(200);
74
+ expect(onFailure).toHaveBeenCalledTimes(1);
75
+ expect(onFailure.mock.calls[0]?.[0]).toMatchObject({ kind: 'error' });
76
+ expect(String((onFailure.mock.calls[0]?.[0] as { error: Error }).error.message)).toContain(
77
+ 'ENOENT',
78
+ );
79
+ vi.advanceTimersByTime(2000);
80
+ expect(onFailure).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it('stop() cancels polling without callbacks', () => {
84
+ const onReady = vi.fn();
85
+ const onFailure = vi.fn();
86
+ const poller = createSessionInitPoller({
87
+ check: () => {
88
+ throw new Error('InteractiveSession not initialized.');
89
+ },
90
+ intervalMs: 200,
91
+ timeoutMs: 1000,
92
+ onReady,
93
+ onFailure,
94
+ });
95
+ poller.start();
96
+ vi.advanceTimersByTime(400);
97
+ poller.stop();
98
+ vi.advanceTimersByTime(5000);
99
+ expect(onReady).not.toHaveBeenCalled();
100
+ expect(onFailure).not.toHaveBeenCalled();
101
+ });
102
+ });
@@ -0,0 +1,93 @@
1
+ import { render } from 'ink-testing-library';
2
+ import React from 'react';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import SessionPicker from '../SessionPicker.js';
6
+ import { shortSessionId } from '../short-session-id.js';
7
+
8
+ import type {
9
+ IResumableSessionSummary,
10
+ ISessionListingEntry,
11
+ } from '@robota-sdk/agent-interface-session';
12
+
13
+ function summary(id: string, name?: string): IResumableSessionSummary {
14
+ return {
15
+ id,
16
+ cwd: '/w',
17
+ updatedAt: '2026-09-26T10:00:00.000Z',
18
+ messageCount: 2,
19
+ preview: '',
20
+ ...(name !== undefined ? { name } : {}),
21
+ };
22
+ }
23
+
24
+ describe('session ids on screen', () => {
25
+ it('shortens an id to the part that tells sessions apart, not the prefix they share', () => {
26
+ expect(shortSessionId('session_07d291bc-4d43-4d26-9375-7d8ca0e60e6f')).toBe('07d291bc');
27
+ expect(shortSessionId('session_1773862349776_rnecma5lu')).toBe('17738623');
28
+ expect(shortSessionId('host-session-id')).toBe('host-ses');
29
+ expect(shortSessionId('session_')).toBe('session_');
30
+ });
31
+
32
+ it('lists unnamed sessions by distinct ids, and named ones by name', () => {
33
+ const view = render(
34
+ <SessionPicker
35
+ sessions={[
36
+ summary('session_07d291bc-4d43-4d26-9375-7d8ca0e60e6f'),
37
+ summary('session_15f05245-1f06-4375-8f29-db19e622afdd'),
38
+ summary('session_aaaaaaaa-0000-0000-0000-000000000000', 'auth work'),
39
+ ]}
40
+ onSelect={() => undefined}
41
+ onCancel={() => undefined}
42
+ />,
43
+ );
44
+ const frame = view.lastFrame() ?? '';
45
+ expect(frame).toContain('07d291bc');
46
+ expect(frame).toContain('15f05245');
47
+ expect(frame).toContain('auth work');
48
+ expect(frame).not.toMatch(/session_\s/);
49
+ view.unmount();
50
+ });
51
+ });
52
+
53
+ describe('live sessions in the picker (#3189)', () => {
54
+ function entry(id: string, name: string, extra: Partial<ISessionListingEntry>): ISessionListingEntry {
55
+ return { ...summary(id, name), ...extra };
56
+ }
57
+
58
+ it('marks a session the host runs now, and how many clients are on it', () => {
59
+ const view = render(
60
+ <SessionPicker
61
+ sessions={[
62
+ entry('session_a', 'busy one', { live: true, clients: 2 }),
63
+ entry('session_b', 'solo one', { live: true, clients: 1 }),
64
+ entry('session_c', 'stored one', { live: false, clients: 0 }),
65
+ ]}
66
+ onSelect={() => undefined}
67
+ onCancel={() => undefined}
68
+ />,
69
+ );
70
+ const lines = (view.lastFrame() ?? '').split('\n');
71
+ const lineOf = (name: string): string => lines.find((line) => line.includes(name)) ?? '';
72
+ expect(lineOf('busy one')).toContain('● live · 2 clients');
73
+ expect(lineOf('solo one')).toContain('● live · 1 client');
74
+ expect(lineOf('solo one')).not.toContain('1 clients');
75
+ expect(lineOf('stored one')).not.toContain('live');
76
+ expect(lineOf('stored one')).not.toContain('client');
77
+ view.unmount();
78
+ });
79
+
80
+ it('shows neither for an older host, which sends neither', () => {
81
+ const view = render(
82
+ <SessionPicker
83
+ sessions={[summary('session_a', 'plain')]}
84
+ onSelect={() => undefined}
85
+ onCancel={() => undefined}
86
+ />,
87
+ );
88
+ const frame = view.lastFrame() ?? '';
89
+ expect(frame).not.toContain('live');
90
+ expect(frame).not.toContain('client');
91
+ view.unmount();
92
+ });
93
+ });
@@ -0,0 +1,470 @@
1
+ /**
2
+ * CLI-B11 TC-01/03/05 + CLI-B12 TC-01/02/04: session-switch channel ownership
3
+ * at the App boundary.
4
+ *
5
+ * The 2026-05-31 context-loss bug lived between render.tsx, App.tsx and
6
+ * TuiInteractionChannel — InteractiveSession-level tests stayed green through it.
7
+ * These tests render the REAL App with a mocked createChannel factory and drive
8
+ * switches through the real SessionPicker, pinning the factory-call contract.
9
+ * Since CLI-B12 the factory is the SOLE channel source: App creates the initial
10
+ * channel in its useState initializer and replaces it on every switch.
11
+ */
12
+
13
+ import { mkdtempSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ import { render } from 'ink-testing-library';
18
+ import React from 'react';
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
20
+
21
+ import App from '../App.js';
22
+ import { TuiStateManager } from '../tui-state-manager.js';
23
+
24
+ import type { ITuiCliAdapter } from '../tui-cli-adapter.js';
25
+ import type { ITuiAppChannelPort } from '../tui-app-channel-port.js';
26
+ import type {
27
+ IInteractiveSessionRecord,
28
+ IInteractiveSessionStore,
29
+ } from '@robota-sdk/agent-interface-session';
30
+
31
+ const TICK_MS = 30;
32
+ const FRAME_DEADLINE_MS = 3000;
33
+
34
+ function tick(ms = TICK_MS): Promise<void> {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+
38
+ async function waitForFrame(
39
+ lastFrame: () => string | undefined,
40
+ predicate: (frame: string) => boolean,
41
+ ): Promise<void> {
42
+ const deadline = Date.now() + FRAME_DEADLINE_MS;
43
+ while (Date.now() < deadline) {
44
+ const frame = lastFrame();
45
+ if (frame !== undefined && predicate(frame)) return;
46
+ await tick(10);
47
+ }
48
+ throw new Error(`waitForFrame timeout\n--- frame ---\n${lastFrame() ?? '<none>'}`);
49
+ }
50
+
51
+ async function waitForSessionPicker(lastFrame: () => string | undefined): Promise<void> {
52
+ await waitForFrame(lastFrame, (frame) => frame.includes('Select a session to resume'));
53
+ await tick();
54
+ }
55
+
56
+ interface IFakeChannel {
57
+ sessionName: string | undefined;
58
+ stateManager: TuiStateManager;
59
+ onChange: (() => void) | null;
60
+ isShuttingDown: boolean;
61
+ permissionRequest: null;
62
+ pendingUserAction: null;
63
+ start: ReturnType<typeof vi.fn>;
64
+ stop: ReturnType<typeof vi.fn>;
65
+ handleInput: ReturnType<typeof vi.fn>;
66
+ abort: ReturnType<typeof vi.fn>;
67
+ cancelQueue: ReturnType<typeof vi.fn>;
68
+ shutdown: ReturnType<typeof vi.fn>;
69
+ selectExecutionWorkspaceEntry: ReturnType<typeof vi.fn>;
70
+ readExecutionWorkspaceDetail: ReturnType<typeof vi.fn>;
71
+ getSession: () => unknown;
72
+ getRegistry: () => unknown;
73
+ /** Test handle: emit a session event to this channel's subscribers (CMD-004 `ui_intent` path). */
74
+ emitSessionEvent: (event: string, payload: unknown) => void;
75
+ /** Test handle: which resumeSessionId this channel was created for. */
76
+ createdFor: string | undefined;
77
+ }
78
+
79
+ function createFakeChannel(createdFor: string | undefined): IFakeChannel & ITuiAppChannelPort {
80
+ // CMD-004 Stage C: App subscribes to `ui_intent`/`session_renamed` on the session.
81
+ const sessionListeners = new Map<string, Set<(payload: unknown) => void>>();
82
+ const fakeSession = {
83
+ // ARCH-012: required. `useTuiChannel` reads the co-drive queue length live from the session; the
84
+ // `?.() ?? …` that tolerated its absence was a branch nothing could take once the member became
85
+ // required, and a wrong count if it ever did.
86
+ getPendingCount: (): number => 0,
87
+ getName: (): string | undefined => undefined,
88
+ getSession: (): never => {
89
+ throw new Error('session not initialized (test fake)');
90
+ },
91
+ getFullHistory: (): never[] => [],
92
+ setName: vi.fn(),
93
+ shutdown: vi.fn(async () => {}),
94
+ sendAgentJob: vi.fn(async () => {}),
95
+ on: (event: string, handler: (payload: unknown) => void): void => {
96
+ const set = sessionListeners.get(event) ?? new Set<(payload: unknown) => void>();
97
+ set.add(handler);
98
+ sessionListeners.set(event, set);
99
+ },
100
+ off: (event: string, handler: (payload: unknown) => void): void => {
101
+ sessionListeners.get(event)?.delete(handler);
102
+ },
103
+ };
104
+ const fakeRegistry = {
105
+ getCommands: (): never[] => [],
106
+ getSubcommands: (): never[] => [],
107
+ };
108
+ const manager = new TuiStateManager();
109
+ let onChange: (() => void) | null = null;
110
+ const fake: IFakeChannel & ITuiAppChannelPort = {
111
+ terminalHandoffController: undefined,
112
+ sessionName: undefined,
113
+ stateManager: manager,
114
+ onChange: null,
115
+ isShuttingDown: false,
116
+ permissionRequest: null,
117
+ pendingUserAction: null,
118
+ start: vi.fn(async () => {}),
119
+ stop: vi.fn(async () => {}),
120
+ handleInput: vi.fn(async () => {}),
121
+ abort: vi.fn(),
122
+ cancelQueue: vi.fn(),
123
+ stopWaitingSelfPacedLoop: vi.fn(async () => {}),
124
+ shutdown: vi.fn(async () => {}),
125
+ selectExecutionWorkspaceEntry: vi.fn(),
126
+ readExecutionWorkspaceDetail: vi.fn(async (entryId: string) => ({ entryId, records: [] })),
127
+ getSession: () => fakeSession,
128
+ getRegistry: () => fakeRegistry,
129
+ subscribe: (handler) => {
130
+ onChange = handler;
131
+ fake.onChange = handler;
132
+ return () => {
133
+ if (onChange === handler) onChange = null;
134
+ if (fake.onChange === handler) fake.onChange = null;
135
+ };
136
+ },
137
+ getSnapshot: () => ({
138
+ history: manager.history,
139
+ streamingText: manager.streamingText,
140
+ activeTools: manager.activeTools,
141
+ isThinking: manager.isThinking,
142
+ isAborting: manager.isAborting,
143
+ lastErrorMessage: manager.lastErrorMessage,
144
+ isStalled: manager.isStalled,
145
+ sessionEventNotices: manager.sessionEventNotices,
146
+ isShuttingDown: false,
147
+ pendingPrompt: manager.pendingPrompt,
148
+ pendingCount: 0,
149
+ executionWorkspaceSnapshot: manager.executionWorkspaceSnapshot,
150
+ permissionRequest: null,
151
+ pendingUserAction: null,
152
+ contextState: manager.contextState,
153
+ }),
154
+ getSessionUiEventPort: () => fakeSession,
155
+ getCommandQueryPort: () => fakeRegistry,
156
+ getRuntimeStatusSnapshot: (permissionMode) => ({ permissionMode, sessionId: '' }),
157
+ addEntry: (entry) => manager.addEntry(entry),
158
+ sendAgentJob: vi.fn(async () => {}),
159
+ resolveUserAction: vi.fn(),
160
+ emitSessionEvent: (event, payload) => {
161
+ for (const handler of [...(sessionListeners.get(event) ?? [])]) handler(payload);
162
+ },
163
+ createdFor,
164
+ };
165
+ return fake;
166
+ }
167
+
168
+ function createFakeStore(records: IInteractiveSessionRecord[]): IInteractiveSessionStore {
169
+ return {
170
+ save: () => undefined,
171
+ load: (id) => {
172
+ const record = records.find((r) => r.id === id);
173
+ return record === undefined ? { status: 'missing' } : { status: 'valid', record };
174
+ },
175
+ list: () => records.map((record) => ({ id: record.id, outcome: { status: 'valid', record } })),
176
+ delete: () => undefined,
177
+ };
178
+ }
179
+
180
+ function sessionRecord(
181
+ id: string,
182
+ cwd: string,
183
+ updatedAt = '2026-06-13T00:00:00.000Z',
184
+ ): IInteractiveSessionRecord {
185
+ return {
186
+ id,
187
+ cwd,
188
+ createdAt: '2026-06-13T00:00:00.000Z',
189
+ updatedAt,
190
+ messages: [
191
+ { role: 'user', content: `hello from ${id}` },
192
+ { role: 'assistant', content: `reply in ${id}` },
193
+ ] as IInteractiveSessionRecord['messages'],
194
+ };
195
+ }
196
+
197
+ /** The picker lists sessions newest-first; bumping updatedAt puts a record on top. */
198
+ function touch(records: IInteractiveSessionRecord[], id: string, updatedAt: string): void {
199
+ const record = records.find((r) => r.id === id);
200
+ if (!record) throw new Error(`no record ${id}`);
201
+ record.updatedAt = updatedAt;
202
+ }
203
+
204
+ function createCliAdapter(settingsPath: string): ITuiCliAdapter {
205
+ return {
206
+ getUserSettingsPath: () => settingsPath,
207
+ readSettings: () => ({}),
208
+ reloadPluginCommandSource: vi.fn(),
209
+ applyActiveModelChange: vi.fn().mockReturnValue({ applied: true }),
210
+ getGitBranch: vi.fn().mockReturnValue(undefined),
211
+ getProviderDisplayName: vi.fn((type: string) => type),
212
+ };
213
+ }
214
+
215
+ describe('App session-switch channel ownership (CLI-B11)', () => {
216
+ let cwd: string;
217
+ let created: IFakeChannel[];
218
+ let createChannel: ReturnType<typeof vi.fn>;
219
+ let initialStartError: Error | undefined;
220
+ let initialStartIntent: boolean;
221
+
222
+ beforeEach(() => {
223
+ cwd = mkdtempSync(join(tmpdir(), 'agent-b11-'));
224
+ created = [];
225
+ initialStartError = undefined;
226
+ initialStartIntent = false;
227
+ createChannel = vi.fn((resumeSessionId?: string) => {
228
+ const fake = createFakeChannel(resumeSessionId);
229
+ if (created.length === 0 && initialStartError !== undefined) {
230
+ fake.start.mockRejectedValueOnce(initialStartError);
231
+ } else if (created.length === 0 && initialStartIntent) {
232
+ fake.start.mockImplementationOnce(async () => {
233
+ fake.emitSessionEvent('ui_intent', {
234
+ intent: { type: 'show-session-picker' },
235
+ requesterDriverId: 'owner',
236
+ });
237
+ });
238
+ }
239
+ created.push(fake);
240
+ return fake;
241
+ });
242
+ });
243
+
244
+ afterEach(() => {
245
+ rmSync(cwd, { recursive: true, force: true });
246
+ });
247
+
248
+ function renderApp(options?: { sessionIds?: string[]; showSessionPickerOnStart?: boolean }) {
249
+ const ids = options?.sessionIds ?? ['session-aaaaaaaa', 'session-bbbbbbbb'];
250
+ const records = ids.map((id) => sessionRecord(id, cwd));
251
+ const store = createFakeStore(records);
252
+ const instance = render(
253
+ <App
254
+ cwd={cwd}
255
+ createChannel={createChannel}
256
+ sessionStore={store}
257
+ showSessionPickerOnStart={options?.showSessionPickerOnStart ?? true}
258
+ cliAdapter={createCliAdapter(join(cwd, 'settings.json'))}
259
+ />,
260
+ );
261
+ return { ...instance, records };
262
+ }
263
+
264
+ it('TC-01 (B11) / TC-01 (B12): the factory is the sole channel source — once at mount, once per switch with the selected sessionId', async () => {
265
+ const { stdin, lastFrame } = renderApp();
266
+ await waitForSessionPicker(lastFrame);
267
+ expect(lastFrame()).toContain('Select a session to resume');
268
+
269
+ // CLI-B12 TC-01: initial channel from the useState initializer, exactly once.
270
+ expect(createChannel).toHaveBeenCalledTimes(1);
271
+ expect(createChannel).toHaveBeenNthCalledWith(1, undefined);
272
+
273
+ stdin.write('\r'); // select first item (newest first — equal timestamps keep list order)
274
+ await tick();
275
+
276
+ // CLI-B11 TC-A: the switch asks the factory for exactly one channel with the id.
277
+ expect(createChannel).toHaveBeenCalledTimes(2);
278
+ expect(createChannel).toHaveBeenNthCalledWith(2, 'session-aaaaaaaa');
279
+ });
280
+
281
+ it('TC-03 (B11) / TC-02 (B12): the previous channel is stopped before the new one becomes active', async () => {
282
+ const { stdin, lastFrame } = renderApp();
283
+ await waitForSessionPicker(lastFrame);
284
+ const initialChannel = created[0]!;
285
+ expect(initialChannel.start).toHaveBeenCalled();
286
+
287
+ stdin.write('\r');
288
+ await tick();
289
+
290
+ // Old channel released by the switch handler; final App teardown belongs to renderApp's
291
+ // awaitable composition boundary.
292
+ expect(initialChannel.stop).toHaveBeenCalled();
293
+ expect(created).toHaveLength(2);
294
+ const newChannel = created[1]!;
295
+ expect(newChannel.start).toHaveBeenCalled();
296
+ expect(newChannel.stop).not.toHaveBeenCalled();
297
+
298
+ // CLI-B12 TC-02 ordering: old stop() was invoked BEFORE the factory built
299
+ // the replacement channel (stop-before-active contract).
300
+ const stopOrder = initialChannel.stop.mock.invocationCallOrder[0]!;
301
+ const replacementOrder = createChannel.mock.invocationCallOrder[1]!;
302
+ expect(stopOrder).toBeLessThan(replacementOrder);
303
+ });
304
+
305
+ it('TC-02 (REFACTOR-025): waits for the prior stop to finish before constructing the replacement', async () => {
306
+ const { stdin, lastFrame } = renderApp();
307
+ await waitForSessionPicker(lastFrame);
308
+ const initialChannel = created[0]!;
309
+ let releaseStop: (() => void) | undefined;
310
+ initialChannel.stop.mockImplementationOnce(
311
+ () => new Promise<void>((resolve) => (releaseStop = resolve)),
312
+ );
313
+
314
+ stdin.write('\r');
315
+ await tick();
316
+ expect(initialChannel.stop).toHaveBeenCalledTimes(1);
317
+ expect(createChannel).toHaveBeenCalledTimes(1);
318
+ expect(lastFrame()).not.toContain('Select a session to resume');
319
+
320
+ releaseStop?.();
321
+ await waitForFrame(
322
+ () => '',
323
+ () => createChannel.mock.calls.length === 2,
324
+ );
325
+ expect(createChannel).toHaveBeenNthCalledWith(2, 'session-aaaaaaaa');
326
+ });
327
+
328
+ it('does not construct a replacement after App unmounts during an in-flight switch', async () => {
329
+ const { stdin, lastFrame, unmount } = renderApp();
330
+ await waitForSessionPicker(lastFrame);
331
+ let releaseStop: (() => void) | undefined;
332
+ created[0]!.stop.mockImplementationOnce(
333
+ () => new Promise<void>((resolve) => (releaseStop = resolve)),
334
+ );
335
+
336
+ stdin.write('\r');
337
+ await tick();
338
+ unmount();
339
+ releaseStop?.();
340
+ await tick();
341
+
342
+ expect(createChannel).toHaveBeenCalledTimes(1);
343
+ });
344
+
345
+ it('TC-02 (REFACTOR-025): keeps the old channel selected and renders a stop failure', async () => {
346
+ const { stdin, lastFrame } = renderApp();
347
+ await waitForSessionPicker(lastFrame);
348
+ created[0]!.stop.mockRejectedValueOnce(new Error('transport stop failed'));
349
+
350
+ stdin.write('\r');
351
+ await waitForFrame(lastFrame, (frame) =>
352
+ frame.includes('Session switch failed: transport stop failed'),
353
+ );
354
+
355
+ expect(createChannel).toHaveBeenCalledTimes(1);
356
+ expect(lastFrame()).toContain('Press Enter to retry.');
357
+
358
+ stdin.write('\r');
359
+ await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 2);
360
+ // One failed switch and one successful retry.
361
+ expect(created[0]!.stop).toHaveBeenCalledTimes(2);
362
+ });
363
+
364
+ it('TC-04 (B12): App renders from the factory alone — no channel prop exists', async () => {
365
+ // The old no-factory fallback (B11 TC-D) is deleted with CLI-B12: createChannel
366
+ // is required and `channel` is no longer a prop (enforced at the type level —
367
+ // passing one is a compile error). This pins the runtime half: a render with
368
+ // only the factory boots, starts the initial channel, and keeps rendering.
369
+ const { lastFrame } = renderApp();
370
+ await tick();
371
+
372
+ expect(lastFrame()).toBeTruthy();
373
+ expect(createChannel).toHaveBeenCalledTimes(1);
374
+ expect(created[0]!.start).toHaveBeenCalled();
375
+ });
376
+
377
+ it('REFACTOR-025: renders a start failure, blocks normal input, and retries on Enter', async () => {
378
+ initialStartError = new Error('transport start failed');
379
+ const { stdin, lastFrame } = renderApp();
380
+ await waitForFrame(lastFrame, (frame) =>
381
+ frame.includes('TUI start failed: transport start failed'),
382
+ );
383
+ expect(lastFrame()).toContain('Press Enter to retry.');
384
+
385
+ stdin.write('\r');
386
+ await waitForFrame(lastFrame, () => created[0]!.start.mock.calls.length === 2);
387
+ await tick();
388
+ expect(createChannel).toHaveBeenCalledTimes(1);
389
+ expect(lastFrame()).toContain('Select a session to resume');
390
+ });
391
+
392
+ it('REFACTOR-025: ignores workspace shortcuts while recovery owns input', async () => {
393
+ initialStartError = new Error('transport start failed');
394
+ const { stdin, lastFrame } = renderApp({ showSessionPickerOnStart: false });
395
+ await waitForFrame(lastFrame, (frame) =>
396
+ frame.includes('TUI start failed: transport start failed'),
397
+ );
398
+
399
+ stdin.write('\x02'); // Ctrl+B must not mutate hidden overlay state.
400
+ stdin.write('\r');
401
+ await waitForFrame(lastFrame, () => created[0]!.start.mock.calls.length === 2);
402
+ await tick();
403
+
404
+ expect(lastFrame()).not.toContain('Execution workspace');
405
+ });
406
+
407
+ it('REFACTOR-025: subscribes to UI events before channel startup', async () => {
408
+ initialStartIntent = true;
409
+ const { lastFrame } = renderApp({ showSessionPickerOnStart: false });
410
+
411
+ await tick(100);
412
+ expect(lastFrame()).toContain('Select a session to resume');
413
+ });
414
+
415
+ it('TC-05: consecutive switches A→B→C create one channel per switch and stop each prior channel', async () => {
416
+ // Selection always takes the top (newest) entry; arrow-key navigation itself
417
+ // is covered by ListPicker.test.tsx. updatedAt ordering decides the target.
418
+ const ids = ['aaaaaaaa-1111', 'bbbbbbbb-2222', 'cccccccc-3333'];
419
+ const { stdin, lastFrame, records } = renderApp({ sessionIds: ids });
420
+ touch(records, 'aaaaaaaa-1111', '2026-06-13T01:00:00.000Z'); // A on top
421
+ await waitForSessionPicker(lastFrame);
422
+
423
+ // Mount creates the initial channel (factory call 1, undefined).
424
+ expect(createChannel).toHaveBeenNthCalledWith(1, undefined);
425
+ const channelInitial = created[0]!;
426
+
427
+ // Switch 1: pick A (top) from the startup picker.
428
+ stdin.write('\r');
429
+ await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 2);
430
+ expect(createChannel).toHaveBeenNthCalledWith(2, 'aaaaaaaa-1111');
431
+ expect(channelInitial.stop).toHaveBeenCalled();
432
+ const channelA = created[1]!;
433
+ await tick(); // allow the replacement view's session-event subscription to attach
434
+
435
+ // Switch 2: reopen the picker via the requester-routed ui_intent (real /resume path since
436
+ // CMD-004 Stage C: the session emits `ui_intent` and the owner surface renders it).
437
+ touch(records, 'bbbbbbbb-2222', '2026-06-13T02:00:00.000Z'); // B on top
438
+ channelA.emitSessionEvent('ui_intent', {
439
+ intent: { type: 'show-session-picker' },
440
+ requesterDriverId: 'owner',
441
+ });
442
+ await waitForFrame(lastFrame, (f) => f.includes('> bbbbbbbb'));
443
+ await tick(); // settle: let the reopened picker's useInput subscription attach
444
+ stdin.write('\r');
445
+ await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 3);
446
+ expect(createChannel).toHaveBeenNthCalledWith(3, 'bbbbbbbb-2222');
447
+ expect(channelA.stop).toHaveBeenCalled();
448
+ const channelB = created[2]!;
449
+ expect(channelB.start).toHaveBeenCalled();
450
+ await tick(); // allow the replacement view's session-event subscription to attach
451
+
452
+ // Switch 3: same drill from B to C.
453
+ touch(records, 'cccccccc-3333', '2026-06-13T03:00:00.000Z'); // C on top
454
+ channelB.emitSessionEvent('ui_intent', {
455
+ intent: { type: 'show-session-picker' },
456
+ requesterDriverId: 'owner',
457
+ });
458
+ await waitForFrame(lastFrame, (f) => f.includes('> cccccccc'));
459
+ await tick(); // settle: let the reopened picker's useInput subscription attach
460
+ stdin.write('\r');
461
+ await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 4);
462
+ expect(createChannel).toHaveBeenNthCalledWith(4, 'cccccccc-3333');
463
+ expect(channelB.stop).toHaveBeenCalled();
464
+
465
+ const channelC = created[3]!;
466
+ expect(channelC.start).toHaveBeenCalled();
467
+ expect(channelC.stop).not.toHaveBeenCalled();
468
+ expect(createChannel).toHaveBeenCalledTimes(4);
469
+ });
470
+ });