@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,567 @@
1
+ /**
2
+ * TuiInteractionChannel — session-owning presentation surface for the Ink TUI.
3
+ *
4
+ * Moves session lifecycle (InteractiveSession, CommandRegistry, TuiStateManager)
5
+ * out of React hooks and into a plain TypeScript class.
6
+ */
7
+
8
+ import { createSystemMessage, messageToHistoryEntry } from '@robota-sdk/agent-core';
9
+ import {
10
+ CommandRegistry,
11
+ buildRuntimeSession,
12
+ generateSessionName,
13
+ } from '@robota-sdk/agent-framework';
14
+
15
+ import { AttentionCoordinator } from './attention/attention-coordinator.js';
16
+ import { MS_PER_SECOND } from './attention/time-units.js';
17
+ import { createSessionInitPoller } from './flows/session-init-poller.js';
18
+ import { applySystemCommandResult } from './hooks/command-result-handler.js';
19
+ import {
20
+ TuiChannelLifecycleCoordinator,
21
+ TuiChannelStartRollbackError,
22
+ } from './tui-channel-lifecycle-coordinator.js';
23
+ import { TuiPermissionQueue, TuiUserActionQueue } from './tui-interaction-queues.js';
24
+ import { TuiSessionEventProjector } from './tui-session-event-projector.js';
25
+ import { buildTuiSessionOptions } from './tui-session-options.js';
26
+ import { TuiStateManager } from './tui-state-manager.js';
27
+
28
+ import type { IAttentionSource } from './attention/attention-tracker.js';
29
+ import type { ISessionInitPoller, TSessionInitFailure } from './flows/session-init-poller.js';
30
+ import type { TerminalHandoffController } from './terminal-handoff-controller.js';
31
+ import type {
32
+ ITuiAppChannelPort,
33
+ ITuiChannelSnapshot,
34
+ ITuiCommandQueryPort,
35
+ ITuiRuntimeStatusSnapshot,
36
+ ITuiSessionUiEventPort,
37
+ } from './tui-app-channel-port.js';
38
+ import type { ITuiInteractionChannelOptions } from './tui-channel-options.js';
39
+ import type { IPendingPermissionRequest } from './types.js';
40
+ import type {
41
+ IHistoryEntry,
42
+ TPermissionMode,
43
+ TSessionEndReason,
44
+ TToolArgs,
45
+ } from '@robota-sdk/agent-core';
46
+ // CMD-004 unified action contract (SSOT in agent-core).
47
+ import type {
48
+ IActionRequest,
49
+ TActionResponse as TUserActionResponse,
50
+ } from '@robota-sdk/agent-core';
51
+ import type { InteractiveSession } from '@robota-sdk/agent-framework';
52
+ import type { IExecutionDetailPage } from '@robota-sdk/agent-interface-execution';
53
+ import type { ICommandInfo, TPermissionResultValue } from '@robota-sdk/agent-interface-session';
54
+
55
+ const SESSION_INIT_POLL_MS = 200;
56
+ const SESSION_INIT_TIMEOUT_MS = 15000;
57
+ /** Upper bound on the graceful session shutdown so a wedged subsystem cannot block process exit
58
+ * (CLI-075 / RUNTIME-33; api-boundary "safely cancelled within a configurable timeout"). */
59
+ const SHUTDOWN_TIMEOUT_MS = 5000;
60
+
61
+ export type { ITuiInteractionChannelOptions } from './tui-channel-options.js';
62
+
63
+ export class TuiInteractionChannel implements ITuiAppChannelPort {
64
+ readonly stateManager: TuiStateManager;
65
+
66
+ private readonly interactiveSession: InteractiveSession;
67
+ private readonly registry: CommandRegistry;
68
+ private readonly opts: ITuiInteractionChannelOptions;
69
+
70
+ private submitHandler: ((text: string) => Promise<void>) | null = null;
71
+
72
+ private readonly userActions: TuiUserActionQueue;
73
+ private readonly permissions: TuiPermissionQueue;
74
+ private readonly eventProjector: TuiSessionEventProjector;
75
+ /** SCREEN-1992: joins the terminal's attention to this channel's recap; absent without a source. */
76
+ private readonly attention: AttentionCoordinator | undefined;
77
+ private readonly lifecycle: TuiChannelLifecycleCoordinator;
78
+ availableCommands: ICommandInfo[] = [];
79
+ sessionName: string | undefined;
80
+
81
+ private autoNameTriggered = false;
82
+ private initPoller: ISessionInitPoller | null = null;
83
+
84
+ /** TERM-002: the App registers its Ink suspend/resume hooks into this controller. */
85
+ get terminalHandoffController(): TerminalHandoffController | undefined {
86
+ return this.opts.terminalHandoff;
87
+ }
88
+ /** Set by React hook to trigger re-render on state change */
89
+ onChange: (() => void) | null = null;
90
+
91
+ constructor(opts: ITuiInteractionChannelOptions) {
92
+ this.opts = opts;
93
+ this.sessionName = opts.sessionName;
94
+ this.stateManager = new TuiStateManager();
95
+ this.stateManager.onChange = () => this.onChange?.();
96
+ this.userActions = new TuiUserActionQueue(() => this.onChange?.());
97
+ this.permissions = new TuiPermissionQueue(() => this.onChange?.());
98
+
99
+ this.interactiveSession = this.createSession();
100
+ this.registry = this.createRegistry();
101
+ this.attention = this.createAttention(opts.attention);
102
+ this.eventProjector = new TuiSessionEventProjector({
103
+ session: this.interactiveSession,
104
+ manager: this.stateManager,
105
+ ...(this.attention ? { attention: this.attention } : {}),
106
+ onUserMessage: (content) => this.handleAutoNaming(content),
107
+ requestPermission: (toolName, toolArgs, id, canPersistProjectPermission, requestedByPeer) =>
108
+ this.permissions.enqueue(
109
+ toolName,
110
+ toolArgs,
111
+ id,
112
+ canPersistProjectPermission,
113
+ requestedByPeer,
114
+ ),
115
+ askUser: (request, id) => this.askUser(request, id),
116
+ dismissPrompt: (id) => this.dismissPromptById(id),
117
+ ...(opts.onSessionEventDeliveryError
118
+ ? { onDeliveryError: opts.onSessionEventDeliveryError }
119
+ : {}),
120
+ });
121
+ this.lifecycle = new TuiChannelLifecycleCoordinator(
122
+ {
123
+ start: () => this.startRuntime(),
124
+ stop: async () => {
125
+ this.eventProjector.unwire();
126
+ this.cancelAllPermissions();
127
+ this.cancelAllUserActions();
128
+ this.stopInitCheck();
129
+ this.onChange = null;
130
+ this.stateManager.dispose();
131
+ await this.stopTransports();
132
+ },
133
+ beginShutdown: () => {
134
+ this.cancelAllUserActions();
135
+ this.cancelAllPermissions();
136
+ this.stateManager.addEntry(
137
+ messageToHistoryEntry(createSystemMessage('Shutting down...')),
138
+ );
139
+ this.onChange?.();
140
+ },
141
+ shutdownSession: async ({ reason, message }) => {
142
+ await this.interactiveSession.shutdown({ reason, message });
143
+ },
144
+ },
145
+ SHUTDOWN_TIMEOUT_MS,
146
+ );
147
+ }
148
+
149
+ private async startRuntime(): Promise<void> {
150
+ let transportStartAttempted = false;
151
+ try {
152
+ this.eventProjector.wire();
153
+ this.syncRestoredHistory();
154
+ this.startInitCheck();
155
+ if (this.opts.transportRegistry) {
156
+ transportStartAttempted = true;
157
+ await this.opts.bindTransports?.(this.interactiveSession);
158
+ await this.opts.transportRegistry.startAll();
159
+ }
160
+ } catch (startError) {
161
+ this.eventProjector.unwire();
162
+ this.stopInitCheck();
163
+ if (transportStartAttempted && this.opts.transportRegistry) {
164
+ try {
165
+ await this.stopTransports();
166
+ } catch (rollbackError) {
167
+ const rollbackErrors =
168
+ rollbackError instanceof AggregateError ? rollbackError.errors : [rollbackError];
169
+ throw new TuiChannelStartRollbackError(
170
+ [startError, ...rollbackErrors],
171
+ 'TUI channel start failed and transport rollback also failed.',
172
+ );
173
+ }
174
+ }
175
+ throw startError;
176
+ }
177
+ }
178
+
179
+ private async stopTransports(): Promise<void> {
180
+ if (!this.opts.transportRegistry) return;
181
+ const result = await this.opts.transportRegistry.stopAll();
182
+ if (result.errors.length > 0) {
183
+ throw new AggregateError(result.errors, 'TUI transport teardown failed.');
184
+ }
185
+ }
186
+
187
+ get permissionRequest(): IPendingPermissionRequest | null {
188
+ return this.permissions.current;
189
+ }
190
+
191
+ /** CMD-004: the action currently awaiting a user answer, or null. */
192
+ get pendingUserAction(): IActionRequest | null {
193
+ return this.userActions.current;
194
+ }
195
+
196
+ get isShuttingDown(): boolean {
197
+ return this.lifecycle.isShuttingDown;
198
+ }
199
+
200
+ get isActiveForPeerStatus(): boolean {
201
+ return this.lifecycle.isActiveForPeerStatus;
202
+ }
203
+
204
+ private createSession(): InteractiveSession {
205
+ return buildRuntimeSession(buildTuiSessionOptions(this.opts));
206
+ }
207
+
208
+ private createRegistry(): CommandRegistry {
209
+ const registry = new CommandRegistry();
210
+ for (const module of this.opts.commandModules ?? []) {
211
+ registry.addModule(module);
212
+ }
213
+ this.opts.reloadPluginCommandSource?.(registry);
214
+ return registry;
215
+ }
216
+
217
+ onSubmit(handler: (text: string) => Promise<void>): void {
218
+ this.submitHandler = handler;
219
+ }
220
+
221
+ setAvailableCommands(commands: ICommandInfo[]): void {
222
+ this.availableCommands = commands;
223
+ this.onChange?.();
224
+ }
225
+
226
+ setBusy(busy: boolean): void {
227
+ this.stateManager.onThinking(busy);
228
+ }
229
+
230
+ async start(): Promise<void> {
231
+ await this.lifecycle.start();
232
+ }
233
+
234
+ /**
235
+ * Full, idempotent channel teardown (CLI-075). Unwires every session listener, drains both
236
+ * request queues, stops the init poller, disposes the render-state manager, stops transports, and
237
+ * — unless a graceful `shutdown()` already ran — shuts the underlying session down so a discarded
238
+ * or switched-away channel releases its background tasks, subagent processes, and timers.
239
+ */
240
+ async stop(): Promise<void> {
241
+ await this.lifecycle.stop();
242
+ }
243
+
244
+ // ── Additional methods for App.tsx ───────────────────────────
245
+
246
+ getSession(): InteractiveSession {
247
+ return this.interactiveSession;
248
+ }
249
+
250
+ getRegistry(): CommandRegistry {
251
+ return this.registry;
252
+ }
253
+
254
+ subscribe(onChange: () => void): () => void {
255
+ this.onChange = onChange;
256
+ return () => {
257
+ if (this.onChange === onChange) this.onChange = null;
258
+ };
259
+ }
260
+
261
+ getSnapshot(): ITuiChannelSnapshot {
262
+ const manager = this.stateManager;
263
+ return {
264
+ history: manager.history,
265
+ streamingText: manager.streamingText,
266
+ activeTools: manager.activeTools,
267
+ isThinking: manager.isThinking,
268
+ isAborting: manager.isAborting,
269
+ lastErrorMessage: manager.lastErrorMessage,
270
+ isStalled: manager.isStalled,
271
+ sessionEventNotices: manager.sessionEventNotices,
272
+ isShuttingDown: this.isShuttingDown,
273
+ pendingPrompt: manager.pendingPrompt,
274
+ pendingCount: this.interactiveSession.getPendingCount(),
275
+ executionWorkspaceSnapshot: manager.executionWorkspaceSnapshot,
276
+ ...(manager.selectedExecutionEntryId !== undefined
277
+ ? { selectedExecutionEntryId: manager.selectedExecutionEntryId }
278
+ : {}),
279
+ permissionRequest: this.permissionRequest,
280
+ pendingUserAction: this.pendingUserAction,
281
+ contextState: manager.contextState,
282
+ };
283
+ }
284
+
285
+ getCommandQueryPort(): ITuiCommandQueryPort {
286
+ return this.registry;
287
+ }
288
+
289
+ getSessionUiEventPort(): ITuiSessionUiEventPort {
290
+ return this.interactiveSession;
291
+ }
292
+
293
+ getRuntimeStatusSnapshot(fallbackPermissionMode: TPermissionMode): ITuiRuntimeStatusSnapshot {
294
+ try {
295
+ const session = this.interactiveSession.getSession();
296
+ const activePresetId = session.getActivePresetId?.();
297
+ const effort = session.getModelEffort();
298
+ return {
299
+ permissionMode: session.getPermissionMode(),
300
+ sessionId: session.getSessionId(),
301
+ ...(activePresetId !== undefined ? { activePresetId } : {}),
302
+ ...(effort !== undefined ? { effort } : {}),
303
+ };
304
+ } catch {
305
+ return { permissionMode: fallbackPermissionMode, sessionId: '' };
306
+ }
307
+ }
308
+
309
+ addEntry(entry: IHistoryEntry): void {
310
+ this.stateManager.addEntry(entry);
311
+ }
312
+
313
+ abort(): void {
314
+ this.stateManager.setAborting(true);
315
+ this.cancelAllUserActions();
316
+ this.cancelAllPermissions();
317
+ this.interactiveSession.abort();
318
+ }
319
+
320
+ cancelQueue(): void {
321
+ this.interactiveSession.cancelQueue();
322
+ this.cancelAllUserActions();
323
+ this.cancelAllPermissions();
324
+ this.stateManager.setPendingPrompt(null);
325
+ }
326
+
327
+ async stopWaitingSelfPacedLoop(): Promise<void> {
328
+ const waiting = this.interactiveSession
329
+ .listSelfPacedLoops()
330
+ .filter((loop) => loop.phase === 'waiting');
331
+ if (waiting.length === 0) return;
332
+ if (waiting.length > 1) {
333
+ this.addEntry(
334
+ messageToHistoryEntry(
335
+ createSystemMessage(
336
+ 'Several self-paced loops are waiting. Use /loop list and /loop stop <id> to choose one.',
337
+ ),
338
+ ),
339
+ );
340
+ return;
341
+ }
342
+ const loopId = waiting[0]!.loopId;
343
+ try {
344
+ await this.interactiveSession.stopSelfPacedLoop(loopId, 'Loop stopped by Esc');
345
+ this.addEntry(messageToHistoryEntry(createSystemMessage(`Loop ${loopId} stopped by Esc.`)));
346
+ } catch (error) {
347
+ this.addEntry(
348
+ messageToHistoryEntry(
349
+ createSystemMessage(
350
+ `Could not stop loop ${loopId}: ${error instanceof Error ? error.message : String(error)}`,
351
+ ),
352
+ ),
353
+ );
354
+ }
355
+ }
356
+
357
+ async shutdown(options?: { reason?: TSessionEndReason; timeoutMs?: number }): Promise<void> {
358
+ await this.lifecycle.shutdown(options);
359
+ }
360
+
361
+ selectExecutionWorkspaceEntry(entryId: string): void {
362
+ this.stateManager.selectExecutionWorkspaceEntry(entryId);
363
+ }
364
+
365
+ async readExecutionWorkspaceDetail(entryId: string): Promise<IExecutionDetailPage> {
366
+ return this.interactiveSession.readExecutionWorkspaceDetail(entryId);
367
+ }
368
+
369
+ async sendAgentJob(taskId: string, input: string): Promise<void> {
370
+ await this.interactiveSession.sendAgentJob(taskId, input);
371
+ }
372
+
373
+ setSessionName(name: string): void {
374
+ this.sessionName = name;
375
+ this.interactiveSession.setName(name);
376
+ this.onChange?.();
377
+ }
378
+
379
+ // ── CMD-004 unified ask path ─────────────────────────────────
380
+
381
+ /**
382
+ * Queue an ask and resolve when the user answers. Reached via the `ask_request` event handler
383
+ * (which passes the framework prompt `id` for co-drive dismissal) and by any direct
384
+ * `IInteractionChannel.askUser` caller (no id).
385
+ */
386
+ async askUser(request: IActionRequest, id?: string): Promise<TUserActionResponse> {
387
+ return this.userActions.enqueue(request, id);
388
+ }
389
+
390
+ /** Called by App's PendingActionPrompt when the user answers (or cancels) the pending action. */
391
+ resolveUserAction(request: IActionRequest, response: TUserActionResponse): void {
392
+ this.userActions.resolveCurrent(request, response);
393
+ }
394
+
395
+ /** Resolve every queued/in-flight ask as cancelled (abort, shutdown). */
396
+ private cancelAllUserActions(): void {
397
+ this.userActions.cancelAll();
398
+ }
399
+
400
+ async handleInput(input: string): Promise<void> {
401
+ if (!input.startsWith('/')) {
402
+ await this.interactiveSession.submit(input);
403
+ this.stateManager.setPendingPrompt(this.interactiveSession.getPendingPrompt());
404
+ return;
405
+ }
406
+ await this.handleSlashCommand(input);
407
+ }
408
+
409
+ private async handleSlashCommand(input: string): Promise<void> {
410
+ const parts = input.slice(1).split(/\s+/);
411
+ const cmd = parts[0]?.toLowerCase() ?? '';
412
+ const args = parts.slice(1).join(' ');
413
+
414
+ const result = await this.interactiveSession.executeCommand(cmd, args);
415
+ if (result) {
416
+ // CMD-004 Stage E: `data.sessionExecution` is the requester-local "a session turn is now
417
+ // running" hint (formerly the `session-execution-started` effect).
418
+ if (result.data?.['sessionExecution'] === true) {
419
+ this.stateManager.setPendingPrompt(this.interactiveSession.getPendingPrompt());
420
+ return;
421
+ }
422
+ applySystemCommandResult(
423
+ result,
424
+ this.interactiveSession,
425
+ this.registry,
426
+ this.stateManager,
427
+ this.opts.reloadPluginCommandSource,
428
+ );
429
+ return;
430
+ }
431
+
432
+ this.stateManager.addEntry(
433
+ messageToHistoryEntry(createSystemMessage(`Unknown command "/${cmd}". Type /help for help.`)),
434
+ );
435
+ }
436
+
437
+ // ── Private helpers ──────────────────────────────────────────
438
+
439
+ private handlePermissionRequest(
440
+ toolName: string,
441
+ toolArgs: TToolArgs,
442
+ id?: string,
443
+ ): Promise<TPermissionResultValue> {
444
+ return this.permissions.enqueue(toolName, toolArgs, id);
445
+ }
446
+
447
+ /**
448
+ * REMOTE-007 co-drive: another surface answered the prompt with this framework `id`, so dismiss the
449
+ * still-showing local dialog. Resolving the local entry (cancelled) is a no-op on the framework side
450
+ * — that id is already settled — but it clears the TUI's queue + render state.
451
+ */
452
+ private dismissPromptById(id: string): void {
453
+ this.userActions.dismissById(id);
454
+ this.permissions.dismissById(id);
455
+ }
456
+
457
+ /**
458
+ * Resolve every queued/in-flight permission request as `false` (deny) — abort, cancelQueue,
459
+ * shutdown, stop. Symmetric to `cancelAllUserActions()`: tearing down must neither leave a tool's
460
+ * permission promise dangling (the tool would hang) nor silently grant it (CLI-075 RUNTIME-32).
461
+ */
462
+ private cancelAllPermissions(): void {
463
+ this.permissions.cancelAll();
464
+ }
465
+
466
+ private handleAutoNaming(content: string): void {
467
+ if (this.autoNameTriggered) return;
468
+ if (this.opts.sessionName || this.interactiveSession.getName()) return;
469
+ this.autoNameTriggered = true;
470
+ generateSessionName(this.opts.provider, content)
471
+ .then((name) => {
472
+ this.interactiveSession.setName(name);
473
+ this.sessionName = name;
474
+ this.opts.onAutoNamed?.(name);
475
+ this.onChange?.();
476
+ })
477
+ .catch(() => {
478
+ this.autoNameTriggered = false;
479
+ });
480
+ }
481
+
482
+ private syncRestoredHistory(): void {
483
+ if (this.stateManager.history.length === 0) {
484
+ const restored = this.interactiveSession.getFullHistory();
485
+ if (restored.length > 0) {
486
+ this.stateManager.syncHistory(restored);
487
+ }
488
+ }
489
+ }
490
+
491
+ private startInitCheck(): void {
492
+ this.stopInitCheck();
493
+ this.initPoller = createSessionInitPoller({
494
+ check: () => this.runInitCheck(),
495
+ intervalMs: SESSION_INIT_POLL_MS,
496
+ timeoutMs: SESSION_INIT_TIMEOUT_MS,
497
+ onReady: () => undefined,
498
+ onFailure: (failure) => this.onInitFailure(failure),
499
+ });
500
+ this.initPoller.start();
501
+ }
502
+
503
+ /** Throws while the session is not ready; the init poller classifies the error. */
504
+ private runInitCheck(): void {
505
+ const ctx = this.interactiveSession.getContextState();
506
+ const restoredSessionName = this.interactiveSession.getName();
507
+ if (restoredSessionName && restoredSessionName !== this.sessionName) {
508
+ this.sessionName = restoredSessionName;
509
+ this.onChange?.();
510
+ }
511
+ this.stateManager.setContextState({
512
+ percentage: ctx.usedPercentage,
513
+ usedTokens: ctx.usedTokens,
514
+ maxTokens: ctx.maxTokens,
515
+ });
516
+ const restored = this.interactiveSession.getFullHistory();
517
+ if (restored.length > 0) {
518
+ this.stateManager.syncHistory(restored);
519
+ }
520
+ this.syncExecutionWorkspace();
521
+ }
522
+
523
+ /** SCREEN-1992: the recap lands in this channel's notice store; no source ⇒ no coordinator. */
524
+ private createAttention(source: IAttentionSource | undefined): AttentionCoordinator | undefined {
525
+ if (!source) return undefined;
526
+ return new AttentionCoordinator({
527
+ source,
528
+ onRecap: (line) => this.stateManager.addAttentionRecap(line),
529
+ });
530
+ }
531
+
532
+ private onInitFailure(failure: TSessionInitFailure): void {
533
+ const message =
534
+ failure.kind === 'timeout'
535
+ ? `Session initialization timed out after ${SESSION_INIT_TIMEOUT_MS / MS_PER_SECOND}s${
536
+ failure.lastError ? ` (last error: ${failure.lastError.message})` : ''
537
+ }`
538
+ : `Session initialization failed: ${failure.error.message}`;
539
+ this.stateManager.onError();
540
+ this.stateManager.addEntry({
541
+ id: `session-init-error-${Date.now()}`,
542
+ timestamp: new Date(),
543
+ category: 'event',
544
+ type: 'session-init-error',
545
+ data: { message },
546
+ });
547
+ }
548
+
549
+ private stopInitCheck(): void {
550
+ this.initPoller?.stop();
551
+ this.initPoller = null;
552
+ }
553
+
554
+ private syncExecutionWorkspace(): void {
555
+ try {
556
+ // allow-fallback: session may not be initialized yet; swallow until ready
557
+ this.stateManager.syncExecutionWorkspaceSnapshot(
558
+ this.interactiveSession.getExecutionWorkspaceSnapshot({
559
+ selectedEntryId: this.stateManager.selectedExecutionEntryId,
560
+ }),
561
+ );
562
+ } catch {
563
+ // allow-fallback: session may not be initialized yet; swallow until ready
564
+ /* Session not initialized yet */
565
+ }
566
+ }
567
+ }
@@ -0,0 +1,18 @@
1
+ import { Box } from 'ink';
2
+ import React from 'react';
3
+
4
+ import { Text } from './SafeText.js';
5
+ import { usePalette } from './theme/index.js';
6
+
7
+ interface IProps {
8
+ message: string;
9
+ }
10
+
11
+ export default function UpdateNotice({ message }: IProps): React.ReactElement {
12
+ const palette = usePalette();
13
+ return (
14
+ <Box paddingX={1} marginBottom={1}>
15
+ <Text color={palette.text.warning}>{message}</Text>
16
+ </Box>
17
+ );
18
+ }
@@ -0,0 +1,49 @@
1
+ import { formatTokenCount } from '@robota-sdk/agent-core';
2
+ import { Box } from 'ink';
3
+ import React from 'react';
4
+
5
+ import { Text } from './SafeText.js';
6
+ import { usePalette } from './theme/index.js';
7
+
8
+ import type { IHistoryEntry } from '@robota-sdk/agent-core';
9
+ import type { IUsageSnapshot } from '@robota-sdk/agent-interface-analytics';
10
+
11
+ const TOKEN_COMPACT_THRESHOLD = 1000;
12
+
13
+ export default function UsageSummaryEntry({ entry }: { entry: IHistoryEntry }): React.ReactElement {
14
+ const palette = usePalette();
15
+ const usage = entry.data as IUsageSnapshot | undefined;
16
+ if (!usage) return <></>;
17
+ const prompt = usage.promptTokens !== undefined ? formatUsageTokenCount(usage.promptTokens) : '?';
18
+ const completion =
19
+ usage.completionTokens !== undefined ? formatUsageTokenCount(usage.completionTokens) : '?';
20
+ const total = formatUsageTokenCount(usage.totalTokens);
21
+ // Usage another unit spent (the advisor, a background task) is named, and has no context window
22
+ // of the main thread to report.
23
+ const source =
24
+ usage.source !== undefined && usage.source.scope !== 'main' ? usage.source : undefined;
25
+ const sourceLabel = source ? (source.label ?? source.id ?? source.scope) : undefined;
26
+ const context = source
27
+ ? ''
28
+ : ` · Context ${Math.round(usage.contextUsedPercentage)}% (${formatTokenCount(
29
+ usage.contextUsedTokens,
30
+ )}/${formatTokenCount(usage.contextMaxTokens)})`;
31
+ const costLabel = usage.costStatus === 'unknown' ? 'cost unknown' : `cost ${usage.costStatus}`;
32
+
33
+ return (
34
+ <Box flexDirection="column" marginBottom={1}>
35
+ <Box>
36
+ <Text color={palette.text.emphasis} bold>
37
+ {sourceLabel ? `Usage (${sourceLabel}):` : 'Usage:'}{' '}
38
+ </Text>
39
+ <Text dimColor>
40
+ {usage.kind} {total} tokens (in {prompt} / out {completion}){context} · {costLabel}
41
+ </Text>
42
+ </Box>
43
+ </Box>
44
+ );
45
+ }
46
+
47
+ function formatUsageTokenCount(tokens: number): string {
48
+ return tokens < TOKEN_COMPACT_THRESHOLD ? tokens.toLocaleString() : formatTokenCount(tokens);
49
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * WaveText — renders text with a subtle wave color animation.
3
+ * Groups of 3-4 characters share the same color, creating a soft flowing effect.
4
+ * The ramp comes from the resolved theme's motion tokens (SCREEN-006, SCREEN-2002): a calm span
5
+ * wide enough to be perceptible (#555→#bbb) at an unhurried 400ms cadence.
6
+ */
7
+
8
+ import React, { useState, useEffect } from 'react';
9
+
10
+ import { Text } from './SafeText.js';
11
+ import { useScreenReader } from './screen-reader-context.js';
12
+ import { useMotion, useMotionTokens, usePalette } from './theme/index.js';
13
+
14
+ /** Cadence is not themed: a theme changes colour, not how often the terminal repaints. */
15
+ const MOTION_INTERVAL_MS = 400;
16
+ const MOTION_CHARS_PER_GROUP = 4;
17
+
18
+ interface IProps {
19
+ text: string;
20
+ }
21
+
22
+ export default function WaveText({ text }: IProps): React.ReactElement {
23
+ // Animate only on an interactive color terminal (shared with the markdown color
24
+ // gate via terminal-capabilities). Non-TTY / NO_COLOR / FORCE_COLOR=0 → static,
25
+ // no interval, no motion (SCREEN-006/008).
26
+ //
27
+ // CLI-2004: screen-reader mode is a gate on the same interval, not a second mechanism. A colour
28
+ // ramp is silent to a reader, but the repaint it causes is not — every tick re-emits the line, and
29
+ // the reader announces it again. SCREEN-2002 added `reducedMotion` as a third input to that rule.
30
+ const screenReader = useScreenReader();
31
+ const palette = usePalette();
32
+ const motionTokens = useMotionTokens();
33
+ // SCREEN-2002: the colour gate, screen-reader mode and the reduced-motion setting are ONE rule
34
+ // with one owner. This is its only consumer — the package's only animation.
35
+ const animate = useMotion();
36
+ const [tick, setTick] = useState(0);
37
+
38
+ useEffect(() => {
39
+ if (!animate) return undefined;
40
+ const timer = setInterval(() => {
41
+ setTick((prev) => prev + 1);
42
+ }, MOTION_INTERVAL_MS);
43
+ return () => clearInterval(timer);
44
+ }, [animate]);
45
+
46
+ if (screenReader) {
47
+ // No colour prop at all: the mode drops the cue rather than substituting a value.
48
+ return <Text>{text}</Text>;
49
+ }
50
+
51
+ if (!animate) {
52
+ return <Text color={palette.text.muted}>{text}</Text>;
53
+ }
54
+
55
+ const chars = [...text];
56
+
57
+ return (
58
+ <Text>
59
+ {chars.map((char, i) => {
60
+ const group = Math.floor(i / MOTION_CHARS_PER_GROUP);
61
+ const colorIndex = (tick + group) % motionTokens.wave.length;
62
+ return (
63
+ <Text key={i} color={motionTokens.wave[colorIndex]}>
64
+ {char}
65
+ </Text>
66
+ );
67
+ })}
68
+ </Text>
69
+ );
70
+ }