feat: add context-aware ask keymaps
This commit is contained in:
@@ -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 |
|
|
||||||
| `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..9` | Options list | Select or toggle matching option |
|
||||||
| `1` `2` `3` | Review tab | Trigger `Submit` / `Elaborate` / `Cancel` |
|
| `1` `2` `3` | Review tab | Trigger `Submit` / `Elaborate` / `Cancel` |
|
||||||
| `↑` `↓` | Review tab | Change highlighted review action |
|
| `@` | Editors | File-reference affordance |
|
||||||
| `↑` `↓` | 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 |
|
| 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,
|
||||||
|
|||||||
@@ -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/`
|
||||||
|
|||||||
+137
-65
@@ -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:
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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");
|
||||||
|
|||||||
+424
-226
@@ -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,
|
|
||||||
{
|
{
|
||||||
...binding,
|
contexts: [CONTEXT_LABELS[context]],
|
||||||
label:
|
description: DESCRIPTIONS[context][action] ?? action,
|
||||||
binding.label ??
|
id,
|
||||||
ACTION_LABEL_OVERRIDES[binding.id] ??
|
keys,
|
||||||
binding.keys
|
kind: "command",
|
||||||
.map((key) => formatKeybindingLabel(String(key)))
|
label: formatBindingLabel(keys),
|
||||||
.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],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -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
@@ -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"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user