@robota-sdk/agent-ui-terminal 3.0.0-beta.100
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/CHANGELOG.md +1204 -0
- package/LICENSE +661 -0
- package/README.md +50 -0
- package/dist/node/index.d.ts +1224 -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 +88 -0
- package/src/App.tsx +157 -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 +236 -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 +92 -0
- package/src/SessionStatusBar.tsx +80 -0
- package/src/SlashAutocomplete.tsx +154 -0
- package/src/StatusBar.tsx +308 -0
- package/src/StreamingIndicator.tsx +145 -0
- package/src/SupervisedSessionView.tsx +872 -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 +530 -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 +52 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +1112 -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 +294 -0
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +650 -0
- package/src/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/__tests__/__fixtures__/korean-text.json +17 -0
- package/src/__tests__/abort-after-permission.test.tsx +160 -0
- package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/__tests__/attached-app-session-switch.test.tsx +317 -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__/checkpoint-store-per-channel.test.ts +81 -0
- package/src/__tests__/cjk-defer-submit.test.tsx +68 -0
- package/src/__tests__/cjk-fallback-render.test.tsx +89 -0
- package/src/__tests__/cjk-text-input-flow.test.ts +187 -0
- package/src/__tests__/cjk-text-input.test.ts +196 -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 +47 -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 +114 -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__/grant-history-reaches-the-session.test.ts +31 -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 +474 -0
- package/src/__tests__/numbered-selection-chunk.test.tsx +41 -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 +329 -0
- package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
- package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
- package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
- package/src/__tests__/permission-prompt-requester.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 +350 -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 +320 -0
- package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +211 -0
- package/src/__tests__/pty/ime-cursor.ptytest.ts +187 -0
- package/src/__tests__/pty/isolated-home.ts +97 -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 +155 -0
- package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +135 -0
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +140 -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/session-attach.ptytest.ts +182 -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/trust-prompt.ptytest.ts +134 -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 +264 -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__/sandbox-reaches-the-session.test.ts +37 -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-picker.test.tsx +93 -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 +290 -0
- package/src/__tests__/status-glyph.test.ts +30 -0
- package/src/__tests__/streaming-indicator.test.tsx +178 -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 +70 -0
- package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
- package/src/__tests__/tui-interaction-queues.test.ts +149 -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 +482 -0
- package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
- package/src/__tests__/wave-text.test.tsx +133 -0
- package/src/__tests__/wire-tui-channel.test.ts +1457 -0
- package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
- package/src/app-banner.tsx +42 -0
- package/src/app-static-items.ts +35 -0
- package/src/app-view-model.ts +140 -0
- package/src/attached-session-connection.ts +14 -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 +93 -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 +88 -0
- package/src/flows/__tests__/real-cursor-flow.test.ts +139 -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-detach-key.test.tsx +72 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +87 -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 +271 -0
- package/src/hooks/useAppInputBindings.ts +163 -0
- package/src/hooks/useAppInteractionState.ts +84 -0
- package/src/hooks/useAppLifecycleState.ts +81 -0
- package/src/hooks/useAppOverlays.ts +90 -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 +93 -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 +141 -0
- package/src/humanize-tool-name.ts +51 -0
- package/src/index.ts +72 -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 +118 -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 +746 -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/short-session-id.ts +9 -0
- package/src/shutdown-signal.ts +34 -0
- package/src/slash-command-input.ts +11 -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 +111 -0
- package/src/tui-channel-lifecycle-coordinator.ts +149 -0
- package/src/tui-channel-options.ts +184 -0
- package/src/tui-cli-adapter-context.tsx +13 -0
- package/src/tui-cli-adapter.ts +24 -0
- package/src/tui-interaction-queues.ts +171 -0
- package/src/tui-session-binding.ts +52 -0
- package/src/tui-session-event-projector.ts +149 -0
- package/src/tui-session-events.ts +89 -0
- package/src/tui-session-notice-store.ts +43 -0
- package/src/tui-session-options.ts +134 -0
- package/src/tui-state-manager.ts +347 -0
- package/src/types.ts +21 -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
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +963 -0
- package/src/wire-tui-client-commands.ts +142 -0
- package/src/wire-tui-projection.ts +136 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLOW-2006 TC-09 — the three user-execution scenarios on the BUILT binary.
|
|
3
|
+
*
|
|
4
|
+
* Scenario 1 runs in a real PTY: a link opens the trusted fixture repository with the prompt in the
|
|
5
|
+
* composer, unsent, labelled as external, and only Enter submits it. Scenarios 2 and 3 are process
|
|
6
|
+
* runs: every malformed or configuration-bearing link, and every untrusted or unrecorded target, is
|
|
7
|
+
* refused on stderr with a non-zero exit and no session.
|
|
8
|
+
*
|
|
9
|
+
* Runs under `test:pty` against the built CLI (`pnpm --filter @robota-sdk/agent-cli build` first).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import {
|
|
15
|
+
mkdirSync,
|
|
16
|
+
mkdtempSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
realpathSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { dirname, join, resolve } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
28
|
+
|
|
29
|
+
import { spawnTui } from './pty-driver.js';
|
|
30
|
+
|
|
31
|
+
import type { IPtySession } from './pty-driver.js';
|
|
32
|
+
|
|
33
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..');
|
|
34
|
+
const AGENT_BIN = join(REPO_ROOT, 'packages/agent-cli/bin/agent.cjs');
|
|
35
|
+
const CLI_NAME = 'fixture-agent';
|
|
36
|
+
const PROTOCOL_SCHEME = 'fixture-agent';
|
|
37
|
+
const WAIT_MS = 20_000;
|
|
38
|
+
const CASE_TIMEOUT_MS = 120_000;
|
|
39
|
+
const PROMPT = 'Summarize the README in one sentence';
|
|
40
|
+
|
|
41
|
+
interface IFixture {
|
|
42
|
+
root: string;
|
|
43
|
+
home: string;
|
|
44
|
+
trusted: string;
|
|
45
|
+
untrusted: string;
|
|
46
|
+
elsewhere: string;
|
|
47
|
+
userStateRoot: string;
|
|
48
|
+
env: NodeJS.ProcessEnv;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeProviderSettings(home: string): void {
|
|
52
|
+
const dir = join(home, 'state');
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
// Skip the first-run welcome: it is an onboarding screen, not what this scenario observes.
|
|
55
|
+
writeFileSync(join(dir, 'onboarded'), new Date().toISOString(), 'utf8');
|
|
56
|
+
writeFileSync(
|
|
57
|
+
join(dir, 'settings.json'),
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
currentProvider: 'anthropic',
|
|
60
|
+
providers: {
|
|
61
|
+
anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
'utf8',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function initRepo(path: string, env: NodeJS.ProcessEnv): void {
|
|
69
|
+
mkdirSync(path, { recursive: true });
|
|
70
|
+
const run = (...args: string[]): void => {
|
|
71
|
+
execFileSync('git', args, { cwd: path, env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
72
|
+
};
|
|
73
|
+
run('init', '-q', '-b', 'main');
|
|
74
|
+
run('config', 'user.name', 'Scenario Fixture');
|
|
75
|
+
run('config', 'user.email', 'scenario@example.invalid');
|
|
76
|
+
writeFileSync(join(path, 'README.md'), 'fixture\n');
|
|
77
|
+
run('add', '--', 'README.md');
|
|
78
|
+
run('commit', '-q', '-m', 'chore: fixture');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function makeFixture(): IFixture {
|
|
82
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), 'agent-flow2006-')));
|
|
83
|
+
const home = join(root, 'home');
|
|
84
|
+
mkdirSync(home, { recursive: true });
|
|
85
|
+
writeProviderSettings(home);
|
|
86
|
+
const userStateRoot = join(home, 'state');
|
|
87
|
+
const env: NodeJS.ProcessEnv = {
|
|
88
|
+
PATH: process.env['PATH'] ?? '',
|
|
89
|
+
HOME: home,
|
|
90
|
+
PRODUCT_ID: CLI_NAME,
|
|
91
|
+
PRODUCT_DISPLAY_NAME: 'Fixture Agent',
|
|
92
|
+
PRODUCT_CLI_NAME: CLI_NAME,
|
|
93
|
+
PRODUCT_ENV_PREFIX: 'FIXTURE_AGENT_',
|
|
94
|
+
PRODUCT_PACKAGE_SCOPE: '@fixture-agent',
|
|
95
|
+
PRODUCT_APP_ID: 'org.example.fixture-agent',
|
|
96
|
+
PRODUCT_PROTOCOL_SCHEME: PROTOCOL_SCHEME,
|
|
97
|
+
PRODUCT_TELEMETRY_SERVICE_NAME: 'fixture-agent',
|
|
98
|
+
PRODUCT_DAEMON_NAMESPACE: 'fixture-agent-daemon',
|
|
99
|
+
PRODUCT_DESKTOP_EXECUTABLE: 'fixture-robota',
|
|
100
|
+
PRODUCT_MCP_CLIENT_NAME: 'fixture-agent-client',
|
|
101
|
+
PRODUCT_MODEL_TOOL_PREFIX: 'fixture_agent_command_',
|
|
102
|
+
PRODUCT_PROMPT_TAG: 'fixture_agent_references',
|
|
103
|
+
PRODUCT_EDITOR_TEMP_PREFIX: 'fixture-agent-editor-',
|
|
104
|
+
PRODUCT_USER_STATE_DIR: userStateRoot,
|
|
105
|
+
PRODUCT_PROJECT_STATE_DIR: '.fixture-agent',
|
|
106
|
+
PRODUCT_CACHE_DIR: join(userStateRoot, 'cache'),
|
|
107
|
+
PRODUCT_LOG_DIR: join(userStateRoot, 'logs'),
|
|
108
|
+
PRODUCT_BROWSER_NAMESPACE: 'fixture-agent-browser',
|
|
109
|
+
PRODUCT_BROWSER_CREDENTIAL_DATABASE: 'fixture-agent-credentials',
|
|
110
|
+
PRODUCT_CREDENTIAL_SERVICE: 'org.example.fixture-agent.credentials',
|
|
111
|
+
PRODUCT_CRYPTO_NAMESPACE: 'fixture-agent-crypto',
|
|
112
|
+
SECURITY_MASTER_KEY_DERIVATION_PATH: '[123,0]',
|
|
113
|
+
TERM: 'xterm-256color',
|
|
114
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
115
|
+
GIT_CONFIG_GLOBAL: join(home, 'gitconfig'),
|
|
116
|
+
};
|
|
117
|
+
writeFileSync(join(home, 'gitconfig'), '[commit]\n\tgpgsign = false\n');
|
|
118
|
+
const fixture: IFixture = {
|
|
119
|
+
root,
|
|
120
|
+
home,
|
|
121
|
+
trusted: join(root, 'trusted'),
|
|
122
|
+
untrusted: join(root, 'untrusted'),
|
|
123
|
+
elsewhere: join(root, 'elsewhere'),
|
|
124
|
+
userStateRoot,
|
|
125
|
+
env,
|
|
126
|
+
};
|
|
127
|
+
initRepo(fixture.trusted, env);
|
|
128
|
+
initRepo(fixture.untrusted, env);
|
|
129
|
+
mkdirSync(fixture.elsewhere, { recursive: true });
|
|
130
|
+
// Grant trust to the one repository a link is allowed to open.
|
|
131
|
+
const granted = spawnSync(process.execPath, [AGENT_BIN, 'trust', '--yes'], {
|
|
132
|
+
cwd: fixture.trusted,
|
|
133
|
+
env,
|
|
134
|
+
encoding: 'utf8',
|
|
135
|
+
});
|
|
136
|
+
if (granted.status !== 0) throw new Error(`trust --yes failed: ${granted.stderr}`);
|
|
137
|
+
return fixture;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function link(fixture: IFixture, extra = ''): string {
|
|
141
|
+
return `${PROTOCOL_SCHEME}://open?v=1&prompt=${encodeURIComponent(PROMPT)}&cwd=${fixture.trusted}${extra}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Run the fixture CLI's `open` command as a process, headless — used for the refusal scenarios. */
|
|
145
|
+
function openHeadless(
|
|
146
|
+
fixture: IFixture,
|
|
147
|
+
url: string,
|
|
148
|
+
cwd = fixture.elsewhere,
|
|
149
|
+
): ReturnType<typeof spawnSync> {
|
|
150
|
+
return spawnSync(process.execPath, [AGENT_BIN, 'open', url], {
|
|
151
|
+
cwd,
|
|
152
|
+
env: { ...fixture.env, TERM: 'dumb' },
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
timeout: 60_000,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What an interactive session leaves behind under HOME: one `peers/*.peer.json` per run. A refusal
|
|
160
|
+
* that started no session leaves this listing untouched, which is how the refusal scenarios prove
|
|
161
|
+
* "no session was started" without watching the process.
|
|
162
|
+
*/
|
|
163
|
+
function sessionRecords(home: string): readonly string[] {
|
|
164
|
+
try {
|
|
165
|
+
return readdirSync(join(home, 'state', 'peers')).sort();
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The trust store's bytes — a refused link must not add, remove or touch a grant. */
|
|
172
|
+
function trustStoreDigest(home: string): string {
|
|
173
|
+
return createHash('sha256')
|
|
174
|
+
.update(readFileSync(join(home, 'state', 'workspace-trust.json')))
|
|
175
|
+
.digest('hex');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Every malformed or configuration-bearing link, each paired with the word its refusal must name —
|
|
180
|
+
* the FIRST rule it breaks, so a refusal that merely says the link is invalid fails the case.
|
|
181
|
+
*/
|
|
182
|
+
function malformedLinks(fixture: IFixture): readonly (readonly [string, string])[] {
|
|
183
|
+
return [
|
|
184
|
+
[link(fixture, '&provider=anthropic'), 'provider'],
|
|
185
|
+
[link(fixture, `&prompt=${encodeURIComponent('second')}`), 'more than once'],
|
|
186
|
+
[`${PROTOCOL_SCHEME}://open?prompt=hi&cwd=${fixture.trusted}`, 'no `v`'],
|
|
187
|
+
[`${PROTOCOL_SCHEME}://open?v=2&prompt=hi&cwd=${fixture.trusted}`, 'version 1'],
|
|
188
|
+
[
|
|
189
|
+
`${PROTOCOL_SCHEME}://open?v=1&prompt=${encodeURIComponent('/mode bypassPermissions')}&cwd=${fixture.trusted}`,
|
|
190
|
+
'not a command',
|
|
191
|
+
],
|
|
192
|
+
[`${PROTOCOL_SCHEME}://open?v=1&prompt=hi&cwd=relative/path`, 'absolute'],
|
|
193
|
+
[`${PROTOCOL_SCHEME}://open?v=1&prompt=hi&cwd=${fixture.trusted}/../untrusted`, '`..`'],
|
|
194
|
+
[`${PROTOCOL_SCHEME}://open?v=1&prompt=hi&cwd=${join(fixture.root, 'absent')}`, 'does not exist'],
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
describe('configured open deep link through the CLI binary (FLOW-2006 TC-09)', () => {
|
|
199
|
+
let fixture: IFixture | undefined;
|
|
200
|
+
let session: IPtySession | undefined;
|
|
201
|
+
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
fixture = makeFixture();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
afterEach(async () => {
|
|
207
|
+
await session?.disposeAsync();
|
|
208
|
+
session = undefined;
|
|
209
|
+
if (fixture) rmSync(fixture.root, { recursive: true, force: true });
|
|
210
|
+
fixture = undefined;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it(
|
|
214
|
+
'Scenario 1: the link opens the trusted repository with the prompt prefilled, inert and labelled',
|
|
215
|
+
async () => {
|
|
216
|
+
const current = fixture;
|
|
217
|
+
if (!current) throw new Error('fixture');
|
|
218
|
+
session = spawnTui({
|
|
219
|
+
projectDir: current.elsewhere,
|
|
220
|
+
homeDir: current.home,
|
|
221
|
+
args: ['open', link(current), '--name', 'flow2006'],
|
|
222
|
+
env: current.env as Record<string, string>,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// The PREFILLED TEXT is the arrival signal, not the empty-composer placeholder (which never
|
|
226
|
+
// renders once there is text) and not `Idle` or the notice — both of those are already on the
|
|
227
|
+
// screen one frame before the composer itself is drawn.
|
|
228
|
+
await session.waitFor(new RegExp(PROMPT), WAIT_MS);
|
|
229
|
+
await session.waitFor(/Idle/, WAIT_MS);
|
|
230
|
+
const first = session.snapshot();
|
|
231
|
+
// The prompt is in the composer, the provenance is visible, and nothing was sent.
|
|
232
|
+
expect(first).toContain(PROMPT);
|
|
233
|
+
expect(first).toContain('Prompt from an external link');
|
|
234
|
+
expect(first).not.toMatch(/Thinking|Interrupting/);
|
|
235
|
+
// The link changed the directory: the status line names the fixture repository's branch.
|
|
236
|
+
expect(first).toContain('git: main');
|
|
237
|
+
|
|
238
|
+
// Enter submits exactly that text: it leaves the composer and becomes the user message.
|
|
239
|
+
const since = session.outputOffset();
|
|
240
|
+
await session.pressEnter();
|
|
241
|
+
await session.waitForSince(since, new RegExp(PROMPT), WAIT_MS);
|
|
242
|
+
|
|
243
|
+
const run = spawnSync('git', ['log', '--oneline'], {
|
|
244
|
+
cwd: current.trusted,
|
|
245
|
+
env: current.env,
|
|
246
|
+
encoding: 'utf8',
|
|
247
|
+
});
|
|
248
|
+
expect(run.stdout.trim().split('\n')).toHaveLength(1);
|
|
249
|
+
},
|
|
250
|
+
CASE_TIMEOUT_MS,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
it(
|
|
254
|
+
'Scenario 2: every malformed or configuration-bearing link is refused with no session',
|
|
255
|
+
() => {
|
|
256
|
+
const current = fixture;
|
|
257
|
+
if (!current) throw new Error('fixture');
|
|
258
|
+
const recordsBefore = sessionRecords(current.home);
|
|
259
|
+
const settingsBefore = readFileSync(join(current.userStateRoot, 'settings.json'), 'utf8');
|
|
260
|
+
const cases = malformedLinks(current);
|
|
261
|
+
for (const [url, expected] of cases) {
|
|
262
|
+
const result = openHeadless(current, url);
|
|
263
|
+
expect(result.status, url).toBe(1);
|
|
264
|
+
expect(`${result.stderr}`, url).toContain(expected);
|
|
265
|
+
expect(`${result.stdout}`, url).not.toContain('Type a message');
|
|
266
|
+
}
|
|
267
|
+
// A SECOND LINK after the first is refused — the argv-appending shape the platforms document.
|
|
268
|
+
const twoArgs = spawnSync(
|
|
269
|
+
process.execPath,
|
|
270
|
+
[AGENT_BIN, 'open', link(current), link(current)],
|
|
271
|
+
{
|
|
272
|
+
cwd: current.elsewhere,
|
|
273
|
+
env: { ...current.env, TERM: 'dumb' },
|
|
274
|
+
encoding: 'utf8',
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
expect(twoArgs.status).toBe(1);
|
|
278
|
+
expect(`${twoArgs.stderr}`).toContain('exactly one link');
|
|
279
|
+
|
|
280
|
+
// Nothing was started and nothing was configured: no session record appeared, and the
|
|
281
|
+
// settings the `/mode bypassPermissions` link tried to reach are byte-identical.
|
|
282
|
+
expect(sessionRecords(current.home)).toEqual(recordsBefore);
|
|
283
|
+
expect(readFileSync(join(current.userStateRoot, 'settings.json'), 'utf8')).toBe(
|
|
284
|
+
settingsBefore,
|
|
285
|
+
);
|
|
286
|
+
},
|
|
287
|
+
CASE_TIMEOUT_MS,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
it(
|
|
291
|
+
'Scenario 3: an untrusted directory and an unrecorded slug are refused, and nothing is cloned',
|
|
292
|
+
() => {
|
|
293
|
+
const current = fixture;
|
|
294
|
+
if (!current) throw new Error('fixture');
|
|
295
|
+
const digestBefore = trustStoreDigest(current.home);
|
|
296
|
+
const untrusted = openHeadless(
|
|
297
|
+
current,
|
|
298
|
+
`${PROTOCOL_SCHEME}://open?v=1&prompt=hi&cwd=${current.untrusted}`,
|
|
299
|
+
);
|
|
300
|
+
expect(untrusted.status).toBe(1);
|
|
301
|
+
expect(`${untrusted.stderr}`).toContain(`${CLI_NAME} trust --yes`);
|
|
302
|
+
|
|
303
|
+
const slug = openHeadless(current, `${PROTOCOL_SCHEME}://open?v=1&prompt=hi&repo=nobody/not-a-clone`);
|
|
304
|
+
expect(slug.status).toBe(1);
|
|
305
|
+
expect(`${slug.stderr}`).toContain('nobody/not-a-clone');
|
|
306
|
+
expect(`${slug.stderr}`).not.toContain('cloning');
|
|
307
|
+
|
|
308
|
+
// A refused link grants nothing and records nothing: the untrusted repository is still
|
|
309
|
+
// untrusted and the store is byte-identical to what it was before the two invocations.
|
|
310
|
+
const status = spawnSync(process.execPath, [AGENT_BIN, 'trust', 'status'], {
|
|
311
|
+
cwd: current.untrusted,
|
|
312
|
+
env: current.env,
|
|
313
|
+
encoding: 'utf8',
|
|
314
|
+
});
|
|
315
|
+
expect(`${status.stdout}`).toContain('untrusted');
|
|
316
|
+
expect(trustStoreDigest(current.home)).toBe(digestBefore);
|
|
317
|
+
},
|
|
318
|
+
CASE_TIMEOUT_MS,
|
|
319
|
+
);
|
|
320
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-062 — the one matrix cell where a REAL terminal emulator answers back.
|
|
3
|
+
*
|
|
4
|
+
* Every other cell reads the cursor out of the byte stream our own VT interpreter replays. tmux is
|
|
5
|
+
* a full, independent terminal emulator that can be driven headlessly AND queried: it runs the
|
|
6
|
+
* built CLI binary in a real pane, receives the Korean keystrokes through its own input path,
|
|
7
|
+
* and then reports where ITS state machine put the hardware cursor
|
|
8
|
+
* (`#{cursor_x}` / `#{cursor_y}` / `#{cursor_flag}`). That makes this the strongest available
|
|
9
|
+
* confirmation short of macOS hardware: a second implementation agrees the cursor sits on the
|
|
10
|
+
* input row at the composition column — which is the cell an OS IME anchors its window to.
|
|
11
|
+
*
|
|
12
|
+
* Skipped when tmux is not installed; `PRODUCT_TMUX_BIN` overrides the binary. GitHub's ubuntu
|
|
13
|
+
* runner images ship tmux, so this runs in CI rather than quietly evaporating there.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
17
|
+
import {
|
|
18
|
+
chmodSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
mkdtempSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
realpathSync,
|
|
25
|
+
} from 'node:fs';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { join, resolve } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
30
|
+
import korean from '../__fixtures__/korean-text.json';
|
|
31
|
+
|
|
32
|
+
import { fixtureProductEnvironment } from './isolated-home.js';
|
|
33
|
+
|
|
34
|
+
const REPO_ROOT = resolve(__dirname, '../../../../..');
|
|
35
|
+
const AGENT_BIN = join(REPO_ROOT, 'packages/agent-cli/bin/agent.cjs');
|
|
36
|
+
const GIT_BIN =
|
|
37
|
+
(process.env['PATH'] ?? '/usr/bin:/bin')
|
|
38
|
+
.split(':')
|
|
39
|
+
.map((directory) => join(directory, 'git'))
|
|
40
|
+
.find((candidate) => existsSync(candidate)) ?? '/usr/bin/git';
|
|
41
|
+
const COLS = 80;
|
|
42
|
+
const COMPOSITION = korean.greetingFirstTwoSyllables;
|
|
43
|
+
const COMPOSITION_WIDTH = 4;
|
|
44
|
+
|
|
45
|
+
function resolveTmux(): string | undefined {
|
|
46
|
+
const override = process.env['PRODUCT_TMUX_BIN'];
|
|
47
|
+
if (override !== undefined && override !== '') return override;
|
|
48
|
+
const which = spawnSync('sh', ['-c', 'command -v tmux'], { encoding: 'utf8' });
|
|
49
|
+
const found = which.stdout.trim();
|
|
50
|
+
return found === '' ? undefined : found;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const TMUX = resolveTmux();
|
|
54
|
+
|
|
55
|
+
interface ITmuxProbe {
|
|
56
|
+
cursorX: number;
|
|
57
|
+
cursorY: number;
|
|
58
|
+
/** tmux's own DECTCEM view: is the hardware cursor visible in the pane? */
|
|
59
|
+
cursorVisible: boolean;
|
|
60
|
+
pane: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ITmuxRun {
|
|
64
|
+
root: string;
|
|
65
|
+
socket: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run the CLI inside a real tmux pane and ask tmux where the cursor ended up. */
|
|
69
|
+
function runInTmux(run: ITmuxRun, rows: number, imeCursor: string | undefined): ITmuxProbe {
|
|
70
|
+
const tmux = TMUX!;
|
|
71
|
+
const projectDir = join(run.root, 'proj');
|
|
72
|
+
const homeDir = join(run.root, 'home');
|
|
73
|
+
mkdirSync(projectDir, { recursive: true });
|
|
74
|
+
mkdirSync(join(homeDir, 'state'), { recursive: true });
|
|
75
|
+
writeFileSync(join(homeDir, 'state/onboarded'), new Date().toISOString(), 'utf8');
|
|
76
|
+
writeFileSync(
|
|
77
|
+
join(homeDir, 'state/settings.json'),
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
currentProvider: 'anthropic',
|
|
80
|
+
providers: {
|
|
81
|
+
anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'tmux-dummy-key' },
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
'utf8',
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// A wrapper keeps the pane's env explicit (never inheriting real provider keys) while letting
|
|
88
|
+
// tmux itself set TERM/TERM_PROGRAM for the pane — the handshake under test.
|
|
89
|
+
const wrapper = join(run.root, 'wrap.sh');
|
|
90
|
+
const imeAssignment = imeCursor === undefined ? '' : `PRODUCT_IME_CURSOR=${imeCursor} `;
|
|
91
|
+
const productAssignments = Object.entries(fixtureProductEnvironment(homeDir))
|
|
92
|
+
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
|
|
93
|
+
.join(' ');
|
|
94
|
+
const fixtureEnv = {
|
|
95
|
+
PATH: process.env['PATH'] || '/usr/bin:/bin:/opt/homebrew/bin',
|
|
96
|
+
HOME: homeDir,
|
|
97
|
+
...fixtureProductEnvironment(homeDir),
|
|
98
|
+
};
|
|
99
|
+
execFileSync(GIT_BIN, ['init', '-q', '-b', 'main'], { cwd: projectDir });
|
|
100
|
+
execFileSync(
|
|
101
|
+
GIT_BIN,
|
|
102
|
+
[
|
|
103
|
+
'-c',
|
|
104
|
+
'user.name=PTY Fixture',
|
|
105
|
+
'-c',
|
|
106
|
+
'user.email=pty@example.invalid',
|
|
107
|
+
'commit',
|
|
108
|
+
'--allow-empty',
|
|
109
|
+
'-q',
|
|
110
|
+
'-m',
|
|
111
|
+
'fixture',
|
|
112
|
+
],
|
|
113
|
+
{ cwd: projectDir, env: fixtureEnv },
|
|
114
|
+
);
|
|
115
|
+
execFileSync(process.execPath, [AGENT_BIN, 'trust', '--yes'], {
|
|
116
|
+
cwd: projectDir,
|
|
117
|
+
env: fixtureEnv,
|
|
118
|
+
});
|
|
119
|
+
writeFileSync(
|
|
120
|
+
wrapper,
|
|
121
|
+
[
|
|
122
|
+
'#!/bin/sh',
|
|
123
|
+
`cd "${projectDir}"`,
|
|
124
|
+
`exec env -i PATH="$PATH" HOME="${homeDir}" TERM="$TERM" TERM_PROGRAM="$TERM_PROGRAM" ${productAssignments} ${imeAssignment}` +
|
|
125
|
+
`"${process.execPath}" "${AGENT_BIN}" --name tmux-fixture`,
|
|
126
|
+
'',
|
|
127
|
+
].join('\n'),
|
|
128
|
+
'utf8',
|
|
129
|
+
);
|
|
130
|
+
chmodSync(wrapper, 0o755);
|
|
131
|
+
|
|
132
|
+
const tmuxCall = (...args: string[]): string =>
|
|
133
|
+
execFileSync(tmux, ['-S', run.socket, ...args], { encoding: 'utf8' });
|
|
134
|
+
|
|
135
|
+
tmuxCall('new-session', '-d', '-x', String(COLS), '-y', String(rows), wrapper);
|
|
136
|
+
|
|
137
|
+
const deadline = Date.now() + 40_000;
|
|
138
|
+
let pane = '';
|
|
139
|
+
while (Date.now() < deadline) {
|
|
140
|
+
pane = tmuxCall('capture-pane', '-p');
|
|
141
|
+
if (pane.includes('Type a message')) break;
|
|
142
|
+
sleepSync(250);
|
|
143
|
+
}
|
|
144
|
+
expect(pane, 'the CLI never reached the prompt inside tmux').toContain('Type a message');
|
|
145
|
+
sleepSync(400);
|
|
146
|
+
|
|
147
|
+
tmuxCall('send-keys', '-l', COMPOSITION);
|
|
148
|
+
sleepSync(1500);
|
|
149
|
+
|
|
150
|
+
const [x = '', y = '', flag = ''] = tmuxCall(
|
|
151
|
+
'display-message',
|
|
152
|
+
'-p',
|
|
153
|
+
'#{cursor_x} #{cursor_y} #{cursor_flag}',
|
|
154
|
+
)
|
|
155
|
+
.trim()
|
|
156
|
+
.split(' ');
|
|
157
|
+
return {
|
|
158
|
+
cursorX: Number.parseInt(x, 10),
|
|
159
|
+
cursorY: Number.parseInt(y, 10),
|
|
160
|
+
cursorVisible: flag === '1',
|
|
161
|
+
pane: tmuxCall('capture-pane', '-p').split('\n'),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Deliberate synchronous wait: tmux is driven through blocking CLI calls, not an event loop. */
|
|
166
|
+
function sleepSync(ms: number): void {
|
|
167
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
describe.skipIf(TMUX === undefined)(
|
|
171
|
+
'CLI-062 terminal matrix — tmux, a real emulator reporting its own cursor',
|
|
172
|
+
() => {
|
|
173
|
+
let run: ITmuxRun;
|
|
174
|
+
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), 'agent-tmux-ime-')));
|
|
177
|
+
run = { root, socket: join(root, 'sock') };
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
if (TMUX !== undefined) {
|
|
182
|
+
spawnSync(TMUX, ['-S', run.socket, 'kill-server'], { stdio: 'ignore' });
|
|
183
|
+
}
|
|
184
|
+
rmSync(run.root, { recursive: true, force: true });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('24-row pane: tmux reports the cursor VISIBLE on the input row at the composition column', () => {
|
|
188
|
+
const probe = runInTmux(run, 24, undefined);
|
|
189
|
+
const inputRow = probe.pane.findIndex((line) => line.includes(`> ${COMPOSITION}`));
|
|
190
|
+
expect(inputRow, `no input row in pane:\n${probe.pane.join('\n')}`).toBeGreaterThanOrEqual(0);
|
|
191
|
+
|
|
192
|
+
expect(probe.cursorVisible).toBe(true);
|
|
193
|
+
expect(probe.cursorY).toBe(inputRow);
|
|
194
|
+
expect(probe.cursorX).toBe(probe.pane[inputRow]!.indexOf('> ') + 2 + COMPOSITION_WIDTH);
|
|
195
|
+
}, 90_000);
|
|
196
|
+
|
|
197
|
+
it('5-row pane (frame ≥ viewport, I2): tmux reports the cursor hidden, never positioned', () => {
|
|
198
|
+
const probe = runInTmux(run, 5, undefined);
|
|
199
|
+
expect(probe.pane.some((line) => line.includes(`> ${COMPOSITION}`))).toBe(true);
|
|
200
|
+
expect(probe.cursorVisible).toBe(false);
|
|
201
|
+
}, 90_000);
|
|
202
|
+
|
|
203
|
+
it('PRODUCT_IME_CURSOR=0 kill switch: tmux reports the cursor hidden even at 24 rows', () => {
|
|
204
|
+
const probe = runInTmux(run, 24, '0');
|
|
205
|
+
const inputRow = probe.pane.findIndex((line) => line.includes(`> ${COMPOSITION}`));
|
|
206
|
+
expect(inputRow).toBeGreaterThanOrEqual(0);
|
|
207
|
+
expect(probe.cursorVisible).toBe(false);
|
|
208
|
+
expect(probe.cursorY).not.toBe(inputRow);
|
|
209
|
+
}, 90_000);
|
|
210
|
+
},
|
|
211
|
+
);
|