From 808b62cc77ee23093f2f672679ada1c34ec1a978 Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Tue, 5 May 2026 17:36:19 +0200 Subject: [PATCH] feat: add context-aware ask keymaps --- README.md | 87 +++-- docs/architecture.md | 6 +- docs/configuration.md | 204 ++++++---- docs/contract.md | 40 +- src/config/defaults.ts | 86 +++-- src/config/migrate.ts | 19 +- src/config/migrations/index.ts | 12 +- src/config/schema.ts | 123 +++++- src/config/store.ts | 4 +- src/constants/keymaps.ts | 660 +++++++++++++++++++++------------ src/ui/controller.ts | 16 + src/ui/input.ts | 78 +++- src/ui/settings-list.ts | 30 +- tests/config-migrate.test.ts | 80 ++++ tests/config-store.test.ts | 2 +- tests/input.test.ts | 102 ++++- tests/render-helpers.test.ts | 7 +- tests/settings-list.test.ts | 46 ++- 18 files changed, 1158 insertions(+), 444 deletions(-) diff --git a/README.md b/README.md index 6d3ec11..103dd31 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ Once installed, this package gives the agent a native way to ask for clarificati - ๐Ÿ“ Question-level and option-level notes - ๐Ÿ‘€ Review tab with `Submit`, `Elaborate`, and `Cancel` - ๐Ÿ’ฌ Elaboration flow to capture note-based clarification before final submission -- โŒจ๏ธ Fast keyboard-first interaction with customizable ask keymaps (also mobile-friendly in remote sessions) -- โš™๏ธ Ask settings with persisted behaviour, notification toggle, keymap customization, and `/answer` extraction config +- โŒจ๏ธ Context-aware customizable keymaps with aliases for main flow, editors, and settings +- โš™๏ธ Ask settings with persisted behaviour, notifications, keymaps, and `/answer` extraction config +- ๐Ÿ”” Optional external notifications when an ask flow is waiting for input - ๐Ÿ” Slash commands for fallback/replay: - `/answer` extracts questions from the latest assistant message into an ask flow - `/answer:again` reopens the latest `/answer` form on the current branch @@ -92,41 +93,33 @@ Capture free-form input inline without leaving the flow. Open ask settings with `?` during the ask flow, or with the `/ask-settings` command from pi. -Customizable via config: +Keymaps are context-aware and configurable in `~/.pi/agent/extensions/eko24ive-pi-ask.json`. +Each action accepts a key string or an array of aliases. -| Action | Default key | -|---|---| -| Cancel flow or close/save editor draft | `Esc` | -| Dismiss entire ask flow immediately | `Ctrl+C` | -| Toggle selected option | `Space` | -| Confirm / continue / save / submit | `Enter` | -| Edit selected option note | `n` | -| Edit question note | `Shift+N` | +Default contexts: + +- `global`: `dismiss` (`Ctrl+C`) and `settings` (`?`) +- `main`: confirm/cancel/toggle, tab navigation, option navigation, and note shortcuts +- `editor`: custom answer submit/close and empty-editor navigation +- `noteEditor`: note save/close and empty-editor navigation +- `settingsModal`: close, next/previous setting, and toggle Fixed bindings: -| Key | Context | Effect | -|-----------------------------|-----------------------------------------|---------------------------------------------| -| `?` | Ask flow / empty editor | Open ask settings | -| `Tab` `Shift+Tab` | Main flow | Switch tabs | -| `โ†` `โ†’` | Main flow | Switch tabs | -| `โ†‘` `โ†“` | Main flow | Move cursor | -| `1..9` | Options list | Select or toggle matching option | -| `1` `2` `3` | Review tab | Trigger `Submit` / `Elaborate` / `Cancel` | -| `โ†‘` `โ†“` | Review tab | Change highlighted review action | -| `โ†‘` `โ†“` | Empty editor | Move options without closing editor | -| `Tab` `Shift+Tab` / `โ†` `โ†’` | Empty editor | Switch tabs without closing editor | -| Arrow keys / `Tab` | Non-empty editor | Stay in editor for cursor movement | +| Key | Context | Effect | +|---|---|---| +| `1..9` | Options list | Select or toggle matching option | +| `1` `2` `3` | Review tab | Trigger `Submit` / `Elaborate` / `Cancel` | +| `@` | Editors | File-reference affordance | +| Arrow keys / `Tab` | Non-empty editor | Stay in editor for cursor movement | Review-tab shortcuts can optionally require the same number key twice via `behaviour.doublePressReviewShortcuts`. -Config file: `~/.pi/agent/extensions/eko24ive-pi-ask.json` - -You can edit this file yourself, ask pi to edit it for you, or use `/ask-settings` to find the exact config path and toggle behaviour/notification settings. +You can edit the config file yourself, ask pi to edit it for you, or use `/ask-settings` to find the exact config path and toggle behaviour/notification settings. ```json { - "schemaVersion": 3, + "schemaVersion": 4, "answer": { "extractionModels": [ { "provider": "openai-codex", "id": "gpt-5.4-mini" }, @@ -143,12 +136,40 @@ You can edit this file yourself, ask pi to edit it for you, or use `/ask-setting "showFooterHints": true }, "keymaps": { - "cancel": "esc", - "dismiss": "ctrl+c", - "toggle": "space", - "confirm": "enter", - "optionNote": "n", - "questionNote": "shift+n" + "global": { "dismiss": ["ctrl+c"], "settings": ["?"] }, + "main": { + "confirm": ["enter"], + "cancel": ["esc"], + "toggle": ["space"], + "nextTab": ["tab", "right"], + "previousTab": ["shift+tab", "left"], + "nextOption": ["down"], + "previousOption": ["up"], + "optionNote": ["n"], + "questionNote": ["shift+n"] + }, + "editor": { + "submit": ["enter"], + "close": ["esc"], + "nextTabWhenEmpty": ["tab", "right"], + "previousTabWhenEmpty": ["shift+tab", "left"], + "nextOptionWhenEmpty": ["down"], + "previousOptionWhenEmpty": ["up"] + }, + "noteEditor": { + "save": ["enter"], + "close": ["esc"], + "nextTabWhenEmpty": ["tab", "right"], + "previousTabWhenEmpty": ["shift+tab", "left"], + "nextOptionWhenEmpty": ["down"], + "previousOptionWhenEmpty": ["up"] + }, + "settingsModal": { + "close": ["esc", "ctrl+c", "?"], + "nextOption": ["down"], + "previousOption": ["up"], + "toggle": ["enter", "space"] + } }, "notifications": { "enabled": true, diff --git a/docs/architecture.md b/docs/architecture.md index baab2a2..2f7a6fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,7 +36,7 @@ The codebase is split so the implementation reads through file boundaries and na - `src/config/defaults.ts` โ€” canonical runtime defaults - `src/config/schema.ts` โ€” persisted config schema and runtime type -- `src/config/migrate.ts` โ€” persisted-file validation boundary, including keymap normalization/fallback +- `src/config/migrate.ts` โ€” persisted-file validation boundary, including schema migration and context-aware keymap normalization/fallback - `src/config/migrations/` โ€” ordered schema-version migration framework for persisted config shape changes - `src/config/path-migrations.ts` โ€” file-location migration before config load - `src/config/store.ts` โ€” load/save/backup/runtime subscription store @@ -44,7 +44,7 @@ The codebase is split so the implementation reads through file boundaries and na ### UI - `src/ui/controller.ts` โ€” connects key input, editor lifecycle, live config subscription, dirty-dismiss confirmation, and pure state transitions -- `src/ui/input.ts` โ€” raw input to commands using resolved config-backed keymaps +- `src/ui/input.ts` โ€” raw input to commands using resolved context-aware config-backed keymaps - `src/ui/dismiss-guard.ts` โ€” pure helpers for dirty-flow exit confirmation behavior - `src/ui/render.ts` and `src/ui/render-*.ts` โ€” screen rendering, including config-backed footer/keymap hints - `src/ui/settings-list.ts` / `src/ui/show-settings.ts` โ€” lightweight ask settings list rendering and launcher @@ -76,7 +76,7 @@ The codebase is split so the implementation reads through file boundaries and na - persisted ask settings are migrated to the current schema version, validated, and normalized before use - config schema migrations preserve user-provided values and add new defaults only when fields are absent - replay payload lookup scans only the current session branch and revalidates payloads before use -- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour settings +- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour, notification, or answer settings - invalid notification channels are skipped and fall back to the default bell channel if none are valid - ask settings behaviour and notification enabled changes persist immediately from the settings list - when the ask config file is missing, the first ask use writes a default persisted config snapshot under `~/.pi/agent/extensions/` diff --git a/docs/configuration.md b/docs/configuration.md index 3f4700a..98f0399 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,7 +28,7 @@ Unsupported future versions or invalid files are backed up and defaults are load ```json { - "schemaVersion": 3, + "schemaVersion": 4, "answer": { "extractionModels": [ { "provider": "openai-codex", "id": "gpt-5.4-mini" }, @@ -45,12 +45,40 @@ Unsupported future versions or invalid files are backed up and defaults are load "showFooterHints": true }, "keymaps": { - "cancel": "esc", - "dismiss": "ctrl+c", - "toggle": "space", - "confirm": "enter", - "optionNote": "n", - "questionNote": "shift+n" + "global": { "dismiss": ["ctrl+c"], "settings": ["?"] }, + "main": { + "confirm": ["enter"], + "cancel": ["esc"], + "toggle": ["space"], + "nextTab": ["tab", "right"], + "previousTab": ["shift+tab", "left"], + "nextOption": ["down"], + "previousOption": ["up"], + "optionNote": ["n"], + "questionNote": ["shift+n"] + }, + "editor": { + "submit": ["enter"], + "close": ["esc"], + "nextTabWhenEmpty": ["tab", "right"], + "previousTabWhenEmpty": ["shift+tab", "left"], + "nextOptionWhenEmpty": ["down"], + "previousOptionWhenEmpty": ["up"] + }, + "noteEditor": { + "save": ["enter"], + "close": ["esc"], + "nextTabWhenEmpty": ["tab", "right"], + "previousTabWhenEmpty": ["shift+tab", "left"], + "nextOptionWhenEmpty": ["down"], + "previousOptionWhenEmpty": ["up"] + }, + "settingsModal": { + "close": ["esc", "ctrl+c", "?"], + "nextOption": ["down"], + "previousOption": ["up"], + "toggle": ["enter", "space"] + } }, "notifications": { "enabled": true, @@ -159,33 +187,69 @@ cmux example: ## Keymaps -`keymaps` must contain all 6 configurable actions if present. - -### Configurable actions - -- `cancel` -- `dismiss` -- `toggle` -- `confirm` -- `optionNote` -- `questionNote` +`keymaps` is context-aware. Each action accepts either one key id string or an array of key id strings. +Arrays are aliases: any listed key triggers the same action. ### Defaults -- `cancel: "esc"` -- `dismiss: "ctrl+c"` -- `toggle: "space"` -- `confirm: "enter"` -- `optionNote: "n"` -- `questionNote: "shift+n"` +```json +"keymaps": { + "global": { + "dismiss": ["ctrl+c"], + "settings": ["?"] + }, + "main": { + "confirm": ["enter"], + "cancel": ["esc"], + "toggle": ["space"], + "nextTab": ["tab", "right"], + "previousTab": ["shift+tab", "left"], + "nextOption": ["down"], + "previousOption": ["up"], + "optionNote": ["n"], + "questionNote": ["shift+n"] + }, + "editor": { + "submit": ["enter"], + "close": ["esc"], + "nextTabWhenEmpty": ["tab", "right"], + "previousTabWhenEmpty": ["shift+tab", "left"], + "nextOptionWhenEmpty": ["down"], + "previousOptionWhenEmpty": ["up"] + }, + "noteEditor": { + "save": ["enter"], + "close": ["esc"], + "nextTabWhenEmpty": ["tab", "right"], + "previousTabWhenEmpty": ["shift+tab", "left"], + "nextOptionWhenEmpty": ["down"], + "previousOptionWhenEmpty": ["up"] + }, + "settingsModal": { + "close": ["esc", "ctrl+c", "?"], + "nextOption": ["down"], + "previousOption": ["up"], + "toggle": ["enter", "space"] + } +} +``` + +### Contexts + +- `global`: active in the main ask flow and editors; duplicates with those contexts are invalid +- `main`: question and review flow +- `editor`: custom answer editor +- `noteEditor`: question/option note editor +- `settingsModal`: `/ask-settings` and `?` settings overlay ### Allowed bindings -Each configurable action accepts any single `pi-tui` key id string, as long as it is: +Each action accepts any `pi-tui` key id string, as long as it is: - supported by `pi-tui` -- not reserved -- not duplicated across configurable actions +- not a fixed numeric shortcut (`1` through `9`) +- not duplicated within the same context +- not duplicated between `global` and `main`, `editor`, or `noteEditor` Examples of valid bindings: @@ -196,7 +260,7 @@ Examples of valid bindings: - `n` - `shift+n` - `alt+f7` -- `ctrl+[` +- `ctrl+[` - `ctrl+shift+p` - `super+k` @@ -213,43 +277,19 @@ Examples: - `pageup` -> `pageUp` - `pagedown` -> `pageDown` -### Reserved bindings - -These bindings are fixed and cannot be used by configurable actions: - -- `?` -- `tab` -- `shift+tab` -- `left` -- `right` -- `up` -- `down` -- `1` -- `2` -- `3` -- `4` -- `5` -- `6` -- `7` -- `8` -- `9` - ### Fixed bindings These are intentionally not configurable: -- `?` opens ask settings -- `tab`, `shift+tab`, `left`, `right` move between tabs -- `up`, `down` move between options/actions - `1..9` triggers option/review shortcuts - when `behaviour.doublePressReviewShortcuts` is enabled, review-tab shortcuts `1`, `2`, and `3` require the same key twice -- `@` remains file-reference affordance in editors +- `@` remains the file-reference affordance in editors ## Invalid keymaps behavior If configured keymaps are invalid: -- valid `behaviour` settings still load +- valid `behaviour`, `notifications`, and `answer` settings still load - invalid `keymaps` fall back to default keymaps for the current session - ask remains usable - a warning notice is shown @@ -257,16 +297,17 @@ If configured keymaps are invalid: Invalid keymaps include: -- missing one of the 6 required actions +- missing one of the required contexts or actions - unsupported key syntax -- duplicate bindings across configurable actions -- use of reserved bindings +- duplicate bindings within one context +- duplicate bindings between `global` and `main`, `editor`, or `noteEditor` +- use of fixed numeric shortcuts (`1` through `9`) ## Example custom config ```json { - "schemaVersion": 3, + "schemaVersion": 4, "answer": { "extractionRetries": 1, "extractionTimeoutMs": 30000, @@ -281,12 +322,43 @@ Invalid keymaps include: "showFooterHints": false }, "keymaps": { - "cancel": "q", - "dismiss": "ctrl+c", - "toggle": "ctrl+t", - "confirm": "ctrl+k", - "optionNote": "x", - "questionNote": "shift+x" + "global": { + "dismiss": ["ctrl+c"], + "settings": ["?"] + }, + "main": { + "confirm": ["ctrl+k"], + "cancel": ["q"], + "toggle": ["ctrl+t"], + "nextTab": ["tab", "right"], + "previousTab": ["shift+tab", "left"], + "nextOption": ["down"], + "previousOption": ["up"], + "optionNote": ["x"], + "questionNote": ["shift+x"] + }, + "editor": { + "submit": ["ctrl+k"], + "close": ["q"], + "nextTabWhenEmpty": ["tab", "right"], + "previousTabWhenEmpty": ["shift+tab", "left"], + "nextOptionWhenEmpty": ["down"], + "previousOptionWhenEmpty": ["up"] + }, + "noteEditor": { + "save": ["ctrl+k"], + "close": ["q"], + "nextTabWhenEmpty": ["tab", "right"], + "previousTabWhenEmpty": ["shift+tab", "left"], + "nextOptionWhenEmpty": ["down"], + "previousOptionWhenEmpty": ["up"] + }, + "settingsModal": { + "close": ["esc", "ctrl+c", "?"], + "nextOption": ["j", "down"], + "previousOption": ["k", "up"], + "toggle": ["enter", "space"] + } }, "notifications": { "enabled": true, @@ -305,11 +377,11 @@ Invalid keymaps include: When editing this config for a user: - preserve unrelated fields -- keep `schemaVersion` at `3` +- keep `schemaVersion` at `4` - preserve `answer.extractionModels` as explicit provider/id pairs - keep `answer.extractionRetries` between `0` and `3` -- do not assign reserved bindings to configurable actions -- do not create duplicate configurable bindings +- do not assign fixed numeric shortcuts (`1` through `9`) to configurable actions +- do not create duplicate bindings within a context or between `global` and `main`, `editor`, or `noteEditor` - preserve existing `notifications.channels` unless the user asks to change notification targets - only toggle `notifications.enabled` unless the user asks to configure channels - use a `cmux notify` command channel when the user asks for cmux notifications diff --git a/docs/contract.md b/docs/contract.md index 543bc8d..ecbb98d 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -209,7 +209,7 @@ This document defines the stable external behavior. It does not explain internal - ask settings list with binary behaviour and notification toggles - `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay - settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, and `Show footer hints` -- `Keymaps` is a persisted config section for `cancel`, `dismiss`, `toggle`, `confirm`, `optionNote`, and `questionNote` +- `Keymaps` is a persisted, context-aware config section for global, main-flow, editor, note-editor, and settings-modal actions - the settings list shows the absolute config file path for customizing keymaps, notifications, and extraction settings - if the flow is already on the review tab, all questions are answered, and no notes exist, enabling auto-submit can complete the current ask flow immediately - elaborate results are phrased as direct follow-up instructions, for example: `User asked to elaborate on question "Which option would you like to select?" option "Option A" with note "why this one?"` @@ -218,26 +218,36 @@ This document defines the stable external behavior. It does not explain internal Main flow: -- `?`: open ask settings -- inside ask settings, `Esc`, `Ctrl+C`, and `?` close it -- `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs -- `Up`, `Down`: move between options -- `1..9`: select or toggle the matching option; on the review tab, `1`, `2`, and `3` trigger `Submit`, `Elaborate`, and `Cancel` +- `global.settings` opens ask settings; default: `?` +- `global.dismiss` dismisses the active ask surface; default: `Ctrl+C` +- `main.nextTab` / `main.previousTab` move between tabs; defaults: `Tab`/`Right`, `Shift+Tab`/`Left` +- `main.nextOption` / `main.previousOption` move between options or review actions; defaults: `Down`, `Up` +- `main.confirm`, `main.cancel`, and `main.toggle` confirm, cancel, or toggle; defaults: `Enter`, `Esc`, `Space` +- `main.optionNote` and `main.questionNote` open option/question notes; defaults: `n`, `Shift+N` +- `1..9` is fixed and selects or toggles the matching option; on the review tab, `1`, `2`, and `3` trigger `Submit`, `Elaborate`, and `Cancel` - when `Double-press review shortcuts` is enabled, review-tab `1`, `2`, and `3` require the same key twice without a timeout, and the review screen shows an inline hint for the pending action -- the following actions are configurable via persisted `keymaps`: `Enter` by default confirms or submits, `Esc` cancels, `Ctrl+C` dismisses, `Space` toggles, `n` edits the active option note, and `Shift+N` edits the current question note Editing flow: -- the configurable `confirm` binding submits the current editor input and closes the editor; in note editors this saves the note only and keeps the ask flow open -- the configurable `cancel` binding saves draft and closes the editor +- `editor.submit` submits the current custom-answer editor input and closes the editor; default: `Enter` +- `noteEditor.save` saves the current note editor and keeps the ask flow open; default: `Enter` +- `editor.close` / `noteEditor.close` save draft and close the editor; default: `Esc` +- `global.dismiss` dismisses the entire flow immediately without saving the current editor draft when no dirty-dismiss confirmation is pending +- `global.settings` opens ask settings when the editor is empty; otherwise the key is delegated to the editor as text/input +- when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing +- when editor is empty, editor-context `*WhenEmpty` navigation actions move options or tabs without requiring the editor close binding first +- `@` remains a fixed file-reference affordance in editors + +Settings modal: + +- `settingsModal.close` closes settings; defaults: `Esc`, `Ctrl+C`, `?` +- `settingsModal.nextOption` / `settingsModal.previousOption` move between settings; defaults: `Down`, `Up` +- `settingsModal.toggle` toggles the highlighted setting and saves immediately; defaults: `Enter`, `Space` + +Dirty dismiss: + - when `Confirm dismiss when dirty` is enabled, cancelling or dismissing a dirty ask flow requires the same action a second time - the dirty-dismiss warning stays visible until the user changes tabs in the ask flow -- the configurable `dismiss` binding dismisses the entire flow immediately without saving the current editor draft when no dirty-dismiss confirmation is pending -- `?`: open ask settings when the editor is empty; otherwise enter `?` as text -- inside ask settings, behaviour changes save immediately without explicit save feedback -- when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing -- when editor is empty, `Up`/`Down` move options and `Tab`/`Shift+Tab`/`Left`/`Right` move between tabs without requiring the configurable cancel key first -- navigation resumes only after closing the editor with the configurable `cancel` binding, unless the editor is empty and the navigation keys above are used ## Non-interactive mode diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 6efd16d..4600383 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,7 +1,11 @@ -import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts"; +import { + DEFAULT_ASK_KEYMAPS, + normalizeConfiguredKeymaps, +} from "../constants/keymaps.ts"; import type { AskConfig, - AskConfigFileV3, + AskConfigFileV4, + AskConfigKeymaps, AskNotificationChannel, } from "./schema.ts"; @@ -25,9 +29,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = { doublePressReviewShortcuts: true, showFooterHints: true, }, - keymaps: { - ...DEFAULT_ASK_KEYMAPS, - }, + keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS), notifications: { channels: ["bell"], enabled: true, @@ -35,7 +37,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = { }; export function normalizeAskConfig( - config?: Partial | AskConfig + config?: Partial | AskConfig ): AskConfig { return { answer: { @@ -67,10 +69,7 @@ export function normalizeAskConfig( config?.behaviour?.showFooterHints ?? DEFAULT_ASK_CONFIG.behaviour.showFooterHints, }, - keymaps: { - ...DEFAULT_ASK_CONFIG.keymaps, - ...(config?.keymaps ?? {}), - }, + keymaps: mergeKeymaps(config?.keymaps), notifications: { channels: normalizeNotificationChannels(config?.notifications?.channels), enabled: @@ -80,10 +79,10 @@ export function normalizeAskConfig( }; } -export function toAskConfigFileV3(config: AskConfig): AskConfigFileV3 { +export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 { const normalized = normalizeAskConfig(config); return { - schemaVersion: 3, + schemaVersion: 4, answer: { extractionModels: normalized.answer.extractionModels, extractionRetries: normalized.answer.extractionRetries, @@ -97,14 +96,7 @@ export function toAskConfigFileV3(config: AskConfig): AskConfigFileV3 { normalized.behaviour.doublePressReviewShortcuts, showFooterHints: normalized.behaviour.showFooterHints, }, - keymaps: { - cancel: normalized.keymaps.cancel, - dismiss: normalized.keymaps.dismiss, - toggle: normalized.keymaps.toggle, - confirm: normalized.keymaps.confirm, - optionNote: normalized.keymaps.optionNote, - questionNote: normalized.keymaps.questionNote, - }, + keymaps: cloneKeymaps(normalized.keymaps), notifications: { channels: normalized.notifications.channels, enabled: normalized.notifications.enabled, @@ -112,6 +104,53 @@ export function toAskConfigFileV3(config: AskConfig): AskConfigFileV3 { }; } +function mergeKeymaps(keymaps: unknown): AskConfigKeymaps { + const normalized = normalizeConfiguredKeymaps(keymaps); + return normalized.ok ? normalized.keymaps : cloneKeymaps(DEFAULT_ASK_KEYMAPS); +} + +function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps { + return { + global: { + dismiss: [...keymaps.global.dismiss], + settings: [...keymaps.global.settings], + }, + main: { + cancel: [...keymaps.main.cancel], + confirm: [...keymaps.main.confirm], + nextOption: [...keymaps.main.nextOption], + nextTab: [...keymaps.main.nextTab], + optionNote: [...keymaps.main.optionNote], + previousOption: [...keymaps.main.previousOption], + previousTab: [...keymaps.main.previousTab], + questionNote: [...keymaps.main.questionNote], + toggle: [...keymaps.main.toggle], + }, + editor: { + close: [...keymaps.editor.close], + nextOptionWhenEmpty: [...keymaps.editor.nextOptionWhenEmpty], + nextTabWhenEmpty: [...keymaps.editor.nextTabWhenEmpty], + previousOptionWhenEmpty: [...keymaps.editor.previousOptionWhenEmpty], + previousTabWhenEmpty: [...keymaps.editor.previousTabWhenEmpty], + submit: [...keymaps.editor.submit], + }, + noteEditor: { + close: [...keymaps.noteEditor.close], + nextOptionWhenEmpty: [...keymaps.noteEditor.nextOptionWhenEmpty], + nextTabWhenEmpty: [...keymaps.noteEditor.nextTabWhenEmpty], + previousOptionWhenEmpty: [...keymaps.noteEditor.previousOptionWhenEmpty], + previousTabWhenEmpty: [...keymaps.noteEditor.previousTabWhenEmpty], + save: [...keymaps.noteEditor.save], + }, + settingsModal: { + close: [...keymaps.settingsModal.close], + nextOption: [...keymaps.settingsModal.nextOption], + previousOption: [...keymaps.settingsModal.previousOption], + toggle: [...keymaps.settingsModal.toggle], + }, + }; +} + function normalizeNotificationChannels( channels: unknown ): AskNotificationChannel[] { @@ -139,10 +178,9 @@ function isValidNotificationChannel( ); } -function isValidModelPreference(value: unknown): value is { - id: string; - provider: string; -} { +function isValidModelPreference( + value: unknown +): value is { id: string; provider: string } { return ( !!value && typeof value === "object" && diff --git a/src/config/migrate.ts b/src/config/migrate.ts index 6203a27..2b41784 100644 --- a/src/config/migrate.ts +++ b/src/config/migrate.ts @@ -1,15 +1,12 @@ -import { - type AskConfigurableKeyAction, - normalizeConfiguredKeymaps, -} from "../constants/keymaps.ts"; +import { normalizeConfiguredKeymaps } from "../constants/keymaps.ts"; import { normalizeAskConfig } from "./defaults.ts"; import { AskConfigVersionMigrationError, type AskConfigVersionMigrationResult, migrateAskConfigFileToCurrent, } from "./migrations/index.ts"; -import type { AskConfig, AskConfigFileV3 } from "./schema.ts"; -import { validateAskConfigFileV3 } from "./schema.ts"; +import type { AskConfig, AskConfigFileV4 } from "./schema.ts"; +import { validateAskConfigFileV4 } from "./schema.ts"; export class AskConfigMigrationError extends Error { readonly reason: "invalid_or_unsupported" | "migration_failed"; @@ -42,20 +39,16 @@ export function migrateAskConfig(raw: unknown): AskConfigMigrationResult { ); } - if (!validateAskConfigFileV3.Check(migratedFile.config)) { + if (!validateAskConfigFileV4.Check(migratedFile.config)) { throw new AskConfigMigrationError( "Config was invalid or unsupported.", "invalid_or_unsupported" ); } - const currentFile = migratedFile.config as AskConfigFileV3; + const currentFile = migratedFile.config as AskConfigFileV4; const config = normalizeAskConfig(currentFile); - const keymapsResult = normalizeConfiguredKeymaps( - currentFile.keymaps as - | Partial> - | undefined - ); + const keymapsResult = normalizeConfiguredKeymaps(currentFile.keymaps); if (!keymapsResult.ok) { return { config: { diff --git a/src/config/migrations/index.ts b/src/config/migrations/index.ts index 30a0835..de7bac7 100644 --- a/src/config/migrations/index.ts +++ b/src/config/migrations/index.ts @@ -1,6 +1,7 @@ +import { normalizeLegacyFlatKeymaps } from "../../constants/keymaps.ts"; import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts"; -export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 3; +export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 4; const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [ { @@ -23,6 +24,15 @@ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [ schemaVersion: 3, }), }, + { + from: 3, + to: 4, + migrate: (config) => ({ + ...config, + keymaps: normalizeLegacyFlatKeymaps(config.keymaps) ?? config.keymaps, + schemaVersion: 4, + }), + }, ]; export class AskConfigVersionMigrationError extends Error { diff --git a/src/config/schema.ts b/src/config/schema.ts index 0296228..1a6e0cd 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -16,6 +16,79 @@ const AskNotificationChannelSchema = Type.Union([ }), ]); +const AskKeyBindingSchema = Type.Union([ + Type.String(), + Type.Array(Type.String()), +]); + +const AskConfigKeymapsSchema = Type.Object({ + global: Type.Object({ + dismiss: Type.Optional(AskKeyBindingSchema), + settings: Type.Optional(AskKeyBindingSchema), + }), + main: Type.Object({ + cancel: Type.Optional(AskKeyBindingSchema), + confirm: Type.Optional(AskKeyBindingSchema), + nextOption: Type.Optional(AskKeyBindingSchema), + nextTab: Type.Optional(AskKeyBindingSchema), + optionNote: Type.Optional(AskKeyBindingSchema), + previousOption: Type.Optional(AskKeyBindingSchema), + previousTab: Type.Optional(AskKeyBindingSchema), + questionNote: Type.Optional(AskKeyBindingSchema), + toggle: Type.Optional(AskKeyBindingSchema), + }), + editor: Type.Object({ + close: Type.Optional(AskKeyBindingSchema), + nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema), + nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema), + previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema), + previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema), + submit: Type.Optional(AskKeyBindingSchema), + }), + noteEditor: Type.Object({ + close: Type.Optional(AskKeyBindingSchema), + nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema), + nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema), + previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema), + previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema), + save: Type.Optional(AskKeyBindingSchema), + }), + settingsModal: Type.Object({ + close: Type.Optional(AskKeyBindingSchema), + nextOption: Type.Optional(AskKeyBindingSchema), + previousOption: Type.Optional(AskKeyBindingSchema), + toggle: Type.Optional(AskKeyBindingSchema), + }), +}); + +export const AskConfigFileV4Schema = Type.Object({ + schemaVersion: Type.Literal(4), + answer: Type.Optional( + Type.Object({ + extractionModels: Type.Optional( + Type.Array(AskAnswerModelPreferenceSchema) + ), + extractionRetries: Type.Optional(Type.Number()), + extractionTimeoutMs: Type.Optional(Type.Number()), + }) + ), + behaviour: Type.Optional( + Type.Object({ + autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()), + confirmDismissWhenDirty: Type.Optional(Type.Boolean()), + doublePressReviewShortcuts: Type.Optional(Type.Boolean()), + showFooterHints: Type.Optional(Type.Boolean()), + }) + ), + keymaps: Type.Optional(AskConfigKeymapsSchema), + notifications: Type.Optional( + Type.Object({ + channels: Type.Optional(Type.Array(AskNotificationChannelSchema)), + enabled: Type.Optional(Type.Boolean()), + }) + ), +}); + export const AskConfigFileV3Schema = Type.Object({ schemaVersion: Type.Literal(3), answer: Type.Optional( @@ -58,6 +131,7 @@ export const AskConfigFileV2Schema = Type.Omit(AskConfigFileV3Schema, [ "schemaVersion", ]); +export type AskConfigFileV4 = Static; export type AskConfigFileV3 = Static; export type AskConfigFileV2 = Static & { schemaVersion: 2; @@ -65,9 +139,7 @@ export type AskConfigFileV2 = Static & { export type AskConfigFileV1 = Omit< AskConfigFileV2, "answer" | "schemaVersion" -> & { - schemaVersion: 1; -}; +> & { schemaVersion: 1 }; export interface AskAnswerModelPreference { id: string; @@ -81,12 +153,43 @@ export type AskNotificationChannel = | { command: string; type: "command" }; export interface AskConfigKeymaps { - cancel: string; - confirm: string; - dismiss: string; - optionNote: string; - questionNote: string; - toggle: string; + editor: { + close: string[]; + nextOptionWhenEmpty: string[]; + nextTabWhenEmpty: string[]; + previousOptionWhenEmpty: string[]; + previousTabWhenEmpty: string[]; + submit: string[]; + }; + global: { + dismiss: string[]; + settings: string[]; + }; + main: { + cancel: string[]; + confirm: string[]; + nextOption: string[]; + nextTab: string[]; + optionNote: string[]; + previousOption: string[]; + previousTab: string[]; + questionNote: string[]; + toggle: string[]; + }; + noteEditor: { + close: string[]; + nextOptionWhenEmpty: string[]; + nextTabWhenEmpty: string[]; + previousOptionWhenEmpty: string[]; + previousTabWhenEmpty: string[]; + save: string[]; + }; + settingsModal: { + close: string[]; + nextOption: string[]; + previousOption: string[]; + toggle: string[]; + }; } export interface AskConfig { @@ -108,4 +211,4 @@ export interface AskConfig { }; } -export const validateAskConfigFileV3 = Compile(AskConfigFileV3Schema); +export const validateAskConfigFileV4 = Compile(AskConfigFileV4Schema); diff --git a/src/config/store.ts b/src/config/store.ts index 25f6ee0..7a0ce47 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent"; import { DEFAULT_ASK_CONFIG, normalizeAskConfig, - toAskConfigFileV3, + toAskConfigFileV4, } from "./defaults.ts"; import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts"; import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts"; @@ -66,7 +66,7 @@ export class AskConfigStore { async save(config: AskConfig | Partial): Promise { const normalized = normalizeAskConfig(config); const content = JSON.stringify( - toAskConfigFileV3(normalized), + toAskConfigFileV4(normalized), null, 2 ).concat("\n"); diff --git a/src/constants/keymaps.ts b/src/constants/keymaps.ts index 14db4b7..6f3a247 100644 --- a/src/constants/keymaps.ts +++ b/src/constants/keymaps.ts @@ -1,4 +1,4 @@ -import { Key, matchesKey } from "@mariozechner/pi-tui"; +import { matchesKey } from "@mariozechner/pi-tui"; import type { AskConfig, AskConfigKeymaps } from "../config/schema.ts"; const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/; @@ -37,14 +37,7 @@ const KNOWN_SPECIAL_KEYS = new Set([ "f11", "f12", ] as const); -const RESERVED_CONFIGURABLE_BINDINGS = new Set([ - "?", - "tab", - "shift+tab", - "left", - "right", - "up", - "down", +const RESERVED_BINDINGS = new Set([ "1", "2", "3", @@ -55,39 +48,27 @@ const RESERVED_CONFIGURABLE_BINDINGS = new Set([ "8", "9", ]); -const ACTION_LABEL_OVERRIDES: Partial> = { - nextTab: "Tab / โ†’", - previousTab: "Shift+Tab / โ†", - previousOption: "โ†‘ / โ†“", - nextOption: "โ†“", - numberShortcut: "1..9", + +const CONTEXT_LABELS: Record = { + global: "Global", + main: "Main flow", + editor: "Answer editor", + noteEditor: "Note editor", + settingsModal: "Settings modal", }; type KeyId = Parameters[1]; type InputKey = KeyId | (string & {}); - -type ConfigurableBindingMetadata = Omit & { - id: AskConfigurableKeyAction; -}; -type FixedBindingMetadata = Omit & { - id: AskFixedKeyBindingId; - keys: readonly InputKey[]; - label?: string; -}; - type ModifierName = (typeof MODIFIER_ORDER)[number]; +export type AskKeymapContext = keyof AskConfigKeymaps; +export type AskKeymapAction = + keyof AskConfigKeymaps[C] & string; export type AskKeyBindingKind = "command" | "affordance"; -export type AskConfigurableKeyAction = keyof AskConfigKeymaps; -export type AskFixedKeyBindingId = - | "settings" - | "nextTab" - | "previousTab" - | "previousOption" - | "nextOption" +export type AskKeyBindingId = + | `${AskKeymapContext}.${string}` | "numberShortcut" | "fileReference"; -export type AskKeyBindingId = AskConfigurableKeyAction | AskFixedKeyBindingId; export interface AskKeyBinding { contexts: readonly string[]; @@ -106,105 +87,84 @@ export type FooterKeymapContext = | "note"; export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = { - cancel: "esc", - dismiss: "ctrl+c", - toggle: "space", - confirm: "enter", - optionNote: "n", - questionNote: "shift+n", + global: { + dismiss: ["ctrl+c"], + settings: ["?"], + }, + main: { + confirm: ["enter"], + cancel: ["esc"], + toggle: ["space"], + nextTab: ["tab", "right"], + previousTab: ["shift+tab", "left"], + nextOption: ["down"], + previousOption: ["up"], + optionNote: ["n"], + questionNote: ["shift+n"], + }, + editor: { + submit: ["enter"], + close: ["esc"], + nextTabWhenEmpty: ["tab", "right"], + previousTabWhenEmpty: ["shift+tab", "left"], + nextOptionWhenEmpty: ["down"], + previousOptionWhenEmpty: ["up"], + }, + noteEditor: { + save: ["enter"], + close: ["esc"], + nextTabWhenEmpty: ["tab", "right"], + previousTabWhenEmpty: ["shift+tab", "left"], + nextOptionWhenEmpty: ["down"], + previousOptionWhenEmpty: ["up"], + }, + settingsModal: { + close: ["esc", "ctrl+c", "?"], + nextOption: ["down"], + previousOption: ["up"], + toggle: ["enter", "space"], + }, }; -const CONFIGURABLE_BINDING_METADATA: readonly ConfigurableBindingMetadata[] = [ - { - id: "cancel", - kind: "command", - description: "Cancel flow or close/save editor draft", - contexts: ["Main flow", "Editors"], +const DESCRIPTIONS: Record> = { + global: { + dismiss: "Dismiss the active ask surface", + settings: "Open ask settings", }, - { - id: "dismiss", - kind: "command", - description: "Dismiss entire ask flow immediately", - contexts: ["Anywhere"], + main: { + confirm: "Confirm selection, continue, or submit", + cancel: "Cancel flow", + toggle: "Toggle selected option", + nextTab: "Switch to next tab", + previousTab: "Switch to previous tab", + nextOption: "Move to next option/action", + previousOption: "Move to previous option/action", + optionNote: "Edit selected option note", + questionNote: "Edit question note", }, - { - id: "toggle", - kind: "command", - description: "Toggle selected option", - contexts: ["Questions"], + editor: { + submit: "Submit custom answer", + close: "Close editor and preserve draft", + nextTabWhenEmpty: "Switch to next tab when editor is empty", + previousTabWhenEmpty: "Switch to previous tab when editor is empty", + nextOptionWhenEmpty: "Move to next option when editor is empty", + previousOptionWhenEmpty: "Move to previous option when editor is empty", }, - { - id: "confirm", - kind: "command", - description: "Confirm selection, continue, save, or submit", - contexts: ["Main flow", "Editors"], + noteEditor: { + save: "Save note", + close: "Close note editor", + nextTabWhenEmpty: "Switch to next tab when editor is empty", + previousTabWhenEmpty: "Switch to previous tab when editor is empty", + nextOptionWhenEmpty: "Move to next option when editor is empty", + previousOptionWhenEmpty: "Move to previous option when editor is empty", }, - { - id: "optionNote", - kind: "command", - description: "Edit selected option note", - contexts: ["Question tabs"], + settingsModal: { + close: "Close settings", + nextOption: "Move to next setting", + previousOption: "Move to previous setting", + toggle: "Toggle selected setting", }, - { - id: "questionNote", - kind: "command", - description: "Edit question note", - contexts: ["Question tabs"], - }, -] as const; - -const FIXED_BINDING_METADATA: readonly FixedBindingMetadata[] = [ - { - id: "settings", - keys: ["?"], - kind: "command", - description: "Open ask settings", - contexts: ["Main flow", "Empty editor"], - }, - { - id: "nextTab", - keys: [Key.tab, Key.right], - kind: "command", - description: "Switch to next tab", - contexts: ["Main flow", "Empty editor"], - }, - { - id: "previousTab", - keys: [Key.shift("tab"), Key.left], - kind: "command", - description: "Switch to previous tab", - contexts: ["Main flow", "Empty editor"], - }, - { - id: "previousOption", - keys: [Key.up], - kind: "command", - description: "Move between options/actions", - contexts: ["Main flow", "Empty editor"], - }, - { - id: "nextOption", - keys: [Key.down], - kind: "command", - description: "Move to next option/action", - contexts: ["Main flow", "Empty editor"], - }, - { - id: "numberShortcut", - keys: ["1..9"], - kind: "command", - label: "1..9", - description: "Quick-select option or submit action", - contexts: ["Main flow", "Submit tab"], - }, - { - id: "fileReference", - keys: ["@"], - kind: "affordance", - description: "Reference files while typing answers or notes", - contexts: ["Editors"], - }, -] as const; +}; const footerHint = ( binding: AskKeyBinding, @@ -213,6 +173,18 @@ const footerHint = ( ) => `${label} ${action}`; export function formatKeybindingLabel(key: string): string { + if (key === "up") { + return "โ†‘"; + } + if (key === "down") { + return "โ†“"; + } + if (key === "left") { + return "โ†"; + } + if (key === "right") { + return "โ†’"; + } return key .split("+") .map((part) => { @@ -227,6 +199,10 @@ export function formatKeybindingLabel(key: string): string { .join("+"); } +export function formatBindingLabel(keys: readonly string[]): string { + return keys.map(formatKeybindingLabel).join(" / "); +} + export function matchesBinding(data: string, binding: AskKeyBinding): boolean { return binding.keys.some((key) => { if (key.length === 1) { @@ -242,33 +218,71 @@ export function matchesDigitShortcut(data: string): number | null { export function getAskKeyBindings( config: AskConfig -): Record { - const customizable: Record = { - cancel: createCustomizableBinding(config, "cancel"), - dismiss: createCustomizableBinding(config, "dismiss"), - toggle: createCustomizableBinding(config, "toggle"), - confirm: createCustomizableBinding(config, "confirm"), - optionNote: createCustomizableBinding(config, "optionNote"), - questionNote: createCustomizableBinding(config, "questionNote"), - }; - const fixed = Object.fromEntries( - FIXED_BINDING_METADATA.map((binding) => [ - binding.id, - { - ...binding, - label: - binding.label ?? - ACTION_LABEL_OVERRIDES[binding.id] ?? - binding.keys - .map((key) => formatKeybindingLabel(String(key))) - .join(" / "), - }, +): Record { + const entries: [string, AskKeyBinding][] = []; + for (const context of Object.keys(config.keymaps) as AskKeymapContext[]) { + const actions = config.keymaps[context] as Record< + string, + readonly string[] + >; + for (const [action, keys] of Object.entries(actions)) { + const id = `${context}.${action}` as AskKeyBindingId; + entries.push([ + id, + { + contexts: [CONTEXT_LABELS[context]], + description: DESCRIPTIONS[context][action] ?? action, + id, + keys, + kind: "command", + label: formatBindingLabel(keys), + }, + ]); + } + } + entries.push([ + "numberShortcut", + { + contexts: ["Main flow", "Submit tab"], + description: "Quick-select option or submit action", + id: "numberShortcut", + keys: ["1..9"], + kind: "command", + label: "1..9", + }, + ]); + entries.push([ + "fileReference", + { + contexts: ["Editors"], + description: "Reference files while typing answers or notes", + id: "fileReference", + keys: ["@"], + kind: "affordance", + label: "@", + }, + ]); + return Object.fromEntries(entries); +} + +export function getAskContextBindings( + config: AskConfig, + context: C +): Record, AskKeyBinding> { + const bindings = getAskKeyBindings(config); + const actions = config.keymaps[context] as Record; + return Object.fromEntries( + Object.keys(actions).map((action) => [ + action, + bindings[`${context}.${action}`], ]) - ) as Record; - return { - ...customizable, - ...fixed, - }; + ) as Record, AskKeyBinding>; +} + +export function getGlobalBindings( + config: AskConfig +): Record, AskKeyBinding> { + return getAskContextBindings(config, "global"); } export function getAskKeymaps(config: AskConfig): { @@ -277,8 +291,10 @@ export function getAskKeymaps(config: AskConfig): { } { const bindings = getAskKeyBindings(config); return { - customizable: CONFIGURABLE_BINDING_METADATA.map(({ id }) => bindings[id]), - fixed: FIXED_BINDING_METADATA.map(({ id }) => bindings[id]), + customizable: Object.keys(bindings) + .filter((id) => id !== "numberShortcut" && id !== "fileReference") + .map((id) => bindings[id]), + fixed: [bindings.numberShortcut, bindings.fileReference], }; } @@ -286,99 +302,260 @@ export function renderFooterKeymaps( config: AskConfig, context: FooterKeymapContext ): string { + const global = getGlobalBindings(config); + const main = getAskContextBindings(config, "main"); + const editor = getAskContextBindings(config, "editor"); + const noteEditor = getAskContextBindings(config, "noteEditor"); const bindings = getAskKeyBindings(config); - const optionNavigationLabel = bindings.previousOption.label.replaceAll( - " / ", - "" - ); + const optionNavigationLabel = + `${main.previousOption.label}${main.nextOption.label}`.replaceAll( + " / ", + "" + ); const tabNavigationLabel = "โ‡†"; - const noteNavigationLabel = `${bindings.optionNote.label}/${bindings.questionNote.label}`; + const noteNavigationLabel = `${main.optionNote.label}/${main.questionNote.label}`; const hintsByContext: Record = { input: [ - footerHint(bindings.confirm, "submit"), - footerHint(bindings.cancel, "close"), - footerHint(bindings.settings, "settings"), + footerHint(editor.submit, "submit"), + footerHint(editor.close, "close"), + footerHint(global.settings, "settings"), ], note: [ - footerHint(bindings.confirm, "save"), - footerHint(bindings.cancel, "close"), - footerHint(bindings.settings, "settings"), + footerHint(noteEditor.save, "save"), + footerHint(noteEditor.close, "close"), + footerHint(global.settings, "settings"), ], submit: [ footerHint(bindings.numberShortcut, "hotkeys"), - footerHint(bindings.previousOption, "select", optionNavigationLabel), - footerHint(bindings.confirm, "confirm"), - footerHint(bindings.cancel, "cancel"), - footerHint(bindings.settings, "settings"), + footerHint(main.previousOption, "select", optionNavigationLabel), + footerHint(main.confirm, "confirm"), + footerHint(main.cancel, "cancel"), + footerHint(global.settings, "settings"), ], multi: [ - footerHint(bindings.nextTab, "tab", tabNavigationLabel), - footerHint(bindings.previousOption, "select", optionNavigationLabel), - footerHint(bindings.toggle, "toggle"), - footerHint(bindings.confirm, "continue"), - footerHint(bindings.optionNote, "note", noteNavigationLabel), - footerHint(bindings.cancel, "dismiss"), - footerHint(bindings.settings, "settings"), + footerHint(main.nextTab, "tab", tabNavigationLabel), + footerHint(main.previousOption, "select", optionNavigationLabel), + footerHint(main.toggle, "toggle"), + footerHint(main.confirm, "continue"), + footerHint(main.optionNote, "note", noteNavigationLabel), + footerHint(main.cancel, "dismiss"), + footerHint(global.settings, "settings"), ], default: [ - footerHint(bindings.nextTab, "tab", tabNavigationLabel), - footerHint(bindings.previousOption, "select", optionNavigationLabel), - footerHint(bindings.confirm, "confirm"), - footerHint(bindings.optionNote, "note", noteNavigationLabel), - footerHint(bindings.cancel, "dismiss"), - footerHint(bindings.settings, "settings"), + footerHint(main.nextTab, "tab", tabNavigationLabel), + footerHint(main.previousOption, "select", optionNavigationLabel), + footerHint(main.confirm, "confirm"), + footerHint(main.optionNote, "note", noteNavigationLabel), + footerHint(main.cancel, "dismiss"), + footerHint(global.settings, "settings"), ], }; return ` ${hintsByContext[context].join(" ยท ")}`; } +export function renderSettingsFooterKeymaps(config: AskConfig): string { + const bindings = getAskContextBindings(config, "settingsModal"); + return `${bindings.toggle.label} to change ยท ${bindings.close.label} to close`; +} + export function normalizeConfiguredKeymaps( - keymaps: Partial> | undefined + keymaps: unknown ): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } { - if (!keymaps) { - return { ok: true, keymaps: { ...DEFAULT_ASK_KEYMAPS } }; + const candidate = normalizeLegacyFlatKeymaps(keymaps) ?? keymaps; + if (!candidate) { + return { ok: true, keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS) }; + } + if (!(candidate && typeof candidate === "object")) { + return { ok: false, error: "Keymaps must be an object." }; } const normalized = {} as AskConfigKeymaps; - for (const action of Object.keys( + for (const context of Object.keys( DEFAULT_ASK_KEYMAPS - ) as AskConfigurableKeyAction[]) { - const rawValue = keymaps[action]; - if (typeof rawValue !== "string") { - return { ok: false, error: `Missing keymap for ${action}.` }; + ) as AskKeymapContext[]) { + const rawContext = (candidate as Record)[context]; + if (!(rawContext && typeof rawContext === "object")) { + return { ok: false, error: `Missing keymap context ${context}.` }; } - const parsed = normalizeKeyId(rawValue); - if (!parsed.ok) { - return { - ok: false, - error: `Invalid keymap for ${action}: ${parsed.error}`, - }; + const contextResult = normalizeContextKeymaps( + context, + rawContext as Record + ); + if (!contextResult.ok) { + return contextResult; } - if (RESERVED_CONFIGURABLE_BINDINGS.has(parsed.keyId)) { - return { - ok: false, - error: `Reserved keymap for ${action}: ${formatKeybindingLabel(parsed.keyId)}.`, - }; - } - normalized[action] = parsed.keyId; + normalized[context] = contextResult.keymaps as never; } - const seen = new Map(); - for (const action of Object.keys(normalized) as AskConfigurableKeyAction[]) { - const key = normalized[action]; - const existing = seen.get(key); - if (existing) { - return { - ok: false, - error: `Duplicate keymap ${formatKeybindingLabel(key)} for ${existing} and ${action}.`, - }; - } - seen.set(key, action); + const globalDuplicates = validateCrossContextConflicts(normalized); + if (globalDuplicates) { + return { ok: false, error: globalDuplicates }; } return { ok: true, keymaps: normalized }; } +function normalizeContextKeymaps( + context: AskKeymapContext, + rawContext: Record +): + | { ok: true; keymaps: Record } + | { ok: false; error: string } { + const defaults = DEFAULT_ASK_KEYMAPS[context] as Record< + string, + readonly string[] + >; + const normalized: Record = {}; + for (const action of Object.keys(defaults)) { + const rawValue = rawContext[action]; + if (rawValue === undefined) { + return { ok: false, error: `Missing keymap for ${context}.${action}.` }; + } + const parsed = normalizeBindingList(rawValue); + if (!parsed.ok) { + return { + ok: false, + error: `Invalid keymap for ${context}.${action}: ${parsed.error}`, + }; + } + normalized[action] = parsed.keys; + } + + const duplicateError = validateContextBindings(context, normalized); + return duplicateError + ? { ok: false, error: duplicateError } + : { ok: true, keymaps: normalized }; +} + +function normalizeBindingList( + rawValue: unknown +): { ok: true; keys: string[] } | { ok: false; error: string } { + const rawKeys = typeof rawValue === "string" ? [rawValue] : rawValue; + if (!Array.isArray(rawKeys)) { + return { ok: false, error: "binding must be a string or array of strings" }; + } + if (rawKeys.length === 0) { + return { ok: false, error: "empty binding list" }; + } + const keys: string[] = []; + for (const rawKey of rawKeys) { + if (typeof rawKey !== "string") { + return { ok: false, error: "binding list must contain only strings" }; + } + const parsed = normalizeKeyId(rawKey); + if (!parsed.ok) { + return parsed; + } + if (!keys.includes(parsed.keyId)) { + keys.push(parsed.keyId); + } + } + return { ok: true, keys }; +} + +function validateContextBindings( + context: AskKeymapContext, + bindings: Record +): string | undefined { + const seen = new Map(); + for (const { action, key } of flattenBindings(bindings)) { + if (RESERVED_BINDINGS.has(key)) { + return `Reserved keymap for ${context}.${action}: ${formatKeybindingLabel(key)}.`; + } + const existing = seen.get(key); + if (existing) { + return `Duplicate keymap ${formatKeybindingLabel(key)} for ${context}.${existing} and ${context}.${action}.`; + } + seen.set(key, action); + } + return; +} + +function validateCrossContextConflicts( + keymaps: AskConfigKeymaps +): string | undefined { + const globalSeen = new Map( + flattenBindings(keymaps.global).map(({ action, key }) => [ + key, + `global.${action}`, + ]) + ); + for (const { context, action, key } of flattenNonGlobalBindings(keymaps)) { + const existing = globalSeen.get(key); + if (existing) { + return `Duplicate global keymap ${formatKeybindingLabel(key)} for ${existing} and ${context}.${action}.`; + } + } + return; +} + +function flattenNonGlobalBindings(keymaps: AskConfigKeymaps): Array<{ + action: string; + context: AskKeymapContext; + key: string; +}> { + return (Object.keys(keymaps) as AskKeymapContext[]) + .filter((context) => context !== "global" && context !== "settingsModal") + .flatMap((context) => + flattenBindings( + keymaps[context] as Record + ).map((binding) => ({ ...binding, context })) + ); +} + +function flattenBindings( + bindings: Record +): Array<{ action: string; key: string }> { + return Object.entries(bindings).flatMap(([action, keys]) => + keys.map((key) => ({ action, key })) + ); +} + +export function normalizeLegacyFlatKeymaps( + keymaps: unknown +): AskConfigKeymaps | undefined { + if (!(keymaps && typeof keymaps === "object")) { + return; + } + if ("main" in keymaps || "global" in keymaps) { + return; + } + const raw = keymaps as Record; + const defaults = cloneKeymaps(DEFAULT_ASK_KEYMAPS); + return { + ...defaults, + global: { + ...defaults.global, + dismiss: coerceLegacyBinding(raw.dismiss, defaults.global.dismiss), + }, + main: { + ...defaults.main, + confirm: coerceLegacyBinding(raw.confirm, defaults.main.confirm), + cancel: coerceLegacyBinding(raw.cancel, defaults.main.cancel), + toggle: coerceLegacyBinding(raw.toggle, defaults.main.toggle), + optionNote: coerceLegacyBinding(raw.optionNote, defaults.main.optionNote), + questionNote: coerceLegacyBinding( + raw.questionNote, + defaults.main.questionNote + ), + }, + editor: { + ...defaults.editor, + submit: coerceLegacyBinding(raw.confirm, defaults.editor.submit), + close: coerceLegacyBinding(raw.cancel, defaults.editor.close), + }, + noteEditor: { + ...defaults.noteEditor, + save: coerceLegacyBinding(raw.confirm, defaults.noteEditor.save), + close: coerceLegacyBinding(raw.cancel, defaults.noteEditor.close), + }, + }; +} + +function coerceLegacyBinding(value: unknown, fallback: string[]): string[] { + return typeof value === "string" ? [value] : fallback; +} + function normalizeKeyId( rawKey: string ): { ok: true; keyId: string } | { ok: false; error: string } { @@ -418,27 +595,48 @@ function normalizeKeyId( const modifierPrefix = MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier) ); - return { - ok: true, - keyId: [...modifierPrefix, baseKey].join("+"), - }; + return { ok: true, keyId: [...modifierPrefix, baseKey].join("+") }; } -function createCustomizableBinding( - config: AskConfig, - id: AskConfigurableKeyAction -): AskKeyBinding { - const binding = CONFIGURABLE_BINDING_METADATA.find( - (entry) => entry.id === id - ); - if (!binding) { - throw new Error(`Unknown customizable ask key binding: ${id}`); - } - const key = config.keymaps[id]; +function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps { return { - ...binding, - keys: [key], - label: formatKeybindingLabel(key), + global: { + dismiss: [...keymaps.global.dismiss], + settings: [...keymaps.global.settings], + }, + main: { + confirm: [...keymaps.main.confirm], + cancel: [...keymaps.main.cancel], + toggle: [...keymaps.main.toggle], + nextTab: [...keymaps.main.nextTab], + previousTab: [...keymaps.main.previousTab], + nextOption: [...keymaps.main.nextOption], + previousOption: [...keymaps.main.previousOption], + optionNote: [...keymaps.main.optionNote], + questionNote: [...keymaps.main.questionNote], + }, + editor: { + submit: [...keymaps.editor.submit], + close: [...keymaps.editor.close], + nextTabWhenEmpty: [...keymaps.editor.nextTabWhenEmpty], + previousTabWhenEmpty: [...keymaps.editor.previousTabWhenEmpty], + nextOptionWhenEmpty: [...keymaps.editor.nextOptionWhenEmpty], + previousOptionWhenEmpty: [...keymaps.editor.previousOptionWhenEmpty], + }, + noteEditor: { + save: [...keymaps.noteEditor.save], + close: [...keymaps.noteEditor.close], + nextTabWhenEmpty: [...keymaps.noteEditor.nextTabWhenEmpty], + previousTabWhenEmpty: [...keymaps.noteEditor.previousTabWhenEmpty], + nextOptionWhenEmpty: [...keymaps.noteEditor.nextOptionWhenEmpty], + previousOptionWhenEmpty: [...keymaps.noteEditor.previousOptionWhenEmpty], + }, + settingsModal: { + close: [...keymaps.settingsModal.close], + nextOption: [...keymaps.settingsModal.nextOption], + previousOption: [...keymaps.settingsModal.previousOption], + toggle: [...keymaps.settingsModal.toggle], + }, }; } diff --git a/src/ui/controller.ts b/src/ui/controller.ts index 10af37c..fb498db 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -172,6 +172,7 @@ function renderController( } function handleControllerInput(controller: AskFlowController, data: string) { + controller.editor.disableSubmit = !isNativeEditorSubmitEnabled(controller); const command = getInputCommand( controller.state, controller.config, @@ -185,6 +186,16 @@ function handleControllerInput(controller: AskFlowController, data: string) { handleNavigationCommand(controller, command); } +function isNativeEditorSubmitEnabled(controller: AskFlowController): boolean { + if (controller.state.view.kind === "input") { + return controller.config.keymaps.editor.submit.includes("enter"); + } + if (controller.state.view.kind === "note") { + return controller.config.keymaps.noteEditor.save.includes("enter"); + } + return true; +} + function handleEditingCommand( controller: AskFlowController, command: AskInputCommand, @@ -210,6 +221,10 @@ function handleEditingCommand( closeEditor(controller); return; } + if (command.kind === "editSubmit") { + submitEditor(controller, controller.editor.getText()); + return; + } if (command.kind === "delegateToEditor") { controller.editor.handleInput(data); refresh(controller); @@ -270,6 +285,7 @@ function handleNavigationCommand( case "editMoveTab": case "editMoveOption": case "editClose": + case "editSubmit": case "delegateToEditor": return; default: diff --git a/src/ui/input.ts b/src/ui/input.ts index 03485e4..bb02677 100644 --- a/src/ui/input.ts +++ b/src/ui/input.ts @@ -1,6 +1,9 @@ +import { matchesKey } from "@mariozechner/pi-tui"; import type { AskConfig } from "../config/schema.ts"; import { - getAskKeyBindings, + type AskKeyBinding, + getAskContextBindings, + getGlobalBindings, matchesBinding, matchesDigitShortcut, } from "../constants/keymaps.ts"; @@ -20,6 +23,7 @@ export type AskInputCommand = | { kind: "editMoveTab"; delta: 1 | -1 } | { kind: "editMoveOption"; delta: 1 | -1 } | { kind: "editClose" } + | { kind: "editSubmit" } | { kind: "delegateToEditor" } | { kind: "ignore" }; @@ -29,50 +33,94 @@ export function getInputCommand( data: string, editingText = "" ): AskInputCommand { - const bindings = getAskKeyBindings(config); - if (matchesBinding(data, bindings.dismiss)) { + const global = getGlobalBindings(config); + if (matchesBinding(data, global.dismiss)) { return { kind: "dismiss" }; } - if (matchesBinding(data, bindings.settings) && editingText.length === 0) { + if (matchesBinding(data, global.settings) && editingText.length === 0) { return { kind: "showSettings" }; } - if (state.view.kind === "input" || state.view.kind === "note") { - return getEditingInputCommand(bindings, data, editingText); + if (state.view.kind === "input") { + return getAnswerEditorInputCommand(config, data, editingText); + } + if (state.view.kind === "note") { + return getNoteEditorInputCommand(config, data, editingText); } - return getNavigationInputCommand(bindings, data); + return getNavigationInputCommand(config, data); } -function getEditingInputCommand( - bindings: ReturnType, +function getAnswerEditorInputCommand( + config: AskConfig, data: string, editingText: string ): AskInputCommand { - if (matchesBinding(data, bindings.cancel)) { + const bindings = getAskContextBindings(config, "editor"); + if (matchesBinding(data, bindings.submit)) { + return isNativeEditorSubmit(data) + ? { kind: "delegateToEditor" } + : { kind: "editSubmit" }; + } + if (matchesBinding(data, bindings.close)) { return { kind: "editClose" }; } + return getEmptyEditorNavigationCommand(bindings, data, editingText); +} + +function getNoteEditorInputCommand( + config: AskConfig, + data: string, + editingText: string +): AskInputCommand { + const bindings = getAskContextBindings(config, "noteEditor"); + if (matchesBinding(data, bindings.save)) { + return isNativeEditorSubmit(data) + ? { kind: "delegateToEditor" } + : { kind: "editSubmit" }; + } + if (matchesBinding(data, bindings.close)) { + return { kind: "editClose" }; + } + return getEmptyEditorNavigationCommand(bindings, data, editingText); +} + +function getEmptyEditorNavigationCommand( + bindings: { + nextTabWhenEmpty: AskKeyBinding; + previousTabWhenEmpty: AskKeyBinding; + previousOptionWhenEmpty: AskKeyBinding; + nextOptionWhenEmpty: AskKeyBinding; + }, + data: string, + editingText: string +): AskInputCommand { if (editingText.length === 0) { - if (matchesBinding(data, bindings.nextTab)) { + if (matchesBinding(data, bindings.nextTabWhenEmpty)) { return { kind: "editMoveTab", delta: 1 }; } - if (matchesBinding(data, bindings.previousTab)) { + if (matchesBinding(data, bindings.previousTabWhenEmpty)) { return { kind: "editMoveTab", delta: -1 }; } - if (matchesBinding(data, bindings.previousOption)) { + if (matchesBinding(data, bindings.previousOptionWhenEmpty)) { return { kind: "editMoveOption", delta: -1 }; } - if (matchesBinding(data, bindings.nextOption)) { + if (matchesBinding(data, bindings.nextOptionWhenEmpty)) { return { kind: "editMoveOption", delta: 1 }; } } return { kind: "delegateToEditor" }; } +function isNativeEditorSubmit(data: string): boolean { + return matchesKey(data, "enter"); +} + function getNavigationInputCommand( - bindings: ReturnType, + config: AskConfig, data: string ): AskInputCommand { + const bindings = getAskContextBindings(config, "main"); if (matchesBinding(data, bindings.nextTab)) { return { kind: "moveTab", delta: 1 }; } diff --git a/src/ui/settings-list.ts b/src/ui/settings-list.ts index 0a3b164..a1683b5 100644 --- a/src/ui/settings-list.ts +++ b/src/ui/settings-list.ts @@ -1,12 +1,15 @@ import { - Key, - matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@mariozechner/pi-tui"; import type { AskConfig } from "../config/schema.ts"; import type { AskConfigNotice } from "../config/store.ts"; +import { + getAskContextBindings, + matchesBinding, + renderSettingsFooterKeymaps, +} from "../constants/keymaps.ts"; interface Theme { bg(color: string, text: string): string; @@ -86,26 +89,23 @@ export class AskSettingsList { } handleInput(data: string): void { - if ( - data === "?" || - matchesKey(data, Key.ctrl("c")) || - matchesKey(data, Key.escape) - ) { + const bindings = getAskContextBindings(this.config, "settingsModal"); + if (matchesBinding(data, bindings.close)) { this.close(); return; } - if (matchesKey(data, Key.up)) { + if (matchesBinding(data, bindings.previousOption)) { this.focusIndex = this.focusIndex === 0 ? SETTINGS.length - 1 : this.focusIndex - 1; this.tui.requestRender(); return; } - if (matchesKey(data, Key.down)) { + if (matchesBinding(data, bindings.nextOption)) { this.focusIndex = (this.focusIndex + 1) % SETTINGS.length; this.tui.requestRender(); return; } - if (matchesKey(data, Key.enter) || data === " ") { + if (matchesBinding(data, bindings.toggle)) { const setting = SETTINGS[this.focusIndex]; if (setting) { this.saveSetting(setting.key, !this.getSettingValue(setting.key)); @@ -175,15 +175,15 @@ export class AskSettingsList { private appendFooter(lines: string[], innerWidth: number): void { if (innerWidth < COMPACT_WIDTH) { - lines.push( - this.line(this.theme.fg("dim", " Enter/Space to change"), innerWidth) - ); - lines.push(this.line(this.theme.fg("dim", " Esc to close"), innerWidth)); + const footer = renderSettingsFooterKeymaps(this.config); + for (const line of wrapTextWithAnsi(footer, innerWidth - 2)) { + lines.push(this.line(this.theme.fg("dim", ` ${line}`), innerWidth)); + } return; } this.appendWrapped( lines, - this.theme.fg("dim", "Enter/Space to change ยท Esc to close"), + this.theme.fg("dim", renderSettingsFooterKeymaps(this.config)), innerWidth ); } diff --git a/tests/config-migrate.test.ts b/tests/config-migrate.test.ts index 6b3db96..b4e7183 100644 --- a/tests/config-migrate.test.ts +++ b/tests/config-migrate.test.ts @@ -19,6 +19,86 @@ test("config migration framework accepts the current schema version", () => { assert.deepEqual(result.config, DEFAULT_ASK_CONFIG); }); +test("config migration maps previous-version config files into v4 shape", () => { + const legacyKeymaps = { + cancel: "q", + confirm: "ctrl+k", + dismiss: "ctrl+d", + optionNote: "x", + questionNote: "shift+x", + toggle: "ctrl+t", + }; + const cases = [ + { + name: "v1", + raw: { + schemaVersion: 1, + behaviour: DEFAULT_ASK_CONFIG.behaviour, + keymaps: legacyKeymaps, + }, + }, + { + name: "v2", + raw: { + schemaVersion: 2, + answer: DEFAULT_ASK_CONFIG.answer, + behaviour: DEFAULT_ASK_CONFIG.behaviour, + keymaps: legacyKeymaps, + }, + }, + { + name: "v3", + raw: { + schemaVersion: 3, + answer: DEFAULT_ASK_CONFIG.answer, + behaviour: DEFAULT_ASK_CONFIG.behaviour, + keymaps: legacyKeymaps, + notifications: DEFAULT_ASK_CONFIG.notifications, + }, + }, + ]; + + for (const { name, raw } of cases) { + const result = migrateAskConfig(raw); + assert.equal(result.migrated, true, name); + assert.deepEqual(result.config.keymaps.global.dismiss, ["ctrl+d"], name); + assert.deepEqual(result.config.keymaps.main.cancel, ["q"], name); + assert.deepEqual(result.config.keymaps.main.confirm, ["ctrl+k"], name); + assert.deepEqual(result.config.keymaps.main.toggle, ["ctrl+t"], name); + assert.deepEqual(result.config.keymaps.editor.submit, ["ctrl+k"], name); + assert.deepEqual(result.config.keymaps.editor.close, ["q"], name); + assert.deepEqual(result.config.keymaps.noteEditor.save, ["ctrl+k"], name); + assert.deepEqual(result.config.keymaps.noteEditor.close, ["q"], name); + assert.deepEqual( + result.config.keymaps.settingsModal, + DEFAULT_ASK_CONFIG.keymaps.settingsModal, + name + ); + } +}); + +test("config migration maps legacy flat keymaps into context keymaps", () => { + const result = migrateAskConfig({ + schemaVersion: 3, + behaviour: DEFAULT_ASK_CONFIG.behaviour, + keymaps: { + cancel: "q", + confirm: "ctrl+k", + dismiss: "ctrl+d", + optionNote: "x", + questionNote: "shift+x", + toggle: "ctrl+t", + }, + notifications: DEFAULT_ASK_CONFIG.notifications, + }); + + assert.equal(result.migrated, true); + assert.deepEqual(result.config.keymaps.global.dismiss, ["ctrl+d"]); + assert.deepEqual(result.config.keymaps.main.cancel, ["q"]); + assert.deepEqual(result.config.keymaps.editor.submit, ["ctrl+k"]); + assert.deepEqual(result.config.keymaps.noteEditor.save, ["ctrl+k"]); +}); + test("config migration framework rejects unsupported future schema versions", () => { assert.throws( () => diff --git a/tests/config-store.test.ts b/tests/config-store.test.ts index 5d1d663..028df53 100644 --- a/tests/config-store.test.ts +++ b/tests/config-store.test.ts @@ -13,7 +13,7 @@ function expectedConfigFile( overrides: { behaviour?: typeof DEFAULT_ASK_CONFIG.behaviour } = {} ) { return { - schemaVersion: 3, + schemaVersion: 4, answer: DEFAULT_ASK_CONFIG.answer, behaviour: overrides.behaviour ?? DEFAULT_ASK_CONFIG.behaviour, keymaps: DEFAULT_ASK_CONFIG.keymaps, diff --git a/tests/input.test.ts b/tests/input.test.ts index 151d0c0..6ec0d7d 100644 --- a/tests/input.test.ts +++ b/tests/input.test.ts @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts"; +import { getAskConfigStore } from "../src/config/store.ts"; import { createInitialState } from "../src/state/create.ts"; import { applyNumberShortcut, enterQuestionNoteMode, } from "../src/state/transitions.ts"; +import { runAskFlow } from "../src/ui/controller.ts"; import { getInputCommand } from "../src/ui/input.ts"; function inputState() { @@ -207,6 +209,99 @@ test("note shortcuts use n for option notes and Shift+N for question notes", () }); }); +test("custom configured editor submit shortcut is used at runtime", () => { + const input = inputState(); + const config = { + ...DEFAULT_ASK_CONFIG, + keymaps: { + ...DEFAULT_ASK_CONFIG.keymaps, + editor: { + ...DEFAULT_ASK_CONFIG.keymaps.editor, + submit: ["ctrl+k"], + }, + }, + }; + + assert.deepEqual(getInputCommand(input, config, "\u000b", "answer"), { + kind: "editSubmit", + }); + assert.deepEqual(getInputCommand(input, config, "\r", "answer"), { + kind: "delegateToEditor", + }); +}); + +test("custom editor submit key controls actual editor submission", async () => { + const config = { + ...DEFAULT_ASK_CONFIG, + notifications: { + ...DEFAULT_ASK_CONFIG.notifications, + enabled: false, + }, + keymaps: { + ...DEFAULT_ASK_CONFIG.keymaps, + editor: { + ...DEFAULT_ASK_CONFIG.keymaps.editor, + submit: ["ctrl+k"], + }, + }, + }; + getAskConfigStore().setConfig(config); + let component: { handleInput(data: string): void } | undefined; + const resultPromise = runAskFlow( + { + cwd: process.cwd(), + ui: { + custom(callback: (...args: unknown[]) => unknown) { + return new Promise((resolve) => { + const tui = { + requestRender() { + // Rendering is not needed for this controller input test. + }, + }; + component = callback( + tui, + plainTheme(), + {}, + resolve + ) as typeof component; + }); + }, + }, + } as never, + { + questions: [ + { + id: "q1", + prompt: "Question?", + options: [{ value: "a", label: "A" }], + }, + ], + } + ); + + await new Promise((resolve) => setImmediate(resolve)); + component?.handleInput("2"); + component?.handleInput("x"); + component?.handleInput("\r"); + component?.handleInput("\u000b"); + component?.handleInput("\r"); + const result = await resultPromise; + + assert.equal(result.answers.q1?.customText, "x"); + getAskConfigStore().setConfig(DEFAULT_ASK_CONFIG); +}); + +function plainTheme() { + return { + bg(_color: string, text: string) { + return text; + }, + fg(_color: string, text: string) { + return text; + }, + }; +} + test("custom configured note shortcuts are used at runtime", () => { const navigation = createInitialState({ questions: [ @@ -221,8 +316,11 @@ test("custom configured note shortcuts are used at runtime", () => { ...DEFAULT_ASK_CONFIG, keymaps: { ...DEFAULT_ASK_CONFIG.keymaps, - optionNote: "x", - questionNote: "shift+x", + main: { + ...DEFAULT_ASK_CONFIG.keymaps.main, + optionNote: ["x"], + questionNote: ["shift+x"], + }, }, }; diff --git a/tests/render-helpers.test.ts b/tests/render-helpers.test.ts index d8c5ce4..452806c 100644 --- a/tests/render-helpers.test.ts +++ b/tests/render-helpers.test.ts @@ -90,8 +90,11 @@ test("editing footers use configured key labels", () => { ...DEFAULT_ASK_CONFIG, keymaps: { ...DEFAULT_ASK_CONFIG.keymaps, - confirm: "ctrl+k", - cancel: "q", + noteEditor: { + ...DEFAULT_ASK_CONFIG.keymaps.noteEditor, + save: ["ctrl+k"], + close: ["q"], + }, }, }; diff --git a/tests/settings-list.test.ts b/tests/settings-list.test.ts index 891dfe3..d4b0546 100644 --- a/tests/settings-list.test.ts +++ b/tests/settings-list.test.ts @@ -15,14 +15,7 @@ const savedConfig: AskConfig = { doublePressReviewShortcuts: true, showFooterHints: true, }, - keymaps: { - cancel: "esc", - dismiss: "ctrl+c", - toggle: "space", - confirm: "enter", - optionNote: "n", - questionNote: "shift+n", - }, + keymaps: DEFAULT_ASK_CONFIG.keymaps, notifications: { channels: ["bell"], enabled: true, @@ -44,6 +37,7 @@ function createList( options: { onClose?: () => void; onSave?: (config: AskConfig) => Promise; + savedConfig?: AskConfig; } = {} ) { const onClose = @@ -56,7 +50,7 @@ function createList( notice: undefined, onClose, onSave: options.onSave ?? ((config) => Promise.resolve(config)), - savedConfig, + savedConfig: options.savedConfig ?? savedConfig, tui: { requestRender() { // no-op in tests @@ -80,7 +74,7 @@ test("settings list renders behaviour settings and config path", () => { assert(text.includes("notifications")); assert(text.includes("extraction settings")); assert(text.includes("/tmp/eko24ive-pi-ask.json")); - assert(text.includes("Esc to close")); + assert(text.includes("Esc / Ctrl+C / ? to close")); assert.equal(text.includes("Esc to cancel"), false); assert.equal(text.includes("Keymaps"), false); assert.equal(text.includes("Ctrl+S"), false); @@ -131,7 +125,37 @@ test("settings list shows save failures and reverts the toggle", async () => { assert(text.includes("[off]")); }); -test("settings list closes with escape, ctrl-c, question mark, and dispose idempotently", () => { +test("settings list uses configured navigation and close keys", async () => { + let saved: AskConfig | undefined; + const customConfig: AskConfig = { + ...savedConfig, + keymaps: { + ...savedConfig.keymaps, + settingsModal: { + ...savedConfig.keymaps.settingsModal, + nextOption: ["j"], + previousOption: ["k"], + toggle: ["x"], + close: ["q"], + }, + }, + }; + const list = createList({ + onSave: (config) => { + saved = config; + return Promise.resolve(config); + }, + savedConfig: customConfig, + }); + + list.handleInput("j"); + list.handleInput("x"); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(saved?.behaviour.confirmDismissWhenDirty, false); +}); + +test("settings list closes with configured keys and dispose idempotently", () => { let closed = 0; const list = createList({ onClose: () => {