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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +27 -0
  3. package/dist/node/index.cjs +32 -0
  4. package/dist/node/index.d.cts +1066 -0
  5. package/dist/node/index.d.cts.map +1 -0
  6. package/dist/node/index.d.ts +1066 -0
  7. package/dist/node/index.d.ts.map +1 -0
  8. package/dist/node/index.js +33 -0
  9. package/dist/node/index.js.map +1 -0
  10. package/package.json +93 -0
  11. package/src/App.tsx +146 -0
  12. package/src/AppPresentation.tsx +190 -0
  13. package/src/AppView.tsx +26 -0
  14. package/src/BackgroundTaskPanel.tsx +73 -0
  15. package/src/CjkTextInput.tsx +336 -0
  16. package/src/ConfirmPrompt.tsx +140 -0
  17. package/src/ContextWarningBanner.tsx +45 -0
  18. package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
  19. package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
  20. package/src/HistorySearchOverlay.tsx +150 -0
  21. package/src/InputArea.tsx +338 -0
  22. package/src/ListPicker.tsx +158 -0
  23. package/src/MenuSelect.tsx +159 -0
  24. package/src/MessageList.tsx +308 -0
  25. package/src/MultiSelectList.tsx +294 -0
  26. package/src/PendingActionPrompt.tsx +121 -0
  27. package/src/PermissionPrompt.tsx +186 -0
  28. package/src/PluginTUI.tsx +258 -0
  29. package/src/RoleLabel.tsx +72 -0
  30. package/src/SafeText.tsx +66 -0
  31. package/src/SessionEventNotices.tsx +27 -0
  32. package/src/SessionPicker.tsx +72 -0
  33. package/src/SessionStatusBar.tsx +76 -0
  34. package/src/SlashAutocomplete.tsx +154 -0
  35. package/src/StatusBar.tsx +284 -0
  36. package/src/StreamingIndicator.tsx +145 -0
  37. package/src/SupervisedSessionView.tsx +455 -0
  38. package/src/TextPrompt.tsx +112 -0
  39. package/src/ThemePicker.tsx +366 -0
  40. package/src/ToolCommandOutput.tsx +48 -0
  41. package/src/ToolDiffBlock.tsx +44 -0
  42. package/src/TransportTUI.tsx +166 -0
  43. package/src/TuiInteractionChannel.ts +567 -0
  44. package/src/UpdateNotice.tsx +18 -0
  45. package/src/UsageSummaryEntry.tsx +49 -0
  46. package/src/WaveText.tsx +70 -0
  47. package/src/__tests__/ListPicker.test.tsx +188 -0
  48. package/src/__tests__/MenuSelect.test.tsx +127 -0
  49. package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
  50. package/src/__tests__/PluginTUI.test.tsx +167 -0
  51. package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
  52. package/src/__tests__/SupervisedSessionView-render.test.tsx +24 -0
  53. package/src/__tests__/SupervisedSessionView.test.tsx +629 -0
  54. package/src/__tests__/TextPrompt.test.tsx +112 -0
  55. package/src/__tests__/TransportTUI.test.tsx +124 -0
  56. package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
  57. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +284 -0
  58. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +642 -0
  59. package/src/__tests__/UpdateNotice.test.tsx +15 -0
  60. package/src/__tests__/abort-after-permission.test.tsx +160 -0
  61. package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
  62. package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
  63. package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
  64. package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
  65. package/src/__tests__/background-task-panel.test.tsx +98 -0
  66. package/src/__tests__/background-task-row-format.test.ts +152 -0
  67. package/src/__tests__/channel-factory-integration.test.ts +150 -0
  68. package/src/__tests__/cjk-defer-submit.test.tsx +67 -0
  69. package/src/__tests__/cjk-fallback-render.test.tsx +78 -0
  70. package/src/__tests__/cjk-text-input-flow.test.ts +186 -0
  71. package/src/__tests__/cjk-text-input.test.ts +191 -0
  72. package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
  73. package/src/__tests__/command-output-summary.test.ts +95 -0
  74. package/src/__tests__/command-result-handler.test.ts +171 -0
  75. package/src/__tests__/compact-event-bridge.test.ts +20 -0
  76. package/src/__tests__/confirm-permission-flow.test.ts +149 -0
  77. package/src/__tests__/confirm-prompt.test.tsx +103 -0
  78. package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
  79. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +45 -0
  80. package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
  81. package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
  82. package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
  83. package/src/__tests__/external-prompt-notice.test.tsx +100 -0
  84. package/src/__tests__/fixtures/command-handoff-driver.tsx +111 -0
  85. package/src/__tests__/fixtures/fake-editor.sh +7 -0
  86. package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
  87. package/src/__tests__/fork-attach.test.tsx +235 -0
  88. package/src/__tests__/helpers/terminal-profiles.ts +152 -0
  89. package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
  90. package/src/__tests__/history-clear-broadcast.test.ts +66 -0
  91. package/src/__tests__/history-search-overlay.test.tsx +329 -0
  92. package/src/__tests__/humanize-tool-name.test.ts +30 -0
  93. package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
  94. package/src/__tests__/input-area-flow.test.ts +199 -0
  95. package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
  96. package/src/__tests__/interaction-channel-charter.test.ts +13 -0
  97. package/src/__tests__/key-hint-consistency.test.tsx +249 -0
  98. package/src/__tests__/key-hint-footer.test.tsx +69 -0
  99. package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
  100. package/src/__tests__/message-list-rendering.test.tsx +473 -0
  101. package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
  102. package/src/__tests__/output-style-reaches-session.test.ts +35 -0
  103. package/src/__tests__/palette-consistency.test.ts +330 -0
  104. package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
  105. package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
  106. package/src/__tests__/product-display-name.test.tsx +79 -0
  107. package/src/__tests__/prompt-queue.test.tsx +260 -0
  108. package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
  109. package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
  110. package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
  111. package/src/__tests__/pty/behavior-2437-git.ptytest.ts +342 -0
  112. package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
  113. package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
  114. package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
  115. package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
  116. package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
  117. package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
  118. package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
  119. package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +291 -0
  120. package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +165 -0
  121. package/src/__tests__/pty/ime-cursor.ptytest.ts +186 -0
  122. package/src/__tests__/pty/isolated-home.ts +64 -0
  123. package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
  124. package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
  125. package/src/__tests__/pty/pty-driver.ts +153 -0
  126. package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
  127. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +134 -0
  128. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +138 -0
  129. package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
  130. package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
  131. package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
  132. package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
  133. package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
  134. package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
  135. package/src/__tests__/pty/spawn-pty.test.ts +120 -0
  136. package/src/__tests__/pty/spawn-pty.ts +229 -0
  137. package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
  138. package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
  139. package/src/__tests__/pty-driver-dispose.test.ts +43 -0
  140. package/src/__tests__/real-cursor-positioning.test.tsx +263 -0
  141. package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
  142. package/src/__tests__/render-channel-options.test.ts +201 -0
  143. package/src/__tests__/render-lifecycle.test.ts +36 -0
  144. package/src/__tests__/render-markdown.test.ts +164 -0
  145. package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
  146. package/src/__tests__/safe-text-boundary.test.tsx +75 -0
  147. package/src/__tests__/screen-reader-input.test.tsx +106 -0
  148. package/src/__tests__/screen-reader-labels.test.ts +91 -0
  149. package/src/__tests__/screen-reader-menus.test.tsx +200 -0
  150. package/src/__tests__/screen-reader-pacing.test.ts +230 -0
  151. package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
  152. package/src/__tests__/screen-reader-render-options.test.ts +262 -0
  153. package/src/__tests__/screen-reader-stdout.test.ts +322 -0
  154. package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
  155. package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
  156. package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
  157. package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
  158. package/src/__tests__/selection-flow.test.ts +61 -0
  159. package/src/__tests__/session-event-notices.test.tsx +31 -0
  160. package/src/__tests__/session-init-poller.test.ts +102 -0
  161. package/src/__tests__/session-switch-channel.test.tsx +470 -0
  162. package/src/__tests__/shutdown-signal.test.ts +34 -0
  163. package/src/__tests__/status-activity.test.ts +71 -0
  164. package/src/__tests__/status-bar.test.tsx +282 -0
  165. package/src/__tests__/status-glyph.test.ts +30 -0
  166. package/src/__tests__/streaming-indicator.test.tsx +177 -0
  167. package/src/__tests__/terminal-capabilities.test.ts +153 -0
  168. package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
  169. package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
  170. package/src/__tests__/text-prompt-flow.test.ts +77 -0
  171. package/src/__tests__/theme-picker.test.tsx +345 -0
  172. package/src/__tests__/transport-boundary.test.ts +11 -0
  173. package/src/__tests__/tui-app-channel-port.test.ts +31 -0
  174. package/src/__tests__/tui-channel-init-failure.test.ts +62 -0
  175. package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
  176. package/src/__tests__/tui-interaction-queues.test.ts +139 -0
  177. package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
  178. package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
  179. package/src/__tests__/tui-state-manager.test.ts +451 -0
  180. package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
  181. package/src/__tests__/wave-text.test.tsx +133 -0
  182. package/src/app-banner.tsx +42 -0
  183. package/src/app-static-items.ts +35 -0
  184. package/src/app-view-model.ts +136 -0
  185. package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
  186. package/src/attention/__tests__/attention-tracker.test.ts +103 -0
  187. package/src/attention/__tests__/countdown.test.ts +15 -0
  188. package/src/attention/__tests__/focus-input-filter.test.ts +92 -0
  189. package/src/attention/__tests__/interval-recap.test.ts +49 -0
  190. package/src/attention/attention-coordinator.ts +71 -0
  191. package/src/attention/attention-tracker.ts +124 -0
  192. package/src/attention/countdown.ts +24 -0
  193. package/src/attention/focus-input-filter.ts +131 -0
  194. package/src/attention/interval-recap.ts +148 -0
  195. package/src/attention/time-units.ts +6 -0
  196. package/src/attention-bell.ts +79 -0
  197. package/src/attributed-user-echo.ts +30 -0
  198. package/src/background-task-row-format.ts +99 -0
  199. package/src/command-interaction.ts +8 -0
  200. package/src/command-output-summary.ts +129 -0
  201. package/src/create-default-tui-cli-adapter.ts +45 -0
  202. package/src/execution-workspace-switcher-selection.ts +118 -0
  203. package/src/execution-workspace-view-model.ts +139 -0
  204. package/src/external-prompt-notice.tsx +38 -0
  205. package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
  206. package/src/flows/__tests__/defer-submit.test.ts +87 -0
  207. package/src/flows/__tests__/real-cursor-flow.test.ts +124 -0
  208. package/src/flows/background-focus-flow.ts +44 -0
  209. package/src/flows/cjk-cursor-motion.ts +75 -0
  210. package/src/flows/cjk-text-deletion.ts +53 -0
  211. package/src/flows/cjk-text-input-flow.ts +248 -0
  212. package/src/flows/cjk-text-input-types.ts +24 -0
  213. package/src/flows/confirm-prompt-flow.ts +90 -0
  214. package/src/flows/defer-submit.ts +67 -0
  215. package/src/flows/fork-attach-flow.ts +62 -0
  216. package/src/flows/input-area-flow.ts +188 -0
  217. package/src/flows/permission-prompt-flow.ts +108 -0
  218. package/src/flows/real-cursor-flow.ts +83 -0
  219. package/src/flows/selection-flow.ts +205 -0
  220. package/src/flows/session-init-poller.ts +76 -0
  221. package/src/flows/text-prompt-flow.ts +93 -0
  222. package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
  223. package/src/history-search/history-search-flow.ts +94 -0
  224. package/src/history-search/useHistorySearch.ts +336 -0
  225. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +74 -0
  226. package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
  227. package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
  228. package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
  229. package/src/hooks/command-result-handler.ts +44 -0
  230. package/src/hooks/side-effects-types.ts +45 -0
  231. package/src/hooks/useAppController.ts +267 -0
  232. package/src/hooks/useAppInputBindings.ts +145 -0
  233. package/src/hooks/useAppInteractionState.ts +81 -0
  234. package/src/hooks/useAppLifecycleState.ts +81 -0
  235. package/src/hooks/useAppOverlays.ts +74 -0
  236. package/src/hooks/useAppScreenState.ts +81 -0
  237. package/src/hooks/useAppSubmissionState.ts +45 -0
  238. package/src/hooks/useAppThemeState.ts +157 -0
  239. package/src/hooks/useAppWorkspaceState.ts +149 -0
  240. package/src/hooks/useAppearanceSettings.ts +30 -0
  241. package/src/hooks/useAutocomplete.ts +101 -0
  242. package/src/hooks/useBackgroundPanel.ts +58 -0
  243. package/src/hooks/useCountdownTick.ts +24 -0
  244. package/src/hooks/useExecutionDetailPage.ts +69 -0
  245. package/src/hooks/useForkAttach.ts +60 -0
  246. package/src/hooks/useInputAreaHistorySearch.ts +72 -0
  247. package/src/hooks/useInputAreaKeys.ts +114 -0
  248. package/src/hooks/useNumberedSelection.ts +76 -0
  249. package/src/hooks/usePluginCallbacks.ts +38 -0
  250. package/src/hooks/usePluginScreenData.ts +85 -0
  251. package/src/hooks/useRealCursorPosition.ts +125 -0
  252. package/src/hooks/useRenderMarkdown.ts +38 -0
  253. package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
  254. package/src/hooks/useSideEffects.ts +144 -0
  255. package/src/hooks/useStatusLineSettings.ts +50 -0
  256. package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
  257. package/src/hooks/useTuiChannel.ts +129 -0
  258. package/src/humanize-tool-name.ts +51 -0
  259. package/src/index.ts +56 -0
  260. package/src/input-area-rules.tsx +71 -0
  261. package/src/json-value.ts +18 -0
  262. package/src/key-hint-footer.tsx +54 -0
  263. package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
  264. package/src/keybindings/__tests__/node-keybindings-source.test.ts +111 -0
  265. package/src/keybindings/keybinding-catalogue.ts +171 -0
  266. package/src/keybindings/keybinding-registry.ts +303 -0
  267. package/src/keybindings/keybinding-syntax.ts +155 -0
  268. package/src/keybindings/keybindings-context.tsx +124 -0
  269. package/src/keybindings/node-keybindings-source.ts +189 -0
  270. package/src/numbered-list.tsx +113 -0
  271. package/src/plugin-tui-handlers.ts +163 -0
  272. package/src/product-display-name-context.tsx +30 -0
  273. package/src/render-markdown.ts +209 -0
  274. package/src/render.tsx +544 -0
  275. package/src/sanitize-terminal-text.ts +171 -0
  276. package/src/screen-reader-announcement.ts +52 -0
  277. package/src/screen-reader-context.tsx +32 -0
  278. package/src/screen-reader-labels.ts +64 -0
  279. package/src/screen-reader-pacing-context.tsx +47 -0
  280. package/src/screen-reader-pacing.ts +178 -0
  281. package/src/screen-reader-stdout.ts +328 -0
  282. package/src/shutdown-signal.ts +34 -0
  283. package/src/status-activity.ts +70 -0
  284. package/src/status-glyph.ts +78 -0
  285. package/src/terminal-capabilities-context.tsx +27 -0
  286. package/src/terminal-capabilities.ts +77 -0
  287. package/src/terminal-focus-reporting.ts +50 -0
  288. package/src/terminal-handoff-controller.ts +98 -0
  289. package/src/terminal-marks.ts +63 -0
  290. package/src/theme/__tests__/built-in-themes.test.ts +117 -0
  291. package/src/theme/__tests__/color-vision.test.ts +82 -0
  292. package/src/theme/__tests__/theme-context.test.tsx +146 -0
  293. package/src/theme/__tests__/theme-document.test.ts +222 -0
  294. package/src/theme/__tests__/theme-registry.test.ts +138 -0
  295. package/src/theme/__tests__/theme-styles.test.ts +233 -0
  296. package/src/theme/built-in-themes.ts +342 -0
  297. package/src/theme/color-vision.ts +171 -0
  298. package/src/theme/index.ts +45 -0
  299. package/src/theme/theme-context.tsx +81 -0
  300. package/src/theme/theme-contracts.ts +139 -0
  301. package/src/theme/theme-document.ts +278 -0
  302. package/src/theme/theme-registry.ts +127 -0
  303. package/src/theme/theme-styles.ts +177 -0
  304. package/src/tool-summary-status.ts +59 -0
  305. package/src/tui-app-channel-port.ts +83 -0
  306. package/src/tui-channel-lifecycle-coordinator.ts +149 -0
  307. package/src/tui-channel-options.ts +172 -0
  308. package/src/tui-cli-adapter-context.tsx +13 -0
  309. package/src/tui-cli-adapter.ts +24 -0
  310. package/src/tui-interaction-queues.ts +163 -0
  311. package/src/tui-session-binding.ts +52 -0
  312. package/src/tui-session-event-projector.ts +140 -0
  313. package/src/tui-session-events.ts +82 -0
  314. package/src/tui-session-notice-store.ts +43 -0
  315. package/src/tui-session-options.ts +118 -0
  316. package/src/tui-state-manager.ts +327 -0
  317. package/src/types.ts +19 -0
  318. package/src/use-terminal-title.ts +29 -0
  319. package/src/utils/__tests__/edit-diff.test.ts +426 -0
  320. package/src/utils/__tests__/paste-detection.test.ts +116 -0
  321. package/src/utils/__tests__/paste-labels.test.ts +46 -0
  322. package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
  323. package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
  324. package/src/utils/edit-diff.ts +151 -0
  325. package/src/utils/input-top-border.ts +19 -0
  326. package/src/utils/paste-labels.ts +9 -0
  327. package/src/utils/tool-call-extractor.ts +92 -0
  328. package/src/utils/tool-diff-summary.ts +75 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * #2222: the SafeText boundary, asserted against the BYTES Ink writes to a tty — not against
3
+ * `lastFrame()`. Measured on PR #2212: Ink itself strips OSC 52 / OSC 0 / CSI / DCS / APC from a
4
+ * frame and passes SGR, OSC 8 and a bare CR, so a frame assertion is green whether or not the code
5
+ * sanitizes. Only the stream a real terminal would receive tells the two apart.
6
+ *
7
+ * The raw-`Text` case below is REQUIRED to leak: it is what proves the detector can answer "yes".
8
+ */
9
+
10
+ import { Writable } from 'node:stream';
11
+
12
+ import { Text as RawInkText, render } from 'ink';
13
+ import React from 'react';
14
+ import { describe, expect, it } from 'vitest';
15
+
16
+ import { SafeText, Text } from '../SafeText.js';
17
+
18
+ import type { ReactElement } from 'react';
19
+
20
+ /** OSC 8 hyperlink (Ink passes it through) and a bare CR — the two markers a frame cannot show. */
21
+ const OSC8 = ']8;;https://evil.exampleclick]8;;';
22
+ const POISON = `before${OSC8}\rafter`;
23
+
24
+ /** A stream that claims to be a tty, so Ink writes what a terminal would receive. */
25
+ function createTtyCapture(): { stdout: NodeJS.WriteStream; bytes: () => string } {
26
+ const chunks: string[] = [];
27
+ const stream = new Writable({
28
+ write(chunk: Buffer | string, _encoding, callback) {
29
+ chunks.push(chunk.toString());
30
+ callback();
31
+ },
32
+ });
33
+ const stdout = Object.assign(stream, {
34
+ isTTY: true,
35
+ columns: 80,
36
+ rows: 24,
37
+ }) as unknown as NodeJS.WriteStream;
38
+ return { stdout, bytes: () => chunks.join('') };
39
+ }
40
+
41
+ async function renderToTty(element: ReactElement): Promise<string> {
42
+ const capture = createTtyCapture();
43
+ const instance = render(element, { stdout: capture.stdout, patchConsole: false });
44
+ await new Promise((resolve) => setTimeout(resolve, 20));
45
+ instance.unmount();
46
+ return capture.bytes();
47
+ }
48
+
49
+ describe('SafeText boundary (#2222)', () => {
50
+ it('a raw ink Text render LEAKS the markers to the tty stream (detector proof)', async () => {
51
+ const bytes = await renderToTty(<RawInkText>{POISON}</RawInkText>);
52
+ expect(bytes).toContain(']8;;https://evil.example');
53
+ expect(bytes).toContain('before');
54
+ });
55
+
56
+ it('SafeText removes the markers from the same payload, keeping the visible text', async () => {
57
+ const bytes = await renderToTty(<SafeText>{POISON}</SafeText>);
58
+ expect(bytes).not.toContain('evil.example');
59
+ expect(bytes).not.toContain(']8;;');
60
+ expect(bytes).toContain('before');
61
+ expect(bytes).toContain('after');
62
+ });
63
+
64
+ it('the `Text` alias call sites use is the boundary, and nested children are covered', async () => {
65
+ const bytes = await renderToTty(
66
+ <Text>
67
+ {'label: '}
68
+ <Text bold>{POISON}</Text>
69
+ {[POISON, 42]}
70
+ </Text>,
71
+ );
72
+ expect(bytes).not.toContain('evil.example');
73
+ expect(bytes).toContain('42');
74
+ });
75
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * CLI-2004 TC-10 — the input area in screen-reader mode.
3
+ *
4
+ * Two things, and they fail for the same reason. The hand-drawn rules above and below the input are
5
+ * repainted with the line, so a reader hears a wall of dashes on every keystroke; and a word
6
+ * deletion leaves a reader announcing what REMAINS, which is precisely not the information wanted.
7
+ */
8
+
9
+ import { render } from 'ink-testing-library';
10
+ import React from 'react';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+
13
+ import InputArea from '../InputArea.js';
14
+ import { formatDeletionAnnouncement } from '../input-area-rules.js';
15
+ import { ScreenReaderProvider } from '../screen-reader-context.js';
16
+
17
+ async function tick(): Promise<void> {
18
+ await new Promise((resolve) => setTimeout(resolve, 20));
19
+ }
20
+
21
+ function renderInput(enabled: boolean): ReturnType<typeof render> {
22
+ return render(
23
+ <ScreenReaderProvider enabled={enabled}>
24
+ <InputArea onSubmit={vi.fn()} isDisabled={false} />
25
+ </ScreenReaderProvider>,
26
+ );
27
+ }
28
+
29
+ describe('TC-10: the input area chrome', () => {
30
+ it('renders no rule line and no border in the mode', async () => {
31
+ const { lastFrame, unmount } = renderInput(true);
32
+ await tick();
33
+ const frame = lastFrame() ?? '';
34
+ unmount();
35
+
36
+ expect(frame).not.toContain('─');
37
+ expect(frame).not.toMatch(/[╭╮╰╯│┌┐└┘]/u);
38
+ expect(frame).toContain('Type a message');
39
+ });
40
+
41
+ it('still renders the rules when the mode is off', async () => {
42
+ const { lastFrame, unmount } = renderInput(false);
43
+ await tick();
44
+ const frame = lastFrame() ?? '';
45
+ unmount();
46
+
47
+ expect(frame).toContain('─');
48
+ });
49
+ });
50
+
51
+ describe('TC-10: the deletion announcement', () => {
52
+ it('prints `[deleted: <word>]` exactly once after a word deletion', async () => {
53
+ const { stdin, lastFrame, unmount } = renderInput(true);
54
+ await tick();
55
+
56
+ for (const char of 'hello world') {
57
+ stdin.write(char);
58
+ }
59
+ await tick();
60
+ // Ctrl+W — the word delete.
61
+ stdin.write('\x17');
62
+ await tick();
63
+
64
+ const frame = lastFrame() ?? '';
65
+ unmount();
66
+
67
+ const announcement = formatDeletionAnnouncement('world');
68
+ expect(frame).toContain(announcement);
69
+ expect(frame.split(announcement).length - 1).toBe(1);
70
+ });
71
+
72
+ it('announces the whole line for a line deletion', async () => {
73
+ const { stdin, lastFrame, unmount } = renderInput(true);
74
+ await tick();
75
+
76
+ for (const char of 'abc') {
77
+ stdin.write(char);
78
+ }
79
+ await tick();
80
+ // Ctrl+U — the line delete.
81
+ stdin.write('\x15');
82
+ await tick();
83
+
84
+ const frame = lastFrame() ?? '';
85
+ unmount();
86
+
87
+ expect(frame).toContain(formatDeletionAnnouncement('abc'));
88
+ });
89
+
90
+ it('announces nothing when the mode is off', async () => {
91
+ const { stdin, lastFrame, unmount } = renderInput(false);
92
+ await tick();
93
+
94
+ for (const char of 'hello world') {
95
+ stdin.write(char);
96
+ }
97
+ await tick();
98
+ stdin.write('\x17');
99
+ await tick();
100
+
101
+ const frame = lastFrame() ?? '';
102
+ unmount();
103
+
104
+ expect(frame).not.toContain('[deleted:');
105
+ });
106
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * CLI-2004 TC-05 — the role-derived, provider-invariant label vocabulary.
3
+ *
4
+ * The point of the negative assertion below is not tidiness: a label naming the vendor would make
5
+ * the transcript differ between `agent-provider-*` packages for the same conversation, and a reader
6
+ * grepping their scrollback for "assistant:" would find nothing after switching providers.
7
+ */
8
+
9
+ import { render } from 'ink-testing-library';
10
+ import React from 'react';
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { RoleLabel } from '../RoleLabel.js';
14
+ import { ScreenReaderProvider } from '../screen-reader-context.js';
15
+ import {
16
+ SCREEN_READER_LABELS,
17
+ screenReaderLabelForRole,
18
+ screenReaderLabelValues,
19
+ } from '../screen-reader-labels.js';
20
+
21
+ import type { TUniversalMessage } from '@robota-sdk/agent-core';
22
+
23
+ /** Every provider package this repo ships, plus the vendor names behind them. */
24
+ const VENDOR_WORDS = /(claude|anthropic|openai|gpt|gemini|google|robota|llama|mistral|copilot)/i;
25
+
26
+ function renderLabel(role: TUniversalMessage['role'], enabled: boolean, driverId?: string): string {
27
+ const label = React.createElement(
28
+ RoleLabel,
29
+ driverId === undefined ? { role } : { role, driverId },
30
+ );
31
+ const { lastFrame, unmount } = render(
32
+ React.createElement(ScreenReaderProvider, { enabled, children: label }),
33
+ );
34
+ const frame = lastFrame() ?? '';
35
+ unmount();
36
+ return frame;
37
+ }
38
+
39
+ describe('TC-05: the label vocabulary', () => {
40
+ it('covers all nine label kinds', () => {
41
+ expect(Object.keys(SCREEN_READER_LABELS)).toHaveLength(9);
42
+ expect(screenReaderLabelValues()).toEqual([
43
+ 'you:',
44
+ 'assistant:',
45
+ 'thinking:',
46
+ 'tool:',
47
+ 'tool error:',
48
+ 'error:',
49
+ 'warning:',
50
+ 'permission required:',
51
+ 'cost:',
52
+ ]);
53
+ });
54
+
55
+ it('is entirely lowercase', () => {
56
+ for (const label of screenReaderLabelValues()) {
57
+ expect(label).toBe(label.toLowerCase());
58
+ }
59
+ });
60
+
61
+ it('names no provider or vendor', () => {
62
+ for (const label of screenReaderLabelValues()) {
63
+ expect(label).not.toMatch(VENDOR_WORDS);
64
+ }
65
+ });
66
+
67
+ it('maps every transcript role onto the vocabulary', () => {
68
+ expect(screenReaderLabelForRole('user')).toBe('you:');
69
+ expect(screenReaderLabelForRole('assistant')).toBe('assistant:');
70
+ expect(screenReaderLabelForRole('tool')).toBe('tool:');
71
+ expect(screenReaderLabelForRole('system')).toBe('warning:');
72
+ });
73
+ });
74
+
75
+ describe('TC-05: RoleLabel in the mode', () => {
76
+ it('renders `assistant:` for an assistant message — the same under every provider', () => {
77
+ const frame = renderLabel('assistant', true);
78
+ expect(frame).toContain('assistant:');
79
+ expect(frame).not.toMatch(VENDOR_WORDS);
80
+ });
81
+
82
+ it('renders `you:` for the operator, and the driver id for a co-driven turn', () => {
83
+ expect(renderLabel('user', true)).toContain('you:');
84
+ expect(renderLabel('user', true, 'peer:s-1')).toContain('peer:s-1:');
85
+ });
86
+
87
+ it('uses neutral visual labels when the mode is off', () => {
88
+ expect(renderLabel('assistant', false)).toContain('Assistant:');
89
+ expect(renderLabel('user', false)).toContain('You:');
90
+ });
91
+ });
@@ -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)} />,
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 Robota pacing variables without a host choice', () => {
43
+ vi.stubEnv('ROBOTA_SCREEN_READER_STARTUP_QUIET_MS', '0');
44
+ vi.stubEnv('ROBOTA_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
+ });