@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,155 @@
|
|
|
1
|
+
export interface IKeyInput {
|
|
2
|
+
readonly ctrl?: boolean;
|
|
3
|
+
readonly shift?: boolean;
|
|
4
|
+
readonly meta?: boolean;
|
|
5
|
+
readonly return?: boolean;
|
|
6
|
+
readonly escape?: boolean;
|
|
7
|
+
readonly tab?: boolean;
|
|
8
|
+
readonly upArrow?: boolean;
|
|
9
|
+
readonly downArrow?: boolean;
|
|
10
|
+
readonly leftArrow?: boolean;
|
|
11
|
+
readonly rightArrow?: boolean;
|
|
12
|
+
readonly backspace?: boolean;
|
|
13
|
+
readonly delete?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MODIFIER_ALIASES: Readonly<Record<string, 'ctrl' | 'alt' | 'meta' | 'shift'>> = {
|
|
17
|
+
ctrl: 'ctrl',
|
|
18
|
+
control: 'ctrl',
|
|
19
|
+
alt: 'alt',
|
|
20
|
+
option: 'alt',
|
|
21
|
+
meta: 'meta',
|
|
22
|
+
cmd: 'meta',
|
|
23
|
+
command: 'meta',
|
|
24
|
+
shift: 'shift',
|
|
25
|
+
};
|
|
26
|
+
const MODIFIER_ORDER = ['ctrl', 'alt', 'meta', 'shift'] as const;
|
|
27
|
+
const KEY_ALIASES: Readonly<Record<string, string>> = {
|
|
28
|
+
return: 'enter',
|
|
29
|
+
enter: 'enter',
|
|
30
|
+
esc: 'escape',
|
|
31
|
+
escape: 'escape',
|
|
32
|
+
tab: 'tab',
|
|
33
|
+
space: 'space',
|
|
34
|
+
spacebar: 'space',
|
|
35
|
+
up: 'up',
|
|
36
|
+
down: 'down',
|
|
37
|
+
left: 'left',
|
|
38
|
+
right: 'right',
|
|
39
|
+
backspace: 'backspace',
|
|
40
|
+
delete: 'delete',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function normalizeBinding(raw: string): string | undefined {
|
|
44
|
+
const strokes = raw.trim().split(/\s+/u);
|
|
45
|
+
if (strokes.length < 1 || strokes.length > 2) return undefined;
|
|
46
|
+
const normalized = strokes.map(normalizeStroke);
|
|
47
|
+
return normalized.every((stroke): stroke is string => stroke !== undefined)
|
|
48
|
+
? normalized.join(' ')
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeStroke(raw: string): string | undefined {
|
|
53
|
+
if (!raw || raw.includes(' ')) return undefined;
|
|
54
|
+
const parts = raw.split('+');
|
|
55
|
+
if (parts.some((part) => part.length === 0)) return undefined;
|
|
56
|
+
const rawKey = parts.at(-1);
|
|
57
|
+
if (rawKey === undefined) return undefined;
|
|
58
|
+
const modifiers = new Set<'ctrl' | 'alt' | 'meta' | 'shift'>();
|
|
59
|
+
for (const rawModifier of parts.slice(0, -1)) {
|
|
60
|
+
const modifier = MODIFIER_ALIASES[rawModifier.toLowerCase()];
|
|
61
|
+
if (modifier === undefined || modifiers.has(modifier)) return undefined;
|
|
62
|
+
modifiers.add(modifier);
|
|
63
|
+
}
|
|
64
|
+
let key = KEY_ALIASES[rawKey.toLowerCase()];
|
|
65
|
+
if (key === undefined) {
|
|
66
|
+
if ([...rawKey].length !== 1 || /\s/u.test(rawKey)) return undefined;
|
|
67
|
+
key = rawKey.toLowerCase();
|
|
68
|
+
if (parts.length === 1 && rawKey !== key) modifiers.add('shift');
|
|
69
|
+
}
|
|
70
|
+
return [...MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)), key].join('+');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function keyStrokeFromInput(input: string, key: IKeyInput): string | undefined {
|
|
74
|
+
// Ink preserves LF as a one-character input string instead of setting `key.return`. In terminal
|
|
75
|
+
// raw mode that byte is Ctrl+J; normalize it before printable-text handling can treat it as paste.
|
|
76
|
+
if (/^[\r\n]*\n[\r\n]*$/u.test(input)) return 'ctrl+j';
|
|
77
|
+
const special = key.return
|
|
78
|
+
? 'enter'
|
|
79
|
+
: key.escape
|
|
80
|
+
? 'escape'
|
|
81
|
+
: key.tab
|
|
82
|
+
? 'tab'
|
|
83
|
+
: key.upArrow
|
|
84
|
+
? 'up'
|
|
85
|
+
: key.downArrow
|
|
86
|
+
? 'down'
|
|
87
|
+
: key.leftArrow
|
|
88
|
+
? 'left'
|
|
89
|
+
: key.rightArrow
|
|
90
|
+
? 'right'
|
|
91
|
+
: key.backspace
|
|
92
|
+
? 'backspace'
|
|
93
|
+
: key.delete
|
|
94
|
+
? 'delete'
|
|
95
|
+
: input === ' '
|
|
96
|
+
? 'space'
|
|
97
|
+
: [...input].length === 1
|
|
98
|
+
? input.toLowerCase()
|
|
99
|
+
: undefined;
|
|
100
|
+
if (special === undefined) return undefined;
|
|
101
|
+
const modifiers = [
|
|
102
|
+
...(key.ctrl ? ['ctrl'] : []),
|
|
103
|
+
...(key.meta ? ['meta'] : []),
|
|
104
|
+
...(key.shift ? ['shift'] : []),
|
|
105
|
+
];
|
|
106
|
+
return [...modifiers, special].join('+');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Terminal protocols collapse several control characters onto named keys. Keep the configured
|
|
111
|
+
* spelling for hints, but offer every spelling that the same received byte can represent.
|
|
112
|
+
*/
|
|
113
|
+
export function keyStrokesFromInput(input: string, key: IKeyInput): readonly string[] {
|
|
114
|
+
const primary = keyStrokeFromInput(input, key);
|
|
115
|
+
if (primary === undefined) return [];
|
|
116
|
+
if (primary === 'enter') return ['enter', 'ctrl+m'];
|
|
117
|
+
if (primary === 'tab') return ['tab', 'ctrl+i'];
|
|
118
|
+
if (primary === 'escape') return ['escape', 'ctrl+['];
|
|
119
|
+
return [primary];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function keybindingDeliverySignature(binding: string): string {
|
|
123
|
+
return binding
|
|
124
|
+
.split(' ')
|
|
125
|
+
.map((stroke) => {
|
|
126
|
+
if (stroke === 'ctrl+m') return 'enter';
|
|
127
|
+
if (stroke === 'ctrl+i') return 'tab';
|
|
128
|
+
if (stroke === 'ctrl+[') return 'escape';
|
|
129
|
+
return stroke;
|
|
130
|
+
})
|
|
131
|
+
.join(' ');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function displayBinding(binding: string): string {
|
|
135
|
+
const displayStroke = (stroke: string): string =>
|
|
136
|
+
stroke
|
|
137
|
+
.split('+')
|
|
138
|
+
.map(
|
|
139
|
+
(part) =>
|
|
140
|
+
({
|
|
141
|
+
ctrl: 'Ctrl',
|
|
142
|
+
alt: 'Alt',
|
|
143
|
+
meta: 'Meta',
|
|
144
|
+
shift: 'Shift',
|
|
145
|
+
escape: 'Esc',
|
|
146
|
+
up: '↑',
|
|
147
|
+
down: '↓',
|
|
148
|
+
left: '←',
|
|
149
|
+
right: '→',
|
|
150
|
+
})[part] ??
|
|
151
|
+
(part.length === 1 ? part.toUpperCase() : `${part[0]?.toUpperCase()}${part.slice(1)}`),
|
|
152
|
+
)
|
|
153
|
+
.join('+');
|
|
154
|
+
return binding.split(' ').map(displayStroke).join(' ');
|
|
155
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useInput } from 'ink';
|
|
2
|
+
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createEmptyChordState,
|
|
6
|
+
keybindingHints,
|
|
7
|
+
parseKeybindingsDocument,
|
|
8
|
+
resolveKeybindingInput,
|
|
9
|
+
type IChordState,
|
|
10
|
+
type IKeybindingSnapshot,
|
|
11
|
+
type IKeyInput,
|
|
12
|
+
type TKeybindingAction,
|
|
13
|
+
type TKeybindingContext,
|
|
14
|
+
} from './keybinding-registry.js';
|
|
15
|
+
|
|
16
|
+
import type { IKeybindingsSource } from './node-keybindings-source.js';
|
|
17
|
+
|
|
18
|
+
const parsedDefault = parseKeybindingsDocument('{"version":1,"bindings":{}}', '<defaults>');
|
|
19
|
+
if (!parsedDefault.ok) throw new Error(parsedDefault.diagnostic.message);
|
|
20
|
+
const DEFAULT_SNAPSHOT = parsedDefault.snapshot;
|
|
21
|
+
|
|
22
|
+
const KeybindingsContext = createContext<IKeybindingSnapshot>(DEFAULT_SNAPSHOT);
|
|
23
|
+
|
|
24
|
+
export function KeybindingsProvider({
|
|
25
|
+
source,
|
|
26
|
+
children,
|
|
27
|
+
}: {
|
|
28
|
+
source?: IKeybindingsSource;
|
|
29
|
+
children: React.ReactNode;
|
|
30
|
+
}): React.ReactElement {
|
|
31
|
+
const [snapshot, setSnapshot] = useState(() => source?.getSnapshot() ?? DEFAULT_SNAPSHOT);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (source === undefined) return;
|
|
34
|
+
setSnapshot(source.getSnapshot());
|
|
35
|
+
return source.subscribe(setSnapshot);
|
|
36
|
+
}, [source]);
|
|
37
|
+
return <KeybindingsContext.Provider value={snapshot}>{children}</KeybindingsContext.Provider>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useKeybindings(): IKeybindingSnapshot {
|
|
41
|
+
return useContext(KeybindingsContext);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useKeybindingHints<TContext extends TKeybindingContext>(
|
|
45
|
+
context: TContext,
|
|
46
|
+
actions: readonly (readonly [
|
|
47
|
+
TKeybindingAction<TContext> | readonly TKeybindingAction<TContext>[],
|
|
48
|
+
string,
|
|
49
|
+
])[],
|
|
50
|
+
): { keys: string; label: string }[] {
|
|
51
|
+
return keybindingHints(useKeybindings(), context, actions);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useKeybindingActions<
|
|
55
|
+
TContext extends TKeybindingContext,
|
|
56
|
+
TFallback extends TKeybindingContext = TContext,
|
|
57
|
+
>(
|
|
58
|
+
context: TContext,
|
|
59
|
+
handler: (
|
|
60
|
+
actions: readonly (TKeybindingAction<TContext> | TKeybindingAction<TFallback>)[],
|
|
61
|
+
input: string,
|
|
62
|
+
key: IKeyInput,
|
|
63
|
+
consumed: boolean,
|
|
64
|
+
resolvedContext: TContext | TFallback,
|
|
65
|
+
) => void,
|
|
66
|
+
options: {
|
|
67
|
+
readonly isActive?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* A key `context` does not bind is resolved again here, so a surface layered over an editor (the
|
|
70
|
+
* autocomplete menu over the chat input) leaves every key it does not use to the editor's bindings.
|
|
71
|
+
*/
|
|
72
|
+
readonly fallbackContext?: TFallback;
|
|
73
|
+
} = {},
|
|
74
|
+
): void {
|
|
75
|
+
const snapshot = useKeybindings();
|
|
76
|
+
const stateRef = useRef<IChordState>(createEmptyChordState());
|
|
77
|
+
const fallbackStateRef = useRef<IChordState>(createEmptyChordState());
|
|
78
|
+
const { fallbackContext } = options;
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
stateRef.current = createEmptyChordState();
|
|
81
|
+
fallbackStateRef.current = createEmptyChordState();
|
|
82
|
+
}, [context, fallbackContext, snapshot.generation]);
|
|
83
|
+
useInput(
|
|
84
|
+
(input, key) => {
|
|
85
|
+
const result = resolveKeybindingInput({
|
|
86
|
+
snapshot,
|
|
87
|
+
state: stateRef.current,
|
|
88
|
+
context,
|
|
89
|
+
input,
|
|
90
|
+
key,
|
|
91
|
+
now: Date.now(),
|
|
92
|
+
});
|
|
93
|
+
stateRef.current = result.state;
|
|
94
|
+
if (fallbackContext === undefined || result.consumed) {
|
|
95
|
+
fallbackStateRef.current = createEmptyChordState();
|
|
96
|
+
handler(
|
|
97
|
+
result.actions as readonly TKeybindingAction<TContext>[],
|
|
98
|
+
input,
|
|
99
|
+
key,
|
|
100
|
+
result.consumed,
|
|
101
|
+
context,
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const fallback = resolveKeybindingInput({
|
|
106
|
+
snapshot,
|
|
107
|
+
state: fallbackStateRef.current,
|
|
108
|
+
context: fallbackContext,
|
|
109
|
+
input,
|
|
110
|
+
key,
|
|
111
|
+
now: Date.now(),
|
|
112
|
+
});
|
|
113
|
+
fallbackStateRef.current = fallback.state;
|
|
114
|
+
handler(
|
|
115
|
+
fallback.actions as readonly TKeybindingAction<TFallback>[],
|
|
116
|
+
input,
|
|
117
|
+
key,
|
|
118
|
+
fallback.consumed,
|
|
119
|
+
fallbackContext,
|
|
120
|
+
);
|
|
121
|
+
},
|
|
122
|
+
{ isActive: options.isActive ?? true },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
parseKeybindingsDocument,
|
|
7
|
+
type IKeybindingDiagnostic,
|
|
8
|
+
type IKeybindingSnapshot,
|
|
9
|
+
} from './keybinding-registry.js';
|
|
10
|
+
|
|
11
|
+
const RELOAD_DEBOUNCE_MS = 10;
|
|
12
|
+
|
|
13
|
+
export interface IKeybindingsFilePort {
|
|
14
|
+
ensureFile(): Promise<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IKeybindingsSource extends IKeybindingsFilePort {
|
|
18
|
+
readonly filePath: string;
|
|
19
|
+
start(): Promise<void>;
|
|
20
|
+
isStarted(): boolean;
|
|
21
|
+
getSnapshot(): IKeybindingSnapshot;
|
|
22
|
+
subscribe(listener: (snapshot: IKeybindingSnapshot) => void): () => void;
|
|
23
|
+
dispose(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface INodeKeybindingsSourceOptions {
|
|
27
|
+
readonly filePath: string;
|
|
28
|
+
readonly schemaUrl: string;
|
|
29
|
+
readonly onDiagnostic?: (diagnostic: IKeybindingDiagnostic) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultDocument(schemaUrl: string) {
|
|
33
|
+
return { $schema: schemaUrl, version: 1 as const, bindings: {} };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultSnapshot(filePath: string, schemaUrl: string): IKeybindingSnapshot {
|
|
37
|
+
const parsed = parseKeybindingsDocument(JSON.stringify(defaultDocument(schemaUrl)), filePath);
|
|
38
|
+
if (!parsed.ok) throw new Error(parsed.diagnostic.message);
|
|
39
|
+
return parsed.snapshot;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class NodeKeybindingsSource implements IKeybindingsSource {
|
|
43
|
+
readonly filePath: string;
|
|
44
|
+
readonly #schemaUrl: string;
|
|
45
|
+
readonly #onDiagnostic: ((diagnostic: IKeybindingDiagnostic) => void) | undefined;
|
|
46
|
+
readonly #listeners = new Set<(snapshot: IKeybindingSnapshot) => void>();
|
|
47
|
+
#snapshot: IKeybindingSnapshot;
|
|
48
|
+
#watcher: FSWatcher | undefined;
|
|
49
|
+
#reloadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
50
|
+
#startPromise: Promise<void> | undefined;
|
|
51
|
+
#disposed = false;
|
|
52
|
+
|
|
53
|
+
constructor(options: INodeKeybindingsSourceOptions) {
|
|
54
|
+
if (options.filePath.trim() === '') throw new Error('Keybindings file path must not be empty.');
|
|
55
|
+
if (options.schemaUrl.trim() === '') throw new Error('Keybindings schema URL must not be empty.');
|
|
56
|
+
this.filePath = options.filePath;
|
|
57
|
+
this.#schemaUrl = options.schemaUrl;
|
|
58
|
+
this.#onDiagnostic = options.onDiagnostic;
|
|
59
|
+
this.#snapshot = defaultSnapshot(this.filePath, this.#schemaUrl);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
start(): Promise<void> {
|
|
63
|
+
if (this.#disposed) return Promise.reject(new Error('Keybindings source is disposed.'));
|
|
64
|
+
if (this.#startPromise !== undefined) return this.#startPromise;
|
|
65
|
+
this.#startPromise = this.#start().catch((cause) => {
|
|
66
|
+
this.#watcher?.close();
|
|
67
|
+
this.#watcher = undefined;
|
|
68
|
+
this.#startPromise = undefined;
|
|
69
|
+
throw cause;
|
|
70
|
+
});
|
|
71
|
+
return this.#startPromise;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async #start(): Promise<void> {
|
|
75
|
+
const directory = dirname(this.filePath);
|
|
76
|
+
await mkdir(directory, { recursive: true });
|
|
77
|
+
await this.#load();
|
|
78
|
+
if (this.#disposed) throw new Error('Keybindings source is disposed.');
|
|
79
|
+
this.#watcher = watch(directory, { persistent: false }, (_event, filename) => {
|
|
80
|
+
if (filename !== null && filename.toString() !== basename(this.filePath)) return;
|
|
81
|
+
this.#scheduleReload();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
isStarted(): boolean {
|
|
86
|
+
return this.#watcher !== undefined && !this.#disposed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getSnapshot(): IKeybindingSnapshot {
|
|
90
|
+
return this.#snapshot;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
subscribe(listener: (snapshot: IKeybindingSnapshot) => void): () => void {
|
|
94
|
+
this.#listeners.add(listener);
|
|
95
|
+
return () => this.#listeners.delete(listener);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async ensureFile(): Promise<string> {
|
|
99
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
100
|
+
try {
|
|
101
|
+
await writeFile(this.filePath, `${JSON.stringify(defaultDocument(this.#schemaUrl), null, 2)}\n`, {
|
|
102
|
+
encoding: 'utf8',
|
|
103
|
+
flag: 'wx',
|
|
104
|
+
mode: 0o600,
|
|
105
|
+
});
|
|
106
|
+
} catch (cause) {
|
|
107
|
+
if (!isAlreadyExists(cause as object)) throw cause;
|
|
108
|
+
}
|
|
109
|
+
return this.filePath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
dispose(): void {
|
|
113
|
+
this.#disposed = true;
|
|
114
|
+
if (this.#reloadTimer !== undefined) clearTimeout(this.#reloadTimer);
|
|
115
|
+
this.#reloadTimer = undefined;
|
|
116
|
+
this.#watcher?.close();
|
|
117
|
+
this.#watcher = undefined;
|
|
118
|
+
this.#listeners.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#scheduleReload(): void {
|
|
122
|
+
if (this.#disposed) return;
|
|
123
|
+
if (this.#reloadTimer !== undefined) clearTimeout(this.#reloadTimer);
|
|
124
|
+
this.#reloadTimer = setTimeout(() => {
|
|
125
|
+
this.#reloadTimer = undefined;
|
|
126
|
+
void this.#load();
|
|
127
|
+
}, RELOAD_DEBOUNCE_MS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async #load(): Promise<void> {
|
|
131
|
+
let text: string;
|
|
132
|
+
try {
|
|
133
|
+
text = await readFile(this.filePath, 'utf8');
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
if (isNotFound(cause as object)) return;
|
|
136
|
+
this.#publishInvalid({
|
|
137
|
+
file: this.filePath,
|
|
138
|
+
path: '$',
|
|
139
|
+
message: errorMessage(cause as object),
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const parsed = parseKeybindingsDocument(text, this.filePath);
|
|
144
|
+
if (!parsed.ok) {
|
|
145
|
+
this.#publishInvalid(parsed.diagnostic);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
this.#publish(Object.freeze({ ...parsed.snapshot, generation: this.#snapshot.generation + 1 }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#publishInvalid(diagnostic: IKeybindingDiagnostic): void {
|
|
152
|
+
this.#onDiagnostic?.(diagnostic);
|
|
153
|
+
this.#publish(
|
|
154
|
+
Object.freeze({
|
|
155
|
+
...this.#snapshot,
|
|
156
|
+
generation: this.#snapshot.generation + 1,
|
|
157
|
+
diagnostic,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#publish(snapshot: IKeybindingSnapshot): void {
|
|
163
|
+
if (this.#disposed) return;
|
|
164
|
+
this.#snapshot = snapshot;
|
|
165
|
+
for (const listener of this.#listeners) listener(snapshot);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isNotFound(cause: object): boolean {
|
|
170
|
+
return isNodeError(cause) && cause.code === 'ENOENT';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isAlreadyExists(cause: object): boolean {
|
|
174
|
+
return isNodeError(cause) && cause.code === 'EEXIST';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isNodeError(cause: object): cause is NodeJS.ErrnoException {
|
|
178
|
+
return cause instanceof Error && 'code' in cause;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function errorMessage(cause: object): string {
|
|
182
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createNodeKeybindingsSource(
|
|
186
|
+
options: INodeKeybindingsSourceOptions,
|
|
187
|
+
): IKeybindingsSource {
|
|
188
|
+
return new NodeKeybindingsSource(options);
|
|
189
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2004 — the numbered-list menu (SSOT for its grammar).
|
|
3
|
+
*
|
|
4
|
+
* Every arrow-key menu becomes this in screen-reader mode: `N. <option>` lines followed by one
|
|
5
|
+
* prompt naming the valid range. Arrow navigation and the `> ` cursor are position cues a reader
|
|
6
|
+
* cannot follow — a spoken "3" is unambiguous, and the range in the prompt is the affordance.
|
|
7
|
+
*
|
|
8
|
+
* The prompt LITERAL is authored here, not copied from any reference: the surveyed documentation
|
|
9
|
+
* publishes the prompt's NAME and the cancel suffix, never the composed string. This module is the
|
|
10
|
+
* one place it exists, so the menus cannot drift into per-component dialects — the same reason
|
|
11
|
+
* `key-hint-footer.tsx` owns the footer grammar.
|
|
12
|
+
*
|
|
13
|
+
* No colour props: the mode drops colour rather than substituting values, so the palette floor stays
|
|
14
|
+
* green and nothing here depends on a cue a reader cannot hear.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Box } from 'ink';
|
|
18
|
+
import React from 'react';
|
|
19
|
+
|
|
20
|
+
import { Text } from './SafeText.js';
|
|
21
|
+
|
|
22
|
+
/** Appended to the prompt when Escape actually cancels the menu. */
|
|
23
|
+
export const NUMBERED_LIST_CANCEL_SUFFIX = ' or Escape to cancel';
|
|
24
|
+
|
|
25
|
+
/** The prompt literal. `Enter selection (1-3)`, plus the cancel suffix where it applies. */
|
|
26
|
+
export function formatNumberedSelectionPrompt(itemCount: number, cancellable: boolean): string {
|
|
27
|
+
const base = `Enter selection (1-${itemCount})`;
|
|
28
|
+
return cancellable ? `${base}${NUMBERED_LIST_CANCEL_SUFFIX}` : base;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One row's number prefix: `1. `, `2. `, … */
|
|
32
|
+
export function numberedRowPrefix(index: number): string {
|
|
33
|
+
return `${index + 1}. `;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface INumberedSelectionPromptProps {
|
|
37
|
+
/** How many rows the prompt names. Zero ⇒ nothing is rendered: `(1-0)` names no answer. */
|
|
38
|
+
itemCount: number;
|
|
39
|
+
cancellable?: boolean;
|
|
40
|
+
/** Digits typed so far, echoed after the prompt. */
|
|
41
|
+
buffer?: string;
|
|
42
|
+
/** The last entry was out of range ⇒ the same literal is re-printed. */
|
|
43
|
+
invalid?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The prompt line, separated from `NumberedList` so a menu that renders its own rows — the
|
|
48
|
+
* checklist, whose rows carry a checkbox — asks its question in the same words rather than
|
|
49
|
+
* inventing a dialect.
|
|
50
|
+
*/
|
|
51
|
+
export function NumberedSelectionPrompt({
|
|
52
|
+
itemCount,
|
|
53
|
+
cancellable = false,
|
|
54
|
+
buffer = '',
|
|
55
|
+
invalid = false,
|
|
56
|
+
}: INumberedSelectionPromptProps): React.ReactElement | null {
|
|
57
|
+
if (itemCount === 0) return null;
|
|
58
|
+
const prompt = formatNumberedSelectionPrompt(itemCount, cancellable);
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<Text>
|
|
62
|
+
{prompt}
|
|
63
|
+
{buffer.length > 0 ? ` ${buffer}` : ''}
|
|
64
|
+
</Text>
|
|
65
|
+
{/* An out-of-range answer re-prints the SAME literal: the range is the correction. */}
|
|
66
|
+
{invalid && <Text>{prompt}</Text>}
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface INumberedListProps {
|
|
72
|
+
/** Heading line, e.g. the menu title or the permission question. */
|
|
73
|
+
title?: string;
|
|
74
|
+
/** Extra context line under the title. */
|
|
75
|
+
description?: string;
|
|
76
|
+
/** The selectable options, in order. */
|
|
77
|
+
options: readonly string[];
|
|
78
|
+
/** Escape cancels ⇒ the prompt says so. */
|
|
79
|
+
cancellable?: boolean;
|
|
80
|
+
/** Digits typed so far, echoed after the prompt. */
|
|
81
|
+
buffer?: string;
|
|
82
|
+
/** The last entry was out of range ⇒ the same literal is re-printed. */
|
|
83
|
+
invalid?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Render the numbered list and its prompt. */
|
|
87
|
+
export function NumberedList({
|
|
88
|
+
title,
|
|
89
|
+
description,
|
|
90
|
+
options,
|
|
91
|
+
cancellable = false,
|
|
92
|
+
buffer = '',
|
|
93
|
+
invalid = false,
|
|
94
|
+
}: INumberedListProps): React.ReactElement {
|
|
95
|
+
return (
|
|
96
|
+
<Box flexDirection="column">
|
|
97
|
+
{title !== undefined && title.length > 0 && <Text>{title}</Text>}
|
|
98
|
+
{description !== undefined && description.length > 0 && <Text>{description}</Text>}
|
|
99
|
+
{options.map((option, index) => (
|
|
100
|
+
<Text key={`${index}-${option}`}>
|
|
101
|
+
{numberedRowPrefix(index)}
|
|
102
|
+
{option}
|
|
103
|
+
</Text>
|
|
104
|
+
))}
|
|
105
|
+
<NumberedSelectionPrompt
|
|
106
|
+
itemCount={options.length}
|
|
107
|
+
cancellable={cancellable}
|
|
108
|
+
buffer={buffer}
|
|
109
|
+
invalid={invalid}
|
|
110
|
+
/>
|
|
111
|
+
</Box>
|
|
112
|
+
);
|
|
113
|
+
}
|