@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,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: ESC abort after permission prompt was shown and dismissed.
|
|
3
|
+
* Verifies that the global ESC handler remains available after overlays close.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState } from 'react';
|
|
7
|
+
import { render } from 'ink-testing-library';
|
|
8
|
+
import { Box, Text, useInput } from 'ink';
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Simulates App's global ESC handler with permission prompt overlay guard.
|
|
13
|
+
* 1. Start with "thinking" active
|
|
14
|
+
* 2. Permission prompt appears (App-level ESC ignores while overlay is active)
|
|
15
|
+
* 3. Permission resolved (App-level ESC handles abort again)
|
|
16
|
+
* 4. ESC should trigger abort
|
|
17
|
+
*/
|
|
18
|
+
function AbortAfterPermissionApp({
|
|
19
|
+
onAbort,
|
|
20
|
+
onPermissionReady,
|
|
21
|
+
}: {
|
|
22
|
+
onAbort: () => void;
|
|
23
|
+
onPermissionReady: (grantPermission: () => void) => void;
|
|
24
|
+
}): React.ReactElement {
|
|
25
|
+
const [isThinking] = useState(true);
|
|
26
|
+
const [permissionRequest, setPermissionRequest] = useState<{
|
|
27
|
+
resolve: () => void;
|
|
28
|
+
} | null>(null);
|
|
29
|
+
const [aborted, setAborted] = useState(false);
|
|
30
|
+
|
|
31
|
+
// Give parent a way to grant permission
|
|
32
|
+
React.useEffect(() => {
|
|
33
|
+
// Show permission prompt immediately
|
|
34
|
+
const pr = {
|
|
35
|
+
resolve: () => setPermissionRequest(null),
|
|
36
|
+
};
|
|
37
|
+
setPermissionRequest(pr);
|
|
38
|
+
onPermissionReady(() => pr.resolve());
|
|
39
|
+
}, [onPermissionReady]);
|
|
40
|
+
|
|
41
|
+
// App's ESC handler — same pattern as real App.tsx
|
|
42
|
+
useInput((_input: string, key: { escape: boolean }) => {
|
|
43
|
+
if (!key.escape || !isThinking) return;
|
|
44
|
+
if (permissionRequest) return;
|
|
45
|
+
setAborted(true);
|
|
46
|
+
onAbort();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Permission prompt's own useInput (when active)
|
|
50
|
+
useInput(
|
|
51
|
+
(_input: string, key: { return: boolean }) => {
|
|
52
|
+
if (key.return && permissionRequest) {
|
|
53
|
+
permissionRequest.resolve();
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{ isActive: !!permissionRequest },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Box flexDirection="column">
|
|
61
|
+
{permissionRequest && <Text color="yellow">[Permission Required]</Text>}
|
|
62
|
+
{!permissionRequest && isThinking && <Text color="cyan">Streaming...</Text>}
|
|
63
|
+
{aborted && <Text color="red">Aborted!</Text>}
|
|
64
|
+
<Text dimColor>
|
|
65
|
+
thinking={String(isThinking)} permission={String(!!permissionRequest)} aborted=
|
|
66
|
+
{String(aborted)}
|
|
67
|
+
</Text>
|
|
68
|
+
</Box>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('ESC abort after permission prompt', () => {
|
|
73
|
+
it('ESC works when no permission prompt was shown', async () => {
|
|
74
|
+
let abortCalled = false;
|
|
75
|
+
const grantHolder: { fn: (() => void) | null } = { fn: null };
|
|
76
|
+
|
|
77
|
+
const { stdin, lastFrame } = render(
|
|
78
|
+
<AbortAfterPermissionApp
|
|
79
|
+
onAbort={() => {
|
|
80
|
+
abortCalled = true;
|
|
81
|
+
}}
|
|
82
|
+
onPermissionReady={(fn) => {
|
|
83
|
+
grantHolder.fn = fn;
|
|
84
|
+
}}
|
|
85
|
+
/>,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Wait for mount
|
|
89
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
90
|
+
|
|
91
|
+
// Grant permission immediately
|
|
92
|
+
grantHolder.fn?.();
|
|
93
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
94
|
+
|
|
95
|
+
// Now ESC should work
|
|
96
|
+
stdin.write('\x1B');
|
|
97
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
98
|
+
|
|
99
|
+
expect(abortCalled).toBe(true);
|
|
100
|
+
expect(lastFrame()!).toContain('Aborted!');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('ESC works AFTER permission prompt was shown and dismissed', async () => {
|
|
104
|
+
let abortCalled = false;
|
|
105
|
+
const grantHolder: { fn: (() => void) | null } = { fn: null };
|
|
106
|
+
|
|
107
|
+
const { stdin, lastFrame } = render(
|
|
108
|
+
<AbortAfterPermissionApp
|
|
109
|
+
onAbort={() => {
|
|
110
|
+
abortCalled = true;
|
|
111
|
+
}}
|
|
112
|
+
onPermissionReady={(fn) => {
|
|
113
|
+
grantHolder.fn = fn;
|
|
114
|
+
}}
|
|
115
|
+
/>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Wait for permission prompt to appear
|
|
119
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
120
|
+
expect(lastFrame()!).toContain('[Permission Required]');
|
|
121
|
+
|
|
122
|
+
// Grant permission (dismiss prompt)
|
|
123
|
+
grantHolder.fn?.();
|
|
124
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
125
|
+
|
|
126
|
+
// Permission dismissed, streaming should show
|
|
127
|
+
expect(lastFrame()!).toContain('Streaming...');
|
|
128
|
+
expect(lastFrame()!).not.toContain('[Permission Required]');
|
|
129
|
+
|
|
130
|
+
// Now press ESC — should trigger abort
|
|
131
|
+
stdin.write('\x1B');
|
|
132
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
133
|
+
|
|
134
|
+
expect(abortCalled).toBe(true);
|
|
135
|
+
expect(lastFrame()!).toContain('Aborted!');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('ESC does NOT work during permission prompt', async () => {
|
|
139
|
+
let abortCalled = false;
|
|
140
|
+
|
|
141
|
+
const { stdin, lastFrame } = render(
|
|
142
|
+
<AbortAfterPermissionApp
|
|
143
|
+
onAbort={() => {
|
|
144
|
+
abortCalled = true;
|
|
145
|
+
}}
|
|
146
|
+
onPermissionReady={() => {}}
|
|
147
|
+
/>,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
151
|
+
expect(lastFrame()!).toContain('[Permission Required]');
|
|
152
|
+
|
|
153
|
+
// ESC during permission prompt should NOT trigger abort
|
|
154
|
+
stdin.write('\x1B');
|
|
155
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
156
|
+
|
|
157
|
+
expect(abortCalled).toBe(false);
|
|
158
|
+
expect(lastFrame()!).not.toContain('Aborted!');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E-style test for abort during streaming.
|
|
3
|
+
* Uses ink-testing-library to verify that:
|
|
4
|
+
* 1. Streaming text debounce works (renders batched, not per-delta)
|
|
5
|
+
* 2. ESC during streaming triggers abort
|
|
6
|
+
* 3. Partial text is preserved after abort
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
10
|
+
import { render } from 'ink-testing-library';
|
|
11
|
+
import { Box, Text, useInput } from 'ink';
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal streaming component that simulates the debounced onTextDelta pattern.
|
|
16
|
+
* Accepts deltas via a callback ref, renders accumulated text.
|
|
17
|
+
*/
|
|
18
|
+
function StreamingTestApp({
|
|
19
|
+
onReady,
|
|
20
|
+
onAbort,
|
|
21
|
+
}: {
|
|
22
|
+
onReady: (appendDelta: (text: string) => void) => void;
|
|
23
|
+
onAbort: () => void;
|
|
24
|
+
}): React.ReactElement {
|
|
25
|
+
const [text, setText] = useState('');
|
|
26
|
+
const textRef = useRef('');
|
|
27
|
+
const [aborted, setAborted] = useState(false);
|
|
28
|
+
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
29
|
+
const renderCountRef = useRef(0);
|
|
30
|
+
|
|
31
|
+
// Debounced delta handler (same pattern as InteractiveSession)
|
|
32
|
+
const appendDelta = useCallback((delta: string) => {
|
|
33
|
+
textRef.current += delta;
|
|
34
|
+
if (!flushTimerRef.current) {
|
|
35
|
+
flushTimerRef.current = setTimeout(() => {
|
|
36
|
+
setText(textRef.current);
|
|
37
|
+
flushTimerRef.current = null;
|
|
38
|
+
}, 16);
|
|
39
|
+
}
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
// ESC handler
|
|
43
|
+
useInput((_input, key) => {
|
|
44
|
+
if (key.escape) {
|
|
45
|
+
setAborted(true);
|
|
46
|
+
onAbort();
|
|
47
|
+
// Force flush any pending text
|
|
48
|
+
if (flushTimerRef.current) {
|
|
49
|
+
clearTimeout(flushTimerRef.current);
|
|
50
|
+
flushTimerRef.current = null;
|
|
51
|
+
}
|
|
52
|
+
setText(textRef.current);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Notify parent that we're ready
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
onReady(appendDelta);
|
|
59
|
+
}, [onReady, appendDelta]);
|
|
60
|
+
|
|
61
|
+
renderCountRef.current++;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Box flexDirection="column">
|
|
65
|
+
<Text>{text}</Text>
|
|
66
|
+
{aborted && <Text color="yellow">Interrupted by user.</Text>}
|
|
67
|
+
<Text dimColor>renders: {renderCountRef.current}</Text>
|
|
68
|
+
</Box>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('Streaming abort E2E', () => {
|
|
73
|
+
it('debounced streaming renders fewer times than delta count', async () => {
|
|
74
|
+
let appendDelta: ((text: string) => void) | null = null;
|
|
75
|
+
|
|
76
|
+
const { lastFrame } = render(
|
|
77
|
+
React.createElement(StreamingTestApp, {
|
|
78
|
+
onReady: (fn: (text: string) => void) => {
|
|
79
|
+
appendDelta = fn;
|
|
80
|
+
},
|
|
81
|
+
onAbort: () => {},
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Wait for component to mount
|
|
86
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
87
|
+
expect(appendDelta).not.toBeNull();
|
|
88
|
+
|
|
89
|
+
// Send 20 rapid deltas
|
|
90
|
+
for (let i = 0; i < 20; i++) {
|
|
91
|
+
appendDelta!(`chunk${i} `);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Wait for debounce flush
|
|
95
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
96
|
+
|
|
97
|
+
const frame = lastFrame()!;
|
|
98
|
+
// All text should be present
|
|
99
|
+
expect(frame).toContain('chunk0');
|
|
100
|
+
expect(frame).toContain('chunk19');
|
|
101
|
+
|
|
102
|
+
// Render count should be MUCH less than 20 (debounced)
|
|
103
|
+
const renderMatch = frame.match(/renders: (\d+)/);
|
|
104
|
+
expect(renderMatch).not.toBeNull();
|
|
105
|
+
const renderCount = parseInt(renderMatch![1], 10);
|
|
106
|
+
// With 16ms debounce and ~50ms total time, expect 3-5 renders, not 20+
|
|
107
|
+
expect(renderCount).toBeLessThan(10);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('ESC during rapid streaming triggers abort and shows text', async () => {
|
|
111
|
+
let appendDelta: ((text: string) => void) | null = null;
|
|
112
|
+
let abortCalled = false;
|
|
113
|
+
|
|
114
|
+
const { stdin, lastFrame } = render(
|
|
115
|
+
React.createElement(StreamingTestApp, {
|
|
116
|
+
onReady: (fn: (text: string) => void) => {
|
|
117
|
+
appendDelta = fn;
|
|
118
|
+
},
|
|
119
|
+
onAbort: () => {
|
|
120
|
+
abortCalled = true;
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
126
|
+
|
|
127
|
+
// Send some deltas
|
|
128
|
+
for (let i = 0; i < 5; i++) {
|
|
129
|
+
appendDelta!(`line${i} `);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Press ESC
|
|
133
|
+
stdin.write('\x1B');
|
|
134
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
135
|
+
|
|
136
|
+
expect(abortCalled).toBe(true);
|
|
137
|
+
|
|
138
|
+
const frame = lastFrame()!;
|
|
139
|
+
// Text should be visible (flush on abort)
|
|
140
|
+
expect(frame).toContain('line0');
|
|
141
|
+
expect(frame).toContain('line4');
|
|
142
|
+
// Cancelled indicator
|
|
143
|
+
expect(frame).toContain('Interrupted by user.');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('ESC during ongoing streaming stops further rendering', async () => {
|
|
147
|
+
let appendDelta: ((text: string) => void) | null = null;
|
|
148
|
+
let abortCalled = false;
|
|
149
|
+
|
|
150
|
+
const { stdin, lastFrame } = render(
|
|
151
|
+
React.createElement(StreamingTestApp, {
|
|
152
|
+
onReady: (fn: (text: string) => void) => {
|
|
153
|
+
appendDelta = fn;
|
|
154
|
+
},
|
|
155
|
+
onAbort: () => {
|
|
156
|
+
abortCalled = true;
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
162
|
+
|
|
163
|
+
// Send first batch
|
|
164
|
+
appendDelta!('before_abort ');
|
|
165
|
+
|
|
166
|
+
// Wait for flush
|
|
167
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
168
|
+
|
|
169
|
+
// Press ESC
|
|
170
|
+
stdin.write('\x1B');
|
|
171
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
172
|
+
|
|
173
|
+
expect(abortCalled).toBe(true);
|
|
174
|
+
|
|
175
|
+
// Send more deltas AFTER abort (should still accumulate in ref but component should show Cancelled)
|
|
176
|
+
appendDelta!('after_abort ');
|
|
177
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
178
|
+
|
|
179
|
+
const frame = lastFrame()!;
|
|
180
|
+
expect(frame).toContain('before_abort');
|
|
181
|
+
expect(frame).toContain('Interrupted by user.');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2004 TC-08 / TC-09 — the two writers that reach the terminal outside Ink.
|
|
3
|
+
*
|
|
4
|
+
* Both are asserted against the exact BYTES, because that is the only channel a screen reader has:
|
|
5
|
+
* there is no `aria-live` in a terminal, so what a reader gets is what lands in the buffer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { BELL, createAttentionBell, LONG_TOOL_BELL_MS } from '../attention-bell.js';
|
|
11
|
+
import { supportsTurnMarks } from '../terminal-capabilities.js';
|
|
12
|
+
import { createTurnMarkWriter, turnMarkSequence } from '../terminal-marks.js';
|
|
13
|
+
|
|
14
|
+
function collector(): { written: string[]; write: (text: string) => void } {
|
|
15
|
+
const written: string[] = [];
|
|
16
|
+
return { written, write: (text: string): void => void written.push(text) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.unstubAllEnvs();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('TC-08: the attention bell', () => {
|
|
24
|
+
it('emits exactly one BEL on reply completion', () => {
|
|
25
|
+
const sink = collector();
|
|
26
|
+
createAttentionBell({ enabled: true, write: sink.write }).replyCompleted();
|
|
27
|
+
expect(sink.written).toEqual([BELL]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('emits exactly one BEL when a prompt mounts', () => {
|
|
31
|
+
const sink = collector();
|
|
32
|
+
createAttentionBell({ enabled: true, write: sink.write }).promptMounted();
|
|
33
|
+
expect(sink.written).toEqual([BELL]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rings for a tool that outlived LONG_TOOL_BELL_MS', () => {
|
|
37
|
+
const sink = collector();
|
|
38
|
+
let now = 0;
|
|
39
|
+
const bell = createAttentionBell({ enabled: true, write: sink.write, now: () => now });
|
|
40
|
+
bell.toolStarted('Bash#0');
|
|
41
|
+
now = LONG_TOOL_BELL_MS + 1;
|
|
42
|
+
bell.toolCompleted('Bash#0');
|
|
43
|
+
expect(sink.written).toEqual([BELL]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('stays silent for a tool that finished under the threshold', () => {
|
|
47
|
+
const sink = collector();
|
|
48
|
+
let now = 0;
|
|
49
|
+
const bell = createAttentionBell({ enabled: true, write: sink.write, now: () => now });
|
|
50
|
+
bell.toolStarted('Bash#0');
|
|
51
|
+
now = LONG_TOOL_BELL_MS;
|
|
52
|
+
bell.toolCompleted('Bash#0');
|
|
53
|
+
expect(sink.written).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('stays silent for a completion whose start was never recorded', () => {
|
|
57
|
+
const sink = collector();
|
|
58
|
+
const bell = createAttentionBell({ enabled: true, write: sink.write });
|
|
59
|
+
bell.toolCompleted('Bash#0');
|
|
60
|
+
expect(sink.written).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('writes nothing at all when the mode is off', () => {
|
|
64
|
+
const sink = collector();
|
|
65
|
+
let now = 0;
|
|
66
|
+
const bell = createAttentionBell({ enabled: false, write: sink.write, now: () => now });
|
|
67
|
+
bell.replyCompleted();
|
|
68
|
+
bell.promptMounted();
|
|
69
|
+
bell.toolStarted('Bash#0');
|
|
70
|
+
now = LONG_TOOL_BELL_MS * 10;
|
|
71
|
+
bell.toolCompleted('Bash#0');
|
|
72
|
+
expect(sink.written).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('TC-09: the OSC 133 turn marks', () => {
|
|
77
|
+
const A = '\x1b]133;A\x07';
|
|
78
|
+
const B = '\x1b]133;B\x07';
|
|
79
|
+
const C = '\x1b]133;C\x07';
|
|
80
|
+
const D = '\x1b]133;D\x07';
|
|
81
|
+
|
|
82
|
+
it('emits A, B, C, D in that order across one turn', () => {
|
|
83
|
+
const sink = collector();
|
|
84
|
+
const marks = createTurnMarkWriter({ enabled: true, supported: () => true, write: sink.write });
|
|
85
|
+
marks.emit('promptStart');
|
|
86
|
+
marks.emit('promptEnd');
|
|
87
|
+
marks.emit('turnStart');
|
|
88
|
+
marks.emit('turnEnd');
|
|
89
|
+
expect(sink.written).toEqual([A, B, C, D]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('spells each mark exactly', () => {
|
|
93
|
+
expect(turnMarkSequence('promptStart')).toBe(A);
|
|
94
|
+
expect(turnMarkSequence('promptEnd')).toBe(B);
|
|
95
|
+
expect(turnMarkSequence('turnStart')).toBe(C);
|
|
96
|
+
expect(turnMarkSequence('turnEnd')).toBe(D);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('emits nothing when the mode is off', () => {
|
|
100
|
+
const sink = collector();
|
|
101
|
+
const marks = createTurnMarkWriter({
|
|
102
|
+
enabled: false,
|
|
103
|
+
supported: () => true,
|
|
104
|
+
write: sink.write,
|
|
105
|
+
});
|
|
106
|
+
marks.emit('promptStart');
|
|
107
|
+
marks.emit('turnEnd');
|
|
108
|
+
expect(sink.written).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('emits nothing when supportsTurnMarks() is false', () => {
|
|
112
|
+
const sink = collector();
|
|
113
|
+
const marks = createTurnMarkWriter({
|
|
114
|
+
enabled: true,
|
|
115
|
+
supported: () => false,
|
|
116
|
+
write: sink.write,
|
|
117
|
+
});
|
|
118
|
+
marks.emit('promptStart');
|
|
119
|
+
marks.emit('turnEnd');
|
|
120
|
+
expect(sink.written).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('supportsTurnMarks — the documented negatives', () => {
|
|
125
|
+
it('is off in WezTerm, which owns OSC 133 itself', () => {
|
|
126
|
+
vi.stubEnv('ROBOTA_TURN_MARKS', '');
|
|
127
|
+
vi.stubEnv('TERM_PROGRAM', 'WezTerm');
|
|
128
|
+
expect(supportsTurnMarks()).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('honours the explicit opt-in and the kill switch over everything else', () => {
|
|
132
|
+
vi.stubEnv('TERM_PROGRAM', 'WezTerm');
|
|
133
|
+
vi.stubEnv('ROBOTA_TURN_MARKS', '1');
|
|
134
|
+
expect(supportsTurnMarks({ override: true })).toBe(true);
|
|
135
|
+
vi.stubEnv('ROBOTA_TURN_MARKS', '0');
|
|
136
|
+
expect(supportsTurnMarks({ override: false })).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('ignores an ambient Robota override without a host choice', () => {
|
|
140
|
+
vi.stubEnv('TERM_PROGRAM', 'WezTerm');
|
|
141
|
+
vi.stubEnv('ROBOTA_TURN_MARKS', '1');
|
|
142
|
+
expect(supportsTurnMarks()).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import InputArea from '../InputArea.js';
|
|
6
|
+
import { KeybindingsProvider } from '../keybindings/keybindings-context.js';
|
|
7
|
+
import { parseKeybindingsDocument } from '../keybindings/keybinding-registry.js';
|
|
8
|
+
|
|
9
|
+
import type { IKeybindingsSource } from '../keybindings/node-keybindings-source.js';
|
|
10
|
+
import type { ITuiCommandQueryPort } from '../tui-app-channel-port.js';
|
|
11
|
+
|
|
12
|
+
async function tick(ms = 25): Promise<void> {
|
|
13
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const commandQueryPort: ITuiCommandQueryPort = {
|
|
17
|
+
getCommands: (filter) =>
|
|
18
|
+
[
|
|
19
|
+
{ name: 'help', description: 'Show help', source: 'test' },
|
|
20
|
+
{ name: 'permissions', description: 'Permissions', source: 'test' },
|
|
21
|
+
].filter((command) => command.name.startsWith(filter ?? '')),
|
|
22
|
+
getSubcommands: (parent) =>
|
|
23
|
+
parent === 'permissions'
|
|
24
|
+
? [{ name: 'retry', description: 'Retry a denial', source: 'test' }]
|
|
25
|
+
: [],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('InputArea editing keys while the slash autocomplete is open', () => {
|
|
29
|
+
it('deletes with backspace', async () => {
|
|
30
|
+
const { stdin, lastFrame } = render(
|
|
31
|
+
<InputArea onSubmit={vi.fn()} isDisabled={false} commandQueryPort={commandQueryPort} />,
|
|
32
|
+
);
|
|
33
|
+
await tick();
|
|
34
|
+
stdin.write('/per');
|
|
35
|
+
await tick();
|
|
36
|
+
expect(lastFrame()).toContain('permissions');
|
|
37
|
+
|
|
38
|
+
stdin.write('\x7f');
|
|
39
|
+
await tick();
|
|
40
|
+
expect(lastFrame()).toMatch(/> \/pe$/m);
|
|
41
|
+
|
|
42
|
+
stdin.write('\x7f\x7f\x7f');
|
|
43
|
+
await tick();
|
|
44
|
+
expect(lastFrame()).toContain('Type a message or /help');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('deletes back out of a tab-completed command whose subcommands stay listed', async () => {
|
|
48
|
+
const { stdin, lastFrame } = render(
|
|
49
|
+
<InputArea onSubmit={vi.fn()} isDisabled={false} commandQueryPort={commandQueryPort} />,
|
|
50
|
+
);
|
|
51
|
+
await tick();
|
|
52
|
+
stdin.write('/per');
|
|
53
|
+
await tick();
|
|
54
|
+
stdin.write('\t');
|
|
55
|
+
await tick();
|
|
56
|
+
expect(lastFrame()).toContain('> /permissions');
|
|
57
|
+
expect(lastFrame()).toContain('retry');
|
|
58
|
+
|
|
59
|
+
stdin.write('\x7f\x7f');
|
|
60
|
+
await tick();
|
|
61
|
+
expect(lastFrame()).toMatch(/> \/permission$/m);
|
|
62
|
+
expect(lastFrame()).not.toContain('retry');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('clears the line with ctrl+u', async () => {
|
|
66
|
+
const { stdin, lastFrame } = render(
|
|
67
|
+
<InputArea onSubmit={vi.fn()} isDisabled={false} commandQueryPort={commandQueryPort} />,
|
|
68
|
+
);
|
|
69
|
+
await tick();
|
|
70
|
+
stdin.write('/he');
|
|
71
|
+
await tick();
|
|
72
|
+
expect(lastFrame()).toContain('help');
|
|
73
|
+
|
|
74
|
+
stdin.write('\x15');
|
|
75
|
+
await tick();
|
|
76
|
+
expect(lastFrame()).toContain('Type a message or /help');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('moves the cursor with the arrow keys so an insertion lands mid-word', async () => {
|
|
80
|
+
const { stdin, lastFrame } = render(
|
|
81
|
+
<InputArea onSubmit={vi.fn()} isDisabled={false} commandQueryPort={commandQueryPort} />,
|
|
82
|
+
);
|
|
83
|
+
await tick();
|
|
84
|
+
stdin.write('/pe');
|
|
85
|
+
await tick();
|
|
86
|
+
expect(lastFrame()).toContain('permissions');
|
|
87
|
+
stdin.write('\x1b[D');
|
|
88
|
+
await tick();
|
|
89
|
+
stdin.write('r');
|
|
90
|
+
await tick();
|
|
91
|
+
expect(lastFrame()).toMatch(/> \/pre$/m);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("follows the user's chat-input bindings, not the defaults", async () => {
|
|
95
|
+
const parsed = parseKeybindingsDocument(
|
|
96
|
+
JSON.stringify({ version: 1, bindings: { 'chat-input': { 'delete-line': 'ctrl+k' } } }),
|
|
97
|
+
'/tmp/keybindings.json',
|
|
98
|
+
);
|
|
99
|
+
if (!parsed.ok) throw new Error(parsed.diagnostic.message);
|
|
100
|
+
const source: IKeybindingsSource = {
|
|
101
|
+
filePath: '/tmp/keybindings.json',
|
|
102
|
+
start: async () => undefined,
|
|
103
|
+
isStarted: () => true,
|
|
104
|
+
ensureFile: async () => '/tmp/keybindings.json',
|
|
105
|
+
getSnapshot: () => parsed.snapshot,
|
|
106
|
+
subscribe: () => () => undefined,
|
|
107
|
+
dispose: () => undefined,
|
|
108
|
+
};
|
|
109
|
+
const { stdin, lastFrame } = render(
|
|
110
|
+
<KeybindingsProvider source={source}>
|
|
111
|
+
<InputArea onSubmit={vi.fn()} isDisabled={false} commandQueryPort={commandQueryPort} />
|
|
112
|
+
</KeybindingsProvider>,
|
|
113
|
+
);
|
|
114
|
+
await tick();
|
|
115
|
+
stdin.write('/he');
|
|
116
|
+
await tick();
|
|
117
|
+
expect(lastFrame()).toContain('help');
|
|
118
|
+
|
|
119
|
+
stdin.write('\x15'); // ctrl+u is no longer bound
|
|
120
|
+
await tick();
|
|
121
|
+
expect(lastFrame()).toMatch(/> \/he$/m);
|
|
122
|
+
|
|
123
|
+
stdin.write('\x0b'); // ctrl+k
|
|
124
|
+
await tick();
|
|
125
|
+
expect(lastFrame()).toContain('Type a message or /help');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import type { IExecutionWorkspaceEntry } from '@robota-sdk/agent-interface-execution';
|
|
5
|
+
import BackgroundTaskPanel from '../BackgroundTaskPanel.js';
|
|
6
|
+
import { ScreenReaderProvider } from '../screen-reader-context.js';
|
|
7
|
+
|
|
8
|
+
function makeEntry(overrides: Partial<IExecutionWorkspaceEntry>): IExecutionWorkspaceEntry {
|
|
9
|
+
return {
|
|
10
|
+
id: 'task:sched_1',
|
|
11
|
+
sourceId: 'sched_1',
|
|
12
|
+
kind: 'background_task',
|
|
13
|
+
origin: { kind: 'slash_command', sessionId: 'session_1', commandName: 'schedule' },
|
|
14
|
+
taskKind: 'scheduled',
|
|
15
|
+
status: 'sleeping',
|
|
16
|
+
state: 'working',
|
|
17
|
+
title: 'wake',
|
|
18
|
+
unread: false,
|
|
19
|
+
attention: 'none',
|
|
20
|
+
visibility: 'default',
|
|
21
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
22
|
+
controls: ['select', 'cancel'],
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** SCREEN-1992 TC-04: the tick advances the countdown once a second, only while one exists. */
|
|
28
|
+
describe('BackgroundTaskPanel countdown tick', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.useFakeTimers();
|
|
31
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.useRealTimers();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('advances the rendered countdown every second while a schedule sleeps', async () => {
|
|
38
|
+
const { lastFrame, rerender } = render(
|
|
39
|
+
<BackgroundTaskPanel entries={[makeEntry({ nextFireAt: '2026-01-01T00:01:00.000Z' })]} />,
|
|
40
|
+
);
|
|
41
|
+
expect(lastFrame()).toContain('in 1m 0s');
|
|
42
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
43
|
+
expect(lastFrame()).toContain('in 59s');
|
|
44
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
45
|
+
expect(lastFrame()).toContain('in 57s');
|
|
46
|
+
|
|
47
|
+
// No sleeping schedule ⇒ the tick stops: the frame no longer changes with time.
|
|
48
|
+
rerender(
|
|
49
|
+
<BackgroundTaskPanel entries={[makeEntry({ status: 'completed', state: 'completed' })]} />,
|
|
50
|
+
);
|
|
51
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
52
|
+
expect(lastFrame()).not.toContain('in ');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('never starts the tick in screen-reader mode, yet renders against the current clock', async () => {
|
|
56
|
+
// Mounted at app start with nothing scheduled, as the real panel is.
|
|
57
|
+
const { lastFrame, rerender } = render(
|
|
58
|
+
<ScreenReaderProvider enabled>
|
|
59
|
+
<BackgroundTaskPanel entries={[makeEntry({ status: 'completed', state: 'completed' })]} />
|
|
60
|
+
</ScreenReaderProvider>,
|
|
61
|
+
);
|
|
62
|
+
// Half an hour later a schedule for one minute out appears: the reader must hear `in 1m`,
|
|
63
|
+
// not the thirty minutes since mount.
|
|
64
|
+
vi.setSystemTime(new Date('2026-01-01T00:30:00.000Z'));
|
|
65
|
+
rerender(
|
|
66
|
+
<ScreenReaderProvider enabled>
|
|
67
|
+
<BackgroundTaskPanel entries={[makeEntry({ nextFireAt: '2026-01-01T00:31:00.000Z' })]} />
|
|
68
|
+
</ScreenReaderProvider>,
|
|
69
|
+
);
|
|
70
|
+
expect(lastFrame()).toContain('in 1m 0s');
|
|
71
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
72
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
73
|
+
expect(lastFrame()).toContain('in 1m 0s');
|
|
74
|
+
});
|
|
75
|
+
});
|