feat: add context-aware ask keymaps

This commit is contained in:
Erik Kotsiuba
2026-05-06 12:44:37 +02:00
parent 3672ada13f
commit 808b62cc77
18 changed files with 1158 additions and 444 deletions
+54 -33
View File
@@ -42,8 +42,9 @@ Once installed, this package gives the agent a native way to ask for clarificati
- 📝 Question-level and option-level notes - 📝 Question-level and option-level notes
- 👀 Review tab with `Submit`, `Elaborate`, and `Cancel` - 👀 Review tab with `Submit`, `Elaborate`, and `Cancel`
- 💬 Elaboration flow to capture note-based clarification before final submission - 💬 Elaboration flow to capture note-based clarification before final submission
- ⌨️ Fast keyboard-first interaction with customizable ask keymaps (also mobile-friendly in remote sessions) - ⌨️ Context-aware customizable keymaps with aliases for main flow, editors, and settings
- ⚙️ Ask settings with persisted behaviour, notification toggle, keymap customization, and `/answer` extraction config - ⚙️ 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: - 🔁 Slash commands for fallback/replay:
- `/answer` extracts questions from the latest assistant message into an ask flow - `/answer` extracts questions from the latest assistant message into an ask flow
- `/answer:again` reopens the latest `/answer` form on the current branch - `/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. 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 | Default contexts:
|---|---|
| Cancel flow or close/save editor draft | `Esc` | - `global`: `dismiss` (`Ctrl+C`) and `settings` (`?`)
| Dismiss entire ask flow immediately | `Ctrl+C` | - `main`: confirm/cancel/toggle, tab navigation, option navigation, and note shortcuts
| Toggle selected option | `Space` | - `editor`: custom answer submit/close and empty-editor navigation
| Confirm / continue / save / submit | `Enter` | - `noteEditor`: note save/close and empty-editor navigation
| Edit selected option note | `n` | - `settingsModal`: close, next/previous setting, and toggle
| Edit question note | `Shift+N` |
Fixed bindings: Fixed bindings:
| Key | Context | Effect | | Key | Context | Effect |
|-----------------------------|-----------------------------------------|---------------------------------------------| |---|---|---|
| `?` | Ask flow / empty editor | Open ask settings | | `1..9` | Options list | Select or toggle matching option |
| `Tab` `Shift+Tab` | Main flow | Switch tabs | | `1` `2` `3` | Review tab | Trigger `Submit` / `Elaborate` / `Cancel` |
| `←` `→` | Main flow | Switch tabs | | `@` | Editors | File-reference affordance |
| `↑` `↓` | Main flow | Move cursor | | Arrow keys / `Tab` | Non-empty editor | Stay in editor for cursor movement |
| `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 |
Review-tab shortcuts can optionally require the same number key twice via `behaviour.doublePressReviewShortcuts`. 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 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.
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.
```json ```json
{ {
"schemaVersion": 3, "schemaVersion": 4,
"answer": { "answer": {
"extractionModels": [ "extractionModels": [
{ "provider": "openai-codex", "id": "gpt-5.4-mini" }, { "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 "showFooterHints": true
}, },
"keymaps": { "keymaps": {
"cancel": "esc", "global": { "dismiss": ["ctrl+c"], "settings": ["?"] },
"dismiss": "ctrl+c", "main": {
"toggle": "space", "confirm": ["enter"],
"confirm": "enter", "cancel": ["esc"],
"optionNote": "n", "toggle": ["space"],
"questionNote": "shift+n" "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": { "notifications": {
"enabled": true, "enabled": true,
+3 -3
View File
@@ -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/defaults.ts` — canonical runtime defaults
- `src/config/schema.ts` — persisted config schema and runtime type - `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/migrations/` — ordered schema-version migration framework for persisted config shape changes
- `src/config/path-migrations.ts` — file-location migration before config load - `src/config/path-migrations.ts` — file-location migration before config load
- `src/config/store.ts` — load/save/backup/runtime subscription store - `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 ### UI
- `src/ui/controller.ts` — connects key input, editor lifecycle, live config subscription, dirty-dismiss confirmation, and pure state transitions - `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/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/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 - `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 - 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 - 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 - 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 - 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 - 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/` - when the ask config file is missing, the first ask use writes a default persisted config snapshot under `~/.pi/agent/extensions/`
+138 -66
View File
@@ -28,7 +28,7 @@ Unsupported future versions or invalid files are backed up and defaults are load
```json ```json
{ {
"schemaVersion": 3, "schemaVersion": 4,
"answer": { "answer": {
"extractionModels": [ "extractionModels": [
{ "provider": "openai-codex", "id": "gpt-5.4-mini" }, { "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 "showFooterHints": true
}, },
"keymaps": { "keymaps": {
"cancel": "esc", "global": { "dismiss": ["ctrl+c"], "settings": ["?"] },
"dismiss": "ctrl+c", "main": {
"toggle": "space", "confirm": ["enter"],
"confirm": "enter", "cancel": ["esc"],
"optionNote": "n", "toggle": ["space"],
"questionNote": "shift+n" "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": { "notifications": {
"enabled": true, "enabled": true,
@@ -159,33 +187,69 @@ cmux example:
## Keymaps ## Keymaps
`keymaps` must contain all 6 configurable actions if present. `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.
### Configurable actions
- `cancel`
- `dismiss`
- `toggle`
- `confirm`
- `optionNote`
- `questionNote`
### Defaults ### Defaults
- `cancel: "esc"` ```json
- `dismiss: "ctrl+c"` "keymaps": {
- `toggle: "space"` "global": {
- `confirm: "enter"` "dismiss": ["ctrl+c"],
- `optionNote: "n"` "settings": ["?"]
- `questionNote: "shift+n"` },
"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 ### 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` - supported by `pi-tui`
- not reserved - not a fixed numeric shortcut (`1` through `9`)
- not duplicated across configurable actions - not duplicated within the same context
- not duplicated between `global` and `main`, `editor`, or `noteEditor`
Examples of valid bindings: Examples of valid bindings:
@@ -196,7 +260,7 @@ Examples of valid bindings:
- `n` - `n`
- `shift+n` - `shift+n`
- `alt+f7` - `alt+f7`
- `ctrl+[` - `ctrl+[`
- `ctrl+shift+p` - `ctrl+shift+p`
- `super+k` - `super+k`
@@ -213,43 +277,19 @@ Examples:
- `pageup` -> `pageUp` - `pageup` -> `pageUp`
- `pagedown` -> `pageDown` - `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 ### Fixed bindings
These are intentionally not configurable: 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 - `1..9` triggers option/review shortcuts
- when `behaviour.doublePressReviewShortcuts` is enabled, review-tab shortcuts `1`, `2`, and `3` require the same key twice - 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 ## Invalid keymaps behavior
If configured keymaps are invalid: 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 - invalid `keymaps` fall back to default keymaps for the current session
- ask remains usable - ask remains usable
- a warning notice is shown - a warning notice is shown
@@ -257,16 +297,17 @@ If configured keymaps are invalid:
Invalid keymaps include: Invalid keymaps include:
- missing one of the 6 required actions - missing one of the required contexts or actions
- unsupported key syntax - unsupported key syntax
- duplicate bindings across configurable actions - duplicate bindings within one context
- use of reserved bindings - duplicate bindings between `global` and `main`, `editor`, or `noteEditor`
- use of fixed numeric shortcuts (`1` through `9`)
## Example custom config ## Example custom config
```json ```json
{ {
"schemaVersion": 3, "schemaVersion": 4,
"answer": { "answer": {
"extractionRetries": 1, "extractionRetries": 1,
"extractionTimeoutMs": 30000, "extractionTimeoutMs": 30000,
@@ -281,12 +322,43 @@ Invalid keymaps include:
"showFooterHints": false "showFooterHints": false
}, },
"keymaps": { "keymaps": {
"cancel": "q", "global": {
"dismiss": "ctrl+c", "dismiss": ["ctrl+c"],
"toggle": "ctrl+t", "settings": ["?"]
"confirm": "ctrl+k", },
"optionNote": "x", "main": {
"questionNote": "shift+x" "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": { "notifications": {
"enabled": true, "enabled": true,
@@ -305,11 +377,11 @@ Invalid keymaps include:
When editing this config for a user: When editing this config for a user:
- preserve unrelated fields - preserve unrelated fields
- keep `schemaVersion` at `3` - keep `schemaVersion` at `4`
- preserve `answer.extractionModels` as explicit provider/id pairs - preserve `answer.extractionModels` as explicit provider/id pairs
- keep `answer.extractionRetries` between `0` and `3` - keep `answer.extractionRetries` between `0` and `3`
- do not assign reserved bindings to configurable actions - do not assign fixed numeric shortcuts (`1` through `9`) to configurable actions
- do not create duplicate configurable bindings - 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 - preserve existing `notifications.channels` unless the user asks to change notification targets
- only toggle `notifications.enabled` unless the user asks to configure channels - only toggle `notifications.enabled` unless the user asks to configure channels
- use a `cmux notify` command channel when the user asks for cmux notifications - use a `cmux notify` command channel when the user asks for cmux notifications
+25 -15
View File
@@ -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 - 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 - `?` 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` - 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 - 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 - 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?"` - 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: Main flow:
- `?`: open ask settings - `global.settings` opens ask settings; default: `?`
- inside ask settings, `Esc`, `Ctrl+C`, and `?` close it - `global.dismiss` dismisses the active ask surface; default: `Ctrl+C`
- `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs - `main.nextTab` / `main.previousTab` move between tabs; defaults: `Tab`/`Right`, `Shift+Tab`/`Left`
- `Up`, `Down`: move between options - `main.nextOption` / `main.previousOption` move between options or review actions; defaults: `Down`, `Up`
- `1..9`: select or toggle the matching option; on the review tab, `1`, `2`, and `3` trigger `Submit`, `Elaborate`, and `Cancel` - `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 - 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: 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 - `editor.submit` submits the current custom-answer editor input and closes the editor; default: `Enter`
- the configurable `cancel` binding saves draft and closes the editor - `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 - 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 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 ## Non-interactive mode
+62 -24
View File
@@ -1,7 +1,11 @@
import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts"; import {
DEFAULT_ASK_KEYMAPS,
normalizeConfiguredKeymaps,
} from "../constants/keymaps.ts";
import type { import type {
AskConfig, AskConfig,
AskConfigFileV3, AskConfigFileV4,
AskConfigKeymaps,
AskNotificationChannel, AskNotificationChannel,
} from "./schema.ts"; } from "./schema.ts";
@@ -25,9 +29,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
doublePressReviewShortcuts: true, doublePressReviewShortcuts: true,
showFooterHints: true, showFooterHints: true,
}, },
keymaps: { keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS),
...DEFAULT_ASK_KEYMAPS,
},
notifications: { notifications: {
channels: ["bell"], channels: ["bell"],
enabled: true, enabled: true,
@@ -35,7 +37,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
}; };
export function normalizeAskConfig( export function normalizeAskConfig(
config?: Partial<AskConfigFileV3> | AskConfig config?: Partial<AskConfigFileV4> | AskConfig
): AskConfig { ): AskConfig {
return { return {
answer: { answer: {
@@ -67,10 +69,7 @@ export function normalizeAskConfig(
config?.behaviour?.showFooterHints ?? config?.behaviour?.showFooterHints ??
DEFAULT_ASK_CONFIG.behaviour.showFooterHints, DEFAULT_ASK_CONFIG.behaviour.showFooterHints,
}, },
keymaps: { keymaps: mergeKeymaps(config?.keymaps),
...DEFAULT_ASK_CONFIG.keymaps,
...(config?.keymaps ?? {}),
},
notifications: { notifications: {
channels: normalizeNotificationChannels(config?.notifications?.channels), channels: normalizeNotificationChannels(config?.notifications?.channels),
enabled: enabled:
@@ -80,10 +79,10 @@ export function normalizeAskConfig(
}; };
} }
export function toAskConfigFileV3(config: AskConfig): AskConfigFileV3 { export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 {
const normalized = normalizeAskConfig(config); const normalized = normalizeAskConfig(config);
return { return {
schemaVersion: 3, schemaVersion: 4,
answer: { answer: {
extractionModels: normalized.answer.extractionModels, extractionModels: normalized.answer.extractionModels,
extractionRetries: normalized.answer.extractionRetries, extractionRetries: normalized.answer.extractionRetries,
@@ -97,14 +96,7 @@ export function toAskConfigFileV3(config: AskConfig): AskConfigFileV3 {
normalized.behaviour.doublePressReviewShortcuts, normalized.behaviour.doublePressReviewShortcuts,
showFooterHints: normalized.behaviour.showFooterHints, showFooterHints: normalized.behaviour.showFooterHints,
}, },
keymaps: { keymaps: cloneKeymaps(normalized.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,
},
notifications: { notifications: {
channels: normalized.notifications.channels, channels: normalized.notifications.channels,
enabled: normalized.notifications.enabled, 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( function normalizeNotificationChannels(
channels: unknown channels: unknown
): AskNotificationChannel[] { ): AskNotificationChannel[] {
@@ -139,10 +178,9 @@ function isValidNotificationChannel(
); );
} }
function isValidModelPreference(value: unknown): value is { function isValidModelPreference(
id: string; value: unknown
provider: string; ): value is { id: string; provider: string } {
} {
return ( return (
!!value && !!value &&
typeof value === "object" && typeof value === "object" &&
+6 -13
View File
@@ -1,15 +1,12 @@
import { import { normalizeConfiguredKeymaps } from "../constants/keymaps.ts";
type AskConfigurableKeyAction,
normalizeConfiguredKeymaps,
} from "../constants/keymaps.ts";
import { normalizeAskConfig } from "./defaults.ts"; import { normalizeAskConfig } from "./defaults.ts";
import { import {
AskConfigVersionMigrationError, AskConfigVersionMigrationError,
type AskConfigVersionMigrationResult, type AskConfigVersionMigrationResult,
migrateAskConfigFileToCurrent, migrateAskConfigFileToCurrent,
} from "./migrations/index.ts"; } from "./migrations/index.ts";
import type { AskConfig, AskConfigFileV3 } from "./schema.ts"; import type { AskConfig, AskConfigFileV4 } from "./schema.ts";
import { validateAskConfigFileV3 } from "./schema.ts"; import { validateAskConfigFileV4 } from "./schema.ts";
export class AskConfigMigrationError extends Error { export class AskConfigMigrationError extends Error {
readonly reason: "invalid_or_unsupported" | "migration_failed"; 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( throw new AskConfigMigrationError(
"Config was invalid or unsupported.", "Config was invalid or unsupported.",
"invalid_or_unsupported" "invalid_or_unsupported"
); );
} }
const currentFile = migratedFile.config as AskConfigFileV3; const currentFile = migratedFile.config as AskConfigFileV4;
const config = normalizeAskConfig(currentFile); const config = normalizeAskConfig(currentFile);
const keymapsResult = normalizeConfiguredKeymaps( const keymapsResult = normalizeConfiguredKeymaps(currentFile.keymaps);
currentFile.keymaps as
| Partial<Record<AskConfigurableKeyAction, unknown>>
| undefined
);
if (!keymapsResult.ok) { if (!keymapsResult.ok) {
return { return {
config: { config: {
+11 -1
View File
@@ -1,6 +1,7 @@
import { normalizeLegacyFlatKeymaps } from "../../constants/keymaps.ts";
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.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[] = [ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
{ {
@@ -23,6 +24,15 @@ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
schemaVersion: 3, schemaVersion: 3,
}), }),
}, },
{
from: 3,
to: 4,
migrate: (config) => ({
...config,
keymaps: normalizeLegacyFlatKeymaps(config.keymaps) ?? config.keymaps,
schemaVersion: 4,
}),
},
]; ];
export class AskConfigVersionMigrationError extends Error { export class AskConfigVersionMigrationError extends Error {
+113 -10
View File
@@ -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({ export const AskConfigFileV3Schema = Type.Object({
schemaVersion: Type.Literal(3), schemaVersion: Type.Literal(3),
answer: Type.Optional( answer: Type.Optional(
@@ -58,6 +131,7 @@ export const AskConfigFileV2Schema = Type.Omit(AskConfigFileV3Schema, [
"schemaVersion", "schemaVersion",
]); ]);
export type AskConfigFileV4 = Static<typeof AskConfigFileV4Schema>;
export type AskConfigFileV3 = Static<typeof AskConfigFileV3Schema>; export type AskConfigFileV3 = Static<typeof AskConfigFileV3Schema>;
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & { export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & {
schemaVersion: 2; schemaVersion: 2;
@@ -65,9 +139,7 @@ export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & {
export type AskConfigFileV1 = Omit< export type AskConfigFileV1 = Omit<
AskConfigFileV2, AskConfigFileV2,
"answer" | "schemaVersion" "answer" | "schemaVersion"
> & { > & { schemaVersion: 1 };
schemaVersion: 1;
};
export interface AskAnswerModelPreference { export interface AskAnswerModelPreference {
id: string; id: string;
@@ -81,12 +153,43 @@ export type AskNotificationChannel =
| { command: string; type: "command" }; | { command: string; type: "command" };
export interface AskConfigKeymaps { export interface AskConfigKeymaps {
cancel: string; editor: {
confirm: string; close: string[];
dismiss: string; nextOptionWhenEmpty: string[];
optionNote: string; nextTabWhenEmpty: string[];
questionNote: string; previousOptionWhenEmpty: string[];
toggle: 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 { export interface AskConfig {
@@ -108,4 +211,4 @@ export interface AskConfig {
}; };
} }
export const validateAskConfigFileV3 = Compile(AskConfigFileV3Schema); export const validateAskConfigFileV4 = Compile(AskConfigFileV4Schema);
+2 -2
View File
@@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
import { import {
DEFAULT_ASK_CONFIG, DEFAULT_ASK_CONFIG,
normalizeAskConfig, normalizeAskConfig,
toAskConfigFileV3, toAskConfigFileV4,
} from "./defaults.ts"; } from "./defaults.ts";
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts"; import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts"; import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
@@ -66,7 +66,7 @@ export class AskConfigStore {
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> { async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
const normalized = normalizeAskConfig(config); const normalized = normalizeAskConfig(config);
const content = JSON.stringify( const content = JSON.stringify(
toAskConfigFileV3(normalized), toAskConfigFileV4(normalized),
null, null,
2 2
).concat("\n"); ).concat("\n");
+429 -231
View File
@@ -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"; import type { AskConfig, AskConfigKeymaps } from "../config/schema.ts";
const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/; const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/;
@@ -37,14 +37,7 @@ const KNOWN_SPECIAL_KEYS = new Set([
"f11", "f11",
"f12", "f12",
] as const); ] as const);
const RESERVED_CONFIGURABLE_BINDINGS = new Set([ const RESERVED_BINDINGS = new Set([
"?",
"tab",
"shift+tab",
"left",
"right",
"up",
"down",
"1", "1",
"2", "2",
"3", "3",
@@ -55,39 +48,27 @@ const RESERVED_CONFIGURABLE_BINDINGS = new Set([
"8", "8",
"9", "9",
]); ]);
const ACTION_LABEL_OVERRIDES: Partial<Record<AskKeyBindingId, string>> = {
nextTab: "Tab / →", const CONTEXT_LABELS: Record<AskKeymapContext, string> = {
previousTab: "Shift+Tab / ←", global: "Global",
previousOption: "↑ / ↓", main: "Main flow",
nextOption: "↓", editor: "Answer editor",
numberShortcut: "1..9", noteEditor: "Note editor",
settingsModal: "Settings modal",
}; };
type KeyId = Parameters<typeof matchesKey>[1]; type KeyId = Parameters<typeof matchesKey>[1];
type InputKey = KeyId | (string & {}); type InputKey = KeyId | (string & {});
type ConfigurableBindingMetadata = Omit<AskKeyBinding, "keys" | "label"> & {
id: AskConfigurableKeyAction;
};
type FixedBindingMetadata = Omit<AskKeyBinding, "keys" | "label"> & {
id: AskFixedKeyBindingId;
keys: readonly InputKey[];
label?: string;
};
type ModifierName = (typeof MODIFIER_ORDER)[number]; type ModifierName = (typeof MODIFIER_ORDER)[number];
export type AskKeymapContext = keyof AskConfigKeymaps;
export type AskKeymapAction<C extends AskKeymapContext = AskKeymapContext> =
keyof AskConfigKeymaps[C] & string;
export type AskKeyBindingKind = "command" | "affordance"; export type AskKeyBindingKind = "command" | "affordance";
export type AskConfigurableKeyAction = keyof AskConfigKeymaps; export type AskKeyBindingId =
export type AskFixedKeyBindingId = | `${AskKeymapContext}.${string}`
| "settings"
| "nextTab"
| "previousTab"
| "previousOption"
| "nextOption"
| "numberShortcut" | "numberShortcut"
| "fileReference"; | "fileReference";
export type AskKeyBindingId = AskConfigurableKeyAction | AskFixedKeyBindingId;
export interface AskKeyBinding { export interface AskKeyBinding {
contexts: readonly string[]; contexts: readonly string[];
@@ -106,105 +87,84 @@ export type FooterKeymapContext =
| "note"; | "note";
export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = { export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
cancel: "esc", global: {
dismiss: "ctrl+c", dismiss: ["ctrl+c"],
toggle: "space", settings: ["?"],
confirm: "enter", },
optionNote: "n", main: {
questionNote: "shift+n", 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[] = [ const DESCRIPTIONS: Record<AskKeymapContext, Record<string, string>> = {
{ global: {
id: "cancel", dismiss: "Dismiss the active ask surface",
kind: "command", settings: "Open ask settings",
description: "Cancel flow or close/save editor draft",
contexts: ["Main flow", "Editors"],
}, },
{ main: {
id: "dismiss", confirm: "Confirm selection, continue, or submit",
kind: "command", cancel: "Cancel flow",
description: "Dismiss entire ask flow immediately", toggle: "Toggle selected option",
contexts: ["Anywhere"], 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",
}, },
{ editor: {
id: "toggle", submit: "Submit custom answer",
kind: "command", close: "Close editor and preserve draft",
description: "Toggle selected option", nextTabWhenEmpty: "Switch to next tab when editor is empty",
contexts: ["Questions"], 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",
}, },
{ noteEditor: {
id: "confirm", save: "Save note",
kind: "command", close: "Close note editor",
description: "Confirm selection, continue, save, or submit", nextTabWhenEmpty: "Switch to next tab when editor is empty",
contexts: ["Main flow", "Editors"], 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",
}, },
{ settingsModal: {
id: "optionNote", close: "Close settings",
kind: "command", nextOption: "Move to next setting",
description: "Edit selected option note", previousOption: "Move to previous setting",
contexts: ["Question tabs"], 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 = ( const footerHint = (
binding: AskKeyBinding, binding: AskKeyBinding,
@@ -213,6 +173,18 @@ const footerHint = (
) => `${label} ${action}`; ) => `${label} ${action}`;
export function formatKeybindingLabel(key: string): string { export function formatKeybindingLabel(key: string): string {
if (key === "up") {
return "↑";
}
if (key === "down") {
return "↓";
}
if (key === "left") {
return "←";
}
if (key === "right") {
return "→";
}
return key return key
.split("+") .split("+")
.map((part) => { .map((part) => {
@@ -227,6 +199,10 @@ export function formatKeybindingLabel(key: string): string {
.join("+"); .join("+");
} }
export function formatBindingLabel(keys: readonly string[]): string {
return keys.map(formatKeybindingLabel).join(" / ");
}
export function matchesBinding(data: string, binding: AskKeyBinding): boolean { export function matchesBinding(data: string, binding: AskKeyBinding): boolean {
return binding.keys.some((key) => { return binding.keys.some((key) => {
if (key.length === 1) { if (key.length === 1) {
@@ -242,33 +218,71 @@ export function matchesDigitShortcut(data: string): number | null {
export function getAskKeyBindings( export function getAskKeyBindings(
config: AskConfig config: AskConfig
): Record<AskKeyBindingId, AskKeyBinding> { ): Record<string, AskKeyBinding> {
const customizable: Record<AskConfigurableKeyAction, AskKeyBinding> = { const entries: [string, AskKeyBinding][] = [];
cancel: createCustomizableBinding(config, "cancel"), for (const context of Object.keys(config.keymaps) as AskKeymapContext[]) {
dismiss: createCustomizableBinding(config, "dismiss"), const actions = config.keymaps[context] as Record<
toggle: createCustomizableBinding(config, "toggle"), string,
confirm: createCustomizableBinding(config, "confirm"), readonly string[]
optionNote: createCustomizableBinding(config, "optionNote"), >;
questionNote: createCustomizableBinding(config, "questionNote"), for (const [action, keys] of Object.entries(actions)) {
}; const id = `${context}.${action}` as AskKeyBindingId;
const fixed = Object.fromEntries( entries.push([
FIXED_BINDING_METADATA.map((binding) => [ id,
binding.id, {
{ contexts: [CONTEXT_LABELS[context]],
...binding, description: DESCRIPTIONS[context][action] ?? action,
label: id,
binding.label ?? keys,
ACTION_LABEL_OVERRIDES[binding.id] ?? kind: "command",
binding.keys label: formatBindingLabel(keys),
.map((key) => formatKeybindingLabel(String(key))) },
.join(" / "), ]);
}, }
}
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<C extends AskKeymapContext>(
config: AskConfig,
context: C
): Record<AskKeymapAction<C>, AskKeyBinding> {
const bindings = getAskKeyBindings(config);
const actions = config.keymaps[context] as Record<string, readonly string[]>;
return Object.fromEntries(
Object.keys(actions).map((action) => [
action,
bindings[`${context}.${action}`],
]) ])
) as Record<AskFixedKeyBindingId, AskKeyBinding>; ) as Record<AskKeymapAction<C>, AskKeyBinding>;
return { }
...customizable,
...fixed, export function getGlobalBindings(
}; config: AskConfig
): Record<AskKeymapAction<"global">, AskKeyBinding> {
return getAskContextBindings(config, "global");
} }
export function getAskKeymaps(config: AskConfig): { export function getAskKeymaps(config: AskConfig): {
@@ -277,8 +291,10 @@ export function getAskKeymaps(config: AskConfig): {
} { } {
const bindings = getAskKeyBindings(config); const bindings = getAskKeyBindings(config);
return { return {
customizable: CONFIGURABLE_BINDING_METADATA.map(({ id }) => bindings[id]), customizable: Object.keys(bindings)
fixed: FIXED_BINDING_METADATA.map(({ id }) => bindings[id]), .filter((id) => id !== "numberShortcut" && id !== "fileReference")
.map((id) => bindings[id]),
fixed: [bindings.numberShortcut, bindings.fileReference],
}; };
} }
@@ -286,99 +302,260 @@ export function renderFooterKeymaps(
config: AskConfig, config: AskConfig,
context: FooterKeymapContext context: FooterKeymapContext
): string { ): 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 bindings = getAskKeyBindings(config);
const optionNavigationLabel = bindings.previousOption.label.replaceAll( const optionNavigationLabel =
" / ", `${main.previousOption.label}${main.nextOption.label}`.replaceAll(
"" " / ",
); ""
);
const tabNavigationLabel = "⇆"; const tabNavigationLabel = "⇆";
const noteNavigationLabel = `${bindings.optionNote.label}/${bindings.questionNote.label}`; const noteNavigationLabel = `${main.optionNote.label}/${main.questionNote.label}`;
const hintsByContext: Record<FooterKeymapContext, readonly string[]> = { const hintsByContext: Record<FooterKeymapContext, readonly string[]> = {
input: [ input: [
footerHint(bindings.confirm, "submit"), footerHint(editor.submit, "submit"),
footerHint(bindings.cancel, "close"), footerHint(editor.close, "close"),
footerHint(bindings.settings, "settings"), footerHint(global.settings, "settings"),
], ],
note: [ note: [
footerHint(bindings.confirm, "save"), footerHint(noteEditor.save, "save"),
footerHint(bindings.cancel, "close"), footerHint(noteEditor.close, "close"),
footerHint(bindings.settings, "settings"), footerHint(global.settings, "settings"),
], ],
submit: [ submit: [
footerHint(bindings.numberShortcut, "hotkeys"), footerHint(bindings.numberShortcut, "hotkeys"),
footerHint(bindings.previousOption, "select", optionNavigationLabel), footerHint(main.previousOption, "select", optionNavigationLabel),
footerHint(bindings.confirm, "confirm"), footerHint(main.confirm, "confirm"),
footerHint(bindings.cancel, "cancel"), footerHint(main.cancel, "cancel"),
footerHint(bindings.settings, "settings"), footerHint(global.settings, "settings"),
], ],
multi: [ multi: [
footerHint(bindings.nextTab, "tab", tabNavigationLabel), footerHint(main.nextTab, "tab", tabNavigationLabel),
footerHint(bindings.previousOption, "select", optionNavigationLabel), footerHint(main.previousOption, "select", optionNavigationLabel),
footerHint(bindings.toggle, "toggle"), footerHint(main.toggle, "toggle"),
footerHint(bindings.confirm, "continue"), footerHint(main.confirm, "continue"),
footerHint(bindings.optionNote, "note", noteNavigationLabel), footerHint(main.optionNote, "note", noteNavigationLabel),
footerHint(bindings.cancel, "dismiss"), footerHint(main.cancel, "dismiss"),
footerHint(bindings.settings, "settings"), footerHint(global.settings, "settings"),
], ],
default: [ default: [
footerHint(bindings.nextTab, "tab", tabNavigationLabel), footerHint(main.nextTab, "tab", tabNavigationLabel),
footerHint(bindings.previousOption, "select", optionNavigationLabel), footerHint(main.previousOption, "select", optionNavigationLabel),
footerHint(bindings.confirm, "confirm"), footerHint(main.confirm, "confirm"),
footerHint(bindings.optionNote, "note", noteNavigationLabel), footerHint(main.optionNote, "note", noteNavigationLabel),
footerHint(bindings.cancel, "dismiss"), footerHint(main.cancel, "dismiss"),
footerHint(bindings.settings, "settings"), footerHint(global.settings, "settings"),
], ],
}; };
return ` ${hintsByContext[context].join(" · ")}`; 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( export function normalizeConfiguredKeymaps(
keymaps: Partial<Record<AskConfigurableKeyAction, unknown>> | undefined keymaps: unknown
): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } { ): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } {
if (!keymaps) { const candidate = normalizeLegacyFlatKeymaps(keymaps) ?? keymaps;
return { ok: true, keymaps: { ...DEFAULT_ASK_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; const normalized = {} as AskConfigKeymaps;
for (const action of Object.keys( for (const context of Object.keys(
DEFAULT_ASK_KEYMAPS DEFAULT_ASK_KEYMAPS
) as AskConfigurableKeyAction[]) { ) as AskKeymapContext[]) {
const rawValue = keymaps[action]; const rawContext = (candidate as Record<string, unknown>)[context];
if (typeof rawValue !== "string") { if (!(rawContext && typeof rawContext === "object")) {
return { ok: false, error: `Missing keymap for ${action}.` }; return { ok: false, error: `Missing keymap context ${context}.` };
} }
const parsed = normalizeKeyId(rawValue); const contextResult = normalizeContextKeymaps(
if (!parsed.ok) { context,
return { rawContext as Record<string, unknown>
ok: false, );
error: `Invalid keymap for ${action}: ${parsed.error}`, if (!contextResult.ok) {
}; return contextResult;
} }
if (RESERVED_CONFIGURABLE_BINDINGS.has(parsed.keyId)) { normalized[context] = contextResult.keymaps as never;
return {
ok: false,
error: `Reserved keymap for ${action}: ${formatKeybindingLabel(parsed.keyId)}.`,
};
}
normalized[action] = parsed.keyId;
} }
const seen = new Map<string, AskConfigurableKeyAction>(); const globalDuplicates = validateCrossContextConflicts(normalized);
for (const action of Object.keys(normalized) as AskConfigurableKeyAction[]) { if (globalDuplicates) {
const key = normalized[action]; return { ok: false, error: globalDuplicates };
const existing = seen.get(key);
if (existing) {
return {
ok: false,
error: `Duplicate keymap ${formatKeybindingLabel(key)} for ${existing} and ${action}.`,
};
}
seen.set(key, action);
} }
return { ok: true, keymaps: normalized }; return { ok: true, keymaps: normalized };
} }
function normalizeContextKeymaps(
context: AskKeymapContext,
rawContext: Record<string, unknown>
):
| { ok: true; keymaps: Record<string, string[]> }
| { ok: false; error: string } {
const defaults = DEFAULT_ASK_KEYMAPS[context] as Record<
string,
readonly string[]
>;
const normalized: Record<string, string[]> = {};
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, readonly string[]>
): string | undefined {
const seen = new Map<string, string>();
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<string, readonly string[]>
).map((binding) => ({ ...binding, context }))
);
}
function flattenBindings(
bindings: Record<string, readonly string[]>
): 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<string, unknown>;
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( function normalizeKeyId(
rawKey: string rawKey: string
): { ok: true; keyId: string } | { ok: false; error: string } { ): { ok: true; keyId: string } | { ok: false; error: string } {
@@ -418,27 +595,48 @@ function normalizeKeyId(
const modifierPrefix = MODIFIER_ORDER.filter((modifier) => const modifierPrefix = MODIFIER_ORDER.filter((modifier) =>
modifiers.has(modifier) modifiers.has(modifier)
); );
return { return { ok: true, keyId: [...modifierPrefix, baseKey].join("+") };
ok: true,
keyId: [...modifierPrefix, baseKey].join("+"),
};
} }
function createCustomizableBinding( function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
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];
return { return {
...binding, global: {
keys: [key], dismiss: [...keymaps.global.dismiss],
label: formatKeybindingLabel(key), 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],
},
}; };
} }
+16
View File
@@ -172,6 +172,7 @@ function renderController(
} }
function handleControllerInput(controller: AskFlowController, data: string) { function handleControllerInput(controller: AskFlowController, data: string) {
controller.editor.disableSubmit = !isNativeEditorSubmitEnabled(controller);
const command = getInputCommand( const command = getInputCommand(
controller.state, controller.state,
controller.config, controller.config,
@@ -185,6 +186,16 @@ function handleControllerInput(controller: AskFlowController, data: string) {
handleNavigationCommand(controller, command); 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( function handleEditingCommand(
controller: AskFlowController, controller: AskFlowController,
command: AskInputCommand, command: AskInputCommand,
@@ -210,6 +221,10 @@ function handleEditingCommand(
closeEditor(controller); closeEditor(controller);
return; return;
} }
if (command.kind === "editSubmit") {
submitEditor(controller, controller.editor.getText());
return;
}
if (command.kind === "delegateToEditor") { if (command.kind === "delegateToEditor") {
controller.editor.handleInput(data); controller.editor.handleInput(data);
refresh(controller); refresh(controller);
@@ -270,6 +285,7 @@ function handleNavigationCommand(
case "editMoveTab": case "editMoveTab":
case "editMoveOption": case "editMoveOption":
case "editClose": case "editClose":
case "editSubmit":
case "delegateToEditor": case "delegateToEditor":
return; return;
default: default:
+63 -15
View File
@@ -1,6 +1,9 @@
import { matchesKey } from "@mariozechner/pi-tui";
import type { AskConfig } from "../config/schema.ts"; import type { AskConfig } from "../config/schema.ts";
import { import {
getAskKeyBindings, type AskKeyBinding,
getAskContextBindings,
getGlobalBindings,
matchesBinding, matchesBinding,
matchesDigitShortcut, matchesDigitShortcut,
} from "../constants/keymaps.ts"; } from "../constants/keymaps.ts";
@@ -20,6 +23,7 @@ export type AskInputCommand =
| { kind: "editMoveTab"; delta: 1 | -1 } | { kind: "editMoveTab"; delta: 1 | -1 }
| { kind: "editMoveOption"; delta: 1 | -1 } | { kind: "editMoveOption"; delta: 1 | -1 }
| { kind: "editClose" } | { kind: "editClose" }
| { kind: "editSubmit" }
| { kind: "delegateToEditor" } | { kind: "delegateToEditor" }
| { kind: "ignore" }; | { kind: "ignore" };
@@ -29,50 +33,94 @@ export function getInputCommand(
data: string, data: string,
editingText = "" editingText = ""
): AskInputCommand { ): AskInputCommand {
const bindings = getAskKeyBindings(config); const global = getGlobalBindings(config);
if (matchesBinding(data, bindings.dismiss)) { if (matchesBinding(data, global.dismiss)) {
return { kind: "dismiss" }; return { kind: "dismiss" };
} }
if (matchesBinding(data, bindings.settings) && editingText.length === 0) { if (matchesBinding(data, global.settings) && editingText.length === 0) {
return { kind: "showSettings" }; return { kind: "showSettings" };
} }
if (state.view.kind === "input" || state.view.kind === "note") { if (state.view.kind === "input") {
return getEditingInputCommand(bindings, data, editingText); 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( function getAnswerEditorInputCommand(
bindings: ReturnType<typeof getAskKeyBindings>, config: AskConfig,
data: string, data: string,
editingText: string editingText: string
): AskInputCommand { ): 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 { 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 (editingText.length === 0) {
if (matchesBinding(data, bindings.nextTab)) { if (matchesBinding(data, bindings.nextTabWhenEmpty)) {
return { kind: "editMoveTab", delta: 1 }; return { kind: "editMoveTab", delta: 1 };
} }
if (matchesBinding(data, bindings.previousTab)) { if (matchesBinding(data, bindings.previousTabWhenEmpty)) {
return { kind: "editMoveTab", delta: -1 }; return { kind: "editMoveTab", delta: -1 };
} }
if (matchesBinding(data, bindings.previousOption)) { if (matchesBinding(data, bindings.previousOptionWhenEmpty)) {
return { kind: "editMoveOption", delta: -1 }; return { kind: "editMoveOption", delta: -1 };
} }
if (matchesBinding(data, bindings.nextOption)) { if (matchesBinding(data, bindings.nextOptionWhenEmpty)) {
return { kind: "editMoveOption", delta: 1 }; return { kind: "editMoveOption", delta: 1 };
} }
} }
return { kind: "delegateToEditor" }; return { kind: "delegateToEditor" };
} }
function isNativeEditorSubmit(data: string): boolean {
return matchesKey(data, "enter");
}
function getNavigationInputCommand( function getNavigationInputCommand(
bindings: ReturnType<typeof getAskKeyBindings>, config: AskConfig,
data: string data: string
): AskInputCommand { ): AskInputCommand {
const bindings = getAskContextBindings(config, "main");
if (matchesBinding(data, bindings.nextTab)) { if (matchesBinding(data, bindings.nextTab)) {
return { kind: "moveTab", delta: 1 }; return { kind: "moveTab", delta: 1 };
} }
+15 -15
View File
@@ -1,12 +1,15 @@
import { import {
Key,
matchesKey,
truncateToWidth, truncateToWidth,
visibleWidth, visibleWidth,
wrapTextWithAnsi, wrapTextWithAnsi,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import type { AskConfig } from "../config/schema.ts"; import type { AskConfig } from "../config/schema.ts";
import type { AskConfigNotice } from "../config/store.ts"; import type { AskConfigNotice } from "../config/store.ts";
import {
getAskContextBindings,
matchesBinding,
renderSettingsFooterKeymaps,
} from "../constants/keymaps.ts";
interface Theme { interface Theme {
bg(color: string, text: string): string; bg(color: string, text: string): string;
@@ -86,26 +89,23 @@ export class AskSettingsList {
} }
handleInput(data: string): void { handleInput(data: string): void {
if ( const bindings = getAskContextBindings(this.config, "settingsModal");
data === "?" || if (matchesBinding(data, bindings.close)) {
matchesKey(data, Key.ctrl("c")) ||
matchesKey(data, Key.escape)
) {
this.close(); this.close();
return; return;
} }
if (matchesKey(data, Key.up)) { if (matchesBinding(data, bindings.previousOption)) {
this.focusIndex = this.focusIndex =
this.focusIndex === 0 ? SETTINGS.length - 1 : this.focusIndex - 1; this.focusIndex === 0 ? SETTINGS.length - 1 : this.focusIndex - 1;
this.tui.requestRender(); this.tui.requestRender();
return; return;
} }
if (matchesKey(data, Key.down)) { if (matchesBinding(data, bindings.nextOption)) {
this.focusIndex = (this.focusIndex + 1) % SETTINGS.length; this.focusIndex = (this.focusIndex + 1) % SETTINGS.length;
this.tui.requestRender(); this.tui.requestRender();
return; return;
} }
if (matchesKey(data, Key.enter) || data === " ") { if (matchesBinding(data, bindings.toggle)) {
const setting = SETTINGS[this.focusIndex]; const setting = SETTINGS[this.focusIndex];
if (setting) { if (setting) {
this.saveSetting(setting.key, !this.getSettingValue(setting.key)); this.saveSetting(setting.key, !this.getSettingValue(setting.key));
@@ -175,15 +175,15 @@ export class AskSettingsList {
private appendFooter(lines: string[], innerWidth: number): void { private appendFooter(lines: string[], innerWidth: number): void {
if (innerWidth < COMPACT_WIDTH) { if (innerWidth < COMPACT_WIDTH) {
lines.push( const footer = renderSettingsFooterKeymaps(this.config);
this.line(this.theme.fg("dim", " Enter/Space to change"), innerWidth) for (const line of wrapTextWithAnsi(footer, innerWidth - 2)) {
); lines.push(this.line(this.theme.fg("dim", ` ${line}`), innerWidth));
lines.push(this.line(this.theme.fg("dim", " Esc to close"), innerWidth)); }
return; return;
} }
this.appendWrapped( this.appendWrapped(
lines, lines,
this.theme.fg("dim", "Enter/Space to change · Esc to close"), this.theme.fg("dim", renderSettingsFooterKeymaps(this.config)),
innerWidth innerWidth
); );
} }
+80
View File
@@ -19,6 +19,86 @@ test("config migration framework accepts the current schema version", () => {
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG); 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", () => { test("config migration framework rejects unsupported future schema versions", () => {
assert.throws( assert.throws(
() => () =>
+1 -1
View File
@@ -13,7 +13,7 @@ function expectedConfigFile(
overrides: { behaviour?: typeof DEFAULT_ASK_CONFIG.behaviour } = {} overrides: { behaviour?: typeof DEFAULT_ASK_CONFIG.behaviour } = {}
) { ) {
return { return {
schemaVersion: 3, schemaVersion: 4,
answer: DEFAULT_ASK_CONFIG.answer, answer: DEFAULT_ASK_CONFIG.answer,
behaviour: overrides.behaviour ?? DEFAULT_ASK_CONFIG.behaviour, behaviour: overrides.behaviour ?? DEFAULT_ASK_CONFIG.behaviour,
keymaps: DEFAULT_ASK_CONFIG.keymaps, keymaps: DEFAULT_ASK_CONFIG.keymaps,
+100 -2
View File
@@ -1,11 +1,13 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts"; import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
import { getAskConfigStore } from "../src/config/store.ts";
import { createInitialState } from "../src/state/create.ts"; import { createInitialState } from "../src/state/create.ts";
import { import {
applyNumberShortcut, applyNumberShortcut,
enterQuestionNoteMode, enterQuestionNoteMode,
} from "../src/state/transitions.ts"; } from "../src/state/transitions.ts";
import { runAskFlow } from "../src/ui/controller.ts";
import { getInputCommand } from "../src/ui/input.ts"; import { getInputCommand } from "../src/ui/input.ts";
function inputState() { 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", () => { test("custom configured note shortcuts are used at runtime", () => {
const navigation = createInitialState({ const navigation = createInitialState({
questions: [ questions: [
@@ -221,8 +316,11 @@ test("custom configured note shortcuts are used at runtime", () => {
...DEFAULT_ASK_CONFIG, ...DEFAULT_ASK_CONFIG,
keymaps: { keymaps: {
...DEFAULT_ASK_CONFIG.keymaps, ...DEFAULT_ASK_CONFIG.keymaps,
optionNote: "x", main: {
questionNote: "shift+x", ...DEFAULT_ASK_CONFIG.keymaps.main,
optionNote: ["x"],
questionNote: ["shift+x"],
},
}, },
}; };
+5 -2
View File
@@ -90,8 +90,11 @@ test("editing footers use configured key labels", () => {
...DEFAULT_ASK_CONFIG, ...DEFAULT_ASK_CONFIG,
keymaps: { keymaps: {
...DEFAULT_ASK_CONFIG.keymaps, ...DEFAULT_ASK_CONFIG.keymaps,
confirm: "ctrl+k", noteEditor: {
cancel: "q", ...DEFAULT_ASK_CONFIG.keymaps.noteEditor,
save: ["ctrl+k"],
close: ["q"],
},
}, },
}; };
+35 -11
View File
@@ -15,14 +15,7 @@ const savedConfig: AskConfig = {
doublePressReviewShortcuts: true, doublePressReviewShortcuts: true,
showFooterHints: true, showFooterHints: true,
}, },
keymaps: { keymaps: DEFAULT_ASK_CONFIG.keymaps,
cancel: "esc",
dismiss: "ctrl+c",
toggle: "space",
confirm: "enter",
optionNote: "n",
questionNote: "shift+n",
},
notifications: { notifications: {
channels: ["bell"], channels: ["bell"],
enabled: true, enabled: true,
@@ -44,6 +37,7 @@ function createList(
options: { options: {
onClose?: () => void; onClose?: () => void;
onSave?: (config: AskConfig) => Promise<AskConfig>; onSave?: (config: AskConfig) => Promise<AskConfig>;
savedConfig?: AskConfig;
} = {} } = {}
) { ) {
const onClose = const onClose =
@@ -56,7 +50,7 @@ function createList(
notice: undefined, notice: undefined,
onClose, onClose,
onSave: options.onSave ?? ((config) => Promise.resolve(config)), onSave: options.onSave ?? ((config) => Promise.resolve(config)),
savedConfig, savedConfig: options.savedConfig ?? savedConfig,
tui: { tui: {
requestRender() { requestRender() {
// no-op in tests // no-op in tests
@@ -80,7 +74,7 @@ test("settings list renders behaviour settings and config path", () => {
assert(text.includes("notifications")); assert(text.includes("notifications"));
assert(text.includes("extraction settings")); assert(text.includes("extraction settings"));
assert(text.includes("/tmp/eko24ive-pi-ask.json")); 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("Esc to cancel"), false);
assert.equal(text.includes("Keymaps"), false); assert.equal(text.includes("Keymaps"), false);
assert.equal(text.includes("Ctrl+S"), 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]")); 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; let closed = 0;
const list = createList({ const list = createList({
onClose: () => { onClose: () => {