@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,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-1994 TC-08 / TC-09 — the `attach` control, and what it refuses.
|
|
3
|
+
*
|
|
4
|
+
* Attach is the only genuinely new user surface this item adds, and the whole of its meaning is that
|
|
5
|
+
* it is a VIEW switch: the terminal starts looking at the fork's own session record, and neither
|
|
6
|
+
* record is written or merged. So what these cases pin is the pair — which entries offer the
|
|
7
|
+
* control, and that selecting it switches this terminal onto the FORK's record — reporting the
|
|
8
|
+
* FORK's id — plus the three refusals, each of which must reach the operator with its reason rather
|
|
9
|
+
* than leaving the terminal pointed at nothing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { render } from 'ink-testing-library';
|
|
13
|
+
import React from 'react';
|
|
14
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
15
|
+
|
|
16
|
+
import ExecutionWorkspaceSwitcher, {
|
|
17
|
+
EXECUTION_WORKSPACE_ATTACH_HINT,
|
|
18
|
+
} from '../ExecutionWorkspaceSwitcher.js';
|
|
19
|
+
import { attachToForkedSession } from '../flows/fork-attach-flow.js';
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
IExecutionWorkspaceEntry,
|
|
23
|
+
IExecutionWorkspaceSnapshot,
|
|
24
|
+
TExecutionControl,
|
|
25
|
+
} from '@robota-sdk/agent-interface-execution';
|
|
26
|
+
|
|
27
|
+
const FORK_SESSION_ID = 'session_cli-1994-fork';
|
|
28
|
+
|
|
29
|
+
function makeForkEntry(
|
|
30
|
+
overrides: Partial<IExecutionWorkspaceEntry> = {},
|
|
31
|
+
): IExecutionWorkspaceEntry {
|
|
32
|
+
return {
|
|
33
|
+
id: 'task:agent_1',
|
|
34
|
+
sourceId: 'agent_1',
|
|
35
|
+
kind: 'background_task',
|
|
36
|
+
taskKind: 'agent',
|
|
37
|
+
origin: { kind: 'slash_command', sessionId: 'session_parent' },
|
|
38
|
+
status: 'running',
|
|
39
|
+
state: 'working',
|
|
40
|
+
title: 'experiment',
|
|
41
|
+
subtitle: 'general-purpose',
|
|
42
|
+
unread: false,
|
|
43
|
+
attention: 'none',
|
|
44
|
+
visibility: 'default',
|
|
45
|
+
updatedAt: '2026-09-07T00:00:00.000Z',
|
|
46
|
+
controls: ['select', 'cancel', 'attach'],
|
|
47
|
+
resumeSessionId: FORK_SESSION_ID,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makePlainEntry(): IExecutionWorkspaceEntry {
|
|
53
|
+
const { resumeSessionId: _dropped, ...entry } = makeForkEntry({
|
|
54
|
+
id: 'task:agent_2',
|
|
55
|
+
sourceId: 'agent_2',
|
|
56
|
+
title: 'plain job',
|
|
57
|
+
controls: ['select', 'cancel'],
|
|
58
|
+
});
|
|
59
|
+
return entry;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeSnapshot(entries: readonly IExecutionWorkspaceEntry[]): IExecutionWorkspaceSnapshot {
|
|
63
|
+
return {
|
|
64
|
+
sessionId: 'session_parent',
|
|
65
|
+
selectedEntryId: entries[0]?.id ?? '',
|
|
66
|
+
updatedAt: '2026-09-07T00:00:00.000Z',
|
|
67
|
+
entries: [...entries],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The store answer: which records still exist. */
|
|
72
|
+
function storeWith(ids: readonly string[]): (sessionId: string) => boolean {
|
|
73
|
+
return (sessionId) => ids.includes(sessionId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('attaching to a fork switches the view (CLI-1994 TC-08)', () => {
|
|
77
|
+
it('a forked entry switches to the FORK, not the parent', () => {
|
|
78
|
+
const switchSession = vi.fn();
|
|
79
|
+
const notify = vi.fn();
|
|
80
|
+
|
|
81
|
+
const outcome = attachToForkedSession(makeForkEntry(), {
|
|
82
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
83
|
+
switchSession,
|
|
84
|
+
notify,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(outcome).toEqual({ sessionId: FORK_SESSION_ID });
|
|
88
|
+
expect(switchSession).toHaveBeenCalledWith(FORK_SESSION_ID);
|
|
89
|
+
// A view switch, not a merge: the only thing that happened is the switch.
|
|
90
|
+
expect(notify).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('an entry with no resumeSessionId is not attachable and emits no intent', () => {
|
|
94
|
+
const switchSession = vi.fn();
|
|
95
|
+
const notify = vi.fn();
|
|
96
|
+
|
|
97
|
+
const intent = attachToForkedSession(makePlainEntry(), {
|
|
98
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
99
|
+
switchSession,
|
|
100
|
+
notify,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(intent).toBeUndefined();
|
|
104
|
+
expect(switchSession).not.toHaveBeenCalled();
|
|
105
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining('not a forked session'));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('the switcher offers the attach hint on a forked entry and calls onAttach for the `a` key', () => {
|
|
109
|
+
const onAttach = vi.fn();
|
|
110
|
+
const entry = makeForkEntry();
|
|
111
|
+
const { lastFrame, stdin } = render(
|
|
112
|
+
<ExecutionWorkspaceSwitcher
|
|
113
|
+
snapshot={makeSnapshot([entry])}
|
|
114
|
+
selectedEntryId={entry.id}
|
|
115
|
+
onSelect={vi.fn()}
|
|
116
|
+
onClose={vi.fn()}
|
|
117
|
+
onAttach={onAttach}
|
|
118
|
+
/>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(lastFrame()).toContain(EXECUTION_WORKSPACE_ATTACH_HINT.label);
|
|
122
|
+
|
|
123
|
+
stdin.write('a');
|
|
124
|
+
|
|
125
|
+
expect(onAttach).toHaveBeenCalledWith(entry);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('an entry without the attach control neither offers the hint nor answers the key', () => {
|
|
129
|
+
const onAttach = vi.fn();
|
|
130
|
+
const entry = makePlainEntry();
|
|
131
|
+
const { lastFrame, stdin } = render(
|
|
132
|
+
<ExecutionWorkspaceSwitcher
|
|
133
|
+
snapshot={makeSnapshot([entry])}
|
|
134
|
+
selectedEntryId={entry.id}
|
|
135
|
+
onSelect={vi.fn()}
|
|
136
|
+
onClose={vi.fn()}
|
|
137
|
+
onAttach={onAttach}
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(lastFrame()).not.toContain(EXECUTION_WORKSPACE_ATTACH_HINT.label);
|
|
142
|
+
|
|
143
|
+
stdin.write('a');
|
|
144
|
+
|
|
145
|
+
expect(onAttach).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('`attach` is a member of TExecutionControl — every control list still accepts it', () => {
|
|
149
|
+
// The runtime half of the union change; the compile half is the package `typecheck` this suite
|
|
150
|
+
// is paired with in the spec's TC-08, which reaches every exhaustive switch over the union.
|
|
151
|
+
const controls: readonly TExecutionControl[] = [
|
|
152
|
+
'select',
|
|
153
|
+
'cancel',
|
|
154
|
+
'close',
|
|
155
|
+
'send',
|
|
156
|
+
'read_log',
|
|
157
|
+
'wait',
|
|
158
|
+
'attach',
|
|
159
|
+
];
|
|
160
|
+
expect(controls).toContain('attach');
|
|
161
|
+
expect(makeForkEntry().controls).toContain('attach');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('attach refuses rather than stranding the terminal (CLI-1994 TC-09)', () => {
|
|
166
|
+
it.each(['completed', 'failed', 'cancelled'] as const)(
|
|
167
|
+
'a %s fork is refused with its status, and no intent is emitted',
|
|
168
|
+
(status) => {
|
|
169
|
+
const switchSession = vi.fn();
|
|
170
|
+
const notify = vi.fn();
|
|
171
|
+
|
|
172
|
+
const intent = attachToForkedSession(makeForkEntry({ status }), {
|
|
173
|
+
// The record is still there; the TASK being terminal is what refuses.
|
|
174
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
175
|
+
switchSession,
|
|
176
|
+
notify,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(intent).toBeUndefined();
|
|
180
|
+
expect(switchSession).not.toHaveBeenCalled();
|
|
181
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining(status));
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
it('uses a host-supplied resume command for a terminal fork while a neutral host gets the session id', () => {
|
|
186
|
+
const entry = makeForkEntry({ status: 'completed' });
|
|
187
|
+
const neutral = vi.fn();
|
|
188
|
+
attachToForkedSession(entry, {
|
|
189
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
190
|
+
switchSession: vi.fn(),
|
|
191
|
+
notify: neutral,
|
|
192
|
+
});
|
|
193
|
+
expect(neutral.mock.calls[0]?.[0]).toContain(FORK_SESSION_ID);
|
|
194
|
+
expect(neutral.mock.calls[0]?.[0]).not.toContain('robota');
|
|
195
|
+
|
|
196
|
+
const product = vi.fn();
|
|
197
|
+
attachToForkedSession(entry, {
|
|
198
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
199
|
+
switchSession: vi.fn(),
|
|
200
|
+
notify: product,
|
|
201
|
+
formatResumeCommand: (id) => `robota --resume ${id}`,
|
|
202
|
+
});
|
|
203
|
+
expect(product.mock.calls[0]?.[0]).toContain(`robota --resume ${FORK_SESSION_ID}`);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('a missing session record is refused, naming the record and the task status', () => {
|
|
207
|
+
const switchSession = vi.fn();
|
|
208
|
+
const notify = vi.fn();
|
|
209
|
+
|
|
210
|
+
const intent = attachToForkedSession(makeForkEntry({ status: 'running' }), {
|
|
211
|
+
hasSessionRecord: storeWith([]),
|
|
212
|
+
switchSession,
|
|
213
|
+
notify,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(intent).toBeUndefined();
|
|
217
|
+
expect(switchSession).not.toHaveBeenCalled();
|
|
218
|
+
const reason = notify.mock.calls[0]?.[0] as string;
|
|
219
|
+
expect(reason).toContain(FORK_SESSION_ID);
|
|
220
|
+
expect(reason).toContain('running');
|
|
221
|
+
expect(reason).toContain('no longer in the session store');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('a live fork whose record exists is the ONLY case that switches', () => {
|
|
225
|
+
const switchSession = vi.fn();
|
|
226
|
+
|
|
227
|
+
attachToForkedSession(makeForkEntry({ status: 'waiting_permission' }), {
|
|
228
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
229
|
+
switchSession,
|
|
230
|
+
notify: vi.fn(),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(switchSession).toHaveBeenCalledTimes(1);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-062 — the terminal matrix, as data.
|
|
3
|
+
*
|
|
4
|
+
* The backlog item's remaining work was a *manual* matrix ("iTerm2 + Terminal.app
|
|
5
|
+
* ±ROBOTA_IME_CURSOR, kitty/WezTerm/Ghostty/Windows Terminal/tmux on real hardware"). This module
|
|
6
|
+
* converts it into something a machine re-runs: each row is the environment handshake a terminal
|
|
7
|
+
* emulator hands the program it launches, which is the ONLY channel through which the emulator's
|
|
8
|
+
* identity reaches our code (`supportsImeCursorPositioning()` reads `TERM_PROGRAM`).
|
|
9
|
+
*
|
|
10
|
+
* Two consumers share this table so the gate is never asserted in isolation from behaviour:
|
|
11
|
+
* - `terminal-capabilities.test.ts` — what `supportsImeCursorPositioning()` returns per row.
|
|
12
|
+
* - `pty/ime-cursor.ptytest.ts` — what the REAL binary then does under a real pty per row.
|
|
13
|
+
*
|
|
14
|
+
* `provenance` is deliberately explicit about how each row's values were obtained. `measured`
|
|
15
|
+
* rows were captured by launching that emulator on the machine that recorded them and dumping the
|
|
16
|
+
* child environment; `documented` rows come from the vendor's documented behaviour because the
|
|
17
|
+
* emulator cannot run on Linux (macOS/Windows) or is not installed. A documented row still gives
|
|
18
|
+
* real coverage of OUR code — the branch it selects is exercised end-to-end — but it is NOT
|
|
19
|
+
* evidence about that emulator's own rendering or its OS IME, and must never be reported as such.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type TerminalProvenance = 'measured' | 'documented';
|
|
23
|
+
|
|
24
|
+
export interface ITerminalProfile {
|
|
25
|
+
/** Stable id used in test names and in the backlog matrix table. */
|
|
26
|
+
id: string;
|
|
27
|
+
/** Human label for the matrix. */
|
|
28
|
+
label: string;
|
|
29
|
+
/** Environment the emulator exports to its child process. */
|
|
30
|
+
env: Readonly<Record<string, string>>;
|
|
31
|
+
/** How the env values above were obtained. */
|
|
32
|
+
provenance: TerminalProvenance;
|
|
33
|
+
/** Free-text provenance detail (version, capture method, or the vendor fact relied on). */
|
|
34
|
+
note: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const TERMINAL_PROFILES: readonly ITerminalProfile[] = [
|
|
38
|
+
{
|
|
39
|
+
id: 'bare-tty',
|
|
40
|
+
label: 'bare TTY (no TERM_PROGRAM)',
|
|
41
|
+
env: { TERM: 'xterm-256color' },
|
|
42
|
+
provenance: 'measured',
|
|
43
|
+
note: 'Baseline: a pty that identifies nothing. This is also what the pty harness itself provides.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'ghostty',
|
|
47
|
+
label: 'Ghostty',
|
|
48
|
+
env: {
|
|
49
|
+
TERM: 'xterm-ghostty',
|
|
50
|
+
TERM_PROGRAM: 'ghostty',
|
|
51
|
+
TERM_PROGRAM_VERSION: '1.3.1',
|
|
52
|
+
COLORTERM: 'truecolor',
|
|
53
|
+
},
|
|
54
|
+
provenance: 'measured',
|
|
55
|
+
note: 'Captured from Ghostty 1.3.1 (Linux/GTK4) by launching it with `-e <env dump>`.',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'gnome-terminal',
|
|
59
|
+
label: 'GNOME Terminal (VTE)',
|
|
60
|
+
env: { TERM: 'xterm-256color', COLORTERM: 'truecolor' },
|
|
61
|
+
provenance: 'measured',
|
|
62
|
+
note: 'Captured from GNOME Terminal 3.52.0 / VTE 0.76.0; VTE sets no TERM_PROGRAM.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'tmux',
|
|
66
|
+
label: 'tmux (multiplexer)',
|
|
67
|
+
env: { TERM: 'tmux-256color', TERM_PROGRAM: 'tmux', TMUX: '/tmp/tmux-fixture,1,0' },
|
|
68
|
+
provenance: 'measured',
|
|
69
|
+
note: 'Captured from tmux 3.4 inside a real pane; tmux 3.4 sets TERM_PROGRAM=tmux. The real emulator is additionally driven end-to-end in pty/ime-cursor-tmux.ptytest.ts.',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'kitty',
|
|
73
|
+
label: 'kitty',
|
|
74
|
+
env: { TERM: 'xterm-kitty', KITTY_WINDOW_ID: '1', COLORTERM: 'truecolor' },
|
|
75
|
+
provenance: 'documented',
|
|
76
|
+
note: 'kitty identifies itself via TERM=xterm-kitty + KITTY_WINDOW_ID and sets no TERM_PROGRAM.',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'wezterm',
|
|
80
|
+
label: 'WezTerm',
|
|
81
|
+
env: { TERM: 'xterm-256color', TERM_PROGRAM: 'WezTerm', TERM_PROGRAM_VERSION: '20240203' },
|
|
82
|
+
provenance: 'documented',
|
|
83
|
+
note: 'WezTerm sets TERM_PROGRAM=WezTerm.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'windows-terminal',
|
|
87
|
+
label: 'Windows Terminal',
|
|
88
|
+
env: { TERM: 'xterm-256color', WT_SESSION: '00000000-0000-0000-0000-000000000000' },
|
|
89
|
+
provenance: 'documented',
|
|
90
|
+
note: 'Windows Terminal marks its sessions with WT_SESSION and sets no TERM_PROGRAM.',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'iterm2',
|
|
94
|
+
label: 'iTerm2 (macOS)',
|
|
95
|
+
env: { TERM: 'xterm-256color', TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.5.0' },
|
|
96
|
+
provenance: 'documented',
|
|
97
|
+
note: 'macOS-only emulator: the env branch is exercised here, its rendering and its OS IME are not.',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'apple-terminal',
|
|
101
|
+
label: 'Terminal.app (macOS)',
|
|
102
|
+
env: { TERM: 'xterm-256color', TERM_PROGRAM: 'Apple_Terminal', TERM_PROGRAM_VERSION: '455' },
|
|
103
|
+
provenance: 'documented',
|
|
104
|
+
note: 'macOS-only emulator, and the ONE profile that is off by default (invariant I5). The env branch is exercised here; the historical Korean-IME SIGSEGV can only be re-checked on macOS.',
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/** `ROBOTA_IME_CURSOR` settings the matrix sweeps. `undefined` means the variable is absent. */
|
|
109
|
+
export const IME_CURSOR_SETTINGS: readonly (string | undefined)[] = [undefined, '1', '0'];
|
|
110
|
+
|
|
111
|
+
export const REPRESENTATIVE_IME_PTY_CELLS: readonly {
|
|
112
|
+
profileId: string;
|
|
113
|
+
override: string | undefined;
|
|
114
|
+
}[] = [
|
|
115
|
+
{ profileId: 'ghostty', override: undefined },
|
|
116
|
+
{ profileId: 'apple-terminal', override: undefined },
|
|
117
|
+
{ profileId: 'apple-terminal', override: '1' },
|
|
118
|
+
{ profileId: 'ghostty', override: '0' },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The expected `supportsImeCursorPositioning()` verdict for a matrix cell, derived from the
|
|
123
|
+
* documented precedence (explicit override wins; Apple_Terminal is off by default; every other
|
|
124
|
+
* interactive TTY is on). Both the unit matrix and the pty matrix assert against this, so the
|
|
125
|
+
* capability detection and the observable behaviour can never drift apart silently.
|
|
126
|
+
*/
|
|
127
|
+
export function expectedImeCursorEnabled(
|
|
128
|
+
profile: ITerminalProfile,
|
|
129
|
+
override: string | undefined,
|
|
130
|
+
): boolean {
|
|
131
|
+
if (override === '1') return true;
|
|
132
|
+
if (override === '0') return false;
|
|
133
|
+
return profile.env['TERM_PROGRAM'] !== 'Apple_Terminal';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Environment for a child process running "under" `profile` with the given override. */
|
|
137
|
+
export function profileEnv(
|
|
138
|
+
profile: ITerminalProfile,
|
|
139
|
+
override: string | undefined,
|
|
140
|
+
): Record<string, string> {
|
|
141
|
+
return {
|
|
142
|
+
...profile.env,
|
|
143
|
+
...(override === undefined ? {} : { ROBOTA_IME_CURSOR: override }),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Matrix label used in test names and in the backlog's evidence table. */
|
|
148
|
+
export function cellLabel(profile: ITerminalProfile, override: string | undefined): string {
|
|
149
|
+
const setting =
|
|
150
|
+
override === undefined ? 'ROBOTA_IME_CURSOR unset' : `ROBOTA_IME_CURSOR=${override}`;
|
|
151
|
+
return `${profile.label} / ${setting}`;
|
|
152
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal VT interpreter for cursor-positioning assertions (CLI-062).
|
|
3
|
+
*
|
|
4
|
+
* Tracks the hardware cursor row/col on a rows x cols screen while consuming a raw ANSI stream,
|
|
5
|
+
* recording every DECTCEM show (`ESC[?25h`) event with the cursor cell it lands on and its byte
|
|
6
|
+
* offset in the stream. This is the same evidence a terminal emulator (and the OS IME) acts on —
|
|
7
|
+
* ported from the CLI-062 investigation PoC (.design/investigations/2026-07-25-cli-062-ime-cursor-design.md).
|
|
8
|
+
*
|
|
9
|
+
* Screen bookkeeping treats every printable as width 1 (wide-char display-column math is exercised
|
|
10
|
+
* by the production x computation, which the recorded `cursorTo` columns reflect directly).
|
|
11
|
+
* Understands: CR, LF, CUU/CUD/CUF/CUB (A/B/C/D), CNL/CPL (E/F), CHA (G), CUP (H/f), EL (K),
|
|
12
|
+
* ED (J, state-only), DECSET/DECRST ?25 (show/hide), OSC strings, and skips other escapes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// eslint-disable-next-line no-control-regex -- a VT interpreter parses control bytes by definition
|
|
16
|
+
const CSI_PATTERN = /^\u001b\[([0-9;?]*)([A-Za-z])/;
|
|
17
|
+
// eslint-disable-next-line no-control-regex -- a VT interpreter parses control bytes by definition
|
|
18
|
+
const OSC_PATTERN = /^\u001b\][^\u0007\u001b]*(\u0007|\u001b\\)/;
|
|
19
|
+
|
|
20
|
+
export interface ICursorShowEvent {
|
|
21
|
+
/** Screen row (0-based) the cursor was on when it was shown. */
|
|
22
|
+
row: number;
|
|
23
|
+
/** Screen column (0-based) the cursor was on when it was shown. */
|
|
24
|
+
col: number;
|
|
25
|
+
/** Byte offset of the show sequence in the interpreted stream (for since-mark filtering). */
|
|
26
|
+
offset: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IVtInterpretation {
|
|
30
|
+
/** Final cursor row. */
|
|
31
|
+
row: number;
|
|
32
|
+
/** Final cursor column. */
|
|
33
|
+
col: number;
|
|
34
|
+
/** Final DECTCEM visibility. */
|
|
35
|
+
visible: boolean;
|
|
36
|
+
/** Screen contents (width-1 bookkeeping per printable). */
|
|
37
|
+
screen: string[];
|
|
38
|
+
/** Every `ESC[?25h` with the cell it landed on. */
|
|
39
|
+
showEvents: ICursorShowEvent[];
|
|
40
|
+
/** Number of scroll-ups that occurred (content pushed past the bottom row). */
|
|
41
|
+
scrolls: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface IVtState {
|
|
45
|
+
row: number;
|
|
46
|
+
col: number;
|
|
47
|
+
visible: boolean;
|
|
48
|
+
screen: string[][];
|
|
49
|
+
scrolls: number;
|
|
50
|
+
showEvents: ICursorShowEvent[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function interpretVtStream(stream: string, rows: number, cols: number): IVtInterpretation {
|
|
54
|
+
const state: IVtState = {
|
|
55
|
+
row: 0,
|
|
56
|
+
col: 0,
|
|
57
|
+
visible: true,
|
|
58
|
+
screen: Array.from({ length: rows }, () => Array<string>(cols).fill(' ')),
|
|
59
|
+
scrolls: 0,
|
|
60
|
+
showEvents: [],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let i = 0;
|
|
64
|
+
while (i < stream.length) {
|
|
65
|
+
const ch = stream[i]!;
|
|
66
|
+
if (ch === '\r') {
|
|
67
|
+
state.col = 0;
|
|
68
|
+
i++;
|
|
69
|
+
} else if (ch === '\n') {
|
|
70
|
+
lineFeed(state, rows, cols);
|
|
71
|
+
state.col = 0;
|
|
72
|
+
i++;
|
|
73
|
+
} else if (ch === '\u001b') {
|
|
74
|
+
i += consumeEscape(state, stream, i, rows, cols);
|
|
75
|
+
} else {
|
|
76
|
+
if (ch >= ' ') {
|
|
77
|
+
state.screen[state.row]![state.col] = ch;
|
|
78
|
+
state.col = Math.min(cols - 1, state.col + 1);
|
|
79
|
+
}
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
row: state.row,
|
|
86
|
+
col: state.col,
|
|
87
|
+
visible: state.visible,
|
|
88
|
+
screen: state.screen.map((line) => line.join('')),
|
|
89
|
+
showEvents: state.showEvents,
|
|
90
|
+
scrolls: state.scrolls,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function lineFeed(state: IVtState, rows: number, cols: number): void {
|
|
95
|
+
if (state.row === rows - 1) {
|
|
96
|
+
state.screen.shift();
|
|
97
|
+
state.screen.push(Array<string>(cols).fill(' '));
|
|
98
|
+
state.scrolls++;
|
|
99
|
+
} else {
|
|
100
|
+
state.row++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Consume one escape sequence at `start`; returns its length in the stream. */
|
|
105
|
+
function consumeEscape(
|
|
106
|
+
state: IVtState,
|
|
107
|
+
stream: string,
|
|
108
|
+
start: number,
|
|
109
|
+
rows: number,
|
|
110
|
+
cols: number,
|
|
111
|
+
): number {
|
|
112
|
+
const csi = CSI_PATTERN.exec(stream.slice(start));
|
|
113
|
+
if (csi) {
|
|
114
|
+
const [full, params = '', cmd = ''] = csi;
|
|
115
|
+
applyCsi(state, params, cmd, start, rows, cols);
|
|
116
|
+
return full.length;
|
|
117
|
+
}
|
|
118
|
+
const osc = OSC_PATTERN.exec(stream.slice(start));
|
|
119
|
+
if (osc) {
|
|
120
|
+
return osc[0].length;
|
|
121
|
+
}
|
|
122
|
+
return 2;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function applyCsi(
|
|
126
|
+
state: IVtState,
|
|
127
|
+
params: string,
|
|
128
|
+
cmd: string,
|
|
129
|
+
offset: number,
|
|
130
|
+
rows: number,
|
|
131
|
+
cols: number,
|
|
132
|
+
): void {
|
|
133
|
+
if (applyCursorMove(state, params, cmd, rows, cols)) return;
|
|
134
|
+
if (cmd === 'K') {
|
|
135
|
+
const from = params === '1' ? 0 : state.col;
|
|
136
|
+
const to = params === '1' ? state.col + 1 : cols;
|
|
137
|
+
for (let c = from; c < to; c++) state.screen[state.row]![c] = ' ';
|
|
138
|
+
} else if (cmd === 'h' && params === '?25') {
|
|
139
|
+
state.visible = true;
|
|
140
|
+
state.showEvents.push({ row: state.row, col: state.col, offset });
|
|
141
|
+
} else if (cmd === 'l' && params === '?25') {
|
|
142
|
+
state.visible = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Cursor-movement CSI commands; returns true when the command was one of them. */
|
|
147
|
+
function applyCursorMove(
|
|
148
|
+
state: IVtState,
|
|
149
|
+
params: string,
|
|
150
|
+
cmd: string,
|
|
151
|
+
rows: number,
|
|
152
|
+
cols: number,
|
|
153
|
+
): boolean {
|
|
154
|
+
const n = Number.parseInt(params === '' ? '1' : params, 10) || 1;
|
|
155
|
+
if (cmd === 'A') state.row = Math.max(0, state.row - n);
|
|
156
|
+
else if (cmd === 'B') state.row = Math.min(rows - 1, state.row + n);
|
|
157
|
+
else if (cmd === 'C') state.col = Math.min(cols - 1, state.col + n);
|
|
158
|
+
else if (cmd === 'D') state.col = Math.max(0, state.col - n);
|
|
159
|
+
else if (cmd === 'E') {
|
|
160
|
+
for (let k = 0; k < n; k++) lineFeed(state, rows, cols);
|
|
161
|
+
state.col = 0;
|
|
162
|
+
} else if (cmd === 'F') {
|
|
163
|
+
state.row = Math.max(0, state.row - n);
|
|
164
|
+
state.col = 0;
|
|
165
|
+
} else if (cmd === 'G') {
|
|
166
|
+
state.col = Math.min(cols - 1, n - 1);
|
|
167
|
+
} else if (cmd === 'H' || cmd === 'f') {
|
|
168
|
+
const [r = '1', c = '1'] = params.split(';');
|
|
169
|
+
state.row = Math.min(rows - 1, (Number.parseInt(r, 10) || 1) - 1);
|
|
170
|
+
state.col = Math.min(cols - 1, (Number.parseInt(c, 10) || 1) - 1);
|
|
171
|
+
} else {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMD-004 Phase 2 Stage E — the TUI transcript follows the broadcast `history_cleared` event.
|
|
3
|
+
*
|
|
4
|
+
* Final carrier (spec decision): `conversation-history-cleared` becomes a BROADCAST session event,
|
|
5
|
+
* so a clear performed by ANY surface (a co-driving remote `/clear`, or any host-side
|
|
6
|
+
* `clearConversationHistory()` call) refreshes every attached transcript — pre-Stage-E the TUI only
|
|
7
|
+
* cleared from its OWN command result's legacy effect, so a remote clear left it stale.
|
|
8
|
+
*
|
|
9
|
+
* Runs the REAL TuiInteractionChannel over a real InteractiveSession (scripted provider) — no
|
|
10
|
+
* command-result path involved: the mutation alone must drive the transcript refresh.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createSystemMessage, messageToHistoryEntry } from '@robota-sdk/agent-core';
|
|
14
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
15
|
+
|
|
16
|
+
import { createScriptedProvider } from '@robota-sdk/agent-core/testing';
|
|
17
|
+
import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
|
|
18
|
+
|
|
19
|
+
const READY_DEADLINE_MS = 10_000;
|
|
20
|
+
const POLL_MS = 25;
|
|
21
|
+
|
|
22
|
+
async function waitUntil(predicate: () => boolean, deadlineMs = READY_DEADLINE_MS): Promise<void> {
|
|
23
|
+
const deadline = Date.now() + deadlineMs;
|
|
24
|
+
while (Date.now() < deadline) {
|
|
25
|
+
if (predicate()) return;
|
|
26
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
27
|
+
}
|
|
28
|
+
expect(predicate()).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('CMD-004 Stage E — history_cleared broadcast clears the TUI transcript', () => {
|
|
32
|
+
let channel: TuiInteractionChannel | undefined;
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await channel?.stop();
|
|
36
|
+
channel = undefined;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('a host-side conversation clear empties the transcript without any command-result path', async () => {
|
|
40
|
+
const scripted = createScriptedProvider([{ text: 'unused in this test' }]);
|
|
41
|
+
channel = new TuiInteractionChannel({
|
|
42
|
+
cwd: process.cwd(),
|
|
43
|
+
provider: scripted.provider,
|
|
44
|
+
});
|
|
45
|
+
await channel.start();
|
|
46
|
+
const session = channel.getSession();
|
|
47
|
+
await waitUntil(() => {
|
|
48
|
+
try {
|
|
49
|
+
session.getContextState();
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false; // allow-fallback: session init is asynchronous; poll until it is ready
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
channel.stateManager.addEntry(messageToHistoryEntry(createSystemMessage('stale entry')));
|
|
57
|
+
expect(channel.stateManager.history.length).toBeGreaterThan(0);
|
|
58
|
+
|
|
59
|
+
// The HOST mutation (what a co-driving remote /clear ultimately executes) — the broadcast
|
|
60
|
+
// `history_cleared` event is the ONLY carrier that can refresh this surface's transcript.
|
|
61
|
+
session.clearConversationHistory();
|
|
62
|
+
|
|
63
|
+
await waitUntil(() => channel!.stateManager.history.length === 0, 3000);
|
|
64
|
+
expect(channel.stateManager.history).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|