@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,200 @@
1
+ /**
2
+ * CLI-2004 TC-06 / TC-07 — arrow-key menus become numbered lists with a typed answer.
3
+ *
4
+ * Position is the thing a reader cannot get: the `> ` cursor and the accent colour say which row is
5
+ * selected, and neither survives being read aloud. A spoken number does.
6
+ */
7
+
8
+ import { render } from 'ink-testing-library';
9
+ import React from 'react';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+
12
+ import ConfirmPrompt, { CONFIRM_PROMPT_TYPED_LITERAL } from '../ConfirmPrompt.js';
13
+ import MenuSelect from '../MenuSelect.js';
14
+ import { NUMBERED_LIST_CANCEL_SUFFIX } from '../numbered-list.js';
15
+ import PermissionPrompt from '../PermissionPrompt.js';
16
+ import { ScreenReaderProvider } from '../screen-reader-context.js';
17
+
18
+ import type { IPendingPermissionRequest } from '../types.js';
19
+
20
+ /** Ink needs a tick to deliver stdin and flush a frame. */
21
+ async function tick(): Promise<void> {
22
+ await new Promise((resolve) => setTimeout(resolve, 20));
23
+ }
24
+
25
+ function renderInMode(element: React.ReactElement): ReturnType<typeof render> {
26
+ return render(<ScreenReaderProvider enabled={true}>{element}</ScreenReaderProvider>);
27
+ }
28
+
29
+ const ITEMS = [
30
+ { label: 'First option', value: 'a' },
31
+ { label: 'Second option', value: 'b' },
32
+ { label: 'Third option', value: 'c' },
33
+ ];
34
+
35
+ describe('TC-06: MenuSelect as a numbered list', () => {
36
+ it('renders `1. `, `2. `, `3. ` rows and the authored prompt literal', async () => {
37
+ const { lastFrame, unmount } = renderInMode(
38
+ <MenuSelect title="Pick one" items={ITEMS} onSelect={vi.fn()} onBack={vi.fn()} />,
39
+ );
40
+ await tick();
41
+ const frame = lastFrame() ?? '';
42
+ unmount();
43
+
44
+ expect(frame).toContain('1. First option');
45
+ expect(frame).toContain('2. Second option');
46
+ expect(frame).toContain('3. Third option');
47
+ expect(frame).toContain('Enter selection (1-3)');
48
+ expect(frame).not.toContain('> ');
49
+ expect(frame).not.toMatch(/[╭╮╰╯│─]/u);
50
+ });
51
+
52
+ it('a cancellable menu names Escape in the same literal', async () => {
53
+ const { lastFrame, unmount } = renderInMode(
54
+ <MenuSelect title="Pick one" items={ITEMS} onSelect={vi.fn()} onBack={vi.fn()} />,
55
+ );
56
+ await tick();
57
+ const frame = lastFrame() ?? '';
58
+ unmount();
59
+
60
+ expect(frame).toContain(`Enter selection (1-3)${NUMBERED_LIST_CANCEL_SUFFIX}`);
61
+ });
62
+
63
+ it('typing `2` then Enter selects the SECOND option', async () => {
64
+ const onSelect = vi.fn();
65
+ const { stdin, unmount } = renderInMode(
66
+ <MenuSelect title="Pick one" items={ITEMS} onSelect={onSelect} onBack={vi.fn()} />,
67
+ );
68
+ await tick();
69
+ stdin.write('2');
70
+ await tick();
71
+ stdin.write('\r');
72
+ await tick();
73
+ unmount();
74
+
75
+ expect(onSelect).toHaveBeenCalledTimes(1);
76
+ expect(onSelect).toHaveBeenCalledWith('b');
77
+ });
78
+
79
+ it('typing `9` then Enter selects nothing and re-prints the same literal', async () => {
80
+ const onSelect = vi.fn();
81
+ const { stdin, lastFrame, unmount } = renderInMode(
82
+ <MenuSelect title="Pick one" items={ITEMS} onSelect={onSelect} onBack={vi.fn()} />,
83
+ );
84
+ await tick();
85
+ stdin.write('9');
86
+ await tick();
87
+ stdin.write('\r');
88
+ await tick();
89
+ const frame = lastFrame() ?? '';
90
+ unmount();
91
+
92
+ expect(onSelect).not.toHaveBeenCalled();
93
+ // The range IS the correction: the same literal comes back, twice in the frame.
94
+ expect(frame.split('Enter selection (1-3)').length - 1).toBeGreaterThanOrEqual(2);
95
+ });
96
+
97
+ it('keeps the bordered arrow-key menu when the mode is off', async () => {
98
+ const { lastFrame, unmount } = render(
99
+ <MenuSelect title="Pick one" items={ITEMS} onSelect={vi.fn()} onBack={vi.fn()} />,
100
+ );
101
+ await tick();
102
+ const frame = lastFrame() ?? '';
103
+ unmount();
104
+
105
+ expect(frame).toContain('> First option');
106
+ expect(frame).not.toContain('Enter selection');
107
+ expect(frame).toMatch(/[╭╮╰╯]/u);
108
+ });
109
+ });
110
+
111
+ describe('TC-07: PermissionPrompt and ConfirmPrompt', () => {
112
+ function permissionRequest(resolve: (value: unknown) => void): IPendingPermissionRequest {
113
+ return {
114
+ toolName: 'Bash',
115
+ toolArgs: { command: 'ls -la' },
116
+ resolve,
117
+ } as unknown as IPendingPermissionRequest;
118
+ }
119
+
120
+ it('renders the permission ask as a numbered list with no border', async () => {
121
+ const { lastFrame, unmount } = renderInMode(
122
+ <PermissionPrompt request={permissionRequest(vi.fn())} />,
123
+ );
124
+ await tick();
125
+ const frame = lastFrame() ?? '';
126
+ unmount();
127
+
128
+ expect(frame).toContain('permission required:');
129
+ expect(frame).toContain('1. Allow');
130
+ expect(frame).toContain('Enter selection (1-4)');
131
+ expect(frame).not.toMatch(/[╭╮╰╯│─]/u);
132
+ expect(frame).not.toContain('> ');
133
+ });
134
+
135
+ it('a typed number resolves the permission ask', async () => {
136
+ const resolve = vi.fn();
137
+ const { stdin, unmount } = renderInMode(
138
+ <PermissionPrompt request={permissionRequest(resolve)} armDelayMs={0} />,
139
+ );
140
+ await tick();
141
+ stdin.write('4');
142
+ await tick();
143
+ stdin.write('\r');
144
+ await tick();
145
+ unmount();
146
+
147
+ // Option 4 is Deny.
148
+ expect(resolve).toHaveBeenCalledWith(false);
149
+ });
150
+
151
+ it('ConfirmPrompt accepts `y` + Enter', async () => {
152
+ const onSelect = vi.fn();
153
+ const { stdin, lastFrame, unmount } = renderInMode(
154
+ <ConfirmPrompt message="Proceed?" onSelect={onSelect} />,
155
+ );
156
+ await tick();
157
+ expect(lastFrame() ?? '').toContain(CONFIRM_PROMPT_TYPED_LITERAL);
158
+ stdin.write('y');
159
+ await tick();
160
+ stdin.write('\r');
161
+ await tick();
162
+ unmount();
163
+
164
+ expect(onSelect).toHaveBeenCalledWith(0);
165
+ });
166
+
167
+ it('ConfirmPrompt accepts `no` + Enter', async () => {
168
+ const onSelect = vi.fn();
169
+ const { stdin, unmount } = renderInMode(
170
+ <ConfirmPrompt message="Proceed?" onSelect={onSelect} />,
171
+ );
172
+ await tick();
173
+ stdin.write('n');
174
+ await tick();
175
+ stdin.write('o');
176
+ await tick();
177
+ stdin.write('\r');
178
+ await tick();
179
+ unmount();
180
+
181
+ expect(onSelect).toHaveBeenCalledWith(1);
182
+ });
183
+
184
+ it('ConfirmPrompt rejects anything else without selecting', async () => {
185
+ const onSelect = vi.fn();
186
+ const { stdin, unmount } = renderInMode(
187
+ <ConfirmPrompt message="Proceed?" onSelect={onSelect} />,
188
+ );
189
+ await tick();
190
+ stdin.write('m');
191
+ await tick();
192
+ stdin.write('a');
193
+ await tick();
194
+ stdin.write('\r');
195
+ await tick();
196
+ unmount();
197
+
198
+ expect(onSelect).not.toHaveBeenCalled();
199
+ });
200
+ });
@@ -0,0 +1,230 @@
1
+ /**
2
+ * CLI-2004 TC-20 — the startup quiet period.
3
+ *
4
+ * The bound matters more than the default: a mistyped host timing override is
5
+ * how a session appears to hang forever, and a silently-swallowed value is how it appears to have no
6
+ * effect at all. Every rejection and every clamp is REPORTED, and this asserts the report.
7
+ *
8
+ * The last case is the one the review added: "any keypress ends the wait" is only true if something
9
+ * puts a TTY stdin into raw mode first, because a canonical-mode terminal delivers nothing until
10
+ * Enter. Asserting it against a synthetic source alone would have proved a behaviour the product
11
+ * does not have.
12
+ */
13
+
14
+ import { describe, expect, it, vi } from 'vitest';
15
+
16
+ import {
17
+ awaitStartupQuietPeriod,
18
+ DEFAULT_PREPARK_MS,
19
+ DEFAULT_STARTUP_QUIET_MS,
20
+ PREPARK_MS_MAX,
21
+ resolvePacing,
22
+ STARTUP_QUIET_MS_MAX,
23
+ } from '../screen-reader-pacing.js';
24
+
25
+ const STARTUP_LABEL = 'ACME_SCREEN_READER_STARTUP_QUIET_MS';
26
+ const PREPARK_LABEL = 'ACME_SCREEN_READER_PREPARK_MS';
27
+
28
+ function startup(raw: string) {
29
+ return { startupQuiet: { raw, label: STARTUP_LABEL } };
30
+ }
31
+
32
+ function prepark(raw: string) {
33
+ return { prepark: { raw, label: PREPARK_LABEL } };
34
+ }
35
+
36
+ function collectWarnings(): { notes: string[]; warn: (message: string) => void } {
37
+ const notes: string[] = [];
38
+ return { notes, warn: (message: string): void => void notes.push(message) };
39
+ }
40
+
41
+ describe('TC-20: resolvePacing', () => {
42
+ it('ignores ambient Fixture Agent pacing variables without a host choice', () => {
43
+ vi.stubEnv('PRODUCT_SCREEN_READER_STARTUP_QUIET_MS', '0');
44
+ vi.stubEnv('PRODUCT_SCREEN_READER_PREPARK_MS', '0');
45
+ try {
46
+ expect(resolvePacing({ enabled: true })).toEqual({
47
+ startupQuietMs: DEFAULT_STARTUP_QUIET_MS,
48
+ preparkMs: DEFAULT_PREPARK_MS,
49
+ });
50
+ } finally {
51
+ vi.unstubAllEnvs();
52
+ }
53
+ });
54
+
55
+ it('returns the documented default when the host supplies no override', () => {
56
+ expect(resolvePacing({ enabled: true })).toEqual({
57
+ startupQuietMs: DEFAULT_STARTUP_QUIET_MS,
58
+ preparkMs: DEFAULT_PREPARK_MS,
59
+ });
60
+ });
61
+
62
+ it('honours an explicit 0 exactly — the documented way to ask for no wait', () => {
63
+ expect(resolvePacing({ enabled: true, overrides: startup('0') })).toEqual({
64
+ startupQuietMs: 0,
65
+ preparkMs: DEFAULT_PREPARK_MS,
66
+ });
67
+ });
68
+
69
+ it('clamps the variable to its sanity bound AND reports the clamp', () => {
70
+ const sink = collectWarnings();
71
+ const pacing = resolvePacing({
72
+ enabled: true,
73
+ overrides: startup('999999'),
74
+ warn: sink.warn,
75
+ });
76
+
77
+ expect(pacing).toEqual({ startupQuietMs: STARTUP_QUIET_MS_MAX, preparkMs: DEFAULT_PREPARK_MS });
78
+ expect(sink.notes).toHaveLength(1);
79
+ expect(sink.notes[0]).toContain(`clamping 999999 to the ${STARTUP_QUIET_MS_MAX} ms`);
80
+ });
81
+
82
+ it('refuses a non-numeric value with a note instead of silently treating it as 0', () => {
83
+ const sink = collectWarnings();
84
+ const pacing = resolvePacing({
85
+ enabled: true,
86
+ overrides: startup('soon'),
87
+ warn: sink.warn,
88
+ });
89
+
90
+ expect(pacing.startupQuietMs).toBe(DEFAULT_STARTUP_QUIET_MS);
91
+ expect(pacing.startupQuietMs).not.toBe(0);
92
+ expect(sink.notes[0]).toContain('ignoring "soon"');
93
+ });
94
+
95
+ it('resolves the wait to 0 without warnings when the mode is off', () => {
96
+ const sink = collectWarnings();
97
+ expect(resolvePacing({
98
+ enabled: false,
99
+ overrides: startup('invalid'),
100
+ warn: sink.warn,
101
+ })).toEqual({
102
+ startupQuietMs: 0,
103
+ preparkMs: 0,
104
+ });
105
+ expect(sink.notes).toEqual([]);
106
+ });
107
+ });
108
+
109
+ describe('TC-20: the startup quiet period', () => {
110
+ it('settles at its deadline when nothing is pressed', async () => {
111
+ vi.useFakeTimers();
112
+ try {
113
+ const settled = awaitStartupQuietPeriod(500);
114
+ await vi.advanceTimersByTimeAsync(500);
115
+ await expect(settled).resolves.toBe('elapsed');
116
+ } finally {
117
+ vi.useRealTimers();
118
+ }
119
+ });
120
+
121
+ it('a keypress settles it BEFORE its deadline', async () => {
122
+ vi.useFakeTimers();
123
+ try {
124
+ let onData: (() => void) | undefined;
125
+ const keys = {
126
+ once: (_event: 'data', listener: () => void): void => void (onData = listener),
127
+ off: (): void => {},
128
+ resume: (): void => {},
129
+ pause: (): void => {},
130
+ };
131
+ const settled = awaitStartupQuietPeriod(600_000, keys);
132
+ await vi.advanceTimersByTimeAsync(10);
133
+ onData?.();
134
+ await expect(settled).resolves.toBe('keypress');
135
+ } finally {
136
+ vi.useRealTimers();
137
+ }
138
+ });
139
+
140
+ it('resolves immediately for a 0 wait without touching the keypress source', async () => {
141
+ let touched = false;
142
+ await expect(
143
+ awaitStartupQuietPeriod(0, {
144
+ once: (): void => void (touched = true),
145
+ off: (): void => {},
146
+ }),
147
+ ).resolves.toBe('elapsed');
148
+ expect(touched).toBe(false);
149
+ });
150
+
151
+ it('puts a TTY stdin into raw mode for the wait and restores it — without raw mode only Enter arrives', async () => {
152
+ vi.useFakeTimers();
153
+ try {
154
+ const modes: boolean[] = [];
155
+ let onData: (() => void) | undefined;
156
+ const keys = {
157
+ once: (_event: 'data', listener: () => void): void => void (onData = listener),
158
+ off: (): void => {},
159
+ resume: (): void => {},
160
+ pause: (): void => {},
161
+ isTTY: true,
162
+ setRawMode: (mode: boolean): void => void modes.push(mode),
163
+ };
164
+
165
+ const settled = awaitStartupQuietPeriod(600_000, keys);
166
+ expect(modes).toEqual([true]);
167
+ onData?.();
168
+ await expect(settled).resolves.toBe('keypress');
169
+ expect(modes).toEqual([true, false]);
170
+ } finally {
171
+ vi.useRealTimers();
172
+ }
173
+ });
174
+
175
+ it('leaves a non-TTY stdin alone — there is no raw mode to set on a pipe', async () => {
176
+ vi.useFakeTimers();
177
+ try {
178
+ let touchedRawMode = false;
179
+ const keys = {
180
+ once: (): void => {},
181
+ off: (): void => {},
182
+ isTTY: false,
183
+ setRawMode: (): void => void (touchedRawMode = true),
184
+ };
185
+ const settled = awaitStartupQuietPeriod(200, keys);
186
+ await vi.advanceTimersByTimeAsync(200);
187
+ await expect(settled).resolves.toBe('elapsed');
188
+ expect(touchedRawMode).toBe(false);
189
+ } finally {
190
+ vi.useRealTimers();
191
+ }
192
+ });
193
+ });
194
+
195
+ describe('SCREEN-2670 TC-01: resolvePacing returns preparkMs by the same discipline', () => {
196
+ it('defaults to the provisional 50 and honours an explicit 0 exactly', () => {
197
+ expect(resolvePacing({ enabled: true }).preparkMs).toBe(50);
198
+ expect(resolvePacing({ enabled: true, overrides: prepark('0') }).preparkMs).toBe(0);
199
+ expect(resolvePacing({ enabled: true, overrides: prepark('250') }).preparkMs).toBe(250);
200
+ });
201
+
202
+ it('refuses a non-numeric or negative value WITH a note naming the variable', () => {
203
+ for (const raw of ['soon', '-5', '1.5']) {
204
+ const sink = collectWarnings();
205
+ const pacing = resolvePacing({ enabled: true, overrides: prepark(raw), warn: sink.warn });
206
+ expect(pacing.preparkMs).toBe(DEFAULT_PREPARK_MS);
207
+ expect(sink.notes).toHaveLength(1);
208
+ expect(sink.notes[0]).toContain(PREPARK_LABEL);
209
+ expect(sink.notes[0]).toContain(`"${raw}"`);
210
+ }
211
+ });
212
+
213
+ it('clamps above the bound AND reports the clamp', () => {
214
+ const sink = collectWarnings();
215
+ const pacing = resolvePacing({
216
+ enabled: true,
217
+ overrides: prepark('99999'),
218
+ warn: sink.warn,
219
+ });
220
+ expect(pacing.preparkMs).toBe(PREPARK_MS_MAX);
221
+ expect(sink.notes[0]).toContain(`clamping 99999 to the ${PREPARK_MS_MAX} ms`);
222
+ });
223
+
224
+ it('is 0 whenever the mode is off, regardless of the variable', () => {
225
+ expect(resolvePacing({ enabled: false, overrides: prepark('250') })).toEqual({
226
+ startupQuietMs: 0,
227
+ preparkMs: 0,
228
+ });
229
+ });
230
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * SCREEN-2670 TC-10 — the echo exemption FIRES through the real render path.
3
+ *
4
+ * TC-03 arms the flag by hand, which proves the queue honours a stamp and nothing about whether
5
+ * anything ever sets one. This drives a keystroke through Ink's own input path into the composed
6
+ * tree — keybindings, the pacing context, the composer's key handler — with the proxy as Ink's
7
+ * stdout, and asserts BOTH halves: the keystroke's commit leaves unparked, and the next commit that
8
+ * is not a keystroke (a prop change from outside the composer, and Enter — whose commit appends the
9
+ * prompt line) is parked. A proxy that stamped everything would pass the first half and fail the
10
+ * second, which is why they sit in one test.
11
+ */
12
+
13
+ import { EventEmitter } from 'node:events';
14
+
15
+ import { Box, render, Text } from 'ink';
16
+ import React, { useState } from 'react';
17
+ import { afterEach, describe, expect, it } from 'vitest';
18
+
19
+ import CjkTextInput from '../CjkTextInput.js';
20
+ import { KeybindingsProvider } from '../keybindings/keybindings-context.js';
21
+ import { parseKeybindingsDocument } from '../keybindings/keybinding-registry.js';
22
+ import { ScreenReaderPacingProvider } from '../screen-reader-pacing-context.js';
23
+ import { createParkedStdout } from '../screen-reader-stdout.js';
24
+
25
+ import type { IKeybindingsSource } from '../keybindings/node-keybindings-source.js';
26
+ import type { IScreenReaderPacingPort } from '../screen-reader-pacing-context.js';
27
+ import type { TParkedStdoutSource } from '../screen-reader-stdout.js';
28
+
29
+ const PARK_MS = 400;
30
+
31
+ class FakeTtyStdout extends EventEmitter {
32
+ readonly isTTY = true;
33
+ readonly columns = 80;
34
+ readonly rows = 24;
35
+ readonly destroyed = false;
36
+ readonly writable = true;
37
+ readonly writableEnded = false;
38
+ readonly writableLength = 0;
39
+ readonly writableNeedDrain = false;
40
+ readonly frames: Array<{ text: string; at: number }> = [];
41
+ write = (chunk: string | Uint8Array, ...rest: unknown[]): boolean => {
42
+ const callback = rest.find((argument) => typeof argument === 'function') as
43
+ ((error?: Error | null) => void) | undefined;
44
+ this.frames.push({ text: String(chunk), at: Date.now() });
45
+ callback?.(null);
46
+ return true;
47
+ };
48
+ asSource(): TParkedStdoutSource {
49
+ return this as unknown as TParkedStdoutSource;
50
+ }
51
+ textSince(index: number): string {
52
+ return this.frames
53
+ .slice(index)
54
+ .map((frame) => frame.text)
55
+ .join('');
56
+ }
57
+ }
58
+
59
+ class FakeStdin extends EventEmitter {
60
+ readonly isTTY = true;
61
+ private pending: string | null = null;
62
+ setEncoding(): void {}
63
+ setRawMode(): void {}
64
+ resume(): void {}
65
+ pause(): void {}
66
+ ref(): void {}
67
+ unref(): void {}
68
+ read(): string | null {
69
+ const data = this.pending;
70
+ this.pending = null;
71
+ return data;
72
+ }
73
+ send(data: string): void {
74
+ this.pending = data;
75
+ this.emit('readable');
76
+ this.emit('data', data);
77
+ }
78
+ }
79
+
80
+ function source(): IKeybindingsSource {
81
+ const parsed = parseKeybindingsDocument(
82
+ JSON.stringify({ version: 1, bindings: {} }),
83
+ '/tmp/keybindings.json',
84
+ );
85
+ if (!parsed.ok) throw new Error(parsed.diagnostic.message);
86
+ return {
87
+ filePath: '/tmp/keybindings.json',
88
+ start: async () => undefined,
89
+ isStarted: () => true,
90
+ ensureFile: async () => '/tmp/keybindings.json',
91
+ getSnapshot: () => parsed.snapshot,
92
+ subscribe: () => () => undefined,
93
+ dispose: () => undefined,
94
+ };
95
+ }
96
+
97
+ function Harness({
98
+ banner,
99
+ onSubmit,
100
+ }: {
101
+ banner: string;
102
+ onSubmit: (value: string) => void;
103
+ }): React.ReactElement {
104
+ const [value, setValue] = useState('');
105
+ return (
106
+ <Box flexDirection="column">
107
+ <Text>{banner}</Text>
108
+ <Box>
109
+ <Text>{'> '}</Text>
110
+ <CjkTextInput
111
+ value={value}
112
+ onChange={setValue}
113
+ onSubmit={(submitted) => {
114
+ // Like the real composer: submitting clears the line, so the commit repaints.
115
+ setValue('');
116
+ onSubmit(submitted);
117
+ }}
118
+ keybindingContext="chat-input"
119
+ />
120
+ </Box>
121
+ </Box>
122
+ );
123
+ }
124
+
125
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
126
+
127
+ /** In screen-reader mode Ink linearises each Text node; compare visible characters only. */
128
+ const visible = (text: string): string =>
129
+ // eslint-disable-next-line no-control-regex -- stripping the escapes IS the point here
130
+ text.replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g, '').replace(/\s+/g, ' ');
131
+
132
+ let unmount: (() => void) | undefined;
133
+
134
+ afterEach(() => {
135
+ unmount?.();
136
+ unmount = undefined;
137
+ });
138
+
139
+ describe('TC-10: a keystroke through the composed tree is released unparked; a non-composer commit is parked', () => {
140
+ it('echo leaves at once, a banner change and Enter each wait out the interval', async () => {
141
+ const stdout = new FakeTtyStdout();
142
+ const stdin = new FakeStdin();
143
+ const parked = createParkedStdout({ stdout: stdout.asSource(), preparkMs: PARK_MS });
144
+ const port: IScreenReaderPacingPort = {
145
+ armEchoRelease: () => parked.armEchoRelease(),
146
+ write: (text) => {
147
+ parked.write(text);
148
+ },
149
+ };
150
+ const submitted: string[] = [];
151
+ const tree = (banner: string): React.ReactElement => (
152
+ <KeybindingsProvider source={source()}>
153
+ <ScreenReaderPacingProvider port={port}>
154
+ <Harness banner={banner} onSubmit={(value) => submitted.push(value)} />
155
+ </ScreenReaderPacingProvider>
156
+ </KeybindingsProvider>
157
+ );
158
+ const instance = render(tree('banner 1'), {
159
+ stdout: parked.asInkStdout(),
160
+ stdin: stdin as unknown as NodeJS.ReadStream,
161
+ isScreenReaderEnabled: true,
162
+ // Ink treats a CI environment as non-interactive and defers every frame to unmount; the
163
+ // park only exists for interactive terminals, so say so explicitly (as the real-cursor
164
+ // suite does) rather than let the runner's CI variable decide what is under test.
165
+ interactive: true,
166
+ exitOnCtrlC: false,
167
+ patchConsole: false,
168
+ });
169
+ unmount = instance.unmount;
170
+
171
+ // First frames: the first batch is never parked; let the mount settle past one interval.
172
+ await sleep(PARK_MS + 100);
173
+ expect(stdout.textSince(0)).toContain('banner 1');
174
+
175
+ // A keystroke: its echo must be on the underlying stream well inside the interval.
176
+ const beforeKey = stdout.frames.length;
177
+ stdin.send('hey');
178
+ await sleep(80);
179
+ expect(visible(stdout.textSince(beforeKey))).toContain('hey');
180
+
181
+ // A commit that is not a keystroke, arriving inside the interval after the echo: parked until
182
+ // the interval since that last printable release has elapsed, then released.
183
+ const beforeBanner = stdout.frames.length;
184
+ instance.rerender(tree('banner 2'));
185
+ await sleep(80);
186
+ expect(visible(stdout.textSince(beforeBanner))).not.toContain('banner 2');
187
+ await sleep(PARK_MS);
188
+ expect(visible(stdout.textSince(beforeBanner))).toContain('banner 2');
189
+
190
+ // Enter runs through the same key handler and must NOT arm: arriving right after the banner's
191
+ // release, the submit commit is parked like any other transcript commit.
192
+ const beforeEnter = stdout.frames.length;
193
+ stdin.send('\r');
194
+ await sleep(80);
195
+ expect(submitted).toEqual(['hey']);
196
+ expect(stdout.frames.length).toBe(beforeEnter);
197
+ await sleep(PARK_MS);
198
+ expect(stdout.frames.length).toBeGreaterThan(beforeEnter);
199
+ }, 10_000);
200
+ });