@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,276 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { renderMarkdown } from '../render-markdown.js';
4
+ import {
5
+ createStreamingTerminalSanitizer,
6
+ sanitizeTerminalText,
7
+ } from '../sanitize-terminal-text.js';
8
+
9
+ /**
10
+ * SEC-019 (issue #2022) - untrusted text may not act on the terminal.
11
+ *
12
+ * Model output, tool output, file contents and plugin text reach Ink `<Text>`, and Ink passes control
13
+ * sequences through. These assert the property rather than the implementation: after sanitization the
14
+ * string contains no byte a terminal interprets as a command, and ordinary content is untouched.
15
+ */
16
+ const ESC = String.fromCharCode(27);
17
+ const BEL = String.fromCharCode(7);
18
+
19
+ // Each entry is a real attack from the issue's impact list, not a synthetic control character, and
20
+ // each carries the EXACT output expected from `'before' + payload + 'after'`.
21
+ //
22
+ // The exact string is the point. The first version of this table asserted only that no control byte
23
+ // survived and that `before` was still there, and both held for a sanitizer that stripped a DCS
24
+ // introducer and left `q#0;2;0;0;0` standing as visible text — the defect review found on PR #2212.
25
+ // An assertion that a buggy implementation also satisfies is not a regression test.
26
+ const C1 = (code: number): string => String.fromCharCode(code);
27
+ const ATTACKS: Array<[string, string, string]> = [
28
+ ['OSC 52 clipboard write', ESC + ']52;c;aGVsbG8=' + BEL, 'beforeafter'],
29
+ ['OSC 52 with ST terminator', ESC + ']52;c;aGVsbG8=' + ESC + '\\', 'beforeafter'],
30
+ [
31
+ 'OSC 8 deceptive hyperlink',
32
+ ESC + ']8;;https://evil.example' + BEL + 'click me' + ESC + ']8;;' + BEL,
33
+ // The visible half survives as ordinary text; only the target and its framing are gone, which is
34
+ // exactly the deception being removed — the link text was never the dangerous part.
35
+ 'beforeclick meafter',
36
+ ],
37
+ ['OSC 0 window title', ESC + ']0;pwned' + BEL, 'beforeafter'],
38
+ ['CSI cursor move', ESC + '[10;10H', 'beforeafter'],
39
+ ['CSI erase display', ESC + '[2J', 'beforeafter'],
40
+ ['CSI private mode', ESC + '[?1049h', 'beforeafter'],
41
+ // ECMA-48 parameter bytes are 0x30-0x3F, which is `0123456789:;<=>?` — not the digits, `;` and
42
+ // `?` an author writes from memory. Every row below was left as visible text by that shorter
43
+ // class, and the first two are not exotic: colon-separated SGR is the standard truecolor form and
44
+ // the `<` report is what a terminal sends on every mouse event.
45
+ ['CSI truecolor SGR, colon form', ESC + '[38:2:255:0:0m', 'beforeafter'],
46
+ ['CSI SGR mouse report', ESC + '[<0;10;20M', 'beforeafter'],
47
+ ['CSI device attributes, > parameter', ESC + '[>c', 'beforeafter'],
48
+ ['CSI = parameter', ESC + '[=1h', 'beforeafter'],
49
+ ['8-bit CSI SGR mouse report', C1(0x9b) + '<0;10;20M', 'beforeafter'],
50
+ ['DCS', ESC + 'Pq#0;2;0;0;0' + ESC + '\\', 'beforeafter'],
51
+ ['APC', ESC + '_Ginline=1' + ESC + '\\', 'beforeafter'],
52
+ ['PM', ESC + '^payload' + ESC + '\\', 'beforeafter'],
53
+ ['SOS with BEL terminator', ESC + 'Xdata' + BEL, 'beforeafter'],
54
+ ['bare ESC', ESC, 'beforeafter'],
55
+ ['bare BEL', BEL, 'beforeafter'],
56
+ // The 8-bit spellings are the same sequences with a one-byte introducer. They are listed
57
+ // separately because an alternation that names only `ESC [` removes the introducer and leaves the
58
+ // parameters — the identical defect, reached by a different route.
59
+ ['8-bit CSI (C1)', C1(0x9b) + '2J', 'beforeafter'],
60
+ ['8-bit OSC with BEL', C1(0x9d) + '52;c;x' + BEL, 'beforeafter'],
61
+ ['8-bit OSC with 8-bit ST', C1(0x9d) + '52;c;x' + C1(0x9c), 'beforeafter'],
62
+ ['8-bit DCS', C1(0x90) + 'q#0;2;0;0;0' + C1(0x9c), 'beforeafter'],
63
+ ['8-bit APC', C1(0x9f) + 'Ginline=1' + C1(0x9c), 'beforeafter'],
64
+ ['8-bit PM', C1(0x9e) + 'payload' + C1(0x9c), 'beforeafter'],
65
+ ['lone 8-bit ST', C1(0x9c), 'beforeafter'],
66
+ // An unterminated sequence swallows what follows, which is what the terminal itself does.
67
+ ['unterminated OSC', ESC + ']52;c;dangling', 'before'],
68
+ ['unterminated 8-bit APC', C1(0x9f) + 'dangling', 'before'],
69
+ ['unterminated DCS', ESC + 'Pq#0;2;0;0;0', 'before'],
70
+ ];
71
+
72
+ /** No byte a terminal reads as a command survives. */
73
+ function hasTerminalControl(text: string): boolean {
74
+ for (const ch of text) {
75
+ const code = ch.codePointAt(0) ?? 0;
76
+ if (ch === '\t' || ch === '\n') continue;
77
+ if (code <= 0x1f || code === 0x7f) return true;
78
+ if (code >= 0x80 && code <= 0x9f) return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ describe('SEC-019 - a control sequence in untrusted text is neutralized', () => {
84
+ it.each(ATTACKS)('removes %s', (_label, payload, expected) => {
85
+ const out = sanitizeTerminalText('before' + payload + 'after');
86
+ // The exact output, not a property. `hasTerminalControl` is asserted as well because the two
87
+ // claims are different: one says the string is safe, the other says the whole sequence is gone.
88
+ expect(out).toBe(expected);
89
+ expect(hasTerminalControl(out), JSON.stringify(out)).toBe(false);
90
+ });
91
+
92
+ it('keeps the text after a TERMINATED sequence', () => {
93
+ const out = sanitizeTerminalText('before' + ESC + ']52;c;aGk=' + BEL + 'after');
94
+ expect(out).toBe('beforeafter');
95
+ });
96
+
97
+ it('swallows what follows an UNTERMINATED sequence, as a terminal would', () => {
98
+ // An unterminated OSC consumes subsequent bytes until a terminator arrives, so dropping the tail
99
+ // matches what the terminal itself does. Keeping it would print the payload's own text as if it
100
+ // were content, which is the deceptive half of OSC 8.
101
+ const out = sanitizeTerminalText('before' + ESC + ']52;c;dangling-and-then-some');
102
+ expect(out).toBe('before');
103
+ });
104
+
105
+ // The two cases below repeat rows of the table above under literal titles ON PURPOSE.
106
+ //
107
+ // `it.each` builds each case's title from a printf placeholder (`'removes %s'`), and the
108
+ // accidental-green floor reads added case titles out of the diff — where the only text present is
109
+ // `removes %s`, which matches no runtime title. Every `it.each` case is therefore invisible to it,
110
+ // so a suite built entirely from tables can supply no red proof at all. Measured on this branch:
111
+ // reversing the fix turned 5 `it.each` cases red and the floor still reported accidental-green.
112
+ //
113
+ // These two are the named proof for the defect review found on PR #2212, and they are the cases
114
+ // that must go red if anyone restores the introducer-only behaviour.
115
+
116
+ it('removes the whole DCS sequence, not only its two-character introducer', () => {
117
+ // The lazy-quantifier bug left this as 'beforeq#0;2;0;0;0after' while every "no control byte
118
+ // survives" assertion in the file stayed green.
119
+ const out = sanitizeTerminalText('before' + ESC + 'Pq#0;2;0;0;0' + ESC + '\\' + 'after');
120
+ expect(out).toBe('beforeafter');
121
+ expect(out).not.toContain('q#0');
122
+ });
123
+
124
+ it('removes a CSI carrying any ECMA-48 parameter byte, not only digits and ? and ;', () => {
125
+ // The shorter parameter class left `38:2:255:0:0m` and `<0;10;20M` standing as text: neither
126
+ // matched the CSI alternative, the two-character-escape alternative does not accept `[` as its
127
+ // second character, and the lone-control fallback took only the ESC.
128
+ for (const body of ['38:2:255:0:0m', '<0;10;20M', '>c', '=1h', ':::x']) {
129
+ expect(sanitizeTerminalText(`before${ESC}[${body}after`)).toBe('beforeafter');
130
+ }
131
+ });
132
+
133
+ it('removes the whole 8-bit sequence, not only its one-byte C1 introducer', () => {
134
+ // A terminal reads \x9b and `ESC [` as the same CSI. An alternation that names only the 7-bit
135
+ // spelling leaves the parameters standing: '2J' and '52;c;x' as visible text.
136
+ expect(sanitizeTerminalText('before' + C1(0x9b) + '2J' + 'after')).toBe('beforeafter');
137
+ expect(sanitizeTerminalText('before' + C1(0x9d) + '52;c;x' + BEL + 'after')).toBe(
138
+ 'beforeafter',
139
+ );
140
+ expect(sanitizeTerminalText('before' + C1(0x9f) + 'Ginline=1' + C1(0x9c) + 'after')).toBe(
141
+ 'beforeafter',
142
+ );
143
+ });
144
+
145
+ it('keeps tab and newline, which are content', () => {
146
+ expect(sanitizeTerminalText('a\tb\nc')).toBe('a\tb\nc');
147
+ });
148
+
149
+ it('normalizes CRLF and removes a bare CR, which is a cursor move', () => {
150
+ // Measured against a real terminal stream: Ink strips OSC 52 and CSI on its way out, and passes
151
+ // a bare `\r` through intact. `safe text\rEVIL` therefore prints EVIL over what the transcript
152
+ // said — the same "what you read is not what happened" attack as a deceptive hyperlink, with no
153
+ // escape sequence in it at all. The first version of this module kept CR as content.
154
+ expect(sanitizeTerminalText('safe text\rEVIL')).toBe('safe textEVIL');
155
+ expect(sanitizeTerminalText('a\r\nb\r\nc')).toBe('a\nb\nc');
156
+ expect(sanitizeTerminalText('trailing\r')).toBe('trailing');
157
+ });
158
+
159
+ it('leaves ordinary Unicode alone - the goal is what the TERMINAL acts on', () => {
160
+ const text = 'héllo 世界 🙂 café — “quoted”';
161
+ expect(sanitizeTerminalText(text)).toBe(text);
162
+ });
163
+
164
+ it('cannot be bypassed through a markdown code fence', () => {
165
+ // A fence is where an author expects raw text to survive verbatim, which is exactly why it is
166
+ // the first place an attacker would put an escape.
167
+ const out = renderMarkdown('```\n' + ESC + ']52;c;aGk=' + BEL + '\n```', { color: false });
168
+ expect(out).not.toContain(ESC + ']');
169
+ expect(out).not.toContain(BEL);
170
+ });
171
+
172
+ it('cannot be bypassed through a markdown link target', () => {
173
+ const out = renderMarkdown('[text](' + ESC + ']8;;https://evil.example' + BEL + ')', {
174
+ color: false,
175
+ });
176
+ expect(out).not.toContain(BEL);
177
+ });
178
+
179
+ it('cannot be bypassed through a diff block or a carriage return', () => {
180
+ const out = renderMarkdown('```diff\n- a' + ESC + '[2J\r+ b\n```', { color: false });
181
+ expect(out).not.toContain(ESC + '[2J');
182
+ });
183
+
184
+ it('still emits the colours the renderer generates AFTER sanitization', () => {
185
+ // The ordering is the design: sanitize the INPUT, never the output. If the sanitizer were moved
186
+ // to the output this goes red, because the repository's own diff colouring is ANSI too.
187
+ //
188
+ // A diff block rather than `**bold**`: bold comes from chalk, which disables itself off a TTY, so
189
+ // that assertion would be testing chalk's environment detection instead of this ordering.
190
+ const out = renderMarkdown('```diff\n- removed\n+ added\n```', { color: true });
191
+ expect(out, 'internally generated ANSI was stripped').toContain(ESC + '[');
192
+ });
193
+ });
194
+
195
+ describe('SEC-019 - a sequence split across streaming chunks is still removed', () => {
196
+ const PAYLOAD = 'A' + ESC + ']52;c;aGVsbG8=' + BEL + 'B';
197
+
198
+ // EVERY split point, not a sampled one: the issue asks for exactly this.
199
+ const SPLITS = Array.from({ length: PAYLOAD.length - 1 }, (_, i) => i + 1);
200
+
201
+ it.each(SPLITS)('split at %i', (at) => {
202
+ const s = createStreamingTerminalSanitizer();
203
+ const out = s.push(PAYLOAD.slice(0, at)) + s.push(PAYLOAD.slice(at)) + s.flush();
204
+ expect(hasTerminalControl(out), JSON.stringify(out)).toBe(false);
205
+ expect(out).toBe('AB');
206
+ });
207
+
208
+ // The 8-bit spelling has its own held-back tail in `INCOMPLETE_TAIL`, and a claim with no test is
209
+ // the thing this file exists to refuse. Same exhaustive split, different introducer.
210
+ const C1_PAYLOAD = 'A' + C1(0x9d) + '52;c;aGVsbG8=' + C1(0x9c) + 'B';
211
+ const C1_SPLITS = Array.from({ length: C1_PAYLOAD.length - 1 }, (_, i) => i + 1);
212
+
213
+ it.each(C1_SPLITS)('8-bit OSC split at %i', (at) => {
214
+ const s = createStreamingTerminalSanitizer();
215
+ const out = s.push(C1_PAYLOAD.slice(0, at)) + s.push(C1_PAYLOAD.slice(at)) + s.flush();
216
+ expect(out).toBe('AB');
217
+ expect(hasTerminalControl(out), JSON.stringify(out)).toBe(false);
218
+ });
219
+
220
+ it('holds back an incomplete tail rather than emitting its visible half', () => {
221
+ const s = createStreamingTerminalSanitizer();
222
+ // `ESC ]52;c;` alone is not yet dangerous and not yet safe to print — emitting `52;c;` as text
223
+ // would show the middle of a sequence while dropping the part that made it an escape.
224
+ expect(s.push('x' + ESC + ']52;c;')).toBe('x');
225
+ expect(s.push('aGk=' + BEL + 'y')).toBe('y');
226
+ });
227
+
228
+ it('flushes an escape that never completes, rather than leaking it', () => {
229
+ const s = createStreamingTerminalSanitizer();
230
+ expect(s.push('x' + ESC + ']52;c;dangling')).toBe('x');
231
+ expect(hasTerminalControl(s.flush())).toBe(false);
232
+ });
233
+
234
+ // A sequence that ENDED inside the chunk is not an incomplete tail, and treating it as one is a
235
+ // stall rather than a leak: `push` returns the text before it and holds everything after, so the
236
+ // trailing content does not appear until the next chunk or the flush. Found in review of PR #2212
237
+ // — the streaming table above splits an OSC and never puts a COMPLETE sequence mid-chunk, so it
238
+ // could not see this.
239
+
240
+ it('emits text that follows a DCS sequence already terminated inside the chunk', () => {
241
+ const s = createStreamingTerminalSanitizer();
242
+ expect(s.push('hello' + ESC + 'Pq#0;2;0;0;0' + ESC + '\\' + 'world')).toBe('helloworld');
243
+ expect(s.flush()).toBe('');
244
+ });
245
+
246
+ it('emits text that follows an 8-bit APC sequence already terminated inside the chunk', () => {
247
+ const s = createStreamingTerminalSanitizer();
248
+ expect(s.push('hello' + C1(0x9f) + 'Ginline=1' + C1(0x9c) + 'world')).toBe('helloworld');
249
+ expect(s.flush()).toBe('');
250
+ });
251
+
252
+ // Exhaustive splits for the DCS family too, not only OSC. The 7-bit and 8-bit bodies are the two
253
+ // branches that carry the lookahead, so they are the two that must survive every boundary.
254
+ const DCS_PAYLOAD = 'A' + ESC + 'Pq#0;2;0;0;0' + ESC + '\\' + 'B';
255
+ const DCS_SPLITS = Array.from({ length: DCS_PAYLOAD.length - 1 }, (_, i) => i + 1);
256
+
257
+ it.each(DCS_SPLITS)('DCS split at %i', (at) => {
258
+ const s = createStreamingTerminalSanitizer();
259
+ const out = s.push(DCS_PAYLOAD.slice(0, at)) + s.push(DCS_PAYLOAD.slice(at)) + s.flush();
260
+ expect(out).toBe('AB');
261
+ });
262
+
263
+ const APC8_PAYLOAD = 'A' + C1(0x9f) + 'Ginline=1' + C1(0x9c) + 'B';
264
+ const APC8_SPLITS = Array.from({ length: APC8_PAYLOAD.length - 1 }, (_, i) => i + 1);
265
+
266
+ it.each(APC8_SPLITS)('8-bit APC split at %i', (at) => {
267
+ const s = createStreamingTerminalSanitizer();
268
+ const out = s.push(APC8_PAYLOAD.slice(0, at)) + s.push(APC8_PAYLOAD.slice(at)) + s.flush();
269
+ expect(out).toBe('AB');
270
+ });
271
+
272
+ it('passes ordinary chunked text through unchanged', () => {
273
+ const s = createStreamingTerminalSanitizer();
274
+ expect(s.push('hello ') + s.push('world') + s.flush()).toBe('hello world');
275
+ });
276
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * SEC-019 (issue #2022) — what actually reaches the terminal, measured rather than assumed.
3
+ *
4
+ * ## The oracle, and why it is not `lastFrame()`
5
+ *
6
+ * The first version of this file asserted against `ink-testing-library`'s `lastFrame()`. That is not
7
+ * what the terminal receives. Ink strips most control sequences on its way out — a side effect of
8
+ * slicing text for layout, not a documented guarantee — so a frame assertion is green whether the
9
+ * code sanitizes or not, for every sequence Ink happens to remove. Mutation exposed it: removing the
10
+ * sanitization from three call sites left every frame-based case passing.
11
+ *
12
+ * So these tests render through REAL Ink into a fake TTY and read the bytes it writes.
13
+ *
14
+ * ## What Ink removes and what it does not — measured on this dependency version
15
+ *
16
+ * stripped OSC 52 (clipboard), OSC 0 (title), CSI erase/cursor/alt-screen, DCS, APC, 8-bit CSI
17
+ * REACHES SGR colour, OSC 8 hyperlink, a bare carriage return
18
+ *
19
+ * The two that reach are the live attack surface through `<Text>`: a link whose visible text and
20
+ * target differ, and a CR that overwrites the line the transcript just printed. `useTerminalTitle`
21
+ * writes to stdout directly and is subject to none of Ink's filtering.
22
+ *
23
+ * The sanitizer covers the whole class anyway, and the reason is the measurement itself: Ink's
24
+ * stripping is incidental. It is not in Ink's contract, no test of Ink's asserts it, and a dependency
25
+ * upgrade can return the entire class silently. A security boundary that rests on another project's
26
+ * implementation detail is a boundary in name.
27
+ */
28
+
29
+ import { render as inkRender, Text } from 'ink';
30
+ import React from 'react';
31
+ import { Writable } from 'node:stream';
32
+ import { describe, expect, it } from 'vitest';
33
+
34
+ import { createToolMessage, messageToHistoryEntry } from '@robota-sdk/agent-core';
35
+
36
+ import MessageList from '../MessageList.js';
37
+ import StreamingIndicator from '../StreamingIndicator.js';
38
+
39
+ import type { IHistoryEntry } from '@robota-sdk/agent-core';
40
+ import type { IToolState } from '@robota-sdk/agent-interface-session';
41
+
42
+ const ESC = String.fromCharCode(27);
43
+ const BEL = String.fromCharCode(7);
44
+
45
+ /** OSC 8 renders a link whose visible text and target differ — and Ink passes it through. */
46
+ const LINK_MARKER = 'pwn-link-marker-8f3a';
47
+ const DECEPTIVE_LINK = `${ESC}]8;;https://evil.example/${LINK_MARKER}${BEL}`;
48
+ /** A bare CR returns the cursor to column zero and overwrites what was printed. */
49
+ const OVERWRITE = `\rpwn-overwrite-4c1e`;
50
+ /** Stripped by this Ink version; included because the sanitizer must not depend on that. */
51
+ const CLIPBOARD_WRITE = `${ESC}]52;c;cG93bmVk${BEL}`;
52
+ const ERASE_DISPLAY = `${ESC}[2J`;
53
+
54
+ const POISON = `${CLIPBOARD_WRITE}${DECEPTIVE_LINK}${ERASE_DISPLAY}${OVERWRITE}`;
55
+
56
+ /** The markers whose presence in the byte stream proves the payload reached the terminal. */
57
+ function carriesPayload(stream: string): boolean {
58
+ return (
59
+ stream.includes(LINK_MARKER) ||
60
+ // The CR immediately before the marker, not the marker alone: once the CR is gone the words are
61
+ // ordinary content and printing them is correct. Asserting on the text would have failed a
62
+ // correct sanitizer, which is the mirror of asserting on nothing.
63
+ stream.includes(OVERWRITE) ||
64
+ stream.includes('52;c;cG93bmVk') ||
65
+ stream.includes(`${ESC}]52`) ||
66
+ stream.includes(`${ESC}[2J`)
67
+ );
68
+ }
69
+
70
+ /** Every byte Ink writes for one render, through a stream that claims to be a terminal. */
71
+ async function streamOf(element: React.ReactElement): Promise<string> {
72
+ const chunks: string[] = [];
73
+ const stdout = new Writable({
74
+ write(chunk, _encoding, done) {
75
+ chunks.push(String(chunk));
76
+ done();
77
+ },
78
+ }) as unknown as NodeJS.WriteStream;
79
+ Object.assign(stdout, { isTTY: true, columns: 80, rows: 24 });
80
+ const app = inkRender(element, { stdout, patchConsole: false });
81
+ await new Promise((resolve) => setTimeout(resolve, 40));
82
+ app.unmount();
83
+ return chunks.join('');
84
+ }
85
+
86
+ const toolEntry = (content: string): IHistoryEntry =>
87
+ messageToHistoryEntry(
88
+ createToolMessage(content, { toolCallId: 'call-1', name: `Read${POISON}` }),
89
+ );
90
+
91
+ const eventEntry = (id: string, type: string, data: unknown): IHistoryEntry =>
92
+ ({ id, timestamp: new Date(), category: 'event', type, data }) as unknown as IHistoryEntry;
93
+
94
+ describe('SEC-019 — the terminal stream carries no payload from poisoned session data', () => {
95
+ it('the oracle is not vacuous: an unsanitized render DOES leak', async () => {
96
+ // Without this the whole file passes for a `carriesPayload` that always answers false, and for
97
+ // an Ink that removed everything. It also pins WHY this module exists: if this case ever goes
98
+ // green, Ink's own behaviour changed and the threat model needs re-reading — that is a signal
99
+ // worth a failing test, not a false alarm.
100
+ const stream = await streamOf(<Text>{`before${POISON}after`}</Text>);
101
+ expect(carriesPayload(stream), JSON.stringify(stream)).toBe(true);
102
+ });
103
+
104
+ it('MessageList: structured tool summary branch (the branch review found unguarded)', async () => {
105
+ const stream = await streamOf(
106
+ <MessageList history={[toolEntry(JSON.stringify([{ line: 'read ok' }]))]} />,
107
+ );
108
+ expect(stream).toContain('Read');
109
+ expect(carriesPayload(stream)).toBe(false);
110
+ });
111
+
112
+ it('MessageList: plain-text tool branch', async () => {
113
+ const stream = await streamOf(<MessageList history={[toolEntry(`not json ${POISON}`)]} />);
114
+ expect(stream).toContain('Read');
115
+ expect(carriesPayload(stream)).toBe(false);
116
+ });
117
+
118
+ it('MessageList: the tool-summary event label, name and first argument both', async () => {
119
+ const stream = await streamOf(
120
+ <MessageList
121
+ history={[
122
+ eventEntry('evt-1', 'tool-summary', {
123
+ tools: [
124
+ {
125
+ toolName: `Read${POISON}`,
126
+ firstArg: `file.ts${POISON}`,
127
+ isRunning: false,
128
+ result: 'success',
129
+ },
130
+ ],
131
+ summary: `✓ Read(file.ts)${POISON}`,
132
+ }),
133
+ ]}
134
+ />,
135
+ );
136
+ expect(stream).toContain('Read');
137
+ expect(carriesPayload(stream)).toBe(false);
138
+ });
139
+
140
+ it('MessageList: an event entry message (skill activation, memory topic)', async () => {
141
+ const stream = await streamOf(
142
+ <MessageList
143
+ history={[
144
+ eventEntry('evt-2', 'skill-activation', {
145
+ message: `Invoking plugin skill: ${POISON}`,
146
+ }),
147
+ ]}
148
+ />,
149
+ );
150
+ expect(stream).toContain('Invoking plugin skill');
151
+ expect(carriesPayload(stream)).toBe(false);
152
+ });
153
+
154
+ it('MessageList: an event entry that carries content instead of message', async () => {
155
+ const stream = await streamOf(
156
+ <MessageList history={[eventEntry('evt-3', 'note', { content: `note ${POISON}` })]} />,
157
+ );
158
+ expect(stream).toContain('note');
159
+ expect(carriesPayload(stream)).toBe(false);
160
+ });
161
+
162
+ it('MessageList: the tool-summary fallback lines, taken when the entry lists no tools', async () => {
163
+ const stream = await streamOf(
164
+ <MessageList
165
+ history={[eventEntry('evt-4', 'tool-summary', { summary: `line one ${POISON}\nline two` })]}
166
+ />,
167
+ );
168
+ expect(stream).toContain('line one');
169
+ expect(carriesPayload(stream)).toBe(false);
170
+ });
171
+
172
+ it('MessageList: command output preview lines, which are raw stdout', async () => {
173
+ const stream = await streamOf(
174
+ <MessageList
175
+ history={[
176
+ eventEntry('evt-5', 'tool-summary', {
177
+ tools: [
178
+ {
179
+ toolName: 'Shell',
180
+ firstArg: 'ls',
181
+ result: 'error',
182
+ toolResultData: JSON.stringify({ exitCode: 1, stdout: `oops ${POISON}` }),
183
+ },
184
+ ],
185
+ summary: '',
186
+ }),
187
+ ]}
188
+ />,
189
+ );
190
+ expect(stream).toContain('oops');
191
+ expect(carriesPayload(stream)).toBe(false);
192
+ });
193
+
194
+ it('StreamingIndicator: the live tool line, name and first argument both', async () => {
195
+ const tools = [
196
+ { toolName: `Read${POISON}`, firstArg: `file.ts${POISON}`, isRunning: true },
197
+ ] as unknown as IToolState[];
198
+ const stream = await streamOf(<StreamingIndicator text="" activeTools={tools} />);
199
+ expect(stream).toContain('Read');
200
+ expect(carriesPayload(stream)).toBe(false);
201
+ });
202
+
203
+ it('StreamingIndicator: the streamed assistant text', async () => {
204
+ const stream = await streamOf(<StreamingIndicator text={`hello ${POISON}`} activeTools={[]} />);
205
+ expect(carriesPayload(stream)).toBe(false);
206
+ });
207
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ applySelectionInput,
4
+ createSelectionFlowState,
5
+ getDirectionalSelectionInputAction,
6
+ getVerticalSelectionInputAction,
7
+ } from '../flows/selection-flow.js';
8
+
9
+ describe('selection flow', () => {
10
+ it('Given first item selected When previous is applied Then selection stays bounded', () => {
11
+ const result = applySelectionInput(createSelectionFlowState(), 'previous', { itemCount: 3 });
12
+
13
+ expect(result.state.selectedIndex).toBe(0);
14
+ expect(result.effect).toEqual({ type: 'none' });
15
+ });
16
+
17
+ it('Given last item selected When next is applied Then selection stays bounded', () => {
18
+ const state = { selectedIndex: 2, scrollOffset: 0, resolved: false };
19
+
20
+ const result = applySelectionInput(state, 'next', { itemCount: 3 });
21
+
22
+ expect(result.state.selectedIndex).toBe(2);
23
+ });
24
+
25
+ it('Given wrapping selection When previous from first is applied Then it wraps to last', () => {
26
+ const result = applySelectionInput(createSelectionFlowState(), 'previous', {
27
+ itemCount: 3,
28
+ wrap: true,
29
+ });
30
+
31
+ expect(result.state.selectedIndex).toBe(2);
32
+ });
33
+
34
+ it('Given max visible window When moving below viewport Then scroll offset follows', () => {
35
+ const state = applySelectionInput(createSelectionFlowState(), 'next', {
36
+ itemCount: 4,
37
+ maxVisible: 2,
38
+ }).state;
39
+
40
+ const result = applySelectionInput(state, 'next', { itemCount: 4, maxVisible: 2 });
41
+
42
+ expect(result.state).toMatchObject({ selectedIndex: 2, scrollOffset: 1 });
43
+ });
44
+
45
+ it('Given selected item When select is applied Then selected index is emitted once', () => {
46
+ const state = { selectedIndex: 1, scrollOffset: 0, resolved: false };
47
+
48
+ const selected = applySelectionInput(state, 'select', { itemCount: 3 });
49
+ const ignored = applySelectionInput(selected.state, 'select', { itemCount: 3 });
50
+
51
+ expect(selected.effect).toEqual({ type: 'select', index: 1 });
52
+ expect(ignored.effect).toEqual({ type: 'none' });
53
+ });
54
+
55
+ it('Given raw key info When mapped Then vertical and directional actions are produced', () => {
56
+ expect(getVerticalSelectionInputAction({ downArrow: true })).toBe('next');
57
+ expect(getVerticalSelectionInputAction({ escape: true })).toBe('cancel');
58
+ expect(getDirectionalSelectionInputAction({ leftArrow: true })).toBe('previous');
59
+ expect(getDirectionalSelectionInputAction({ rightArrow: true })).toBe('next');
60
+ });
61
+ });
@@ -0,0 +1,31 @@
1
+ import { render } from 'ink-testing-library';
2
+ import React from 'react';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import SessionEventNotices from '../SessionEventNotices.js';
6
+
7
+ describe('SessionEventNotices', () => {
8
+ it('renders deterministic plan, context, and branch notices', () => {
9
+ const { lastFrame } = render(
10
+ <SessionEventNotices
11
+ notices={[
12
+ { id: '1', event: 'plan_event', message: 'Plan plan approved' },
13
+ {
14
+ id: '2',
15
+ event: 'context_file_refreshed',
16
+ message: 'Context refreshed: AGENTS.md',
17
+ },
18
+ {
19
+ id: '3',
20
+ event: 'branch_event',
21
+ message: 'Branch branch switched: main @ turn-0001',
22
+ },
23
+ ]}
24
+ />,
25
+ );
26
+
27
+ expect(lastFrame()).toContain('Plan plan approved');
28
+ expect(lastFrame()).toContain('Context refreshed: AGENTS.md');
29
+ expect(lastFrame()).toContain('Branch branch switched: main @ turn-0001');
30
+ });
31
+ });
@@ -0,0 +1,102 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createSessionInitPoller } from '../flows/session-init-poller.js';
4
+
5
+ describe('createSessionInitPoller', () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ it('TC-04: calls onReady and stops once the check succeeds', () => {
15
+ const onReady = vi.fn();
16
+ const onFailure = vi.fn();
17
+ let ready = false;
18
+ const poller = createSessionInitPoller({
19
+ check: () => {
20
+ if (!ready) throw new Error('InteractiveSession not initialized. Call submit().');
21
+ },
22
+ intervalMs: 200,
23
+ timeoutMs: 15000,
24
+ onReady,
25
+ onFailure,
26
+ });
27
+ poller.start();
28
+ vi.advanceTimersByTime(600);
29
+ expect(onReady).not.toHaveBeenCalled();
30
+ ready = true;
31
+ vi.advanceTimersByTime(200);
32
+ expect(onReady).toHaveBeenCalledTimes(1);
33
+ vi.advanceTimersByTime(2000);
34
+ expect(onReady).toHaveBeenCalledTimes(1);
35
+ expect(onFailure).not.toHaveBeenCalled();
36
+ });
37
+
38
+ it('TC-04: benign not-initialized errors poll until timeout, then fail with timeout kind', () => {
39
+ const onReady = vi.fn();
40
+ const onFailure = vi.fn();
41
+ const poller = createSessionInitPoller({
42
+ check: () => {
43
+ throw new Error('InteractiveSession not initialized. Call submit().');
44
+ },
45
+ intervalMs: 200,
46
+ timeoutMs: 1000,
47
+ onReady,
48
+ onFailure,
49
+ });
50
+ poller.start();
51
+ vi.advanceTimersByTime(900);
52
+ expect(onFailure).not.toHaveBeenCalled();
53
+ vi.advanceTimersByTime(400);
54
+ expect(onFailure).toHaveBeenCalledTimes(1);
55
+ expect(onFailure.mock.calls[0]?.[0]).toMatchObject({ kind: 'timeout' });
56
+ expect(onReady).not.toHaveBeenCalled();
57
+ vi.advanceTimersByTime(2000);
58
+ expect(onFailure).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it('TC-04: a real error fails immediately with the error attached', () => {
62
+ const onFailure = vi.fn();
63
+ const poller = createSessionInitPoller({
64
+ check: () => {
65
+ throw new Error('ENOENT: session store unreadable');
66
+ },
67
+ intervalMs: 200,
68
+ timeoutMs: 15000,
69
+ onReady: vi.fn(),
70
+ onFailure,
71
+ });
72
+ poller.start();
73
+ vi.advanceTimersByTime(200);
74
+ expect(onFailure).toHaveBeenCalledTimes(1);
75
+ expect(onFailure.mock.calls[0]?.[0]).toMatchObject({ kind: 'error' });
76
+ expect(String((onFailure.mock.calls[0]?.[0] as { error: Error }).error.message)).toContain(
77
+ 'ENOENT',
78
+ );
79
+ vi.advanceTimersByTime(2000);
80
+ expect(onFailure).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it('stop() cancels polling without callbacks', () => {
84
+ const onReady = vi.fn();
85
+ const onFailure = vi.fn();
86
+ const poller = createSessionInitPoller({
87
+ check: () => {
88
+ throw new Error('InteractiveSession not initialized.');
89
+ },
90
+ intervalMs: 200,
91
+ timeoutMs: 1000,
92
+ onReady,
93
+ onFailure,
94
+ });
95
+ poller.start();
96
+ vi.advanceTimersByTime(400);
97
+ poller.stop();
98
+ vi.advanceTimersByTime(5000);
99
+ expect(onReady).not.toHaveBeenCalled();
100
+ expect(onFailure).not.toHaveBeenCalled();
101
+ });
102
+ });