@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,111 @@
1
+ import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ import {
8
+ createNodeKeybindingsSource,
9
+ } from '../node-keybindings-source.js';
10
+
11
+ const roots: string[] = [];
12
+
13
+ async function temporaryRoot(): Promise<string> {
14
+ const root = await mkdtemp(join(tmpdir(), 'robota-keybindings-'));
15
+ roots.push(root);
16
+ return root;
17
+ }
18
+
19
+ async function eventually(assertion: () => void, timeout = 2_000): Promise<void> {
20
+ const deadline = Date.now() + timeout;
21
+ while (Date.now() < deadline) {
22
+ try {
23
+ assertion();
24
+ return;
25
+ } catch {
26
+ await new Promise((resolve) => setTimeout(resolve, 10));
27
+ }
28
+ }
29
+ assertion();
30
+ }
31
+
32
+ afterEach(async () => {
33
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
34
+ });
35
+
36
+ describe('Node keybindings source', () => {
37
+ it('uses only the host-selected file and schema when creating the sparse document', async () => {
38
+ const root = await temporaryRoot();
39
+ const filePath = join(root, 'host-config', 'keys.json');
40
+ const schemaUrl = 'https://example.invalid/schemas/keys.json';
41
+ const source = createNodeKeybindingsSource({ filePath, schemaUrl });
42
+ const firstPath = await source.ensureFile();
43
+ expect(firstPath).toBe(filePath);
44
+ expect(JSON.parse(await readFile(firstPath, 'utf8'))).toEqual({
45
+ $schema: schemaUrl,
46
+ version: 1,
47
+ bindings: {},
48
+ });
49
+
50
+ await writeFile(firstPath, '{"owned":true}\n', 'utf8');
51
+ expect(await source.ensureFile()).toBe(firstPath);
52
+ expect(await readFile(firstPath, 'utf8')).toBe('{"owned":true}\n');
53
+ source.dispose();
54
+ });
55
+
56
+ it('loads atomic replacements, retains last-valid bindings on invalid input, and disposes', async () => {
57
+ const root = await temporaryRoot();
58
+ const path = join(root, 'custom', 'keybindings.json');
59
+ const log = vi.fn();
60
+ const source = createNodeKeybindingsSource({
61
+ filePath: path,
62
+ schemaUrl: 'https://example.invalid/keys.json',
63
+ onDiagnostic: log,
64
+ });
65
+ await source.start();
66
+ expect(await source.ensureFile()).toBe(path);
67
+ const snapshots = [source.getSnapshot()];
68
+ const unsubscribe = source.subscribe((snapshot) => snapshots.push(snapshot));
69
+
70
+ const replacement = join(root, 'custom', 'keybindings.next.json');
71
+ await writeFile(
72
+ replacement,
73
+ JSON.stringify({ version: 1, bindings: { 'chat-input': { submit: 'ctrl+k' } } }),
74
+ 'utf8',
75
+ );
76
+ await rename(replacement, path);
77
+ await eventually(() => {
78
+ expect(source.getSnapshot().bindings['chat-input'].submit).toEqual(['ctrl+k']);
79
+ });
80
+ const valid = source.getSnapshot();
81
+
82
+ await writeFile(replacement, '{"version":1,"bindings":{"missing":{}}}', 'utf8');
83
+ await rename(replacement, path);
84
+ await eventually(() => {
85
+ expect(source.getSnapshot().diagnostic?.path).toBe('$.bindings.missing');
86
+ });
87
+ expect(source.getSnapshot().bindings).toBe(valid.bindings);
88
+ expect(source.getSnapshot().generation).toBeGreaterThan(valid.generation);
89
+ expect(log).toHaveBeenCalledWith(expect.objectContaining({ file: path }));
90
+
91
+ unsubscribe();
92
+ source.dispose();
93
+ const count = snapshots.length;
94
+ await writeFile(path, JSON.stringify({ version: 1, bindings: {} }), 'utf8');
95
+ await new Promise((resolve) => setTimeout(resolve, 40));
96
+ expect(snapshots).toHaveLength(count);
97
+ });
98
+
99
+ it('shares one start, and a failed start leaves no half-started source', async () => {
100
+ const root = await temporaryRoot();
101
+ const source = createNodeKeybindingsSource({
102
+ filePath: join(root, 'custom', 'keybindings.json'),
103
+ schemaUrl: 'https://example.invalid/keys.json',
104
+ });
105
+ await Promise.all([source.start(), source.start()]);
106
+ expect(source.isStarted()).toBe(true);
107
+ source.dispose();
108
+ expect(source.isStarted()).toBe(false);
109
+ await expect(source.start()).rejects.toThrow(/disposed/i);
110
+ });
111
+ });
@@ -0,0 +1,171 @@
1
+ export const KEYBINDING_ACTIONS = {
2
+ app: ['open-workspace-switcher'],
3
+ thinking: ['abort'],
4
+ 'background-detail': ['return-to-main'],
5
+ recovery: ['retry'],
6
+ 'chat-input': [
7
+ 'submit',
8
+ 'history-previous',
9
+ 'history-next',
10
+ 'history-search',
11
+ 'cursor-left',
12
+ 'cursor-right',
13
+ 'delete-backward',
14
+ 'delete-word',
15
+ 'delete-line',
16
+ ],
17
+ 'text-input': [
18
+ 'submit',
19
+ 'cursor-left',
20
+ 'cursor-right',
21
+ 'cursor-up',
22
+ 'cursor-down',
23
+ 'delete-backward',
24
+ 'delete-word',
25
+ 'delete-line',
26
+ ],
27
+ 'autocomplete-menu': ['previous', 'next', 'close', 'accept', 'execute'],
28
+ 'queued-prompt': ['cancel'],
29
+ 'confirm-prompt': ['previous', 'next', 'confirm', 'choose-yes', 'choose-no'],
30
+ 'permission-prompt': [
31
+ 'previous',
32
+ 'next',
33
+ 'confirm',
34
+ 'allow-once',
35
+ 'allow-session',
36
+ 'allow-project',
37
+ 'deny',
38
+ ],
39
+ 'text-prompt': ['submit', 'cancel', 'delete-backward'],
40
+ 'list-picker': ['previous', 'next', 'select', 'cancel'],
41
+ 'menu-select': ['previous', 'next', 'select', 'back'],
42
+ 'multi-select': ['previous', 'next', 'toggle', 'confirm', 'cancel'],
43
+ 'workspace-switcher': ['previous', 'next', 'select', 'close', 'attach'],
44
+ 'background-list': ['previous', 'next', 'open', 'close'],
45
+ 'transport-settings': ['previous', 'next', 'toggle', 'close'],
46
+ /** SCREEN-1993: the reverse prompt-history search overlay. */
47
+ 'history-search': ['previous', 'next', 'cycle-scope', 'insert', 'execute', 'cancel'],
48
+ /**
49
+ * SCREEN-2002: the theme picker. Navigation PREVIEWS; only `select` applies. Printable keys are
50
+ * fine here because the picker takes no text.
51
+ */
52
+ 'theme-picker': ['previous', 'next', 'select', 'toggle-syntax', 'toggle-motion', 'cancel'],
53
+ } as const;
54
+
55
+ export type TKeybindingContext = keyof typeof KEYBINDING_ACTIONS;
56
+ export type TKeybindingAction<TContext extends TKeybindingContext = TKeybindingContext> =
57
+ (typeof KEYBINDING_ACTIONS)[TContext][number];
58
+
59
+ type TDefaultKeybindings = {
60
+ readonly [TContext in TKeybindingContext]: Readonly<
61
+ Record<TKeybindingAction<TContext>, readonly string[]>
62
+ >;
63
+ };
64
+
65
+ export const DEFAULT_KEYBINDINGS = {
66
+ app: { 'open-workspace-switcher': ['ctrl+b'] },
67
+ thinking: { abort: ['escape'] },
68
+ 'background-detail': { 'return-to-main': ['escape'] },
69
+ recovery: { retry: ['enter'] },
70
+ 'chat-input': {
71
+ submit: ['enter'],
72
+ 'history-previous': ['up'],
73
+ 'history-next': ['down'],
74
+ 'history-search': ['ctrl+r'],
75
+ 'cursor-left': ['left'],
76
+ 'cursor-right': ['right'],
77
+ 'delete-backward': ['backspace', 'delete'],
78
+ 'delete-word': ['ctrl+w'],
79
+ 'delete-line': ['ctrl+u'],
80
+ },
81
+ 'text-input': {
82
+ submit: ['enter'],
83
+ 'cursor-left': ['left'],
84
+ 'cursor-right': ['right'],
85
+ 'cursor-up': ['up'],
86
+ 'cursor-down': ['down'],
87
+ 'delete-backward': ['backspace', 'delete'],
88
+ 'delete-word': ['ctrl+w'],
89
+ 'delete-line': ['ctrl+u'],
90
+ },
91
+ 'autocomplete-menu': {
92
+ previous: ['up'],
93
+ next: ['down'],
94
+ close: ['escape'],
95
+ accept: ['tab'],
96
+ execute: ['enter'],
97
+ },
98
+ 'queued-prompt': { cancel: ['backspace', 'delete'] },
99
+ 'confirm-prompt': {
100
+ previous: ['left', 'up'],
101
+ next: ['right', 'down'],
102
+ confirm: ['enter'],
103
+ 'choose-yes': ['y'],
104
+ 'choose-no': ['n'],
105
+ },
106
+ 'permission-prompt': {
107
+ previous: ['left', 'up'],
108
+ next: ['right', 'down'],
109
+ confirm: ['enter'],
110
+ 'allow-once': ['y'],
111
+ 'allow-session': ['s', 'a'],
112
+ 'allow-project': ['p'],
113
+ deny: ['n', 'd'],
114
+ },
115
+ 'text-prompt': {
116
+ submit: ['enter'],
117
+ cancel: ['escape'],
118
+ 'delete-backward': ['backspace', 'delete'],
119
+ },
120
+ 'list-picker': { previous: ['up'], next: ['down'], select: ['enter'], cancel: ['escape'] },
121
+ 'menu-select': { previous: ['up'], next: ['down'], select: ['enter'], back: ['escape'] },
122
+ 'multi-select': {
123
+ previous: ['up'],
124
+ next: ['down'],
125
+ toggle: ['space'],
126
+ confirm: ['enter'],
127
+ cancel: ['escape'],
128
+ },
129
+ 'workspace-switcher': {
130
+ previous: ['up'],
131
+ next: ['down'],
132
+ select: ['enter'],
133
+ close: ['ctrl+b', 'escape'],
134
+ attach: ['a'],
135
+ },
136
+ 'background-list': {
137
+ previous: ['up'],
138
+ next: ['down'],
139
+ open: ['enter'],
140
+ close: ['escape'],
141
+ },
142
+ 'transport-settings': {
143
+ previous: ['up'],
144
+ next: ['down'],
145
+ toggle: ['space'],
146
+ close: ['enter', 'escape'],
147
+ },
148
+ 'history-search': {
149
+ previous: ['up'],
150
+ next: ['down', 'ctrl+r'],
151
+ 'cycle-scope': ['ctrl+s'],
152
+ insert: ['enter', 'tab'],
153
+ execute: ['ctrl+e'],
154
+ cancel: ['escape'],
155
+ },
156
+ 'theme-picker': {
157
+ previous: ['up'],
158
+ next: ['down'],
159
+ select: ['enter'],
160
+ 'toggle-syntax': ['s'],
161
+ 'toggle-motion': ['m'],
162
+ cancel: ['escape'],
163
+ },
164
+ } as const satisfies TDefaultKeybindings;
165
+
166
+ export const TEXT_ENTRY_CONTEXTS: ReadonlySet<TKeybindingContext> = new Set([
167
+ 'chat-input',
168
+ 'text-input',
169
+ 'text-prompt',
170
+ 'history-search',
171
+ ]);
@@ -0,0 +1,303 @@
1
+ import { isJsonRecord } from '../json-value.js';
2
+ import {
3
+ DEFAULT_KEYBINDINGS,
4
+ KEYBINDING_ACTIONS,
5
+ TEXT_ENTRY_CONTEXTS,
6
+ type TKeybindingAction,
7
+ type TKeybindingContext,
8
+ } from './keybinding-catalogue.js';
9
+ import {
10
+ displayBinding,
11
+ keybindingDeliverySignature,
12
+ keyStrokesFromInput,
13
+ normalizeBinding,
14
+ type IKeyInput,
15
+ } from './keybinding-syntax.js';
16
+
17
+ import type { TJsonValue } from '../json-value.js';
18
+
19
+ export { DEFAULT_KEYBINDINGS } from './keybinding-catalogue.js';
20
+ export type { IKeyInput } from './keybinding-syntax.js';
21
+ export type { TKeybindingAction, TKeybindingContext } from './keybinding-catalogue.js';
22
+
23
+ export interface IKeybindingWarning {
24
+ readonly code: 'multiplexer-conflict' | 'modifier-delivery';
25
+ readonly path: string;
26
+ readonly message: string;
27
+ }
28
+
29
+ export interface IKeybindingDiagnostic {
30
+ readonly file: string;
31
+ readonly path: string;
32
+ readonly message: string;
33
+ }
34
+
35
+ export interface IKeybindingSnapshot {
36
+ readonly generation: number;
37
+ readonly sourcePath: string;
38
+ readonly bindings: Readonly<
39
+ Record<TKeybindingContext, Readonly<Record<string, readonly string[]>>>
40
+ >;
41
+ readonly warnings: readonly IKeybindingWarning[];
42
+ readonly diagnostic?: IKeybindingDiagnostic;
43
+ }
44
+
45
+ export type TParseKeybindingsResult =
46
+ | { readonly ok: true; readonly snapshot: IKeybindingSnapshot }
47
+ | { readonly ok: false; readonly diagnostic: IKeybindingDiagnostic };
48
+
49
+ const CHORD_TIMEOUT_MS = 1_000;
50
+
51
+ function diagnostic(file: string, path: string, message: string): TParseKeybindingsResult {
52
+ return { ok: false, diagnostic: { file, path, message } };
53
+ }
54
+
55
+ function cloneDefaults(): Record<TKeybindingContext, Record<string, string[]>> {
56
+ return Object.fromEntries(
57
+ Object.entries(DEFAULT_KEYBINDINGS).map(([context, actions]) => [
58
+ context,
59
+ Object.fromEntries(
60
+ Object.entries(actions).map(([action, bindings]) => [action, [...bindings]]),
61
+ ),
62
+ ]),
63
+ ) as Record<TKeybindingContext, Record<string, string[]>>;
64
+ }
65
+
66
+ function bindingValues(value: TJsonValue): readonly string[] | null | undefined {
67
+ if (value === null) return null;
68
+ if (typeof value === 'string') return [value];
69
+ if (Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string')) {
70
+ return value;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function validateContextBindings(
76
+ context: TKeybindingContext,
77
+ bindings: Record<string, string[]>,
78
+ file: string,
79
+ ): TParseKeybindingsResult | undefined {
80
+ const owners = new Map<string, string>();
81
+ const prefixes = new Map<string, string>();
82
+ for (const [action, values] of Object.entries(bindings)) {
83
+ for (const binding of values) {
84
+ const deliveryBinding = keybindingDeliverySignature(binding);
85
+ const owner = owners.get(deliveryBinding);
86
+ const actionPath = `$.bindings.${context}.${action}`;
87
+ if (owner !== undefined && owner !== action) {
88
+ return diagnostic(file, actionPath, `Binding duplicates action ${owner}.`);
89
+ }
90
+ const strokes = deliveryBinding.split(' ');
91
+ if (strokes.length > 1 && TEXT_ENTRY_CONTEXTS.has(context) && isPrintable(strokes[0] ?? '')) {
92
+ return diagnostic(
93
+ file,
94
+ actionPath,
95
+ 'Printable text cannot begin a chord in a text context.',
96
+ );
97
+ }
98
+ owners.set(deliveryBinding, action);
99
+ if (strokes.length > 1) prefixes.set(strokes[0] ?? '', action);
100
+ }
101
+ }
102
+ for (const [binding, action] of owners) {
103
+ const prefixOwner = prefixes.get(binding);
104
+ if (prefixOwner !== undefined) {
105
+ return diagnostic(
106
+ file,
107
+ `$.bindings.${context}.${prefixOwner}`,
108
+ `Chord prefix collides with action ${action}.`,
109
+ );
110
+ }
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ function isPrintable(stroke: string): boolean {
116
+ return !stroke.includes('+') && [...stroke].length === 1;
117
+ }
118
+
119
+ function warningFor(binding: string, path: string): IKeybindingWarning[] {
120
+ const warnings: IKeybindingWarning[] = [];
121
+ if (binding.split(' ').includes('ctrl+b')) {
122
+ warnings.push({
123
+ code: 'multiplexer-conflict',
124
+ path,
125
+ message: 'Ctrl+B may be captured by a terminal multiplexer.',
126
+ });
127
+ }
128
+ if (/ctrl\+shift\+[a-z]/u.test(binding)) {
129
+ warnings.push({
130
+ code: 'modifier-delivery',
131
+ path,
132
+ message: 'This terminal may not distinguish Ctrl+Shift from Ctrl.',
133
+ });
134
+ }
135
+ return warnings;
136
+ }
137
+
138
+ export function parseKeybindingsDocument(text: string, file: string): TParseKeybindingsResult {
139
+ let value: TJsonValue;
140
+ try {
141
+ value = JSON.parse(text) as TJsonValue;
142
+ } catch (cause) {
143
+ return diagnostic(file, '$', cause instanceof Error ? cause.message : 'Invalid JSON.');
144
+ }
145
+ if (!isJsonRecord(value)) return diagnostic(file, '$', 'Expected an object.');
146
+ if (value.version !== 1) return diagnostic(file, '$.version', 'Expected version 1.');
147
+ if (!isJsonRecord(value.bindings)) return diagnostic(file, '$.bindings', 'Expected an object.');
148
+
149
+ const effective = cloneDefaults();
150
+ const warnings: IKeybindingWarning[] = [];
151
+ for (const [rawContext, rawActions] of Object.entries(value.bindings)) {
152
+ if (!(rawContext in KEYBINDING_ACTIONS)) {
153
+ return diagnostic(file, `$.bindings.${rawContext}`, 'Unknown keybinding context.');
154
+ }
155
+ const context = rawContext as TKeybindingContext;
156
+ if (!isJsonRecord(rawActions)) {
157
+ return diagnostic(file, `$.bindings.${context}`, 'Expected an action object.');
158
+ }
159
+ for (const [action, rawBindings] of Object.entries(rawActions)) {
160
+ if (!(KEYBINDING_ACTIONS[context] as readonly string[]).includes(action)) {
161
+ return diagnostic(file, `$.bindings.${context}.${action}`, 'Unknown action.');
162
+ }
163
+ const values = bindingValues(rawBindings);
164
+ const actionPath = `$.bindings.${context}.${action}`;
165
+ if (values === undefined) {
166
+ return diagnostic(file, actionPath, 'Expected a binding, binding list, or null.');
167
+ }
168
+ if (values === null) {
169
+ effective[context][action] = [];
170
+ continue;
171
+ }
172
+ const normalized: string[] = [];
173
+ for (const rawBinding of values) {
174
+ const binding = normalizeBinding(rawBinding);
175
+ if (binding === undefined) return diagnostic(file, actionPath, 'Invalid binding syntax.');
176
+ if (binding.split(' ').includes('ctrl+c')) {
177
+ return diagnostic(file, actionPath, 'Ctrl+C is reserved for two-stage shutdown.');
178
+ }
179
+ normalized.push(binding);
180
+ warnings.push(...warningFor(binding, actionPath));
181
+ }
182
+ effective[context][action] = normalized;
183
+ }
184
+ }
185
+ for (const context of Object.keys(effective) as TKeybindingContext[]) {
186
+ const invalid = validateContextBindings(context, effective[context], file);
187
+ if (invalid !== undefined) return invalid;
188
+ }
189
+ const bindings = Object.freeze(
190
+ Object.fromEntries(
191
+ Object.entries(effective).map(([context, actions]) => [
192
+ context,
193
+ Object.freeze(
194
+ Object.fromEntries(
195
+ Object.entries(actions).map(([action, bindings]) => [action, Object.freeze(bindings)]),
196
+ ),
197
+ ),
198
+ ]),
199
+ ),
200
+ ) as IKeybindingSnapshot['bindings'];
201
+ return {
202
+ ok: true,
203
+ snapshot: Object.freeze({
204
+ generation: 0,
205
+ sourcePath: file,
206
+ bindings,
207
+ warnings: Object.freeze(warnings),
208
+ }),
209
+ };
210
+ }
211
+
212
+ export interface IChordState {
213
+ readonly context?: TKeybindingContext;
214
+ readonly generation?: number;
215
+ readonly strokes: readonly string[];
216
+ readonly deadline?: number;
217
+ }
218
+
219
+ export function createEmptyChordState(): IChordState {
220
+ return { strokes: [] };
221
+ }
222
+
223
+ function actionBindings(
224
+ snapshot: IKeybindingSnapshot,
225
+ context: TKeybindingContext,
226
+ ): [TKeybindingAction, readonly string[]][] {
227
+ return Object.entries(snapshot.bindings[context]) as [TKeybindingAction, readonly string[]][];
228
+ }
229
+
230
+ export function resolveKeybindingInput(options: {
231
+ readonly snapshot: IKeybindingSnapshot;
232
+ readonly state: IChordState;
233
+ readonly context: TKeybindingContext;
234
+ readonly input: string;
235
+ readonly key: IKeyInput;
236
+ readonly now: number;
237
+ }): {
238
+ readonly state: IChordState;
239
+ readonly actions: readonly TKeybindingAction[];
240
+ readonly consumed: boolean;
241
+ } {
242
+ const strokes = keyStrokesFromInput(options.input, options.key);
243
+ if (strokes.length === 0) {
244
+ return { state: createEmptyChordState(), actions: [], consumed: false };
245
+ }
246
+ const stateValid =
247
+ options.state.context === options.context &&
248
+ options.state.generation === options.snapshot.generation &&
249
+ (options.state.deadline === undefined || options.now <= options.state.deadline) &&
250
+ !options.key.escape;
251
+ const prior = stateValid ? options.state.strokes : [];
252
+ const entries = actionBindings(options.snapshot, options.context);
253
+ for (const stroke of strokes) {
254
+ const sequence = [...prior, stroke].join(' ');
255
+ const exact = entries
256
+ .filter(([, bindings]) => bindings.includes(sequence))
257
+ .map(([action]) => action);
258
+ if (exact.length > 0) {
259
+ return { state: createEmptyChordState(), actions: exact, consumed: true };
260
+ }
261
+ if (
262
+ entries.some(([, bindings]) => bindings.some((binding) => binding.startsWith(`${sequence} `)))
263
+ ) {
264
+ return {
265
+ state: {
266
+ context: options.context,
267
+ generation: options.snapshot.generation,
268
+ strokes: [...prior, stroke],
269
+ deadline: options.now + CHORD_TIMEOUT_MS,
270
+ },
271
+ actions: [],
272
+ consumed: true,
273
+ };
274
+ }
275
+ }
276
+ if (prior.length > 0) {
277
+ return resolveKeybindingInput({ ...options, state: createEmptyChordState() });
278
+ }
279
+ return { state: createEmptyChordState(), actions: [], consumed: false };
280
+ }
281
+
282
+ export function keybindingHints<TContext extends TKeybindingContext>(
283
+ snapshot: IKeybindingSnapshot,
284
+ context: TContext,
285
+ actions: readonly (readonly [
286
+ TKeybindingAction<TContext> | readonly TKeybindingAction<TContext>[],
287
+ string,
288
+ ])[],
289
+ ): { keys: string; label: string }[] {
290
+ return actions.flatMap(([actionOrActions, label]) => {
291
+ const grouped = Array.isArray(actionOrActions);
292
+ const actionList = grouped ? actionOrActions : [actionOrActions];
293
+ const bindings = actionList.flatMap((action) => {
294
+ const values = snapshot.bindings[context][action] ?? [];
295
+ return grouped ? values.slice(0, 1) : values;
296
+ });
297
+ const displayed = bindings.map(displayBinding);
298
+ const keys = displayed.every((binding) => /^[↑↓←→]$/u.test(binding))
299
+ ? displayed.join('')
300
+ : displayed.join('/');
301
+ return bindings.length === 0 ? [] : [{ keys, label }];
302
+ });
303
+ }