@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,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated HOME for PTY children (HARNESS-025).
|
|
3
|
+
*
|
|
4
|
+
* A PTY test that forwards the developer's real `HOME` runs its child against whatever happens to
|
|
5
|
+
* be in that home directory — a `~/.robota` settings file, a shell rc, an `$EDITOR` preference.
|
|
6
|
+
* The suite then passes or fails depending on the machine it runs on: green on CI (empty HOME),
|
|
7
|
+
* red on a developer box whose real config contradicts the fixture. That is the conditional flake
|
|
8
|
+
* this module removes.
|
|
9
|
+
*
|
|
10
|
+
* Every PTY child gets a throwaway directory instead. The directories are tracked and removed on
|
|
11
|
+
* process exit, so a test that forgets to dispose leaks nothing beyond the run.
|
|
12
|
+
*
|
|
13
|
+
* `packages/agent-ui-terminal/src/__tests__/pty/pty-driver.ts` already took an explicit
|
|
14
|
+
* `homeDir`; this generalizes that discipline to every `spawnPty` caller, including the default.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const createdHomes: string[] = [];
|
|
21
|
+
let exitHookInstalled = false;
|
|
22
|
+
|
|
23
|
+
function installExitHook(): void {
|
|
24
|
+
if (exitHookInstalled) return;
|
|
25
|
+
exitHookInstalled = true;
|
|
26
|
+
process.on('exit', disposeIsolatedHomes);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a throwaway HOME directory for a PTY child and return its absolute path.
|
|
31
|
+
*
|
|
32
|
+
* The directory is empty: the child sees no `~/.robota`, no shell rc, no user preferences. Callers
|
|
33
|
+
* that need seeded content write it themselves after creating the directory.
|
|
34
|
+
*/
|
|
35
|
+
export function createIsolatedHome(): string {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), 'robota-pty-home-'));
|
|
37
|
+
createdHomes.push(dir);
|
|
38
|
+
installExitHook();
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Remove every directory handed out by {@link createIsolatedHome}. Idempotent. */
|
|
43
|
+
export function disposeIsolatedHomes(): void {
|
|
44
|
+
for (const dir of createdHomes.splice(0)) {
|
|
45
|
+
rmSync(dir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the environment for a PTY child: an isolated HOME, the real PATH (the child still needs to
|
|
51
|
+
* find `sh`, `node`, …), and a known TERM.
|
|
52
|
+
*
|
|
53
|
+
* The environment is deliberately minimal rather than a copy of `process.env` — a PTY run must not
|
|
54
|
+
* inherit real provider API keys, and an explicit base makes every variable the child can see
|
|
55
|
+
* visible at the call site. Pass `overrides` for anything else the fixture needs (e.g. `EDITOR`).
|
|
56
|
+
*/
|
|
57
|
+
export function createPtyEnv(overrides: Readonly<NodeJS.ProcessEnv> = {}): NodeJS.ProcessEnv {
|
|
58
|
+
return {
|
|
59
|
+
PATH: process.env['PATH'] ?? '',
|
|
60
|
+
HOME: createIsolatedHome(),
|
|
61
|
+
TERM: 'xterm-256color',
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-PTY organization-policy engineering regression scenario (CLI-083).
|
|
3
|
+
*
|
|
4
|
+
* Drives the built Robota CLI with an isolated HOME. Nothing injects a policy object into the
|
|
5
|
+
* command module: the refusal can only come from the shipped `loadOrgPolicy()` startup path. The
|
|
6
|
+
* update check is disabled so this isolated scenario performs no startup network request.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import { spawnTui } from './pty-driver.js';
|
|
16
|
+
|
|
17
|
+
import type { IPtySession } from './pty-driver.js';
|
|
18
|
+
|
|
19
|
+
function writeOrgPolicyFixture(homeDir: string): void {
|
|
20
|
+
const settingsDir = join(homeDir, '.robota');
|
|
21
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
22
|
+
writeFileSync(
|
|
23
|
+
join(settingsDir, 'settings.json'),
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
currentProvider: 'anthropic',
|
|
26
|
+
providers: {
|
|
27
|
+
anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
|
|
28
|
+
openai: { type: 'openai', model: 'gpt-test-model', apiKey: 'pty-dummy-key' },
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
'utf8',
|
|
32
|
+
);
|
|
33
|
+
writeFileSync(
|
|
34
|
+
join(settingsDir, 'org-policy.json'),
|
|
35
|
+
JSON.stringify({ allowedProviders: ['anthropic'], adminContact: 'ops@example.com' }),
|
|
36
|
+
'utf8',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('organization policy through the built CLI (CLI-083)', () => {
|
|
41
|
+
let projectDir: string;
|
|
42
|
+
let homeDir: string;
|
|
43
|
+
let session: IPtySession | undefined;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-pty-org-policy-')));
|
|
47
|
+
homeDir = join(projectDir, 'home');
|
|
48
|
+
writeOrgPolicyFixture(homeDir);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await session?.disposeAsync();
|
|
53
|
+
session = undefined;
|
|
54
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('blocks a provider switch forbidden only by the policy loaded from disk', async () => {
|
|
58
|
+
session = spawnTui({ projectDir, homeDir, args: ['--disable-update-check'] });
|
|
59
|
+
await session.waitFor(/Type a message or \/help/);
|
|
60
|
+
|
|
61
|
+
const since = session.outputOffset();
|
|
62
|
+
await session.sendKeys('/provider switch openai');
|
|
63
|
+
await session.pressEnter();
|
|
64
|
+
|
|
65
|
+
await session.waitForSince(
|
|
66
|
+
since,
|
|
67
|
+
/Provider "openai" is not allowed by your organization policy/,
|
|
68
|
+
);
|
|
69
|
+
expect(session.snapshotSince(since)).toContain('Allowed: anthropic');
|
|
70
|
+
}, 60_000);
|
|
71
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-PTY provider-setup ask suite (CMD-004 TC-09 done-gate).
|
|
3
|
+
*
|
|
4
|
+
* Drives the BUILT robota binary through `/provider add` so the unified ask seam renders end-to-end on
|
|
5
|
+
* a real terminal: the provider command self-asks (via the injected `askHandler` → `TuiInteractionChannel.askUser`)
|
|
6
|
+
* and the TUI renders the request through `PendingActionPrompt`. Proves the masked secret field hides
|
|
7
|
+
* the typed API key on the real CLI — the User Execution evidence the done-gate requires (no human
|
|
8
|
+
* needed; the PTY drives the binary). Run via `pnpm --filter @robota-sdk/agent-ui-terminal test:pty`
|
|
9
|
+
* against a freshly built CLI.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
19
|
+
|
|
20
|
+
import type { IPtySession } from './pty-driver.js';
|
|
21
|
+
|
|
22
|
+
const SECRET = 'topsecretapikey';
|
|
23
|
+
|
|
24
|
+
describe('provider setup ask through a real PTY (CMD-004 TC-09)', () => {
|
|
25
|
+
let projectDir: string;
|
|
26
|
+
let session: IPtySession | undefined;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-pty-provider-')));
|
|
30
|
+
writeTuiProviderSettings(projectDir);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await session?.disposeAsync();
|
|
35
|
+
session = undefined;
|
|
36
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('TC-09: /provider add asks the masked API-key field and never echoes the secret', async () => {
|
|
40
|
+
session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
|
|
41
|
+
await session.waitFor(/Type a message or \/help/);
|
|
42
|
+
|
|
43
|
+
// The provider command self-asks; the first anthropic setup step is the masked API key.
|
|
44
|
+
await session.sendKeys('/provider add anthropic');
|
|
45
|
+
await session.pressEnter();
|
|
46
|
+
await session.waitFor(/Anthropic API key/);
|
|
47
|
+
|
|
48
|
+
// Type the secret at human key rate; the masked field must render `*`, not the plaintext.
|
|
49
|
+
await session.sendKeys(SECRET);
|
|
50
|
+
await session.waitFor(new RegExp(`\\*{${SECRET.length}}`));
|
|
51
|
+
|
|
52
|
+
const masked = session.snapshot();
|
|
53
|
+
expect(masked).not.toContain(SECRET);
|
|
54
|
+
expect(masked).toContain('*'.repeat(SECRET.length));
|
|
55
|
+
|
|
56
|
+
// Esc cancels the wizard (no settings write / restart effect); the secret stays off-screen.
|
|
57
|
+
session.pressEscape();
|
|
58
|
+
await session.waitFor(/Provider setup cancelled\./);
|
|
59
|
+
expect(session.snapshot()).not.toContain(SECRET);
|
|
60
|
+
}, 60_000);
|
|
61
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY TUI driver (CLI-074 TC-07/08).
|
|
3
|
+
*
|
|
4
|
+
* Built-CLI convenience over the shared `spawnPty` harness (TEST-007): spawns the built robota CLI in
|
|
5
|
+
* a real pseudo-terminal so Ink renders exactly as in a user terminal, with per-key paced input
|
|
6
|
+
* (expect(1)-style burst input gets bundled as a bracketed paste — the failure mode this driver
|
|
7
|
+
* exists to avoid). Test-only; lives in a dedicated vitest project (*.ptytest.ts).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { spawnPty } from './spawn-pty.js';
|
|
14
|
+
|
|
15
|
+
import type { IPtyRunSession } from './spawn-pty.js';
|
|
16
|
+
|
|
17
|
+
const REPO_ROOT = resolve(__dirname, '../../../../..');
|
|
18
|
+
const ROBOTA_BIN = join(REPO_ROOT, 'packages/agent-cli/bin/robota.cjs');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Signal the PTY child and await its ACTUAL exit before the caller removes the project dir.
|
|
22
|
+
* `dispose()` sends SIGTERM (then SIGKILL after the grace window) but returns synchronously;
|
|
23
|
+
* `expectExit()` resolves once the child has exited and **throws** if it has not within the timeout.
|
|
24
|
+
* Awaiting this closes the INFRA-026 race where a dying child writes `<projectDir>/.robota` between
|
|
25
|
+
* `rmSync`'s readdir and rmdir. A child that refuses to die fails the test loudly — the exit is awaited
|
|
26
|
+
* at the source, never masked by a removal fallback.
|
|
27
|
+
* Extracted so the compose is unit-testable with a fake session.
|
|
28
|
+
*/
|
|
29
|
+
export async function killAndAwaitExit(
|
|
30
|
+
session: Pick<IPtyRunSession, 'dispose' | 'expectExit'>,
|
|
31
|
+
timeoutMs?: number,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
session.dispose();
|
|
34
|
+
await session.expectExit(timeoutMs);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Driving session for a built-CLI PTY run. Mirrors the historical `spawnTui` surface (kept for the
|
|
39
|
+
* existing `*.ptytest.ts` suites) on top of the shared `IPtyRunSession`.
|
|
40
|
+
*/
|
|
41
|
+
export interface IPtySession {
|
|
42
|
+
sendKeys(text: string, perKeyDelayMs?: number): Promise<void>;
|
|
43
|
+
pressEnter(): Promise<void>;
|
|
44
|
+
/** Send a raw Escape keystroke (cancels a pending Ink prompt). */
|
|
45
|
+
pressEscape(): void;
|
|
46
|
+
/**
|
|
47
|
+
* Write bytes verbatim, with no per-character pacing. An escape SEQUENCE must come this way:
|
|
48
|
+
* `sendKeys` paces character by character, so `\x1b[B` arrives as a lone Escape — which an
|
|
49
|
+
* overlay reads as "cancel" — followed by the literal text `[B` typed into the composer.
|
|
50
|
+
*/
|
|
51
|
+
writeRaw(data: string): void;
|
|
52
|
+
waitFor(pattern: RegExp, timeoutMs?: number): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Mark for `waitForSince`/`snapshotSince`. `snapshot()` is a CUMULATIVE transcript (old frames +
|
|
55
|
+
* the echo of your own typed input included) — take a mark before acting and assert on what
|
|
56
|
+
* arrived after it, or a pattern can match stale frames / your own prompt text.
|
|
57
|
+
*/
|
|
58
|
+
outputOffset(): number;
|
|
59
|
+
/** Like `waitFor`, but only matches output that arrived after the `since` mark. */
|
|
60
|
+
waitForSince(since: number, pattern: RegExp, timeoutMs?: number): Promise<void>;
|
|
61
|
+
/** ANSI-stripped output that arrived after the `since` mark. */
|
|
62
|
+
snapshotSince(since: number): string;
|
|
63
|
+
snapshot(): string;
|
|
64
|
+
/** Raw (un-stripped) cumulative output — for cursor/escape-sequence assertions (CLI-062). */
|
|
65
|
+
raw(): string;
|
|
66
|
+
expectExit(timeoutMs?: number): Promise<number>;
|
|
67
|
+
kill(): void;
|
|
68
|
+
/**
|
|
69
|
+
* Teardown that awaits ACTUAL child exit: signals the PTY child (`kill()`/`dispose()`) then awaits
|
|
70
|
+
* `expectExit`, which resolves once the child has exited and **throws** if it has not within the
|
|
71
|
+
* timeout (default ≥ the SIGTERM→SIGKILL grace). Await this before removing the project dir so the
|
|
72
|
+
* dying child cannot write `<projectDir>/.robota` between `rmSync`'s readdir and rmdir (INFRA-026).
|
|
73
|
+
*/
|
|
74
|
+
disposeAsync(timeoutMs?: number): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ISpawnTuiOptions {
|
|
78
|
+
/** Project cwd. The fixture provider profile lives in the isolated user HOME. */
|
|
79
|
+
projectDir: string;
|
|
80
|
+
/** Isolated HOME directory. */
|
|
81
|
+
homeDir: string;
|
|
82
|
+
cols?: number;
|
|
83
|
+
rows?: number;
|
|
84
|
+
/** Extra CLI args appended after the binary (e.g. `['--session-log', path]`). */
|
|
85
|
+
args?: readonly string[];
|
|
86
|
+
/**
|
|
87
|
+
* Extra environment variables merged over the driver's minimal base env
|
|
88
|
+
* (e.g. `{ NO_COLOR: '1' }` for the SCREEN-006 no-color scenario). The base stays
|
|
89
|
+
* explicit — real provider keys are never inherited into PTY runs.
|
|
90
|
+
*/
|
|
91
|
+
env?: Readonly<Record<string, string>>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function writeTuiProviderSettings(projectDir: string): void {
|
|
95
|
+
// ARCH-042: a capabilityless built CLI is Restricted and must not read project settings. PTY
|
|
96
|
+
// fixtures therefore configure the host-owned user layer under the same isolated HOME that
|
|
97
|
+
// `spawnTui` derives from the fixture root.
|
|
98
|
+
const settingsDir = join(projectDir, 'home', '.robota');
|
|
99
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
100
|
+
writeFileSync(
|
|
101
|
+
join(settingsDir, 'settings.json'),
|
|
102
|
+
JSON.stringify({
|
|
103
|
+
currentProvider: 'anthropic',
|
|
104
|
+
providers: {
|
|
105
|
+
// Boot/slash/exit make zero model calls — the key is never used.
|
|
106
|
+
anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
'utf8',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function spawnTui(options: ISpawnTuiOptions): IPtySession {
|
|
114
|
+
mkdirSync(options.homeDir, { recursive: true });
|
|
115
|
+
|
|
116
|
+
// An explicit session name suppresses LLM auto-naming, which fires a provider call on the first
|
|
117
|
+
// user message — with a replay/scripted provider that call silently CONSUMES the next recorded
|
|
118
|
+
// response and desyncs the fixture (lesson 2026-07-02). Tests that assert naming pass their own.
|
|
119
|
+
const args = options.args ?? [];
|
|
120
|
+
const defaultedArgs = args.includes('--name') ? args : [...args, '--name', 'pty-fixture'];
|
|
121
|
+
|
|
122
|
+
const session: IPtyRunSession = spawnPty({
|
|
123
|
+
command: process.execPath,
|
|
124
|
+
args: [ROBOTA_BIN, ...defaultedArgs],
|
|
125
|
+
cwd: options.projectDir,
|
|
126
|
+
env: {
|
|
127
|
+
PATH: process.env['PATH'] ?? '',
|
|
128
|
+
HOME: options.homeDir,
|
|
129
|
+
TERM: 'xterm-256color',
|
|
130
|
+
// Never inherit real provider keys into PTY runs.
|
|
131
|
+
...(options.env ?? {}),
|
|
132
|
+
},
|
|
133
|
+
...(options.cols !== undefined ? { cols: options.cols } : {}),
|
|
134
|
+
...(options.rows !== undefined ? { rows: options.rows } : {}),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
sendKeys: (text, perKeyDelayMs): Promise<void> => session.sendKeys(text, perKeyDelayMs),
|
|
139
|
+
pressEnter: (): Promise<void> => session.pressEnter(),
|
|
140
|
+
pressEscape: (): void => session.write('\x1b'),
|
|
141
|
+
writeRaw: (data): void => session.write(data),
|
|
142
|
+
waitFor: (pattern, timeoutMs): Promise<void> => session.waitFor(pattern, timeoutMs),
|
|
143
|
+
outputOffset: (): number => session.outputOffset(),
|
|
144
|
+
waitForSince: (since, pattern, timeoutMs): Promise<void> =>
|
|
145
|
+
session.waitForSince(since, pattern, timeoutMs),
|
|
146
|
+
snapshotSince: (since): string => session.snapshotSince(since),
|
|
147
|
+
snapshot: (): string => session.snapshot(),
|
|
148
|
+
raw: (): string => session.raw(),
|
|
149
|
+
expectExit: (timeoutMs): Promise<number> => session.expectExit(timeoutMs),
|
|
150
|
+
kill: (): void => session.dispose(),
|
|
151
|
+
disposeAsync: (timeoutMs): Promise<void> => killAndAwaitExit(session, timeoutMs),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INFRA-018: deterministic conversation through the real binary via `--session-log` (replay).
|
|
3
|
+
*
|
|
4
|
+
* Runs in the PTY vitest project against the BUILT robota CLI. Boots with
|
|
5
|
+
* `--session-log <fixture>` so the INFRA-017 ReplayProvider answers from a recorded log (no model
|
|
6
|
+
* key, no network), sends a user message, and asserts the recorded assistant response renders and
|
|
7
|
+
* commits to `<Static>` scrollback while the input stays pinned — closing SCREEN-010 TC-02/03
|
|
8
|
+
* end-to-end on the real binary.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
19
|
+
|
|
20
|
+
import type { IPtySession } from './pty-driver.js';
|
|
21
|
+
|
|
22
|
+
const FIXTURE = join(
|
|
23
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
24
|
+
'fixtures',
|
|
25
|
+
'replay-conversation.jsonl',
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
describe('replay conversation through the real binary (INFRA-018)', () => {
|
|
29
|
+
let projectDir: string;
|
|
30
|
+
let session: IPtySession | undefined;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-replay-pty-')));
|
|
34
|
+
writeTuiProviderSettings(projectDir); // dummy provider profile so the CLI boots; key never used
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await session?.disposeAsync();
|
|
39
|
+
session = undefined;
|
|
40
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('TC-01/02/03: replays a recorded assistant turn — streams, commits to scrollback, input pinned', async () => {
|
|
44
|
+
session = spawnTui({
|
|
45
|
+
projectDir,
|
|
46
|
+
homeDir: join(projectDir, 'home'),
|
|
47
|
+
args: ['--session-log', FIXTURE],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// TC-01: boots with the replay provider (no model key used).
|
|
51
|
+
await session.waitFor(/Type a message or \/help/);
|
|
52
|
+
await session.waitFor(/Idle/);
|
|
53
|
+
|
|
54
|
+
// Send a user message → the framework calls the provider → ReplayProvider returns the recorded
|
|
55
|
+
// assistant response.
|
|
56
|
+
await session.sendKeys('hello');
|
|
57
|
+
await session.pressEnter();
|
|
58
|
+
|
|
59
|
+
// TC-02: the recorded assistant response renders and commits to scrollback.
|
|
60
|
+
await session.waitFor(/REPLAYED_ANSWER_42/, 20_000);
|
|
61
|
+
|
|
62
|
+
// TC-03: the input prompt re-renders at the bottom (live region) below the committed reply.
|
|
63
|
+
await session.waitFor(/Type a message or \/help/, 20_000);
|
|
64
|
+
const snap = session.snapshot();
|
|
65
|
+
expect(snap).toContain('REPLAYED_ANSWER_42');
|
|
66
|
+
expect(snap.lastIndexOf('Type a message')).toBeGreaterThan(snap.indexOf('REPLAYED_ANSWER_42'));
|
|
67
|
+
}, 60_000);
|
|
68
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-005 User Execution Test Scenarios — prompt footers and key-hint affordance on the BUILT
|
|
3
|
+
* robota binary in a real PTY (run by `test:pty`; requires `pnpm build:deps` first).
|
|
4
|
+
*
|
|
5
|
+
* Scenario 1: `/` opens the autocomplete → unified footer + `> ` indicator on the selected row.
|
|
6
|
+
* Scenario 2: a replayed model turn drives a CMD-004 ask in each shape (single-select,
|
|
7
|
+
* multi-select with the dynamic `(min N)` segment, free-text) → all footers share the grammar.
|
|
8
|
+
* Scenario 3: a replayed Shell tool call opens the permission prompt → footer names the real keys
|
|
9
|
+
* (`←→ Navigate · Enter Confirm`, no Esc); pressing Esc resolves nothing (documented hard-stop).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
18
|
+
|
|
19
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
20
|
+
import { formatKeyHints } from '../../key-hint-footer.js';
|
|
21
|
+
import { LIST_PICKER_DEFAULT_FOOTER_HINTS } from '../../ListPicker.js';
|
|
22
|
+
import { getMultiSelectFooterHints } from '../../MultiSelectList.js';
|
|
23
|
+
import { PERMISSION_PROMPT_FOOTER_HINTS } from '../../PermissionPrompt.js';
|
|
24
|
+
import { SLASH_AUTOCOMPLETE_FOOTER_HINTS } from '../../SlashAutocomplete.js';
|
|
25
|
+
import { TEXT_PROMPT_FOOTER_HINTS } from '../../TextPrompt.js';
|
|
26
|
+
|
|
27
|
+
import type { IPtySession } from './pty-driver.js';
|
|
28
|
+
|
|
29
|
+
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
30
|
+
const ASK_SHAPES_FIXTURE = join(FIXTURES, 'screen-005-ask-shapes.jsonl');
|
|
31
|
+
const PERMISSION_FIXTURE = join(FIXTURES, 'screen-005-permission.jsonl');
|
|
32
|
+
|
|
33
|
+
function escapeRegExp(text: string): RegExp {
|
|
34
|
+
return new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
|
+
|
|
39
|
+
describe('SCREEN-005 prompt footers through the real binary', () => {
|
|
40
|
+
let projectDir: string;
|
|
41
|
+
let session: IPtySession | undefined;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-screen005-pty-')));
|
|
45
|
+
writeTuiProviderSettings(projectDir);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await session?.disposeAsync();
|
|
50
|
+
session = undefined;
|
|
51
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('S1: slash autocomplete shows the unified footer and the > indicator on the selected row', async () => {
|
|
55
|
+
session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
|
|
56
|
+
await session.waitFor(/Type a message or \/help/);
|
|
57
|
+
|
|
58
|
+
await session.sendKeys('/');
|
|
59
|
+
const expectedFooter = formatKeyHints(SLASH_AUTOCOMPLETE_FOOTER_HINTS);
|
|
60
|
+
await session.waitFor(escapeRegExp(expectedFooter), 20_000);
|
|
61
|
+
|
|
62
|
+
const frame = session.snapshot();
|
|
63
|
+
expect(frame).toContain(expectedFooter);
|
|
64
|
+
// The highlighted first row carries the selection indicator in front of the command name.
|
|
65
|
+
expect(frame).toMatch(/> \/\w+/);
|
|
66
|
+
}, 60_000);
|
|
67
|
+
|
|
68
|
+
it('S2: single-select, multi-select (dynamic min segment), and free-text asks share the grammar', async () => {
|
|
69
|
+
session = spawnTui({
|
|
70
|
+
projectDir,
|
|
71
|
+
homeDir: join(projectDir, 'home'),
|
|
72
|
+
args: ['--session-log', ASK_SHAPES_FIXTURE, '--name', 'shapes-fixture'],
|
|
73
|
+
});
|
|
74
|
+
await session.waitFor(/Type a message or \/help/);
|
|
75
|
+
|
|
76
|
+
await session.sendKeys('go');
|
|
77
|
+
await session.pressEnter();
|
|
78
|
+
|
|
79
|
+
// Shape 1 — single-select (ListPicker default footer).
|
|
80
|
+
await session.waitFor(/PICK_SINGLE/, 20_000);
|
|
81
|
+
await session.waitFor(escapeRegExp(formatKeyHints(LIST_PICKER_DEFAULT_FOOTER_HINTS)), 10_000);
|
|
82
|
+
expect(session.snapshot()).toContain('> Red'); // indicator on the highlighted option
|
|
83
|
+
await session.pressEnter(); // answer "Red"
|
|
84
|
+
|
|
85
|
+
// Shape 2 — multi-select: min segment shows until satisfiable, then drops.
|
|
86
|
+
await session.waitFor(/PICK_MULTI/, 20_000);
|
|
87
|
+
const unsatisfied = formatKeyHints(
|
|
88
|
+
getMultiSelectFooterHints({ canConfirm: false, minSelect: 1 }),
|
|
89
|
+
);
|
|
90
|
+
await session.waitFor(escapeRegExp(unsatisfied), 10_000);
|
|
91
|
+
const beforeToggle = session.outputOffset();
|
|
92
|
+
await session.sendKeys(' '); // toggle Alpha → selection satisfiable
|
|
93
|
+
const satisfied = formatKeyHints(getMultiSelectFooterHints({ canConfirm: true, minSelect: 1 }));
|
|
94
|
+
await session.waitForSince(beforeToggle, escapeRegExp(satisfied), 10_000);
|
|
95
|
+
await session.pressEnter(); // confirm
|
|
96
|
+
|
|
97
|
+
// Shape 3 — free-text (TextPrompt footer).
|
|
98
|
+
await session.waitFor(/TYPE_TEXT/, 20_000);
|
|
99
|
+
await session.waitFor(escapeRegExp(formatKeyHints(TEXT_PROMPT_FOOTER_HINTS)), 10_000);
|
|
100
|
+
await session.sendKeys('hello');
|
|
101
|
+
await session.pressEnter();
|
|
102
|
+
|
|
103
|
+
// The replayed follow-up proves every ask resolved through the shared-grammar prompts.
|
|
104
|
+
await session.waitFor(/ALL_SHAPES_DONE/, 20_000);
|
|
105
|
+
}, 60_000);
|
|
106
|
+
|
|
107
|
+
it('S3: permission prompt names the real keys and Esc resolves nothing (explicit hard-stop)', async () => {
|
|
108
|
+
session = spawnTui({
|
|
109
|
+
projectDir,
|
|
110
|
+
homeDir: join(projectDir, 'home'),
|
|
111
|
+
args: ['--session-log', PERMISSION_FIXTURE, '--name', 'perm-fixture'],
|
|
112
|
+
});
|
|
113
|
+
await session.waitFor(/Type a message or \/help/);
|
|
114
|
+
|
|
115
|
+
await session.sendKeys('go');
|
|
116
|
+
await session.pressEnter();
|
|
117
|
+
|
|
118
|
+
// The replayed Shell call needs approval in default permission mode → prompt renders.
|
|
119
|
+
await session.waitFor(/Permission Required/, 20_000);
|
|
120
|
+
const expectedFooter = formatKeyHints(PERMISSION_PROMPT_FOOTER_HINTS);
|
|
121
|
+
await session.waitFor(escapeRegExp(expectedFooter), 10_000);
|
|
122
|
+
|
|
123
|
+
// Esc is suppressed (a dismissal would be an implicit deny): nothing resolves, the prompt stays.
|
|
124
|
+
session.pressEscape();
|
|
125
|
+
await delay(500);
|
|
126
|
+
const afterEsc = session.snapshot();
|
|
127
|
+
expect(afterEsc).not.toContain('AFTER_PERMISSION_FLOW');
|
|
128
|
+
expect(afterEsc).toContain('Allow [y]'); // prompt still displayed, still unresolved
|
|
129
|
+
|
|
130
|
+
// Resolve explicitly (allow) → the tool runs and the turn continues.
|
|
131
|
+
await session.sendKeys('y');
|
|
132
|
+
await session.waitFor(/AFTER_PERMISSION_FLOW/, 20_000);
|
|
133
|
+
}, 60_000);
|
|
134
|
+
});
|