@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,629 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { ScreenReaderProvider } from '../screen-reader-context.js';
|
|
6
|
+
import SupervisedSessionView, { type ISupervisedViewRow } from '../SupervisedSessionView.js';
|
|
7
|
+
|
|
8
|
+
const FIRST: ISupervisedViewRow = {
|
|
9
|
+
id: '8bf9bc27-d773-4e88-b88f-f7a43e9eb1f4',
|
|
10
|
+
liveness: 'alive', control: 'available', activity: 'working',
|
|
11
|
+
};
|
|
12
|
+
const SECOND: ISupervisedViewRow = {
|
|
13
|
+
id: 'fe2c7f72-ecb3-4a05-9bb1-2563ec80e615',
|
|
14
|
+
liveness: 'dead', control: 'unavailable', activity: 'unknown',
|
|
15
|
+
};
|
|
16
|
+
const THIRD: ISupervisedViewRow = {
|
|
17
|
+
id: '4fa26e15-b17d-4908-afed-a156f8a8a17c',
|
|
18
|
+
liveness: 'alive', control: 'available', activity: 'idle',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('supervised session view', () => {
|
|
22
|
+
it('shows a linked PR only while verified and opens it only on explicit keypress', async () => {
|
|
23
|
+
const url = 'https://github.com/team/repo/pull/123';
|
|
24
|
+
const onOpenPr = vi.fn(async () => undefined);
|
|
25
|
+
const row = { ...FIRST, pr: { url, host: 'github.com', number: 123, kind: 'pull' as const } };
|
|
26
|
+
const view = render(<SupervisedSessionView loadRows={async () => [row]} onOpenPr={onOpenPr} />);
|
|
27
|
+
try {
|
|
28
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('github.com #123'));
|
|
29
|
+
expect(view.lastFrame()).toContain(url);
|
|
30
|
+
expect(onOpenPr).not.toHaveBeenCalled();
|
|
31
|
+
view.stdin.write('p');
|
|
32
|
+
await vi.waitFor(() => expect(onOpenPr).toHaveBeenCalledExactlyOnceWith(FIRST.id, url));
|
|
33
|
+
} finally { view.unmount(); }
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('hides stale PR links and refuses open when discovery fails', async () => {
|
|
37
|
+
const url = 'https://github.com/team/repo/pull/123';
|
|
38
|
+
let fail = false;
|
|
39
|
+
const onOpenPr = vi.fn(async () => undefined);
|
|
40
|
+
const view = render(<SupervisedSessionView refreshMs={20} onOpenPr={onOpenPr}
|
|
41
|
+
loadRows={async () => {
|
|
42
|
+
if (fail) throw new Error('offline');
|
|
43
|
+
return [{ ...FIRST, pr: { url, host: 'github.com', number: 123, kind: 'pull' as const } }];
|
|
44
|
+
}} />);
|
|
45
|
+
try {
|
|
46
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(url));
|
|
47
|
+
fail = true;
|
|
48
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('discovery unavailable'));
|
|
49
|
+
expect(view.lastFrame()).not.toContain(url);
|
|
50
|
+
view.stdin.write('p');
|
|
51
|
+
expect(onOpenPr).not.toHaveBeenCalled();
|
|
52
|
+
} finally { view.unmount(); }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('refreshes a replaced or cleared association without retaining the old URL', async () => {
|
|
56
|
+
const first = 'https://github.com/team/repo/pull/123';
|
|
57
|
+
const next = 'https://git.example.org/team/repo/-/merge_requests/24';
|
|
58
|
+
let association: ISupervisedViewRow['pr'] = { url: first, host: 'github.com', number: 123, kind: 'pull' };
|
|
59
|
+
const view = render(<SupervisedSessionView refreshMs={20} loadRows={async () => [
|
|
60
|
+
{ ...FIRST, ...(association ? { pr: association } : {}) },
|
|
61
|
+
]} />);
|
|
62
|
+
try {
|
|
63
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(first));
|
|
64
|
+
association = { url: next, host: 'git.example.org', number: 24, kind: 'merge-request' };
|
|
65
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(next));
|
|
66
|
+
expect(view.lastFrame()).not.toContain(first);
|
|
67
|
+
association = undefined;
|
|
68
|
+
await vi.waitFor(() => expect(view.lastFrame()).not.toContain(next));
|
|
69
|
+
} finally { view.unmount(); }
|
|
70
|
+
});
|
|
71
|
+
it('toggles verified directory grouping without losing selection or inventing unknown paths', async () => {
|
|
72
|
+
const rows = [
|
|
73
|
+
{ ...FIRST, cwd: '/projects/alpha' },
|
|
74
|
+
{ ...THIRD, cwd: '/projects/beta' },
|
|
75
|
+
SECOND,
|
|
76
|
+
];
|
|
77
|
+
const view = render(<SupervisedSessionView loadRows={async () => rows} />);
|
|
78
|
+
try {
|
|
79
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
80
|
+
expect(view.lastFrame()).not.toContain('/projects/alpha');
|
|
81
|
+
view.stdin.write('g');
|
|
82
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1: alpha — /projects/alpha'));
|
|
83
|
+
expect(view.lastFrame()).toContain('Dir 2: beta — /projects/beta');
|
|
84
|
+
expect(view.lastFrame()).toContain('Directory: unverified');
|
|
85
|
+
expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`);
|
|
86
|
+
view.stdin.write('g');
|
|
87
|
+
await vi.waitFor(() => expect(view.lastFrame()).not.toContain('/projects/alpha'));
|
|
88
|
+
expect(view.lastFrame()).toContain('working:');
|
|
89
|
+
} finally {
|
|
90
|
+
view.unmount();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('announces directory groups and their selection in screen-reader mode', async () => {
|
|
95
|
+
const view = render(
|
|
96
|
+
<ScreenReaderProvider enabled>
|
|
97
|
+
<SupervisedSessionView loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, SECOND]} />
|
|
98
|
+
</ScreenReaderProvider>,
|
|
99
|
+
);
|
|
100
|
+
try {
|
|
101
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
102
|
+
view.stdin.write('g');
|
|
103
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1: alpha — /projects/alpha'));
|
|
104
|
+
expect(view.lastFrame()).toContain('Directory: unverified');
|
|
105
|
+
expect(view.lastFrame()).toContain('Enter selection (1-2)');
|
|
106
|
+
view.stdin.write('?');
|
|
107
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('g Group state/dir'));
|
|
108
|
+
} finally {
|
|
109
|
+
view.unmount();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('keeps long directory headings within a narrow terminal viewport', async () => {
|
|
114
|
+
const view = render(<SupervisedSessionView loadRows={async () => [
|
|
115
|
+
{ ...FIRST, cwd: `/projects/${'very-long-directory-name-'.repeat(8)}` },
|
|
116
|
+
]} />);
|
|
117
|
+
try {
|
|
118
|
+
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
119
|
+
view.stdout.emit('resize');
|
|
120
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id.slice(0, 8)}`));
|
|
121
|
+
view.stdin.write('g');
|
|
122
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1:'));
|
|
123
|
+
expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
|
|
124
|
+
} finally {
|
|
125
|
+
view.unmount();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('distinguishes directory groups with a shared prefix in a narrow terminal', async () => {
|
|
130
|
+
const view = render(<SupervisedSessionView loadRows={async () => [
|
|
131
|
+
{ ...FIRST, cwd: '/projects/alpha' },
|
|
132
|
+
{ ...THIRD, cwd: '/projects/beta' },
|
|
133
|
+
]} />);
|
|
134
|
+
try {
|
|
135
|
+
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
136
|
+
view.stdout.emit('resize');
|
|
137
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('2 supervised'));
|
|
138
|
+
view.stdin.write('g');
|
|
139
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1: alpha'));
|
|
140
|
+
expect(view.lastFrame()).toContain('Dir 2: beta');
|
|
141
|
+
} finally {
|
|
142
|
+
view.unmount();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('shows differing parent directories when project basenames match at 20 columns', async () => {
|
|
147
|
+
const view = render(<SupervisedSessionView loadRows={async () => [
|
|
148
|
+
{ ...FIRST, cwd: '/projects/alpha/app' },
|
|
149
|
+
{ ...THIRD, cwd: '/projects/beta/app' },
|
|
150
|
+
]} />);
|
|
151
|
+
try {
|
|
152
|
+
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
153
|
+
view.stdout.emit('resize');
|
|
154
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('2 supervised'));
|
|
155
|
+
view.stdin.write('g');
|
|
156
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('alpha/app'));
|
|
157
|
+
expect(view.lastFrame()).toContain('beta/app');
|
|
158
|
+
} finally {
|
|
159
|
+
view.unmount();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('keeps the differing path portion visible when long parent names share a prefix', async () => {
|
|
164
|
+
const view = render(<SupervisedSessionView loadRows={async () => [
|
|
165
|
+
{ ...FIRST, cwd: '/projects/alpha-very-long-parent/app' },
|
|
166
|
+
{ ...THIRD, cwd: '/projects/alpha-very-long-pardon/app' },
|
|
167
|
+
]} />);
|
|
168
|
+
try {
|
|
169
|
+
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
170
|
+
view.stdout.emit('resize');
|
|
171
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('2 supervised'));
|
|
172
|
+
view.stdin.write('g');
|
|
173
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('ent/app'));
|
|
174
|
+
expect(view.lastFrame()).toContain('don/app');
|
|
175
|
+
} finally {
|
|
176
|
+
view.unmount();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('keeps the distinguishing wide character visible in a 20-column terminal', async () => {
|
|
181
|
+
const view = render(<SupervisedSessionView loadRows={async () => [
|
|
182
|
+
{ ...FIRST, cwd: '/projects/あいうえおかきくけこ甲' },
|
|
183
|
+
{ ...THIRD, cwd: '/projects/あいうえおかきくけこ乙' },
|
|
184
|
+
]} />);
|
|
185
|
+
try {
|
|
186
|
+
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
187
|
+
view.stdout.emit('resize');
|
|
188
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('2 supervised'));
|
|
189
|
+
view.stdin.write('g');
|
|
190
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('甲'));
|
|
191
|
+
expect(view.lastFrame()).toContain('乙');
|
|
192
|
+
expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
|
|
193
|
+
} finally {
|
|
194
|
+
view.unmount();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('escapes layout controls in verified directory headings', async () => {
|
|
199
|
+
const view = render(<SupervisedSessionView loadRows={async () => [
|
|
200
|
+
{ ...FIRST, cwd: '/projects/line\nbreak/app' },
|
|
201
|
+
]} />);
|
|
202
|
+
try {
|
|
203
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
204
|
+
view.stdin.write('g');
|
|
205
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1:'));
|
|
206
|
+
expect(view.lastFrame()).toContain('line\\u{a}break/app');
|
|
207
|
+
expect(view.lastFrame()).not.toContain('line\nbreak/app');
|
|
208
|
+
expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
|
|
209
|
+
} finally {
|
|
210
|
+
view.unmount();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('clears a pending screen-reader number before directory regrouping', async () => {
|
|
215
|
+
const view = render(
|
|
216
|
+
<ScreenReaderProvider enabled>
|
|
217
|
+
<SupervisedSessionView loadRows={async () => [
|
|
218
|
+
{ ...FIRST, cwd: '/projects/z' },
|
|
219
|
+
{ ...THIRD, cwd: '/projects/a' },
|
|
220
|
+
]} />
|
|
221
|
+
</ScreenReaderProvider>,
|
|
222
|
+
);
|
|
223
|
+
try {
|
|
224
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
225
|
+
view.stdin.write('1');
|
|
226
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Escape to cancel 1'));
|
|
227
|
+
view.stdin.write('g');
|
|
228
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1: projects/a'));
|
|
229
|
+
expect(view.lastFrame()).not.toContain('Escape to cancel 1');
|
|
230
|
+
view.stdin.write('\r');
|
|
231
|
+
expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`);
|
|
232
|
+
} finally {
|
|
233
|
+
view.unmount();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('filters by an observed group without treating dead or unverified rows as idle', async () => {
|
|
238
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND, THIRD]} stateFilter="idle" />);
|
|
239
|
+
try {
|
|
240
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
241
|
+
expect(view.lastFrame()).toContain('State: idle');
|
|
242
|
+
expect(view.lastFrame()).not.toContain(FIRST.id);
|
|
243
|
+
expect(view.lastFrame()).not.toContain(SECOND.id);
|
|
244
|
+
} finally {
|
|
245
|
+
view.unmount();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('shows an observed loop eligibility countdown without changing idle into a process state', async () => {
|
|
250
|
+
const nextLoopAt = new Date(Date.now() + 1_200).toISOString();
|
|
251
|
+
const view = render(<SupervisedSessionView
|
|
252
|
+
loadRows={async () => [{ ...THIRD, nextLoopAt }]}
|
|
253
|
+
stateFilter="idle" refreshMs={100}
|
|
254
|
+
/>);
|
|
255
|
+
try {
|
|
256
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible in'));
|
|
257
|
+
expect(view.lastFrame()).toContain('activity idle');
|
|
258
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible now'), { timeout: 3_000 });
|
|
259
|
+
} finally {
|
|
260
|
+
view.unmount();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('includes the loop eligibility label in screen-reader output', async () => {
|
|
265
|
+
const nextLoopAt = new Date(Date.now() + 60_000).toISOString();
|
|
266
|
+
const view = render(
|
|
267
|
+
<ScreenReaderProvider enabled>
|
|
268
|
+
<SupervisedSessionView loadRows={async () => [{ ...THIRD, nextLoopAt }]} />
|
|
269
|
+
</ScreenReaderProvider>,
|
|
270
|
+
);
|
|
271
|
+
try {
|
|
272
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible in'));
|
|
273
|
+
expect(view.lastFrame()).toContain('activity idle');
|
|
274
|
+
} finally {
|
|
275
|
+
view.unmount();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('shows a verified human name while preserving the exact selected ID', async () => {
|
|
280
|
+
const view = render(<SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />);
|
|
281
|
+
try {
|
|
282
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
|
|
283
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
284
|
+
expect(view.lastFrame()).toContain('activity idle');
|
|
285
|
+
} finally {
|
|
286
|
+
view.unmount();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('starts a new background session without closing the view', async () => {
|
|
291
|
+
const start = vi.fn(async () => SECOND.id);
|
|
292
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onStart={start} />);
|
|
293
|
+
try {
|
|
294
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
295
|
+
view.stdin.write('n');
|
|
296
|
+
await vi.waitFor(() => expect(start).toHaveBeenCalledTimes(1));
|
|
297
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Started ${SECOND.id}`));
|
|
298
|
+
expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`);
|
|
299
|
+
view.stdin.write('?');
|
|
300
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('n New session'));
|
|
301
|
+
} finally {
|
|
302
|
+
view.unmount();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('keeps stop cancellation separate from starting a session', async () => {
|
|
307
|
+
const start = vi.fn(async () => SECOND.id);
|
|
308
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onStart={start}
|
|
309
|
+
onStop={async () => undefined} />);
|
|
310
|
+
try {
|
|
311
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
312
|
+
view.stdin.write('s');
|
|
313
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
314
|
+
view.stdin.write('n');
|
|
315
|
+
await vi.waitFor(() => expect(view.lastFrame()).not.toContain('y Yes / n No'));
|
|
316
|
+
expect(start).not.toHaveBeenCalled();
|
|
317
|
+
} finally {
|
|
318
|
+
view.unmount();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('can start from an empty view and hides private launch errors', async () => {
|
|
323
|
+
const start = vi.fn().mockRejectedValueOnce(new Error('/private/token-path'))
|
|
324
|
+
.mockResolvedValueOnce(SECOND.id);
|
|
325
|
+
const view = render(<SupervisedSessionView loadRows={async () => []} onStart={start} />);
|
|
326
|
+
try {
|
|
327
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('No supervised sessions'));
|
|
328
|
+
view.stdin.write('n');
|
|
329
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Start failed'));
|
|
330
|
+
expect(view.lastFrame()).not.toContain('/private/token-path');
|
|
331
|
+
view.stdin.write('n');
|
|
332
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Started ${SECOND.id}`));
|
|
333
|
+
expect(start).toHaveBeenCalledTimes(2);
|
|
334
|
+
} finally {
|
|
335
|
+
view.unmount();
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('does not stop a row that leaves the selected state while confirmation is open', async () => {
|
|
340
|
+
let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
341
|
+
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => { finishRefresh = resolve; });
|
|
342
|
+
const loadRows = vi.fn().mockResolvedValueOnce([THIRD]).mockImplementation(() => refresh);
|
|
343
|
+
const stop = vi.fn(async () => undefined);
|
|
344
|
+
const view = render(<SupervisedSessionView loadRows={loadRows} onStop={stop} stateFilter="idle" refreshMs={100} />);
|
|
345
|
+
try {
|
|
346
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
347
|
+
view.stdin.write('s');
|
|
348
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
349
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
|
|
350
|
+
finishRefresh?.([{ ...THIRD, activity: 'working' }]);
|
|
351
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('0 supervised session(s)'));
|
|
352
|
+
view.stdin.write('y');
|
|
353
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
|
|
354
|
+
expect(stop).not.toHaveBeenCalled();
|
|
355
|
+
} finally {
|
|
356
|
+
view.unmount();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('lists every active key in help even in a narrow, crowded terminal', async () => {
|
|
361
|
+
const rows = Array.from({ length: 18 }, (_, index) => ({
|
|
362
|
+
...FIRST, id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
|
|
363
|
+
}));
|
|
364
|
+
const view = render(<SupervisedSessionView loadRows={async () => rows} onStart={async () => SECOND.id} />);
|
|
365
|
+
try {
|
|
366
|
+
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
367
|
+
view.stdout.emit('resize');
|
|
368
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('18 supervised'));
|
|
369
|
+
view.stdin.write('?');
|
|
370
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Keys:'));
|
|
371
|
+
for (const key of ['↑/↓ Select', 's Request stop', 'g Group state/dir', 'n New session', 'y Confirm stop', 'n/Esc Cancel stop', 'q/Esc/Ctrl+C Close', '? Toggle help']) {
|
|
372
|
+
expect(view.lastFrame()).toContain(key);
|
|
373
|
+
}
|
|
374
|
+
expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
|
|
375
|
+
} finally {
|
|
376
|
+
view.unmount();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('announces an active directory filter without printing its path', async () => {
|
|
381
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} filteredByCwd />);
|
|
382
|
+
try {
|
|
383
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
|
|
384
|
+
expect(view.lastFrame()).toContain('Background sessions in selected directory');
|
|
385
|
+
expect(view.lastFrame()).not.toContain('/private/project');
|
|
386
|
+
} finally {
|
|
387
|
+
view.unmount();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('shows a navigable global background list without inventing completion or attach', async () => {
|
|
392
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} refreshMs={100} />);
|
|
393
|
+
try {
|
|
394
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
|
|
395
|
+
expect(view.lastFrame()).toContain('working');
|
|
396
|
+
expect(view.lastFrame()).toContain('dead');
|
|
397
|
+
expect(view.lastFrame()).not.toMatch(/completed|failed|restartable|attach/i);
|
|
398
|
+
view.stdin.write('\x1B[B');
|
|
399
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
|
|
400
|
+
} finally {
|
|
401
|
+
view.unmount();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('never overlaps refreshes and aborts an in-flight probe when closed', async () => {
|
|
406
|
+
let resolveSecond: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
407
|
+
const pending = new Promise<readonly ISupervisedViewRow[]>((resolve) => { resolveSecond = resolve; });
|
|
408
|
+
const signals: AbortSignal[] = [];
|
|
409
|
+
const loadRows = vi.fn((signal: AbortSignal) => {
|
|
410
|
+
signals.push(signal);
|
|
411
|
+
return signals.length === 1 ? Promise.resolve([FIRST]) : pending;
|
|
412
|
+
});
|
|
413
|
+
const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={20} />);
|
|
414
|
+
try {
|
|
415
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
|
|
416
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 80));
|
|
417
|
+
expect(loadRows).toHaveBeenCalledTimes(2);
|
|
418
|
+
view.stdin.write('q');
|
|
419
|
+
await vi.waitFor(() => expect(signals[1]?.aborted).toBe(true));
|
|
420
|
+
resolveSecond?.([SECOND]);
|
|
421
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 40));
|
|
422
|
+
expect(loadRows).toHaveBeenCalledTimes(2);
|
|
423
|
+
} finally {
|
|
424
|
+
view.unmount();
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('keeps the selected session when activity changes reorder the groups', async () => {
|
|
429
|
+
const loadRows = vi.fn()
|
|
430
|
+
.mockResolvedValueOnce([FIRST, SECOND])
|
|
431
|
+
.mockResolvedValue([
|
|
432
|
+
{ ...FIRST, liveness: 'dead', control: 'unavailable', activity: 'unknown' },
|
|
433
|
+
{ ...SECOND, liveness: 'alive', control: 'available', activity: 'working' },
|
|
434
|
+
]);
|
|
435
|
+
const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={150} />);
|
|
436
|
+
try {
|
|
437
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
|
|
438
|
+
view.stdin.write('\x1B[B');
|
|
439
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
|
|
440
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
|
|
441
|
+
expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`);
|
|
442
|
+
} finally {
|
|
443
|
+
view.unmount();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('uses spoken state words and numbered selection in screen-reader mode', async () => {
|
|
448
|
+
const view = render(
|
|
449
|
+
<ScreenReaderProvider enabled>
|
|
450
|
+
<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} refreshMs={100} />
|
|
451
|
+
</ScreenReaderProvider>,
|
|
452
|
+
);
|
|
453
|
+
try {
|
|
454
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('1.'));
|
|
455
|
+
expect(view.lastFrame()).toContain('working');
|
|
456
|
+
expect(view.lastFrame()).toContain('dead');
|
|
457
|
+
expect(view.lastFrame()).toContain('Enter selection (1-2)');
|
|
458
|
+
view.stdin.write('?');
|
|
459
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Number+Enter Select'));
|
|
460
|
+
expect(view.lastFrame()).not.toContain('↑/↓ Select');
|
|
461
|
+
view.stdin.write('2');
|
|
462
|
+
view.stdin.write('\r');
|
|
463
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
|
|
464
|
+
view.stdin.write('1');
|
|
465
|
+
view.stdin.write('\r');
|
|
466
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
467
|
+
} finally {
|
|
468
|
+
view.unmount();
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('keeps grouped rows and controls within a 24-line terminal', async () => {
|
|
473
|
+
const activities = ['needs-input', 'working', 'idle', 'unknown'] as const;
|
|
474
|
+
const rows: ISupervisedViewRow[] = Array.from({ length: 17 }, (_, index) => ({
|
|
475
|
+
id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
|
|
476
|
+
liveness: index === 16 ? 'dead' : 'alive',
|
|
477
|
+
control: index === 15 ? 'unavailable' : 'available',
|
|
478
|
+
activity: activities[index % activities.length]!,
|
|
479
|
+
}));
|
|
480
|
+
const view = render(<SupervisedSessionView loadRows={async () => rows} refreshMs={100} />);
|
|
481
|
+
try {
|
|
482
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('17 supervised session(s)'));
|
|
483
|
+
const frame = view.lastFrame() ?? '';
|
|
484
|
+
expect(frame.split('\n').length).toBeLessThanOrEqual(24);
|
|
485
|
+
expect(frame).toContain('more below');
|
|
486
|
+
expect(frame).toContain('↑↓ Navigate');
|
|
487
|
+
} finally {
|
|
488
|
+
view.unmount();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('keeps stop confirmation and controls visible in a narrow 24-line terminal', async () => {
|
|
493
|
+
const rows: ISupervisedViewRow[] = Array.from({ length: 22 }, (_, index) => ({
|
|
494
|
+
...FIRST,
|
|
495
|
+
id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
|
|
496
|
+
}));
|
|
497
|
+
const view = render(<SupervisedSessionView loadRows={async () => rows} onStop={async () => undefined} />);
|
|
498
|
+
try {
|
|
499
|
+
Object.defineProperty(view.stdout, 'columns', { value: 40 });
|
|
500
|
+
view.stdout.emit('resize');
|
|
501
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Selected'));
|
|
502
|
+
view.stdin.write('s');
|
|
503
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Stop …00000000?'));
|
|
504
|
+
expect(view.lastFrame()).toContain('y Yes / n No');
|
|
505
|
+
const frame = view.lastFrame() ?? '';
|
|
506
|
+
expect(frame.split('\n').length).toBeLessThanOrEqual(24);
|
|
507
|
+
expect(frame).toContain('Confirm stop or cancel before closing.');
|
|
508
|
+
} finally {
|
|
509
|
+
view.unmount();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('identifies the selected stop target even at 20 columns with matching UUID prefixes', async () => {
|
|
514
|
+
const similar = { ...FIRST, id: `${FIRST.id.slice(0, -8)}ffffffff` };
|
|
515
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST, similar]} onStop={async () => undefined} />);
|
|
516
|
+
try {
|
|
517
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
518
|
+
view.stdin.write('\x1B[B');
|
|
519
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${similar.id}`));
|
|
520
|
+
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
521
|
+
view.stdout.emit('resize');
|
|
522
|
+
view.stdin.write('s');
|
|
523
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Stop …ffffffff?'));
|
|
524
|
+
expect(view.lastFrame()).toContain('y Yes / n No');
|
|
525
|
+
expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
|
|
526
|
+
} finally {
|
|
527
|
+
view.unmount();
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('requires confirmation and stops only the selected owner-controllable row', async () => {
|
|
532
|
+
let finishStop: (() => void) | undefined;
|
|
533
|
+
const stop = vi.fn(() => new Promise<void>((resolve) => { finishStop = resolve; }));
|
|
534
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />);
|
|
535
|
+
try {
|
|
536
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
537
|
+
view.stdin.write('s');
|
|
538
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
539
|
+
expect(stop).not.toHaveBeenCalled();
|
|
540
|
+
view.stdin.write('n');
|
|
541
|
+
expect(stop).not.toHaveBeenCalled();
|
|
542
|
+
await vi.waitFor(() => expect(view.lastFrame()).not.toContain('y Yes / n No'));
|
|
543
|
+
view.stdin.write('s');
|
|
544
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
545
|
+
view.stdin.write('y');
|
|
546
|
+
await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(FIRST.id));
|
|
547
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Stopping'));
|
|
548
|
+
expect(view.lastFrame()).toContain('Stop in progress; wait for result.');
|
|
549
|
+
expect(view.lastFrame()).not.toContain('q/Esc Close');
|
|
550
|
+
view.stdin.write('q');
|
|
551
|
+
view.stdin.write('\x1B');
|
|
552
|
+
view.stdin.write('\x03');
|
|
553
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
|
554
|
+
expect(view.lastFrame()).toContain('Stopping');
|
|
555
|
+
finishStop?.();
|
|
556
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Stopped ${FIRST.id}`));
|
|
557
|
+
} finally {
|
|
558
|
+
view.unmount();
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('refuses a stop when polling removes control while confirmation is open', async () => {
|
|
563
|
+
let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
564
|
+
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => { finishRefresh = resolve; });
|
|
565
|
+
const loadRows = vi.fn()
|
|
566
|
+
.mockResolvedValueOnce([FIRST])
|
|
567
|
+
.mockImplementation(() => refresh);
|
|
568
|
+
const stop = vi.fn(async () => undefined);
|
|
569
|
+
const view = render(<SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />);
|
|
570
|
+
try {
|
|
571
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
572
|
+
view.stdin.write('s');
|
|
573
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
574
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
|
|
575
|
+
finishRefresh?.([{ ...FIRST, control: 'unavailable', activity: 'unknown' }]);
|
|
576
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('control unavailable'));
|
|
577
|
+
view.stdin.write('y');
|
|
578
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
|
|
579
|
+
expect(stop).not.toHaveBeenCalled();
|
|
580
|
+
} finally {
|
|
581
|
+
view.unmount();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('never offers stop for unavailable rows and leaves failures visible', async () => {
|
|
586
|
+
const stop = vi.fn(async () => { throw new Error('private path must not be shown'); });
|
|
587
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />);
|
|
588
|
+
try {
|
|
589
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
590
|
+
view.stdin.write('\x1B[B');
|
|
591
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
|
|
592
|
+
view.stdin.write('s');
|
|
593
|
+
expect(stop).not.toHaveBeenCalled();
|
|
594
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
|
|
595
|
+
view.stdin.write('\x1B[A');
|
|
596
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
597
|
+
view.stdin.write('s');
|
|
598
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
599
|
+
view.stdin.write('y');
|
|
600
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Stop failed'));
|
|
601
|
+
expect(view.lastFrame()).toContain(FIRST.id);
|
|
602
|
+
expect(view.lastFrame()).not.toContain('private path');
|
|
603
|
+
} finally {
|
|
604
|
+
view.unmount();
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('uses the same confirmed stop action after screen-reader selection', async () => {
|
|
609
|
+
const stop = vi.fn(async () => undefined);
|
|
610
|
+
const view = render(
|
|
611
|
+
<ScreenReaderProvider enabled>
|
|
612
|
+
<SupervisedSessionView loadRows={async () => [FIRST, SECOND, THIRD]} onStop={stop} />
|
|
613
|
+
</ScreenReaderProvider>,
|
|
614
|
+
);
|
|
615
|
+
try {
|
|
616
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
617
|
+
view.stdin.write('2');
|
|
618
|
+
view.stdin.write('\r');
|
|
619
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
620
|
+
view.stdin.write('s');
|
|
621
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Stop ${THIRD.id}?`));
|
|
622
|
+
expect(view.lastFrame()).not.toContain('Enter selection');
|
|
623
|
+
view.stdin.write('y');
|
|
624
|
+
await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(THIRD.id));
|
|
625
|
+
} finally {
|
|
626
|
+
view.unmount();
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
});
|