@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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The switcher's selection state: where the cursor is, which slice of entries is on screen, and what
|
|
3
|
+
* a keypress does to both.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `ExecutionWorkspaceSwitcher.tsx` (CLI-1994 + CLI-2004 both grew that file past its
|
|
6
|
+
* size budget) along the seam that was already there: this half owns "what is selected", the
|
|
7
|
+
* component owns "what is drawn".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
applySelectionInput,
|
|
14
|
+
createSelectionFlowState,
|
|
15
|
+
normalizeSelectionState,
|
|
16
|
+
type ISelectionFlowState,
|
|
17
|
+
type TSelectionInputAction,
|
|
18
|
+
} from './flows/selection-flow.js';
|
|
19
|
+
|
|
20
|
+
import type { IExecutionWorkspaceEntry } from '@robota-sdk/agent-interface-execution';
|
|
21
|
+
import type React from 'react';
|
|
22
|
+
|
|
23
|
+
/** How many entries the list shows at once; the rest scroll. Read only by this module. */
|
|
24
|
+
const MAX_VISIBLE_WORKSPACE_ENTRIES = 8;
|
|
25
|
+
|
|
26
|
+
export interface IUseWorkspaceSwitcherSelectionInput {
|
|
27
|
+
entries: IExecutionWorkspaceEntry[];
|
|
28
|
+
selectedEntryId?: string;
|
|
29
|
+
onSelect: (entryId: string) => void;
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useWorkspaceSwitcherSelection({
|
|
34
|
+
entries,
|
|
35
|
+
selectedEntryId,
|
|
36
|
+
onSelect,
|
|
37
|
+
onClose,
|
|
38
|
+
}: IUseWorkspaceSwitcherSelectionInput): {
|
|
39
|
+
normalized: ISelectionFlowState;
|
|
40
|
+
visibleEntries: IExecutionWorkspaceEntry[];
|
|
41
|
+
applyAction: (action: TSelectionInputAction) => void;
|
|
42
|
+
} {
|
|
43
|
+
const [state, setState] = useState<ISelectionFlowState>(() => createSelectionFlowState());
|
|
44
|
+
const stateRef = useRef(state);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const selectedIndex = Math.max(
|
|
48
|
+
0,
|
|
49
|
+
entries.findIndex((entry) => entry.id === selectedEntryId),
|
|
50
|
+
);
|
|
51
|
+
const nextState = createNormalizedSelection({ selectedIndex, itemCount: entries.length });
|
|
52
|
+
stateRef.current = nextState;
|
|
53
|
+
setState(nextState);
|
|
54
|
+
}, [entries.length, selectedEntryId]);
|
|
55
|
+
|
|
56
|
+
const normalized = createNormalizedSelection({
|
|
57
|
+
selectedIndex: state.selectedIndex,
|
|
58
|
+
scrollOffset: state.scrollOffset,
|
|
59
|
+
itemCount: entries.length,
|
|
60
|
+
});
|
|
61
|
+
if (normalized !== state) stateRef.current = normalized;
|
|
62
|
+
return {
|
|
63
|
+
normalized,
|
|
64
|
+
visibleEntries: entries.slice(
|
|
65
|
+
normalized.scrollOffset,
|
|
66
|
+
normalized.scrollOffset + MAX_VISIBLE_WORKSPACE_ENTRIES,
|
|
67
|
+
),
|
|
68
|
+
applyAction: createApplyAction({ entries, stateRef, setState, onSelect, onClose }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createApplyAction({
|
|
73
|
+
entries,
|
|
74
|
+
stateRef,
|
|
75
|
+
setState,
|
|
76
|
+
onSelect,
|
|
77
|
+
onClose,
|
|
78
|
+
}: {
|
|
79
|
+
entries: IExecutionWorkspaceEntry[];
|
|
80
|
+
stateRef: React.MutableRefObject<ISelectionFlowState>;
|
|
81
|
+
setState: React.Dispatch<React.SetStateAction<ISelectionFlowState>>;
|
|
82
|
+
onSelect: (entryId: string) => void;
|
|
83
|
+
onClose: () => void;
|
|
84
|
+
}): (action: TSelectionInputAction) => void {
|
|
85
|
+
return (action): void => {
|
|
86
|
+
const result = applySelectionInput(stateRef.current, action, {
|
|
87
|
+
itemCount: entries.length,
|
|
88
|
+
maxVisible: MAX_VISIBLE_WORKSPACE_ENTRIES,
|
|
89
|
+
});
|
|
90
|
+
const nextState =
|
|
91
|
+
result.effect.type === 'select' || result.effect.type === 'cancel'
|
|
92
|
+
? { ...result.state, resolved: false }
|
|
93
|
+
: result.state;
|
|
94
|
+
stateRef.current = nextState;
|
|
95
|
+
setState(nextState);
|
|
96
|
+
if (result.effect.type === 'cancel') {
|
|
97
|
+
onClose();
|
|
98
|
+
} else if (result.effect.type === 'select') {
|
|
99
|
+
const entry = entries[result.effect.index];
|
|
100
|
+
if (entry) onSelect(entry.id);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createNormalizedSelection(input: {
|
|
106
|
+
selectedIndex: number;
|
|
107
|
+
scrollOffset?: number;
|
|
108
|
+
itemCount: number;
|
|
109
|
+
}): ISelectionFlowState {
|
|
110
|
+
return normalizeSelectionState(
|
|
111
|
+
{
|
|
112
|
+
selectedIndex: input.selectedIndex,
|
|
113
|
+
scrollOffset: input.scrollOffset ?? 0,
|
|
114
|
+
resolved: false,
|
|
115
|
+
},
|
|
116
|
+
{ itemCount: input.itemCount, maxVisible: MAX_VISIBLE_WORKSPACE_ENTRIES },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { workspaceStatusKind } from './status-glyph.js';
|
|
2
|
+
|
|
3
|
+
import type { TUiStatusKind } from './status-glyph.js';
|
|
4
|
+
import type {
|
|
5
|
+
IExecutionDetailRecord,
|
|
6
|
+
IExecutionWorkspaceEntry,
|
|
7
|
+
IExecutionWorkspaceSnapshot,
|
|
8
|
+
TExecutionWorkspaceStatus,
|
|
9
|
+
} from '@robota-sdk/agent-interface-execution';
|
|
10
|
+
|
|
11
|
+
// "Active" for the in-flight COUNT (countActiveBackgroundWorkspaceEntries) —
|
|
12
|
+
// intentionally includes `waiting_permission` (a task awaiting permission is still
|
|
13
|
+
// in flight). This is a different question from the status KIND used for
|
|
14
|
+
// colour/glyph (where `waiting_permission` maps to its own 'waiting' kind via
|
|
15
|
+
// `workspaceStatusKind`), so the two lists differ on purpose.
|
|
16
|
+
const ACTIVE_STATUSES: readonly TExecutionWorkspaceStatus[] = [
|
|
17
|
+
'active',
|
|
18
|
+
'queued',
|
|
19
|
+
'running',
|
|
20
|
+
'waiting_permission',
|
|
21
|
+
'sleeping',
|
|
22
|
+
];
|
|
23
|
+
const DETAIL_RECORD_TEXT_LIMIT = 160;
|
|
24
|
+
const PREVIEW_WHITESPACE = /\s+/g;
|
|
25
|
+
const PREVIEW_SEPARATOR = ' ';
|
|
26
|
+
|
|
27
|
+
export interface IExecutionWorkspaceEntryRow {
|
|
28
|
+
id: string;
|
|
29
|
+
radio: '●' | '○';
|
|
30
|
+
/** SCREEN-1992: the five-word state, rendered beside the radio and carried in the accessible text. */
|
|
31
|
+
state: string;
|
|
32
|
+
title: string;
|
|
33
|
+
subtitle?: string;
|
|
34
|
+
statusLabel: string;
|
|
35
|
+
preview?: string;
|
|
36
|
+
/** SCREEN-2002: the status KIND; the component resolves its colour from the live theme. */
|
|
37
|
+
statusKind: TUiStatusKind;
|
|
38
|
+
isSelected: boolean;
|
|
39
|
+
accessibleText: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface IExecutionWorkspaceEntryRowOptions {
|
|
43
|
+
selectedEntryId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getDefaultBackgroundWorkspaceEntries(
|
|
47
|
+
snapshot: IExecutionWorkspaceSnapshot | null,
|
|
48
|
+
): IExecutionWorkspaceEntry[] {
|
|
49
|
+
return (snapshot?.entries ?? []).filter(
|
|
50
|
+
(entry) => entry.kind === 'background_task' && entry.visibility === 'default',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function countActiveBackgroundWorkspaceEntries(
|
|
55
|
+
snapshot: IExecutionWorkspaceSnapshot | null,
|
|
56
|
+
): number {
|
|
57
|
+
return getDefaultBackgroundWorkspaceEntries(snapshot).filter((entry) =>
|
|
58
|
+
ACTIVE_STATUSES.includes(entry.status),
|
|
59
|
+
).length;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatExecutionWorkspaceEntryRow(
|
|
63
|
+
entry: IExecutionWorkspaceEntry,
|
|
64
|
+
options: IExecutionWorkspaceEntryRowOptions = {},
|
|
65
|
+
): IExecutionWorkspaceEntryRow {
|
|
66
|
+
const isSelected = entry.id === options.selectedEntryId;
|
|
67
|
+
const row = {
|
|
68
|
+
id: entry.id,
|
|
69
|
+
radio: isSelected ? '●' : '○',
|
|
70
|
+
state: entry.state,
|
|
71
|
+
title: formatEntryTitle(entry),
|
|
72
|
+
subtitle: formatEntrySubtitle(entry),
|
|
73
|
+
statusLabel: formatStatusLabel(entry.status),
|
|
74
|
+
preview: trimPreview(entry.preview ?? entry.currentAction),
|
|
75
|
+
statusKind: getEntryStatusKind(entry),
|
|
76
|
+
isSelected,
|
|
77
|
+
} satisfies Omit<IExecutionWorkspaceEntryRow, 'accessibleText'>;
|
|
78
|
+
return { ...row, accessibleText: formatAccessibleText(row) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatExecutionDetailRecord(record: IExecutionDetailRecord): string {
|
|
82
|
+
const text = record.text.trim().replace(PREVIEW_WHITESPACE, PREVIEW_SEPARATOR);
|
|
83
|
+
if (!text) return record.kind;
|
|
84
|
+
return text.length > DETAIL_RECORD_TEXT_LIMIT
|
|
85
|
+
? `${text.slice(0, DETAIL_RECORD_TEXT_LIMIT)}...`
|
|
86
|
+
: text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatEntryTitle(entry: IExecutionWorkspaceEntry): string {
|
|
90
|
+
if (entry.kind === 'main_thread') return entry.title;
|
|
91
|
+
if (entry.kind === 'background_group') return `${entry.title} group`;
|
|
92
|
+
if (entry.taskKind === 'agent') return `${entry.title} agent`;
|
|
93
|
+
if (entry.taskKind === 'process') return entry.title || 'Process';
|
|
94
|
+
if (entry.taskKind === 'scheduled') return entry.title || 'Scheduled';
|
|
95
|
+
return entry.title;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatEntrySubtitle(entry: IExecutionWorkspaceEntry): string | undefined {
|
|
99
|
+
if (entry.kind === 'main_thread') return entry.subtitle;
|
|
100
|
+
const parts = [
|
|
101
|
+
entry.taskKind,
|
|
102
|
+
entry.subtitle,
|
|
103
|
+
entry.attention === 'none' ? undefined : entry.attention,
|
|
104
|
+
];
|
|
105
|
+
return (
|
|
106
|
+
parts
|
|
107
|
+
.filter((part): part is string => typeof part === 'string' && part.length > 0)
|
|
108
|
+
.join(' · ') || undefined
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatStatusLabel(status: TExecutionWorkspaceStatus): string {
|
|
113
|
+
return status.replace(/_/g, ' ');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// SCREEN-2002: the view model names the status KIND, not a colour — a colour belongs to the theme
|
|
117
|
+
// resolved at render time, and the kind is what keeps an entry's colour matching the glyph shown for
|
|
118
|
+
// the same status everywhere (SCREEN-007). The component resolves it through `statusGlyphColor`.
|
|
119
|
+
function getEntryStatusKind(entry: IExecutionWorkspaceEntry): TUiStatusKind {
|
|
120
|
+
return workspaceStatusKind(entry.status, entry.attention);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function trimPreview(value: string | undefined): string | undefined {
|
|
124
|
+
const preview = value?.trim().replace(PREVIEW_WHITESPACE, PREVIEW_SEPARATOR);
|
|
125
|
+
return preview || undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatAccessibleText(row: Omit<IExecutionWorkspaceEntryRow, 'accessibleText'>): string {
|
|
129
|
+
const parts = [
|
|
130
|
+
`${row.radio} ${row.state}`,
|
|
131
|
+
row.title,
|
|
132
|
+
row.statusLabel,
|
|
133
|
+
row.subtitle,
|
|
134
|
+
row.preview,
|
|
135
|
+
];
|
|
136
|
+
return parts
|
|
137
|
+
.filter((part): part is string => typeof part === 'string' && part.length > 0)
|
|
138
|
+
.join(' · ');
|
|
139
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLOW-2006: the line that says a prompt did not come from the person at the keyboard.
|
|
3
|
+
*
|
|
4
|
+
* A deep link can fill the composer, so the provenance has to be visible for as long as the composer
|
|
5
|
+
* holds exactly what the link supplied — otherwise a prompt the user never typed reads exactly like
|
|
6
|
+
* one they did. The moment that text is edited, sent or cleared the label goes: what is there is then
|
|
7
|
+
* the user's own, and a label that outlived the text would be claiming otherwise. Above the long
|
|
8
|
+
* threshold it also carries the character count and says to read the whole thing, because a long
|
|
9
|
+
* prompt pushes its own instructions off screen.
|
|
10
|
+
*/
|
|
11
|
+
import React from 'react';
|
|
12
|
+
|
|
13
|
+
import { Text } from './SafeText.js';
|
|
14
|
+
|
|
15
|
+
/** Above this many characters the notice escalates from a label to a review instruction. */
|
|
16
|
+
export const EXTERNAL_PROMPT_REVIEW_THRESHOLD = 1000;
|
|
17
|
+
export const EXTERNAL_PROMPT_NOTICE = 'Prompt from an external link';
|
|
18
|
+
|
|
19
|
+
export interface IExternalPromptNoticeProps {
|
|
20
|
+
/** The composer's current text, so the notice can size its warning to what is actually there. */
|
|
21
|
+
readonly value: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The notice text for a value, or `undefined` when there is nothing to say. */
|
|
25
|
+
export function externalPromptNotice(value: string): string | undefined {
|
|
26
|
+
if (value.length === 0) return undefined;
|
|
27
|
+
return value.length > EXTERNAL_PROMPT_REVIEW_THRESHOLD
|
|
28
|
+
? `${EXTERNAL_PROMPT_NOTICE} — ${value.length} characters; scroll and review the full text before sending`
|
|
29
|
+
: EXTERNAL_PROMPT_NOTICE;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function ExternalPromptNotice({
|
|
33
|
+
value,
|
|
34
|
+
}: IExternalPromptNoticeProps): React.ReactElement | null {
|
|
35
|
+
const notice = externalPromptNotice(value);
|
|
36
|
+
if (notice === undefined) return null;
|
|
37
|
+
return <Text dimColor>{notice}</Text>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { resolveBackgroundFocusKey } from '../background-focus-flow.js';
|
|
4
|
+
|
|
5
|
+
const KEY = { upArrow: false, downArrow: false, return: false, escape: false };
|
|
6
|
+
|
|
7
|
+
describe('resolveBackgroundFocusKey (SCREEN-014)', () => {
|
|
8
|
+
it('↓ moves down, clamped at the last item', () => {
|
|
9
|
+
expect(resolveBackgroundFocusKey(0, 3, { ...KEY, downArrow: true })).toEqual({
|
|
10
|
+
type: 'move',
|
|
11
|
+
index: 1,
|
|
12
|
+
});
|
|
13
|
+
expect(resolveBackgroundFocusKey(2, 3, { ...KEY, downArrow: true })).toEqual({
|
|
14
|
+
type: 'move',
|
|
15
|
+
index: 2,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('↑ moves up; ↑ past the first item returns focus to the input (null)', () => {
|
|
20
|
+
expect(resolveBackgroundFocusKey(2, 3, { ...KEY, upArrow: true })).toEqual({
|
|
21
|
+
type: 'move',
|
|
22
|
+
index: 1,
|
|
23
|
+
});
|
|
24
|
+
expect(resolveBackgroundFocusKey(0, 3, { ...KEY, upArrow: true })).toEqual({
|
|
25
|
+
type: 'move',
|
|
26
|
+
index: null,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('Enter opens the highlighted task', () => {
|
|
31
|
+
expect(resolveBackgroundFocusKey(1, 3, { ...KEY, return: true })).toEqual({
|
|
32
|
+
type: 'open',
|
|
33
|
+
index: 1,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('Esc exits to the input', () => {
|
|
38
|
+
expect(resolveBackgroundFocusKey(1, 3, { ...KEY, escape: true })).toEqual({ type: 'exit' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('an emptied list always exits', () => {
|
|
42
|
+
expect(resolveBackgroundFocusKey(0, 0, { ...KEY, downArrow: true })).toEqual({ type: 'exit' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('ignores unrelated keys', () => {
|
|
46
|
+
expect(resolveBackgroundFocusKey(1, 3, KEY)).toEqual({ type: 'none' });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
cancelDeferredSubmit,
|
|
5
|
+
createDeferSubmitState,
|
|
6
|
+
scheduleDeferredSubmit,
|
|
7
|
+
} from '../defer-submit.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* CLI-061 — the deferred-submit orchestration. The load-bearing property: at fire time the submit reads
|
|
11
|
+
* `readLatest()` (the LIVE value), so a trailing IME character applied AFTER the schedule is included. The
|
|
12
|
+
* pre-fix path submitted the value captured at Enter time — this test fails against that.
|
|
13
|
+
*/
|
|
14
|
+
describe('scheduleDeferredSubmit (CLI-061)', () => {
|
|
15
|
+
/** A synchronous "scheduler" that captures the callback so the test fires it deterministically. */
|
|
16
|
+
function manualScheduler() {
|
|
17
|
+
let fired: (() => void) | undefined;
|
|
18
|
+
return {
|
|
19
|
+
schedule: (fn: () => void) => {
|
|
20
|
+
fired = fn;
|
|
21
|
+
return 1 as unknown as ReturnType<typeof setTimeout>;
|
|
22
|
+
},
|
|
23
|
+
fire: () => fired?.(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
it('submits the LATEST value at fire time, not the value at schedule time (the trailing char)', () => {
|
|
28
|
+
const state = createDeferSubmitState();
|
|
29
|
+
const submit = vi.fn();
|
|
30
|
+
const sched = manualScheduler();
|
|
31
|
+
|
|
32
|
+
// Value at Enter/schedule time is the pre-trailing-char string...
|
|
33
|
+
let live = '안녕하세';
|
|
34
|
+
scheduleDeferredSubmit(state, () => live, submit, 50, sched.schedule);
|
|
35
|
+
expect(submit).not.toHaveBeenCalled(); // deferred, not immediate
|
|
36
|
+
|
|
37
|
+
// ...the trailing syllable lands during the window...
|
|
38
|
+
live = '안녕하세요';
|
|
39
|
+
sched.fire();
|
|
40
|
+
|
|
41
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(submit).toHaveBeenCalledWith('안녕하세요'); // FULL value — fails on the pre-fix immediate submit
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('ignores a second submit while one is in flight (no double-submit)', () => {
|
|
46
|
+
const state = createDeferSubmitState();
|
|
47
|
+
const submit = vi.fn();
|
|
48
|
+
const sched = manualScheduler();
|
|
49
|
+
|
|
50
|
+
scheduleDeferredSubmit(state, () => 'a', submit, 50, sched.schedule);
|
|
51
|
+
scheduleDeferredSubmit(state, () => 'a', submit, 50, sched.schedule); // second Enter within the window
|
|
52
|
+
sched.fire();
|
|
53
|
+
|
|
54
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('a fresh submit is allowed after the previous one fires', () => {
|
|
58
|
+
const state = createDeferSubmitState();
|
|
59
|
+
const submit = vi.fn();
|
|
60
|
+
const sched = manualScheduler();
|
|
61
|
+
|
|
62
|
+
scheduleDeferredSubmit(state, () => 'a', submit, 50, sched.schedule);
|
|
63
|
+
sched.fire();
|
|
64
|
+
scheduleDeferredSubmit(state, () => 'b', submit, 50, sched.schedule);
|
|
65
|
+
sched.fire();
|
|
66
|
+
|
|
67
|
+
expect(submit.mock.calls).toEqual([['a'], ['b']]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('cancel clears a pending timer and releases the guard (no submit after unmount)', () => {
|
|
71
|
+
const state = createDeferSubmitState();
|
|
72
|
+
const submit = vi.fn();
|
|
73
|
+
const clear = vi.fn();
|
|
74
|
+
const sched = manualScheduler();
|
|
75
|
+
|
|
76
|
+
scheduleDeferredSubmit(state, () => 'a', submit, 50, sched.schedule);
|
|
77
|
+
cancelDeferredSubmit(state, clear);
|
|
78
|
+
|
|
79
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
80
|
+
expect(state.timer).toBeNull();
|
|
81
|
+
expect(state.isSubmitting).toBe(false);
|
|
82
|
+
// A new submit is possible again after cancel.
|
|
83
|
+
scheduleDeferredSubmit(state, () => 'b', submit, 50, sched.schedule);
|
|
84
|
+
sched.fire();
|
|
85
|
+
expect(submit).toHaveBeenCalledWith('b');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-062 — unit tables for the real-cursor pure flow.
|
|
3
|
+
*
|
|
4
|
+
* `computeCursorCell` maps (absolute input origin, value, cursor index, wrap width) to the
|
|
5
|
+
* hardware-cursor cell, reusing the wrap-aware `displayOffset` math the input's vertical
|
|
6
|
+
* navigation already trusts. `shouldPositionRealCursor` is the crash-avoidance guard: every
|
|
7
|
+
* `false` row below is a SIGSEGV-invariant case (the historical Terminal.app crash geometry —
|
|
8
|
+
* see .design/investigations/2026-07-25-cli-062-ime-cursor-design.md, invariants I1/I2/I5).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { computeCursorCell, shouldPositionRealCursor } from '../real-cursor-flow.js';
|
|
14
|
+
|
|
15
|
+
describe('computeCursorCell', () => {
|
|
16
|
+
const origin = { absX: 3, absY: 2 };
|
|
17
|
+
|
|
18
|
+
it('ASCII value, cursor at end, no wrap width', () => {
|
|
19
|
+
expect(computeCursorCell({ ...origin, value: 'hello', cursor: 5 })).toEqual({ x: 8, y: 2 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('ASCII value, cursor at start', () => {
|
|
23
|
+
expect(computeCursorCell({ ...origin, value: 'hello', cursor: 0 })).toEqual({ x: 3, y: 2 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('CJK wide chars count 2 columns each (안녕 → 4 columns)', () => {
|
|
27
|
+
expect(computeCursorCell({ ...origin, value: '안녕', cursor: 2, availableWidth: 20 })).toEqual({
|
|
28
|
+
x: 7,
|
|
29
|
+
y: 2,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('mid-string cursor inside a CJK value', () => {
|
|
34
|
+
expect(
|
|
35
|
+
computeCursorCell({ ...origin, value: '안녕하세요', cursor: 2, availableWidth: 20 }),
|
|
36
|
+
).toEqual({ x: 7, y: 2 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('no wrap width: CJK offset is the plain display-width sum', () => {
|
|
40
|
+
expect(computeCursorCell({ ...origin, value: '안녕하', cursor: 3 })).toEqual({ x: 9, y: 2 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('wraps to the next row when the offset passes the width', () => {
|
|
44
|
+
expect(
|
|
45
|
+
computeCursorCell({ ...origin, value: 'a'.repeat(12), cursor: 12, availableWidth: 10 }),
|
|
46
|
+
).toEqual({ x: 5, y: 3 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('exact-width boundary lands on column 0 of the next row', () => {
|
|
50
|
+
expect(
|
|
51
|
+
computeCursorCell({ ...origin, value: 'a'.repeat(12), cursor: 10, availableWidth: 10 }),
|
|
52
|
+
).toEqual({ x: 3, y: 3 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('wide char straddling the wrap boundary is pushed to the next row (display gap preserved)', () => {
|
|
56
|
+
// 9 ASCII cols + a wide char that cannot fit in the last column: displayOffset skips the
|
|
57
|
+
// dead column (9 → 10) then adds the wide char (→ 12) — cursor lands at row+1, col 2.
|
|
58
|
+
expect(
|
|
59
|
+
computeCursorCell({ ...origin, value: 'aaaaaaaaa안', cursor: 10, availableWidth: 10 }),
|
|
60
|
+
).toEqual({ x: 5, y: 3 });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('empty value (placeholder case) → the origin cell', () => {
|
|
64
|
+
expect(computeCursorCell({ ...origin, value: '', cursor: 0, availableWidth: 20 })).toEqual({
|
|
65
|
+
x: 3,
|
|
66
|
+
y: 2,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('cursor index beyond the value length clamps to the end', () => {
|
|
71
|
+
expect(computeCursorCell({ ...origin, value: 'ab', cursor: 99 })).toEqual({ x: 5, y: 2 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('non-positive availableWidth behaves as unwrapped', () => {
|
|
75
|
+
expect(computeCursorCell({ ...origin, value: 'hello', cursor: 5, availableWidth: 0 })).toEqual({
|
|
76
|
+
x: 8,
|
|
77
|
+
y: 2,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('shouldPositionRealCursor (every false row is a SIGSEGV-invariant case)', () => {
|
|
83
|
+
const ok = { hasMeasured: true, y: 3, frameHeight: 8, viewportRows: 24, capability: true };
|
|
84
|
+
|
|
85
|
+
it('positions when measured, in-bounds, frame smaller than viewport, capability on', () => {
|
|
86
|
+
expect(shouldPositionRealCursor(ok)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('measured y === 0 is valid (only a GUESSED y:0 is the bug class, not a measured one)', () => {
|
|
90
|
+
expect(shouldPositionRealCursor({ ...ok, y: 0 })).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('I5/gate: capability off → never position', () => {
|
|
94
|
+
expect(shouldPositionRealCursor({ ...ok, capability: false })).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('I1: no measurement yet → never position (the historical hardcoded y:0 class)', () => {
|
|
98
|
+
expect(shouldPositionRealCursor({ ...ok, hasMeasured: false })).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('I2: frame fills the viewport (ink fullscreen path, off-by-one) → never position', () => {
|
|
102
|
+
expect(shouldPositionRealCursor({ ...ok, frameHeight: 24 })).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('I2: frame overflows the viewport (cursorUp clamps at screen top — crash geometry) → never', () => {
|
|
106
|
+
expect(shouldPositionRealCursor({ ...ok, frameHeight: 30 })).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('I2: y below the frame → never position', () => {
|
|
110
|
+
expect(shouldPositionRealCursor({ ...ok, y: 8 })).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('I2: negative y → never position', () => {
|
|
114
|
+
expect(shouldPositionRealCursor({ ...ok, y: -1 })).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('degenerate viewport (0 rows) → never position', () => {
|
|
118
|
+
expect(shouldPositionRealCursor({ ...ok, viewportRows: 0 })).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('degenerate frame (0 height) → never position', () => {
|
|
122
|
+
expect(shouldPositionRealCursor({ ...ok, frameHeight: 0 })).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-014: pure reducer for keyboard navigation of the inline background-work list.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of the App component so the navigation rules are exhaustively unit-testable without
|
|
5
|
+
* rendering the whole TUI. The App's `useInput` translates the resulting action into focus/selection
|
|
6
|
+
* state changes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A key event relevant to background-list navigation (subset of Ink's key object). */
|
|
10
|
+
export interface IBackgroundFocusKey {
|
|
11
|
+
upArrow: boolean;
|
|
12
|
+
downArrow: boolean;
|
|
13
|
+
return: boolean;
|
|
14
|
+
escape: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TBackgroundFocusAction =
|
|
18
|
+
| { type: 'move'; index: number | null } // null = focus returns to the prompt input
|
|
19
|
+
| { type: 'open'; index: number } // open the highlighted task's inline detail
|
|
20
|
+
| { type: 'exit' } // leave the list, focus the prompt input
|
|
21
|
+
| { type: 'none' }; // unhandled key — ignore
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a keypress while the background list holds focus.
|
|
25
|
+
*
|
|
26
|
+
* - ↑ moves up; ↑ past the first item returns focus to the input (`move` to `null`).
|
|
27
|
+
* - ↓ moves down, clamped at the last item.
|
|
28
|
+
* - Enter opens the highlighted task.
|
|
29
|
+
* - Esc (or an emptied list) exits to the input.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveBackgroundFocusKey(
|
|
32
|
+
current: number,
|
|
33
|
+
entryCount: number,
|
|
34
|
+
key: IBackgroundFocusKey,
|
|
35
|
+
): TBackgroundFocusAction {
|
|
36
|
+
if (entryCount <= 0) return { type: 'exit' };
|
|
37
|
+
if (key.upArrow) return { type: 'move', index: current > 0 ? current - 1 : null };
|
|
38
|
+
if (key.downArrow) {
|
|
39
|
+
return { type: 'move', index: current < entryCount - 1 ? current + 1 : current };
|
|
40
|
+
}
|
|
41
|
+
if (key.return) return { type: 'open', index: Math.min(Math.max(current, 0), entryCount - 1) };
|
|
42
|
+
if (key.escape) return { type: 'exit' };
|
|
43
|
+
return { type: 'none' };
|
|
44
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CJK-aware cursor geometry and motion.
|
|
3
|
+
*
|
|
4
|
+
* Display columns and character indices are different things when a character is two columns wide;
|
|
5
|
+
* these functions are the translation between them, and the two cursor moves built on it.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from `cjk-text-input-flow.ts` (CLI-2004) by responsibility — that module routes
|
|
8
|
+
* keystrokes, this one measures and moves.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import stringWidth from 'string-width';
|
|
12
|
+
|
|
13
|
+
import type { ICjkTextInputFlowState, TCjkTextInputEffect } from './cjk-text-input-types.js';
|
|
14
|
+
|
|
15
|
+
interface ICjkMotionResult {
|
|
16
|
+
state: ICjkTextInputFlowState;
|
|
17
|
+
effect: TCjkTextInputEffect;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function displayOffset(chars: string[], charIndex: number, width: number): number {
|
|
21
|
+
let offset = 0;
|
|
22
|
+
for (let i = 0; i < charIndex && i < chars.length; i++) {
|
|
23
|
+
const w = stringWidth(chars[i]!);
|
|
24
|
+
const col = offset % width;
|
|
25
|
+
if (col + w > width) offset += width - col;
|
|
26
|
+
offset += w;
|
|
27
|
+
}
|
|
28
|
+
return offset;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function charIndexAtDisplayOffset(chars: string[], target: number, width: number): number {
|
|
32
|
+
let offset = 0;
|
|
33
|
+
for (let i = 0; i < chars.length; i++) {
|
|
34
|
+
if (offset >= target) return i;
|
|
35
|
+
const w = stringWidth(chars[i]!);
|
|
36
|
+
const col = offset % width;
|
|
37
|
+
if (col + w > width) offset += width - col;
|
|
38
|
+
offset += w;
|
|
39
|
+
}
|
|
40
|
+
return chars.length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function moveCursorVertically(
|
|
44
|
+
state: ICjkTextInputFlowState,
|
|
45
|
+
direction: 'up' | 'down',
|
|
46
|
+
availableWidth: number | undefined,
|
|
47
|
+
): ICjkMotionResult {
|
|
48
|
+
if (!availableWidth || availableWidth <= 0) {
|
|
49
|
+
return { state, effect: { type: 'none' } };
|
|
50
|
+
}
|
|
51
|
+
const chars = [...state.value];
|
|
52
|
+
const offset = displayOffset(chars, state.cursor, availableWidth);
|
|
53
|
+
const target = direction === 'up' ? offset - availableWidth : offset + availableWidth;
|
|
54
|
+
if (target < 0) {
|
|
55
|
+
return { state, effect: { type: 'none' } };
|
|
56
|
+
}
|
|
57
|
+
const cursor = charIndexAtDisplayOffset(chars, target, availableWidth);
|
|
58
|
+
if (cursor === state.cursor) {
|
|
59
|
+
return { state, effect: { type: 'none' } };
|
|
60
|
+
}
|
|
61
|
+
return { state: { ...state, cursor }, effect: { type: 'render' } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function moveCursorHorizontally(
|
|
65
|
+
state: ICjkTextInputFlowState,
|
|
66
|
+
direction: 'left' | 'right',
|
|
67
|
+
): ICjkMotionResult {
|
|
68
|
+
if (direction === 'left' && state.cursor > 0) {
|
|
69
|
+
return { state: { ...state, cursor: state.cursor - 1 }, effect: { type: 'render' } };
|
|
70
|
+
}
|
|
71
|
+
if (direction === 'right' && state.cursor < state.value.length) {
|
|
72
|
+
return { state: { ...state, cursor: state.cursor + 1 }, effect: { type: 'render' } };
|
|
73
|
+
}
|
|
74
|
+
return { state, effect: { type: 'none' } };
|
|
75
|
+
}
|