@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,160 @@
1
+ /**
2
+ * Test: ESC abort after permission prompt was shown and dismissed.
3
+ * Verifies that the global ESC handler remains available after overlays close.
4
+ */
5
+
6
+ import React, { useState } from 'react';
7
+ import { render } from 'ink-testing-library';
8
+ import { Box, Text, useInput } from 'ink';
9
+ import { describe, it, expect } from 'vitest';
10
+
11
+ /**
12
+ * Simulates App's global ESC handler with permission prompt overlay guard.
13
+ * 1. Start with "thinking" active
14
+ * 2. Permission prompt appears (App-level ESC ignores while overlay is active)
15
+ * 3. Permission resolved (App-level ESC handles abort again)
16
+ * 4. ESC should trigger abort
17
+ */
18
+ function AbortAfterPermissionApp({
19
+ onAbort,
20
+ onPermissionReady,
21
+ }: {
22
+ onAbort: () => void;
23
+ onPermissionReady: (grantPermission: () => void) => void;
24
+ }): React.ReactElement {
25
+ const [isThinking] = useState(true);
26
+ const [permissionRequest, setPermissionRequest] = useState<{
27
+ resolve: () => void;
28
+ } | null>(null);
29
+ const [aborted, setAborted] = useState(false);
30
+
31
+ // Give parent a way to grant permission
32
+ React.useEffect(() => {
33
+ // Show permission prompt immediately
34
+ const pr = {
35
+ resolve: () => setPermissionRequest(null),
36
+ };
37
+ setPermissionRequest(pr);
38
+ onPermissionReady(() => pr.resolve());
39
+ }, [onPermissionReady]);
40
+
41
+ // App's ESC handler — same pattern as real App.tsx
42
+ useInput((_input: string, key: { escape: boolean }) => {
43
+ if (!key.escape || !isThinking) return;
44
+ if (permissionRequest) return;
45
+ setAborted(true);
46
+ onAbort();
47
+ });
48
+
49
+ // Permission prompt's own useInput (when active)
50
+ useInput(
51
+ (_input: string, key: { return: boolean }) => {
52
+ if (key.return && permissionRequest) {
53
+ permissionRequest.resolve();
54
+ }
55
+ },
56
+ { isActive: !!permissionRequest },
57
+ );
58
+
59
+ return (
60
+ <Box flexDirection="column">
61
+ {permissionRequest && <Text color="yellow">[Permission Required]</Text>}
62
+ {!permissionRequest && isThinking && <Text color="cyan">Streaming...</Text>}
63
+ {aborted && <Text color="red">Aborted!</Text>}
64
+ <Text dimColor>
65
+ thinking={String(isThinking)} permission={String(!!permissionRequest)} aborted=
66
+ {String(aborted)}
67
+ </Text>
68
+ </Box>
69
+ );
70
+ }
71
+
72
+ describe('ESC abort after permission prompt', () => {
73
+ it('ESC works when no permission prompt was shown', async () => {
74
+ let abortCalled = false;
75
+ const grantHolder: { fn: (() => void) | null } = { fn: null };
76
+
77
+ const { stdin, lastFrame } = render(
78
+ <AbortAfterPermissionApp
79
+ onAbort={() => {
80
+ abortCalled = true;
81
+ }}
82
+ onPermissionReady={(fn) => {
83
+ grantHolder.fn = fn;
84
+ }}
85
+ />,
86
+ );
87
+
88
+ // Wait for mount
89
+ await new Promise((r) => setTimeout(r, 20));
90
+
91
+ // Grant permission immediately
92
+ grantHolder.fn?.();
93
+ await new Promise((r) => setTimeout(r, 50));
94
+
95
+ // Now ESC should work
96
+ stdin.write('\x1B');
97
+ await new Promise((r) => setTimeout(r, 50));
98
+
99
+ expect(abortCalled).toBe(true);
100
+ expect(lastFrame()!).toContain('Aborted!');
101
+ });
102
+
103
+ it('ESC works AFTER permission prompt was shown and dismissed', async () => {
104
+ let abortCalled = false;
105
+ const grantHolder: { fn: (() => void) | null } = { fn: null };
106
+
107
+ const { stdin, lastFrame } = render(
108
+ <AbortAfterPermissionApp
109
+ onAbort={() => {
110
+ abortCalled = true;
111
+ }}
112
+ onPermissionReady={(fn) => {
113
+ grantHolder.fn = fn;
114
+ }}
115
+ />,
116
+ );
117
+
118
+ // Wait for permission prompt to appear
119
+ await new Promise((r) => setTimeout(r, 20));
120
+ expect(lastFrame()!).toContain('[Permission Required]');
121
+
122
+ // Grant permission (dismiss prompt)
123
+ grantHolder.fn?.();
124
+ await new Promise((r) => setTimeout(r, 50));
125
+
126
+ // Permission dismissed, streaming should show
127
+ expect(lastFrame()!).toContain('Streaming...');
128
+ expect(lastFrame()!).not.toContain('[Permission Required]');
129
+
130
+ // Now press ESC — should trigger abort
131
+ stdin.write('\x1B');
132
+ await new Promise((r) => setTimeout(r, 50));
133
+
134
+ expect(abortCalled).toBe(true);
135
+ expect(lastFrame()!).toContain('Aborted!');
136
+ });
137
+
138
+ it('ESC does NOT work during permission prompt', async () => {
139
+ let abortCalled = false;
140
+
141
+ const { stdin, lastFrame } = render(
142
+ <AbortAfterPermissionApp
143
+ onAbort={() => {
144
+ abortCalled = true;
145
+ }}
146
+ onPermissionReady={() => {}}
147
+ />,
148
+ );
149
+
150
+ await new Promise((r) => setTimeout(r, 20));
151
+ expect(lastFrame()!).toContain('[Permission Required]');
152
+
153
+ // ESC during permission prompt should NOT trigger abort
154
+ stdin.write('\x1B');
155
+ await new Promise((r) => setTimeout(r, 50));
156
+
157
+ expect(abortCalled).toBe(false);
158
+ expect(lastFrame()!).not.toContain('Aborted!');
159
+ });
160
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * E2E-style test for abort during streaming.
3
+ * Uses ink-testing-library to verify that:
4
+ * 1. Streaming text debounce works (renders batched, not per-delta)
5
+ * 2. ESC during streaming triggers abort
6
+ * 3. Partial text is preserved after abort
7
+ */
8
+
9
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
10
+ import { render } from 'ink-testing-library';
11
+ import { Box, Text, useInput } from 'ink';
12
+ import { describe, it, expect } from 'vitest';
13
+
14
+ /**
15
+ * Minimal streaming component that simulates the debounced onTextDelta pattern.
16
+ * Accepts deltas via a callback ref, renders accumulated text.
17
+ */
18
+ function StreamingTestApp({
19
+ onReady,
20
+ onAbort,
21
+ }: {
22
+ onReady: (appendDelta: (text: string) => void) => void;
23
+ onAbort: () => void;
24
+ }): React.ReactElement {
25
+ const [text, setText] = useState('');
26
+ const textRef = useRef('');
27
+ const [aborted, setAborted] = useState(false);
28
+ const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+ const renderCountRef = useRef(0);
30
+
31
+ // Debounced delta handler (same pattern as InteractiveSession)
32
+ const appendDelta = useCallback((delta: string) => {
33
+ textRef.current += delta;
34
+ if (!flushTimerRef.current) {
35
+ flushTimerRef.current = setTimeout(() => {
36
+ setText(textRef.current);
37
+ flushTimerRef.current = null;
38
+ }, 16);
39
+ }
40
+ }, []);
41
+
42
+ // ESC handler
43
+ useInput((_input, key) => {
44
+ if (key.escape) {
45
+ setAborted(true);
46
+ onAbort();
47
+ // Force flush any pending text
48
+ if (flushTimerRef.current) {
49
+ clearTimeout(flushTimerRef.current);
50
+ flushTimerRef.current = null;
51
+ }
52
+ setText(textRef.current);
53
+ }
54
+ });
55
+
56
+ // Notify parent that we're ready
57
+ useEffect(() => {
58
+ onReady(appendDelta);
59
+ }, [onReady, appendDelta]);
60
+
61
+ renderCountRef.current++;
62
+
63
+ return (
64
+ <Box flexDirection="column">
65
+ <Text>{text}</Text>
66
+ {aborted && <Text color="yellow">Interrupted by user.</Text>}
67
+ <Text dimColor>renders: {renderCountRef.current}</Text>
68
+ </Box>
69
+ );
70
+ }
71
+
72
+ describe('Streaming abort E2E', () => {
73
+ it('debounced streaming renders fewer times than delta count', async () => {
74
+ let appendDelta: ((text: string) => void) | null = null;
75
+
76
+ const { lastFrame } = render(
77
+ React.createElement(StreamingTestApp, {
78
+ onReady: (fn: (text: string) => void) => {
79
+ appendDelta = fn;
80
+ },
81
+ onAbort: () => {},
82
+ }),
83
+ );
84
+
85
+ // Wait for component to mount
86
+ await new Promise((r) => setTimeout(r, 20));
87
+ expect(appendDelta).not.toBeNull();
88
+
89
+ // Send 20 rapid deltas
90
+ for (let i = 0; i < 20; i++) {
91
+ appendDelta!(`chunk${i} `);
92
+ }
93
+
94
+ // Wait for debounce flush
95
+ await new Promise((r) => setTimeout(r, 50));
96
+
97
+ const frame = lastFrame()!;
98
+ // All text should be present
99
+ expect(frame).toContain('chunk0');
100
+ expect(frame).toContain('chunk19');
101
+
102
+ // Render count should be MUCH less than 20 (debounced)
103
+ const renderMatch = frame.match(/renders: (\d+)/);
104
+ expect(renderMatch).not.toBeNull();
105
+ const renderCount = parseInt(renderMatch![1], 10);
106
+ // With 16ms debounce and ~50ms total time, expect 3-5 renders, not 20+
107
+ expect(renderCount).toBeLessThan(10);
108
+ });
109
+
110
+ it('ESC during rapid streaming triggers abort and shows text', async () => {
111
+ let appendDelta: ((text: string) => void) | null = null;
112
+ let abortCalled = false;
113
+
114
+ const { stdin, lastFrame } = render(
115
+ React.createElement(StreamingTestApp, {
116
+ onReady: (fn: (text: string) => void) => {
117
+ appendDelta = fn;
118
+ },
119
+ onAbort: () => {
120
+ abortCalled = true;
121
+ },
122
+ }),
123
+ );
124
+
125
+ await new Promise((r) => setTimeout(r, 20));
126
+
127
+ // Send some deltas
128
+ for (let i = 0; i < 5; i++) {
129
+ appendDelta!(`line${i} `);
130
+ }
131
+
132
+ // Press ESC
133
+ stdin.write('\x1B');
134
+ await new Promise((r) => setTimeout(r, 50));
135
+
136
+ expect(abortCalled).toBe(true);
137
+
138
+ const frame = lastFrame()!;
139
+ // Text should be visible (flush on abort)
140
+ expect(frame).toContain('line0');
141
+ expect(frame).toContain('line4');
142
+ // Cancelled indicator
143
+ expect(frame).toContain('Interrupted by user.');
144
+ });
145
+
146
+ it('ESC during ongoing streaming stops further rendering', async () => {
147
+ let appendDelta: ((text: string) => void) | null = null;
148
+ let abortCalled = false;
149
+
150
+ const { stdin, lastFrame } = render(
151
+ React.createElement(StreamingTestApp, {
152
+ onReady: (fn: (text: string) => void) => {
153
+ appendDelta = fn;
154
+ },
155
+ onAbort: () => {
156
+ abortCalled = true;
157
+ },
158
+ }),
159
+ );
160
+
161
+ await new Promise((r) => setTimeout(r, 20));
162
+
163
+ // Send first batch
164
+ appendDelta!('before_abort ');
165
+
166
+ // Wait for flush
167
+ await new Promise((r) => setTimeout(r, 20));
168
+
169
+ // Press ESC
170
+ stdin.write('\x1B');
171
+ await new Promise((r) => setTimeout(r, 30));
172
+
173
+ expect(abortCalled).toBe(true);
174
+
175
+ // Send more deltas AFTER abort (should still accumulate in ref but component should show Cancelled)
176
+ appendDelta!('after_abort ');
177
+ await new Promise((r) => setTimeout(r, 50));
178
+
179
+ const frame = lastFrame()!;
180
+ expect(frame).toContain('before_abort');
181
+ expect(frame).toContain('Interrupted by user.');
182
+ });
183
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * CLI-2004 TC-08 / TC-09 — the two writers that reach the terminal outside Ink.
3
+ *
4
+ * Both are asserted against the exact BYTES, because that is the only channel a screen reader has:
5
+ * there is no `aria-live` in a terminal, so what a reader gets is what lands in the buffer.
6
+ */
7
+
8
+ import { afterEach, describe, expect, it, vi } from 'vitest';
9
+
10
+ import { BELL, createAttentionBell, LONG_TOOL_BELL_MS } from '../attention-bell.js';
11
+ import { supportsTurnMarks } from '../terminal-capabilities.js';
12
+ import { createTurnMarkWriter, turnMarkSequence } from '../terminal-marks.js';
13
+
14
+ function collector(): { written: string[]; write: (text: string) => void } {
15
+ const written: string[] = [];
16
+ return { written, write: (text: string): void => void written.push(text) };
17
+ }
18
+
19
+ afterEach(() => {
20
+ vi.unstubAllEnvs();
21
+ });
22
+
23
+ describe('TC-08: the attention bell', () => {
24
+ it('emits exactly one BEL on reply completion', () => {
25
+ const sink = collector();
26
+ createAttentionBell({ enabled: true, write: sink.write }).replyCompleted();
27
+ expect(sink.written).toEqual([BELL]);
28
+ });
29
+
30
+ it('emits exactly one BEL when a prompt mounts', () => {
31
+ const sink = collector();
32
+ createAttentionBell({ enabled: true, write: sink.write }).promptMounted();
33
+ expect(sink.written).toEqual([BELL]);
34
+ });
35
+
36
+ it('rings for a tool that outlived LONG_TOOL_BELL_MS', () => {
37
+ const sink = collector();
38
+ let now = 0;
39
+ const bell = createAttentionBell({ enabled: true, write: sink.write, now: () => now });
40
+ bell.toolStarted('Bash#0');
41
+ now = LONG_TOOL_BELL_MS + 1;
42
+ bell.toolCompleted('Bash#0');
43
+ expect(sink.written).toEqual([BELL]);
44
+ });
45
+
46
+ it('stays silent for a tool that finished under the threshold', () => {
47
+ const sink = collector();
48
+ let now = 0;
49
+ const bell = createAttentionBell({ enabled: true, write: sink.write, now: () => now });
50
+ bell.toolStarted('Bash#0');
51
+ now = LONG_TOOL_BELL_MS;
52
+ bell.toolCompleted('Bash#0');
53
+ expect(sink.written).toEqual([]);
54
+ });
55
+
56
+ it('stays silent for a completion whose start was never recorded', () => {
57
+ const sink = collector();
58
+ const bell = createAttentionBell({ enabled: true, write: sink.write });
59
+ bell.toolCompleted('Bash#0');
60
+ expect(sink.written).toEqual([]);
61
+ });
62
+
63
+ it('writes nothing at all when the mode is off', () => {
64
+ const sink = collector();
65
+ let now = 0;
66
+ const bell = createAttentionBell({ enabled: false, write: sink.write, now: () => now });
67
+ bell.replyCompleted();
68
+ bell.promptMounted();
69
+ bell.toolStarted('Bash#0');
70
+ now = LONG_TOOL_BELL_MS * 10;
71
+ bell.toolCompleted('Bash#0');
72
+ expect(sink.written).toEqual([]);
73
+ });
74
+ });
75
+
76
+ describe('TC-09: the OSC 133 turn marks', () => {
77
+ const A = '\x1b]133;A\x07';
78
+ const B = '\x1b]133;B\x07';
79
+ const C = '\x1b]133;C\x07';
80
+ const D = '\x1b]133;D\x07';
81
+
82
+ it('emits A, B, C, D in that order across one turn', () => {
83
+ const sink = collector();
84
+ const marks = createTurnMarkWriter({ enabled: true, supported: () => true, write: sink.write });
85
+ marks.emit('promptStart');
86
+ marks.emit('promptEnd');
87
+ marks.emit('turnStart');
88
+ marks.emit('turnEnd');
89
+ expect(sink.written).toEqual([A, B, C, D]);
90
+ });
91
+
92
+ it('spells each mark exactly', () => {
93
+ expect(turnMarkSequence('promptStart')).toBe(A);
94
+ expect(turnMarkSequence('promptEnd')).toBe(B);
95
+ expect(turnMarkSequence('turnStart')).toBe(C);
96
+ expect(turnMarkSequence('turnEnd')).toBe(D);
97
+ });
98
+
99
+ it('emits nothing when the mode is off', () => {
100
+ const sink = collector();
101
+ const marks = createTurnMarkWriter({
102
+ enabled: false,
103
+ supported: () => true,
104
+ write: sink.write,
105
+ });
106
+ marks.emit('promptStart');
107
+ marks.emit('turnEnd');
108
+ expect(sink.written).toEqual([]);
109
+ });
110
+
111
+ it('emits nothing when supportsTurnMarks() is false', () => {
112
+ const sink = collector();
113
+ const marks = createTurnMarkWriter({
114
+ enabled: true,
115
+ supported: () => false,
116
+ write: sink.write,
117
+ });
118
+ marks.emit('promptStart');
119
+ marks.emit('turnEnd');
120
+ expect(sink.written).toEqual([]);
121
+ });
122
+ });
123
+
124
+ describe('supportsTurnMarks — the documented negatives', () => {
125
+ it('is off in WezTerm, which owns OSC 133 itself', () => {
126
+ vi.stubEnv('ROBOTA_TURN_MARKS', '');
127
+ vi.stubEnv('TERM_PROGRAM', 'WezTerm');
128
+ expect(supportsTurnMarks()).toBe(false);
129
+ });
130
+
131
+ it('honours the explicit opt-in and the kill switch over everything else', () => {
132
+ vi.stubEnv('TERM_PROGRAM', 'WezTerm');
133
+ vi.stubEnv('ROBOTA_TURN_MARKS', '1');
134
+ expect(supportsTurnMarks({ override: true })).toBe(true);
135
+ vi.stubEnv('ROBOTA_TURN_MARKS', '0');
136
+ expect(supportsTurnMarks({ override: false })).toBe(false);
137
+ });
138
+
139
+ it('ignores an ambient Robota override without a host choice', () => {
140
+ vi.stubEnv('TERM_PROGRAM', 'WezTerm');
141
+ vi.stubEnv('ROBOTA_TURN_MARKS', '1');
142
+ expect(supportsTurnMarks()).toBe(false);
143
+ });
144
+ });
@@ -0,0 +1,127 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import InputArea from '../InputArea.js';
6
+ import { KeybindingsProvider } from '../keybindings/keybindings-context.js';
7
+ import { parseKeybindingsDocument } from '../keybindings/keybinding-registry.js';
8
+
9
+ import type { IKeybindingsSource } from '../keybindings/node-keybindings-source.js';
10
+ import type { ITuiCommandQueryPort } from '../tui-app-channel-port.js';
11
+
12
+ async function tick(ms = 25): Promise<void> {
13
+ await new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+
16
+ const commandQueryPort: ITuiCommandQueryPort = {
17
+ getCommands: (filter) =>
18
+ [
19
+ { name: 'help', description: 'Show help', source: 'test' },
20
+ { name: 'permissions', description: 'Permissions', source: 'test' },
21
+ ].filter((command) => command.name.startsWith(filter ?? '')),
22
+ getSubcommands: (parent) =>
23
+ parent === 'permissions'
24
+ ? [{ name: 'retry', description: 'Retry a denial', source: 'test' }]
25
+ : [],
26
+ };
27
+
28
+ describe('InputArea editing keys while the slash autocomplete is open', () => {
29
+ it('deletes with backspace', async () => {
30
+ const { stdin, lastFrame } = render(
31
+ <InputArea onSubmit={vi.fn()} isDisabled={false} commandQueryPort={commandQueryPort} />,
32
+ );
33
+ await tick();
34
+ stdin.write('/per');
35
+ await tick();
36
+ expect(lastFrame()).toContain('permissions');
37
+
38
+ stdin.write('\x7f');
39
+ await tick();
40
+ expect(lastFrame()).toMatch(/> \/pe$/m);
41
+
42
+ stdin.write('\x7f\x7f\x7f');
43
+ await tick();
44
+ expect(lastFrame()).toContain('Type a message or /help');
45
+ });
46
+
47
+ it('deletes back out of a tab-completed command whose subcommands stay listed', async () => {
48
+ const { stdin, lastFrame } = render(
49
+ <InputArea onSubmit={vi.fn()} isDisabled={false} commandQueryPort={commandQueryPort} />,
50
+ );
51
+ await tick();
52
+ stdin.write('/per');
53
+ await tick();
54
+ stdin.write('\t');
55
+ await tick();
56
+ expect(lastFrame()).toContain('> /permissions');
57
+ expect(lastFrame()).toContain('retry');
58
+
59
+ stdin.write('\x7f\x7f');
60
+ await tick();
61
+ expect(lastFrame()).toMatch(/> \/permission$/m);
62
+ expect(lastFrame()).not.toContain('retry');
63
+ });
64
+
65
+ it('clears the line with ctrl+u', async () => {
66
+ const { stdin, lastFrame } = render(
67
+ <InputArea onSubmit={vi.fn()} isDisabled={false} commandQueryPort={commandQueryPort} />,
68
+ );
69
+ await tick();
70
+ stdin.write('/he');
71
+ await tick();
72
+ expect(lastFrame()).toContain('help');
73
+
74
+ stdin.write('\x15');
75
+ await tick();
76
+ expect(lastFrame()).toContain('Type a message or /help');
77
+ });
78
+
79
+ it('moves the cursor with the arrow keys so an insertion lands mid-word', async () => {
80
+ const { stdin, lastFrame } = render(
81
+ <InputArea onSubmit={vi.fn()} isDisabled={false} commandQueryPort={commandQueryPort} />,
82
+ );
83
+ await tick();
84
+ stdin.write('/pe');
85
+ await tick();
86
+ expect(lastFrame()).toContain('permissions');
87
+ stdin.write('\x1b[D');
88
+ await tick();
89
+ stdin.write('r');
90
+ await tick();
91
+ expect(lastFrame()).toMatch(/> \/pre$/m);
92
+ });
93
+
94
+ it("follows the user's chat-input bindings, not the defaults", async () => {
95
+ const parsed = parseKeybindingsDocument(
96
+ JSON.stringify({ version: 1, bindings: { 'chat-input': { 'delete-line': 'ctrl+k' } } }),
97
+ '/tmp/keybindings.json',
98
+ );
99
+ if (!parsed.ok) throw new Error(parsed.diagnostic.message);
100
+ const source: IKeybindingsSource = {
101
+ filePath: '/tmp/keybindings.json',
102
+ start: async () => undefined,
103
+ isStarted: () => true,
104
+ ensureFile: async () => '/tmp/keybindings.json',
105
+ getSnapshot: () => parsed.snapshot,
106
+ subscribe: () => () => undefined,
107
+ dispose: () => undefined,
108
+ };
109
+ const { stdin, lastFrame } = render(
110
+ <KeybindingsProvider source={source}>
111
+ <InputArea onSubmit={vi.fn()} isDisabled={false} commandQueryPort={commandQueryPort} />
112
+ </KeybindingsProvider>,
113
+ );
114
+ await tick();
115
+ stdin.write('/he');
116
+ await tick();
117
+ expect(lastFrame()).toContain('help');
118
+
119
+ stdin.write('\x15'); // ctrl+u is no longer bound
120
+ await tick();
121
+ expect(lastFrame()).toMatch(/> \/he$/m);
122
+
123
+ stdin.write('\x0b'); // ctrl+k
124
+ await tick();
125
+ expect(lastFrame()).toContain('Type a message or /help');
126
+ });
127
+ });
@@ -0,0 +1,75 @@
1
+ import React from 'react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { render } from 'ink-testing-library';
4
+ import type { IExecutionWorkspaceEntry } from '@robota-sdk/agent-interface-execution';
5
+ import BackgroundTaskPanel from '../BackgroundTaskPanel.js';
6
+ import { ScreenReaderProvider } from '../screen-reader-context.js';
7
+
8
+ function makeEntry(overrides: Partial<IExecutionWorkspaceEntry>): IExecutionWorkspaceEntry {
9
+ return {
10
+ id: 'task:sched_1',
11
+ sourceId: 'sched_1',
12
+ kind: 'background_task',
13
+ origin: { kind: 'slash_command', sessionId: 'session_1', commandName: 'schedule' },
14
+ taskKind: 'scheduled',
15
+ status: 'sleeping',
16
+ state: 'working',
17
+ title: 'wake',
18
+ unread: false,
19
+ attention: 'none',
20
+ visibility: 'default',
21
+ updatedAt: '2026-01-01T00:00:00.000Z',
22
+ controls: ['select', 'cancel'],
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ /** SCREEN-1992 TC-04: the tick advances the countdown once a second, only while one exists. */
28
+ describe('BackgroundTaskPanel countdown tick', () => {
29
+ beforeEach(() => {
30
+ vi.useFakeTimers();
31
+ vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
32
+ });
33
+ afterEach(() => {
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ it('advances the rendered countdown every second while a schedule sleeps', async () => {
38
+ const { lastFrame, rerender } = render(
39
+ <BackgroundTaskPanel entries={[makeEntry({ nextFireAt: '2026-01-01T00:01:00.000Z' })]} />,
40
+ );
41
+ expect(lastFrame()).toContain('in 1m 0s');
42
+ await vi.advanceTimersByTimeAsync(1000);
43
+ expect(lastFrame()).toContain('in 59s');
44
+ await vi.advanceTimersByTimeAsync(2000);
45
+ expect(lastFrame()).toContain('in 57s');
46
+
47
+ // No sleeping schedule ⇒ the tick stops: the frame no longer changes with time.
48
+ rerender(
49
+ <BackgroundTaskPanel entries={[makeEntry({ status: 'completed', state: 'completed' })]} />,
50
+ );
51
+ expect(vi.getTimerCount()).toBe(0);
52
+ expect(lastFrame()).not.toContain('in ');
53
+ });
54
+
55
+ it('never starts the tick in screen-reader mode, yet renders against the current clock', async () => {
56
+ // Mounted at app start with nothing scheduled, as the real panel is.
57
+ const { lastFrame, rerender } = render(
58
+ <ScreenReaderProvider enabled>
59
+ <BackgroundTaskPanel entries={[makeEntry({ status: 'completed', state: 'completed' })]} />
60
+ </ScreenReaderProvider>,
61
+ );
62
+ // Half an hour later a schedule for one minute out appears: the reader must hear `in 1m`,
63
+ // not the thirty minutes since mount.
64
+ vi.setSystemTime(new Date('2026-01-01T00:30:00.000Z'));
65
+ rerender(
66
+ <ScreenReaderProvider enabled>
67
+ <BackgroundTaskPanel entries={[makeEntry({ nextFireAt: '2026-01-01T00:31:00.000Z' })]} />
68
+ </ScreenReaderProvider>,
69
+ );
70
+ expect(lastFrame()).toContain('in 1m 0s');
71
+ expect(vi.getTimerCount()).toBe(0);
72
+ await vi.advanceTimersByTimeAsync(3000);
73
+ expect(lastFrame()).toContain('in 1m 0s');
74
+ });
75
+ });