@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,243 @@
1
+ /**
2
+ * #3189 — the commands a terminal attached to a workspace daemon runs in its own process.
3
+ *
4
+ * The attached terminal gives them its own handoff and working directory, applies the appearance
5
+ * patch `/theme` asks for itself, and refuses any other host action by name instead of dropping it.
6
+ */
7
+ import { createTerminalClientCommands } from '@robota-sdk/agent-command';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+
10
+ import {
11
+ createTuiClientCommandHost,
12
+ findTuiClientCommand,
13
+ runTuiClientCommand,
14
+ } from '../wire-tui-client-commands.js';
15
+
16
+ import type {
17
+ ITuiClientCommand,
18
+ ITuiClientCommands,
19
+ TTuiClientCommandHost,
20
+ } from '../wire-tui-client-commands.js';
21
+ import type {
22
+ ICommandResult,
23
+ IThemeCatalogueEntry,
24
+ IThemeCataloguePort,
25
+ TAppearanceSettingsPatch,
26
+ TCommandHostAction,
27
+ } from '@robota-sdk/agent-interface-command';
28
+ import type { ITerminalHandoff } from '@robota-sdk/agent-interface-session';
29
+
30
+ /** Stands in for `/shell`: hands the terminal to a "child" that runs in the host's directory. */
31
+ const shellLike: ITuiClientCommand = {
32
+ name: 'shell',
33
+ async execute(host, args) {
34
+ if (!host.canHandoffTerminal()) {
35
+ return { success: false, message: 'An interactive shell is unavailable here.' };
36
+ }
37
+ const cwd = host.getCwd();
38
+ const ran = await host.runWithTerminal(async () => `${args} in ${cwd}`);
39
+ return { success: true, message: ran };
40
+ },
41
+ };
42
+
43
+ function commandReturning(name: string, result: ICommandResult): ITuiClientCommand {
44
+ return { name, execute: () => result };
45
+ }
46
+
47
+ function hostFor(terminalHandoff?: ITerminalHandoff): TTuiClientCommandHost {
48
+ return createTuiClientCommandHost({ terminalHandoff, cwd: '/work/project' });
49
+ }
50
+
51
+ function writerSpy() {
52
+ return {
53
+ writeAppearanceSettings: vi.fn(async (_patch: TAppearanceSettingsPatch) => undefined),
54
+ };
55
+ }
56
+
57
+ const THEMES: IThemeCatalogueEntry[] = [
58
+ { id: 'dark', name: 'Dark', appearance: 'dark', source: 'built-in' },
59
+ { id: 'light', name: 'Light', appearance: 'light', source: 'built-in' },
60
+ ];
61
+ const themeCatalogue: IThemeCataloguePort = {
62
+ listThemes: () => THEMES,
63
+ getTheme: (id) => THEMES.find((theme) => theme.id === id),
64
+ getAppearance: () => ({
65
+ settings: { theme: 'dark', syntaxHighlighting: true, reducedMotion: false },
66
+ }),
67
+ };
68
+
69
+ describe('createTuiClientCommandHost (#3189)', () => {
70
+ it('hands this terminal to the command and runs it in this terminal’s directory', async () => {
71
+ const handedOff = vi.fn();
72
+ const handoff: ITerminalHandoff = {
73
+ canHandoffTerminal: true,
74
+ runWithTerminal: <T>(fn: () => Promise<T>): Promise<T> => {
75
+ handedOff();
76
+ return fn();
77
+ },
78
+ };
79
+ const host = hostFor(handoff);
80
+
81
+ const outcome = await runTuiClientCommand(shellLike, 'ls', host, writerSpy());
82
+
83
+ expect(handedOff).toHaveBeenCalledTimes(1);
84
+ expect(outcome.result).toEqual({ success: true, message: 'ls in /work/project' });
85
+ expect(host.getCommandInvocationSource()).toBe('user');
86
+ });
87
+
88
+ it('without a handoff it cannot take the terminal, so the command takes its refusal path', async () => {
89
+ const host = hostFor(undefined);
90
+
91
+ expect(host.canHandoffTerminal()).toBe(false);
92
+ const outcome = await runTuiClientCommand(shellLike, 'ls', host, writerSpy());
93
+
94
+ expect(outcome.result).toEqual({
95
+ success: false,
96
+ message: 'An interactive shell is unavailable here.',
97
+ });
98
+ await expect(host.runWithTerminal(async () => 'never')).rejects.toThrow(/unavailable/u);
99
+ });
100
+ });
101
+
102
+ describe('runTuiClientCommand (#3189)', () => {
103
+ it('/theme with no arguments opens this terminal’s theme picker and writes nothing', async () => {
104
+ const theme = commandReturning('theme', {
105
+ success: true,
106
+ message: 'Opening the theme picker...',
107
+ uiIntents: [{ type: 'show-theme-picker' }],
108
+ });
109
+ const writers = writerSpy();
110
+
111
+ const outcome = await runTuiClientCommand(theme, '', hostFor(), writers);
112
+
113
+ expect(outcome.uiIntents).toEqual([{ type: 'show-theme-picker' }]);
114
+ expect(outcome.result).toEqual({ success: true, message: 'Opening the theme picker...' });
115
+ expect(writers.writeAppearanceSettings).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it('/theme light, from the real client command set, is written before the command settles', async () => {
119
+ let finishWrite: () => void = () => undefined;
120
+ const written: TAppearanceSettingsPatch[] = [];
121
+ const commands: ITuiClientCommands = {
122
+ commands: createTerminalClientCommands({ themeCatalogue }),
123
+ writeAppearanceSettings: (patch) => {
124
+ written.push(patch);
125
+ return new Promise<void>((resolve) => {
126
+ finishWrite = resolve;
127
+ });
128
+ },
129
+ };
130
+ const theme = findTuiClientCommand(commands, 'theme');
131
+ if (!theme) throw new Error('the client command set has no /theme');
132
+
133
+ let settled = false;
134
+ const running = runTuiClientCommand(theme, 'light', hostFor(), commands).then((outcome) => {
135
+ settled = true;
136
+ return outcome;
137
+ });
138
+ await vi.waitFor(() => expect(written).toEqual([{ theme: 'light' }]));
139
+ await new Promise((resolve) => setImmediate(resolve));
140
+ expect(settled).toBe(false);
141
+
142
+ finishWrite();
143
+ const outcome = await running;
144
+
145
+ expect(outcome.result.success).toBe(true);
146
+ expect(outcome.result.hostActions).toBeUndefined();
147
+ expect(outcome.result.data).toEqual({ appearance: { theme: 'light' } });
148
+ expect(outcome.uiIntents).toEqual([]);
149
+ });
150
+
151
+ it('an invalid appearance patch writes nothing and fails', async () => {
152
+ const command = commandReturning('theme', {
153
+ success: true,
154
+ message: 'Applied.',
155
+ hostActions: [
156
+ {
157
+ type: 'appearance-settings-patch',
158
+ patch: { reducedMotion: 'yes' } as unknown as TAppearanceSettingsPatch,
159
+ },
160
+ ],
161
+ });
162
+ const writers = writerSpy();
163
+
164
+ const outcome = await runTuiClientCommand(command, 'x', hostFor(), writers);
165
+
166
+ expect(outcome.result.success).toBe(false);
167
+ expect(outcome.result.message).toContain('invalid appearance settings patch');
168
+ expect(outcome.uiIntents).toEqual([]);
169
+ expect(writers.writeAppearanceSettings).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it('a host action this terminal cannot perform is refused by name, and nothing beside it is written', async () => {
173
+ const hostActions: TCommandHostAction[] = [
174
+ { type: 'appearance-settings-patch', patch: { theme: 'light' } },
175
+ { type: 'session-exit' },
176
+ ];
177
+ const command = commandReturning('theme', {
178
+ success: true,
179
+ message: 'Applied.',
180
+ hostActions,
181
+ uiIntents: [{ type: 'show-theme-picker' }],
182
+ });
183
+ const writers = writerSpy();
184
+
185
+ const outcome = await runTuiClientCommand(command, '', hostFor(), writers);
186
+
187
+ expect(outcome).toEqual({
188
+ result: { success: false, message: "Cannot apply 'session-exit' on this terminal." },
189
+ uiIntents: [],
190
+ });
191
+ expect(writers.writeAppearanceSettings).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it('a write that fails turns the result into that failure', async () => {
195
+ const command = commandReturning('theme', {
196
+ success: true,
197
+ message: 'Applied.',
198
+ hostActions: [{ type: 'appearance-settings-patch', patch: { theme: 'light' } }],
199
+ });
200
+
201
+ const outcome = await runTuiClientCommand(command, 'light', hostFor(), {
202
+ writeAppearanceSettings: () => Promise.reject(new Error('disk full')),
203
+ });
204
+
205
+ expect(outcome.result).toEqual({
206
+ success: false,
207
+ message: "Failed to apply 'appearance-settings-patch': disk full",
208
+ });
209
+ });
210
+
211
+ it('a failed result passes through unchanged and applies nothing', async () => {
212
+ const failed: ICommandResult = {
213
+ success: false,
214
+ message: 'Unknown theme "nope".',
215
+ hostActions: [{ type: 'appearance-settings-patch', patch: { theme: 'nope' } }],
216
+ };
217
+ const writers = writerSpy();
218
+
219
+ const outcome = await runTuiClientCommand(
220
+ commandReturning('theme', failed),
221
+ 'nope',
222
+ hostFor(),
223
+ writers,
224
+ );
225
+
226
+ expect(outcome.result).toBe(failed);
227
+ expect(outcome.uiIntents).toEqual([]);
228
+ expect(writers.writeAppearanceSettings).not.toHaveBeenCalled();
229
+ });
230
+ });
231
+
232
+ describe('findTuiClientCommand (#3189)', () => {
233
+ it('finds a client command by name, and nothing without a set', () => {
234
+ const commands: ITuiClientCommands = {
235
+ commands: [shellLike],
236
+ writeAppearanceSettings: () => undefined,
237
+ };
238
+
239
+ expect(findTuiClientCommand(commands, 'shell')).toBe(shellLike);
240
+ expect(findTuiClientCommand(commands, 'model')).toBeUndefined();
241
+ expect(findTuiClientCommand(undefined, 'shell')).toBeUndefined();
242
+ });
243
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The startup banner, committed once to the terminal scrollback.
3
+ *
4
+ * Extracted from `App.tsx` under CLI-2004: the art is ASCII, not box-drawing, so a
5
+ * no-box-drawing sweep passes over it unchanged — it needs its own suppression AND its own
6
+ * assertion, and neither is reviewable while the glyphs live inline in a 600-line component.
7
+ * `BANNER_GLYPHS` is the set a test asserts is absent in screen-reader mode.
8
+ */
9
+
10
+ import { Box } from 'ink';
11
+ import React from 'react';
12
+
13
+ import { Text } from './SafeText.js';
14
+ import { usePalette } from './theme/index.js';
15
+
16
+ /** The ASCII art itself. */
17
+ export const BANNER_ART = `
18
+ ____ ___ ____ ___ _____ _
19
+ | _ \\ / _ \\| __ ) / _ \\_ _|/ \\
20
+ | |_) | | | | _ \\| | | || | / _ \\
21
+ | _ <| |_| | |_) | |_| || |/ ___ \\
22
+ |_| \\_\\\\___/|____/ \\___/ |_/_/ \\_\\
23
+ `;
24
+
25
+ /**
26
+ * The characters the art is drawn from. None is a box-drawing character, which is exactly why the
27
+ * banner needs a suppression test of its own.
28
+ */
29
+ export const BANNER_GLYPHS: readonly string[] = ['_', '|', '\\', '/', '<'];
30
+
31
+ /** The banner as it is committed to scrollback: the art plus the version line. */
32
+ export function AppBanner({ version }: { version: string }): React.ReactElement {
33
+ const palette = usePalette();
34
+ return (
35
+ <Box flexDirection="column" paddingX={1} marginBottom={1}>
36
+ <Text color={palette.text.accent} bold>
37
+ {BANNER_ART}
38
+ </Text>
39
+ <Text dimColor> v{version}</Text>
40
+ </Box>
41
+ );
42
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * SCREEN-010 / CLI-2004 — what gets committed to the terminal's native scrollback.
3
+ *
4
+ * The App renders exactly this list inside a single Ink `<Static>`, so each item is written once and
5
+ * never repainted. Building the list is a pure function so the ONE decision that varies — whether
6
+ * the startup banner is committed at all — is assertable without mounting the App.
7
+ */
8
+
9
+ import type { IHistoryEntry } from '@robota-sdk/agent-core';
10
+
11
+ /** A committed item: the startup banner, or one conversation entry. */
12
+ export type TStaticItem =
13
+ | { readonly kind: 'banner'; readonly version: string }
14
+ | { readonly kind: 'entry'; readonly entry: IHistoryEntry };
15
+
16
+ export interface IBuildStaticItemsInputs {
17
+ history: readonly IHistoryEntry[];
18
+ version: string | undefined;
19
+ /**
20
+ * Screen-reader mode. The banner is ASCII art with no spoken meaning — a reader announces it
21
+ * character by character before the session has said anything — so the mode omits the item
22
+ * entirely rather than styling it differently.
23
+ */
24
+ screenReader: boolean;
25
+ }
26
+
27
+ const FALLBACK_VERSION = '0.0.0';
28
+
29
+ /** Build the `<Static>` item list. */
30
+ export function buildStaticItems(inputs: IBuildStaticItemsInputs): TStaticItem[] {
31
+ const banner: TStaticItem[] = inputs.screenReader
32
+ ? []
33
+ : [{ kind: 'banner', version: inputs.version ?? FALLBACK_VERSION }];
34
+ return [...banner, ...inputs.history.map((entry): TStaticItem => ({ kind: 'entry', entry }))];
35
+ }
@@ -0,0 +1,140 @@
1
+ import type { TStaticItem } from './app-static-items.js';
2
+ import type { IAppThemeViewModel } from './hooks/useAppThemeState.js';
3
+ import type { IInputAreaHistorySearch } from './hooks/useInputAreaHistorySearch.js';
4
+ import type { ITuiCommandQueryPort } from './tui-app-channel-port.js';
5
+ import type { ITuiSessionEventNotice } from './tui-session-events.js';
6
+ import type { IPendingPermissionRequest } from './types.js';
7
+ import type {
8
+ IActionRequest,
9
+ IHistoryEntry,
10
+ TActionResponse,
11
+ TModelEffortSelection,
12
+ TPermissionMode,
13
+ } from '@robota-sdk/agent-core';
14
+ import type {
15
+ ICommandPluginAdapter,
16
+ IStatusLineCommandSettings,
17
+ } from '@robota-sdk/agent-interface-command';
18
+ import type {
19
+ IExecutionDetailPage,
20
+ IExecutionWorkspaceEntry,
21
+ IExecutionWorkspaceSnapshot,
22
+ } from '@robota-sdk/agent-interface-execution';
23
+ import type {
24
+ ISessionListingEntry,
25
+ IToolState,
26
+ } from '@robota-sdk/agent-interface-session';
27
+ import type { ITransportRegistryView } from '@robota-sdk/agent-interface-transport';
28
+
29
+ export interface IAppViewModel {
30
+ readonly staticItems: TStaticItem[];
31
+ /** Printing starts over from the first item when this changes (see `transcriptGeneration`). */
32
+ readonly transcriptKey: number;
33
+ readonly handoffSuspended: boolean;
34
+ readonly updateNotice: string | undefined;
35
+ readonly coordinationError: string | undefined;
36
+ readonly coordinationPending: boolean;
37
+ readonly sessionEventNotices: readonly ITuiSessionEventNotice[];
38
+ readonly isShuttingDown: boolean;
39
+ readonly stream: {
40
+ readonly text: string;
41
+ readonly activeTools: readonly IToolState[];
42
+ readonly isThinking: boolean;
43
+ readonly isStalled: boolean;
44
+ readonly lastErrorMessage: string | null;
45
+ };
46
+ readonly background: IAppBackgroundViewModel;
47
+ readonly permissionRequest: IPendingPermissionRequest | null;
48
+ readonly pendingUserAction: IActionRequest | null;
49
+ readonly resolveUserAction: (request: IActionRequest, response: TActionResponse) => void;
50
+ readonly plugin: IAppPluginViewModel;
51
+ readonly transport: IAppTransportViewModel;
52
+ readonly sessionPicker: IAppSessionPickerViewModel;
53
+ /** SCREEN-2002: the live theme, the resolved motion setting, and the picker over them. */
54
+ readonly theme: IAppThemeViewModel;
55
+ readonly contextPercentage: number;
56
+ readonly input: IAppInputViewModel;
57
+ readonly status: IAppStatusViewModel;
58
+ }
59
+
60
+ export type { IAppThemePickerViewModel, IAppThemeViewModel } from './hooks/useAppThemeState.js';
61
+
62
+ export interface IAppBackgroundViewModel {
63
+ readonly entries: IExecutionWorkspaceEntry[];
64
+ readonly focusedIndex: number | null;
65
+ readonly selectedEntry: IExecutionWorkspaceEntry | undefined;
66
+ readonly detail: {
67
+ readonly page: IExecutionDetailPage | null;
68
+ readonly loading: boolean;
69
+ readonly error: string | undefined;
70
+ };
71
+ readonly switcherVisible: boolean;
72
+ readonly snapshot: IExecutionWorkspaceSnapshot | null;
73
+ readonly selectedEntryId: string | undefined;
74
+ readonly select: (entryId: string) => void;
75
+ readonly closeSwitcher: () => void;
76
+ readonly attachToFork: (entry: IExecutionWorkspaceEntry) => void;
77
+ }
78
+
79
+ export interface IAppPluginViewModel {
80
+ readonly visible: boolean;
81
+ readonly callbacks: ICommandPluginAdapter;
82
+ readonly close: () => void;
83
+ readonly addMessage: (content: string) => void;
84
+ }
85
+
86
+ export interface IAppTransportViewModel {
87
+ readonly visible: boolean;
88
+ readonly registry: ITransportRegistryView | undefined;
89
+ readonly close: () => void;
90
+ }
91
+
92
+ export interface IAppSessionPickerViewModel {
93
+ readonly visible: boolean;
94
+ readonly sessions: readonly ISessionListingEntry[];
95
+ readonly select: (sessionId: string) => void;
96
+ readonly cancel: () => void;
97
+ }
98
+
99
+ export interface IAppInputViewModel {
100
+ /** FLOW-2006: the deep link's prefill, present only until the composer takes it. */
101
+ readonly initialValue?: string | undefined;
102
+ /** Called by the composer the first time it seeds itself, so a remount does not re-seed. */
103
+ readonly consumeInitialValue?: (() => void) | undefined;
104
+ /** True while the composer's text is the one a deep link supplied. */
105
+ readonly externalPromptOrigin?: boolean | undefined;
106
+ readonly submit: (input: string) => Promise<void>;
107
+ readonly cancelQueue: () => void;
108
+ readonly disabled: boolean;
109
+ readonly queueCancellationDisabled: boolean;
110
+ readonly isAborting: boolean;
111
+ readonly pendingPrompt: string | null;
112
+ readonly pendingCount: number;
113
+ readonly commandQueryPort: ITuiCommandQueryPort;
114
+ readonly sessionName: string | undefined;
115
+ readonly history: readonly IHistoryEntry[];
116
+ readonly focusBackgroundList: () => void;
117
+ /** SCREEN-1993: the stored-prompt search surface; absent ⇒ the feature is off. */
118
+ readonly historySearch: IInputAreaHistorySearch | undefined;
119
+ }
120
+
121
+ export interface IAppStatusViewModel {
122
+ readonly cwd: string;
123
+ readonly permissionMode: TPermissionMode;
124
+ readonly modelId: string | undefined;
125
+ readonly providerType: string | undefined;
126
+ readonly sessionId: string;
127
+ readonly isThinking: boolean;
128
+ readonly activeToolCount: number;
129
+ readonly activeBackgroundTaskCount: number;
130
+ readonly hasPendingPrompt: boolean;
131
+ readonly contextState: { percentage: number; usedTokens: number; maxTokens: number };
132
+ readonly sessionName: string | undefined;
133
+ readonly settings: IStatusLineCommandSettings;
134
+ readonly activeAgentLabel: string | undefined;
135
+ readonly activePresetId: string | undefined;
136
+ readonly effort: TModelEffortSelection | undefined;
137
+ readonly gitRefreshToken: number;
138
+ /** This terminal only observes the session; the status bar says so. */
139
+ readonly readOnly: boolean;
140
+ }
@@ -0,0 +1,14 @@
1
+ import type { TClientMessage, TServerMessage } from '@robota-sdk/agent-transport/client';
2
+
3
+ /**
4
+ * A terminal's end of the session protocol to a session it is attached to. The terminal only sends
5
+ * and listens; closing the connection belongs to whoever opened it.
6
+ */
7
+ export interface IAttachedSessionConnection {
8
+ send(message: TClientMessage): void;
9
+ subscribe(listener: (message: TServerMessage) => void): () => void;
10
+ onClose(listener: () => void): () => void;
11
+ }
12
+
13
+ /** Why an attached terminal stopped showing the session: the user left, or the connection closed. */
14
+ export type TAttachedSessionEnd = 'user' | 'closed';
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { AttentionCoordinator } from '../attention-coordinator.js';
4
+
5
+ import type { IAttentionSource, TAttentionListener } from '../attention-tracker.js';
6
+ import type { IExecutionWorkspaceSnapshot } from '@robota-sdk/agent-interface-execution';
7
+
8
+ class FakeSource implements IAttentionSource {
9
+ attended = true;
10
+ readonly source = 'focus' as const;
11
+ private readonly listeners = new Set<TAttentionListener>();
12
+ subscribe(listener: TAttentionListener): () => void {
13
+ this.listeners.add(listener);
14
+ return () => this.listeners.delete(listener);
15
+ }
16
+ lost(at: string): void {
17
+ this.attended = false;
18
+ for (const listener of this.listeners) listener({ kind: 'lost', at });
19
+ }
20
+ returned(at: string): void {
21
+ this.attended = true;
22
+ for (const listener of this.listeners) listener({ kind: 'returned', at });
23
+ }
24
+ get listenerCount(): number {
25
+ return this.listeners.size;
26
+ }
27
+ }
28
+
29
+ function snapshot(
30
+ entries: ReadonlyArray<{
31
+ id: string;
32
+ state: 'working' | 'completed' | 'failed';
33
+ kind?: 'main_thread' | 'background_task';
34
+ }>,
35
+ ) {
36
+ return {
37
+ sessionId: 's1',
38
+ updatedAt: '2026-01-01T00:00:00.000Z',
39
+ entries: entries.map((entry) => ({
40
+ id: entry.id,
41
+ sourceId: entry.id,
42
+ kind: entry.kind ?? ('background_task' as const),
43
+ origin: { kind: 'slash_command' as const, sessionId: 's1' },
44
+ status: 'running' as const,
45
+ state: entry.state,
46
+ title: entry.id,
47
+ unread: false,
48
+ attention: 'none' as const,
49
+ visibility: 'default' as const,
50
+ updatedAt: '2026-01-01T00:00:00.000Z',
51
+ controls: ['select' as const],
52
+ })),
53
+ } satisfies IExecutionWorkspaceSnapshot;
54
+ }
55
+
56
+ describe('AttentionCoordinator (SCREEN-1992 TC-02 wiring)', () => {
57
+ it('pushes one recap per unattended interval from the channel events it is fed', () => {
58
+ const source = new FakeSource();
59
+ const recaps: string[] = [];
60
+ const coordinator = new AttentionCoordinator({ source, onRecap: (line) => recaps.push(line) });
61
+ coordinator.wire();
62
+ coordinator.wire();
63
+ expect(source.listenerCount).toBe(1);
64
+
65
+ // Before the interval: an idle main thread and a task that already finished.
66
+ coordinator.onWorkspaceSnapshot(
67
+ snapshot([
68
+ { id: 'main:s1', state: 'completed', kind: 'main_thread' },
69
+ { id: 'task:done', state: 'completed' },
70
+ { id: 'task:a', state: 'working' },
71
+ ]),
72
+ );
73
+ source.lost('2026-01-01T00:00:00.000Z');
74
+ coordinator.onTurnSource('agent-wakeup');
75
+ coordinator.onWorkspaceSnapshot(
76
+ snapshot([
77
+ { id: 'main:s1', state: 'working', kind: 'main_thread' },
78
+ { id: 'task:done', state: 'completed' },
79
+ { id: 'task:a', state: 'working' },
80
+ ]),
81
+ );
82
+ coordinator.onComplete();
83
+ coordinator.onNeedsInput();
84
+ coordinator.onWorkspaceSnapshot(
85
+ snapshot([
86
+ { id: 'main:s1', state: 'completed', kind: 'main_thread' },
87
+ { id: 'task:done', state: 'completed' },
88
+ { id: 'task:a', state: 'failed' },
89
+ ]),
90
+ );
91
+ source.returned('2026-01-01T00:03:00.000Z');
92
+ expect(recaps).toEqual(['While away 3m: 1 turn finished (1 wake) · 1 needs input · 1 failed']);
93
+
94
+ // Attended activity is never recapped.
95
+ coordinator.onComplete();
96
+ source.lost('2026-01-01T00:10:00.000Z');
97
+ source.returned('2026-01-01T00:11:00.000Z');
98
+ expect(recaps).toHaveLength(1);
99
+
100
+ coordinator.unwire();
101
+ expect(source.listenerCount).toBe(0);
102
+ });
103
+
104
+ it('starts an interval immediately when wired while the user is already away', () => {
105
+ const source = new FakeSource();
106
+ source.attended = false;
107
+ const recaps: string[] = [];
108
+ const coordinator = new AttentionCoordinator({
109
+ source,
110
+ onRecap: (line) => recaps.push(line),
111
+ now: () => Date.parse('2026-01-01T00:00:00.000Z'),
112
+ });
113
+ coordinator.wire();
114
+ coordinator.onTurnSource('user');
115
+ coordinator.onComplete();
116
+ source.returned('2026-01-01T00:05:00.000Z');
117
+ expect(recaps).toEqual(['While away 5m: 1 turn finished']);
118
+ });
119
+
120
+ it('takes the first snapshot as the baseline when wired while already away', () => {
121
+ const source = new FakeSource();
122
+ source.attended = false;
123
+ const recaps: string[] = [];
124
+ const coordinator = new AttentionCoordinator({
125
+ source,
126
+ onRecap: (line) => recaps.push(line),
127
+ now: () => Date.parse('2026-01-01T00:00:00.000Z'),
128
+ });
129
+ coordinator.wire();
130
+ // A resumed session restores an already-finished task: not news.
131
+ coordinator.onWorkspaceSnapshot(
132
+ snapshot([
133
+ { id: 'task:restored', state: 'completed' },
134
+ { id: 'task:live', state: 'working' },
135
+ ]),
136
+ );
137
+ coordinator.onWorkspaceSnapshot(
138
+ snapshot([
139
+ { id: 'task:restored', state: 'completed' },
140
+ { id: 'task:live', state: 'failed' },
141
+ ]),
142
+ );
143
+ source.returned('2026-01-01T00:04:00.000Z');
144
+ expect(recaps).toEqual(['While away 4m: 1 failed']);
145
+ });
146
+
147
+ it('never reports the main thread finishing a turn as a completed entry', () => {
148
+ const source = new FakeSource();
149
+ const recaps: string[] = [];
150
+ const coordinator = new AttentionCoordinator({ source, onRecap: (line) => recaps.push(line) });
151
+ coordinator.wire();
152
+ // The user leaves mid-turn: the main thread is `working` when attention is lost …
153
+ coordinator.onWorkspaceSnapshot(
154
+ snapshot([{ id: 'main:s1', state: 'working', kind: 'main_thread' }]),
155
+ );
156
+ source.lost('2026-01-01T00:00:00.000Z');
157
+ coordinator.onTurnSource('user');
158
+ coordinator.onComplete();
159
+ // … and idle (`completed`) when they return. That is the turn, already counted, not an entry.
160
+ coordinator.onWorkspaceSnapshot(
161
+ snapshot([{ id: 'main:s1', state: 'completed', kind: 'main_thread' }]),
162
+ );
163
+ source.returned('2026-01-01T00:02:00.000Z');
164
+ expect(recaps).toEqual(['While away 2m: 1 turn finished']);
165
+ });
166
+ });