@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,229 @@
1
+ /**
2
+ * TUI-owned PTY harness (TEST-007; relocated internally by BOUNDARY-2655).
3
+ *
4
+ * One reusable pseudo-terminal driver for end-to-end tests. Spawns a process in a real PTY (via
5
+ * the prebuilt `@homebridge/node-pty-prebuilt-multiarch`, so Ink renders and reads input exactly as
6
+ * in a user terminal) and exposes per-key paced input plus marker/exit waiting. Two convenience
7
+ * spawners build on the core:
8
+ *
9
+ * - `spawnPty` — drive any command (e.g. the built CLI binary).
10
+ * - `spawnPtyFixture` — drive a TSX fixture through the `tsx/esm` import hook (no build step),
11
+ * the pattern used for focused source-level E2E.
12
+ *
13
+ * Per-key pacing matters: a burst write is bundled by the terminal as a bracketed paste, which is the
14
+ * exact failure mode this harness exists to avoid.
15
+ */
16
+ import { createRequire } from 'node:module';
17
+
18
+ import { spawn } from '@homebridge/node-pty-prebuilt-multiarch';
19
+
20
+ import { createPtyEnv } from './isolated-home.js';
21
+
22
+ import type { IPty } from '@homebridge/node-pty-prebuilt-multiarch';
23
+
24
+ // eslint-disable-next-line no-control-regex
25
+ const ANSI_PATTERN = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][B0]|[\x00-\x08\x0b-\x1f]/g;
26
+
27
+ const DEFAULT_PER_KEY_DELAY_MS = 35;
28
+ const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
29
+ const DEFAULT_EXIT_TIMEOUT_MS = 10_000;
30
+ const OUTPUT_TAIL_LENGTH = 2000;
31
+ /** CORE-023: grace before escalating a lingering PTY child from SIGTERM to SIGKILL. */
32
+ const PTY_KILL_GRACE_MS = 2000;
33
+
34
+ export interface IPtyRunOptions {
35
+ command: string;
36
+ args: string[];
37
+ cwd: string;
38
+ /**
39
+ * Environment for the child. Defaults to {@link createPtyEnv} — a minimal env with an ISOLATED
40
+ * HOME. Build overrides with `createPtyEnv({ ... })` rather than by hand, so a caller cannot
41
+ * reintroduce the real `process.env.HOME` (which makes the suite depend on the host's `~`).
42
+ */
43
+ env?: NodeJS.ProcessEnv;
44
+ cols?: number;
45
+ rows?: number;
46
+ }
47
+
48
+ export interface IPtyRunSession {
49
+ /** Type text one key at a time (default 35ms/key — human-ish, avoids paste bundling). */
50
+ sendKeys(text: string, perKeyDelayMs?: number): Promise<void>;
51
+ /** Write bytes verbatim (no pacing) — for control sequences / single keystrokes. */
52
+ write(data: string): void;
53
+ /** Press Enter as a single keystroke (with small settle on either side). */
54
+ pressEnter(): Promise<void>;
55
+ /** Wait until the ANSI-stripped output matches; throws with a snapshot on timeout. */
56
+ waitFor(pattern: RegExp | string, timeoutMs?: number): Promise<void>;
57
+ /**
58
+ * Current length of the ANSI-stripped output — a mark for `waitForSince`/`snapshotSince`.
59
+ * The stripped output is CUMULATIVE (a transcript, not the current screen): a plain `waitFor`
60
+ * also matches old frames and the echo of your own typed input. Take a mark before acting, then
61
+ * assert only on what arrived after it.
62
+ */
63
+ outputOffset(): number;
64
+ /** Like `waitFor`, but matches only output that arrived after the `since` mark. */
65
+ waitForSince(since: number, pattern: RegExp | string, timeoutMs?: number): Promise<void>;
66
+ /** ANSI-stripped output that arrived after the `since` mark. */
67
+ snapshotSince(since: number): string;
68
+ /** Current ANSI-stripped output. */
69
+ snapshot(): string;
70
+ /** Current raw (un-stripped) output. */
71
+ raw(): string;
72
+ /** Wait for process exit; throws with a snapshot on timeout. */
73
+ expectExit(timeoutMs?: number): Promise<number>;
74
+ /** Force-kill / cleanup (no-op if already exited). */
75
+ dispose(): void;
76
+ }
77
+
78
+ function sleep(ms: number): Promise<void> {
79
+ return new Promise((resolve) => setTimeout(resolve, ms));
80
+ }
81
+
82
+ function toRegExp(pattern: RegExp | string): RegExp {
83
+ if (pattern instanceof RegExp) return pattern;
84
+ // Escape regex metacharacters so a plain string is matched literally.
85
+ return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
86
+ }
87
+
88
+ /** Spawn any command in a real PTY and return a driving session. */
89
+ export function spawnPty(options: IPtyRunOptions): IPtyRunSession {
90
+ let output = '';
91
+ let exitCode: number | undefined;
92
+
93
+ const pty: IPty = spawn(options.command, options.args, {
94
+ name: 'xterm-256color',
95
+ cols: options.cols ?? 100,
96
+ rows: options.rows ?? 32,
97
+ cwd: options.cwd,
98
+ // HARNESS-025: the default HOME is a throwaway directory, never the developer's real one — a
99
+ // Configured state and HOME are both isolated so the run cannot depend on the developer profile.
100
+ env: createPtyEnv(options.env ?? {}),
101
+ });
102
+
103
+ pty.onData((data) => {
104
+ output += data;
105
+ });
106
+ pty.onExit(({ exitCode: code }) => {
107
+ exitCode = code;
108
+ });
109
+
110
+ const stripped = (): string => output.replace(ANSI_PATTERN, '');
111
+
112
+ return {
113
+ async sendKeys(text: string, perKeyDelayMs = DEFAULT_PER_KEY_DELAY_MS): Promise<void> {
114
+ for (const ch of text) {
115
+ pty.write(ch);
116
+ await sleep(perKeyDelayMs);
117
+ }
118
+ },
119
+ write(data: string): void {
120
+ pty.write(data);
121
+ },
122
+ async pressEnter(): Promise<void> {
123
+ await sleep(120);
124
+ pty.write('\r');
125
+ await sleep(120);
126
+ },
127
+ async waitFor(pattern: RegExp | string, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS): Promise<void> {
128
+ const regex = toRegExp(pattern);
129
+ const deadline = Date.now() + timeoutMs;
130
+ while (Date.now() < deadline) {
131
+ if (regex.test(stripped())) return;
132
+ if (exitCode !== undefined && regex.test(stripped())) return;
133
+ await sleep(50);
134
+ }
135
+ throw new Error(
136
+ `PTY waitFor timeout (${timeoutMs}ms) for ${String(regex)}\n--- snapshot ---\n${stripped().slice(-OUTPUT_TAIL_LENGTH)}`,
137
+ );
138
+ },
139
+ outputOffset(): number {
140
+ return stripped().length;
141
+ },
142
+ async waitForSince(
143
+ since: number,
144
+ pattern: RegExp | string,
145
+ timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
146
+ ): Promise<void> {
147
+ const regex = toRegExp(pattern);
148
+ const deadline = Date.now() + timeoutMs;
149
+ while (Date.now() < deadline) {
150
+ if (regex.test(stripped().slice(since))) return;
151
+ await sleep(50);
152
+ }
153
+ throw new Error(
154
+ `PTY waitForSince timeout (${timeoutMs}ms) for ${String(regex)} after offset ${since}\n--- snapshot since mark ---\n${stripped().slice(since).slice(-OUTPUT_TAIL_LENGTH)}`,
155
+ );
156
+ },
157
+ snapshotSince(since: number): string {
158
+ return stripped().slice(since);
159
+ },
160
+ snapshot: stripped,
161
+ raw: (): string => output,
162
+ async expectExit(timeoutMs = DEFAULT_EXIT_TIMEOUT_MS): Promise<number> {
163
+ const deadline = Date.now() + timeoutMs;
164
+ while (Date.now() < deadline) {
165
+ if (exitCode !== undefined) return exitCode;
166
+ await sleep(50);
167
+ }
168
+ throw new Error(
169
+ `PTY process did not exit within ${timeoutMs}ms\n--- snapshot ---\n${stripped().slice(-OUTPUT_TAIL_LENGTH)}`,
170
+ );
171
+ },
172
+ dispose(): void {
173
+ if (exitCode !== undefined) return;
174
+ try {
175
+ pty.kill();
176
+ } catch {
177
+ // allow-fallback: process already exited — kill on a dead pty is a no-op by design
178
+ }
179
+ // CORE-023: mirror the killProcessTree escalation pattern — a child that ignores SIGTERM
180
+ // is force-killed after the grace window. Keep this test-local lifecycle implementation
181
+ // independent of @robota-sdk/agent-process.
182
+ const graceTimer = setTimeout(() => {
183
+ if (exitCode === undefined) {
184
+ try {
185
+ pty.kill('SIGKILL');
186
+ } catch {
187
+ // allow-fallback: process exited during the grace window — SIGKILL is a no-op
188
+ }
189
+ }
190
+ }, PTY_KILL_GRACE_MS);
191
+ graceTimer.unref?.();
192
+ },
193
+ };
194
+ }
195
+
196
+ export interface ISpawnFixtureOptions {
197
+ /** Extra argv passed to the fixture after its path (e.g. an output JSON path). */
198
+ argv?: string[];
199
+ cwd: string;
200
+ env?: NodeJS.ProcessEnv;
201
+ cols?: number;
202
+ rows?: number;
203
+ }
204
+
205
+ let tsxEsmHookPath: string | undefined;
206
+ function resolveTsxEsmHook(): string {
207
+ // Lazily resolved so built-CLI consumers (which never run a TSX fixture) don't require tsx.
208
+ tsxEsmHookPath ??= createRequire(import.meta.url).resolve('tsx/esm');
209
+ return tsxEsmHookPath;
210
+ }
211
+
212
+ /**
213
+ * Spawn a TSX fixture inside a PTY via the `tsx/esm` import hook (no build step). The fixture's own
214
+ * relative imports resolve from its absolute path; `cwd` controls module resolution for bare
215
+ * specifiers (point it at the package dir or repo root).
216
+ */
217
+ export function spawnPtyFixture(
218
+ fixturePath: string,
219
+ options: ISpawnFixtureOptions,
220
+ ): IPtyRunSession {
221
+ return spawnPty({
222
+ command: process.execPath,
223
+ args: ['--import', resolveTsxEsmHook(), fixturePath, ...(options.argv ?? [])],
224
+ cwd: options.cwd,
225
+ ...(options.env ? { env: options.env } : {}),
226
+ ...(options.cols !== undefined ? { cols: options.cols } : {}),
227
+ ...(options.rows !== undefined ? { rows: options.rows } : {}),
228
+ });
229
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * TERM-003 / TEST-007: real-binary terminal-handoff evidence.
3
+ *
4
+ * Runs in the dedicated PTY vitest project (vitest.pty.config.ts) against the BUILT CLI binary
5
+ * (`pnpm --filter @robota-sdk/agent-cli build` first). Unlike the source-level fixture E2E
6
+ * (`terminal-handoff-pty-e2e` / `command-handoff-pty-e2e`), this drives the WHOLE user path —
7
+ * real CLI → real command pipeline → real `TerminalHandoffController` injected by `render.tsx` →
8
+ * `/shell` — through a real pseudo-terminal, simulating a user typing. It is automated
9
+ * user-execution-level proof: the child runs on the real terminal during the handoff and the TUI
10
+ * frame restores afterward (machine proxy for "no stale frame / clean resume").
11
+ */
12
+
13
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
+
19
+ import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
20
+
21
+ import type { IPtySession } from './pty-driver.js';
22
+
23
+ describe('terminal handoff through the real binary (TERM-003)', () => {
24
+ let projectDir: string;
25
+ let session: IPtySession | undefined;
26
+
27
+ beforeEach(() => {
28
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'agent-handoff-pty-')));
29
+ writeTuiProviderSettings(projectDir);
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await session?.disposeAsync();
34
+ session = undefined;
35
+ rmSync(projectDir, { recursive: true, force: true });
36
+ });
37
+
38
+ it('TC-09: /shell runs a one-shot command on the real terminal and the TUI restores', async () => {
39
+ session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
40
+
41
+ // Boot.
42
+ await session.waitFor(/Type a message or \/help/);
43
+ await session.waitFor(/Idle/);
44
+
45
+ // Run a one-shot shell command. A user-typed command is not permission-gated (that gate is for
46
+ // model/agent-invoked actions), so it runs directly through the handoff.
47
+ await session.sendKeys('/shell echo HANDOFF_REAL_OK');
48
+ await session.pressEnter();
49
+
50
+ // The child's output appears on the real (inherited) terminal during the handoff.
51
+ await session.waitFor(/HANDOFF_REAL_OK/, 20_000);
52
+
53
+ // The TUI reclaims the screen: the prompt frame redraws (clean resume, no hang).
54
+ await session.waitFor(/Type a message or \/help/, 20_000);
55
+ }, 60_000);
56
+
57
+ it('TC-04/TC-01: committed history (Static) is not re-printed after a /shell handoff (SCREEN-010 × TERM-002)', async () => {
58
+ session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
59
+
60
+ await session.waitFor(/Type a message or \/help/);
61
+ await session.waitFor(/Idle/);
62
+
63
+ // The startup banner is committed to the terminal scrollback via a single Ink <Static> (TC-01).
64
+ // Count its version line; if the handoff resume re-emitted committed history, it would re-print.
65
+ const versionRe = /v\d+\.\d+\.\d+/g;
66
+ const beforeCount = (session.snapshot().match(versionRe) ?? []).length;
67
+ expect(beforeCount).toBeGreaterThanOrEqual(1);
68
+
69
+ await session.sendKeys('/shell echo HANDOFF_OK');
70
+ await session.pressEnter();
71
+ await session.waitFor(/HANDOFF_OK/, 20_000);
72
+ await session.waitFor(/Type a message or \/help/, 20_000);
73
+
74
+ // SCREEN-010 keeps <Static> mounted at a stable tree position across suspend/resume, so the
75
+ // committed banner is NOT re-printed on return (TC-04).
76
+ const afterCount = (session.snapshot().match(versionRe) ?? []).length;
77
+ expect(afterCount).toBe(beforeCount);
78
+ }, 60_000);
79
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Issue #3268 — an interactive start in an untrusted repository asks whether to trust it, on the
3
+ * BUILT binary in a real PTY. A yes records the grant and starts Trusted, so the next start does not
4
+ * ask; a no starts Restricted and records nothing, so the next start asks again.
5
+ *
6
+ * Runs under `test:pty` against the built CLI (`pnpm --filter @robota-sdk/agent-cli build` first).
7
+ */
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ realpathSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ import { afterEach, describe, expect, it } from 'vitest';
23
+
24
+ import { spawnTui } from './pty-driver.js';
25
+
26
+ import type { IPtySession } from './pty-driver.js';
27
+
28
+ const WAIT_MS = 20_000;
29
+ const CASE_TIMEOUT_MS = 120_000;
30
+ const QUESTION = /Trust this folder\? \[y\/N\]/;
31
+ const READY = /Type a message or \/help/;
32
+
33
+ interface IFixture {
34
+ readonly root: string;
35
+ readonly home: string;
36
+ readonly repo: string;
37
+ }
38
+
39
+ function makeFixture(): IFixture {
40
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'agent-trust-prompt-')));
41
+ const home = join(root, 'home');
42
+ const repo = join(root, 'repo');
43
+ mkdirSync(join(home, 'state'), { recursive: true });
44
+ // Skip the first-run welcome and provider setup: neither is what this scenario observes.
45
+ writeFileSync(join(home, 'state', 'onboarded'), new Date().toISOString(), 'utf8');
46
+ writeFileSync(
47
+ join(home, 'state', 'settings.json'),
48
+ JSON.stringify({
49
+ currentProvider: 'anthropic',
50
+ providers: {
51
+ anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
52
+ },
53
+ }),
54
+ 'utf8',
55
+ );
56
+ execFileSync('git', ['init', '-q', repo]);
57
+ return { root, home, repo };
58
+ }
59
+
60
+ function trustedGrants(fixture: IFixture): number {
61
+ const store = join(fixture.home, 'state', 'workspace-trust.json');
62
+ if (!existsSync(store)) return 0;
63
+ const parsed = JSON.parse(readFileSync(store, 'utf8')) as { grants: { state: string }[] };
64
+ return parsed.grants.filter((grant) => grant.state === 'trusted').length;
65
+ }
66
+
67
+ async function answerAndExit(session: IPtySession, answer: 'y' | 'n'): Promise<void> {
68
+ await session.waitFor(QUESTION, WAIT_MS);
69
+ await session.sendKeys(answer);
70
+ await session.pressEnter();
71
+ await session.waitFor(READY, WAIT_MS);
72
+ await session.sendKeys('/exit');
73
+ await session.pressEnter();
74
+ await session.waitFor(/Exit the session\?/, WAIT_MS);
75
+ await session.pressEnter();
76
+ await session.expectExit(WAIT_MS);
77
+ }
78
+
79
+ describe('the trust question at an interactive start (issue #3268)', () => {
80
+ let fixture: IFixture | undefined;
81
+ let session: IPtySession | undefined;
82
+
83
+ afterEach(async () => {
84
+ await session?.disposeAsync();
85
+ session = undefined;
86
+ if (fixture) rmSync(fixture.root, { recursive: true, force: true });
87
+ fixture = undefined;
88
+ });
89
+
90
+ it(
91
+ 'a yes records the grant, and the next start is Trusted without asking',
92
+ async () => {
93
+ fixture = makeFixture();
94
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
95
+ await answerAndExit(session, 'y');
96
+ expect(trustedGrants(fixture)).toBe(1);
97
+
98
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
99
+ await session.waitFor(READY, WAIT_MS);
100
+ expect(session.snapshot()).not.toMatch(QUESTION);
101
+ },
102
+ CASE_TIMEOUT_MS,
103
+ );
104
+
105
+ it.each([
106
+ ['--safe-mode', ['--safe-mode']],
107
+ // A resumed or continued session keeps the store it was saved in, so it is not asked.
108
+ ['--continue', ['--continue']],
109
+ ])(
110
+ 'does not ask with %s',
111
+ async (_case, args) => {
112
+ fixture = makeFixture();
113
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home, args: [...args] });
114
+ await session.waitFor(READY, WAIT_MS);
115
+ expect(session.snapshot()).not.toMatch(QUESTION);
116
+ expect(trustedGrants(fixture)).toBe(0);
117
+ },
118
+ CASE_TIMEOUT_MS,
119
+ );
120
+
121
+ it(
122
+ 'a no starts Restricted, records nothing, and the next start asks again',
123
+ async () => {
124
+ fixture = makeFixture();
125
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
126
+ await answerAndExit(session, 'n');
127
+ expect(trustedGrants(fixture)).toBe(0);
128
+
129
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
130
+ await session.waitFor(QUESTION, WAIT_MS);
131
+ },
132
+ CASE_TIMEOUT_MS,
133
+ );
134
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Real-PTY TUI suites (CLI-074 TC-07/08).
3
+ *
4
+ * Runs in the dedicated PTY vitest project (vitest.pty.config.ts) against the
5
+ * BUILT CLI binary — `pnpm --filter @robota-sdk/agent-cli build` first.
6
+ */
7
+
8
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
13
+
14
+ import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
15
+
16
+ import type { IPtySession } from './pty-driver.js';
17
+
18
+ describe('TUI through a real PTY (CLI-074)', () => {
19
+ let projectDir: string;
20
+ let session: IPtySession | undefined;
21
+
22
+ beforeEach(() => {
23
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'agent-pty-')));
24
+ writeTuiProviderSettings(projectDir);
25
+ });
26
+
27
+ afterEach(async () => {
28
+ await session?.disposeAsync();
29
+ session = undefined;
30
+ rmSync(projectDir, { recursive: true, force: true });
31
+ });
32
+
33
+ it('TC-07: boots, opens slash autocomplete, and executes /help as a command', async () => {
34
+ session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
35
+
36
+ // Boot: prompt + status bar render.
37
+ await session.waitFor(/Type a message or \/help/);
38
+ await session.waitFor(/Idle/);
39
+
40
+ // '/' opens the autocomplete dropdown listing commands.
41
+ await session.sendKeys('/');
42
+ await session.waitFor(/\/help\s+Show available commands/);
43
+
44
+ // Typing the rest at human key rate must stay a command, not a paste.
45
+ await session.sendKeys('help');
46
+ await session.pressEnter();
47
+ await session.waitFor(/Available commands|\/cost|\/clear/i, 20_000);
48
+ expect(session.snapshot()).not.toContain('[Pasted text');
49
+ }, 60_000);
50
+
51
+ it('TC-08: /exit confirms then reaches process exit within 10s', async () => {
52
+ session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
53
+ await session.waitFor(/Type a message or \/help/);
54
+
55
+ await session.sendKeys('/exit');
56
+ await session.pressEnter();
57
+
58
+ // CMD-004: /exit now asks for confirmation; answer Yes (the default-highlighted option).
59
+ await session.waitFor(/Exit the session\?/);
60
+ await session.pressEnter();
61
+
62
+ const exitCode = await session.expectExit(10_000);
63
+ expect(exitCode).toBe(0);
64
+ }, 60_000);
65
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * INFRA-026 TC-01 — `killAndAwaitExit` (the compose behind the PTY driver's `disposeAsync`) awaits
3
+ * ACTUAL child exit and rejects loudly on non-exit. Unit-level with a fake session (no real PTY), so
4
+ * it runs in the default vitest project rather than the `*.ptytest` built-CLI project.
5
+ */
6
+ import { describe, expect, it, vi } from 'vitest';
7
+
8
+ import { killAndAwaitExit } from './pty/pty-driver.js';
9
+
10
+ describe('killAndAwaitExit (INFRA-026 teardown compose)', () => {
11
+ it('signals the child (dispose) then resolves once expectExit resolves', async () => {
12
+ const order: string[] = [];
13
+ const session = {
14
+ dispose: vi.fn(() => {
15
+ order.push('dispose');
16
+ }),
17
+ expectExit: vi.fn(async (): Promise<number> => {
18
+ order.push('expectExit');
19
+ return 0;
20
+ }),
21
+ };
22
+
23
+ await expect(killAndAwaitExit(session, 1000)).resolves.toBeUndefined();
24
+
25
+ expect(session.dispose).toHaveBeenCalledTimes(1);
26
+ expect(session.expectExit).toHaveBeenCalledWith(1000);
27
+ // dispose must be signalled BEFORE we await exit.
28
+ expect(order).toEqual(['dispose', 'expectExit']);
29
+ });
30
+
31
+ it('rejects (does not swallow) when the child does not exit within the timeout', async () => {
32
+ const session = {
33
+ dispose: vi.fn(),
34
+ expectExit: vi.fn(async (): Promise<number> => {
35
+ throw new Error('PTY process did not exit within 1000ms');
36
+ }),
37
+ };
38
+
39
+ await expect(killAndAwaitExit(session, 1000)).rejects.toThrow(/did not exit/);
40
+ // Still signalled the child — the failure is a loud teardown defect, not a masked one.
41
+ expect(session.dispose).toHaveBeenCalledTimes(1);
42
+ });
43
+ });