@robota-sdk/agent-ui-terminal 3.0.0-beta.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +27 -0
- package/dist/node/index.cjs +32 -0
- package/dist/node/index.d.cts +1066 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1066 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +33 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +93 -0
- package/src/App.tsx +146 -0
- package/src/AppPresentation.tsx +190 -0
- package/src/AppView.tsx +26 -0
- package/src/BackgroundTaskPanel.tsx +73 -0
- package/src/CjkTextInput.tsx +336 -0
- package/src/ConfirmPrompt.tsx +140 -0
- package/src/ContextWarningBanner.tsx +45 -0
- package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
- package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
- package/src/HistorySearchOverlay.tsx +150 -0
- package/src/InputArea.tsx +338 -0
- package/src/ListPicker.tsx +158 -0
- package/src/MenuSelect.tsx +159 -0
- package/src/MessageList.tsx +308 -0
- package/src/MultiSelectList.tsx +294 -0
- package/src/PendingActionPrompt.tsx +121 -0
- package/src/PermissionPrompt.tsx +186 -0
- package/src/PluginTUI.tsx +258 -0
- package/src/RoleLabel.tsx +72 -0
- package/src/SafeText.tsx +66 -0
- package/src/SessionEventNotices.tsx +27 -0
- package/src/SessionPicker.tsx +72 -0
- package/src/SessionStatusBar.tsx +76 -0
- package/src/SlashAutocomplete.tsx +154 -0
- package/src/StatusBar.tsx +284 -0
- package/src/StreamingIndicator.tsx +145 -0
- package/src/SupervisedSessionView.tsx +455 -0
- package/src/TextPrompt.tsx +112 -0
- package/src/ThemePicker.tsx +366 -0
- package/src/ToolCommandOutput.tsx +48 -0
- package/src/ToolDiffBlock.tsx +44 -0
- package/src/TransportTUI.tsx +166 -0
- package/src/TuiInteractionChannel.ts +567 -0
- package/src/UpdateNotice.tsx +18 -0
- package/src/UsageSummaryEntry.tsx +49 -0
- package/src/WaveText.tsx +70 -0
- package/src/__tests__/ListPicker.test.tsx +188 -0
- package/src/__tests__/MenuSelect.test.tsx +127 -0
- package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
- package/src/__tests__/PluginTUI.test.tsx +167 -0
- package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
- package/src/__tests__/SupervisedSessionView-render.test.tsx +24 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +629 -0
- package/src/__tests__/TextPrompt.test.tsx +112 -0
- package/src/__tests__/TransportTUI.test.tsx +124 -0
- package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +284 -0
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +642 -0
- package/src/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/__tests__/abort-after-permission.test.tsx +160 -0
- package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
- package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
- package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
- package/src/__tests__/background-task-panel.test.tsx +98 -0
- package/src/__tests__/background-task-row-format.test.ts +152 -0
- package/src/__tests__/channel-factory-integration.test.ts +150 -0
- package/src/__tests__/cjk-defer-submit.test.tsx +67 -0
- package/src/__tests__/cjk-fallback-render.test.tsx +78 -0
- package/src/__tests__/cjk-text-input-flow.test.ts +186 -0
- package/src/__tests__/cjk-text-input.test.ts +191 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
- package/src/__tests__/command-output-summary.test.ts +95 -0
- package/src/__tests__/command-result-handler.test.ts +171 -0
- package/src/__tests__/compact-event-bridge.test.ts +20 -0
- package/src/__tests__/confirm-permission-flow.test.ts +149 -0
- package/src/__tests__/confirm-prompt.test.tsx +103 -0
- package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +45 -0
- package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
- package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
- package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
- package/src/__tests__/external-prompt-notice.test.tsx +100 -0
- package/src/__tests__/fixtures/command-handoff-driver.tsx +111 -0
- package/src/__tests__/fixtures/fake-editor.sh +7 -0
- package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
- package/src/__tests__/fork-attach.test.tsx +235 -0
- package/src/__tests__/helpers/terminal-profiles.ts +152 -0
- package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
- package/src/__tests__/history-clear-broadcast.test.ts +66 -0
- package/src/__tests__/history-search-overlay.test.tsx +329 -0
- package/src/__tests__/humanize-tool-name.test.ts +30 -0
- package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
- package/src/__tests__/input-area-flow.test.ts +199 -0
- package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
- package/src/__tests__/interaction-channel-charter.test.ts +13 -0
- package/src/__tests__/key-hint-consistency.test.tsx +249 -0
- package/src/__tests__/key-hint-footer.test.tsx +69 -0
- package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
- package/src/__tests__/message-list-rendering.test.tsx +473 -0
- package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
- package/src/__tests__/output-style-reaches-session.test.ts +35 -0
- package/src/__tests__/palette-consistency.test.ts +330 -0
- package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
- package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
- package/src/__tests__/product-display-name.test.tsx +79 -0
- package/src/__tests__/prompt-queue.test.tsx +260 -0
- package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
- package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
- package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +342 -0
- package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
- package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
- package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
- package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
- package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
- package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +291 -0
- package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +165 -0
- package/src/__tests__/pty/ime-cursor.ptytest.ts +186 -0
- package/src/__tests__/pty/isolated-home.ts +64 -0
- package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
- package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
- package/src/__tests__/pty/pty-driver.ts +153 -0
- package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +134 -0
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +138 -0
- package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
- package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
- package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
- package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
- package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
- package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
- package/src/__tests__/pty/spawn-pty.test.ts +120 -0
- package/src/__tests__/pty/spawn-pty.ts +229 -0
- package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
- package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
- package/src/__tests__/pty-driver-dispose.test.ts +43 -0
- package/src/__tests__/real-cursor-positioning.test.tsx +263 -0
- package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
- package/src/__tests__/render-channel-options.test.ts +201 -0
- package/src/__tests__/render-lifecycle.test.ts +36 -0
- package/src/__tests__/render-markdown.test.ts +164 -0
- package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
- package/src/__tests__/safe-text-boundary.test.tsx +75 -0
- package/src/__tests__/screen-reader-input.test.tsx +106 -0
- package/src/__tests__/screen-reader-labels.test.ts +91 -0
- package/src/__tests__/screen-reader-menus.test.tsx +200 -0
- package/src/__tests__/screen-reader-pacing.test.ts +230 -0
- package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
- package/src/__tests__/screen-reader-render-options.test.ts +262 -0
- package/src/__tests__/screen-reader-stdout.test.ts +322 -0
- package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
- package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
- package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
- package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
- package/src/__tests__/selection-flow.test.ts +61 -0
- package/src/__tests__/session-event-notices.test.tsx +31 -0
- package/src/__tests__/session-init-poller.test.ts +102 -0
- package/src/__tests__/session-switch-channel.test.tsx +470 -0
- package/src/__tests__/shutdown-signal.test.ts +34 -0
- package/src/__tests__/status-activity.test.ts +71 -0
- package/src/__tests__/status-bar.test.tsx +282 -0
- package/src/__tests__/status-glyph.test.ts +30 -0
- package/src/__tests__/streaming-indicator.test.tsx +177 -0
- package/src/__tests__/terminal-capabilities.test.ts +153 -0
- package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
- package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
- package/src/__tests__/text-prompt-flow.test.ts +77 -0
- package/src/__tests__/theme-picker.test.tsx +345 -0
- package/src/__tests__/transport-boundary.test.ts +11 -0
- package/src/__tests__/tui-app-channel-port.test.ts +31 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +62 -0
- package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
- package/src/__tests__/tui-interaction-queues.test.ts +139 -0
- package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
- package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
- package/src/__tests__/tui-state-manager.test.ts +451 -0
- package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
- package/src/__tests__/wave-text.test.tsx +133 -0
- package/src/app-banner.tsx +42 -0
- package/src/app-static-items.ts +35 -0
- package/src/app-view-model.ts +136 -0
- package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
- package/src/attention/__tests__/attention-tracker.test.ts +103 -0
- package/src/attention/__tests__/countdown.test.ts +15 -0
- package/src/attention/__tests__/focus-input-filter.test.ts +92 -0
- package/src/attention/__tests__/interval-recap.test.ts +49 -0
- package/src/attention/attention-coordinator.ts +71 -0
- package/src/attention/attention-tracker.ts +124 -0
- package/src/attention/countdown.ts +24 -0
- package/src/attention/focus-input-filter.ts +131 -0
- package/src/attention/interval-recap.ts +148 -0
- package/src/attention/time-units.ts +6 -0
- package/src/attention-bell.ts +79 -0
- package/src/attributed-user-echo.ts +30 -0
- package/src/background-task-row-format.ts +99 -0
- package/src/command-interaction.ts +8 -0
- package/src/command-output-summary.ts +129 -0
- package/src/create-default-tui-cli-adapter.ts +45 -0
- package/src/execution-workspace-switcher-selection.ts +118 -0
- package/src/execution-workspace-view-model.ts +139 -0
- package/src/external-prompt-notice.tsx +38 -0
- package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
- package/src/flows/__tests__/defer-submit.test.ts +87 -0
- package/src/flows/__tests__/real-cursor-flow.test.ts +124 -0
- package/src/flows/background-focus-flow.ts +44 -0
- package/src/flows/cjk-cursor-motion.ts +75 -0
- package/src/flows/cjk-text-deletion.ts +53 -0
- package/src/flows/cjk-text-input-flow.ts +248 -0
- package/src/flows/cjk-text-input-types.ts +24 -0
- package/src/flows/confirm-prompt-flow.ts +90 -0
- package/src/flows/defer-submit.ts +67 -0
- package/src/flows/fork-attach-flow.ts +62 -0
- package/src/flows/input-area-flow.ts +188 -0
- package/src/flows/permission-prompt-flow.ts +108 -0
- package/src/flows/real-cursor-flow.ts +83 -0
- package/src/flows/selection-flow.ts +205 -0
- package/src/flows/session-init-poller.ts +76 -0
- package/src/flows/text-prompt-flow.ts +93 -0
- package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
- package/src/history-search/history-search-flow.ts +94 -0
- package/src/history-search/useHistorySearch.ts +336 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +74 -0
- package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
- package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
- package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
- package/src/hooks/command-result-handler.ts +44 -0
- package/src/hooks/side-effects-types.ts +45 -0
- package/src/hooks/useAppController.ts +267 -0
- package/src/hooks/useAppInputBindings.ts +145 -0
- package/src/hooks/useAppInteractionState.ts +81 -0
- package/src/hooks/useAppLifecycleState.ts +81 -0
- package/src/hooks/useAppOverlays.ts +74 -0
- package/src/hooks/useAppScreenState.ts +81 -0
- package/src/hooks/useAppSubmissionState.ts +45 -0
- package/src/hooks/useAppThemeState.ts +157 -0
- package/src/hooks/useAppWorkspaceState.ts +149 -0
- package/src/hooks/useAppearanceSettings.ts +30 -0
- package/src/hooks/useAutocomplete.ts +101 -0
- package/src/hooks/useBackgroundPanel.ts +58 -0
- package/src/hooks/useCountdownTick.ts +24 -0
- package/src/hooks/useExecutionDetailPage.ts +69 -0
- package/src/hooks/useForkAttach.ts +60 -0
- package/src/hooks/useInputAreaHistorySearch.ts +72 -0
- package/src/hooks/useInputAreaKeys.ts +114 -0
- package/src/hooks/useNumberedSelection.ts +76 -0
- package/src/hooks/usePluginCallbacks.ts +38 -0
- package/src/hooks/usePluginScreenData.ts +85 -0
- package/src/hooks/useRealCursorPosition.ts +125 -0
- package/src/hooks/useRenderMarkdown.ts +38 -0
- package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
- package/src/hooks/useSideEffects.ts +144 -0
- package/src/hooks/useStatusLineSettings.ts +50 -0
- package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
- package/src/hooks/useTuiChannel.ts +129 -0
- package/src/humanize-tool-name.ts +51 -0
- package/src/index.ts +56 -0
- package/src/input-area-rules.tsx +71 -0
- package/src/json-value.ts +18 -0
- package/src/key-hint-footer.tsx +54 -0
- package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
- package/src/keybindings/__tests__/node-keybindings-source.test.ts +111 -0
- package/src/keybindings/keybinding-catalogue.ts +171 -0
- package/src/keybindings/keybinding-registry.ts +303 -0
- package/src/keybindings/keybinding-syntax.ts +155 -0
- package/src/keybindings/keybindings-context.tsx +124 -0
- package/src/keybindings/node-keybindings-source.ts +189 -0
- package/src/numbered-list.tsx +113 -0
- package/src/plugin-tui-handlers.ts +163 -0
- package/src/product-display-name-context.tsx +30 -0
- package/src/render-markdown.ts +209 -0
- package/src/render.tsx +544 -0
- package/src/sanitize-terminal-text.ts +171 -0
- package/src/screen-reader-announcement.ts +52 -0
- package/src/screen-reader-context.tsx +32 -0
- package/src/screen-reader-labels.ts +64 -0
- package/src/screen-reader-pacing-context.tsx +47 -0
- package/src/screen-reader-pacing.ts +178 -0
- package/src/screen-reader-stdout.ts +328 -0
- package/src/shutdown-signal.ts +34 -0
- package/src/status-activity.ts +70 -0
- package/src/status-glyph.ts +78 -0
- package/src/terminal-capabilities-context.tsx +27 -0
- package/src/terminal-capabilities.ts +77 -0
- package/src/terminal-focus-reporting.ts +50 -0
- package/src/terminal-handoff-controller.ts +98 -0
- package/src/terminal-marks.ts +63 -0
- package/src/theme/__tests__/built-in-themes.test.ts +117 -0
- package/src/theme/__tests__/color-vision.test.ts +82 -0
- package/src/theme/__tests__/theme-context.test.tsx +146 -0
- package/src/theme/__tests__/theme-document.test.ts +222 -0
- package/src/theme/__tests__/theme-registry.test.ts +138 -0
- package/src/theme/__tests__/theme-styles.test.ts +233 -0
- package/src/theme/built-in-themes.ts +342 -0
- package/src/theme/color-vision.ts +171 -0
- package/src/theme/index.ts +45 -0
- package/src/theme/theme-context.tsx +81 -0
- package/src/theme/theme-contracts.ts +139 -0
- package/src/theme/theme-document.ts +278 -0
- package/src/theme/theme-registry.ts +127 -0
- package/src/theme/theme-styles.ts +177 -0
- package/src/tool-summary-status.ts +59 -0
- package/src/tui-app-channel-port.ts +83 -0
- package/src/tui-channel-lifecycle-coordinator.ts +149 -0
- package/src/tui-channel-options.ts +172 -0
- package/src/tui-cli-adapter-context.tsx +13 -0
- package/src/tui-cli-adapter.ts +24 -0
- package/src/tui-interaction-queues.ts +163 -0
- package/src/tui-session-binding.ts +52 -0
- package/src/tui-session-event-projector.ts +140 -0
- package/src/tui-session-events.ts +82 -0
- package/src/tui-session-notice-store.ts +43 -0
- package/src/tui-session-options.ts +118 -0
- package/src/tui-state-manager.ts +327 -0
- package/src/types.ts +19 -0
- package/src/use-terminal-title.ts +29 -0
- package/src/utils/__tests__/edit-diff.test.ts +426 -0
- package/src/utils/__tests__/paste-detection.test.ts +116 -0
- package/src/utils/__tests__/paste-labels.test.ts +46 -0
- package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
- package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
- package/src/utils/edit-diff.ts +151 -0
- package/src/utils/input-top-border.ts +19 -0
- package/src/utils/paste-labels.ts +9 -0
- package/src/utils/tool-call-extractor.ts +92 -0
- package/src/utils/tool-diff-summary.ts +75 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { isSlashCommand, tokeniseSlashCommand } from '@robota-sdk/agent-framework';
|
|
2
|
+
|
|
3
|
+
import type { IHistoryEntry, TUniversalValue } from '@robota-sdk/agent-core';
|
|
4
|
+
import type { ICommand } from '@robota-sdk/agent-interface-command';
|
|
5
|
+
|
|
6
|
+
export interface IAutocompleteInputKey {
|
|
7
|
+
upArrow?: boolean;
|
|
8
|
+
downArrow?: boolean;
|
|
9
|
+
escape?: boolean;
|
|
10
|
+
tab?: boolean;
|
|
11
|
+
backspace?: boolean;
|
|
12
|
+
delete?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TAutocompletePopupAction = 'previous' | 'next' | 'close' | 'complete';
|
|
16
|
+
export type TPendingPromptInputAction = 'cancelQueue';
|
|
17
|
+
export type TPromptHistoryInputAction = 'previous' | 'next';
|
|
18
|
+
|
|
19
|
+
export type TCommandSelectionResult =
|
|
20
|
+
{ type: 'insert'; value: string; selectedIndex?: number } | { type: 'submit'; value: string };
|
|
21
|
+
|
|
22
|
+
export interface IPasteLabelChange {
|
|
23
|
+
value: string;
|
|
24
|
+
cursorHint: number;
|
|
25
|
+
label: string;
|
|
26
|
+
lineCount: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IPromptHistoryNavigationState {
|
|
30
|
+
selectedIndex: number | null;
|
|
31
|
+
draft: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IPromptHistoryNavigationResult {
|
|
35
|
+
value: string;
|
|
36
|
+
cursorHint: number;
|
|
37
|
+
state: IPromptHistoryNavigationState;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getAutocompletePopupAction(
|
|
41
|
+
key: IAutocompleteInputKey,
|
|
42
|
+
): TAutocompletePopupAction | undefined {
|
|
43
|
+
if (key.upArrow === true) return 'previous';
|
|
44
|
+
if (key.downArrow === true) return 'next';
|
|
45
|
+
if (key.escape === true) return 'close';
|
|
46
|
+
if (key.tab === true) return 'complete';
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getPendingPromptInputAction(
|
|
51
|
+
key: IAutocompleteInputKey,
|
|
52
|
+
): TPendingPromptInputAction | undefined {
|
|
53
|
+
if (key.backspace === true || key.delete === true) {
|
|
54
|
+
return 'cancelQueue';
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getPromptHistoryInputAction(
|
|
60
|
+
key: IAutocompleteInputKey,
|
|
61
|
+
): TPromptHistoryInputAction | undefined {
|
|
62
|
+
if (key.upArrow === true) return 'previous';
|
|
63
|
+
if (key.downArrow === true) return 'next';
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createPromptHistoryNavigationState(): IPromptHistoryNavigationState {
|
|
68
|
+
return { selectedIndex: null, draft: '' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function navigatePromptHistory(
|
|
72
|
+
value: string,
|
|
73
|
+
history: readonly string[],
|
|
74
|
+
state: IPromptHistoryNavigationState,
|
|
75
|
+
action: TPromptHistoryInputAction,
|
|
76
|
+
): IPromptHistoryNavigationResult {
|
|
77
|
+
if (history.length === 0) {
|
|
78
|
+
return { value, cursorHint: value.length, state };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (action === 'previous') {
|
|
82
|
+
const selectedIndex =
|
|
83
|
+
state.selectedIndex === null ? history.length - 1 : Math.max(0, state.selectedIndex - 1);
|
|
84
|
+
const nextValue = history[selectedIndex] ?? value;
|
|
85
|
+
return {
|
|
86
|
+
value: nextValue,
|
|
87
|
+
cursorHint: nextValue.length,
|
|
88
|
+
state: { selectedIndex, draft: state.selectedIndex === null ? value : state.draft },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (state.selectedIndex === null) {
|
|
93
|
+
return { value, cursorHint: value.length, state };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (state.selectedIndex < history.length - 1) {
|
|
97
|
+
const selectedIndex = state.selectedIndex + 1;
|
|
98
|
+
const nextValue = history[selectedIndex] ?? value;
|
|
99
|
+
return {
|
|
100
|
+
value: nextValue,
|
|
101
|
+
cursorHint: nextValue.length,
|
|
102
|
+
state: { ...state, selectedIndex },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
value: state.draft,
|
|
108
|
+
cursorHint: state.draft.length,
|
|
109
|
+
state: createPromptHistoryNavigationState(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function appendPromptHistory(history: readonly string[], value: string): string[] {
|
|
114
|
+
const prompt = value.trim();
|
|
115
|
+
if (prompt.length === 0) return [...history];
|
|
116
|
+
if (history[history.length - 1] === prompt) return [...history];
|
|
117
|
+
return [...history, prompt];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function extractPromptHistory(entries: readonly IHistoryEntry[]): string[] {
|
|
121
|
+
let prompts: string[] = [];
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (entry.category !== 'chat' || entry.type !== 'user') continue;
|
|
124
|
+
const data = entry.data as Record<string, TUniversalValue> | undefined;
|
|
125
|
+
if (typeof data?.content !== 'string') continue;
|
|
126
|
+
prompts = appendPromptHistory(prompts, data.content);
|
|
127
|
+
}
|
|
128
|
+
return prompts;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function moveAutocompleteSelection(
|
|
132
|
+
selectedIndex: number,
|
|
133
|
+
commandCount: number,
|
|
134
|
+
direction: 'previous' | 'next',
|
|
135
|
+
): number {
|
|
136
|
+
if (commandCount === 0) return 0;
|
|
137
|
+
if (direction === 'previous') {
|
|
138
|
+
return selectedIndex > 0 ? selectedIndex - 1 : commandCount - 1;
|
|
139
|
+
}
|
|
140
|
+
return selectedIndex < commandCount - 1 ? selectedIndex + 1 : 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resolveTabCompletion(value: string, command: ICommand): TCommandSelectionResult {
|
|
144
|
+
// Subcommand mode: '/parent filter' — space present after command name
|
|
145
|
+
if (isSlashCommand(value) && value.slice(1).includes(' ')) {
|
|
146
|
+
const { name } = tokeniseSlashCommand(value);
|
|
147
|
+
return { type: 'insert', value: `/${name} ${command.name} ` };
|
|
148
|
+
}
|
|
149
|
+
if (command.subcommands && command.subcommands.length > 0) {
|
|
150
|
+
return { type: 'insert', value: `/${command.name} `, selectedIndex: 0 };
|
|
151
|
+
}
|
|
152
|
+
return { type: 'insert', value: `/${command.name} ` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function resolveEnterCommandSelection(
|
|
156
|
+
value: string,
|
|
157
|
+
command: ICommand,
|
|
158
|
+
): TCommandSelectionResult {
|
|
159
|
+
// Subcommand mode: '/parent filter' — space present after command name
|
|
160
|
+
if (isSlashCommand(value) && value.slice(1).includes(' ')) {
|
|
161
|
+
const { name } = tokeniseSlashCommand(value);
|
|
162
|
+
return { type: 'submit', value: `/${name} ${command.name}` };
|
|
163
|
+
}
|
|
164
|
+
if (command.subcommands && command.subcommands.length > 0 && command.runsBare !== true) {
|
|
165
|
+
return { type: 'insert', value: `/${command.name} `, selectedIndex: 0 };
|
|
166
|
+
}
|
|
167
|
+
return { type: 'submit', value: `/${command.name}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function createPasteLabelChange(
|
|
171
|
+
value: string,
|
|
172
|
+
cursorPosition: number,
|
|
173
|
+
pasteId: number,
|
|
174
|
+
text: string,
|
|
175
|
+
): IPasteLabelChange {
|
|
176
|
+
const lineCount = text.split('\n').length;
|
|
177
|
+
const label = `[Pasted text #${pasteId} +${lineCount} lines]`;
|
|
178
|
+
return {
|
|
179
|
+
value: value.slice(0, cursorPosition) + label + value.slice(cursorPosition),
|
|
180
|
+
cursorHint: cursorPosition + label.length,
|
|
181
|
+
label,
|
|
182
|
+
lineCount,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function shouldSubmitInput(text: string): boolean {
|
|
187
|
+
return text.trim().length > 0;
|
|
188
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applySelectionInput,
|
|
3
|
+
getDirectionalSelectionInputAction,
|
|
4
|
+
type ISelectionFlowState,
|
|
5
|
+
type ISelectionInputKey,
|
|
6
|
+
type TSelectionInputAction,
|
|
7
|
+
} from './selection-flow.js';
|
|
8
|
+
|
|
9
|
+
export const PERMISSION_PROMPT_OPTIONS = [
|
|
10
|
+
'Allow [y]',
|
|
11
|
+
'Allow always (this session) [s]',
|
|
12
|
+
'Allow always (this project) [p]',
|
|
13
|
+
'Deny [n]',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Issue #2351: the "always" options name the SCOPE they grant — the consent pattern the enforcer
|
|
18
|
+
* remembers (`Bash(git *)`, `Read(/w/src/**)`, `WebFetch(https://h/**)`), not the whole tool.
|
|
19
|
+
*/
|
|
20
|
+
export function permissionPromptOptionsFor(
|
|
21
|
+
scope: string,
|
|
22
|
+
canPersistProjectPermission = true,
|
|
23
|
+
): readonly string[] {
|
|
24
|
+
return [
|
|
25
|
+
PERMISSION_PROMPT_OPTIONS[0],
|
|
26
|
+
`Allow ${scope} always (this session) [s]`,
|
|
27
|
+
canPersistProjectPermission
|
|
28
|
+
? `Allow ${scope} always (this project) [p]`
|
|
29
|
+
: 'Project-wide approval unavailable',
|
|
30
|
+
PERMISSION_PROMPT_OPTIONS[3],
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type TPermissionPromptDecision = true | 'allow-session' | 'allow-project' | false;
|
|
35
|
+
export type TPermissionPromptInputAction =
|
|
36
|
+
TSelectionInputAction | { type: 'shortcut'; index: number };
|
|
37
|
+
|
|
38
|
+
export type TPermissionPromptEffect =
|
|
39
|
+
{ type: 'none' } | { type: 'resolve'; decision: TPermissionPromptDecision };
|
|
40
|
+
|
|
41
|
+
export function getPermissionPromptInputAction(
|
|
42
|
+
input: string,
|
|
43
|
+
key: ISelectionInputKey,
|
|
44
|
+
): TPermissionPromptInputAction | undefined {
|
|
45
|
+
const action = getDirectionalSelectionInputAction({ ...key, escape: false });
|
|
46
|
+
if (action !== undefined) {
|
|
47
|
+
return action;
|
|
48
|
+
}
|
|
49
|
+
if (input === 'y' || input === '1') {
|
|
50
|
+
return { type: 'shortcut', index: 0 };
|
|
51
|
+
}
|
|
52
|
+
if (input === 's' || input === 'a' || input === '2') {
|
|
53
|
+
return { type: 'shortcut', index: 1 };
|
|
54
|
+
}
|
|
55
|
+
if (input === 'p' || input === '3') {
|
|
56
|
+
return { type: 'shortcut', index: 2 };
|
|
57
|
+
}
|
|
58
|
+
if (input === 'n' || input === 'd' || input === '4') {
|
|
59
|
+
return { type: 'shortcut', index: 3 };
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function applyPermissionPromptInput(
|
|
65
|
+
state: ISelectionFlowState,
|
|
66
|
+
action: TPermissionPromptInputAction,
|
|
67
|
+
canPersistProjectPermission = true,
|
|
68
|
+
): { state: ISelectionFlowState; effect: TPermissionPromptEffect } {
|
|
69
|
+
if (state.resolved) {
|
|
70
|
+
return { state, effect: { type: 'none' } };
|
|
71
|
+
}
|
|
72
|
+
if (typeof action !== 'string') {
|
|
73
|
+
if (action.index === 2 && !canPersistProjectPermission) {
|
|
74
|
+
return { state, effect: { type: 'none' } };
|
|
75
|
+
}
|
|
76
|
+
return resolvePermissionIndex(state, action.index);
|
|
77
|
+
}
|
|
78
|
+
const result = applySelectionInput(state, action, {
|
|
79
|
+
itemCount: PERMISSION_PROMPT_OPTIONS.length,
|
|
80
|
+
});
|
|
81
|
+
if (result.effect.type !== 'select') {
|
|
82
|
+
return { state: result.state, effect: { type: 'none' } };
|
|
83
|
+
}
|
|
84
|
+
if (result.effect.index === 2 && !canPersistProjectPermission) {
|
|
85
|
+
return { state: result.state, effect: { type: 'none' } };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
state: result.state,
|
|
89
|
+
effect: { type: 'resolve', decision: getPermissionDecision(result.effect.index) },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getPermissionDecision(index: number): TPermissionPromptDecision {
|
|
94
|
+
if (index === 0) return true;
|
|
95
|
+
if (index === 1) return 'allow-session';
|
|
96
|
+
if (index === 2) return 'allow-project';
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolvePermissionIndex(
|
|
101
|
+
state: ISelectionFlowState,
|
|
102
|
+
index: number,
|
|
103
|
+
): { state: ISelectionFlowState; effect: TPermissionPromptEffect } {
|
|
104
|
+
return {
|
|
105
|
+
state: { ...state, selectedIndex: index, resolved: true },
|
|
106
|
+
effect: { type: 'resolve', decision: getPermissionDecision(index) },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-062 — pure flow for real-terminal-cursor positioning during CJK IME composition.
|
|
3
|
+
*
|
|
4
|
+
* Maps the input box's absolute frame-space origin plus the live (value, cursor) to the exact
|
|
5
|
+
* screen cell the hardware cursor must occupy, and guards WHEN positioning is allowed at all.
|
|
6
|
+
* Contract: .design/investigations/2026-07-25-cli-062-ime-cursor-design.md.
|
|
7
|
+
*
|
|
8
|
+
* The wrap math reuses `displayOffset` — the same wrap-aware column accounting the input's
|
|
9
|
+
* vertical navigation already trusts — so the computed cell always matches where ink actually
|
|
10
|
+
* rendered the character under the cursor.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { displayOffset } from './cjk-text-input-flow.js';
|
|
14
|
+
|
|
15
|
+
export interface ICursorCellInput {
|
|
16
|
+
/** Absolute frame-space column of the input box's first cell. */
|
|
17
|
+
absX: number;
|
|
18
|
+
/** Absolute frame-space row of the input box's first line. */
|
|
19
|
+
absY: number;
|
|
20
|
+
/** Current input value. */
|
|
21
|
+
value: string;
|
|
22
|
+
/** Cursor as a character index into the value (clamped to the value length). */
|
|
23
|
+
cursor: number;
|
|
24
|
+
/** Wrap width in columns; unset/non-positive means the value renders unwrapped. */
|
|
25
|
+
availableWidth?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ICursorCell {
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface IRealCursorGuardInput {
|
|
34
|
+
/** Whether the input box has been measured in the CURRENT yoga layout. */
|
|
35
|
+
hasMeasured: boolean;
|
|
36
|
+
/** Target cursor row (absolute frame space). */
|
|
37
|
+
y: number;
|
|
38
|
+
/** Height of the live frame (ink-root yoga height). */
|
|
39
|
+
frameHeight: number;
|
|
40
|
+
/** Terminal viewport rows. */
|
|
41
|
+
viewportRows: number;
|
|
42
|
+
/** Terminal capability + focus gate (supportsImeCursorPositioning() && focused && showCursor). */
|
|
43
|
+
capability: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Compute the hardware-cursor cell for the current composition point. */
|
|
47
|
+
export function computeCursorCell(input: ICursorCellInput): ICursorCell {
|
|
48
|
+
const chars = [...input.value];
|
|
49
|
+
const cursor = Math.min(Math.max(input.cursor, 0), chars.length);
|
|
50
|
+
const width =
|
|
51
|
+
input.availableWidth !== undefined && input.availableWidth > 0
|
|
52
|
+
? input.availableWidth
|
|
53
|
+
: undefined;
|
|
54
|
+
const offset = displayOffset(chars, cursor, width ?? Number.MAX_SAFE_INTEGER);
|
|
55
|
+
if (width === undefined) {
|
|
56
|
+
return { x: input.absX + offset, y: input.absY };
|
|
57
|
+
}
|
|
58
|
+
return { x: input.absX + (offset % width), y: input.absY + Math.floor(offset / width) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Crash-avoidance guard — the SIGSEGV invariants from the CLI-062 contract:
|
|
63
|
+
*
|
|
64
|
+
* - I1: never a GUESSED row — position only with a y measured from the current yoga layout
|
|
65
|
+
* (`hasMeasured`); the historical hardcoded `y: 0` pointed the cursor at the logo area, where
|
|
66
|
+
* Terminal.app's Korean IME `attributedSubstringFromRange:` null-derefs (Apple-side bug).
|
|
67
|
+
* - I2: never into an overflowing frame — when `frameHeight >= viewportRows` ink's bottom-anchor
|
|
68
|
+
* assumption breaks (upstream defect, present in ink 7.0.5 AND 7.1.1: the cursor lands one row
|
|
69
|
+
* high in the fullscreen path, and an overflowing frame clamps `cursorUp` at the screen top —
|
|
70
|
+
* the original crash geometry). Same for a y outside `[0, frameHeight)`.
|
|
71
|
+
* - I5 lives in `supportsImeCursorPositioning()` and arrives here folded into `capability`.
|
|
72
|
+
*
|
|
73
|
+
* I3 (all movement rides ink's synchronized frame writes) and I4 (guard fail → no call → today's
|
|
74
|
+
* rendering) are properties of the hook that consumes this guard (useRealCursorPosition).
|
|
75
|
+
*/
|
|
76
|
+
export function shouldPositionRealCursor(input: IRealCursorGuardInput): boolean {
|
|
77
|
+
if (!input.capability) return false;
|
|
78
|
+
if (!input.hasMeasured) return false; // I1
|
|
79
|
+
if (input.viewportRows <= 0 || input.frameHeight <= 0) return false;
|
|
80
|
+
if (input.frameHeight >= input.viewportRows) return false; // I2 (fullscreen / overflow)
|
|
81
|
+
if (input.y < 0 || input.y >= input.frameHeight) return false; // I2 (out of frame)
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
export interface ISelectionFlowState {
|
|
2
|
+
selectedIndex: number;
|
|
3
|
+
scrollOffset: number;
|
|
4
|
+
resolved: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ISelectionInputKey {
|
|
8
|
+
escape?: boolean;
|
|
9
|
+
return?: boolean;
|
|
10
|
+
upArrow?: boolean;
|
|
11
|
+
downArrow?: boolean;
|
|
12
|
+
leftArrow?: boolean;
|
|
13
|
+
rightArrow?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TSelectionInputAction = 'cancel' | 'select' | 'previous' | 'next';
|
|
17
|
+
|
|
18
|
+
export type TSelectionEffect =
|
|
19
|
+
| { type: 'none' }
|
|
20
|
+
| { type: 'cancel' }
|
|
21
|
+
| { type: 'select'; index: number }
|
|
22
|
+
// CLI-2004: a checklist toggles many times and then commits; only the numbered branch emits it.
|
|
23
|
+
| { type: 'confirm' };
|
|
24
|
+
|
|
25
|
+
export interface ISelectionFlowOptions {
|
|
26
|
+
itemCount: number;
|
|
27
|
+
maxVisible?: number;
|
|
28
|
+
wrap?: boolean;
|
|
29
|
+
enabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `initialIndex` seeds the highlight (a request's declared default); the caller clamps it. */
|
|
33
|
+
export function createSelectionFlowState(initialIndex = 0): ISelectionFlowState {
|
|
34
|
+
return { selectedIndex: initialIndex, scrollOffset: 0, resolved: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getVerticalSelectionInputAction(
|
|
38
|
+
key: ISelectionInputKey,
|
|
39
|
+
): TSelectionInputAction | undefined {
|
|
40
|
+
if (key.escape === true) return 'cancel';
|
|
41
|
+
if (key.upArrow === true) return 'previous';
|
|
42
|
+
if (key.downArrow === true) return 'next';
|
|
43
|
+
if (key.return === true) return 'select';
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getDirectionalSelectionInputAction(
|
|
48
|
+
key: ISelectionInputKey,
|
|
49
|
+
): TSelectionInputAction | undefined {
|
|
50
|
+
if (key.escape === true) return 'cancel';
|
|
51
|
+
if (key.leftArrow === true || key.upArrow === true) return 'previous';
|
|
52
|
+
if (key.rightArrow === true || key.downArrow === true) return 'next';
|
|
53
|
+
if (key.return === true) return 'select';
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* CLI-2004 — the typed-number branch of a selection.
|
|
59
|
+
*
|
|
60
|
+
* Screen-reader mode replaces arrow-key navigation with a numbered list and a typed answer, but the
|
|
61
|
+
* SELECTION itself must stay owned by this module: two reducers deciding what "selected" means is
|
|
62
|
+
* how a menu ends up resolving differently depending on how you drove it. `applyNumericSelection`
|
|
63
|
+
* accumulates digits and resolves on Enter; everything downstream is the same `TSelectionEffect`.
|
|
64
|
+
*/
|
|
65
|
+
export interface INumericSelectionState {
|
|
66
|
+
/** Digits typed so far, before Enter. */
|
|
67
|
+
buffer: string;
|
|
68
|
+
/** The last Enter was outside `1..itemCount` (or empty) — the caller re-prints the prompt. */
|
|
69
|
+
invalid: boolean;
|
|
70
|
+
resolved: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface INumericSelectionOptions {
|
|
74
|
+
itemCount: number;
|
|
75
|
+
/** Escape resolves as a cancel only when the menu offers one. */
|
|
76
|
+
cancellable?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* A checklist: a number TOGGLES its row and the menu stays open, so Enter on an empty buffer is
|
|
79
|
+
* the commit rather than an invalid entry. A single-choice menu resolves on the first number.
|
|
80
|
+
*/
|
|
81
|
+
multi?: boolean;
|
|
82
|
+
/** A persistent view: selecting one row does not resolve the input. */
|
|
83
|
+
repeatable?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createNumericSelectionState(): INumericSelectionState {
|
|
87
|
+
return { buffer: '', invalid: false, resolved: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Apply one keystroke to a numbered selection. Digits accumulate; Backspace edits; Enter resolves or
|
|
92
|
+
* marks the entry invalid; Escape cancels when the menu is cancellable. A non-digit character is
|
|
93
|
+
* ignored rather than treated as an answer — the invalid signal is reserved for a completed entry.
|
|
94
|
+
*/
|
|
95
|
+
export function applyNumericSelection(
|
|
96
|
+
state: INumericSelectionState,
|
|
97
|
+
input: string,
|
|
98
|
+
key: ISelectionInputKey & { backspace?: boolean; delete?: boolean },
|
|
99
|
+
options: INumericSelectionOptions,
|
|
100
|
+
): { state: INumericSelectionState; effect: TSelectionEffect } {
|
|
101
|
+
if (state.resolved) return { state, effect: { type: 'none' } };
|
|
102
|
+
if (key.escape === true && options.cancellable === true) {
|
|
103
|
+
return { state: { ...state, resolved: true }, effect: { type: 'cancel' } };
|
|
104
|
+
}
|
|
105
|
+
if (key.backspace === true || key.delete === true) {
|
|
106
|
+
return { state: { ...state, buffer: state.buffer.slice(0, -1) }, effect: { type: 'none' } };
|
|
107
|
+
}
|
|
108
|
+
if (key.return === true) {
|
|
109
|
+
// A checklist commits on an empty Enter; a single-choice menu has nothing to commit, so the
|
|
110
|
+
// same keystroke there is an entry that named no row.
|
|
111
|
+
if (options.multi === true && state.buffer === '') {
|
|
112
|
+
return { state: { ...state, invalid: false, resolved: true }, effect: { type: 'confirm' } };
|
|
113
|
+
}
|
|
114
|
+
const index = Number.parseInt(state.buffer, 10) - 1;
|
|
115
|
+
if (!Number.isInteger(index) || index < 0 || index >= options.itemCount) {
|
|
116
|
+
return { state: { ...state, buffer: '', invalid: true }, effect: { type: 'none' } };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
// A toggle leaves the menu open — only a single-choice selection resolves it.
|
|
120
|
+
state: { ...state, buffer: '', invalid: false, resolved: options.multi !== true && options.repeatable !== true },
|
|
121
|
+
effect: { type: 'select', index },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (/^[0-9]+$/.test(input)) {
|
|
125
|
+
return {
|
|
126
|
+
state: { ...state, buffer: state.buffer + input, invalid: false },
|
|
127
|
+
effect: { type: 'none' },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return { state, effect: { type: 'none' } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function applySelectionInput(
|
|
134
|
+
state: ISelectionFlowState,
|
|
135
|
+
action: TSelectionInputAction,
|
|
136
|
+
options: ISelectionFlowOptions,
|
|
137
|
+
): { state: ISelectionFlowState; effect: TSelectionEffect } {
|
|
138
|
+
if (state.resolved) {
|
|
139
|
+
return { state, effect: { type: 'none' } };
|
|
140
|
+
}
|
|
141
|
+
if (action === 'cancel') {
|
|
142
|
+
return { state: { ...state, resolved: true }, effect: { type: 'cancel' } };
|
|
143
|
+
}
|
|
144
|
+
if (options.enabled === false || options.itemCount === 0) {
|
|
145
|
+
return { state, effect: { type: 'none' } };
|
|
146
|
+
}
|
|
147
|
+
if (action === 'select') {
|
|
148
|
+
const index = clampIndex(state.selectedIndex, options.itemCount);
|
|
149
|
+
return {
|
|
150
|
+
state: { ...state, selectedIndex: index, resolved: true },
|
|
151
|
+
effect: { type: 'select', index },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const selectedIndex = moveSelection(state.selectedIndex, action, options);
|
|
155
|
+
const scrollOffset = resolveScrollOffset(selectedIndex, state.scrollOffset, options);
|
|
156
|
+
return { state: { ...state, selectedIndex, scrollOffset }, effect: { type: 'none' } };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function normalizeSelectionState(
|
|
160
|
+
state: ISelectionFlowState,
|
|
161
|
+
options: ISelectionFlowOptions,
|
|
162
|
+
): ISelectionFlowState {
|
|
163
|
+
if (options.itemCount === 0) {
|
|
164
|
+
return { ...state, selectedIndex: 0, scrollOffset: 0 };
|
|
165
|
+
}
|
|
166
|
+
const selectedIndex = clampIndex(state.selectedIndex, options.itemCount);
|
|
167
|
+
const scrollOffset = resolveScrollOffset(selectedIndex, state.scrollOffset, options);
|
|
168
|
+
if (selectedIndex === state.selectedIndex && scrollOffset === state.scrollOffset) {
|
|
169
|
+
return state;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
...state,
|
|
173
|
+
selectedIndex,
|
|
174
|
+
scrollOffset,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function moveSelection(
|
|
179
|
+
selectedIndex: number,
|
|
180
|
+
action: TSelectionInputAction,
|
|
181
|
+
options: ISelectionFlowOptions,
|
|
182
|
+
): number {
|
|
183
|
+
if (action === 'previous') {
|
|
184
|
+
if (options.wrap === true && selectedIndex === 0) return options.itemCount - 1;
|
|
185
|
+
return Math.max(0, selectedIndex - 1);
|
|
186
|
+
}
|
|
187
|
+
if (options.wrap === true && selectedIndex === options.itemCount - 1) return 0;
|
|
188
|
+
return Math.min(options.itemCount - 1, selectedIndex + 1);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resolveScrollOffset(
|
|
192
|
+
selectedIndex: number,
|
|
193
|
+
scrollOffset: number,
|
|
194
|
+
options: ISelectionFlowOptions,
|
|
195
|
+
): number {
|
|
196
|
+
const maxVisible = options.maxVisible ?? options.itemCount;
|
|
197
|
+
if (maxVisible <= 0) return 0;
|
|
198
|
+
if (selectedIndex < scrollOffset) return selectedIndex;
|
|
199
|
+
if (selectedIndex >= scrollOffset + maxVisible) return selectedIndex - maxVisible + 1;
|
|
200
|
+
return Math.max(0, scrollOffset);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function clampIndex(index: number, itemCount: number): number {
|
|
204
|
+
return Math.min(Math.max(index, 0), itemCount - 1);
|
|
205
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded polling helper for asynchronous session initialization.
|
|
3
|
+
*
|
|
4
|
+
* Distinguishes benign "not yet initialized" errors (retried until a timeout)
|
|
5
|
+
* from real failures (surfaced immediately), so the TUI can show an error
|
|
6
|
+
* instead of spinning forever.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type TSessionInitFailure =
|
|
10
|
+
{ kind: 'timeout'; lastError: Error | undefined } | { kind: 'error'; error: Error };
|
|
11
|
+
|
|
12
|
+
export interface ISessionInitPollerOptions {
|
|
13
|
+
/** Throws while the session is not ready; returns normally when ready. */
|
|
14
|
+
check: () => void;
|
|
15
|
+
intervalMs: number;
|
|
16
|
+
timeoutMs: number;
|
|
17
|
+
/** Returns true when an error means "keep polling". Defaults to /not initialized/i. */
|
|
18
|
+
isBenignError?: (error: Error) => boolean;
|
|
19
|
+
onReady: () => void;
|
|
20
|
+
onFailure: (failure: TSessionInitFailure) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ISessionInitPoller {
|
|
24
|
+
start(): void;
|
|
25
|
+
stop(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function defaultIsBenignError(error: Error): boolean {
|
|
29
|
+
return /not initialized/i.test(error.message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createSessionInitPoller(options: ISessionInitPollerOptions): ISessionInitPoller {
|
|
33
|
+
const isBenign = options.isBenignError ?? defaultIsBenignError;
|
|
34
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
35
|
+
let elapsedMs = 0;
|
|
36
|
+
let lastBenignError: Error | undefined;
|
|
37
|
+
|
|
38
|
+
function stop(): void {
|
|
39
|
+
if (timer !== null) {
|
|
40
|
+
clearInterval(timer);
|
|
41
|
+
timer = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tick(): void {
|
|
46
|
+
elapsedMs += options.intervalMs;
|
|
47
|
+
try {
|
|
48
|
+
options.check();
|
|
49
|
+
} catch (raw) {
|
|
50
|
+
// allow-fallback: poller's purpose is to classify and route this error, never to swallow it
|
|
51
|
+
const error = raw instanceof Error ? raw : new Error(String(raw));
|
|
52
|
+
if (!isBenign(error)) {
|
|
53
|
+
stop();
|
|
54
|
+
options.onFailure({ kind: 'error', error });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
lastBenignError = error;
|
|
58
|
+
if (elapsedMs >= options.timeoutMs) {
|
|
59
|
+
stop();
|
|
60
|
+
options.onFailure({ kind: 'timeout', lastError: lastBenignError });
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
stop();
|
|
65
|
+
options.onReady();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
start(): void {
|
|
70
|
+
if (timer !== null) return;
|
|
71
|
+
elapsedMs = 0;
|
|
72
|
+
timer = setInterval(tick, options.intervalMs);
|
|
73
|
+
},
|
|
74
|
+
stop,
|
|
75
|
+
};
|
|
76
|
+
}
|