@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,329 @@
1
+ /**
2
+ * SCREEN-1993 TC-04 — the reverse prompt-history search overlay inside the input area.
3
+ *
4
+ * The source is an async iterable the test releases block by block, so "the first block renders
5
+ * before the second is yielded" and "cancel aborts the loader" are observed, not assumed.
6
+ */
7
+ import { render } from 'ink-testing-library';
8
+ import React from 'react';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+
11
+ import InputArea from '../InputArea.js';
12
+ import { ScreenReaderProvider } from '../screen-reader-context.js';
13
+
14
+ import type { IInputAreaHistorySearch } from '../hooks/useInputAreaHistorySearch.js';
15
+ import type {
16
+ IPromptHistoryBlock,
17
+ IPromptHistoryEntry,
18
+ IPromptHistorySource,
19
+ } from '@robota-sdk/agent-interface-session';
20
+
21
+ const CTRL_R = '\x12';
22
+ const CTRL_S = '\x13';
23
+ const CTRL_E = '\x05';
24
+ const ESCAPE = '\x1b';
25
+ const ENTER = '\r';
26
+ const ARROW_DOWN = '\x1b[B';
27
+
28
+ async function tick(ms = 25): Promise<void> {
29
+ await new Promise((resolve) => setTimeout(resolve, ms));
30
+ }
31
+
32
+ /** A loaded CI runner can take longer than one tick to land a block: wait for the frame, bounded. */
33
+ async function until(condition: () => boolean, timeoutMs = 5_000): Promise<void> {
34
+ const deadline = Date.now() + timeoutMs;
35
+ while (!condition()) {
36
+ if (Date.now() > deadline) throw new Error('until: condition not met in time');
37
+ await tick(10);
38
+ }
39
+ }
40
+
41
+ function entry(text: string, sessionId = 'other', project = '/elsewhere'): IPromptHistoryEntry {
42
+ return { at: '2026-01-01T00:00:00.000Z', sessionId, project, text };
43
+ }
44
+
45
+ /** A source whose blocks are released by the test, recording opens and abort signals. */
46
+ function gatedSource(blocks: readonly IPromptHistoryBlock[]): {
47
+ readonly source: IPromptHistorySource;
48
+ readonly release: () => void;
49
+ readonly reads: number;
50
+ readonly aborted: () => boolean;
51
+ readonly opens: () => number;
52
+ } {
53
+ let opens = 0;
54
+ let resolveNext: (() => void) | undefined;
55
+ let signal: AbortSignal | undefined;
56
+ const source: IPromptHistorySource = {
57
+ async *read(options) {
58
+ opens += 1;
59
+ signal = options.signal;
60
+ for (const block of blocks) {
61
+ await new Promise<void>((resolve) => {
62
+ resolveNext = resolve;
63
+ });
64
+ if (options.signal.aborted) return;
65
+ yield block;
66
+ }
67
+ },
68
+ };
69
+ return {
70
+ source,
71
+ release: () => resolveNext?.(),
72
+ reads: blocks.length,
73
+ aborted: () => signal?.aborted === true,
74
+ opens: () => opens,
75
+ };
76
+ }
77
+
78
+ function renderWith(
79
+ source: IPromptHistorySource,
80
+ options: {
81
+ readonly screenReader?: boolean;
82
+ readonly onOpenChange?: (open: boolean) => void;
83
+ } = {},
84
+ ): ReturnType<typeof render> & { readonly onSubmit: ReturnType<typeof vi.fn> } {
85
+ const onSubmit = vi.fn();
86
+ const historySearch: IInputAreaHistorySearch = {
87
+ source,
88
+ sessionId: 's-current',
89
+ project: '/here',
90
+ onOpenChange: options.onOpenChange,
91
+ };
92
+ const rendered = render(
93
+ <ScreenReaderProvider enabled={options.screenReader === true}>
94
+ <InputArea onSubmit={onSubmit} isDisabled={false} historySearch={historySearch} />
95
+ </ScreenReaderProvider>,
96
+ );
97
+ return { ...rendered, onSubmit };
98
+ }
99
+
100
+ describe('SCREEN-1993 TC-04: the history-search overlay', () => {
101
+ it('opens on ctrl+r, starts one loader, and renders the first block before the second lands', async () => {
102
+ const gate = gatedSource([
103
+ { entries: [entry('deploy the canary to eu-west', 's-current', '/here')], skippedLines: 1 },
104
+ { entries: [entry('rotate the staging secrets')], skippedLines: 0 },
105
+ ]);
106
+ const opened: boolean[] = [];
107
+ const { stdin, lastFrame, unmount } = renderWith(gate.source, {
108
+ onOpenChange: (open) => opened.push(open),
109
+ });
110
+ await tick();
111
+ stdin.write('dra');
112
+ await tick();
113
+ stdin.write(CTRL_R);
114
+ await tick();
115
+ expect(gate.opens()).toBe(1);
116
+ expect(opened).toEqual([true]);
117
+ expect(lastFrame()).toContain('(reverse-i-search)');
118
+ expect(lastFrame()).toContain('scope: all');
119
+ expect(lastFrame()).toContain('loading…');
120
+
121
+ gate.release();
122
+ await until(() => (lastFrame() ?? '').includes('deploy the canary to eu-west'));
123
+ expect(lastFrame()).not.toContain('rotate the staging secrets');
124
+ expect(lastFrame()).toContain('1 unreadable line skipped');
125
+
126
+ gate.release();
127
+ await until(() => (lastFrame() ?? '').includes('rotate the staging secrets'));
128
+ await until(() => !(lastFrame() ?? '').includes('loading…'));
129
+ expect(gate.opens()).toBe(1);
130
+ unmount();
131
+ });
132
+
133
+ it('a source with a synchronous body still renders the first block before the second lands', async () => {
134
+ // `NodePromptHistoryFile.read` is an async generator whose body is `readSync` all the way down:
135
+ // between two blocks there is only a microtask. Without a macrotask yield in the loader, Ink's
136
+ // throttled write never runs between publishes and the user sees the whole file at once.
137
+ const source: IPromptHistorySource = {
138
+ async *read() {
139
+ yield { entries: [entry('newest block prompt')], skippedLines: 0 };
140
+ yield { entries: [entry('older block prompt')], skippedLines: 0 };
141
+ },
142
+ };
143
+ const { stdin, frames, lastFrame, unmount } = renderWith(source);
144
+ await tick();
145
+ stdin.write(CTRL_R);
146
+ await until(() => (lastFrame() ?? '').includes('older block prompt'));
147
+ const firstBlockFrame = frames.find(
148
+ (frame) => frame.includes('newest block prompt') && !frame.includes('older block prompt'),
149
+ );
150
+ expect(firstBlockFrame).toBeDefined();
151
+ unmount();
152
+ });
153
+
154
+ it('narrows as the query is typed with the match marked, and enter inserts without submitting', async () => {
155
+ const gate = gatedSource([
156
+ {
157
+ entries: [
158
+ entry('deploy the canary to eu-west'),
159
+ entry('tidy the changelog headings'),
160
+ entry('redeploy after the deploy hook fails'),
161
+ ],
162
+ skippedLines: 0,
163
+ },
164
+ ]);
165
+ const { stdin, lastFrame, onSubmit, unmount } = renderWith(gate.source);
166
+ await tick();
167
+ stdin.write(CTRL_R);
168
+ gate.release();
169
+ await until(() => (lastFrame() ?? '').includes('tidy the changelog headings'));
170
+ stdin.write('deploy');
171
+ await until(() => (lastFrame() ?? '').includes('query: deploy'));
172
+ const frame = lastFrame() ?? '';
173
+ expect(frame).toContain('query: deploy');
174
+ expect(frame).toContain('[deploy]');
175
+ expect(frame).not.toContain('tidy the changelog headings');
176
+ expect(frame).toContain('2 matches');
177
+
178
+ stdin.write(ARROW_DOWN);
179
+ await tick();
180
+ stdin.write(ENTER);
181
+ await until(() => !(lastFrame() ?? '').includes('(reverse-i-search)'));
182
+ expect(lastFrame()).toContain('redeploy after the deploy hook fails');
183
+ expect(onSubmit).not.toHaveBeenCalled();
184
+ unmount();
185
+ });
186
+
187
+ it('ctrl+e submits the highlighted match through the normal submit path', async () => {
188
+ const gate = gatedSource([{ entries: [entry('rotate the staging secrets')], skippedLines: 0 }]);
189
+ const { stdin, lastFrame, onSubmit, unmount } = renderWith(gate.source);
190
+ await tick();
191
+ stdin.write(CTRL_R);
192
+ gate.release();
193
+ await until(() => (lastFrame() ?? '').includes('rotate the staging secrets'));
194
+ stdin.write('rotate');
195
+ await until(() => (lastFrame() ?? '').includes('query: rotate'));
196
+ stdin.write(CTRL_E);
197
+ await until(() => onSubmit.mock.calls.length > 0);
198
+ expect(onSubmit).toHaveBeenCalledTimes(1);
199
+ expect(onSubmit).toHaveBeenCalledWith('rotate the staging secrets');
200
+ expect(lastFrame()).not.toContain('(reverse-i-search)');
201
+ unmount();
202
+ });
203
+
204
+ it('escape mid-load aborts the loader and leaves the draft byte-identical', async () => {
205
+ const gate = gatedSource([
206
+ { entries: [entry('a')], skippedLines: 0 },
207
+ { entries: [entry('b')], skippedLines: 0 },
208
+ ]);
209
+ const opened: boolean[] = [];
210
+ const { stdin, lastFrame, onSubmit, unmount } = renderWith(gate.source, {
211
+ onOpenChange: (open) => opened.push(open),
212
+ });
213
+ await tick();
214
+ const draft = 'third draft: keep me byte-identical ';
215
+ stdin.write(draft);
216
+ await tick();
217
+ stdin.write(CTRL_R);
218
+ gate.release();
219
+ await until(() => (lastFrame() ?? '').includes('(reverse-i-search)'));
220
+ stdin.write('zzz');
221
+ await until(() => (lastFrame() ?? '').includes('query: zzz'));
222
+ expect(lastFrame()).toContain('loading…');
223
+ stdin.write(ESCAPE);
224
+ await until(() => !(lastFrame() ?? '').includes('(reverse-i-search)'));
225
+ expect(gate.aborted()).toBe(true);
226
+ expect(opened).toEqual([true, false]);
227
+ expect(lastFrame()).not.toContain('(reverse-i-search)');
228
+ expect(lastFrame()).toContain(draft.trimEnd());
229
+ expect(onSubmit).not.toHaveBeenCalled();
230
+ // The draft is submitted unchanged: what the composer holds is what it held before ctrl+r.
231
+ stdin.write(ENTER);
232
+ await until(() => onSubmit.mock.calls.length === 1);
233
+ expect(onSubmit).toHaveBeenCalledWith(draft.trim());
234
+ unmount();
235
+ });
236
+
237
+ it('ctrl+s cycles the scope and the session scope is the live prompt list', async () => {
238
+ const gate = gatedSource([
239
+ {
240
+ entries: [entry('stored elsewhere'), entry('stored here', 'other', '/here')],
241
+ skippedLines: 0,
242
+ },
243
+ ]);
244
+ const { stdin, lastFrame, onSubmit, unmount } = renderWith(gate.source);
245
+ await tick();
246
+ const prompts = ['typed this session', 'another prompt', 'typed this session'];
247
+ for (const [index, prompt] of prompts.entries()) {
248
+ stdin.write(prompt);
249
+ await until(() => (lastFrame() ?? '').includes(prompt));
250
+ stdin.write(ENTER);
251
+ // The deferred submit has fired and cleared the composer before the next prompt is typed.
252
+ await until(() => onSubmit.mock.calls.length === index + 1);
253
+ await until(() => !(lastFrame() ?? '').includes(prompt));
254
+ }
255
+ stdin.write(CTRL_R);
256
+ gate.release();
257
+ await until(() => (lastFrame() ?? '').includes('stored elsewhere'));
258
+ stdin.write(CTRL_S);
259
+ await until(() => (lastFrame() ?? '').includes('scope: session'));
260
+ expect(lastFrame()).toContain('typed this session');
261
+ // Collapsed to the newest occurrence in the session scope too.
262
+ expect((lastFrame() ?? '').split('typed this session').length - 1).toBe(1);
263
+ expect(lastFrame()).not.toContain('stored elsewhere');
264
+ stdin.write(CTRL_S);
265
+ await until(() => (lastFrame() ?? '').includes('scope: project'));
266
+ expect(lastFrame()).toContain('stored here');
267
+ expect(lastFrame()).not.toContain('stored elsewhere');
268
+ stdin.write(CTRL_S);
269
+ await until(() => (lastFrame() ?? '').includes('scope: all'));
270
+ unmount();
271
+ });
272
+
273
+ it('keeps a multi-line prompt on one row with a visible newline glyph, ranges aligned', async () => {
274
+ const source: IPromptHistorySource = {
275
+ async *read() {
276
+ yield {
277
+ entries: [entry('first line\nsecond line'), entry('crlf line\r\nthen deploy')],
278
+ skippedLines: 0,
279
+ };
280
+ },
281
+ };
282
+ const { stdin, lastFrame, unmount } = renderWith(source);
283
+ await tick();
284
+ stdin.write(CTRL_R);
285
+ await until(() => (lastFrame() ?? '').includes('first line↵second line'));
286
+ expect(lastFrame()).toContain('crlf line ↵then deploy');
287
+ stdin.write('deploy');
288
+ // The two-unit CRLF became two units, so the match range still lands on the word.
289
+ await until(() => (lastFrame() ?? '').includes('crlf line ↵then [deploy]'));
290
+ unmount();
291
+ });
292
+
293
+ it('renders a read error instead of an empty list', async () => {
294
+ const source: IPromptHistorySource = {
295
+ // eslint-disable-next-line require-yield -- the failure is the whole behaviour under test.
296
+ async *read() {
297
+ throw new Error('EACCES: permission denied');
298
+ },
299
+ };
300
+ const { stdin, lastFrame, unmount } = renderWith(source);
301
+ await tick();
302
+ stdin.write(CTRL_R);
303
+ await until(() => (lastFrame() ?? '').includes('History could not be read'));
304
+ expect(lastFrame()).toContain('History could not be read: EACCES: permission denied');
305
+ unmount();
306
+ });
307
+
308
+ it('in screen-reader mode the rows are numbered and blocks are published only at load end', async () => {
309
+ const gate = gatedSource([
310
+ { entries: [entry('first block prompt')], skippedLines: 0 },
311
+ { entries: [entry('second block prompt')], skippedLines: 0 },
312
+ ]);
313
+ const { stdin, lastFrame, unmount } = renderWith(gate.source, { screenReader: true });
314
+ await tick();
315
+ stdin.write(CTRL_R);
316
+ gate.release();
317
+ await until(() => (lastFrame() ?? '').includes('(reverse-i-search)'));
318
+ await tick(50);
319
+ expect(lastFrame()).not.toContain('first block prompt');
320
+ gate.release();
321
+ await until(() => (lastFrame() ?? '').includes('2. second block prompt'));
322
+ expect(lastFrame()).toContain('1. first block prompt');
323
+ // Digits are query text, not a selection.
324
+ stdin.write('2');
325
+ await until(() => (lastFrame() ?? '').includes('query: 2'));
326
+ expect(lastFrame()).toContain('no match');
327
+ unmount();
328
+ });
329
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { humanizeToolName } from '../humanize-tool-name.js';
4
+
5
+ describe('humanizeToolName (SCREEN-012)', () => {
6
+ it('strips the neutral prefix to recover the command name', () => {
7
+ expect(humanizeToolName('command_agent')).toBe('agent');
8
+ expect(humanizeToolName('command_parallel')).toBe('parallel');
9
+ });
10
+
11
+ it('uses a host-selected prefix without changing other tool names', () => {
12
+ expect(humanizeToolName('robota_command_agent', 'robota_command_')).toBe('agent');
13
+ expect(humanizeToolName('acme_command_agent', 'acme_command_')).toBe('agent');
14
+ expect(humanizeToolName('acme_command_agent')).toBe('acme_command_agent');
15
+ });
16
+
17
+ it('drops a trailing 8-hex projection hash', () => {
18
+ expect(humanizeToolName('command_some_long_command_1a2b3c4d')).toBe('some_long_command');
19
+ });
20
+
21
+ it('leaves non-command tool names unchanged', () => {
22
+ expect(humanizeToolName('Shell')).toBe('Shell');
23
+ expect(humanizeToolName('Read')).toBe('Read');
24
+ expect(humanizeToolName('Bash')).toBe('Bash');
25
+ });
26
+
27
+ it('falls back to the original when stripping would leave nothing', () => {
28
+ expect(humanizeToolName('command_')).toBe('command_');
29
+ });
30
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * SCREEN-003 TC-01 — the input box's bottom border is an always-present hand-drawn `<Text>` row,
3
+ * not a Yoga-synthesized Box border that can drop glyphs under rapid re-render. Deterministic guard:
4
+ * the last rendered line is a full-width run of `─`, identical to the top border, in BOTH the
5
+ * streaming (`isDisabled`) state (where the flicker appeared) and the idle state.
6
+ */
7
+ import React from 'react';
8
+ import { render } from 'ink-testing-library';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+
11
+ import InputArea from '../InputArea.js';
12
+
13
+ // Built from the code point rather than a literal ESC in the pattern (no-control-regex).
14
+ const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
15
+ const stripAnsi = (s: string): string => s.replace(ANSI_SGR, '');
16
+
17
+ function borderLines(frame: string): { first: string; last: string; all: string[] } {
18
+ const lines = stripAnsi(frame)
19
+ .split('\n')
20
+ .map((l) => l.replace(/\s+$/, ''));
21
+ const nonEmpty = lines.filter((l) => l.length > 0);
22
+ return { first: nonEmpty[0], last: nonEmpty[nonEmpty.length - 1], all: nonEmpty };
23
+ }
24
+
25
+ async function tick(ms = 25): Promise<void> {
26
+ await new Promise((resolve) => setTimeout(resolve, ms));
27
+ }
28
+
29
+ describe('InputArea bottom border stability (SCREEN-003)', () => {
30
+ for (const isDisabled of [true, false]) {
31
+ it(`renders a full-width \`─\` bottom border row (isDisabled=${isDisabled})`, async () => {
32
+ const { lastFrame } = render(<InputArea onSubmit={vi.fn()} isDisabled={isDisabled} />);
33
+ await tick();
34
+
35
+ const { first, last } = borderLines(lastFrame() ?? '');
36
+
37
+ // Bottom border is present and is a pure run of the horizontal box glyph.
38
+ expect(last).toMatch(/^─+$/);
39
+ // Top border is the same hand-drawn full-width run — both share `innerWidth`.
40
+ expect(first).toMatch(/^─+$/);
41
+ expect(last).toBe(first);
42
+ expect(last.length).toBeGreaterThan(0);
43
+ });
44
+ }
45
+ });
@@ -0,0 +1,199 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ createCostCommandEntry,
4
+ createLoopCommandEntry,
5
+ createMCPActivationCommandEntry,
6
+ createRemoteControlCommandEntry,
7
+ } from '@robota-sdk/agent-command';
8
+ import {
9
+ appendPromptHistory,
10
+ createPasteLabelChange,
11
+ createPromptHistoryNavigationState,
12
+ extractPromptHistory,
13
+ getAutocompletePopupAction,
14
+ getPromptHistoryInputAction,
15
+ getPendingPromptInputAction,
16
+ moveAutocompleteSelection,
17
+ navigatePromptHistory,
18
+ resolveEnterCommandSelection,
19
+ resolveTabCompletion,
20
+ shouldSubmitInput,
21
+ } from '../flows/input-area-flow.js';
22
+ import type { ICommand } from '@robota-sdk/agent-interface-command';
23
+ import {
24
+ createAssistantMessage,
25
+ createSystemMessage,
26
+ createUserMessage,
27
+ messageToHistoryEntry,
28
+ } from '@robota-sdk/agent-core';
29
+
30
+ const command = (name: string, subcommands?: ICommand[]): ICommand => ({
31
+ name,
32
+ description: `${name} command`,
33
+ source: 'test',
34
+ subcommands,
35
+ execute: async () => {},
36
+ });
37
+
38
+ describe('input area flow', () => {
39
+ it('Given autocomplete key info When mapped Then popup actions are produced', () => {
40
+ expect(getAutocompletePopupAction({ upArrow: true })).toBe('previous');
41
+ expect(getAutocompletePopupAction({ downArrow: true })).toBe('next');
42
+ expect(getAutocompletePopupAction({ escape: true })).toBe('close');
43
+ expect(getAutocompletePopupAction({ tab: true })).toBe('complete');
44
+ });
45
+
46
+ it('Given pending prompt key info When mapped Then cancel queue action is produced', () => {
47
+ expect(getPendingPromptInputAction({ backspace: true })).toBe('cancelQueue');
48
+ expect(getPendingPromptInputAction({ delete: true })).toBe('cancelQueue');
49
+ expect(getPendingPromptInputAction({ downArrow: true })).toBeUndefined();
50
+ });
51
+
52
+ it('Given wrapping autocomplete When moving beyond bounds Then index wraps', () => {
53
+ expect(moveAutocompleteSelection(0, 3, 'previous')).toBe(2);
54
+ expect(moveAutocompleteSelection(2, 3, 'next')).toBe(0);
55
+ });
56
+
57
+ it('Given parent command input When tab completes subcommand Then child name is inserted', () => {
58
+ const result = resolveTabCompletion('/plugin i', command('install'));
59
+
60
+ expect(result).toEqual({ type: 'insert', value: '/plugin install ' });
61
+ });
62
+
63
+ it('Given command with subcommands When enter selects it Then command name is inserted', () => {
64
+ const result = resolveEnterCommandSelection('/pl', command('plugin', [command('list')]));
65
+
66
+ expect(result).toEqual({ type: 'insert', value: '/plugin ', selectedIndex: 0 });
67
+ });
68
+
69
+ it('Given leaf command When enter selects it Then submit value is emitted', () => {
70
+ const result = resolveEnterCommandSelection('/he', command('help'));
71
+
72
+ expect(result).toEqual({ type: 'submit', value: '/help' });
73
+ });
74
+
75
+ it('Given command with no args and no subcommands When enter selects Then submits', () => {
76
+ const result = resolveEnterCommandSelection('/ex', command('exit'));
77
+
78
+ expect(result).toEqual({ type: 'submit', value: '/exit' });
79
+ });
80
+
81
+ it('submits the default session cost summary when Enter selects /cost', () => {
82
+ const cost = createCostCommandEntry();
83
+
84
+ expect(resolveEnterCommandSelection('/co', cost)).toEqual({ type: 'submit', value: '/cost' });
85
+ expect(resolveEnterCommandSelection('/cost', cost)).toEqual({ type: 'submit', value: '/cost' });
86
+ });
87
+
88
+ it('runs a command whose bare form is complete even though it declares subcommands', () => {
89
+ const mcp = createMCPActivationCommandEntry();
90
+
91
+ expect(mcp.subcommands?.length).toBeGreaterThan(0);
92
+ expect(resolveEnterCommandSelection('/mc', mcp)).toEqual({ type: 'submit', value: '/mcp' });
93
+ });
94
+
95
+ it.each([
96
+ { entry: createRemoteControlCommandEntry(), safeAction: 'status' },
97
+ { entry: createLoopCommandEntry(), safeAction: 'list' },
98
+ ])('requires an explicit safe action after autocomplete selects /$entry.name', ({ entry, safeAction }) => {
99
+ const firstEnter = resolveEnterCommandSelection(`/${entry.name.slice(0, 3)}`, entry);
100
+ expect(firstEnter).toEqual({ type: 'insert', value: `/${entry.name} `, selectedIndex: 0 });
101
+
102
+ const suggestion = entry.subcommands?.[0];
103
+ expect(suggestion?.name).toBe(safeAction);
104
+ expect(resolveEnterCommandSelection(firstEnter.value, suggestion!)).toEqual({
105
+ type: 'submit',
106
+ value: `/${entry.name} ${safeAction}`,
107
+ });
108
+ });
109
+
110
+ it('Given subcommand selected (args present) When enter selects Then submits', () => {
111
+ const result = resolveEnterCommandSelection('/mode plan', command('plan'));
112
+
113
+ expect(result).toEqual({ type: 'submit', value: '/mode plan' });
114
+ });
115
+
116
+ it('Given multiline paste When label change is created Then label is inserted at cursor', () => {
117
+ const result = createPasteLabelChange('abef', 2, 7, 'c\nd\ne');
118
+
119
+ expect(result).toEqual({
120
+ value: 'ab[Pasted text #7 +3 lines]ef',
121
+ cursorHint: 27,
122
+ label: '[Pasted text #7 +3 lines]',
123
+ lineCount: 3,
124
+ });
125
+ });
126
+
127
+ it('Given blank prompt When checked Then submit is rejected', () => {
128
+ expect(shouldSubmitInput(' ')).toBe(false);
129
+ expect(shouldSubmitInput(' hello ')).toBe(true);
130
+ });
131
+
132
+ it('Given prompt history key info When mapped Then history actions are produced', () => {
133
+ expect(getPromptHistoryInputAction({ upArrow: true })).toBe('previous');
134
+ expect(getPromptHistoryInputAction({ downArrow: true })).toBe('next');
135
+ expect(getPromptHistoryInputAction({ escape: true })).toBeUndefined();
136
+ });
137
+
138
+ it('Given empty prompt history When navigating Then input is unchanged', () => {
139
+ const result = navigatePromptHistory(
140
+ 'draft',
141
+ [],
142
+ createPromptHistoryNavigationState(),
143
+ 'previous',
144
+ );
145
+
146
+ expect(result).toEqual({
147
+ value: 'draft',
148
+ cursorHint: 5,
149
+ state: createPromptHistoryNavigationState(),
150
+ });
151
+ });
152
+
153
+ it('Given draft and history When pressing up Then latest prompt is recalled and draft is saved', () => {
154
+ const result = navigatePromptHistory(
155
+ 'draft text',
156
+ ['first', 'second'],
157
+ createPromptHistoryNavigationState(),
158
+ 'previous',
159
+ );
160
+
161
+ expect(result).toEqual({
162
+ value: 'second',
163
+ cursorHint: 6,
164
+ state: { selectedIndex: 1, draft: 'draft text' },
165
+ });
166
+ });
167
+
168
+ it('Given history navigation When pressing down past latest Then draft is restored', () => {
169
+ const result = navigatePromptHistory(
170
+ 'second',
171
+ ['first', 'second'],
172
+ { selectedIndex: 1, draft: 'draft text' },
173
+ 'next',
174
+ );
175
+
176
+ expect(result).toEqual({
177
+ value: 'draft text',
178
+ cursorHint: 10,
179
+ state: createPromptHistoryNavigationState(),
180
+ });
181
+ });
182
+
183
+ it('Given prompt submit When appended Then blank and consecutive duplicates are ignored', () => {
184
+ expect(appendPromptHistory(['first'], 'second')).toEqual(['first', 'second']);
185
+ expect(appendPromptHistory(['first'], 'first')).toEqual(['first']);
186
+ expect(appendPromptHistory(['first'], ' ')).toEqual(['first']);
187
+ });
188
+
189
+ it('Given history entries When extracted Then only user chat content is included', () => {
190
+ const entries = [
191
+ messageToHistoryEntry(createUserMessage('first')),
192
+ messageToHistoryEntry(createAssistantMessage('answer')),
193
+ messageToHistoryEntry(createSystemMessage('system')),
194
+ messageToHistoryEntry(createUserMessage('second')),
195
+ ];
196
+
197
+ expect(extractPromptHistory(entries)).toEqual(['first', 'second']);
198
+ });
199
+ });
@@ -0,0 +1,77 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import InputArea from '../InputArea.js';
6
+
7
+ // ESC [ B — the terminal down-arrow sequence Ink parses into `key.downArrow`.
8
+ const DOWN_ARROW = '';
9
+
10
+ async function tick(ms = 25): Promise<void> {
11
+ await new Promise((resolve) => setTimeout(resolve, ms));
12
+ }
13
+
14
+ describe('InputArea ↓ fall-through into the background list (SCREEN-014)', () => {
15
+ it('requests background-list focus when ↓ is pressed on an empty input', async () => {
16
+ const onRequestFocusBackgroundList = vi.fn();
17
+ const { stdin } = render(
18
+ <InputArea
19
+ onSubmit={vi.fn()}
20
+ isDisabled={false}
21
+ onRequestFocusBackgroundList={onRequestFocusBackgroundList}
22
+ />,
23
+ );
24
+ await tick();
25
+ stdin.write(DOWN_ARROW);
26
+ await tick();
27
+ expect(onRequestFocusBackgroundList).toHaveBeenCalledTimes(1);
28
+ });
29
+
30
+ it('does not request focus while the input is disabled', async () => {
31
+ const onRequestFocusBackgroundList = vi.fn();
32
+ const { stdin } = render(
33
+ <InputArea
34
+ onSubmit={vi.fn()}
35
+ isDisabled={true}
36
+ onRequestFocusBackgroundList={onRequestFocusBackgroundList}
37
+ />,
38
+ );
39
+ await tick();
40
+ stdin.write(DOWN_ARROW);
41
+ await tick();
42
+ expect(onRequestFocusBackgroundList).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it('does not cancel a queued prompt while recovery disables the input', async () => {
46
+ const onCancelQueue = vi.fn();
47
+ const { stdin } = render(
48
+ <InputArea
49
+ onSubmit={vi.fn()}
50
+ isDisabled
51
+ pendingPrompt="queued prompt"
52
+ onCancelQueue={onCancelQueue}
53
+ />,
54
+ );
55
+ await tick();
56
+ stdin.write('\x7F');
57
+ await tick();
58
+ expect(onCancelQueue).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('keeps queued-prompt cancellation active during ordinary thinking disablement', async () => {
62
+ const onCancelQueue = vi.fn();
63
+ const { stdin } = render(
64
+ <InputArea
65
+ onSubmit={vi.fn()}
66
+ isDisabled
67
+ isQueueCancellationDisabled={false}
68
+ pendingPrompt="queued prompt"
69
+ onCancelQueue={onCancelQueue}
70
+ />,
71
+ );
72
+ await tick();
73
+ stdin.write('\x7F');
74
+ await tick();
75
+ expect(onCancelQueue).toHaveBeenCalledTimes(1);
76
+ });
77
+ });