@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,291 @@
1
+ /**
2
+ * FLOW-2006 TC-09 — the three user-execution scenarios on the BUILT binary.
3
+ *
4
+ * Scenario 1 runs in a real PTY: a link opens the trusted fixture repository with the prompt in the
5
+ * composer, unsent, labelled as external, and only Enter submits it. Scenarios 2 and 3 are process
6
+ * runs: every malformed or configuration-bearing link, and every untrusted or unrecorded target, is
7
+ * refused on stderr with a non-zero exit and no session.
8
+ *
9
+ * Runs under `test:pty` against the built CLI (`pnpm --filter @robota-sdk/agent-cli build` first).
10
+ */
11
+
12
+ import { execFileSync, spawnSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+ import {
15
+ mkdirSync,
16
+ mkdtempSync,
17
+ readFileSync,
18
+ readdirSync,
19
+ realpathSync,
20
+ rmSync,
21
+ writeFileSync,
22
+ } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { dirname, join, resolve } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
28
+
29
+ import { spawnTui } from './pty-driver.js';
30
+
31
+ import type { IPtySession } from './pty-driver.js';
32
+
33
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..');
34
+ const ROBOTA_BIN = join(REPO_ROOT, 'packages/agent-cli/bin/robota.cjs');
35
+ const WAIT_MS = 20_000;
36
+ const CASE_TIMEOUT_MS = 120_000;
37
+ const PROMPT = 'Summarize the README in one sentence';
38
+
39
+ interface IFixture {
40
+ root: string;
41
+ home: string;
42
+ trusted: string;
43
+ untrusted: string;
44
+ elsewhere: string;
45
+ env: NodeJS.ProcessEnv;
46
+ }
47
+
48
+ function writeProviderSettings(home: string): void {
49
+ const dir = join(home, '.robota');
50
+ mkdirSync(dir, { recursive: true });
51
+ // Skip the first-run welcome: it is an onboarding screen, not what this scenario observes.
52
+ writeFileSync(join(dir, 'onboarded'), new Date().toISOString(), 'utf8');
53
+ writeFileSync(
54
+ join(dir, 'settings.json'),
55
+ JSON.stringify({
56
+ currentProvider: 'anthropic',
57
+ providers: {
58
+ anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
59
+ },
60
+ }),
61
+ 'utf8',
62
+ );
63
+ }
64
+
65
+ function initRepo(path: string, env: NodeJS.ProcessEnv): void {
66
+ mkdirSync(path, { recursive: true });
67
+ const run = (...args: string[]): void => {
68
+ execFileSync('git', args, { cwd: path, env, stdio: ['ignore', 'ignore', 'pipe'] });
69
+ };
70
+ run('init', '-q', '-b', 'main');
71
+ run('config', 'user.name', 'Robota Scenario');
72
+ run('config', 'user.email', 'scenario@example.invalid');
73
+ writeFileSync(join(path, 'README.md'), 'fixture\n');
74
+ run('add', '--', 'README.md');
75
+ run('commit', '-q', '-m', 'chore: fixture');
76
+ }
77
+
78
+ function makeFixture(): IFixture {
79
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'robota-flow2006-')));
80
+ const home = join(root, 'home');
81
+ mkdirSync(home, { recursive: true });
82
+ writeProviderSettings(home);
83
+ const env: NodeJS.ProcessEnv = {
84
+ PATH: process.env['PATH'] ?? '',
85
+ HOME: home,
86
+ TERM: 'xterm-256color',
87
+ GIT_CONFIG_NOSYSTEM: '1',
88
+ GIT_CONFIG_GLOBAL: join(home, 'gitconfig'),
89
+ };
90
+ writeFileSync(join(home, 'gitconfig'), '[commit]\n\tgpgsign = false\n');
91
+ const fixture: IFixture = {
92
+ root,
93
+ home,
94
+ trusted: join(root, 'trusted'),
95
+ untrusted: join(root, 'untrusted'),
96
+ elsewhere: join(root, 'elsewhere'),
97
+ env,
98
+ };
99
+ initRepo(fixture.trusted, env);
100
+ initRepo(fixture.untrusted, env);
101
+ mkdirSync(fixture.elsewhere, { recursive: true });
102
+ // Grant trust to the one repository a link is allowed to open.
103
+ const granted = spawnSync(process.execPath, [ROBOTA_BIN, 'trust', '--yes'], {
104
+ cwd: fixture.trusted,
105
+ env,
106
+ encoding: 'utf8',
107
+ });
108
+ if (granted.status !== 0) throw new Error(`trust --yes failed: ${granted.stderr}`);
109
+ return fixture;
110
+ }
111
+
112
+ function link(fixture: IFixture, extra = ''): string {
113
+ return `robota://open?v=1&prompt=${encodeURIComponent(PROMPT)}&cwd=${fixture.trusted}${extra}`;
114
+ }
115
+
116
+ /** Run `robota open <url>` as a process, headless — used for the refusal scenarios. */
117
+ function openHeadless(
118
+ fixture: IFixture,
119
+ url: string,
120
+ cwd = fixture.elsewhere,
121
+ ): ReturnType<typeof spawnSync> {
122
+ return spawnSync(process.execPath, [ROBOTA_BIN, 'open', url], {
123
+ cwd,
124
+ env: { ...fixture.env, TERM: 'dumb' },
125
+ encoding: 'utf8',
126
+ timeout: 60_000,
127
+ });
128
+ }
129
+
130
+ /**
131
+ * What an interactive session leaves behind under HOME: one `peers/*.peer.json` per run. A refusal
132
+ * that started no session leaves this listing untouched, which is how the refusal scenarios prove
133
+ * "no session was started" without watching the process.
134
+ */
135
+ function sessionRecords(home: string): readonly string[] {
136
+ try {
137
+ return readdirSync(join(home, '.robota', 'peers')).sort();
138
+ } catch {
139
+ return [];
140
+ }
141
+ }
142
+
143
+ /** The trust store's bytes — a refused link must not add, remove or touch a grant. */
144
+ function trustStoreDigest(home: string): string {
145
+ return createHash('sha256')
146
+ .update(readFileSync(join(home, '.robota', 'workspace-trust.json')))
147
+ .digest('hex');
148
+ }
149
+
150
+ /**
151
+ * Every malformed or configuration-bearing link, each paired with the word its refusal must name —
152
+ * the FIRST rule it breaks, so a refusal that merely says the link is invalid fails the case.
153
+ */
154
+ function malformedLinks(fixture: IFixture): readonly (readonly [string, string])[] {
155
+ return [
156
+ [link(fixture, '&provider=anthropic'), 'provider'],
157
+ [link(fixture, `&prompt=${encodeURIComponent('second')}`), 'more than once'],
158
+ [`robota://open?prompt=hi&cwd=${fixture.trusted}`, 'no `v`'],
159
+ [`robota://open?v=2&prompt=hi&cwd=${fixture.trusted}`, 'version 1'],
160
+ [
161
+ `robota://open?v=1&prompt=${encodeURIComponent('/mode bypassPermissions')}&cwd=${fixture.trusted}`,
162
+ 'not a command',
163
+ ],
164
+ ['robota://open?v=1&prompt=hi&cwd=relative/path', 'absolute'],
165
+ [`robota://open?v=1&prompt=hi&cwd=${fixture.trusted}/../untrusted`, '`..`'],
166
+ [`robota://open?v=1&prompt=hi&cwd=${join(fixture.root, 'absent')}`, 'does not exist'],
167
+ ];
168
+ }
169
+
170
+ describe('robota open through the real binary (FLOW-2006 TC-09)', () => {
171
+ let fixture: IFixture | undefined;
172
+ let session: IPtySession | undefined;
173
+
174
+ beforeEach(() => {
175
+ fixture = makeFixture();
176
+ });
177
+
178
+ afterEach(async () => {
179
+ await session?.disposeAsync();
180
+ session = undefined;
181
+ if (fixture) rmSync(fixture.root, { recursive: true, force: true });
182
+ fixture = undefined;
183
+ });
184
+
185
+ it(
186
+ 'Scenario 1: the link opens the trusted repository with the prompt prefilled, inert and labelled',
187
+ async () => {
188
+ const current = fixture;
189
+ if (!current) throw new Error('fixture');
190
+ session = spawnTui({
191
+ projectDir: current.elsewhere,
192
+ homeDir: current.home,
193
+ args: ['open', link(current), '--name', 'flow2006'],
194
+ });
195
+
196
+ // The PREFILLED TEXT is the arrival signal, not the empty-composer placeholder (which never
197
+ // renders once there is text) and not `Idle` or the notice — both of those are already on the
198
+ // screen one frame before the composer itself is drawn.
199
+ await session.waitFor(new RegExp(PROMPT), WAIT_MS);
200
+ await session.waitFor(/Idle/, WAIT_MS);
201
+ const first = session.snapshot();
202
+ // The prompt is in the composer, the provenance is visible, and nothing was sent.
203
+ expect(first).toContain(PROMPT);
204
+ expect(first).toContain('Prompt from an external link');
205
+ expect(first).not.toMatch(/Thinking|Interrupting/);
206
+ // The link changed the directory: the status line names the fixture repository's branch.
207
+ expect(first).toContain('git: main');
208
+
209
+ // Enter submits exactly that text: it leaves the composer and becomes the user message.
210
+ const since = session.outputOffset();
211
+ await session.pressEnter();
212
+ await session.waitForSince(since, new RegExp(PROMPT), WAIT_MS);
213
+
214
+ const run = spawnSync('git', ['log', '--oneline'], {
215
+ cwd: current.trusted,
216
+ env: current.env,
217
+ encoding: 'utf8',
218
+ });
219
+ expect(run.stdout.trim().split('\n')).toHaveLength(1);
220
+ },
221
+ CASE_TIMEOUT_MS,
222
+ );
223
+
224
+ it(
225
+ 'Scenario 2: every malformed or configuration-bearing link is refused with no session',
226
+ () => {
227
+ const current = fixture;
228
+ if (!current) throw new Error('fixture');
229
+ const recordsBefore = sessionRecords(current.home);
230
+ const settingsBefore = readFileSync(join(current.home, '.robota', 'settings.json'), 'utf8');
231
+ const cases = malformedLinks(current);
232
+ for (const [url, expected] of cases) {
233
+ const result = openHeadless(current, url);
234
+ expect(result.status, url).toBe(1);
235
+ expect(`${result.stderr}`, url).toContain(expected);
236
+ expect(`${result.stdout}`, url).not.toContain('Type a message');
237
+ }
238
+ // A SECOND LINK after the first is refused — the argv-appending shape the platforms document.
239
+ const twoArgs = spawnSync(
240
+ process.execPath,
241
+ [ROBOTA_BIN, 'open', link(current), link(current)],
242
+ {
243
+ cwd: current.elsewhere,
244
+ env: { ...current.env, TERM: 'dumb' },
245
+ encoding: 'utf8',
246
+ },
247
+ );
248
+ expect(twoArgs.status).toBe(1);
249
+ expect(`${twoArgs.stderr}`).toContain('exactly one link');
250
+
251
+ // Nothing was started and nothing was configured: no session record appeared, and the
252
+ // settings the `/mode bypassPermissions` link tried to reach are byte-identical.
253
+ expect(sessionRecords(current.home)).toEqual(recordsBefore);
254
+ expect(readFileSync(join(current.home, '.robota', 'settings.json'), 'utf8')).toBe(
255
+ settingsBefore,
256
+ );
257
+ },
258
+ CASE_TIMEOUT_MS,
259
+ );
260
+
261
+ it(
262
+ 'Scenario 3: an untrusted directory and an unrecorded slug are refused, and nothing is cloned',
263
+ () => {
264
+ const current = fixture;
265
+ if (!current) throw new Error('fixture');
266
+ const digestBefore = trustStoreDigest(current.home);
267
+ const untrusted = openHeadless(
268
+ current,
269
+ `robota://open?v=1&prompt=hi&cwd=${current.untrusted}`,
270
+ );
271
+ expect(untrusted.status).toBe(1);
272
+ expect(`${untrusted.stderr}`).toContain('robota trust --yes');
273
+
274
+ const slug = openHeadless(current, 'robota://open?v=1&prompt=hi&repo=nobody/not-a-clone');
275
+ expect(slug.status).toBe(1);
276
+ expect(`${slug.stderr}`).toContain('nobody/not-a-clone');
277
+ expect(`${slug.stderr}`).not.toContain('cloning');
278
+
279
+ // A refused link grants nothing and records nothing: the untrusted repository is still
280
+ // untrusted and the store is byte-identical to what it was before the two invocations.
281
+ const status = spawnSync(process.execPath, [ROBOTA_BIN, 'trust', 'status'], {
282
+ cwd: current.untrusted,
283
+ env: current.env,
284
+ encoding: 'utf8',
285
+ });
286
+ expect(`${status.stdout}`).toContain('untrusted');
287
+ expect(trustStoreDigest(current.home)).toBe(digestBefore);
288
+ },
289
+ CASE_TIMEOUT_MS,
290
+ );
291
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * CLI-062 — the one matrix cell where a REAL terminal emulator answers back.
3
+ *
4
+ * Every other cell reads the cursor out of the byte stream our own VT interpreter replays. tmux is
5
+ * a full, independent terminal emulator that can be driven headlessly AND queried: it runs the
6
+ * built robota binary in a real pane, receives the Korean keystrokes through its own input path,
7
+ * and then reports where ITS state machine put the hardware cursor
8
+ * (`#{cursor_x}` / `#{cursor_y}` / `#{cursor_flag}`). That makes this the strongest available
9
+ * confirmation short of macOS hardware: a second implementation agrees the cursor sits on the
10
+ * input row at the composition column — which is the cell an OS IME anchors its window to.
11
+ *
12
+ * Skipped when tmux is not installed; `ROBOTA_TMUX_BIN` overrides the binary. GitHub's ubuntu
13
+ * runner images ship tmux, so this runs in CI rather than quietly evaporating there.
14
+ */
15
+
16
+ import { execFileSync, spawnSync } from 'node:child_process';
17
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join, resolve } from 'node:path';
20
+
21
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
+
23
+ const REPO_ROOT = resolve(__dirname, '../../../../..');
24
+ const ROBOTA_BIN = join(REPO_ROOT, 'packages/agent-cli/bin/robota.cjs');
25
+ const COLS = 80;
26
+ const COMPOSITION = '안녕';
27
+ const COMPOSITION_WIDTH = 4;
28
+
29
+ function resolveTmux(): string | undefined {
30
+ const override = process.env['ROBOTA_TMUX_BIN'];
31
+ if (override !== undefined && override !== '') return override;
32
+ const which = spawnSync('sh', ['-c', 'command -v tmux'], { encoding: 'utf8' });
33
+ const found = which.stdout.trim();
34
+ return found === '' ? undefined : found;
35
+ }
36
+
37
+ const TMUX = resolveTmux();
38
+
39
+ interface ITmuxProbe {
40
+ cursorX: number;
41
+ cursorY: number;
42
+ /** tmux's own DECTCEM view: is the hardware cursor visible in the pane? */
43
+ cursorVisible: boolean;
44
+ pane: string[];
45
+ }
46
+
47
+ interface ITmuxRun {
48
+ root: string;
49
+ socket: string;
50
+ }
51
+
52
+ /** Run robota inside a real tmux pane and ask tmux where the cursor ended up. */
53
+ function runInTmux(run: ITmuxRun, rows: number, imeCursor: string | undefined): ITmuxProbe {
54
+ const tmux = TMUX!;
55
+ const projectDir = join(run.root, 'proj');
56
+ const homeDir = join(run.root, 'home');
57
+ mkdirSync(join(homeDir, '.robota'), { recursive: true });
58
+ writeFileSync(
59
+ join(homeDir, '.robota/settings.json'),
60
+ JSON.stringify({
61
+ currentProvider: 'anthropic',
62
+ providers: {
63
+ anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'tmux-dummy-key' },
64
+ },
65
+ }),
66
+ 'utf8',
67
+ );
68
+
69
+ // A wrapper keeps the pane's env explicit (never inheriting real provider keys) while letting
70
+ // tmux itself set TERM/TERM_PROGRAM for the pane — the handshake under test.
71
+ const wrapper = join(run.root, 'wrap.sh');
72
+ const imeAssignment = imeCursor === undefined ? '' : `ROBOTA_IME_CURSOR=${imeCursor} `;
73
+ writeFileSync(
74
+ wrapper,
75
+ [
76
+ '#!/bin/sh',
77
+ `cd "${projectDir}"`,
78
+ `exec env -i PATH="$PATH" HOME="${homeDir}" TERM="$TERM" TERM_PROGRAM="$TERM_PROGRAM" ${imeAssignment}` +
79
+ `"${process.execPath}" "${ROBOTA_BIN}" --name tmux-fixture`,
80
+ '',
81
+ ].join('\n'),
82
+ 'utf8',
83
+ );
84
+ chmodSync(wrapper, 0o755);
85
+
86
+ const tmuxCall = (...args: string[]): string =>
87
+ execFileSync(tmux, ['-S', run.socket, ...args], { encoding: 'utf8' });
88
+
89
+ tmuxCall('new-session', '-d', '-x', String(COLS), '-y', String(rows), wrapper);
90
+
91
+ const deadline = Date.now() + 40_000;
92
+ let pane = '';
93
+ while (Date.now() < deadline) {
94
+ pane = tmuxCall('capture-pane', '-p');
95
+ if (pane.includes('Type a message')) break;
96
+ sleepSync(250);
97
+ }
98
+ expect(pane, 'robota never reached the prompt inside tmux').toContain('Type a message');
99
+ sleepSync(400);
100
+
101
+ tmuxCall('send-keys', '-l', COMPOSITION);
102
+ sleepSync(1500);
103
+
104
+ const [x = '', y = '', flag = ''] = tmuxCall(
105
+ 'display-message',
106
+ '-p',
107
+ '#{cursor_x} #{cursor_y} #{cursor_flag}',
108
+ )
109
+ .trim()
110
+ .split(' ');
111
+ return {
112
+ cursorX: Number.parseInt(x, 10),
113
+ cursorY: Number.parseInt(y, 10),
114
+ cursorVisible: flag === '1',
115
+ pane: tmuxCall('capture-pane', '-p').split('\n'),
116
+ };
117
+ }
118
+
119
+ /** Deliberate synchronous wait: tmux is driven through blocking CLI calls, not an event loop. */
120
+ function sleepSync(ms: number): void {
121
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
122
+ }
123
+
124
+ describe.skipIf(TMUX === undefined)(
125
+ 'CLI-062 terminal matrix — tmux, a real emulator reporting its own cursor',
126
+ () => {
127
+ let run: ITmuxRun;
128
+
129
+ beforeEach(() => {
130
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'robota-tmux-ime-')));
131
+ run = { root, socket: join(root, 'sock') };
132
+ });
133
+
134
+ afterEach(() => {
135
+ if (TMUX !== undefined) {
136
+ spawnSync(TMUX, ['-S', run.socket, 'kill-server'], { stdio: 'ignore' });
137
+ }
138
+ rmSync(run.root, { recursive: true, force: true });
139
+ });
140
+
141
+ it('24-row pane: tmux reports the cursor VISIBLE on the input row at the composition column', () => {
142
+ const probe = runInTmux(run, 24, undefined);
143
+ const inputRow = probe.pane.findIndex((line) => line.includes(`> ${COMPOSITION}`));
144
+ expect(inputRow, `no input row in pane:\n${probe.pane.join('\n')}`).toBeGreaterThanOrEqual(0);
145
+
146
+ expect(probe.cursorVisible).toBe(true);
147
+ expect(probe.cursorY).toBe(inputRow);
148
+ expect(probe.cursorX).toBe(probe.pane[inputRow]!.indexOf('> ') + 2 + COMPOSITION_WIDTH);
149
+ }, 90_000);
150
+
151
+ it('5-row pane (frame ≥ viewport, I2): tmux reports the cursor hidden, never positioned', () => {
152
+ const probe = runInTmux(run, 5, undefined);
153
+ expect(probe.pane.some((line) => line.includes(`> ${COMPOSITION}`))).toBe(true);
154
+ expect(probe.cursorVisible).toBe(false);
155
+ }, 90_000);
156
+
157
+ it('ROBOTA_IME_CURSOR=0 kill switch: tmux reports the cursor hidden even at 24 rows', () => {
158
+ const probe = runInTmux(run, 24, '0');
159
+ const inputRow = probe.pane.findIndex((line) => line.includes(`> ${COMPOSITION}`));
160
+ expect(inputRow).toBeGreaterThanOrEqual(0);
161
+ expect(probe.cursorVisible).toBe(false);
162
+ expect(probe.cursorY).not.toBe(inputRow);
163
+ }, 90_000);
164
+ },
165
+ );
@@ -0,0 +1,186 @@
1
+ /**
2
+ * CLI-062 — PTY regression for real-terminal-cursor positioning (the OS-IME evidence).
3
+ *
4
+ * Runs the BUILT robota binary in a real pseudo-terminal and interprets the raw ANSI stream the
5
+ * way a terminal emulator (and the OS IME) does. Two geometries from the implementation contract
6
+ * (.design/investigations/2026-07-25-cli-062-ime-cursor-design.md):
7
+ *
8
+ * - 24-row pty: while typing CJK, every `ESC[?25h` emitted after boot lands ON the input row at
9
+ * the composition column — never the top region (the historical hardcoded-y:0 crash geometry).
10
+ * - 5-row pty: the live frame fills the viewport (ink's fullscreen path, bottom-anchor
11
+ * off-by-one) — invariant I2 must refuse to position: ZERO cursor-show sequences after boot.
12
+ *
13
+ * Assertions only cover the post-boot composition window: boot may legitimately show the cursor
14
+ * (spinners etc.), and teardown restores visibility — both are outside the invariant.
15
+ *
16
+ * The second describe block drives four boundary representatives through the real binary. The full
17
+ * terminal/override matrix remains exhaustive in terminal-capabilities.test.ts; process startup is
18
+ * reserved for supported-default, Terminal.app-default-off, force-on, and kill-switch boundaries.
19
+ */
20
+
21
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+
25
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
26
+
27
+ import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
28
+ import {
29
+ REPRESENTATIVE_IME_PTY_CELLS,
30
+ TERMINAL_PROFILES,
31
+ cellLabel,
32
+ expectedImeCursorEnabled,
33
+ profileEnv,
34
+ } from '../helpers/terminal-profiles.js';
35
+ import { interpretVtStream } from '../helpers/vt-cursor-interpreter.js';
36
+
37
+ import type { IPtySession } from './pty-driver.js';
38
+ import type { ICursorShowEvent } from '../helpers/vt-cursor-interpreter.js';
39
+
40
+ const COLS = 80;
41
+ const PROMPT_PLACEHOLDER = /Type a message or \/help/;
42
+ const COMPOSITION = '안녕';
43
+ /** `안녕` occupies 4 display columns; the prompt adds `> `. */
44
+ const COMPOSITION_WIDTH = 4;
45
+ /** Let ink's throttled (≤30fps) frame writes flush after the last keystroke. */
46
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 400));
47
+
48
+ interface IProbeResult {
49
+ /** Cursor shows emitted after boot — the composition window the OS IME would act on. */
50
+ compositionShows: ICursorShowEvent[];
51
+ /** Screen row holding `> 안녕`, or -1 when the composition never rendered. */
52
+ inputRow: number;
53
+ /** Column of the `> ` prompt on the input row, or -1. */
54
+ promptCol: number;
55
+ }
56
+
57
+ /**
58
+ * Boot the built binary in a pty of the given geometry, type CJK, and report the cursor shows the
59
+ * terminal would have acted on. Shared by the original two cases and by every matrix cell.
60
+ */
61
+ async function probe(
62
+ projectDir: string,
63
+ rows: number,
64
+ env: Record<string, string>,
65
+ register: (session: IPtySession) => void,
66
+ ): Promise<IProbeResult> {
67
+ const session = spawnTui({
68
+ projectDir,
69
+ homeDir: join(projectDir, 'home'),
70
+ cols: COLS,
71
+ rows,
72
+ env,
73
+ });
74
+ register(session);
75
+ await session.waitFor(PROMPT_PLACEHOLDER);
76
+ await flush();
77
+
78
+ const bootMark = session.raw().length;
79
+ await session.sendKeys(COMPOSITION);
80
+ await session.waitFor(new RegExp(COMPOSITION));
81
+ await flush();
82
+
83
+ const vt = interpretVtStream(session.raw(), rows, COLS);
84
+ const inputRow = vt.screen.findIndex((line) => line.includes(`> ${COMPOSITION}`));
85
+ return {
86
+ compositionShows: vt.showEvents.filter((event) => event.offset >= bootMark),
87
+ inputRow,
88
+ promptCol: inputRow < 0 ? -1 : vt.screen[inputRow]!.indexOf('> '),
89
+ };
90
+ }
91
+
92
+ /** Every post-boot show sits on the input row at the composition column (the positive contract). */
93
+ function expectPositionedOnInputRow(result: IProbeResult): void {
94
+ expect(result.compositionShows.length).toBeGreaterThan(0);
95
+ expect(result.inputRow).toBeGreaterThanOrEqual(0);
96
+ for (const event of result.compositionShows) {
97
+ // Never the top region (the Terminal.app SIGSEGV geometry), always the input row.
98
+ expect(event.row).toBe(result.inputRow);
99
+ }
100
+ expect(result.compositionShows.at(-1)!.col).toBe(result.promptCol + 2 + COMPOSITION_WIDTH);
101
+ }
102
+
103
+ describe('CLI-062 — IME hardware-cursor positioning through a real PTY', () => {
104
+ let projectDir: string;
105
+ let session: IPtySession | undefined;
106
+
107
+ beforeEach(() => {
108
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-pty-ime-')));
109
+ writeTuiProviderSettings(projectDir);
110
+ });
111
+
112
+ afterEach(async () => {
113
+ await session?.disposeAsync();
114
+ session = undefined;
115
+ rmSync(projectDir, { recursive: true, force: true });
116
+ });
117
+
118
+ it('24-row pty: every post-boot cursor show lands on the input row at the composition column', async () => {
119
+ const result = await probe(projectDir, 24, {}, (s) => (session = s));
120
+ expectPositionedOnInputRow(result);
121
+ }, 60_000);
122
+
123
+ it('5-row pty (frame ≥ viewport, I2): zero cursor-show sequences during composition', async () => {
124
+ const result = await probe(projectDir, 5, {}, (s) => (session = s));
125
+ expect(result.compositionShows).toEqual([]);
126
+ }, 60_000);
127
+ });
128
+
129
+ /**
130
+ * Representative terminal boundaries, re-runnable through the real binary.
131
+ *
132
+ * PROVES, per cell: the built binary's observable cursor contract under exactly the environment
133
+ * that terminal exports — positioned on the input row when the capability gate says yes, and no
134
+ * cursor shown at all when it says no (Terminal.app's default-off I5 branch, and the
135
+ * `ROBOTA_IME_CURSOR=0` kill switch).
136
+ *
137
+ * DOES NOT PROVE, for a `documented` row: anything about that emulator's own rendering, its
138
+ * pre-edit display, or its OS IME. Terminal.app's historical Korean-IME SIGSEGV in particular is
139
+ * an Apple-side defect that can only be re-checked on macOS; it stays off by default (I5) until
140
+ * someone runs the macOS cells. tmux is additionally driven as a REAL emulator in
141
+ * ime-cursor-tmux.ptytest.ts, where the emulator reports its own cursor position back.
142
+ */
143
+ describe('CLI-062 terminal matrix — observable cursor contract per terminal', () => {
144
+ let projectDir: string;
145
+ let session: IPtySession | undefined;
146
+
147
+ beforeEach(() => {
148
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-pty-ime-matrix-')));
149
+ writeTuiProviderSettings(projectDir);
150
+ });
151
+
152
+ afterEach(async () => {
153
+ await session?.disposeAsync();
154
+ session = undefined;
155
+ rmSync(projectDir, { recursive: true, force: true });
156
+ });
157
+
158
+ for (const cell of REPRESENTATIVE_IME_PTY_CELLS) {
159
+ const profile = TERMINAL_PROFILES.find((candidate) => candidate.id === cell.profileId);
160
+ if (!profile) {
161
+ throw new Error(`Unknown representative terminal profile: ${cell.profileId}`);
162
+ }
163
+ const { override } = cell;
164
+ const enabled = expectedImeCursorEnabled(profile, override);
165
+ const verdict = enabled ? 'positions on the input row' : 'never shows the cursor';
166
+
167
+ it(`${cellLabel(profile, override)} → ${verdict}`, async () => {
168
+ const env = profileEnv(profile, override);
169
+
170
+ const wide = await probe(projectDir, 24, env, (s) => (session = s));
171
+ if (!enabled) {
172
+ expect(wide.compositionShows).toEqual([]);
173
+ return;
174
+ }
175
+ expectPositionedOnInputRow(wide);
176
+ await session?.disposeAsync();
177
+ session = undefined;
178
+
179
+ // I2, checked wherever the gate would otherwise allow positioning: a frame that fills the
180
+ // viewport is never positioned into, however capable the terminal is. (For a gated-off
181
+ // cell the 24-row probe above already proves nothing is ever shown.)
182
+ const narrow = await probe(projectDir, 5, env, (s) => (session = s));
183
+ expect(narrow.compositionShows).toEqual([]);
184
+ }, 90_000);
185
+ }
186
+ });