@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.
- package/LICENSE +661 -0
- package/README.md +27 -0
- package/dist/node/index.cjs +32 -0
- package/dist/node/index.d.cts +1066 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1066 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +33 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +93 -0
- package/src/App.tsx +146 -0
- package/src/AppPresentation.tsx +190 -0
- package/src/AppView.tsx +26 -0
- package/src/BackgroundTaskPanel.tsx +73 -0
- package/src/CjkTextInput.tsx +336 -0
- package/src/ConfirmPrompt.tsx +140 -0
- package/src/ContextWarningBanner.tsx +45 -0
- package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
- package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
- package/src/HistorySearchOverlay.tsx +150 -0
- package/src/InputArea.tsx +338 -0
- package/src/ListPicker.tsx +158 -0
- package/src/MenuSelect.tsx +159 -0
- package/src/MessageList.tsx +308 -0
- package/src/MultiSelectList.tsx +294 -0
- package/src/PendingActionPrompt.tsx +121 -0
- package/src/PermissionPrompt.tsx +186 -0
- package/src/PluginTUI.tsx +258 -0
- package/src/RoleLabel.tsx +72 -0
- package/src/SafeText.tsx +66 -0
- package/src/SessionEventNotices.tsx +27 -0
- package/src/SessionPicker.tsx +72 -0
- package/src/SessionStatusBar.tsx +76 -0
- package/src/SlashAutocomplete.tsx +154 -0
- package/src/StatusBar.tsx +284 -0
- package/src/StreamingIndicator.tsx +145 -0
- package/src/SupervisedSessionView.tsx +455 -0
- package/src/TextPrompt.tsx +112 -0
- package/src/ThemePicker.tsx +366 -0
- package/src/ToolCommandOutput.tsx +48 -0
- package/src/ToolDiffBlock.tsx +44 -0
- package/src/TransportTUI.tsx +166 -0
- package/src/TuiInteractionChannel.ts +567 -0
- package/src/UpdateNotice.tsx +18 -0
- package/src/UsageSummaryEntry.tsx +49 -0
- package/src/WaveText.tsx +70 -0
- package/src/__tests__/ListPicker.test.tsx +188 -0
- package/src/__tests__/MenuSelect.test.tsx +127 -0
- package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
- package/src/__tests__/PluginTUI.test.tsx +167 -0
- package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
- package/src/__tests__/SupervisedSessionView-render.test.tsx +24 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +629 -0
- package/src/__tests__/TextPrompt.test.tsx +112 -0
- package/src/__tests__/TransportTUI.test.tsx +124 -0
- package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +284 -0
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +642 -0
- package/src/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/__tests__/abort-after-permission.test.tsx +160 -0
- package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
- package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
- package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
- package/src/__tests__/background-task-panel.test.tsx +98 -0
- package/src/__tests__/background-task-row-format.test.ts +152 -0
- package/src/__tests__/channel-factory-integration.test.ts +150 -0
- package/src/__tests__/cjk-defer-submit.test.tsx +67 -0
- package/src/__tests__/cjk-fallback-render.test.tsx +78 -0
- package/src/__tests__/cjk-text-input-flow.test.ts +186 -0
- package/src/__tests__/cjk-text-input.test.ts +191 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
- package/src/__tests__/command-output-summary.test.ts +95 -0
- package/src/__tests__/command-result-handler.test.ts +171 -0
- package/src/__tests__/compact-event-bridge.test.ts +20 -0
- package/src/__tests__/confirm-permission-flow.test.ts +149 -0
- package/src/__tests__/confirm-prompt.test.tsx +103 -0
- package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +45 -0
- package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
- package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
- package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
- package/src/__tests__/external-prompt-notice.test.tsx +100 -0
- package/src/__tests__/fixtures/command-handoff-driver.tsx +111 -0
- package/src/__tests__/fixtures/fake-editor.sh +7 -0
- package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
- package/src/__tests__/fork-attach.test.tsx +235 -0
- package/src/__tests__/helpers/terminal-profiles.ts +152 -0
- package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
- package/src/__tests__/history-clear-broadcast.test.ts +66 -0
- package/src/__tests__/history-search-overlay.test.tsx +329 -0
- package/src/__tests__/humanize-tool-name.test.ts +30 -0
- package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
- package/src/__tests__/input-area-flow.test.ts +199 -0
- package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
- package/src/__tests__/interaction-channel-charter.test.ts +13 -0
- package/src/__tests__/key-hint-consistency.test.tsx +249 -0
- package/src/__tests__/key-hint-footer.test.tsx +69 -0
- package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
- package/src/__tests__/message-list-rendering.test.tsx +473 -0
- package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
- package/src/__tests__/output-style-reaches-session.test.ts +35 -0
- package/src/__tests__/palette-consistency.test.ts +330 -0
- package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
- package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
- package/src/__tests__/product-display-name.test.tsx +79 -0
- package/src/__tests__/prompt-queue.test.tsx +260 -0
- package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
- package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
- package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +342 -0
- package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
- package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
- package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
- package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
- package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
- package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +291 -0
- package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +165 -0
- package/src/__tests__/pty/ime-cursor.ptytest.ts +186 -0
- package/src/__tests__/pty/isolated-home.ts +64 -0
- package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
- package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
- package/src/__tests__/pty/pty-driver.ts +153 -0
- package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +134 -0
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +138 -0
- package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
- package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
- package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
- package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
- package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
- package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
- package/src/__tests__/pty/spawn-pty.test.ts +120 -0
- package/src/__tests__/pty/spawn-pty.ts +229 -0
- package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
- package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
- package/src/__tests__/pty-driver-dispose.test.ts +43 -0
- package/src/__tests__/real-cursor-positioning.test.tsx +263 -0
- package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
- package/src/__tests__/render-channel-options.test.ts +201 -0
- package/src/__tests__/render-lifecycle.test.ts +36 -0
- package/src/__tests__/render-markdown.test.ts +164 -0
- package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
- package/src/__tests__/safe-text-boundary.test.tsx +75 -0
- package/src/__tests__/screen-reader-input.test.tsx +106 -0
- package/src/__tests__/screen-reader-labels.test.ts +91 -0
- package/src/__tests__/screen-reader-menus.test.tsx +200 -0
- package/src/__tests__/screen-reader-pacing.test.ts +230 -0
- package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
- package/src/__tests__/screen-reader-render-options.test.ts +262 -0
- package/src/__tests__/screen-reader-stdout.test.ts +322 -0
- package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
- package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
- package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
- package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
- package/src/__tests__/selection-flow.test.ts +61 -0
- package/src/__tests__/session-event-notices.test.tsx +31 -0
- package/src/__tests__/session-init-poller.test.ts +102 -0
- package/src/__tests__/session-switch-channel.test.tsx +470 -0
- package/src/__tests__/shutdown-signal.test.ts +34 -0
- package/src/__tests__/status-activity.test.ts +71 -0
- package/src/__tests__/status-bar.test.tsx +282 -0
- package/src/__tests__/status-glyph.test.ts +30 -0
- package/src/__tests__/streaming-indicator.test.tsx +177 -0
- package/src/__tests__/terminal-capabilities.test.ts +153 -0
- package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
- package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
- package/src/__tests__/text-prompt-flow.test.ts +77 -0
- package/src/__tests__/theme-picker.test.tsx +345 -0
- package/src/__tests__/transport-boundary.test.ts +11 -0
- package/src/__tests__/tui-app-channel-port.test.ts +31 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +62 -0
- package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
- package/src/__tests__/tui-interaction-queues.test.ts +139 -0
- package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
- package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
- package/src/__tests__/tui-state-manager.test.ts +451 -0
- package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
- package/src/__tests__/wave-text.test.tsx +133 -0
- package/src/app-banner.tsx +42 -0
- package/src/app-static-items.ts +35 -0
- package/src/app-view-model.ts +136 -0
- package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
- package/src/attention/__tests__/attention-tracker.test.ts +103 -0
- package/src/attention/__tests__/countdown.test.ts +15 -0
- package/src/attention/__tests__/focus-input-filter.test.ts +92 -0
- package/src/attention/__tests__/interval-recap.test.ts +49 -0
- package/src/attention/attention-coordinator.ts +71 -0
- package/src/attention/attention-tracker.ts +124 -0
- package/src/attention/countdown.ts +24 -0
- package/src/attention/focus-input-filter.ts +131 -0
- package/src/attention/interval-recap.ts +148 -0
- package/src/attention/time-units.ts +6 -0
- package/src/attention-bell.ts +79 -0
- package/src/attributed-user-echo.ts +30 -0
- package/src/background-task-row-format.ts +99 -0
- package/src/command-interaction.ts +8 -0
- package/src/command-output-summary.ts +129 -0
- package/src/create-default-tui-cli-adapter.ts +45 -0
- package/src/execution-workspace-switcher-selection.ts +118 -0
- package/src/execution-workspace-view-model.ts +139 -0
- package/src/external-prompt-notice.tsx +38 -0
- package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
- package/src/flows/__tests__/defer-submit.test.ts +87 -0
- package/src/flows/__tests__/real-cursor-flow.test.ts +124 -0
- package/src/flows/background-focus-flow.ts +44 -0
- package/src/flows/cjk-cursor-motion.ts +75 -0
- package/src/flows/cjk-text-deletion.ts +53 -0
- package/src/flows/cjk-text-input-flow.ts +248 -0
- package/src/flows/cjk-text-input-types.ts +24 -0
- package/src/flows/confirm-prompt-flow.ts +90 -0
- package/src/flows/defer-submit.ts +67 -0
- package/src/flows/fork-attach-flow.ts +62 -0
- package/src/flows/input-area-flow.ts +188 -0
- package/src/flows/permission-prompt-flow.ts +108 -0
- package/src/flows/real-cursor-flow.ts +83 -0
- package/src/flows/selection-flow.ts +205 -0
- package/src/flows/session-init-poller.ts +76 -0
- package/src/flows/text-prompt-flow.ts +93 -0
- package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
- package/src/history-search/history-search-flow.ts +94 -0
- package/src/history-search/useHistorySearch.ts +336 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +74 -0
- package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
- package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
- package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
- package/src/hooks/command-result-handler.ts +44 -0
- package/src/hooks/side-effects-types.ts +45 -0
- package/src/hooks/useAppController.ts +267 -0
- package/src/hooks/useAppInputBindings.ts +145 -0
- package/src/hooks/useAppInteractionState.ts +81 -0
- package/src/hooks/useAppLifecycleState.ts +81 -0
- package/src/hooks/useAppOverlays.ts +74 -0
- package/src/hooks/useAppScreenState.ts +81 -0
- package/src/hooks/useAppSubmissionState.ts +45 -0
- package/src/hooks/useAppThemeState.ts +157 -0
- package/src/hooks/useAppWorkspaceState.ts +149 -0
- package/src/hooks/useAppearanceSettings.ts +30 -0
- package/src/hooks/useAutocomplete.ts +101 -0
- package/src/hooks/useBackgroundPanel.ts +58 -0
- package/src/hooks/useCountdownTick.ts +24 -0
- package/src/hooks/useExecutionDetailPage.ts +69 -0
- package/src/hooks/useForkAttach.ts +60 -0
- package/src/hooks/useInputAreaHistorySearch.ts +72 -0
- package/src/hooks/useInputAreaKeys.ts +114 -0
- package/src/hooks/useNumberedSelection.ts +76 -0
- package/src/hooks/usePluginCallbacks.ts +38 -0
- package/src/hooks/usePluginScreenData.ts +85 -0
- package/src/hooks/useRealCursorPosition.ts +125 -0
- package/src/hooks/useRenderMarkdown.ts +38 -0
- package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
- package/src/hooks/useSideEffects.ts +144 -0
- package/src/hooks/useStatusLineSettings.ts +50 -0
- package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
- package/src/hooks/useTuiChannel.ts +129 -0
- package/src/humanize-tool-name.ts +51 -0
- package/src/index.ts +56 -0
- package/src/input-area-rules.tsx +71 -0
- package/src/json-value.ts +18 -0
- package/src/key-hint-footer.tsx +54 -0
- package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
- package/src/keybindings/__tests__/node-keybindings-source.test.ts +111 -0
- package/src/keybindings/keybinding-catalogue.ts +171 -0
- package/src/keybindings/keybinding-registry.ts +303 -0
- package/src/keybindings/keybinding-syntax.ts +155 -0
- package/src/keybindings/keybindings-context.tsx +124 -0
- package/src/keybindings/node-keybindings-source.ts +189 -0
- package/src/numbered-list.tsx +113 -0
- package/src/plugin-tui-handlers.ts +163 -0
- package/src/product-display-name-context.tsx +30 -0
- package/src/render-markdown.ts +209 -0
- package/src/render.tsx +544 -0
- package/src/sanitize-terminal-text.ts +171 -0
- package/src/screen-reader-announcement.ts +52 -0
- package/src/screen-reader-context.tsx +32 -0
- package/src/screen-reader-labels.ts +64 -0
- package/src/screen-reader-pacing-context.tsx +47 -0
- package/src/screen-reader-pacing.ts +178 -0
- package/src/screen-reader-stdout.ts +328 -0
- package/src/shutdown-signal.ts +34 -0
- package/src/status-activity.ts +70 -0
- package/src/status-glyph.ts +78 -0
- package/src/terminal-capabilities-context.tsx +27 -0
- package/src/terminal-capabilities.ts +77 -0
- package/src/terminal-focus-reporting.ts +50 -0
- package/src/terminal-handoff-controller.ts +98 -0
- package/src/terminal-marks.ts +63 -0
- package/src/theme/__tests__/built-in-themes.test.ts +117 -0
- package/src/theme/__tests__/color-vision.test.ts +82 -0
- package/src/theme/__tests__/theme-context.test.tsx +146 -0
- package/src/theme/__tests__/theme-document.test.ts +222 -0
- package/src/theme/__tests__/theme-registry.test.ts +138 -0
- package/src/theme/__tests__/theme-styles.test.ts +233 -0
- package/src/theme/built-in-themes.ts +342 -0
- package/src/theme/color-vision.ts +171 -0
- package/src/theme/index.ts +45 -0
- package/src/theme/theme-context.tsx +81 -0
- package/src/theme/theme-contracts.ts +139 -0
- package/src/theme/theme-document.ts +278 -0
- package/src/theme/theme-registry.ts +127 -0
- package/src/theme/theme-styles.ts +177 -0
- package/src/tool-summary-status.ts +59 -0
- package/src/tui-app-channel-port.ts +83 -0
- package/src/tui-channel-lifecycle-coordinator.ts +149 -0
- package/src/tui-channel-options.ts +172 -0
- package/src/tui-cli-adapter-context.tsx +13 -0
- package/src/tui-cli-adapter.ts +24 -0
- package/src/tui-interaction-queues.ts +163 -0
- package/src/tui-session-binding.ts +52 -0
- package/src/tui-session-event-projector.ts +140 -0
- package/src/tui-session-events.ts +82 -0
- package/src/tui-session-notice-store.ts +43 -0
- package/src/tui-session-options.ts +118 -0
- package/src/tui-state-manager.ts +327 -0
- package/src/types.ts +19 -0
- package/src/use-terminal-title.ts +29 -0
- package/src/utils/__tests__/edit-diff.test.ts +426 -0
- package/src/utils/__tests__/paste-detection.test.ts +116 -0
- package/src/utils/__tests__/paste-labels.test.ts +46 -0
- package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
- package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
- package/src/utils/edit-diff.ts +151 -0
- package/src/utils/input-top-border.ts +19 -0
- package/src/utils/paste-labels.ts +9 -0
- package/src/utils/tool-call-extractor.ts +92 -0
- 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
|
+
});
|