@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,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { buildTuiSessionOptions } from '../tui-session-options.js';
|
|
4
|
+
|
|
5
|
+
import type { ITuiInteractionChannelOptions } from '../TuiInteractionChannel.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ARCH-013 — a preset's `effort` is resolved at startup and then dropped.
|
|
9
|
+
*
|
|
10
|
+
* `TPresetEffort` is defined as `NonNullable<ICreateSessionOptions['effort']>` precisely so a preset
|
|
11
|
+
* can thread onto that seam, and three of the four built-in presets set it —
|
|
12
|
+
* `neutral-executor` asks for `'medium'`. At startup none of it arrives: no hop between the resolved
|
|
13
|
+
* preset and `createSession` carried the field, so the core applied its own `'high'` default.
|
|
14
|
+
*
|
|
15
|
+
* The same preset selected mid-session with `/preset` DOES apply it
|
|
16
|
+
* (`command-api/preset/preset-application.ts:91` → `applyModelOptions`), so one session could hold
|
|
17
|
+
* two different answers for the same preset depending on when it was chosen. That is the observable
|
|
18
|
+
* this pins, at the projection hop the TUI actually uses.
|
|
19
|
+
*/
|
|
20
|
+
function channelOptions(
|
|
21
|
+
overrides: Partial<ITuiInteractionChannelOptions> = {},
|
|
22
|
+
): ITuiInteractionChannelOptions {
|
|
23
|
+
return { cwd: '/tmp', ...overrides } as ITuiInteractionChannelOptions;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('the TUI projection carries effort (ARCH-013)', () => {
|
|
27
|
+
it('forwards a resolved effort onto the session options', () => {
|
|
28
|
+
// Against the defect `effort` is not a key of the target type at all, so it is simply absent.
|
|
29
|
+
// Asserted over the entries rather than through a cast: the declared return type is the
|
|
30
|
+
// standard|injected union and only the standard arm can carry a construction-time option, but a
|
|
31
|
+
// double cast to reach the key would be the same "trust me" this whole item is about.
|
|
32
|
+
const built = buildTuiSessionOptions(channelOptions({ effort: 'medium' }));
|
|
33
|
+
expect(Object.entries(built)).toContainEqual(['effort', 'medium']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('omits the key entirely when no effort was resolved', () => {
|
|
37
|
+
// Not `undefined` — absent. The framework→provider seam owns the default, and a present-but-
|
|
38
|
+
// undefined key would claim a decision nobody made.
|
|
39
|
+
expect('effort' in buildTuiSessionOptions(channelOptions())).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import type {
|
|
5
|
+
IExecutionWorkspaceEntry,
|
|
6
|
+
IExecutionWorkspaceSnapshot,
|
|
7
|
+
} from '@robota-sdk/agent-interface-execution';
|
|
8
|
+
import ExecutionWorkspaceSwitcher from '../ExecutionWorkspaceSwitcher.js';
|
|
9
|
+
import { formatKeyHints, SELECTION_INDICATOR } from '../key-hint-footer.js';
|
|
10
|
+
import ExecutionWorkspaceDetailPane from '../ExecutionWorkspaceDetailPane.js';
|
|
11
|
+
|
|
12
|
+
function makeEntry(overrides: Partial<IExecutionWorkspaceEntry>): IExecutionWorkspaceEntry {
|
|
13
|
+
return {
|
|
14
|
+
id: 'main:session_1',
|
|
15
|
+
sourceId: 'session_1',
|
|
16
|
+
kind: 'main_thread',
|
|
17
|
+
origin: { kind: 'user_prompt', sessionId: 'session_1' },
|
|
18
|
+
status: 'idle',
|
|
19
|
+
state: 'completed',
|
|
20
|
+
title: 'Main thread',
|
|
21
|
+
subtitle: '2 history entries',
|
|
22
|
+
unread: false,
|
|
23
|
+
attention: 'none',
|
|
24
|
+
visibility: 'default',
|
|
25
|
+
updatedAt: '2026-05-09T00:00:00.000Z',
|
|
26
|
+
controls: ['select'],
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeSnapshot(): IExecutionWorkspaceSnapshot {
|
|
32
|
+
return {
|
|
33
|
+
sessionId: 'session_1',
|
|
34
|
+
selectedEntryId: 'main:session_1',
|
|
35
|
+
updatedAt: '2026-05-09T00:00:00.000Z',
|
|
36
|
+
entries: [
|
|
37
|
+
makeEntry({ id: 'main:session_1', kind: 'main_thread', title: 'Main thread' }),
|
|
38
|
+
makeEntry({
|
|
39
|
+
id: 'task:agent_1',
|
|
40
|
+
sourceId: 'agent_1',
|
|
41
|
+
kind: 'background_task',
|
|
42
|
+
taskKind: 'agent',
|
|
43
|
+
status: 'running',
|
|
44
|
+
state: 'working',
|
|
45
|
+
title: 'Explore',
|
|
46
|
+
subtitle: 'general-purpose',
|
|
47
|
+
preview: 'Inspect task layer',
|
|
48
|
+
controls: ['select', 'cancel'],
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('ExecutionWorkspaceSwitcher', () => {
|
|
55
|
+
it('renders main and background entries with selected radio markers', () => {
|
|
56
|
+
const { lastFrame } = render(
|
|
57
|
+
<ExecutionWorkspaceSwitcher
|
|
58
|
+
snapshot={makeSnapshot()}
|
|
59
|
+
selectedEntryId="task:agent_1"
|
|
60
|
+
onSelect={vi.fn()}
|
|
61
|
+
onClose={vi.fn()}
|
|
62
|
+
/>,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const frame = lastFrame()!;
|
|
66
|
+
expect(frame).toContain('Execution workspace');
|
|
67
|
+
// SCREEN-1992: the state word sits beside the radio.
|
|
68
|
+
expect(frame).toContain('○ completed Main thread');
|
|
69
|
+
expect(frame).toContain('● working Explore agent');
|
|
70
|
+
expect(frame).toContain('Inspect task layer');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('commits the highlighted entry on enter without invoking task controls', () => {
|
|
74
|
+
const onSelect = vi.fn();
|
|
75
|
+
const { stdin } = render(
|
|
76
|
+
<ExecutionWorkspaceSwitcher
|
|
77
|
+
snapshot={makeSnapshot()}
|
|
78
|
+
selectedEntryId="main:session_1"
|
|
79
|
+
onSelect={onSelect}
|
|
80
|
+
onClose={vi.fn()}
|
|
81
|
+
/>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
stdin.write('\u001B[B');
|
|
85
|
+
stdin.write('\r');
|
|
86
|
+
|
|
87
|
+
expect(onSelect).toHaveBeenCalledWith('task:agent_1');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('renders selected entry detail records from the SDK detail page', () => {
|
|
91
|
+
const entry = makeEntry({
|
|
92
|
+
id: 'task:agent_1',
|
|
93
|
+
sourceId: 'agent_1',
|
|
94
|
+
kind: 'background_task',
|
|
95
|
+
taskKind: 'agent',
|
|
96
|
+
status: 'completed',
|
|
97
|
+
title: 'Explore',
|
|
98
|
+
preview: 'Done',
|
|
99
|
+
});
|
|
100
|
+
const { lastFrame } = render(
|
|
101
|
+
<ExecutionWorkspaceDetailPane
|
|
102
|
+
entry={entry}
|
|
103
|
+
page={{
|
|
104
|
+
entryId: 'task:agent_1',
|
|
105
|
+
records: [{ id: 'r1', kind: 'result', text: 'Final background result' }],
|
|
106
|
+
}}
|
|
107
|
+
/>,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const frame = lastFrame()!;
|
|
111
|
+
expect(frame).toContain('Viewing Explore agent');
|
|
112
|
+
expect(frame).toContain('Final background result');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// SCREEN-005: switcher footer + focused-row cursor go through the key-hint/indicator SSOT.
|
|
117
|
+
describe('ExecutionWorkspaceSwitcher affordance (SCREEN-005)', () => {
|
|
118
|
+
it('renders the footer in the shared key-hint grammar (navigate → primary → dismiss)', () => {
|
|
119
|
+
const { lastFrame } = render(
|
|
120
|
+
<ExecutionWorkspaceSwitcher
|
|
121
|
+
snapshot={makeSnapshot()}
|
|
122
|
+
selectedEntryId="main:session_1"
|
|
123
|
+
onSelect={vi.fn()}
|
|
124
|
+
onClose={vi.fn()}
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
expect(lastFrame()!).toContain(
|
|
128
|
+
formatKeyHints([
|
|
129
|
+
{ keys: '↑↓', label: 'Navigate' },
|
|
130
|
+
{ keys: 'Enter', label: 'Switch' },
|
|
131
|
+
{ keys: 'Ctrl+B/Esc', label: 'Close' },
|
|
132
|
+
]),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('marks the focused row with the selection indicator', () => {
|
|
137
|
+
const { lastFrame } = render(
|
|
138
|
+
<ExecutionWorkspaceSwitcher
|
|
139
|
+
snapshot={makeSnapshot()}
|
|
140
|
+
selectedEntryId="main:session_1"
|
|
141
|
+
onSelect={vi.fn()}
|
|
142
|
+
onClose={vi.fn()}
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
expect(lastFrame()!).toContain(`${SELECTION_INDICATOR}`);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
IExecutionWorkspaceEntry,
|
|
4
|
+
IExecutionWorkspaceSnapshot,
|
|
5
|
+
} from '@robota-sdk/agent-interface-execution';
|
|
6
|
+
import {
|
|
7
|
+
countActiveBackgroundWorkspaceEntries,
|
|
8
|
+
formatExecutionDetailRecord,
|
|
9
|
+
formatExecutionWorkspaceEntryRow,
|
|
10
|
+
getDefaultBackgroundWorkspaceEntries,
|
|
11
|
+
} from '../execution-workspace-view-model.js';
|
|
12
|
+
|
|
13
|
+
function makeEntry(overrides: Partial<IExecutionWorkspaceEntry>): IExecutionWorkspaceEntry {
|
|
14
|
+
return {
|
|
15
|
+
id: 'task:agent_1',
|
|
16
|
+
sourceId: 'agent_1',
|
|
17
|
+
kind: 'background_task',
|
|
18
|
+
origin: { kind: 'slash_command', sessionId: 'session_1', commandName: 'agent' },
|
|
19
|
+
taskKind: 'agent',
|
|
20
|
+
status: 'running',
|
|
21
|
+
state: 'working',
|
|
22
|
+
title: 'Explore',
|
|
23
|
+
subtitle: 'general-purpose',
|
|
24
|
+
preview: 'Map the CLI state manager',
|
|
25
|
+
unread: false,
|
|
26
|
+
attention: 'none',
|
|
27
|
+
visibility: 'default',
|
|
28
|
+
updatedAt: '2026-05-09T00:00:00.000Z',
|
|
29
|
+
controls: ['select', 'cancel'],
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeSnapshot(entries: readonly IExecutionWorkspaceEntry[]): IExecutionWorkspaceSnapshot {
|
|
35
|
+
return {
|
|
36
|
+
sessionId: 'session_1',
|
|
37
|
+
selectedEntryId: 'main:session_1',
|
|
38
|
+
updatedAt: '2026-05-09T00:00:00.000Z',
|
|
39
|
+
entries,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('execution workspace view model', () => {
|
|
44
|
+
it('renders selected and inactive entries with radio markers', () => {
|
|
45
|
+
const row = formatExecutionWorkspaceEntryRow(makeEntry({ id: 'task:agent_1' }), {
|
|
46
|
+
selectedEntryId: 'task:agent_1',
|
|
47
|
+
});
|
|
48
|
+
const inactive = formatExecutionWorkspaceEntryRow(makeEntry({ id: 'task:process_1' }), {
|
|
49
|
+
selectedEntryId: 'task:agent_1',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(row.radio).toBe('●');
|
|
53
|
+
expect(row.title).toBe('Explore agent');
|
|
54
|
+
expect(row.subtitle).toBe('agent · general-purpose');
|
|
55
|
+
expect(row.statusLabel).toBe('running');
|
|
56
|
+
expect(inactive.radio).toBe('○');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('filters default-visible background task entries for the compact panel', () => {
|
|
60
|
+
const snapshot = makeSnapshot([
|
|
61
|
+
makeEntry({ id: 'main:session_1', kind: 'main_thread', title: 'Main thread' }),
|
|
62
|
+
makeEntry({ id: 'task:agent_1', visibility: 'default' }),
|
|
63
|
+
makeEntry({ id: 'task:agent_2', visibility: 'collapsed' }),
|
|
64
|
+
makeEntry({ id: 'group:group_1', kind: 'background_group', visibility: 'default' }),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
expect(getDefaultBackgroundWorkspaceEntries(snapshot).map((entry) => entry.id)).toEqual([
|
|
68
|
+
'task:agent_1',
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('counts active default-visible background tasks without treating collapsed tasks as active', () => {
|
|
73
|
+
const snapshot = makeSnapshot([
|
|
74
|
+
makeEntry({ id: 'task:queued', status: 'queued' }),
|
|
75
|
+
makeEntry({ id: 'task:running', status: 'running' }),
|
|
76
|
+
makeEntry({ id: 'task:permission', status: 'waiting_permission' }),
|
|
77
|
+
makeEntry({ id: 'task:done', status: 'completed' }),
|
|
78
|
+
makeEntry({ id: 'task:hidden', status: 'running', visibility: 'collapsed' }),
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
expect(countActiveBackgroundWorkspaceEntries(snapshot)).toBe(3);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('bounds detail record text without parsing runner-specific output', () => {
|
|
85
|
+
const text = formatExecutionDetailRecord({
|
|
86
|
+
id: 'record_1',
|
|
87
|
+
kind: 'process_output',
|
|
88
|
+
text: ` ${'a'.repeat(180)} `,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(text).toHaveLength(163);
|
|
92
|
+
expect(text.endsWith('...')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLOW-2006 TC-05 — the provenance notice and the consumed-once prefill.
|
|
3
|
+
*
|
|
4
|
+
* The notice is what keeps a prompt the user never typed from reading like one they did, and the
|
|
5
|
+
* consumed-once rule is why a handoff remount cannot re-seed a prompt they already cleared.
|
|
6
|
+
*/
|
|
7
|
+
import { render } from 'ink-testing-library';
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import InputArea from '../InputArea.js';
|
|
12
|
+
import ExternalPromptNotice, {
|
|
13
|
+
externalPromptNotice,
|
|
14
|
+
EXTERNAL_PROMPT_NOTICE,
|
|
15
|
+
EXTERNAL_PROMPT_REVIEW_THRESHOLD,
|
|
16
|
+
} from '../external-prompt-notice.js';
|
|
17
|
+
|
|
18
|
+
const delay = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 20));
|
|
19
|
+
|
|
20
|
+
describe('externalPromptNotice', () => {
|
|
21
|
+
it('labels a prompt, escalates a long one, and says nothing about an empty composer', () => {
|
|
22
|
+
expect(externalPromptNotice('hi')).toBe(EXTERNAL_PROMPT_NOTICE);
|
|
23
|
+
expect(externalPromptNotice('')).toBeUndefined();
|
|
24
|
+
const long = 'x'.repeat(EXTERNAL_PROMPT_REVIEW_THRESHOLD + 1);
|
|
25
|
+
const escalated = externalPromptNotice(long) ?? '';
|
|
26
|
+
expect(escalated).toContain(EXTERNAL_PROMPT_NOTICE);
|
|
27
|
+
expect(escalated).toContain(String(long.length));
|
|
28
|
+
expect(escalated).toContain('review');
|
|
29
|
+
// Exactly at the threshold is still the plain label.
|
|
30
|
+
expect(externalPromptNotice('x'.repeat(EXTERNAL_PROMPT_REVIEW_THRESHOLD))).toBe(
|
|
31
|
+
EXTERNAL_PROMPT_NOTICE,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('ExternalPromptNotice', () => {
|
|
37
|
+
it('renders the line while the prompt is there and nothing once it is gone', () => {
|
|
38
|
+
const withPrompt = render(<ExternalPromptNotice value="from a link" />);
|
|
39
|
+
expect(withPrompt.lastFrame()).toContain(EXTERNAL_PROMPT_NOTICE);
|
|
40
|
+
const cleared = render(<ExternalPromptNotice value="" />);
|
|
41
|
+
expect(cleared.lastFrame() ?? '').not.toContain(EXTERNAL_PROMPT_NOTICE);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The consumed-once contract and the provenance lifetime, driven through the REAL composer.
|
|
47
|
+
*
|
|
48
|
+
* A hand-copied stand-in would keep passing if `InputArea`'s own seeding changed — which is the one
|
|
49
|
+
* thing these assertions exist to catch — so every case below mounts the shipped component.
|
|
50
|
+
*/
|
|
51
|
+
describe('the prefill is consumed once, and the label belongs to that text', () => {
|
|
52
|
+
it('seeds the composer, tells the controller, and drops the label once the text changes', async () => {
|
|
53
|
+
const consume = vi.fn();
|
|
54
|
+
const { lastFrame, stdin } = render(
|
|
55
|
+
<InputArea
|
|
56
|
+
onSubmit={vi.fn()}
|
|
57
|
+
isDisabled={false}
|
|
58
|
+
initialValue="from a link"
|
|
59
|
+
consumeInitialValue={consume}
|
|
60
|
+
externalPromptOrigin
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
await delay();
|
|
64
|
+
expect(lastFrame()).toContain('from a link');
|
|
65
|
+
expect(lastFrame()).toContain(EXTERNAL_PROMPT_NOTICE);
|
|
66
|
+
expect(consume).toHaveBeenCalledTimes(1);
|
|
67
|
+
|
|
68
|
+
// The user edits: what is in the composer is no longer what the link supplied, so the
|
|
69
|
+
// provenance line goes with it. A label that outlived the text would mark the user's own words.
|
|
70
|
+
stdin.write('!');
|
|
71
|
+
await delay();
|
|
72
|
+
expect(lastFrame()).toContain('from a link!');
|
|
73
|
+
expect(lastFrame() ?? '').not.toContain(EXTERNAL_PROMPT_NOTICE);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not re-seed on the handoff remount, after the controller dropped the value', async () => {
|
|
77
|
+
const consume = vi.fn();
|
|
78
|
+
const remounted = render(
|
|
79
|
+
<InputArea
|
|
80
|
+
onSubmit={vi.fn()}
|
|
81
|
+
isDisabled={false}
|
|
82
|
+
initialValue={undefined}
|
|
83
|
+
consumeInitialValue={consume}
|
|
84
|
+
externalPromptOrigin
|
|
85
|
+
/>,
|
|
86
|
+
);
|
|
87
|
+
await delay();
|
|
88
|
+
expect(remounted.lastFrame() ?? '').not.toContain(EXTERNAL_PROMPT_NOTICE);
|
|
89
|
+
expect(consume).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('never labels a prompt the user typed themselves', async () => {
|
|
93
|
+
const { lastFrame, stdin } = render(<InputArea onSubmit={vi.fn()} isDisabled={false} />);
|
|
94
|
+
await delay();
|
|
95
|
+
stdin.write('my own prompt');
|
|
96
|
+
await delay();
|
|
97
|
+
expect(lastFrame()).toContain('my own prompt');
|
|
98
|
+
expect(lastFrame() ?? '').not.toContain(EXTERNAL_PROMPT_NOTICE);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEST-007 / TERM-003 + TERM-004 fixture: run the real `/shell` and `/editor` command executors
|
|
3
|
+
* through the real `TerminalHandoffController` on a real PTY.
|
|
4
|
+
*
|
|
5
|
+
* A real Ink app (input hooks below a suspend gate, like the App) provides the live handoff; a minimal
|
|
6
|
+
* host context wires the executors' three touch-points (`canHandoffTerminal` / `runWithTerminal` /
|
|
7
|
+
* `getCwd`) to the controller. The session command-pipeline integration is already covered by the
|
|
8
|
+
* framework functional tests with a fake handoff — this fixture exercises the one thing only a real
|
|
9
|
+
* terminal can: the controller releasing raw mode so the child (subshell / `$EDITOR`) owns the TTY,
|
|
10
|
+
* and `runWithTerminal` returning so the TUI resumes.
|
|
11
|
+
*
|
|
12
|
+
* argv: [mode ('shell'|'editor'), outputPath, commandArg?]. Markers: `@@READY canHandoff=…@@`,
|
|
13
|
+
* `@@CMD_DONE@@`, `@@INPUT_AFTER_HANDOFF …@@`. A JSON result
|
|
14
|
+
* `{ success, exitCode?, message }` is written to outputPath.
|
|
15
|
+
*/
|
|
16
|
+
import { writeFileSync } from 'node:fs';
|
|
17
|
+
|
|
18
|
+
import { executeEditorCommand, executeShellCommand } from '@robota-sdk/agent-command';
|
|
19
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
20
|
+
import { Box, Text, render, useInput } from 'ink';
|
|
21
|
+
import React from 'react';
|
|
22
|
+
|
|
23
|
+
import { useTerminalHandoffSuspension } from '../../hooks/useTerminalHandoffSuspension.js';
|
|
24
|
+
import { TerminalHandoffController } from '../../terminal-handoff-controller.js';
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
ICommandHostTerminalHandoff,
|
|
28
|
+
ICommandHostWorkspace,
|
|
29
|
+
} from '@robota-sdk/agent-framework';
|
|
30
|
+
|
|
31
|
+
const MODE = process.argv[2];
|
|
32
|
+
const OUTPUT_PATH = process.argv[3];
|
|
33
|
+
const COMMAND_ARG = process.argv[4] ?? '';
|
|
34
|
+
|
|
35
|
+
const controller = new TerminalHandoffController();
|
|
36
|
+
let resolveInputAfterHandoff: ((input: string) => void) | undefined;
|
|
37
|
+
|
|
38
|
+
/** Minimal host context — the shell/editor executors only read these three members. */
|
|
39
|
+
const context = {
|
|
40
|
+
canHandoffTerminal: (): boolean => controller.canHandoffTerminal,
|
|
41
|
+
runWithTerminal: <T,>(fn: () => Promise<T>): Promise<T> => controller.runWithTerminal(fn),
|
|
42
|
+
getCwd: (): string => process.cwd(),
|
|
43
|
+
} satisfies ICommandHostTerminalHandoff & Pick<ICommandHostWorkspace, 'getCwd'>;
|
|
44
|
+
|
|
45
|
+
function InputCapture(): React.ReactElement {
|
|
46
|
+
useInput((input) => resolveInputAfterHandoff?.(input));
|
|
47
|
+
return <Text>READY</Text>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function CommandApp(): React.ReactElement {
|
|
51
|
+
const suspended = useTerminalHandoffSuspension(controller);
|
|
52
|
+
if (suspended) return <Box />;
|
|
53
|
+
return (
|
|
54
|
+
<Box>
|
|
55
|
+
<InputCapture />
|
|
56
|
+
</Box>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function marker(line: string): void {
|
|
61
|
+
process.stdout.write(`\r\n@@${line}@@\r\n`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeResult(result: Record<string, string | number | boolean | undefined>): void {
|
|
65
|
+
if (OUTPUT_PATH) writeFileSync(OUTPUT_PATH, JSON.stringify(result), 'utf8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function main(): Promise<void> {
|
|
69
|
+
const instance = render(<CommandApp />);
|
|
70
|
+
controller.setInkInstance(instance);
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
72
|
+
marker(`READY canHandoff=${controller.canHandoffTerminal}`);
|
|
73
|
+
|
|
74
|
+
// ARCH-029: the fixture's `satisfies Pick<>` above is the honest part — it declares exactly the
|
|
75
|
+
// three members these executors read. The widening cast existed only because the executors demand
|
|
76
|
+
// the whole 46-member facade. S3's role ports remove the need to widen at all; until then the
|
|
77
|
+
// double supplies the rest so no cast is required.
|
|
78
|
+
const ctx = createTestCommandHost({ overrides: context });
|
|
79
|
+
const result =
|
|
80
|
+
MODE === 'editor'
|
|
81
|
+
? await executeEditorCommand(ctx, COMMAND_ARG)
|
|
82
|
+
: await executeShellCommand(ctx, COMMAND_ARG);
|
|
83
|
+
|
|
84
|
+
marker('CMD_DONE');
|
|
85
|
+
const inputAfterHandoff = await new Promise<string>((resolve) => {
|
|
86
|
+
resolveInputAfterHandoff = resolve;
|
|
87
|
+
});
|
|
88
|
+
marker(`INPUT_AFTER_HANDOFF ${inputAfterHandoff}`);
|
|
89
|
+
instance.unmount();
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
91
|
+
|
|
92
|
+
writeResult({
|
|
93
|
+
success: result.success,
|
|
94
|
+
message: result.message,
|
|
95
|
+
exitCode: (result.data as { exitCode?: number } | undefined)?.exitCode,
|
|
96
|
+
});
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
process.on('uncaughtException', (error) => {
|
|
102
|
+
writeResult({ success: false, message: String(error) });
|
|
103
|
+
process.stdout.write(`\r\n@@ERROR ${String(error)}@@\r\n`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
void main().catch((error) => {
|
|
108
|
+
writeResult({ success: false, message: String(error) });
|
|
109
|
+
process.stdout.write(`\r\n@@ERROR ${String(error)}@@\r\n`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# TEST-007 fake $EDITOR for the /editor PTY scenario.
|
|
3
|
+
# Invoked as: sh fake-editor.sh <file>. Reads ONE line from the inherited terminal (proving the
|
|
4
|
+
# editor runs on the real TTY under the handoff) and writes it as the file's contents, then exits 0.
|
|
5
|
+
file="$1"
|
|
6
|
+
IFS= read -r line
|
|
7
|
+
printf '%s\n' "$line" > "$file"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEST-007 / TERM-002 fixture: real-terminal handoff under a PTY.
|
|
3
|
+
*
|
|
4
|
+
* Renders a real Ink app that takes raw mode (useInput) BELOW a suspend gate — exactly like the real
|
|
5
|
+
* App — and uses the real `TerminalHandoffController` + `useTerminalHandoffSuspension`. It performs an
|
|
6
|
+
* actual handoff: `runWithTerminal` spawns a child that reads ONE line from the inherited terminal and
|
|
7
|
+
* echoes it. If Ink released raw mode on suspend, the child receives the driver's keystrokes; after
|
|
8
|
+
* the child exits, `runWithTerminal` must RETURN and the App must resume.
|
|
9
|
+
*
|
|
10
|
+
* Driven by `terminal-handoff-pty-e2e.test.ts`. Markers the harness keys off: `@@READY@@`,
|
|
11
|
+
* `@@HANDOFF_STARTED@@`, `CHILD_GOT:[...]`, `@@RESUMED exit=N@@`. A JSON result is written to argv[0].
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { writeFileSync } from 'node:fs';
|
|
15
|
+
|
|
16
|
+
import { Box, Text, render, useInput } from 'ink';
|
|
17
|
+
import React from 'react';
|
|
18
|
+
|
|
19
|
+
import { useTerminalHandoffSuspension } from '../../hooks/useTerminalHandoffSuspension.js';
|
|
20
|
+
import { TerminalHandoffController } from '../../terminal-handoff-controller.js';
|
|
21
|
+
|
|
22
|
+
const OUTPUT_PATH = process.argv[2];
|
|
23
|
+
const controller = new TerminalHandoffController();
|
|
24
|
+
|
|
25
|
+
/** Input-bearing subtree: useInput lives BELOW the suspend gate, so it unmounts (Ink releases raw
|
|
26
|
+
* mode) while suspended — mirrors the real App. */
|
|
27
|
+
function InputCapture(): React.ReactElement {
|
|
28
|
+
useInput(() => {});
|
|
29
|
+
return <Text>READY</Text>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function HandoffApp(): React.ReactElement {
|
|
33
|
+
const suspended = useTerminalHandoffSuspension(controller);
|
|
34
|
+
if (suspended) return <Box />;
|
|
35
|
+
return (
|
|
36
|
+
<Box>
|
|
37
|
+
<InputCapture />
|
|
38
|
+
</Box>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function marker(line: string): void {
|
|
43
|
+
process.stdout.write(`\r\n@@${line}@@\r\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeResult(result: Record<string, string | number | boolean>): void {
|
|
47
|
+
if (OUTPUT_PATH) writeFileSync(OUTPUT_PATH, JSON.stringify(result), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function main(): Promise<void> {
|
|
51
|
+
const instance = render(<HandoffApp />);
|
|
52
|
+
controller.setInkInstance(instance);
|
|
53
|
+
|
|
54
|
+
// Wait for the App to mount + register its suspend hooks.
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
56
|
+
const canHandoff = controller.canHandoffTerminal;
|
|
57
|
+
marker(`READY canHandoff=${canHandoff}`);
|
|
58
|
+
|
|
59
|
+
const exitCode = await controller.runWithTerminal(async () => {
|
|
60
|
+
marker('HANDOFF_STARTED');
|
|
61
|
+
return await new Promise<number>((resolve) => {
|
|
62
|
+
const child = spawn('sh', ['-c', 'IFS= read -r line; printf "CHILD_GOT:[%s]\\n" "$line"'], {
|
|
63
|
+
stdio: 'inherit',
|
|
64
|
+
});
|
|
65
|
+
child.on('exit', (code) => resolve(code ?? 0));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// If runWithTerminal returned, the App resumed (finally → resume ran). Confirm with a marker.
|
|
70
|
+
marker(`AFTER_HANDOFF exit=${exitCode}`);
|
|
71
|
+
instance.unmount();
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
73
|
+
marker(`RESUMED exit=${exitCode}`);
|
|
74
|
+
|
|
75
|
+
writeResult({ canHandoff, exitCode, returned: true });
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
process.on('uncaughtException', (error) => {
|
|
81
|
+
writeResult({ error: String(error), returned: false });
|
|
82
|
+
process.stdout.write(`\r\n@@ERROR ${String(error)}@@\r\n`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
void main().catch((error) => {
|
|
87
|
+
writeResult({ error: String(error), returned: false });
|
|
88
|
+
process.stdout.write(`\r\n@@ERROR ${String(error)}@@\r\n`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
});
|