@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,30 @@
1
+ /**
2
+ * TERM-002: React glue for the terminal-handoff controller.
3
+ *
4
+ * Registers the App's suspend/resume into the controller. On suspend the App renders nothing (see
5
+ * App.tsx) so Ink unmounts its input hooks and releases raw mode, handing the real terminal to the
6
+ * child process; on resume the App re-renders.
7
+ */
8
+ import { useEffect, useState } from 'react';
9
+
10
+ import type { TerminalHandoffController } from '../terminal-handoff-controller.js';
11
+
12
+ /** Time for React/Ink to commit the empty render (unmount input, release raw mode) before handoff. */
13
+ const SUSPEND_COMMIT_DELAY_MS = 50;
14
+
15
+ export function useTerminalHandoffSuspension(
16
+ controller: TerminalHandoffController | undefined,
17
+ ): boolean {
18
+ const [suspended, setSuspended] = useState(false);
19
+ useEffect(() => {
20
+ if (!controller) return;
21
+ return controller.registerSuspendHooks({
22
+ suspend: async () => {
23
+ setSuspended(true);
24
+ await new Promise<void>((resolve) => setTimeout(resolve, SUSPEND_COMMIT_DELAY_MS));
25
+ },
26
+ resume: () => setSuspended(false),
27
+ });
28
+ }, [controller]);
29
+ return suspended;
30
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * useTuiChannel — React hook that subscribes to the bounded TUI channel port.
3
+ */
4
+
5
+ import { useState, useEffect, useCallback } from 'react';
6
+
7
+ import type {
8
+ ITuiAppChannelPort,
9
+ ITuiCommandQueryPort,
10
+ ITuiRuntimeStatusSnapshot,
11
+ ITuiSessionUiEventPort,
12
+ } from '../tui-app-channel-port.js';
13
+ import type { ITuiSessionEventNotice } from '../tui-session-events.js';
14
+ import type { IPendingPermissionRequest } from '../types.js';
15
+ import type {
16
+ IActionRequest,
17
+ IHistoryEntry,
18
+ TActionResponse,
19
+ TPermissionMode,
20
+ TSessionEndReason,
21
+ } from '@robota-sdk/agent-core';
22
+ import type {
23
+ IExecutionDetailPage,
24
+ IExecutionWorkspaceSnapshot,
25
+ } from '@robota-sdk/agent-interface-execution';
26
+ import type { IToolState } from '@robota-sdk/agent-interface-session';
27
+
28
+ export interface ITuiChannelState {
29
+ uiEventPort: ITuiSessionUiEventPort;
30
+ commandQueryPort: ITuiCommandQueryPort;
31
+ history: readonly IHistoryEntry[];
32
+ addEntry: (entry: IHistoryEntry) => void;
33
+ streamingText: string;
34
+ activeTools: readonly IToolState[];
35
+ isThinking: boolean;
36
+ isAborting: boolean;
37
+ /** ERR-001 G2: humanized message of the last failed turn (null once the next turn starts). */
38
+ lastErrorMessage: string | null;
39
+ /** ERR-001 G3: no provider activity for a while during a turn — connection may be stalled. */
40
+ isStalled: boolean;
41
+ sessionEventNotices: readonly ITuiSessionEventNotice[];
42
+ isShuttingDown: boolean;
43
+ pendingPrompt: string | null;
44
+ /** REMOTE-014 E5: total queued turns across all drivers (owner + co-drivers); >1 means a co-driver is queued. */
45
+ pendingCount: number;
46
+ executionWorkspaceSnapshot: IExecutionWorkspaceSnapshot | null;
47
+ selectedExecutionEntryId?: string;
48
+ permissionRequest: IPendingPermissionRequest | null;
49
+ /** CMD-004: the unified action awaiting a user answer, or null. */
50
+ pendingUserAction: IActionRequest | null;
51
+ contextState: { percentage: number; usedTokens: number; maxTokens: number };
52
+ handleSubmit: (input: string) => Promise<void>;
53
+ handleAbort: () => void;
54
+ handleCancelQueue: () => void;
55
+ handleStopWaitingLoop: () => Promise<void>;
56
+ handleShutdown: (reason?: TSessionEndReason) => Promise<void>;
57
+ sendAgentJob: (taskId: string, input: string) => Promise<void>;
58
+ resolveUserAction: (request: IActionRequest, response: TActionResponse) => void;
59
+ getRuntimeStatusSnapshot: (fallback: TPermissionMode) => ITuiRuntimeStatusSnapshot;
60
+ selectExecutionWorkspaceEntry: (entryId: string) => void;
61
+ readExecutionWorkspaceDetail: (entryId: string) => Promise<IExecutionDetailPage>;
62
+ }
63
+
64
+ interface IHistoryReadableSession {
65
+ getFullHistory(): IHistoryEntry[];
66
+ }
67
+
68
+ interface IHistorySyncManager {
69
+ syncHistory(entries: IHistoryEntry[]): void;
70
+ }
71
+
72
+ export function applyCompactEventToManager(
73
+ interactiveSession: IHistoryReadableSession,
74
+ manager: IHistorySyncManager,
75
+ ): void {
76
+ manager.syncHistory(interactiveSession.getFullHistory());
77
+ }
78
+
79
+ export function useTuiChannel(channel: ITuiAppChannelPort): ITuiChannelState {
80
+ const [, forceRender] = useState(0);
81
+
82
+ useEffect(() => channel.subscribe(() => forceRender((n) => n + 1)), [channel]);
83
+ const snapshot = channel.getSnapshot();
84
+
85
+ // SCREEN-014 fix: these are consumed in `useEffect` dependency arrays in App. `channel` is stable
86
+ // (created once), so memoize them — a fresh closure each render made the detail-loading effect
87
+ // re-run every render and `setState`-loop ("Maximum update depth exceeded") whenever a background
88
+ // entry was selected.
89
+ const selectExecutionWorkspaceEntry = useCallback(
90
+ (id: string) => channel.selectExecutionWorkspaceEntry(id),
91
+ [channel],
92
+ );
93
+ const readExecutionWorkspaceDetail = useCallback(
94
+ (id: string) => channel.readExecutionWorkspaceDetail(id),
95
+ [channel],
96
+ );
97
+
98
+ return {
99
+ uiEventPort: channel.getSessionUiEventPort(),
100
+ commandQueryPort: channel.getCommandQueryPort(),
101
+ history: snapshot.history,
102
+ addEntry: (entry) => channel.addEntry(entry),
103
+ streamingText: snapshot.streamingText,
104
+ activeTools: snapshot.activeTools,
105
+ isThinking: snapshot.isThinking,
106
+ isAborting: snapshot.isAborting,
107
+ lastErrorMessage: snapshot.lastErrorMessage,
108
+ isStalled: snapshot.isStalled,
109
+ sessionEventNotices: snapshot.sessionEventNotices,
110
+ isShuttingDown: snapshot.isShuttingDown,
111
+ pendingPrompt: snapshot.pendingPrompt,
112
+ pendingCount: snapshot.pendingCount,
113
+ executionWorkspaceSnapshot: snapshot.executionWorkspaceSnapshot,
114
+ selectedExecutionEntryId: snapshot.selectedExecutionEntryId,
115
+ permissionRequest: snapshot.permissionRequest,
116
+ pendingUserAction: snapshot.pendingUserAction,
117
+ contextState: snapshot.contextState,
118
+ handleSubmit: (input) => channel.handleInput(input),
119
+ handleAbort: () => channel.abort(),
120
+ handleCancelQueue: () => channel.cancelQueue(),
121
+ handleStopWaitingLoop: () => channel.stopWaitingSelfPacedLoop(),
122
+ handleShutdown: (reason) => channel.shutdown({ reason }),
123
+ sendAgentJob: (taskId, input) => channel.sendAgentJob(taskId, input),
124
+ resolveUserAction: (request, response) => channel.resolveUserAction(request, response),
125
+ getRuntimeStatusSnapshot: (fallback) => channel.getRuntimeStatusSnapshot(fallback),
126
+ selectExecutionWorkspaceEntry,
127
+ readExecutionWorkspaceDetail,
128
+ };
129
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * SCREEN-012: turn a provider-safe projected tool id into the natural command name for display.
3
+ *
4
+ * Slash commands invoked by the model are projected to provider-safe tool ids prefixed with
5
+ * the host-selected prefix (or the neutral default), optionally suffixed with a `_<8 hex>` hash when
6
+ * the name would otherwise exceed the provider length limit (see model-command-tool-projection.ts).
7
+ * For display we recover the command name: strip the prefix and a trailing projection hash. Tool
8
+ * names that are not command projections (e.g. `Shell`, `Read`) are returned unchanged.
9
+ *
10
+ * ## SEC-019: this is where a tool label is made safe, not at each render site
11
+ *
12
+ * A tool name and its first argument come from the model, from an MCP server's manifest, or from a
13
+ * plugin — all untrusted — and they are put on the terminal as text. Sanitizing at the render sites
14
+ * was tried and failed within one review round: `MessageList` has two branches that show a tool name
15
+ * and only one of them got the call, so a name carrying OSC 52 still reached `<Text>` whenever the
16
+ * tool's content happened to parse as a structured summary. Three more sites had the same gap
17
+ * (`StreamingIndicator`, and the label `tool-summary-status` composes for the history view).
18
+ *
19
+ * The defect is not that a call was forgotten. It is that "make this displayable" and "make this
20
+ * safe to display" were two separate steps, so every new render site had to remember the second one.
21
+ * They are one step here, and a fifth caller is covered because it cannot obtain the display text
22
+ * without going through the function that sanitizes it.
23
+ */
24
+ import { MODEL_COMMAND_TOOL_PREFIX } from '@robota-sdk/agent-framework';
25
+
26
+ import { sanitizeTerminalText } from './sanitize-terminal-text.js';
27
+
28
+ /** A trailing `_<8 lowercase hex>` projection hash appended to over-long command tool names. */
29
+ const PROJECTION_HASH_SUFFIX = /_[0-9a-f]{8}$/;
30
+
31
+ export function humanizeToolName(
32
+ toolName: string,
33
+ prefix: string = MODEL_COMMAND_TOOL_PREFIX,
34
+ ): string {
35
+ if (!toolName.startsWith(prefix)) return sanitizeTerminalText(toolName);
36
+ const body = toolName.slice(prefix.length);
37
+ const withoutHash = body.replace(PROJECTION_HASH_SUFFIX, '');
38
+ return sanitizeTerminalText(withoutHash.length > 0 ? withoutHash : toolName);
39
+ }
40
+
41
+ /**
42
+ * The first argument of a tool call, as display text.
43
+ *
44
+ * A separate function rather than a bare `sanitizeTerminalText` at the two sites that show it: the
45
+ * argument is the other half of a tool label, it is equally untrusted — a path, a shell command, a
46
+ * URL the model chose — and pairing it with {@link humanizeToolName} is what makes the boundary
47
+ * findable. A render site that reaches for one naturally reaches for the other.
48
+ */
49
+ export function humanizeToolArgument(firstArg: string | undefined): string {
50
+ return firstArg === undefined ? '' : sanitizeTerminalText(firstArg);
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ export { renderApp } from './render.js';
2
+ export { renderSupervisedSessionView } from './SupervisedSessionView.js';
3
+ export type { ISupervisedViewRow, ISupervisedSessionViewProps } from './SupervisedSessionView.js';
4
+ export { TuiInteractionChannel } from './TuiInteractionChannel.js';
5
+ export type { ITuiInteractionChannelOptions } from './TuiInteractionChannel.js';
6
+ export type { IRenderOptions } from './render.js';
7
+ export type { ITerminalCapabilityOverrides } from './terminal-capabilities-context.js';
8
+ export type { TScreenReaderChannel } from './screen-reader-announcement.js';
9
+ export type { IScreenReaderPacingOverrides } from './screen-reader-pacing.js';
10
+ export type { ITuiCliAdapter } from './tui-cli-adapter.js';
11
+ export type { IDefaultTuiCliAdapterOptions } from './create-default-tui-cli-adapter.js';
12
+ export { createDefaultTuiCliAdapter } from './create-default-tui-cli-adapter.js';
13
+ export { createNodeKeybindingsSource } from './keybindings/node-keybindings-source.js';
14
+ // SCREEN-2002: the product shell assembles one registry and hands it to both `/theme` and
15
+ // `renderApp`. The theme DATA stays unexported — a caller asks the registry, never a built-in.
16
+ export { createThemeCataloguePort, createThemeRegistry } from './theme/theme-registry.js';
17
+ // The product shell composes the registry, so it must be able to enumerate the built-ins, parse the
18
+ // files that join them, and build its OWN diagnostics through the same escaping policy. The
19
+ // accessor comes from the module that USES it rather than from the data module, which the
20
+ // anti-drift floor forbids naming outside `src/theme/`.
21
+ export { listBuiltInThemes } from './theme/theme-registry.js';
22
+ export {
23
+ escapeThemeText,
24
+ parseThemeDocument,
25
+ quoteThemeText,
26
+ sanitizeThemeProse,
27
+ } from './theme/theme-document.js';
28
+ export type {
29
+ IThemeCataloguePortOptions,
30
+ IThemeRegistry,
31
+ IThemeResolution,
32
+ IThemeSkip,
33
+ } from './theme/theme-registry.js';
34
+ export type { IThemeDocumentInput, TThemeDocumentResult } from './theme/theme-document.js';
35
+ // `createThemeRegistry` takes themes, so a caller must be able to name one.
36
+ export type { ITuiTheme, TThemeSource } from './theme/theme-contracts.js';
37
+ export type {
38
+ IKeybindingsFilePort,
39
+ IKeybindingsSource,
40
+ INodeKeybindingsSourceOptions,
41
+ } from './keybindings/node-keybindings-source.js';
42
+ export type {
43
+ IKeybindingDiagnostic,
44
+ IKeybindingSnapshot,
45
+ IKeybindingWarning,
46
+ TKeybindingAction,
47
+ TKeybindingContext,
48
+ } from './keybindings/keybinding-registry.js';
49
+ export type {
50
+ TOnMissingArgsAction,
51
+ ITuiPickerItem,
52
+ ITuiCommandInteraction,
53
+ ITuiPickerInteraction,
54
+ ITuiConfirmInteraction,
55
+ TAnyTuiCommandInteraction,
56
+ } from './command-interaction.js';
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The input area's horizontal rules, and the deletion announcement that replaces them.
3
+ *
4
+ * Extracted from `InputArea.tsx` (CLI-2004). The rules are hand-drawn `<Text>` lines rather than a
5
+ * Yoga-synthesized `Box` border because a synthesized border drops glyphs during rapid re-render at
6
+ * full terminal height (SCREEN-003) — which is exactly why they are not covered by the `borderStyle`
7
+ * suppression the mode applies elsewhere and need their own branch.
8
+ */
9
+
10
+ import React from 'react';
11
+
12
+ import { Text } from './SafeText.js';
13
+ import { usePalette } from './theme/index.js';
14
+ import { buildInputTopBorder } from './utils/input-top-border.js';
15
+
16
+ /** The announcement's exact shape, so the input and its test cannot disagree about it. */
17
+ export function formatDeletionAnnouncement(deleted: string): string {
18
+ return `[deleted: ${deleted}]`;
19
+ }
20
+
21
+ /** Top rule, carrying the optional right-aligned session title. Nothing in screen-reader mode. */
22
+ export function InputTopRule({
23
+ screenReader,
24
+ innerWidth,
25
+ borderColor,
26
+ sessionName,
27
+ }: {
28
+ screenReader: boolean;
29
+ innerWidth: number;
30
+ borderColor: string;
31
+ sessionName?: string;
32
+ }): React.ReactElement | null {
33
+ const palette = usePalette();
34
+ if (screenReader) return null;
35
+ const topBorder = buildInputTopBorder(innerWidth, sessionName);
36
+ return (
37
+ <Text color={borderColor}>
38
+ {topBorder.left}
39
+ {topBorder.label ? (
40
+ <Text backgroundColor={borderColor} color={palette.text.onAccent} bold>
41
+ {topBorder.label}
42
+ </Text>
43
+ ) : null}
44
+ {topBorder.right}
45
+ </Text>
46
+ );
47
+ }
48
+
49
+ /** Bottom rule (mirrors the top). Nothing in screen-reader mode. */
50
+ export function InputBottomRule({
51
+ screenReader,
52
+ innerWidth,
53
+ borderColor,
54
+ }: {
55
+ screenReader: boolean;
56
+ innerWidth: number;
57
+ borderColor: string;
58
+ }): React.ReactElement | null {
59
+ if (screenReader) return null;
60
+ return <Text color={borderColor}>{'─'.repeat(innerWidth)}</Text>;
61
+ }
62
+
63
+ /** `[deleted: <text>]`, rendered once for the most recent word/line delete. */
64
+ export function DeletionAnnouncement({
65
+ deleted,
66
+ }: {
67
+ deleted: string | null;
68
+ }): React.ReactElement | null {
69
+ if (deleted === null) return null;
70
+ return <Text>{formatDeletionAnnouncement(deleted)}</Text>;
71
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The shape of a parsed JSON document, and the one guard that narrows it.
3
+ *
4
+ * Two user-owned files are read as JSON in this package — the keybindings document and, since
5
+ * SCREEN-2002, a theme document — and both walk an arbitrary tree looking for the keys they know.
6
+ * A second copy of this type would be a second answer to "what can a parsed file hold", which is
7
+ * how two readers of the same kind of file start disagreeing about what a null or an array means.
8
+ */
9
+ export type TJsonValue = string | number | boolean | null | TJsonValue[] | IJsonRecord;
10
+
11
+ export interface IJsonRecord {
12
+ [key: string]: TJsonValue;
13
+ }
14
+
15
+ /** An object, not an array and not `null` — the distinction `typeof` alone does not make. */
16
+ export function isJsonRecord(value: TJsonValue): value is IJsonRecord {
17
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
18
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Key-hint footer SSOT (SCREEN-005).
3
+ *
4
+ * Single source of truth for prompt-footer key hints and the selection-row indicator across the
5
+ * package's Ink prompt/selection components. Every footer call site declares its hints as
6
+ * `IKeyHint[]` and renders them through `formatKeyHints` / `KeyHintFooter`, so exactly one grammar
7
+ * exists (no per-component footer dialects) and a drift cannot re-appear silently — the
8
+ * `key-hint-consistency` test asserts every declared hint set round-trips through this module.
9
+ *
10
+ * This module ships MECHANICS only: the separator, the pair grammar, the indicator constants.
11
+ * Verb vocabulary (Navigate/Select/Confirm/…) is supplied by callers — the package stays a
12
+ * content-neutral library ingredient. No config surface: per-call-site suppression is passing an
13
+ * empty hint list.
14
+ *
15
+ * Affordance contract (see docs/SPEC.md "Interaction affordance contract"): the footer lists
16
+ * exactly the keys that do something — a prompt that suppresses Esc in its flow omits Esc here.
17
+ */
18
+
19
+ import React from 'react';
20
+
21
+ import { Text } from './SafeText.js';
22
+
23
+ /** One key hint: the key(s) and the verb label, e.g. `{ keys: '↑↓', label: 'Navigate' }`. */
24
+ export interface IKeyHint {
25
+ keys: string;
26
+ label: string;
27
+ }
28
+
29
+ /** Separator between key-hint pairs. Changing the footer separator is a one-constant edit. */
30
+ export const KEY_HINT_SEPARATOR = ' · ';
31
+
32
+ /** Selection cursor rendered in front of the focused row. */
33
+ export const SELECTION_INDICATOR = '> ';
34
+
35
+ /** Same-width blank rendered in front of non-focused rows so columns stay aligned. */
36
+ export const SELECTION_INDICATOR_NONE = ' ';
37
+
38
+ /** Pure formatter: joins `keys label` pairs with {@link KEY_HINT_SEPARATOR}. Empty list → ''. */
39
+ export function formatKeyHints(hints: readonly IKeyHint[]): string {
40
+ return hints.map((hint) => `${hint.keys} ${hint.label}`).join(KEY_HINT_SEPARATOR);
41
+ }
42
+
43
+ /**
44
+ * Dim one-line footer: leading pad + formatted hints. Renders nothing for an empty hint list
45
+ * (per-call-site suppression without a config surface).
46
+ */
47
+ export function KeyHintFooter({
48
+ hints,
49
+ }: {
50
+ hints: readonly IKeyHint[];
51
+ }): React.ReactElement | null {
52
+ if (hints.length === 0) return null;
53
+ return <Text dimColor> {formatKeyHints(hints)}</Text>;
54
+ }
@@ -0,0 +1,228 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { KEYBINDING_ACTIONS } from '../keybinding-catalogue.js';
6
+ import {
7
+ createEmptyChordState,
8
+ DEFAULT_KEYBINDINGS,
9
+ keybindingHints,
10
+ parseKeybindingsDocument,
11
+ resolveKeybindingInput,
12
+ } from '../keybinding-registry.js';
13
+
14
+ const SOURCE = '/tmp/robota-keybindings.json';
15
+
16
+ function parse(document: unknown) {
17
+ return parseKeybindingsDocument(JSON.stringify(document), SOURCE);
18
+ }
19
+
20
+ describe('contextual keybinding registry', () => {
21
+ it('normalizes aliases and uppercase, reuses bindings by context, and applies null unbinding', () => {
22
+ const result = parse({
23
+ version: 1,
24
+ bindings: {
25
+ 'chat-input': { submit: 'control+j', 'history-previous': null },
26
+ 'autocomplete-menu': { accept: 'ctrl+j' },
27
+ 'workspace-switcher': { attach: 'A' },
28
+ },
29
+ });
30
+
31
+ expect(result.ok).toBe(true);
32
+ if (!result.ok) return;
33
+ expect(result.snapshot.bindings['chat-input'].submit).toEqual(['ctrl+j']);
34
+ expect(result.snapshot.bindings['chat-input']['history-previous']).toEqual([]);
35
+ expect(result.snapshot.bindings['autocomplete-menu'].accept).toEqual(['ctrl+j']);
36
+ expect(result.snapshot.bindings['workspace-switcher'].attach).toEqual(['shift+a']);
37
+ expect(result.snapshot.bindings['chat-input']['cursor-left']).toEqual(
38
+ DEFAULT_KEYBINDINGS['chat-input']['cursor-left'],
39
+ );
40
+ expect(Object.isFrozen(result.snapshot)).toBe(true);
41
+ });
42
+
43
+ it.each([
44
+ [{ version: 1, bindings: { missing: { submit: 'enter' } } }, '$.bindings.missing'],
45
+ [
46
+ { version: 1, bindings: { 'chat-input': { missing: 'enter' } } },
47
+ '$.bindings.chat-input.missing',
48
+ ],
49
+ [
50
+ { version: 1, bindings: { 'chat-input': { submit: 'ctrl+c' } } },
51
+ '$.bindings.chat-input.submit',
52
+ ],
53
+ // SCREEN-1993: Ctrl+C stays reserved in the search overlay too.
54
+ [
55
+ { version: 1, bindings: { 'history-search': { execute: 'ctrl+c' } } },
56
+ '$.bindings.history-search.execute',
57
+ ],
58
+ [
59
+ {
60
+ version: 1,
61
+ bindings: { 'chat-input': { submit: 'ctrl+j', 'history-previous': 'control+j' } },
62
+ },
63
+ '$.bindings.chat-input.history-previous',
64
+ ],
65
+ [
66
+ {
67
+ version: 1,
68
+ bindings: { 'chat-input': { submit: 'ctrl+m', 'history-previous': 'enter' } },
69
+ },
70
+ '$.bindings.chat-input.history-previous',
71
+ ],
72
+ [
73
+ {
74
+ version: 1,
75
+ bindings: { 'chat-input': { submit: 'ctrl+x', 'history-previous': 'ctrl+x ctrl+p' } },
76
+ },
77
+ '$.bindings.chat-input.history-previous',
78
+ ],
79
+ [{ version: 1, bindings: { 'chat-input': { submit: 'g g' } } }, '$.bindings.chat-input.submit'],
80
+ ])('rejects an invalid replacement atomically with an exact path', (document, path) => {
81
+ const result = parse(document);
82
+ expect(result.ok).toBe(false);
83
+ if (result.ok) return;
84
+ expect(result.diagnostic.path).toBe(path);
85
+ expect(result.diagnostic.file).toBe(SOURCE);
86
+ });
87
+
88
+ it('keeps multiplexer and undeliverable modifier conflicts as warnings', () => {
89
+ const result = parse({
90
+ version: 1,
91
+ bindings: {
92
+ app: { 'open-workspace-switcher': 'ctrl+b' },
93
+ 'workspace-switcher': { attach: 'ctrl+shift+a' },
94
+ },
95
+ });
96
+ expect(result.ok).toBe(true);
97
+ if (!result.ok) return;
98
+ expect(result.snapshot.warnings.map((warning) => warning.code)).toEqual([
99
+ 'multiplexer-conflict',
100
+ 'modifier-delivery',
101
+ ]);
102
+ });
103
+
104
+ it('resolves bounded chords and re-evaluates one mismatching stroke', () => {
105
+ const parsed = parse({
106
+ version: 1,
107
+ bindings: {
108
+ 'workspace-switcher': { attach: 'g g', close: 'escape' },
109
+ },
110
+ });
111
+ expect(parsed.ok).toBe(true);
112
+ if (!parsed.ok) return;
113
+
114
+ const first = resolveKeybindingInput({
115
+ snapshot: parsed.snapshot,
116
+ state: createEmptyChordState(),
117
+ context: 'workspace-switcher',
118
+ input: 'g',
119
+ key: {},
120
+ now: 100,
121
+ });
122
+ expect(first).toMatchObject({ actions: [], consumed: true });
123
+
124
+ const mismatch = resolveKeybindingInput({
125
+ snapshot: parsed.snapshot,
126
+ state: first.state,
127
+ context: 'workspace-switcher',
128
+ input: '',
129
+ key: { escape: true },
130
+ now: 200,
131
+ });
132
+ expect(mismatch.actions).toEqual(['close']);
133
+ expect(mismatch.state.strokes).toEqual([]);
134
+ });
135
+
136
+ it('resolves terminal control-code aliases without changing the configured label', () => {
137
+ const parsed = parse({
138
+ version: 1,
139
+ bindings: { 'chat-input': { submit: 'ctrl+m' } },
140
+ });
141
+ expect(parsed.ok).toBe(true);
142
+ if (!parsed.ok) return;
143
+
144
+ const result = resolveKeybindingInput({
145
+ snapshot: parsed.snapshot,
146
+ state: createEmptyChordState(),
147
+ context: 'chat-input',
148
+ input: '',
149
+ key: { return: true },
150
+ now: 0,
151
+ });
152
+
153
+ expect(result).toMatchObject({ actions: ['submit'], consumed: true });
154
+ expect(keybindingHints(parsed.snapshot, 'chat-input', [['submit', 'Submit']])).toEqual([
155
+ { keys: 'Ctrl+M', label: 'Submit' },
156
+ ]);
157
+ });
158
+
159
+ it('resets pending chords on timeout, context change, and snapshot replacement', () => {
160
+ const parsed = parse({
161
+ version: 1,
162
+ bindings: { 'workspace-switcher': { attach: 'g g' } },
163
+ });
164
+ expect(parsed.ok).toBe(true);
165
+ if (!parsed.ok) return;
166
+ const pending = resolveKeybindingInput({
167
+ snapshot: parsed.snapshot,
168
+ state: createEmptyChordState(),
169
+ context: 'workspace-switcher',
170
+ input: 'g',
171
+ key: {},
172
+ now: 0,
173
+ }).state;
174
+
175
+ for (const input of [
176
+ { context: 'workspace-switcher' as const, now: 1_001, generation: 0, strokes: ['g'] },
177
+ { context: 'list-picker' as const, now: 100, generation: 0, strokes: [] },
178
+ { context: 'workspace-switcher' as const, now: 100, generation: 1, strokes: ['g'] },
179
+ ]) {
180
+ const snapshot = { ...parsed.snapshot, generation: input.generation };
181
+ const result = resolveKeybindingInput({
182
+ snapshot,
183
+ state: pending,
184
+ context: input.context,
185
+ input: 'g',
186
+ key: {},
187
+ now: input.now,
188
+ });
189
+ expect(result.actions).toEqual([]);
190
+ expect(result.state.strokes).toEqual(input.strokes);
191
+ }
192
+ });
193
+
194
+ it('derives user-facing hints only from effective active bindings', () => {
195
+ const parsed = parse({
196
+ version: 1,
197
+ bindings: { 'chat-input': { submit: 'ctrl+j', 'history-previous': null } },
198
+ });
199
+ expect(parsed.ok).toBe(true);
200
+ if (!parsed.ok) return;
201
+ expect(
202
+ keybindingHints(parsed.snapshot, 'chat-input', [
203
+ ['history-previous', 'Previous'],
204
+ ['submit', 'Submit'],
205
+ ]),
206
+ ).toEqual([{ keys: 'Ctrl+J', label: 'Submit' }]);
207
+ });
208
+
209
+ it('keeps the published JSON Schema aligned with every runtime context and action', () => {
210
+ const schemaPath = new URL(
211
+ '../../../../../apps/docs/public/schemas/keybindings.schema.json',
212
+ import.meta.url,
213
+ );
214
+ const schema = JSON.parse(readFileSync(schemaPath, 'utf8')) as {
215
+ $id: string;
216
+ properties: { bindings: { properties: Record<string, { $ref: string }> } };
217
+ $defs: Record<string, { properties?: Record<string, unknown> }>;
218
+ };
219
+
220
+ expect(schema.$id).toBe('https://docs.robota.io/schemas/keybindings.schema.json');
221
+ expect(Object.keys(schema.properties.bindings.properties)).toEqual(
222
+ Object.keys(KEYBINDING_ACTIONS),
223
+ );
224
+ for (const [context, actions] of Object.entries(KEYBINDING_ACTIONS)) {
225
+ expect(Object.keys(schema.$defs[context]?.properties ?? {})).toEqual(actions);
226
+ }
227
+ });
228
+ });