@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,188 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { Text } from 'ink';
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import ListPicker from '../ListPicker.js';
|
|
6
|
+
import {
|
|
7
|
+
formatKeyHints,
|
|
8
|
+
SELECTION_INDICATOR,
|
|
9
|
+
SELECTION_INDICATOR_NONE,
|
|
10
|
+
} from '../key-hint-footer.js';
|
|
11
|
+
|
|
12
|
+
describe('ListPicker', () => {
|
|
13
|
+
it('renders all items with first selected by default', () => {
|
|
14
|
+
const items = ['Alpha', 'Beta', 'Gamma'];
|
|
15
|
+
const { lastFrame } = render(
|
|
16
|
+
<ListPicker
|
|
17
|
+
items={items}
|
|
18
|
+
renderItem={(item, isSelected) => (
|
|
19
|
+
<Text>
|
|
20
|
+
{isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
|
|
21
|
+
{item}
|
|
22
|
+
</Text>
|
|
23
|
+
)}
|
|
24
|
+
onSelect={() => {}}
|
|
25
|
+
onCancel={() => {}}
|
|
26
|
+
/>,
|
|
27
|
+
);
|
|
28
|
+
const frame = lastFrame()!;
|
|
29
|
+
expect(frame).toContain('> Alpha');
|
|
30
|
+
expect(frame).toContain(' Beta');
|
|
31
|
+
expect(frame).toContain(' Gamma');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders empty state when no items', () => {
|
|
35
|
+
const { lastFrame } = render(
|
|
36
|
+
<ListPicker
|
|
37
|
+
items={[]}
|
|
38
|
+
renderItem={(item, isSelected) => (
|
|
39
|
+
<Text>
|
|
40
|
+
{isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
|
|
41
|
+
{String(item)}
|
|
42
|
+
</Text>
|
|
43
|
+
)}
|
|
44
|
+
onSelect={() => {}}
|
|
45
|
+
onCancel={() => {}}
|
|
46
|
+
/>,
|
|
47
|
+
);
|
|
48
|
+
// Should render without crashing
|
|
49
|
+
expect(lastFrame()).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('calls onSelect with item on Enter', () => {
|
|
53
|
+
let selected = '';
|
|
54
|
+
const items = ['Alpha', 'Beta'];
|
|
55
|
+
const { stdin } = render(
|
|
56
|
+
<ListPicker
|
|
57
|
+
items={items}
|
|
58
|
+
renderItem={(item, isSelected) => (
|
|
59
|
+
<Text>
|
|
60
|
+
{isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
|
|
61
|
+
{item}
|
|
62
|
+
</Text>
|
|
63
|
+
)}
|
|
64
|
+
onSelect={(item) => {
|
|
65
|
+
selected = item;
|
|
66
|
+
}}
|
|
67
|
+
onCancel={() => {}}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
stdin.write('\r');
|
|
71
|
+
expect(selected).toBe('Alpha');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('navigates down with arrow key and selects', () => {
|
|
75
|
+
let selected = '';
|
|
76
|
+
const items = ['Alpha', 'Beta', 'Gamma'];
|
|
77
|
+
const { stdin } = render(
|
|
78
|
+
<ListPicker
|
|
79
|
+
items={items}
|
|
80
|
+
renderItem={(item, isSelected) => (
|
|
81
|
+
<Text>
|
|
82
|
+
{isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
|
|
83
|
+
{item}
|
|
84
|
+
</Text>
|
|
85
|
+
)}
|
|
86
|
+
onSelect={(item) => {
|
|
87
|
+
selected = item;
|
|
88
|
+
}}
|
|
89
|
+
onCancel={() => {}}
|
|
90
|
+
/>,
|
|
91
|
+
);
|
|
92
|
+
stdin.write('\x1B[B'); // Down arrow
|
|
93
|
+
stdin.write('\r');
|
|
94
|
+
expect(selected).toBe('Beta');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('calls onCancel on Escape', async () => {
|
|
98
|
+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
99
|
+
let cancelled = false;
|
|
100
|
+
const { stdin } = render(
|
|
101
|
+
<ListPicker
|
|
102
|
+
items={['Alpha']}
|
|
103
|
+
renderItem={(item, isSelected) => (
|
|
104
|
+
<Text>
|
|
105
|
+
{isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
|
|
106
|
+
{item}
|
|
107
|
+
</Text>
|
|
108
|
+
)}
|
|
109
|
+
onSelect={() => {}}
|
|
110
|
+
onCancel={() => {
|
|
111
|
+
cancelled = true;
|
|
112
|
+
}}
|
|
113
|
+
/>,
|
|
114
|
+
);
|
|
115
|
+
stdin.write('\x1B');
|
|
116
|
+
await delay(50);
|
|
117
|
+
expect(cancelled).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does not go above first item', () => {
|
|
121
|
+
const items = ['Alpha', 'Beta'];
|
|
122
|
+
const { stdin, lastFrame } = render(
|
|
123
|
+
<ListPicker
|
|
124
|
+
items={items}
|
|
125
|
+
renderItem={(item, isSelected) => (
|
|
126
|
+
<Text>
|
|
127
|
+
{isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
|
|
128
|
+
{item}
|
|
129
|
+
</Text>
|
|
130
|
+
)}
|
|
131
|
+
onSelect={() => {}}
|
|
132
|
+
onCancel={() => {}}
|
|
133
|
+
/>,
|
|
134
|
+
);
|
|
135
|
+
stdin.write('\x1B[A'); // Up arrow (already at 0)
|
|
136
|
+
const frame = lastFrame()!;
|
|
137
|
+
expect(frame).toContain('> Alpha');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('does not go below last item', () => {
|
|
141
|
+
let selected = '';
|
|
142
|
+
const items = ['Alpha', 'Beta'];
|
|
143
|
+
const { stdin } = render(
|
|
144
|
+
<ListPicker
|
|
145
|
+
items={items}
|
|
146
|
+
renderItem={(item, isSelected) => (
|
|
147
|
+
<Text>
|
|
148
|
+
{isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
|
|
149
|
+
{item}
|
|
150
|
+
</Text>
|
|
151
|
+
)}
|
|
152
|
+
onSelect={(item) => {
|
|
153
|
+
selected = item;
|
|
154
|
+
}}
|
|
155
|
+
onCancel={() => {}}
|
|
156
|
+
/>,
|
|
157
|
+
);
|
|
158
|
+
stdin.write('\x1B[B'); // Down
|
|
159
|
+
stdin.write('\x1B[B'); // Down (should stay at Beta)
|
|
160
|
+
stdin.write('\x1B[B'); // Down (should stay at Beta)
|
|
161
|
+
stdin.write('\r');
|
|
162
|
+
expect(selected).toBe('Beta');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// SCREEN-005: default footer renders through the key-hint SSOT grammar.
|
|
166
|
+
it('renders the default footer in the shared key-hint grammar', () => {
|
|
167
|
+
const { lastFrame } = render(
|
|
168
|
+
<ListPicker
|
|
169
|
+
items={['Alpha']}
|
|
170
|
+
renderItem={(item, isSelected) => (
|
|
171
|
+
<Text>
|
|
172
|
+
{isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
|
|
173
|
+
{item}
|
|
174
|
+
</Text>
|
|
175
|
+
)}
|
|
176
|
+
onSelect={() => {}}
|
|
177
|
+
onCancel={() => {}}
|
|
178
|
+
/>,
|
|
179
|
+
);
|
|
180
|
+
expect(lastFrame()!).toContain(
|
|
181
|
+
formatKeyHints([
|
|
182
|
+
{ keys: '↑↓', label: 'Navigate' },
|
|
183
|
+
{ keys: 'Enter', label: 'Select' },
|
|
184
|
+
{ keys: 'Esc', label: 'Cancel' },
|
|
185
|
+
]),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import MenuSelect from '../MenuSelect.js';
|
|
5
|
+
import { formatKeyHints } from '../key-hint-footer.js';
|
|
6
|
+
|
|
7
|
+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
|
|
9
|
+
describe('MenuSelect', () => {
|
|
10
|
+
const items = [
|
|
11
|
+
{ label: 'Option A', value: 'a' },
|
|
12
|
+
{ label: 'Option B', value: 'b', hint: 'some hint' },
|
|
13
|
+
{ label: 'Option C', value: 'c' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
it('renders title and all items', () => {
|
|
17
|
+
const { lastFrame } = render(
|
|
18
|
+
<MenuSelect title="Test Menu" items={items} onSelect={() => {}} onBack={() => {}} />,
|
|
19
|
+
);
|
|
20
|
+
const frame = lastFrame()!;
|
|
21
|
+
expect(frame).toContain('Test Menu');
|
|
22
|
+
expect(frame).toContain('Option A');
|
|
23
|
+
expect(frame).toContain('Option B');
|
|
24
|
+
expect(frame).toContain('Option C');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders hint text when provided', () => {
|
|
28
|
+
const { lastFrame } = render(
|
|
29
|
+
<MenuSelect title="Test" items={items} onSelect={() => {}} onBack={() => {}} />,
|
|
30
|
+
);
|
|
31
|
+
expect(lastFrame()!).toContain('some hint');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('highlights first item by default with > prefix', () => {
|
|
35
|
+
const { lastFrame } = render(
|
|
36
|
+
<MenuSelect title="Test" items={items} onSelect={() => {}} onBack={() => {}} />,
|
|
37
|
+
);
|
|
38
|
+
expect(lastFrame()!).toContain('>');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('shows loading state', () => {
|
|
42
|
+
const { lastFrame } = render(
|
|
43
|
+
<MenuSelect title="Test" items={[]} onSelect={() => {}} onBack={() => {}} loading />,
|
|
44
|
+
);
|
|
45
|
+
expect(lastFrame()!).toContain('Loading');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('shows error state', () => {
|
|
49
|
+
const { lastFrame } = render(
|
|
50
|
+
<MenuSelect title="Test" items={[]} onSelect={() => {}} onBack={() => {}} error="Failed" />,
|
|
51
|
+
);
|
|
52
|
+
expect(lastFrame()!).toContain('Failed');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('calls onSelect with value on Enter', () => {
|
|
56
|
+
let selected = '';
|
|
57
|
+
const { stdin } = render(
|
|
58
|
+
<MenuSelect
|
|
59
|
+
title="Test"
|
|
60
|
+
items={items}
|
|
61
|
+
onSelect={(v) => {
|
|
62
|
+
selected = v;
|
|
63
|
+
}}
|
|
64
|
+
onBack={() => {}}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
stdin.write('\r');
|
|
68
|
+
expect(selected).toBe('a');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('calls onBack on Escape', async () => {
|
|
72
|
+
let backed = false;
|
|
73
|
+
const { stdin } = render(
|
|
74
|
+
<MenuSelect
|
|
75
|
+
title="Test"
|
|
76
|
+
items={items}
|
|
77
|
+
onSelect={() => {}}
|
|
78
|
+
onBack={() => {
|
|
79
|
+
backed = true;
|
|
80
|
+
}}
|
|
81
|
+
/>,
|
|
82
|
+
);
|
|
83
|
+
stdin.write('\x1B');
|
|
84
|
+
await delay(50);
|
|
85
|
+
expect(backed).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('navigates down with arrow key', () => {
|
|
89
|
+
let selected = '';
|
|
90
|
+
const { stdin } = render(
|
|
91
|
+
<MenuSelect
|
|
92
|
+
title="Test"
|
|
93
|
+
items={items}
|
|
94
|
+
onSelect={(v) => {
|
|
95
|
+
selected = v;
|
|
96
|
+
}}
|
|
97
|
+
onBack={() => {}}
|
|
98
|
+
/>,
|
|
99
|
+
);
|
|
100
|
+
stdin.write('\x1B[B'); // Down arrow
|
|
101
|
+
stdin.write('\r');
|
|
102
|
+
expect(selected).toBe('b');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// SCREEN-005: footer + error-state hint render through the key-hint SSOT grammar.
|
|
106
|
+
it('renders the footer in the shared key-hint grammar', () => {
|
|
107
|
+
const { lastFrame } = render(
|
|
108
|
+
<MenuSelect title="Test" items={items} onSelect={() => {}} onBack={() => {}} />,
|
|
109
|
+
);
|
|
110
|
+
expect(lastFrame()!).toContain(
|
|
111
|
+
formatKeyHints([
|
|
112
|
+
{ keys: '↑↓', label: 'Navigate' },
|
|
113
|
+
{ keys: 'Enter', label: 'Select' },
|
|
114
|
+
{ keys: 'Esc', label: 'Back' },
|
|
115
|
+
]),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('renders the error-state hint via the shared key-hint grammar', () => {
|
|
120
|
+
const { lastFrame } = render(
|
|
121
|
+
<MenuSelect title="Test" items={[]} onSelect={() => {}} onBack={() => {}} error="Failed" />,
|
|
122
|
+
);
|
|
123
|
+
const frame = lastFrame()!;
|
|
124
|
+
expect(frame).toContain(formatKeyHints([{ keys: 'Esc', label: 'Back' }]));
|
|
125
|
+
expect(frame).not.toContain('Press Esc to go back');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { render } from 'ink-testing-library';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import PendingActionPrompt from '../PendingActionPrompt.js';
|
|
6
|
+
import { formatKeyHints } from '../key-hint-footer.js';
|
|
7
|
+
|
|
8
|
+
import type { IActionRequest } from '@robota-sdk/agent-core';
|
|
9
|
+
|
|
10
|
+
const delay = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 20));
|
|
11
|
+
const ESC = String.fromCharCode(27);
|
|
12
|
+
const DOWN = `${ESC}[B`;
|
|
13
|
+
const ENTER = '\r';
|
|
14
|
+
|
|
15
|
+
describe('PendingActionPrompt (CMD-004 unified action renderer)', () => {
|
|
16
|
+
it('single-select: renders options and answers with the chosen value', async () => {
|
|
17
|
+
const onAnswer = vi.fn();
|
|
18
|
+
const request: IActionRequest = {
|
|
19
|
+
id: 'mode',
|
|
20
|
+
title: 'Select mode',
|
|
21
|
+
options: [
|
|
22
|
+
{ value: 'plan', label: 'Plan' },
|
|
23
|
+
{ value: 'default', label: 'Default' },
|
|
24
|
+
],
|
|
25
|
+
maxSelect: 1,
|
|
26
|
+
};
|
|
27
|
+
const { stdin, lastFrame } = render(
|
|
28
|
+
<PendingActionPrompt request={request} onAnswer={onAnswer} />,
|
|
29
|
+
);
|
|
30
|
+
expect(lastFrame()).toContain('Select mode');
|
|
31
|
+
expect(lastFrame()).toContain('Plan');
|
|
32
|
+
|
|
33
|
+
stdin.write(DOWN);
|
|
34
|
+
await delay();
|
|
35
|
+
stdin.write(ENTER);
|
|
36
|
+
await delay();
|
|
37
|
+
expect(onAnswer).toHaveBeenCalledWith({ type: 'answer', values: ['default'] });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('single-select: the highlight starts on the request default — a No-default confirmation answers No on Enter (BEHAVIOR-2437)', async () => {
|
|
41
|
+
const onAnswer = vi.fn();
|
|
42
|
+
const request: IActionRequest = {
|
|
43
|
+
id: 'git-commit',
|
|
44
|
+
title: 'Commit 1 staged file(s)?',
|
|
45
|
+
options: [
|
|
46
|
+
{ value: 'yes', label: 'Yes' },
|
|
47
|
+
{ value: 'no', label: 'No' },
|
|
48
|
+
],
|
|
49
|
+
maxSelect: 1,
|
|
50
|
+
default: { values: ['no'] },
|
|
51
|
+
};
|
|
52
|
+
const { stdin, lastFrame } = render(
|
|
53
|
+
<PendingActionPrompt request={request} onAnswer={onAnswer} />,
|
|
54
|
+
);
|
|
55
|
+
expect(lastFrame()).toContain('> No');
|
|
56
|
+
expect(lastFrame()).not.toContain('> Yes');
|
|
57
|
+
|
|
58
|
+
stdin.write(ENTER);
|
|
59
|
+
await delay();
|
|
60
|
+
expect(onAnswer).toHaveBeenCalledWith({ type: 'answer', values: ['no'] });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('multi-select (maxSelect>1): toggles with Space and confirms with Enter', async () => {
|
|
64
|
+
const onAnswer = vi.fn();
|
|
65
|
+
const request: IActionRequest = {
|
|
66
|
+
id: 'tags',
|
|
67
|
+
title: 'Pick tags',
|
|
68
|
+
options: [
|
|
69
|
+
{ value: 'a', label: 'Alpha' },
|
|
70
|
+
{ value: 'b', label: 'Beta' },
|
|
71
|
+
{ value: 'c', label: 'Gamma' },
|
|
72
|
+
],
|
|
73
|
+
minSelect: 1,
|
|
74
|
+
maxSelect: 3,
|
|
75
|
+
};
|
|
76
|
+
const { stdin, lastFrame } = render(
|
|
77
|
+
<PendingActionPrompt request={request} onAnswer={onAnswer} />,
|
|
78
|
+
);
|
|
79
|
+
expect(lastFrame()).toContain('[ ] Alpha');
|
|
80
|
+
|
|
81
|
+
stdin.write(' ');
|
|
82
|
+
await delay();
|
|
83
|
+
expect(lastFrame()).toContain('[x] Alpha');
|
|
84
|
+
stdin.write(DOWN);
|
|
85
|
+
await delay();
|
|
86
|
+
stdin.write(' ');
|
|
87
|
+
await delay();
|
|
88
|
+
stdin.write(ENTER);
|
|
89
|
+
await delay();
|
|
90
|
+
expect(onAnswer).toHaveBeenCalledWith({ type: 'answer', values: ['a', 'b'] });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('free-text with masked: hides input and answers with typed text', async () => {
|
|
94
|
+
const onAnswer = vi.fn();
|
|
95
|
+
const request: IActionRequest = {
|
|
96
|
+
id: 'apiKey',
|
|
97
|
+
title: 'Enter API key',
|
|
98
|
+
allowFreeText: true,
|
|
99
|
+
masked: true,
|
|
100
|
+
};
|
|
101
|
+
const { stdin, lastFrame } = render(
|
|
102
|
+
<PendingActionPrompt request={request} onAnswer={onAnswer} />,
|
|
103
|
+
);
|
|
104
|
+
expect(lastFrame()).toContain('Enter API key');
|
|
105
|
+
|
|
106
|
+
stdin.write('sk-secret');
|
|
107
|
+
await delay();
|
|
108
|
+
expect(lastFrame()).not.toContain('sk-secret');
|
|
109
|
+
expect(lastFrame()).toContain('*'.repeat('sk-secret'.length));
|
|
110
|
+
stdin.write(ENTER);
|
|
111
|
+
await delay();
|
|
112
|
+
expect(onAnswer).toHaveBeenCalledWith({ type: 'answer', values: [], text: 'sk-secret' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('Esc cancels the request (resolves cancelled)', async () => {
|
|
116
|
+
const onAnswer = vi.fn();
|
|
117
|
+
const request: IActionRequest = {
|
|
118
|
+
id: 'exit',
|
|
119
|
+
title: 'Exit?',
|
|
120
|
+
options: [
|
|
121
|
+
{ value: 'yes', label: 'Yes' },
|
|
122
|
+
{ value: 'no', label: 'No' },
|
|
123
|
+
],
|
|
124
|
+
maxSelect: 1,
|
|
125
|
+
};
|
|
126
|
+
const { stdin } = render(<PendingActionPrompt request={request} onAnswer={onAnswer} />);
|
|
127
|
+
stdin.write(ESC);
|
|
128
|
+
await delay();
|
|
129
|
+
expect(onAnswer).toHaveBeenCalledWith({ type: 'cancelled' });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('single-select + allowFreeText: "type a custom answer" opens text; Esc returns to picker (not cancel)', async () => {
|
|
133
|
+
const onAnswer = vi.fn();
|
|
134
|
+
const request: IActionRequest = {
|
|
135
|
+
id: 'provider',
|
|
136
|
+
title: 'Select provider',
|
|
137
|
+
options: [{ value: 'openai', label: 'OpenAI' }],
|
|
138
|
+
maxSelect: 1,
|
|
139
|
+
allowFreeText: true,
|
|
140
|
+
};
|
|
141
|
+
const { stdin, lastFrame } = render(
|
|
142
|
+
<PendingActionPrompt request={request} onAnswer={onAnswer} />,
|
|
143
|
+
);
|
|
144
|
+
expect(lastFrame()).toContain('Type a custom answer');
|
|
145
|
+
|
|
146
|
+
stdin.write(DOWN); // move to the synthetic "type a custom answer" entry
|
|
147
|
+
await delay();
|
|
148
|
+
stdin.write(ENTER); // enter free-text mode
|
|
149
|
+
await delay();
|
|
150
|
+
stdin.write(ESC); // Esc here returns to the picker, does NOT cancel the request
|
|
151
|
+
await delay();
|
|
152
|
+
expect(onAnswer).not.toHaveBeenCalled();
|
|
153
|
+
expect(lastFrame()).toContain('OpenAI'); // back at the picker
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// SCREEN-005: multi-select footer goes through the key-hint SSOT, keeping the dynamic (min N)
|
|
157
|
+
// segment on the Enter hint until the selection is confirmable.
|
|
158
|
+
it('multi-select footer shows the (min N) segment until satisfiable, in the shared grammar', async () => {
|
|
159
|
+
const onAnswer = vi.fn();
|
|
160
|
+
const request: IActionRequest = {
|
|
161
|
+
id: 'tags',
|
|
162
|
+
title: 'Pick tags',
|
|
163
|
+
options: [
|
|
164
|
+
{ value: 'a', label: 'Alpha' },
|
|
165
|
+
{ value: 'b', label: 'Beta' },
|
|
166
|
+
],
|
|
167
|
+
minSelect: 1,
|
|
168
|
+
maxSelect: 2,
|
|
169
|
+
};
|
|
170
|
+
const { stdin, lastFrame } = render(
|
|
171
|
+
<PendingActionPrompt request={request} onAnswer={onAnswer} />,
|
|
172
|
+
);
|
|
173
|
+
expect(lastFrame()).toContain(
|
|
174
|
+
formatKeyHints([
|
|
175
|
+
{ keys: '↑↓', label: 'Navigate' },
|
|
176
|
+
{ keys: 'Space', label: 'Toggle' },
|
|
177
|
+
{ keys: 'Enter', label: 'Confirm (min 1)' },
|
|
178
|
+
{ keys: 'Esc', label: 'Cancel' },
|
|
179
|
+
]),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
stdin.write(' '); // toggle Alpha → selection satisfiable → min segment drops
|
|
183
|
+
await delay();
|
|
184
|
+
expect(lastFrame()).toContain(
|
|
185
|
+
formatKeyHints([
|
|
186
|
+
{ keys: '↑↓', label: 'Navigate' },
|
|
187
|
+
{ keys: 'Space', label: 'Toggle' },
|
|
188
|
+
{ keys: 'Enter', label: 'Confirm' },
|
|
189
|
+
{ keys: 'Esc', label: 'Cancel' },
|
|
190
|
+
]),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// packages/agent-cli/src/ui/__tests__/PluginTUI.test.tsx
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import PluginTUI from '../PluginTUI.js';
|
|
6
|
+
import type { ICommandPluginAdapter } from '@robota-sdk/agent-interface-command';
|
|
7
|
+
|
|
8
|
+
function mockCallbacks(): ICommandPluginAdapter {
|
|
9
|
+
return {
|
|
10
|
+
listInstalled: vi.fn().mockResolvedValue([]),
|
|
11
|
+
listAvailablePlugins: vi.fn().mockResolvedValue([]),
|
|
12
|
+
install: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
uninstall: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
enable: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
disable: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
marketplaceAdd: vi.fn().mockResolvedValue('test-marketplace'),
|
|
17
|
+
marketplaceRemove: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
marketplaceUpdate: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
marketplaceList: vi.fn().mockResolvedValue([]),
|
|
20
|
+
reloadPlugins: vi.fn().mockResolvedValue({ loadedPluginCount: 0 }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('PluginTUI', () => {
|
|
25
|
+
it('renders main menu with Marketplace and Installed Plugins', () => {
|
|
26
|
+
const { lastFrame } = render(<PluginTUI callbacks={mockCallbacks()} onClose={() => {}} />);
|
|
27
|
+
const frame = lastFrame()!;
|
|
28
|
+
expect(frame).toContain('Marketplace');
|
|
29
|
+
expect(frame).toContain('Installed Plugins');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('calls onClose when Escape on main menu', async () => {
|
|
33
|
+
let closed = false;
|
|
34
|
+
const { stdin } = render(
|
|
35
|
+
<PluginTUI
|
|
36
|
+
callbacks={mockCallbacks()}
|
|
37
|
+
onClose={() => {
|
|
38
|
+
closed = true;
|
|
39
|
+
}}
|
|
40
|
+
/>,
|
|
41
|
+
);
|
|
42
|
+
stdin.write('\x1B');
|
|
43
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
44
|
+
expect(closed).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('navigates to installed plugins and shows actions', async () => {
|
|
48
|
+
const cbs = mockCallbacks();
|
|
49
|
+
cbs.listInstalled = vi
|
|
50
|
+
.fn()
|
|
51
|
+
.mockResolvedValue([{ name: 'my-plugin', description: 'A plugin', enabled: true }]);
|
|
52
|
+
const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
53
|
+
stdin.write('\x1B[B'); // Down to "Installed Plugins"
|
|
54
|
+
stdin.write('\r');
|
|
55
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
56
|
+
expect(lastFrame()!).toContain('my-plugin');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('navigates to marketplace list on Enter', async () => {
|
|
60
|
+
const cbs = mockCallbacks();
|
|
61
|
+
cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
|
|
62
|
+
const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
63
|
+
stdin.write('\r'); // Enter on "Marketplace"
|
|
64
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
65
|
+
const frame = lastFrame()!;
|
|
66
|
+
expect(frame).toContain('Add Marketplace');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Regression: arrow keys must work after navigating to a sub-screen (resolvedRef reset via key)
|
|
70
|
+
it('arrow keys work in marketplace list after navigating from main', async () => {
|
|
71
|
+
const cbs = mockCallbacks();
|
|
72
|
+
cbs.marketplaceList = vi.fn().mockResolvedValue([
|
|
73
|
+
{ name: 'mp-a', type: 'github' },
|
|
74
|
+
{ name: 'mp-b', type: 'git' },
|
|
75
|
+
]);
|
|
76
|
+
const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
77
|
+
stdin.write('\r'); // Enter on "Marketplace"
|
|
78
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
79
|
+
// Should be on marketplace-list with arrow keys working
|
|
80
|
+
stdin.write('\x1B[B'); // Down from "Add Marketplace" to "mp-a"
|
|
81
|
+
stdin.write('\x1B[B'); // Down to "mp-b"
|
|
82
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
83
|
+
const frame = lastFrame()!;
|
|
84
|
+
// "mp-b" should be highlighted (has > prefix)
|
|
85
|
+
expect(frame).toContain('mp-b');
|
|
86
|
+
// Select mp-b → should navigate to marketplace-action
|
|
87
|
+
stdin.write('\r');
|
|
88
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
89
|
+
expect(lastFrame()!).toContain('Browse plugins');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Regression: installed plugin browse shows uninstall for already-installed plugins
|
|
93
|
+
it('selecting installed plugin in browse shows uninstall action', async () => {
|
|
94
|
+
const cbs = mockCallbacks();
|
|
95
|
+
cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
|
|
96
|
+
cbs.listAvailablePlugins = vi.fn().mockResolvedValue([
|
|
97
|
+
{ name: 'my-plugin', description: 'A plugin', installed: true },
|
|
98
|
+
{ name: 'new-plugin', description: 'Not installed', installed: false },
|
|
99
|
+
]);
|
|
100
|
+
const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
101
|
+
// Main → Marketplace
|
|
102
|
+
stdin.write('\r');
|
|
103
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
104
|
+
// Marketplace list → select test-mp
|
|
105
|
+
stdin.write('\x1B[B'); // Down to "test-mp"
|
|
106
|
+
stdin.write('\r');
|
|
107
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
108
|
+
// Marketplace action → Browse plugins
|
|
109
|
+
stdin.write('\r');
|
|
110
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
111
|
+
// Browse → select installed "my-plugin"
|
|
112
|
+
stdin.write('\r');
|
|
113
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
114
|
+
// Should show uninstall action, not install scope
|
|
115
|
+
expect(lastFrame()!).toContain('Uninstall');
|
|
116
|
+
expect(lastFrame()!).not.toContain('User scope');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Regression: uninstalled plugin in browse shows install scope selection
|
|
120
|
+
it('selecting uninstalled plugin in browse shows install scope', async () => {
|
|
121
|
+
const cbs = mockCallbacks();
|
|
122
|
+
cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
|
|
123
|
+
cbs.listAvailablePlugins = vi
|
|
124
|
+
.fn()
|
|
125
|
+
.mockResolvedValue([{ name: 'new-plugin', description: 'Not installed', installed: false }]);
|
|
126
|
+
const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
127
|
+
// Main → Marketplace
|
|
128
|
+
stdin.write('\r');
|
|
129
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
130
|
+
// Marketplace list → select test-mp
|
|
131
|
+
stdin.write('\x1B[B');
|
|
132
|
+
stdin.write('\r');
|
|
133
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
134
|
+
// Marketplace action → Browse plugins
|
|
135
|
+
stdin.write('\r');
|
|
136
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
137
|
+
// Browse → select "new-plugin"
|
|
138
|
+
stdin.write('\r');
|
|
139
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
140
|
+
// Should show install scope, not uninstall
|
|
141
|
+
expect(lastFrame()!).toContain('User scope');
|
|
142
|
+
expect(lastFrame()!).toContain('Project scope');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Regression: install passes name@marketplace format to callback
|
|
146
|
+
it('install callback receives pluginId in name@marketplace format', async () => {
|
|
147
|
+
const cbs = mockCallbacks();
|
|
148
|
+
cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
|
|
149
|
+
cbs.listAvailablePlugins = vi
|
|
150
|
+
.fn()
|
|
151
|
+
.mockResolvedValue([{ name: 'new-plugin', description: 'A plugin', installed: false }]);
|
|
152
|
+
const { stdin } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
153
|
+
// Main → Marketplace → test-mp → Browse → new-plugin → User scope
|
|
154
|
+
stdin.write('\r');
|
|
155
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
156
|
+
stdin.write('\x1B[B');
|
|
157
|
+
stdin.write('\r');
|
|
158
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
159
|
+
stdin.write('\r'); // Browse plugins
|
|
160
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
161
|
+
stdin.write('\r'); // new-plugin
|
|
162
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
163
|
+
stdin.write('\r'); // User scope
|
|
164
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
165
|
+
expect(cbs.install).toHaveBeenCalledWith('new-plugin@test-mp', 'user');
|
|
166
|
+
}, 15000);
|
|
167
|
+
});
|