@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,229 @@
1
+ /**
2
+ * TUI-owned PTY harness (TEST-007; relocated internally by BOUNDARY-2655).
3
+ *
4
+ * One reusable pseudo-terminal driver for end-to-end tests. Spawns a process in a real PTY (via
5
+ * the prebuilt `@homebridge/node-pty-prebuilt-multiarch`, so Ink renders and reads input exactly as
6
+ * in a user terminal) and exposes per-key paced input plus marker/exit waiting. Two convenience
7
+ * spawners build on the core:
8
+ *
9
+ * - `spawnPty` — drive any command (e.g. the built robota CLI binary).
10
+ * - `spawnPtyFixture` — drive a TSX fixture through the `tsx/esm` import hook (no build step),
11
+ * the pattern used for focused source-level E2E.
12
+ *
13
+ * Per-key pacing matters: a burst write is bundled by the terminal as a bracketed paste, which is the
14
+ * exact failure mode this harness exists to avoid.
15
+ */
16
+ import { createRequire } from 'node:module';
17
+
18
+ import { spawn } from '@homebridge/node-pty-prebuilt-multiarch';
19
+
20
+ import { createPtyEnv } from './isolated-home.js';
21
+
22
+ import type { IPty } from '@homebridge/node-pty-prebuilt-multiarch';
23
+
24
+ // eslint-disable-next-line no-control-regex
25
+ const ANSI_PATTERN = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][B0]|[\x00-\x08\x0b-\x1f]/g;
26
+
27
+ const DEFAULT_PER_KEY_DELAY_MS = 35;
28
+ const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
29
+ const DEFAULT_EXIT_TIMEOUT_MS = 10_000;
30
+ const OUTPUT_TAIL_LENGTH = 2000;
31
+ /** CORE-023: grace before escalating a lingering PTY child from SIGTERM to SIGKILL. */
32
+ const PTY_KILL_GRACE_MS = 2000;
33
+
34
+ export interface IPtyRunOptions {
35
+ command: string;
36
+ args: string[];
37
+ cwd: string;
38
+ /**
39
+ * Environment for the child. Defaults to {@link createPtyEnv} — a minimal env with an ISOLATED
40
+ * HOME. Build overrides with `createPtyEnv({ ... })` rather than by hand, so a caller cannot
41
+ * reintroduce the real `process.env.HOME` (which makes the suite depend on the host's `~`).
42
+ */
43
+ env?: NodeJS.ProcessEnv;
44
+ cols?: number;
45
+ rows?: number;
46
+ }
47
+
48
+ export interface IPtyRunSession {
49
+ /** Type text one key at a time (default 35ms/key — human-ish, avoids paste bundling). */
50
+ sendKeys(text: string, perKeyDelayMs?: number): Promise<void>;
51
+ /** Write bytes verbatim (no pacing) — for control sequences / single keystrokes. */
52
+ write(data: string): void;
53
+ /** Press Enter as a single keystroke (with small settle on either side). */
54
+ pressEnter(): Promise<void>;
55
+ /** Wait until the ANSI-stripped output matches; throws with a snapshot on timeout. */
56
+ waitFor(pattern: RegExp | string, timeoutMs?: number): Promise<void>;
57
+ /**
58
+ * Current length of the ANSI-stripped output — a mark for `waitForSince`/`snapshotSince`.
59
+ * The stripped output is CUMULATIVE (a transcript, not the current screen): a plain `waitFor`
60
+ * also matches old frames and the echo of your own typed input. Take a mark before acting, then
61
+ * assert only on what arrived after it.
62
+ */
63
+ outputOffset(): number;
64
+ /** Like `waitFor`, but matches only output that arrived after the `since` mark. */
65
+ waitForSince(since: number, pattern: RegExp | string, timeoutMs?: number): Promise<void>;
66
+ /** ANSI-stripped output that arrived after the `since` mark. */
67
+ snapshotSince(since: number): string;
68
+ /** Current ANSI-stripped output. */
69
+ snapshot(): string;
70
+ /** Current raw (un-stripped) output. */
71
+ raw(): string;
72
+ /** Wait for process exit; throws with a snapshot on timeout. */
73
+ expectExit(timeoutMs?: number): Promise<number>;
74
+ /** Force-kill / cleanup (no-op if already exited). */
75
+ dispose(): void;
76
+ }
77
+
78
+ function sleep(ms: number): Promise<void> {
79
+ return new Promise((resolve) => setTimeout(resolve, ms));
80
+ }
81
+
82
+ function toRegExp(pattern: RegExp | string): RegExp {
83
+ if (pattern instanceof RegExp) return pattern;
84
+ // Escape regex metacharacters so a plain string is matched literally.
85
+ return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
86
+ }
87
+
88
+ /** Spawn any command in a real PTY and return a driving session. */
89
+ export function spawnPty(options: IPtyRunOptions): IPtyRunSession {
90
+ let output = '';
91
+ let exitCode: number | undefined;
92
+
93
+ const pty: IPty = spawn(options.command, options.args, {
94
+ name: 'xterm-256color',
95
+ cols: options.cols ?? 100,
96
+ rows: options.rows ?? 32,
97
+ cwd: options.cwd,
98
+ // HARNESS-025: the default HOME is a throwaway directory, never the developer's real one — a
99
+ // PTY child that reads `~/.robota` (or any user rc) must not make the suite machine-dependent.
100
+ env: options.env ?? createPtyEnv(),
101
+ });
102
+
103
+ pty.onData((data) => {
104
+ output += data;
105
+ });
106
+ pty.onExit(({ exitCode: code }) => {
107
+ exitCode = code;
108
+ });
109
+
110
+ const stripped = (): string => output.replace(ANSI_PATTERN, '');
111
+
112
+ return {
113
+ async sendKeys(text: string, perKeyDelayMs = DEFAULT_PER_KEY_DELAY_MS): Promise<void> {
114
+ for (const ch of text) {
115
+ pty.write(ch);
116
+ await sleep(perKeyDelayMs);
117
+ }
118
+ },
119
+ write(data: string): void {
120
+ pty.write(data);
121
+ },
122
+ async pressEnter(): Promise<void> {
123
+ await sleep(120);
124
+ pty.write('\r');
125
+ await sleep(120);
126
+ },
127
+ async waitFor(pattern: RegExp | string, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS): Promise<void> {
128
+ const regex = toRegExp(pattern);
129
+ const deadline = Date.now() + timeoutMs;
130
+ while (Date.now() < deadline) {
131
+ if (regex.test(stripped())) return;
132
+ if (exitCode !== undefined && regex.test(stripped())) return;
133
+ await sleep(50);
134
+ }
135
+ throw new Error(
136
+ `PTY waitFor timeout (${timeoutMs}ms) for ${String(regex)}\n--- snapshot ---\n${stripped().slice(-OUTPUT_TAIL_LENGTH)}`,
137
+ );
138
+ },
139
+ outputOffset(): number {
140
+ return stripped().length;
141
+ },
142
+ async waitForSince(
143
+ since: number,
144
+ pattern: RegExp | string,
145
+ timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
146
+ ): Promise<void> {
147
+ const regex = toRegExp(pattern);
148
+ const deadline = Date.now() + timeoutMs;
149
+ while (Date.now() < deadline) {
150
+ if (regex.test(stripped().slice(since))) return;
151
+ await sleep(50);
152
+ }
153
+ throw new Error(
154
+ `PTY waitForSince timeout (${timeoutMs}ms) for ${String(regex)} after offset ${since}\n--- snapshot since mark ---\n${stripped().slice(since).slice(-OUTPUT_TAIL_LENGTH)}`,
155
+ );
156
+ },
157
+ snapshotSince(since: number): string {
158
+ return stripped().slice(since);
159
+ },
160
+ snapshot: stripped,
161
+ raw: (): string => output,
162
+ async expectExit(timeoutMs = DEFAULT_EXIT_TIMEOUT_MS): Promise<number> {
163
+ const deadline = Date.now() + timeoutMs;
164
+ while (Date.now() < deadline) {
165
+ if (exitCode !== undefined) return exitCode;
166
+ await sleep(50);
167
+ }
168
+ throw new Error(
169
+ `PTY process did not exit within ${timeoutMs}ms\n--- snapshot ---\n${stripped().slice(-OUTPUT_TAIL_LENGTH)}`,
170
+ );
171
+ },
172
+ dispose(): void {
173
+ if (exitCode !== undefined) return;
174
+ try {
175
+ pty.kill();
176
+ } catch {
177
+ // allow-fallback: process already exited — kill on a dead pty is a no-op by design
178
+ }
179
+ // CORE-023: mirror the killProcessTree escalation pattern — a child that ignores SIGTERM
180
+ // is force-killed after the grace window. Keep this test-local lifecycle implementation
181
+ // independent of @robota-sdk/agent-process.
182
+ const graceTimer = setTimeout(() => {
183
+ if (exitCode === undefined) {
184
+ try {
185
+ pty.kill('SIGKILL');
186
+ } catch {
187
+ // allow-fallback: process exited during the grace window — SIGKILL is a no-op
188
+ }
189
+ }
190
+ }, PTY_KILL_GRACE_MS);
191
+ graceTimer.unref?.();
192
+ },
193
+ };
194
+ }
195
+
196
+ export interface ISpawnFixtureOptions {
197
+ /** Extra argv passed to the fixture after its path (e.g. an output JSON path). */
198
+ argv?: string[];
199
+ cwd: string;
200
+ env?: NodeJS.ProcessEnv;
201
+ cols?: number;
202
+ rows?: number;
203
+ }
204
+
205
+ let tsxEsmHookPath: string | undefined;
206
+ function resolveTsxEsmHook(): string {
207
+ // Lazily resolved so built-CLI consumers (which never run a TSX fixture) don't require tsx.
208
+ tsxEsmHookPath ??= createRequire(import.meta.url).resolve('tsx/esm');
209
+ return tsxEsmHookPath;
210
+ }
211
+
212
+ /**
213
+ * Spawn a TSX fixture inside a PTY via the `tsx/esm` import hook (no build step). The fixture's own
214
+ * relative imports resolve from its absolute path; `cwd` controls module resolution for bare
215
+ * specifiers (point it at the package dir or repo root).
216
+ */
217
+ export function spawnPtyFixture(
218
+ fixturePath: string,
219
+ options: ISpawnFixtureOptions,
220
+ ): IPtyRunSession {
221
+ return spawnPty({
222
+ command: process.execPath,
223
+ args: ['--import', resolveTsxEsmHook(), fixturePath, ...(options.argv ?? [])],
224
+ cwd: options.cwd,
225
+ ...(options.env ? { env: options.env } : {}),
226
+ ...(options.cols !== undefined ? { cols: options.cols } : {}),
227
+ ...(options.rows !== undefined ? { rows: options.rows } : {}),
228
+ });
229
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * TERM-003 / TEST-007: real-binary terminal-handoff evidence.
3
+ *
4
+ * Runs in the dedicated PTY vitest project (vitest.pty.config.ts) against the BUILT robota binary
5
+ * (`pnpm --filter @robota-sdk/agent-cli build` first). Unlike the source-level fixture E2E
6
+ * (`terminal-handoff-pty-e2e` / `command-handoff-pty-e2e`), this drives the WHOLE user path —
7
+ * real CLI → real command pipeline → real `TerminalHandoffController` injected by `render.tsx` →
8
+ * `/shell` — through a real pseudo-terminal, simulating a user typing. It is automated
9
+ * user-execution-level proof: the child runs on the real terminal during the handoff and the TUI
10
+ * frame restores afterward (machine proxy for "no stale frame / clean resume").
11
+ */
12
+
13
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
+
19
+ import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
20
+
21
+ import type { IPtySession } from './pty-driver.js';
22
+
23
+ describe('terminal handoff through the real binary (TERM-003)', () => {
24
+ let projectDir: string;
25
+ let session: IPtySession | undefined;
26
+
27
+ beforeEach(() => {
28
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-handoff-pty-')));
29
+ writeTuiProviderSettings(projectDir);
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await session?.disposeAsync();
34
+ session = undefined;
35
+ rmSync(projectDir, { recursive: true, force: true });
36
+ });
37
+
38
+ it('TC-09: /shell runs a one-shot command on the real terminal and the TUI restores', async () => {
39
+ session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
40
+
41
+ // Boot.
42
+ await session.waitFor(/Type a message or \/help/);
43
+ await session.waitFor(/Idle/);
44
+
45
+ // Run a one-shot shell command. A user-typed command is not permission-gated (that gate is for
46
+ // model/agent-invoked actions), so it runs directly through the handoff.
47
+ await session.sendKeys('/shell echo HANDOFF_REAL_OK');
48
+ await session.pressEnter();
49
+
50
+ // The child's output appears on the real (inherited) terminal during the handoff.
51
+ await session.waitFor(/HANDOFF_REAL_OK/, 20_000);
52
+
53
+ // The TUI reclaims the screen: the prompt frame redraws (clean resume, no hang).
54
+ await session.waitFor(/Type a message or \/help/, 20_000);
55
+ }, 60_000);
56
+
57
+ it('TC-04/TC-01: committed history (Static) is not re-printed after a /shell handoff (SCREEN-010 × TERM-002)', async () => {
58
+ session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
59
+
60
+ await session.waitFor(/Type a message or \/help/);
61
+ await session.waitFor(/Idle/);
62
+
63
+ // The startup banner is committed to the terminal scrollback via a single Ink <Static> (TC-01).
64
+ // Count its version line; if the handoff resume re-emitted committed history, it would re-print.
65
+ const versionRe = /v\d+\.\d+\.\d+/g;
66
+ const beforeCount = (session.snapshot().match(versionRe) ?? []).length;
67
+ expect(beforeCount).toBeGreaterThanOrEqual(1);
68
+
69
+ await session.sendKeys('/shell echo HANDOFF_OK');
70
+ await session.pressEnter();
71
+ await session.waitFor(/HANDOFF_OK/, 20_000);
72
+ await session.waitFor(/Type a message or \/help/, 20_000);
73
+
74
+ // SCREEN-010 keeps <Static> mounted at a stable tree position across suspend/resume, so the
75
+ // committed banner is NOT re-printed on return (TC-04).
76
+ const afterCount = (session.snapshot().match(versionRe) ?? []).length;
77
+ expect(afterCount).toBe(beforeCount);
78
+ }, 60_000);
79
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Real-PTY TUI suites (CLI-074 TC-07/08).
3
+ *
4
+ * Runs in the dedicated PTY vitest project (vitest.pty.config.ts) against the
5
+ * BUILT robota binary — `pnpm --filter @robota-sdk/agent-cli build` first.
6
+ */
7
+
8
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
13
+
14
+ import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
15
+
16
+ import type { IPtySession } from './pty-driver.js';
17
+
18
+ describe('TUI through a real PTY (CLI-074)', () => {
19
+ let projectDir: string;
20
+ let session: IPtySession | undefined;
21
+
22
+ beforeEach(() => {
23
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-pty-')));
24
+ writeTuiProviderSettings(projectDir);
25
+ });
26
+
27
+ afterEach(async () => {
28
+ await session?.disposeAsync();
29
+ session = undefined;
30
+ rmSync(projectDir, { recursive: true, force: true });
31
+ });
32
+
33
+ it('TC-07: boots, opens slash autocomplete, and executes /help as a command', async () => {
34
+ session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
35
+
36
+ // Boot: prompt + status bar render.
37
+ await session.waitFor(/Type a message or \/help/);
38
+ await session.waitFor(/Idle/);
39
+
40
+ // '/' opens the autocomplete dropdown listing commands.
41
+ await session.sendKeys('/');
42
+ await session.waitFor(/\/help\s+Show available commands/);
43
+
44
+ // Typing the rest at human key rate must stay a command, not a paste.
45
+ await session.sendKeys('help');
46
+ await session.pressEnter();
47
+ await session.waitFor(/Available commands|\/cost|\/clear/i, 20_000);
48
+ expect(session.snapshot()).not.toContain('[Pasted text');
49
+ }, 60_000);
50
+
51
+ it('TC-08: /exit confirms then reaches process exit within 10s', async () => {
52
+ session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
53
+ await session.waitFor(/Type a message or \/help/);
54
+
55
+ await session.sendKeys('/exit');
56
+ await session.pressEnter();
57
+
58
+ // CMD-004: /exit now asks for confirmation; answer Yes (the default-highlighted option).
59
+ await session.waitFor(/Exit the session\?/);
60
+ await session.pressEnter();
61
+
62
+ const exitCode = await session.expectExit(10_000);
63
+ expect(exitCode).toBe(0);
64
+ }, 60_000);
65
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * INFRA-026 TC-01 — `killAndAwaitExit` (the compose behind the PTY driver's `disposeAsync`) awaits
3
+ * ACTUAL child exit and rejects loudly on non-exit. Unit-level with a fake session (no real PTY), so
4
+ * it runs in the default vitest project rather than the `*.ptytest` built-CLI project.
5
+ */
6
+ import { describe, expect, it, vi } from 'vitest';
7
+
8
+ import { killAndAwaitExit } from './pty/pty-driver.js';
9
+
10
+ describe('killAndAwaitExit (INFRA-026 teardown compose)', () => {
11
+ it('signals the child (dispose) then resolves once expectExit resolves', async () => {
12
+ const order: string[] = [];
13
+ const session = {
14
+ dispose: vi.fn(() => {
15
+ order.push('dispose');
16
+ }),
17
+ expectExit: vi.fn(async (): Promise<number> => {
18
+ order.push('expectExit');
19
+ return 0;
20
+ }),
21
+ };
22
+
23
+ await expect(killAndAwaitExit(session, 1000)).resolves.toBeUndefined();
24
+
25
+ expect(session.dispose).toHaveBeenCalledTimes(1);
26
+ expect(session.expectExit).toHaveBeenCalledWith(1000);
27
+ // dispose must be signalled BEFORE we await exit.
28
+ expect(order).toEqual(['dispose', 'expectExit']);
29
+ });
30
+
31
+ it('rejects (does not swallow) when the child does not exit within the timeout', async () => {
32
+ const session = {
33
+ dispose: vi.fn(),
34
+ expectExit: vi.fn(async (): Promise<number> => {
35
+ throw new Error('PTY process did not exit within 1000ms');
36
+ }),
37
+ };
38
+
39
+ await expect(killAndAwaitExit(session, 1000)).rejects.toThrow(/did not exit/);
40
+ // Still signalled the child — the failure is a loud teardown defect, not a masked one.
41
+ expect(session.dispose).toHaveBeenCalledTimes(1);
42
+ });
43
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * CLI-062 — real-terminal-cursor positioning through ink's actual interactive render path.
3
+ *
4
+ * Renders CjkTextInput inside a TransportTUI-shaped harness against a fake TTY stdout
5
+ * (`interactive: true`), then interprets the raw ANSI stream with the same VT logic a terminal
6
+ * emulator (and the OS IME) applies. This is the component-level half of the CLI-062 contract
7
+ * (.design/investigations/2026-07-25-cli-062-ime-cursor-design.md): the hardware cursor must be
8
+ * shown ON the input row at the composition column, track CJK width growth, disappear when the
9
+ * input is unfocused (I4), propagate `undefined` on unmount (I4), and the production code must
10
+ * write NOTHING to the real process streams (the PoC row-accounting lesson, I3).
11
+ *
12
+ * The host override opts the capability gate in explicitly — the vitest process has no TTY,
13
+ * so the default gate would (correctly) refuse.
14
+ */
15
+
16
+ import { EventEmitter } from 'node:events';
17
+
18
+ import chalk from 'chalk';
19
+ import { Box, Text, render } from 'ink';
20
+ import React, { useState } from 'react';
21
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
22
+
23
+ import { interpretVtStream } from './helpers/vt-cursor-interpreter.js';
24
+ import CjkTextInput from '../CjkTextInput.js';
25
+ import { TerminalCapabilitiesProvider } from '../terminal-capabilities-context.js';
26
+
27
+ const COLS = 60;
28
+ const ROWS = 24;
29
+ const PROMPT = '> ';
30
+
31
+ class FakeTtyStdout extends EventEmitter {
32
+ readonly isTTY = true;
33
+ readonly columns = COLS;
34
+ readonly rows = ROWS;
35
+ private chunks: string[] = [];
36
+ write = (chunk: string, callback?: () => void): boolean => {
37
+ this.chunks.push(String(chunk));
38
+ callback?.();
39
+ return true;
40
+ };
41
+ stream(): string {
42
+ return this.chunks.join('');
43
+ }
44
+ }
45
+
46
+ class FakeStdin extends EventEmitter {
47
+ readonly isTTY = true;
48
+ private pending: string | null = null;
49
+ setEncoding(): void {}
50
+ setRawMode(): void {}
51
+ resume(): void {}
52
+ pause(): void {}
53
+ ref(): void {}
54
+ unref(): void {}
55
+ read(): string | null {
56
+ const data = this.pending;
57
+ this.pending = null;
58
+ return data;
59
+ }
60
+ send(data: string): void {
61
+ this.pending = data;
62
+ this.emit('readable');
63
+ this.emit('data', data);
64
+ }
65
+ }
66
+
67
+ function Harness({
68
+ focus = true,
69
+ bannerLines = 1,
70
+ }: {
71
+ focus?: boolean;
72
+ /** Rows rendered above the input — the layout the measured y must follow. */
73
+ bannerLines?: number;
74
+ }): React.ReactElement {
75
+ const [value, setValue] = useState('');
76
+ return (
77
+ <Box flexDirection="column">
78
+ {Array.from({ length: bannerLines }, (_, index) => (
79
+ <Text key={index}>banner line {index}</Text>
80
+ ))}
81
+ <Box>
82
+ <Text>{PROMPT}</Text>
83
+ <CjkTextInput value={value} onChange={setValue} availableWidth={COLS - 2} focus={focus} />
84
+ </Box>
85
+ <Text>status line</Text>
86
+ </Box>
87
+ );
88
+ }
89
+
90
+ interface IScenario {
91
+ stdout: FakeTtyStdout;
92
+ stdin: FakeStdin;
93
+ unmount: () => void;
94
+ }
95
+
96
+ function renderScenario(focus?: boolean, bannerLines?: number): IScenario {
97
+ const stdout = new FakeTtyStdout();
98
+ const stdin = new FakeStdin();
99
+ const instance = render(
100
+ <TerminalCapabilitiesProvider overrides={{ imeCursorPositioning: true }}>
101
+ <Harness
102
+ {...(focus === undefined ? {} : { focus })}
103
+ {...(bannerLines === undefined ? {} : { bannerLines })}
104
+ />
105
+ </TerminalCapabilitiesProvider>,
106
+ {
107
+ // Cast: ink wants real process streams; the fakes implement the parts its render loop uses.
108
+ stdout: stdout as unknown as NodeJS.WriteStream,
109
+ stdin: stdin as unknown as NodeJS.ReadStream,
110
+ interactive: true,
111
+ exitOnCtrlC: false,
112
+ patchConsole: false,
113
+ },
114
+ );
115
+ return { stdout, stdin, unmount: instance.unmount };
116
+ }
117
+
118
+ /** Ink throttles frames (≤30fps) and React effects settle asynchronously — allow both to flush. */
119
+ const settle = (): Promise<void> => new Promise((r) => setTimeout(r, 120));
120
+
121
+ const INPUT_ROW = 1; // banner is row 0, input row is row 1 in the harness frame
122
+
123
+ describe('CLI-062 — real cursor positioning (interactive ink render)', () => {
124
+ const ORIGINAL_CHALK_LEVEL = chalk.level;
125
+
126
+ beforeAll(() => {
127
+ chalk.level = 3; // make the drawn inverse cursor observable in bytes, so suppression is provable
128
+ });
129
+
130
+ afterAll(() => {
131
+ vi.unstubAllEnvs();
132
+ chalk.level = ORIGINAL_CHALK_LEVEL;
133
+ });
134
+
135
+ afterEach(() => {
136
+ vi.restoreAllMocks();
137
+ });
138
+
139
+ it('shows the hardware cursor on the input row at the composition column and tracks CJK growth', async () => {
140
+ const stdoutSpy = vi.spyOn(process.stdout, 'write');
141
+ const stderrSpy = vi.spyOn(process.stderr, 'write');
142
+
143
+ const { stdout, stdin, unmount } = renderScenario();
144
+ await settle();
145
+
146
+ stdin.send('안녕');
147
+ await settle();
148
+
149
+ const afterTwo = interpretVtStream(stdout.stream(), ROWS, COLS);
150
+ const showsAfterTwo = afterTwo.showEvents;
151
+ expect(showsAfterTwo.length).toBeGreaterThan(0);
152
+ // Every show lands on the input row (never the banner/top region — the crash geometry).
153
+ for (const event of showsAfterTwo) {
154
+ expect(event.row).toBe(INPUT_ROW);
155
+ }
156
+ // Composition column: '> ' (2 cols) + 안녕 (2 wide chars = 4 cols).
157
+ expect(showsAfterTwo.at(-1)).toMatchObject({ row: INPUT_ROW, col: 6 });
158
+
159
+ stdin.send('하'); // one more wide char → +2 columns
160
+ await settle();
161
+
162
+ const afterThree = interpretVtStream(stdout.stream(), ROWS, COLS);
163
+ expect(afterThree.showEvents.at(-1)).toMatchObject({ row: INPUT_ROW, col: 8 });
164
+
165
+ // Drawn-cursor suppression: before the first measurement the fallback legitimately draws the
166
+ // inverse block (I1: no guessed rows), but from the first positioned show onward the hardware
167
+ // cursor and the inverse block must never coexist.
168
+ const firstShowOffset = afterThree.showEvents[0]!.offset;
169
+ expect(stdout.stream().slice(firstShowOffset)).not.toContain('\u001b[7m');
170
+
171
+ // I3/PoC lesson: the production path writes NOTHING to the real process streams.
172
+ expect(stdoutSpy).not.toHaveBeenCalled();
173
+ expect(stderrSpy).not.toHaveBeenCalled();
174
+
175
+ unmount();
176
+ });
177
+
178
+ /**
179
+ * SIGSEGV regression guard, as a test rather than an argument.
180
+ *
181
+ * The Terminal.app Korean-IME crash was root-caused to `setCursorPosition({x, y: 0})` — a
182
+ * CONSTANT row that pointed the hardware cursor at the logo area. The mechanical statement that
183
+ * the bug class is gone is not "the literal is absent from the source" but "the emitted row is a
184
+ * function of the layout": move the input box down and the positioned row must move with it. A
185
+ * hardcoded `y: 0` — or any other constant — cannot satisfy both halves of this test.
186
+ */
187
+ it('SIGSEGV guard: the positioned row FOLLOWS the layout, so a constant y (the y:0 bug) is unrepresentable', async () => {
188
+ const rowsSeen: number[] = [];
189
+ for (const bannerLines of [1, 4]) {
190
+ const { stdout, stdin, unmount } = renderScenario(true, bannerLines);
191
+ await settle();
192
+ stdin.send('안녕');
193
+ await settle();
194
+
195
+ const vt = interpretVtStream(stdout.stream(), ROWS, COLS);
196
+ expect(vt.showEvents.length, `banner=${bannerLines}`).toBeGreaterThan(0);
197
+ const row = vt.showEvents.at(-1)!.row;
198
+ // The input box starts immediately below the banner, so its measured row IS bannerLines.
199
+ expect(row, `banner=${bannerLines}`).toBe(bannerLines);
200
+ // ...and never the top of the frame while any banner occupies it (the crash geometry).
201
+ expect(row, `banner=${bannerLines}`).toBeGreaterThan(0);
202
+ rowsSeen.push(row);
203
+ unmount();
204
+ await settle();
205
+ }
206
+ // Two different layouts must yield two different rows — a constant cannot pass both.
207
+ expect(new Set(rowsSeen).size).toBe(2);
208
+ });
209
+
210
+ it('I4: unfocused input never positions the cursor (and keeps the fallback drawn-cursor path off)', async () => {
211
+ const { stdout, stdin, unmount } = renderScenario(false);
212
+ await settle();
213
+ stdin.send('x'); // ignored — not focused
214
+ await settle();
215
+
216
+ const vt = interpretVtStream(stdout.stream(), ROWS, COLS);
217
+ expect(vt.showEvents).toEqual([]);
218
+ unmount();
219
+ });
220
+
221
+ it('I4: blur withdraws the position (no shows after focus loss) and unmount leaves the cursor visible', async () => {
222
+ const stdout = new FakeTtyStdout();
223
+ const stdin = new FakeStdin();
224
+ const instance = render(
225
+ <TerminalCapabilitiesProvider overrides={{ imeCursorPositioning: true }}>
226
+ <Harness />
227
+ </TerminalCapabilitiesProvider>,
228
+ {
229
+ stdout: stdout as unknown as NodeJS.WriteStream,
230
+ stdin: stdin as unknown as NodeJS.ReadStream,
231
+ interactive: true,
232
+ exitOnCtrlC: false,
233
+ patchConsole: false,
234
+ },
235
+ );
236
+ await settle();
237
+ stdin.send('안');
238
+ await settle();
239
+ expect(interpretVtStream(stdout.stream(), ROWS, COLS).showEvents.length).toBeGreaterThan(0);
240
+
241
+ // Blur: the guard fails → setCursorPosition(undefined) → ink hides the hardware cursor and
242
+ // emits NO further positioned shows (the I4 fallback path).
243
+ instance.rerender(
244
+ <TerminalCapabilitiesProvider overrides={{ imeCursorPositioning: true }}>
245
+ <Harness focus={false} />
246
+ </TerminalCapabilitiesProvider>,
247
+ );
248
+ await settle();
249
+ const afterBlurMark = stdout.stream().length;
250
+ stdin.send('x'); // ignored — not focused; also must not resurrect the cursor
251
+ await settle();
252
+ const blurShows = interpretVtStream(stdout.stream(), ROWS, COLS).showEvents.filter(
253
+ (event) => event.offset >= afterBlurMark,
254
+ );
255
+ expect(blurShows).toEqual([]);
256
+
257
+ // Unmount: useCursor's cleanup propagates `undefined`; ink's teardown restores a bare,
258
+ // visible cursor (never a freshly positioned one).
259
+ instance.unmount();
260
+ await settle();
261
+ expect(interpretVtStream(stdout.stream(), ROWS, COLS).visible).toBe(true);
262
+ });
263
+ });