@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,98 @@
1
+ /**
2
+ * TERM-002: TUI implementation of the `ITerminalHandoff` transport contract.
3
+ *
4
+ * Manual suspend/resume (NOT Ink 7.1.0 `suspendTerminal`): while a child process owns the real
5
+ * terminal, the App renders nothing so Ink unmounts its input hooks and releases raw mode; the Ink
6
+ * frame is cleared; the caller's `fn` runs the child with inherited stdio; then the App re-renders.
7
+ *
8
+ * The framework (`InteractiveSession`) owns the orchestration (exclusivity, fast-fail) — this class
9
+ * only performs the actual screen release/reclaim. It spawns nothing itself (platform-neutral).
10
+ */
11
+ import type { ITerminalHandoff } from '@robota-sdk/agent-interface-session';
12
+
13
+ /** How the App suspends/resumes its rendering, registered into the controller on mount. */
14
+ export interface ITuiSuspendHooks {
15
+ /** Render nothing (release Ink input/raw mode); resolves once that has committed. */
16
+ suspend(): Promise<void>;
17
+ /** Resume rendering and force a redraw. */
18
+ resume(): void;
19
+ }
20
+
21
+ /**
22
+ * SCREEN-1992: terminal modes the TUI negotiated (focus reporting) are released before a child owns
23
+ * the terminal and re-negotiated once it is reclaimed, so the child never receives `CSI I`/`CSI O`
24
+ * and a child that reset the mode does not leave the TUI blind afterwards.
25
+ */
26
+ export interface ITerminalModeHooks {
27
+ /** May be async: SCREEN-2670 drains the parked screen-reader output before the child gets the TTY. */
28
+ preSuspend(): void | Promise<void>;
29
+ postResume(): void;
30
+ }
31
+
32
+ /** Minimal slice of the Ink render instance the controller needs. */
33
+ export interface IInkClearable {
34
+ clear(): void;
35
+ }
36
+
37
+ export class TerminalHandoffController implements ITerminalHandoff {
38
+ private hooks?: ITuiSuspendHooks;
39
+ private instance?: IInkClearable;
40
+ private modeHooks?: ITerminalModeHooks;
41
+
42
+ /**
43
+ * The App registers how to suspend/resume Ink. Returns an unregister function for cleanup on
44
+ * unmount. Only a mounted App provides these hooks, which gates `canHandoffTerminal`.
45
+ */
46
+ registerSuspendHooks(hooks: ITuiSuspendHooks): () => void {
47
+ this.hooks = hooks;
48
+ return () => {
49
+ if (this.hooks === hooks) this.hooks = undefined;
50
+ };
51
+ }
52
+
53
+ /** render.tsx registers the terminal-mode bracket (SCREEN-1992) before `render()`. */
54
+ setTerminalModeHooks(hooks: ITerminalModeHooks | undefined): void {
55
+ this.modeHooks = hooks;
56
+ }
57
+
58
+ /** render.tsx supplies the Ink instance (for `clear()`) after `render()` returns. */
59
+ setInkInstance(instance: IInkClearable): void {
60
+ this.instance = instance;
61
+ }
62
+
63
+ get canHandoffTerminal(): boolean {
64
+ return (
65
+ process.stdin.isTTY === true && process.stdout.isTTY === true && this.hooks !== undefined
66
+ );
67
+ }
68
+
69
+ async runWithTerminal<T>(fn: () => Promise<T>): Promise<T> {
70
+ const hooks = this.hooks;
71
+ if (!this.canHandoffTerminal || hooks === undefined) {
72
+ throw new Error(
73
+ 'TUI terminal handoff unavailable: no interactive TTY, or the App is not mounted.',
74
+ );
75
+ }
76
+ await hooks.suspend();
77
+ await this.modeHooks?.preSuspend();
78
+ this.instance?.clear();
79
+ // Releasing Ink's React input hooks (empty render) is not enough: the parent process still holds
80
+ // a raw-mode TTY read on stdin, which (a) steals input from the inherited child and (b) starves
81
+ // the parent event loop so the child's exit is never observed — the handoff would hang forever.
82
+ // Explicitly hand stdin to the child by dropping raw mode and pausing the parent's reader; Ink
83
+ // re-grabs stdin (raw mode + resume) when its input hooks re-mount on resume.
84
+ const stdin = process.stdin;
85
+ if (stdin.isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(false);
86
+ stdin.pause();
87
+ try {
88
+ return await fn();
89
+ } finally {
90
+ // Always reclaim the screen, even when the child failed. Re-rendering re-mounts the input
91
+ // hooks, which is what restores raw mode and resumes the parent's stdin reader.
92
+ if (stdin.isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
93
+ stdin.resume();
94
+ hooks.resume();
95
+ this.modeHooks?.postResume();
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * CLI-2004 — OSC 133 shell-integration marks at agent-turn boundaries.
3
+ *
4
+ * `OSC 133 ; A` marks prompt start, `; B` prompt end, `; C` pre-execution and `; D` execution
5
+ * finished. Terminals that implement the protocol turn those into jump-to-previous-prompt
6
+ * navigation, which is how a reader reviews a long session without scrolling line by line.
7
+ *
8
+ * Written OUTSIDE Ink, following the `use-terminal-title.ts` carve-out: `sanitize-terminal-text.ts`
9
+ * strips OSC from untrusted text and the `tui-safe-text-boundary` scan permits only `SafeText` to
10
+ * import Ink's `Text`, so an escape sequence this package writes deliberately cannot go through a
11
+ * component. Unlike the title writer there is no interpolated payload here at all — the four
12
+ * sequences are constants — so nothing needs sanitizing.
13
+ *
14
+ * A terminal that does not implement OSC 133 discards the unknown sequence silently, so emission is
15
+ * safe wherever it is on; `supportsTurnMarks()` (terminal-capabilities.ts) carries the documented
16
+ * negatives rather than a capability probe.
17
+ */
18
+
19
+ /** The four boundary marks, in the order one turn emits them. */
20
+ export type TTurnMark = 'promptStart' | 'promptEnd' | 'turnStart' | 'turnEnd';
21
+
22
+ const MARK_CODES: Readonly<Record<TTurnMark, string>> = {
23
+ promptStart: 'A',
24
+ promptEnd: 'B',
25
+ turnStart: 'C',
26
+ turnEnd: 'D',
27
+ };
28
+
29
+ /** The exact byte sequence for one mark. */
30
+ export function turnMarkSequence(mark: TTurnMark): string {
31
+ return `\x1b]133;${MARK_CODES[mark]}\x07`;
32
+ }
33
+
34
+ export interface ITurnMarkWriterOptions {
35
+ /** Screen-reader mode. Off ⇒ nothing is ever written. */
36
+ enabled: boolean;
37
+ /** Documented per-terminal support gate. */
38
+ supported: () => boolean;
39
+ /** Injected sink; defaults to the process's own stdout. */
40
+ write?: (text: string) => void;
41
+ }
42
+
43
+ export interface ITurnMarkWriter {
44
+ emit(mark: TTurnMark): void;
45
+ }
46
+
47
+ /**
48
+ * Build the mark writer. Both gates are read at emit time, not construction time, so a test can flip
49
+ * the support gate between emissions without rebuilding the writer.
50
+ */
51
+ export function createTurnMarkWriter(options: ITurnMarkWriterOptions): ITurnMarkWriter {
52
+ const write =
53
+ options.write ??
54
+ ((text: string): void => {
55
+ process.stdout.write(text);
56
+ });
57
+ return {
58
+ emit(mark: TTurnMark): void {
59
+ if (!options.enabled || !options.supported()) return;
60
+ write(turnMarkSequence(mark));
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * SCREEN-2002 TC-01/TC-05 — the built-in themes as data, and the pairing the palette floor guards.
3
+ *
4
+ * Succeeds the SCREEN-006 `tui-palette.test.ts`: the same mechanics (every leaf is a colour in the
5
+ * grammar, the wave ramp is four stops, status covers the seven kinds), now over four themes instead
6
+ * of one map, plus the pairing `status-glyph` used to hold as a constant — a symbol for every kind a
7
+ * theme colours, so a status is never colour alone.
8
+ */
9
+ import { describe, expect, it } from 'vitest';
10
+
11
+ import { DEFAULT_APPEARANCE_SETTINGS } from '@robota-sdk/agent-framework';
12
+
13
+ import { BUILT_IN_THEMES, DARK_THEME, DEFAULT_THEME_ID } from '../built-in-themes.js';
14
+ import { isThemeColor } from '../theme-styles.js';
15
+ import { STATUS_SYMBOL, statusGlyphColor, type TUiStatusKind } from '../../status-glyph.js';
16
+
17
+ import type { ITuiTheme } from '../theme-contracts.js';
18
+
19
+ const KINDS: readonly TUiStatusKind[] = [
20
+ 'running',
21
+ 'success',
22
+ 'error',
23
+ 'denied',
24
+ 'waiting',
25
+ 'cancelled',
26
+ 'idle',
27
+ ];
28
+
29
+ const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/u;
30
+
31
+ function leaves(theme: ITuiTheme): [string, string][] {
32
+ const entries: [string, string][] = [];
33
+ for (const [group, values] of Object.entries(theme.colors)) {
34
+ for (const [token, value] of Object.entries(values)) {
35
+ entries.push([`colors.${group}.${token}`, value as string]);
36
+ }
37
+ }
38
+ for (const [token, value] of Object.entries(theme.markdown))
39
+ entries.push([`markdown.${token}`, value]);
40
+ for (const [token, value] of Object.entries(theme.syntax))
41
+ entries.push([`syntax.${token}`, value]);
42
+ theme.motion.wave.forEach((value, index) => entries.push([`motion.wave[${index}]`, value]));
43
+ return entries;
44
+ }
45
+
46
+ describe('built-in themes (SCREEN-2002 TC-01)', () => {
47
+ it.each(BUILT_IN_THEMES.map((theme) => [theme.id, theme] as const))(
48
+ '%s: every leaf is a colour in Ink’s grammar',
49
+ (_id, theme) => {
50
+ const invalid = leaves(theme).filter(([, value]) => !isThemeColor(value));
51
+ expect(invalid).toEqual([]);
52
+ },
53
+ );
54
+
55
+ it.each(BUILT_IN_THEMES.map((theme) => [theme.id, theme] as const))(
56
+ '%s: the wave ramp is four stops and status covers the seven kinds',
57
+ (_id, theme) => {
58
+ expect(theme.motion.wave).toHaveLength(4);
59
+ expect(Object.keys(theme.colors.status).sort()).toEqual([...KINDS].sort());
60
+ },
61
+ );
62
+
63
+ it('ships one dark and one light variant of each family, with unique ids', () => {
64
+ const ids = BUILT_IN_THEMES.map((theme) => theme.id);
65
+ expect(new Set(ids).size).toBe(ids.length);
66
+ expect(ids).toEqual(['dark', 'light', 'dark-daltonized', 'light-daltonized']);
67
+ expect(BUILT_IN_THEMES.filter((theme) => theme.appearance === 'light')).toHaveLength(2);
68
+ expect(BUILT_IN_THEMES.every((theme) => theme.source === 'built-in')).toBe(true);
69
+ });
70
+
71
+ it('specifies the daltonized pair in hex so the CVD guard can simulate it', () => {
72
+ for (const theme of BUILT_IN_THEMES.filter((candidate) =>
73
+ candidate.id.endsWith('daltonized'),
74
+ )) {
75
+ const named = leaves(theme).filter(([, value]) => !HEX_PATTERN.test(value));
76
+ expect(named).toEqual([]);
77
+ }
78
+ });
79
+ });
80
+
81
+ describe('status is never colour alone (SCREEN-2002 TC-05)', () => {
82
+ it.each(KINDS)('%s has a symbol and a colour in every theme', (kind) => {
83
+ expect(STATUS_SYMBOL[kind].length).toBeGreaterThan(0);
84
+ for (const theme of BUILT_IN_THEMES) {
85
+ expect(isThemeColor(statusGlyphColor(theme.colors, kind))).toBe(true);
86
+ }
87
+ });
88
+
89
+ it('keeps the dark theme’s status colours identical to the values the package shipped', () => {
90
+ expect(DARK_THEME.colors.status).toEqual({
91
+ running: 'yellow',
92
+ success: 'green',
93
+ error: 'red',
94
+ denied: 'yellowBright',
95
+ waiting: 'yellow',
96
+ cancelled: 'yellow',
97
+ idle: 'gray',
98
+ });
99
+ });
100
+ });
101
+
102
+ /**
103
+ * SCREEN-2002: the settings document's default theme id and this package's default built-in are two
104
+ * declarations of one fact, in two packages, because the dependency runs one way — `agent-framework`
105
+ * cannot read the catalogue. A silent divergence would not crash: it would resolve to "theme
106
+ * `<id>` is not installed" on every startup for a user who set nothing, which is the failure mode
107
+ * hardest to attribute. So it is pinned here, where both are importable.
108
+ */
109
+ describe('the default theme id has one meaning across packages', () => {
110
+ it('matches the appearance settings default', () => {
111
+ expect(DEFAULT_THEME_ID).toBe(DEFAULT_APPEARANCE_SETTINGS.theme);
112
+ });
113
+
114
+ it('names a theme the catalogue actually holds', () => {
115
+ expect(BUILT_IN_THEMES.map((theme) => theme.id)).toContain(DEFAULT_APPEARANCE_SETTINGS.theme);
116
+ });
117
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * SCREEN-2002 TC-04 — the daltonized built-ins are guarded, not asserted.
3
+ *
4
+ * The claim "a colour-blind user can tell these apart" is measurable, so it is measured: every pair
5
+ * of status colours and the diff pair are simulated for protanopia and deuteranopia and required to
6
+ * stay apart in Lab. The guard's own failure modes are exercised too — a red/green pair must FAIL it,
7
+ * and a value it cannot simulate must FAIL rather than skip, because a guard that quietly passes what
8
+ * it did not check is worse than none.
9
+ */
10
+ import { describe, expect, it } from 'vitest';
11
+
12
+ import { DARK_DALTONIZED_THEME, DARK_THEME, LIGHT_DALTONIZED_THEME } from '../built-in-themes.js';
13
+ import { simulate, simulatedDistance, type TColorVision } from '../color-vision.js';
14
+
15
+ import type { ITuiTheme } from '../theme-contracts.js';
16
+
17
+ /**
18
+ * The floor, in CIE76 units, measured rather than assumed: the shipped daltonized pairs sit at 56–88
19
+ * under both simulations, and the green/red pair the default theme uses for the same distinction
20
+ * measures 41.7 under protanopia. 50 therefore separates the two with margin on each side. It is an
21
+ * empirical floor for THIS projection, not a published perceptual constant, and is recorded as such.
22
+ */
23
+ const MIN_DISTANCE = 50;
24
+
25
+ const VISIONS: readonly TColorVision[] = ['protanopia', 'deuteranopia'];
26
+ const DALTONIZED = [DARK_DALTONIZED_THEME, LIGHT_DALTONIZED_THEME];
27
+
28
+ /**
29
+ * The pairs whose DIFFERENCE IN COLOUR carries meaning — "this went well" against "this did not",
30
+ * in the three places the product draws that distinction. Other status colours (running, waiting,
31
+ * cancelled, denied) are told apart by their glyph and their word, which is the rule this package
32
+ * has kept since SCREEN-005; requiring them to differ in colour too would be guarding a claim the
33
+ * product does not make.
34
+ */
35
+ function meaningfulPairs(theme: ITuiTheme): [string, string, string][] {
36
+ return [
37
+ ['status.success vs status.error', theme.colors.status.success, theme.colors.status.error],
38
+ ['diff added vs removed', theme.markdown.diffAdded, theme.markdown.diffRemoved],
39
+ ['syntax addition vs deletion', theme.syntax.addition, theme.syntax.deletion],
40
+ ];
41
+ }
42
+
43
+ describe('daltonized built-ins survive simulated colour-vision deficiency (SCREEN-2002 TC-04)', () => {
44
+ it.each(
45
+ DALTONIZED.flatMap((theme) => VISIONS.map((vision) => [theme.id, vision, theme] as const)),
46
+ )('%s under %s: every meaningful pair stays apart', (_id, vision, theme) => {
47
+ const tooClose = meaningfulPairs(theme)
48
+ .map(([label, first, second]) => ({
49
+ label,
50
+ distance: simulatedDistance(first, second, vision),
51
+ }))
52
+ // A pair the guard could not simulate is a failure, not a skip — and a NON-FINITE distance is
53
+ // the same hole wearing a number: `NaN < MIN_DISTANCE` is false, so one bad coefficient would
54
+ // turn this guard green for every pair at once.
55
+ .filter((pair) => !Number.isFinite(pair.distance) || (pair.distance ?? 0) < MIN_DISTANCE);
56
+ expect(tooClose).toEqual([]);
57
+ });
58
+
59
+ it('discriminates: the green/red pair the default theme uses falls below the floor', () => {
60
+ // The default theme names its colours, and a name resolves in the terminal — so the guard cannot
61
+ // measure it at all, which is itself the refusal it makes. Measured as the hexes a terminal
62
+ // typically renders those names as, the pair falls under the floor for protanopia.
63
+ expect(
64
+ simulatedDistance(
65
+ DARK_THEME.colors.status.success,
66
+ DARK_THEME.colors.status.error,
67
+ 'protanopia',
68
+ ),
69
+ ).toBeUndefined();
70
+ const asHex = simulatedDistance('#00a000', '#d00000', 'protanopia');
71
+ expect(asHex).toBeDefined();
72
+ expect(asHex ?? 0).toBeLessThan(MIN_DISTANCE);
73
+ });
74
+
75
+ it('refuses a value it cannot simulate rather than passing it', () => {
76
+ expect(simulate('cyan', 'protanopia')).toBeUndefined();
77
+ expect(simulate('ansi256(9)', 'protanopia')).toBeUndefined();
78
+ expect(simulate('#abc', 'protanopia')).toBeDefined();
79
+ expect(simulate('ansi256(22)', 'protanopia')).toBeDefined();
80
+ expect(simulate('rgb(10, 20, 30)', 'deuteranopia')).toBeDefined();
81
+ });
82
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * SCREEN-2002 TC-05/TC-06 — the resolved theme reaches components, and motion has one owner.
3
+ *
4
+ * The reduced-motion input is the accessibility setting this item exists for, so it is asserted
5
+ * against the component that consumes it rather than against the hook in isolation: `WaveText` must
6
+ * stop scheduling entirely, not merely look still.
7
+ */
8
+ import chalk from 'chalk';
9
+ import { Text } from 'ink';
10
+ import { render } from 'ink-testing-library';
11
+ import React from 'react';
12
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
13
+
14
+ import { AppBanner } from '../../app-banner.js';
15
+ import WaveText from '../../WaveText.js';
16
+ import { DARK_THEME, LIGHT_THEME } from '../built-in-themes.js';
17
+ // Through the BARREL, deliberately: the module's export surface is what every consumer outside
18
+ // `src/theme/` reaches, and an export dropped from it is a runtime `undefined` rather than a type
19
+ // error at the call site. Importing from `theme-context.js` here would leave that surface unguarded.
20
+ import { useSyntaxHighlighting } from '../index.js';
21
+ import { ThemeProvider } from '../theme-context.js';
22
+ import { foreground } from '../theme-styles.js';
23
+
24
+ const gateMock = vi.hoisted(() => ({ value: true }));
25
+ vi.mock('../../terminal-capabilities.js', () => ({
26
+ isInteractiveColorTerminal: (): boolean => gateMock.value,
27
+ supportsImeCursorPositioning: (): boolean => false,
28
+ supportsFocusReporting: (): boolean => false,
29
+ }));
30
+
31
+ const WAVE_INTERVAL_MS = 400;
32
+
33
+ afterEach(() => {
34
+ gateMock.value = true;
35
+ vi.useRealTimers();
36
+ });
37
+
38
+ describe('reduced motion (SCREEN-2002 TC-06)', () => {
39
+ const originalLevel = chalk.level;
40
+ const TRUECOLOR = 3;
41
+ beforeAll(() => {
42
+ chalk.level = TRUECOLOR;
43
+ });
44
+ afterAll(() => {
45
+ chalk.level = originalLevel;
46
+ });
47
+
48
+ it('stops the animation without stopping colour, and schedules no interval at all', () => {
49
+ vi.useFakeTimers();
50
+ const { lastFrame, unmount } = render(
51
+ <ThemeProvider reducedMotion>
52
+ <WaveText text="Waiting" />
53
+ </ThemeProvider>,
54
+ );
55
+ const first = lastFrame() ?? '';
56
+ // The whole point: no timer is armed, so nothing repaints.
57
+ expect(vi.getTimerCount()).toBe(0);
58
+ vi.advanceTimersByTime(WAVE_INTERVAL_MS * 5);
59
+ const later = lastFrame() ?? '';
60
+ unmount();
61
+ expect(later).toBe(first);
62
+ // Colour is untouched — reduced motion is orthogonal to the colour gate. The frame is the
63
+ // canonical MUTED token, not an uncoloured one and not a stopped ramp stop.
64
+ expect(first).toContain('Waiting');
65
+ expect(first).toContain(openCode(DARK_THEME.colors.text.muted));
66
+ for (const stop of DARK_THEME.motion.wave) {
67
+ expect(first).not.toContain(openCode(stop));
68
+ }
69
+ });
70
+
71
+ it('animates when reduced motion is off', () => {
72
+ vi.useFakeTimers();
73
+ const { unmount } = render(
74
+ <ThemeProvider>
75
+ <WaveText text="Waiting" />
76
+ </ThemeProvider>,
77
+ );
78
+ expect(vi.getTimerCount()).toBeGreaterThan(0);
79
+ unmount();
80
+ });
81
+
82
+ it('stays still when the colour gate is off, whatever the setting', () => {
83
+ gateMock.value = false;
84
+ vi.useFakeTimers();
85
+ const { unmount } = render(
86
+ <ThemeProvider>
87
+ <WaveText text="Waiting" />
88
+ </ThemeProvider>,
89
+ );
90
+ expect(vi.getTimerCount()).toBe(0);
91
+ unmount();
92
+ });
93
+ });
94
+
95
+ describe('a provided theme reaches components (SCREEN-2002 TC-05)', () => {
96
+ // These assertions are about the emitted SGR, so the level is forced for them.
97
+ const originalLevel = chalk.level;
98
+ const TRUECOLOR = 3;
99
+ beforeAll(() => {
100
+ chalk.level = TRUECOLOR;
101
+ });
102
+ afterAll(() => {
103
+ chalk.level = originalLevel;
104
+ });
105
+
106
+ it('renders the provided theme’s colours, not the default built-in’s', () => {
107
+ const themed = render(
108
+ <ThemeProvider theme={LIGHT_THEME}>
109
+ <AppBanner version="1.2.3" />
110
+ </ThemeProvider>,
111
+ ).lastFrame();
112
+ const unthemed = render(<AppBanner version="1.2.3" />).lastFrame();
113
+ expect(themed).not.toBe(unthemed);
114
+ expect(themed ?? '').toContain(openCode(LIGHT_THEME.colors.text.accent));
115
+ });
116
+ });
117
+
118
+ function openCode(color: string): string {
119
+ const [open] = foreground(color)('x').split('x');
120
+ return open ?? '';
121
+ }
122
+
123
+ /**
124
+ * SCREEN-2002: `/theme syntax off` persisted a setting that reached nothing until this travelled
125
+ * with the theme — the command reported success for a no-op. So the assertion is that the value
126
+ * ARRIVES at a consumer, not merely that the provider accepts it.
127
+ */
128
+ describe('syntax highlighting travels with the theme (SCREEN-2002 TC-03)', () => {
129
+ function Probe(): React.ReactElement {
130
+ return <Text>{`syntax:${String(useSyntaxHighlighting())}`}</Text>;
131
+ }
132
+
133
+ it('defaults to on outside a provider, so today s rendering is unchanged', () => {
134
+ expect(render(<Probe />).lastFrame()).toContain('syntax:true');
135
+ });
136
+
137
+ it('carries false to a consumer when the appearance says off', () => {
138
+ const frame = render(
139
+ <ThemeProvider syntaxHighlighting={false}>
140
+ <Probe />
141
+ </ThemeProvider>,
142
+ ).lastFrame();
143
+
144
+ expect(frame).toContain('syntax:false');
145
+ });
146
+ });