@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,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('PRODUCT_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('PRODUCT_TURN_MARKS', '1');
134
+ expect(supportsTurnMarks({ override: true })).toBe(true);
135
+ vi.stubEnv('PRODUCT_TURN_MARKS', '0');
136
+ expect(supportsTurnMarks({ override: false })).toBe(false);
137
+ });
138
+
139
+ it('ignores an ambient Fixture Agent override without a host choice', () => {
140
+ vi.stubEnv('TERM_PROGRAM', 'WezTerm');
141
+ vi.stubEnv('PRODUCT_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
+ });
@@ -0,0 +1,98 @@
1
+ import React from 'react';
2
+ import { describe, expect, it } 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
+
7
+ function makeEntry(overrides: Partial<IExecutionWorkspaceEntry>): IExecutionWorkspaceEntry {
8
+ return {
9
+ id: 'task:agent_1',
10
+ sourceId: 'agent_1',
11
+ kind: 'background_task',
12
+ origin: { kind: 'slash_command', sessionId: 'session_1', commandName: 'agent' },
13
+ taskKind: 'agent',
14
+ status: 'running',
15
+ state: 'working',
16
+ title: 'general-purpose',
17
+ subtitle: 'agent',
18
+ preview: 'Analyze backlog',
19
+ unread: false,
20
+ attention: 'none',
21
+ visibility: 'default',
22
+ updatedAt: '2026-05-09T00:00:00.000Z',
23
+ controls: ['select', 'cancel'],
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe('BackgroundTaskPanel', () => {
29
+ it('renders SDK workspace entries with compact markers instead of raw task ids', () => {
30
+ const { lastFrame } = render(
31
+ <BackgroundTaskPanel
32
+ entries={[
33
+ makeEntry({ id: 'task:agent_1', status: 'running' }),
34
+ makeEntry({
35
+ id: 'task:agent_2',
36
+ status: 'completed',
37
+ state: 'completed',
38
+ preview: 'Done',
39
+ }),
40
+ makeEntry({
41
+ id: 'task:agent_3',
42
+ status: 'failed',
43
+ state: 'failed',
44
+ attention: 'failed',
45
+ preview: 'Timed out',
46
+ }),
47
+ ]}
48
+ />,
49
+ );
50
+
51
+ const frame = lastFrame()!;
52
+ expect(frame).toContain('Background work');
53
+ // SCREEN-1992: the state word sits beside the glyph, so a reader hears it and no-color sees it.
54
+ expect(frame).toContain('├ ⟳ working general-purpose agent');
55
+ expect(frame).toContain('├ ✓ completed general-purpose agent · completed');
56
+ expect(frame).toContain('└ ✗ failed general-purpose agent · failed');
57
+ expect(frame).not.toContain('agent_1');
58
+ expect(frame).not.toContain('agent_2');
59
+ expect(frame).not.toContain('agent_3');
60
+ });
61
+
62
+ it('advertises the keyboard drill-in when not focused (SCREEN-013/014)', () => {
63
+ const { lastFrame } = render(
64
+ <BackgroundTaskPanel entries={[makeEntry({ id: 'task:agent_1' })]} />,
65
+ );
66
+ const frame = lastFrame()!;
67
+ // Entry affordance: ↓ to select, Ctrl+B for the full switcher.
68
+ expect(frame).toContain('select');
69
+ expect(frame).toContain('Ctrl+B');
70
+ });
71
+
72
+ it('shows the move/open affordance when a row is focused (SCREEN-014)', () => {
73
+ const { lastFrame } = render(
74
+ <BackgroundTaskPanel
75
+ entries={[makeEntry({ id: 'task:agent_1' }), makeEntry({ id: 'task:agent_2' })]}
76
+ focusedIndex={0}
77
+ />,
78
+ );
79
+ const frame = lastFrame()!;
80
+ expect(frame).toContain('Enter open');
81
+ expect(frame).toContain('Esc back');
82
+ });
83
+
84
+ it('keeps a long-preview row on a single line (SCREEN-011)', () => {
85
+ const longPreview =
86
+ 'Read each of the following backlog files and analyze the current status, implementation ' +
87
+ 'difficulty, dependencies, and estimated effort for every item; output a structured summary';
88
+ const { lastFrame } = render(
89
+ <BackgroundTaskPanel entries={[makeEntry({ id: 'task:agent_1', preview: longPreview })]} />,
90
+ );
91
+ const frame = lastFrame()!;
92
+ // The row with the connector must be exactly one line — no wrapped continuation line.
93
+ const rowLines = frame.split('\n').filter((line) => line.includes('⟳'));
94
+ expect(rowLines).toHaveLength(1);
95
+ // The connector + glyph still lead the single row line.
96
+ expect(rowLines[0]).toMatch(/└ ⟳ working general-purpose agent/);
97
+ });
98
+ });
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { IExecutionWorkspaceEntry } from '@robota-sdk/agent-interface-execution';
3
+ import {
4
+ BACKGROUND_TASK_CONNECTORS,
5
+ formatBackgroundTaskRow,
6
+ } from '../background-task-row-format.js';
7
+
8
+ function makeEntry(overrides: Partial<IExecutionWorkspaceEntry>): IExecutionWorkspaceEntry {
9
+ return {
10
+ id: 'task:agent_1',
11
+ sourceId: 'agent_1',
12
+ kind: 'background_task',
13
+ origin: { kind: 'slash_command', sessionId: 'session_1', commandName: 'agent' },
14
+ taskKind: 'agent',
15
+ status: 'running',
16
+ state: 'working',
17
+ title: 'Explore',
18
+ subtitle: 'general-purpose',
19
+ preview: 'Analyze backlog',
20
+ unread: false,
21
+ attention: 'none',
22
+ visibility: 'default',
23
+ updatedAt: '2026-05-09T00:00:00.000Z',
24
+ controls: ['select', 'cancel'],
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ describe('formatBackgroundTaskRow', () => {
30
+ it('formats running SDK workspace entries without raw task ids', () => {
31
+ const row = formatBackgroundTaskRow(makeEntry({ id: 'task:agent_1' }), { isLast: true });
32
+
33
+ expect(row.connector).toBe('└');
34
+ expect(row.marker).toBe('⟳'); // running → shared status glyph (SCREEN-005)
35
+ // marker symbol AND colour come from the same glyph now (SCREEN-007): the running
36
+ // glyph is yellow, so the row colour is yellow (was cyan via the old view-model rule).
37
+ expect(row.statusKind).toBe('running');
38
+ expect(row.label).toBe('Explore agent');
39
+ expect(row.segments).toEqual(['running', 'agent · general-purpose']);
40
+ expect(row.preview).toBe('Analyze backlog');
41
+ expect(row.accessibleText).not.toContain('agent_1');
42
+ });
43
+
44
+ it('formats failed and completed rows from SDK-owned status and attention', () => {
45
+ const failed = formatBackgroundTaskRow(
46
+ makeEntry({
47
+ id: 'task:agent_2',
48
+ status: 'failed',
49
+ attention: 'failed',
50
+ preview: 'Timed out',
51
+ }),
52
+ { isLast: false },
53
+ );
54
+ const completed = formatBackgroundTaskRow(
55
+ makeEntry({ id: 'task:agent_3', status: 'completed', preview: 'Summary ready' }),
56
+ );
57
+
58
+ expect(failed.connector).toBe('├');
59
+ expect(failed.marker).toBe('✗'); // failed → error glyph (SCREEN-005)
60
+ expect(failed.statusKind).toBe('error');
61
+ expect(failed.preview).toBe('Timed out');
62
+ expect(completed.marker).toBe('✓'); // completed → success glyph (SCREEN-005)
63
+ expect(completed.statusKind).toBe('success');
64
+ expect(completed.preview).toBe('Summary ready');
65
+ });
66
+ });
67
+
68
+ /** CLI-2004: the tree connectors are drawing, and a reader announces them on every row. */
69
+ describe('formatBackgroundTaskRow in screen-reader mode', () => {
70
+ it('uses the plain connector instead of the box-drawing branch/last glyphs', () => {
71
+ const branch = formatBackgroundTaskRow(makeEntry({}), { isLast: false, screenReader: true });
72
+ const last = formatBackgroundTaskRow(makeEntry({}), { isLast: true, screenReader: true });
73
+
74
+ expect(branch.connector).toBe(BACKGROUND_TASK_CONNECTORS.plain);
75
+ expect(last.connector).toBe(BACKGROUND_TASK_CONNECTORS.plain);
76
+ expect(branch.accessibleText.startsWith(BACKGROUND_TASK_CONNECTORS.plain)).toBe(true);
77
+ });
78
+
79
+ it('keeps the tree connectors outside the mode', () => {
80
+ expect(formatBackgroundTaskRow(makeEntry({}), { isLast: false }).connector).toBe(
81
+ BACKGROUND_TASK_CONNECTORS.branch,
82
+ );
83
+ expect(formatBackgroundTaskRow(makeEntry({}), { isLast: true }).connector).toBe(
84
+ BACKGROUND_TASK_CONNECTORS.last,
85
+ );
86
+ });
87
+ });
88
+
89
+ /** SCREEN-1992 TC-07: the state word beside the glyph, the headline and the countdown. */
90
+ describe('formatBackgroundTaskRow state, headline and countdown', () => {
91
+ const now = new Date('2026-01-01T00:00:00.000Z');
92
+
93
+ it('places the state word beside the glyph and carries it in the accessible text', () => {
94
+ const row = formatBackgroundTaskRow(makeEntry({ status: 'cancelled', state: 'stopped' }), {
95
+ isLast: true,
96
+ now,
97
+ });
98
+ expect(row.state).toBe('stopped');
99
+ expect(row.accessibleText.startsWith('└ ⊗ stopped Explore agent')).toBe(true);
100
+ });
101
+
102
+ it('adds the headline only when it says more than the preview, marking a question', () => {
103
+ const same = formatBackgroundTaskRow(
104
+ makeEntry({ headline: { kind: 'activity', text: 'Analyze backlog' } }),
105
+ { now },
106
+ );
107
+ expect(same.headline).toBeUndefined();
108
+ const question = formatBackgroundTaskRow(
109
+ makeEntry({
110
+ status: 'waiting_permission',
111
+ state: 'needs-input',
112
+ attention: 'permission',
113
+ headline: { kind: 'question', text: 'Allow Bash?' },
114
+ }),
115
+ { now },
116
+ );
117
+ expect(question.headline).toBe('? Allow Bash?');
118
+ expect(question.accessibleText).toContain('needs-input');
119
+ expect(question.accessibleText).toContain('? Allow Bash?');
120
+ });
121
+
122
+ it('renders a countdown against the given clock for a sleeping schedule only', () => {
123
+ const sleeping = formatBackgroundTaskRow(
124
+ makeEntry({
125
+ taskKind: 'scheduled',
126
+ status: 'sleeping',
127
+ state: 'working',
128
+ nextFireAt: '2026-01-01T00:04:59.000Z',
129
+ }),
130
+ { now },
131
+ );
132
+ expect(sleeping.countdown).toBe('in 4m 59s');
133
+ expect(sleeping.accessibleText.endsWith('in 4m 59s')).toBe(true);
134
+ expect(formatBackgroundTaskRow(makeEntry({}), { now }).countdown).toBeUndefined();
135
+ });
136
+ });
137
+
138
+ describe('formatBackgroundTaskRow headline dedup against the subtitle (PR #2749 review)', () => {
139
+ it('drops a headline the subtitle already carries, such as a wake instruction', () => {
140
+ const row = formatBackgroundTaskRow(
141
+ makeEntry({
142
+ taskKind: 'scheduled',
143
+ status: 'sleeping',
144
+ subtitle: '↻ wake "say hello"',
145
+ preview: undefined,
146
+ headline: { kind: 'activity', text: 'say hello' },
147
+ }),
148
+ { now: new Date('2026-01-01T00:00:00.000Z') },
149
+ );
150
+ expect(row.headline).toBeUndefined();
151
+ });
152
+ });