feat: add config-backed ask keymaps
This commit is contained in:
@@ -35,7 +35,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 (also mobile-friendly in remote sessions)
|
- ⌨️ Fast keyboard-first interaction with customizable ask keymaps (also mobile-friendly in remote sessions)
|
||||||
|
- ⚙️ Ask settings with persisted behaviour and keymap customization via config
|
||||||
|
- 🗣️ You can talk to your agent to configure pi-ask; it will read the bundled configuration guide and tailor the config for you
|
||||||
|
|
||||||
## Feature walkthrough
|
## Feature walkthrough
|
||||||
|
|
||||||
@@ -89,7 +91,22 @@ Ask the agent to elaborate on notes before finalizing choices.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Key bindings
|
## Default key bindings
|
||||||
|
|
||||||
|
Open the ask settings modal with `?` during the ask flow, or with the `/ask-settings` command from pi.
|
||||||
|
|
||||||
|
Customizable via config:
|
||||||
|
|
||||||
|
| Action | Default key |
|
||||||
|
|---|---|
|
||||||
|
| Cancel flow or close/save editor draft | `Esc` |
|
||||||
|
| Dismiss entire ask flow immediately | `Ctrl+C` |
|
||||||
|
| Toggle selected option | `Space` |
|
||||||
|
| Confirm / continue / save / submit | `Enter` |
|
||||||
|
| Edit selected option note | `n` |
|
||||||
|
| Edit question note | `Shift+N` |
|
||||||
|
|
||||||
|
Fixed bindings:
|
||||||
|
|
||||||
| Key | Context | Effect |
|
| Key | Context | Effect |
|
||||||
|-----------------------------|-----------------------------------------|---------------------------------------------|
|
|-----------------------------|-----------------------------------------|---------------------------------------------|
|
||||||
@@ -98,27 +115,42 @@ Ask the agent to elaborate on notes before finalizing choices.
|
|||||||
| `←` `→` | Main flow | Switch tabs |
|
| `←` `→` | Main flow | Switch tabs |
|
||||||
| `↑` `↓` | Main flow | Move cursor |
|
| `↑` `↓` | Main flow | Move cursor |
|
||||||
| `1..9` | Options list | Select or toggle matching option |
|
| `1..9` | Options list | Select or toggle matching option |
|
||||||
| `Space` | Single- or multi-select option | Toggle selection |
|
|
||||||
| `Enter` | Single-select option | Confirm and continue |
|
|
||||||
| `Enter` | Multi-select option | Continue |
|
|
||||||
| `n` | Active option | Edit option note |
|
|
||||||
| `Shift+N` | Current question | Edit question note |
|
|
||||||
| `1` `2` `3` | Review tab | Trigger `Submit` / `Elaborate` / `Cancel` |
|
| `1` `2` `3` | Review tab | Trigger `Submit` / `Elaborate` / `Cancel` |
|
||||||
| `↑` `↓` | Review tab | Change highlighted review action |
|
| `↑` `↓` | Review tab | Change highlighted review action |
|
||||||
| `Enter` | Review tab | Confirm highlighted review action |
|
|
||||||
| `Enter` | Free-form answer editor | Save and submit current input |
|
|
||||||
| `Enter` | Note editor | Save current note and return to navigation |
|
|
||||||
| `Esc` | Free-form or note editor | Save draft and close editor |
|
|
||||||
| `Ctrl+C` | Anywhere | Dismiss entire ask flow immediately |
|
|
||||||
| `↑` `↓` | Empty editor | Move options without closing editor |
|
| `↑` `↓` | Empty editor | Move options without closing editor |
|
||||||
| `Tab` `Shift+Tab` / `←` `→` | Empty editor | Switch tabs 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 |
|
||||||
|
|
||||||
|
Config file: `~/.pi/.../extensions/eko24ive-pi-ask.json`
|
||||||
|
|
||||||
|
You can edit this file yourself, ask pi to edit it for you, or use the read-only `Keymaps` tab to find the exact path and active bindings.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"behaviour": {
|
||||||
|
"autoSubmitWhenAnsweredWithoutNotes": false
|
||||||
|
},
|
||||||
|
"keymaps": {
|
||||||
|
"cancel": "esc",
|
||||||
|
"dismiss": "ctrl+c",
|
||||||
|
"toggle": "space",
|
||||||
|
"confirm": "enter",
|
||||||
|
"optionNote": "n",
|
||||||
|
"questionNote": "shift+n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted notation follows pi-tui key ids. Common aliases are normalized, for example `escape` → `esc`, `return` → `enter`, `control+c` → `ctrl+c`, and `Shift+N` → `shift+n`.
|
||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
After installation, the extension registers the `ask_user` tool and `/ask-settings` command.
|
After installation, the extension registers the `ask_user` tool and `/ask-settings` command.
|
||||||
|
|
||||||
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. `/ask-settings` opens the same modal used by `?`, with `Keymaps` and `Behaviour` tabs.
|
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. The ask settings modal is available both from `?` in the ask flow and from the `/ask-settings` command, with `Keymaps` and `Behaviour` tabs. `Keymaps` is read-only and shows the active resolved bindings plus the config file path. Behaviour settings are saved explicitly with `Ctrl+S`.
|
||||||
|
|
||||||
|
You can also talk to pi to configure this extension. When asked to customize pi-ask settings or keymaps, the agent is instructed to read the bundled `docs/configuration.md` guide first and then edit the config file accordingly.
|
||||||
|
|
||||||
This package also bundles the `ask-user` skill profile from `skills/ask-user/SKILL.md`. It reinforces when to use the tool, is enabled by default when installed, and can be disabled via `pi config`. The skill was inspired by https://github.com/edlsh/pi-ask-user.
|
This package also bundles the `ask-user` skill profile from `skills/ask-user/SKILL.md`. It reinforces when to use the tool, is enabled by default when installed, and can be disabled via `pi config`. The skill was inspired by https://github.com/edlsh/pi-ask-user.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -4,13 +4,15 @@ This folder keeps only the documentation needed to understand and maintain the e
|
|||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
- `configuration.md` — source of truth for configuring pi-ask keymaps and behaviour
|
||||||
- `contract.md` — external behavior, full tool payload/result details, and UX guarantees
|
- `contract.md` — external behavior, full tool payload/result details, and UX guarantees
|
||||||
- `architecture.md` — module boundaries and invariants
|
- `architecture.md` — module boundaries and invariants
|
||||||
- `../skills/ask-user/SKILL.md` — auto-bundled agent-side decision-gate guidance; enabled by default, but can be disabled via `pi config`; inspired by https://github.com/edlsh/pi-ask-user
|
- `../skills/ask-user/SKILL.md` — auto-bundled agent-side decision-gate guidance; enabled by default, but can be disabled via `pi config`; inspired by https://github.com/edlsh/pi-ask-user
|
||||||
|
|
||||||
## Reading order
|
## Reading order
|
||||||
|
|
||||||
- start with `contract.md` for behavior
|
- start with `configuration.md` for config-editing rules
|
||||||
|
- read `contract.md` for behavior
|
||||||
- read `architecture.md` for code layout
|
- read `architecture.md` for code layout
|
||||||
|
|
||||||
## Local test run
|
## Local test run
|
||||||
|
|||||||
+16
-3
@@ -28,11 +28,20 @@ The codebase is split so the implementation reads through file boundaries and na
|
|||||||
- `src/state/view.ts` — view-mode helpers
|
- `src/state/view.ts` — view-mode helpers
|
||||||
- `src/state.ts` — state barrel used by UI and tests
|
- `src/state.ts` — state barrel used by UI and tests
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
- `src/config/defaults.ts` — canonical runtime defaults
|
||||||
|
- `src/config/schema.ts` — persisted config schema and runtime type
|
||||||
|
- `src/config/migrate.ts` — persisted-file validation/migration boundary, including keymap normalization/fallback
|
||||||
|
- `src/config/store.ts` — load/save/backup/runtime subscription store
|
||||||
|
|
||||||
### UI
|
### UI
|
||||||
|
|
||||||
- `src/ui/controller.ts` — connects key input, editor lifecycle, and pure state transitions
|
- `src/ui/controller.ts` — connects key input, editor lifecycle, live config subscription, and pure state transitions
|
||||||
- `src/ui/input.ts` — raw input to commands
|
- `src/ui/input.ts` — raw input to commands using resolved config-backed keymaps
|
||||||
- `src/ui/render.ts` and `src/ui/render-*.ts` — screen rendering
|
- `src/ui/render.ts` and `src/ui/render-*.ts` — screen rendering, including config-backed footer/keymap hints
|
||||||
|
- `src/ui/settings-state.ts` — pure settings-modal draft/dirty/notice state
|
||||||
|
- `src/ui/settings-modal.ts` / `src/ui/show-settings-modal.ts` — ask settings modal rendering and launcher
|
||||||
- `src/ui/constants.ts` and `src/ui/render-types.ts` — rendering constants/contracts
|
- `src/ui/constants.ts` and `src/ui/render-types.ts` — rendering constants/contracts
|
||||||
- `src/ask-component.ts` — thin custom UI export
|
- `src/ask-component.ts` — thin custom UI export
|
||||||
|
|
||||||
@@ -59,6 +68,10 @@ The codebase is split so the implementation reads through file boundaries and na
|
|||||||
- deselected option notes stay in UI state
|
- deselected option notes stay in UI state
|
||||||
- only selected option notes are emitted in the final result
|
- only selected option notes are emitted in the final result
|
||||||
- editor lifecycle stays in the controller, not in the reducers
|
- editor lifecycle stays in the controller, not in the reducers
|
||||||
|
- persisted ask settings are versioned and normalized before use
|
||||||
|
- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour settings
|
||||||
|
- settings modal edits a local draft and only persists on explicit save
|
||||||
|
- live config updates can affect an in-progress ask flow immediately
|
||||||
|
|
||||||
## Documentation rule
|
## Documentation rule
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# pi-ask configuration
|
||||||
|
|
||||||
|
This file is the source of truth for configuring `@eko24ive/pi-ask`.
|
||||||
|
|
||||||
|
When changing pi-ask settings:
|
||||||
|
|
||||||
|
1. Edit the config file.
|
||||||
|
2. Validate keymaps against the rules below.
|
||||||
|
3. Run `/reload` or restart pi so the new config is picked up.
|
||||||
|
|
||||||
|
## Config file path
|
||||||
|
|
||||||
|
Default path:
|
||||||
|
|
||||||
|
`~/.pi/agent/extensions/eko24ive-pi-ask.json`
|
||||||
|
|
||||||
|
## Config shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"behaviour": {
|
||||||
|
"autoSubmitWhenAnsweredWithoutNotes": false
|
||||||
|
},
|
||||||
|
"keymaps": {
|
||||||
|
"cancel": "esc",
|
||||||
|
"dismiss": "ctrl+c",
|
||||||
|
"toggle": "space",
|
||||||
|
"confirm": "enter",
|
||||||
|
"optionNote": "n",
|
||||||
|
"questionNote": "shift+n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
### `behaviour.autoSubmitWhenAnsweredWithoutNotes`
|
||||||
|
|
||||||
|
- type: boolean
|
||||||
|
- default: `false`
|
||||||
|
- effect: when enabled, a fully answered ask flow with no notes can auto-submit from the review tab
|
||||||
|
|
||||||
|
## Keymaps
|
||||||
|
|
||||||
|
`keymaps` must contain all 6 configurable actions if present.
|
||||||
|
|
||||||
|
### Configurable actions
|
||||||
|
|
||||||
|
- `cancel`
|
||||||
|
- `dismiss`
|
||||||
|
- `toggle`
|
||||||
|
- `confirm`
|
||||||
|
- `optionNote`
|
||||||
|
- `questionNote`
|
||||||
|
|
||||||
|
### Defaults
|
||||||
|
|
||||||
|
- `cancel: "esc"`
|
||||||
|
- `dismiss: "ctrl+c"`
|
||||||
|
- `toggle: "space"`
|
||||||
|
- `confirm: "enter"`
|
||||||
|
- `optionNote: "n"`
|
||||||
|
- `questionNote: "shift+n"`
|
||||||
|
|
||||||
|
### Allowed bindings
|
||||||
|
|
||||||
|
Each configurable action accepts any single `pi-tui` key id string, as long as it is:
|
||||||
|
|
||||||
|
- supported by `pi-tui`
|
||||||
|
- not reserved
|
||||||
|
- not duplicated across configurable actions
|
||||||
|
|
||||||
|
Examples of valid bindings:
|
||||||
|
|
||||||
|
- `esc`
|
||||||
|
- `ctrl+c`
|
||||||
|
- `space`
|
||||||
|
- `enter`
|
||||||
|
- `n`
|
||||||
|
- `shift+n`
|
||||||
|
- `alt+f7`
|
||||||
|
- `ctrl+[`
|
||||||
|
- `ctrl+shift+p`
|
||||||
|
- `super+k`
|
||||||
|
|
||||||
|
### Accepted alias normalization
|
||||||
|
|
||||||
|
Common aliases are normalized to canonical `pi-tui`-style strings.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `escape` -> `esc`
|
||||||
|
- `return` -> `enter`
|
||||||
|
- `control+c` -> `ctrl+c`
|
||||||
|
- `Shift+N` -> `shift+n`
|
||||||
|
- `pageup` -> `pageUp`
|
||||||
|
- `pagedown` -> `pageDown`
|
||||||
|
|
||||||
|
### Reserved bindings
|
||||||
|
|
||||||
|
These bindings are fixed and cannot be used by configurable actions:
|
||||||
|
|
||||||
|
- `?`
|
||||||
|
- `tab`
|
||||||
|
- `shift+tab`
|
||||||
|
- `left`
|
||||||
|
- `right`
|
||||||
|
- `up`
|
||||||
|
- `down`
|
||||||
|
- `1`
|
||||||
|
- `2`
|
||||||
|
- `3`
|
||||||
|
- `4`
|
||||||
|
- `5`
|
||||||
|
- `6`
|
||||||
|
- `7`
|
||||||
|
- `8`
|
||||||
|
- `9`
|
||||||
|
|
||||||
|
### Fixed bindings
|
||||||
|
|
||||||
|
These are intentionally not configurable:
|
||||||
|
|
||||||
|
- `?` opens ask settings
|
||||||
|
- `tab`, `shift+tab`, `left`, `right` move between tabs
|
||||||
|
- `up`, `down` move between options/actions
|
||||||
|
- `1..9` triggers option/review shortcuts
|
||||||
|
- `@` remains file-reference affordance in editors
|
||||||
|
|
||||||
|
## Invalid keymaps behavior
|
||||||
|
|
||||||
|
If configured keymaps are invalid:
|
||||||
|
|
||||||
|
- valid `behaviour` settings still load
|
||||||
|
- invalid `keymaps` fall back to default keymaps for the current session
|
||||||
|
- ask remains usable
|
||||||
|
- a warning notice is shown
|
||||||
|
- after fixing the file, run `/reload` or restart pi
|
||||||
|
|
||||||
|
Invalid keymaps include:
|
||||||
|
|
||||||
|
- missing one of the 6 required actions
|
||||||
|
- unsupported key syntax
|
||||||
|
- duplicate bindings across configurable actions
|
||||||
|
- use of reserved bindings
|
||||||
|
|
||||||
|
## Example custom config
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"behaviour": {
|
||||||
|
"autoSubmitWhenAnsweredWithoutNotes": true
|
||||||
|
},
|
||||||
|
"keymaps": {
|
||||||
|
"cancel": "q",
|
||||||
|
"dismiss": "ctrl+c",
|
||||||
|
"toggle": "ctrl+t",
|
||||||
|
"confirm": "ctrl+k",
|
||||||
|
"optionNote": "x",
|
||||||
|
"questionNote": "shift+x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent editing rule
|
||||||
|
|
||||||
|
When editing this config for a user:
|
||||||
|
|
||||||
|
- preserve unrelated fields
|
||||||
|
- keep `schemaVersion` at `1`
|
||||||
|
- do not assign reserved bindings to configurable actions
|
||||||
|
- do not create duplicate configurable bindings
|
||||||
|
- after changing the file, tell the user to run `/reload` or restart pi
|
||||||
+16
-13
@@ -199,8 +199,12 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
- on the review tab, `Submit` and `Cancel` preview notes only for answered questions
|
- on the review tab, `Submit` and `Cancel` preview notes only for answered questions
|
||||||
- on the review tab, `Elaborate` preview expands to all question notes and all option notes, including notes on unselected options
|
- on the review tab, `Elaborate` preview expands to all question notes and all option notes, including notes on unselected options
|
||||||
- transcript-friendly call and result rendering
|
- transcript-friendly call and result rendering
|
||||||
- ask settings modal with `Keymaps` and `Behaviour` tabs; `Behaviour` may be empty while unimplemented
|
- ask settings modal with `Keymaps` and `Behaviour` tabs
|
||||||
- `/ask-settings` opens the same ask settings modal as `?`
|
- `?` in the ask flow and `/ask-settings` in pi open the same ask settings modal
|
||||||
|
- `Behaviour` exposes `Auto-submit when answered without notes` as a persisted user setting
|
||||||
|
- `Keymaps` is a persisted config section for `cancel`, `dismiss`, `toggle`, `confirm`, `optionNote`, and `questionNote`
|
||||||
|
- the `Keymaps` tab is read-only and shows the active bindings plus the config file path
|
||||||
|
- 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?"`
|
||||||
|
|
||||||
## Keyboard behavior
|
## Keyboard behavior
|
||||||
@@ -208,25 +212,22 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
Main flow:
|
Main flow:
|
||||||
|
|
||||||
- `?`: open the ask settings modal on the `Keymaps` tab
|
- `?`: open the ask settings modal on the `Keymaps` tab
|
||||||
|
- inside the ask settings modal, `Esc`, `Ctrl+C`, and `?` close it; dirty drafts require a second close action within a short window to discard unsaved changes
|
||||||
- `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs
|
- `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs
|
||||||
- `Up`, `Down`: move between options
|
- `Up`, `Down`: move between options
|
||||||
- `Enter`: confirm or submit
|
|
||||||
- `Esc`: cancel the flow
|
|
||||||
- `Ctrl+C`: dismiss the entire flow immediately, even while editing a note or free-form answer
|
|
||||||
- `1..9`: select or toggle the matching option; on the review tab, `1`, `2`, and `3` trigger `Submit`, `Elaborate`, and `Cancel`
|
- `1..9`: select or toggle the matching option; on the review tab, `1`, `2`, and `3` trigger `Submit`, `Elaborate`, and `Cancel`
|
||||||
- `Space`: toggle the active option
|
- 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
|
||||||
- `n`: edit the active option note
|
|
||||||
- `Shift+N`: edit the current question note
|
|
||||||
|
|
||||||
Editing flow:
|
Editing flow:
|
||||||
|
|
||||||
- `Enter`: submit the current editor input and close the editor; in note editors this saves the note only and keeps the ask flow open
|
- 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
|
||||||
- `Esc`: save draft and close the editor
|
- the configurable `cancel` binding saves draft and closes the editor
|
||||||
- `Ctrl+C`: dismiss the entire flow immediately without saving the current editor draft
|
- the configurable `dismiss` binding dismisses the entire flow immediately without saving the current editor draft
|
||||||
- `?`: open the ask settings modal on the `Keymaps` tab when the editor is empty; otherwise enter `?` as text
|
- `?`: open the ask settings modal on the `Keymaps` tab when the editor is empty; otherwise enter `?` as text
|
||||||
|
- inside the ask settings modal, `Ctrl+S` saves the current draft config without closing the modal
|
||||||
- when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing
|
- 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 `Esc` first
|
- 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 `Esc`, unless the editor is empty and the navigation keys above are used
|
- 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
|
||||||
|
|
||||||
@@ -234,6 +235,8 @@ If `ctx.hasUI === false`, the tool returns a `Needs user input` message in `cont
|
|||||||
|
|
||||||
Validation is handled inside the tool so malformed calls produce the same structured error shape as other invalid payloads instead of relying on pre-execution schema failures.
|
Validation is handled inside the tool so malformed calls produce the same structured error shape as other invalid payloads instead of relying on pre-execution schema failures.
|
||||||
|
|
||||||
|
The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes` or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks.
|
||||||
|
|
||||||
The fallback message includes normalized pending questions and options so the caller can re-ask them manually. `details.questions` still contains normalized question metadata, while `details.answers` stays empty until a user responds.
|
The fallback message includes normalized pending questions and options so the caller can re-ask them manually. `details.questions` still contains normalized question metadata, while `details.answers` stays empty until a user responds.
|
||||||
|
|
||||||
## Skill alignment (advisory)
|
## Skill alignment (advisory)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"src/",
|
"src/",
|
||||||
"skills/",
|
"skills/",
|
||||||
|
"docs/configuration.md",
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"CHANGELOG.md"
|
"CHANGELOG.md"
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts";
|
||||||
|
import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
|
||||||
|
|
||||||
|
export const DEFAULT_ASK_CONFIG: AskConfig = {
|
||||||
|
behaviour: {
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||||
|
},
|
||||||
|
keymaps: {
|
||||||
|
...DEFAULT_ASK_KEYMAPS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeAskConfig(
|
||||||
|
config?: Partial<AskConfigFileV1> | AskConfig
|
||||||
|
): AskConfig {
|
||||||
|
return {
|
||||||
|
behaviour: {
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes:
|
||||||
|
config?.behaviour?.autoSubmitWhenAnsweredWithoutNotes ??
|
||||||
|
DEFAULT_ASK_CONFIG.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
||||||
|
},
|
||||||
|
keymaps: {
|
||||||
|
...DEFAULT_ASK_CONFIG.keymaps,
|
||||||
|
...(config?.keymaps ?? {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
|
||||||
|
const normalized = normalizeAskConfig(config);
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
behaviour: {
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes:
|
||||||
|
normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
type AskConfigurableKeyAction,
|
||||||
|
normalizeConfiguredKeymaps,
|
||||||
|
} from "../constants/keymaps.ts";
|
||||||
|
import { normalizeAskConfig } from "./defaults.ts";
|
||||||
|
import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
|
||||||
|
import { validateAskConfigFileV1 } from "./schema.ts";
|
||||||
|
|
||||||
|
export class AskConfigMigrationError extends Error {
|
||||||
|
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
reason: "invalid_or_unsupported" | "migration_failed"
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskConfigMigrationResult {
|
||||||
|
config: AskConfig;
|
||||||
|
migrated: boolean;
|
||||||
|
notice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateAskConfig(raw: unknown): AskConfigMigrationResult {
|
||||||
|
if (!(raw && typeof raw === "object")) {
|
||||||
|
throw new AskConfigMigrationError(
|
||||||
|
"Config was invalid or unsupported.",
|
||||||
|
"invalid_or_unsupported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("schemaVersion" in raw)) {
|
||||||
|
throw new AskConfigMigrationError(
|
||||||
|
"Config was invalid or unsupported.",
|
||||||
|
"invalid_or_unsupported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((raw as { schemaVersion?: unknown }).schemaVersion !== 1) {
|
||||||
|
throw new AskConfigMigrationError(
|
||||||
|
"Config was invalid or unsupported.",
|
||||||
|
"invalid_or_unsupported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateAskConfigFileV1.Check(raw)) {
|
||||||
|
throw new AskConfigMigrationError(
|
||||||
|
"Config was invalid or unsupported.",
|
||||||
|
"invalid_or_unsupported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = normalizeAskConfig(raw as AskConfigFileV1);
|
||||||
|
const keymapsResult = normalizeConfiguredKeymaps(
|
||||||
|
(raw as AskConfigFileV1).keymaps as
|
||||||
|
| Partial<Record<AskConfigurableKeyAction, unknown>>
|
||||||
|
| undefined
|
||||||
|
);
|
||||||
|
if (!keymapsResult.ok) {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
keymaps: normalizeAskConfig().keymaps,
|
||||||
|
},
|
||||||
|
migrated: false,
|
||||||
|
notice: `${keymapsResult.error} Using default ask keymaps for this session. Edit the config and restart pi or run /reload.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
keymaps: keymapsResult.keymaps,
|
||||||
|
},
|
||||||
|
migrated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { type Static, Type } from "typebox";
|
||||||
|
import { Compile } from "typebox/compile";
|
||||||
|
|
||||||
|
export const AskConfigFileV1Schema = Type.Object({
|
||||||
|
schemaVersion: Type.Literal(1),
|
||||||
|
behaviour: Type.Optional(
|
||||||
|
Type.Object({
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
keymaps: Type.Optional(
|
||||||
|
Type.Object({
|
||||||
|
cancel: Type.Optional(Type.String()),
|
||||||
|
dismiss: Type.Optional(Type.String()),
|
||||||
|
toggle: Type.Optional(Type.String()),
|
||||||
|
confirm: Type.Optional(Type.String()),
|
||||||
|
optionNote: Type.Optional(Type.String()),
|
||||||
|
questionNote: Type.Optional(Type.String()),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AskConfigFileV1 = Static<typeof AskConfigFileV1Schema>;
|
||||||
|
|
||||||
|
export interface AskConfigKeymaps {
|
||||||
|
cancel: string;
|
||||||
|
confirm: string;
|
||||||
|
dismiss: string;
|
||||||
|
optionNote: string;
|
||||||
|
questionNote: string;
|
||||||
|
toggle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskConfig {
|
||||||
|
behaviour: {
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes: boolean;
|
||||||
|
};
|
||||||
|
keymaps: AskConfigKeymaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateAskConfigFileV1 = Compile(AskConfigFileV1Schema);
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const JSON_EXTENSION_PATTERN = /\.json$/u;
|
||||||
|
|
||||||
|
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
DEFAULT_ASK_CONFIG,
|
||||||
|
normalizeAskConfig,
|
||||||
|
toAskConfigFileV1,
|
||||||
|
} from "./defaults.ts";
|
||||||
|
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
|
||||||
|
import type { AskConfig } from "./schema.ts";
|
||||||
|
|
||||||
|
export interface AskConfigNotice {
|
||||||
|
kind: "error" | "warning" | "success";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AskConfigLoadResult {
|
||||||
|
config: AskConfig;
|
||||||
|
notice?: AskConfigNotice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AskConfigStore {
|
||||||
|
private config?: AskConfig;
|
||||||
|
private loadPromise?: Promise<AskConfigLoadResult>;
|
||||||
|
private notice?: AskConfigNotice;
|
||||||
|
private readonly listeners = new Set<(config: AskConfig) => void>();
|
||||||
|
private readonly configPath: string;
|
||||||
|
|
||||||
|
constructor(configPath = getAskConfigPath()) {
|
||||||
|
this.configPath = configPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(onChange: (config: AskConfig) => void): () => void {
|
||||||
|
this.listeners.add(onChange);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(onChange);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureLoaded(): Promise<AskConfigLoadResult> {
|
||||||
|
if (this.config) {
|
||||||
|
return { config: this.config, notice: this.notice };
|
||||||
|
}
|
||||||
|
if (!this.loadPromise) {
|
||||||
|
this.loadPromise = this.loadFromDisk();
|
||||||
|
}
|
||||||
|
const result = await this.loadPromise;
|
||||||
|
this.config = result.config;
|
||||||
|
this.notice = result.notice;
|
||||||
|
this.loadPromise = undefined;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfig(): Promise<AskConfig> {
|
||||||
|
return (await this.ensureLoaded()).config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(config: AskConfig): Promise<AskConfig> {
|
||||||
|
const normalized = normalizeAskConfig(config);
|
||||||
|
const content = JSON.stringify(
|
||||||
|
toAskConfigFileV1(normalized),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
).concat("\n");
|
||||||
|
await mkdir(dirname(this.configPath), { recursive: true });
|
||||||
|
await writeFile(this.configPath, content, "utf-8");
|
||||||
|
this.setConfig(normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(config: AskConfig): void {
|
||||||
|
this.config = normalizeAskConfig(config);
|
||||||
|
this.notice = undefined;
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(this.config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFromDisk(): Promise<AskConfigLoadResult> {
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await readFile(this.configPath, "utf-8");
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingFileError(error)) {
|
||||||
|
return { config: normalizeAskConfig(DEFAULT_ASK_CONFIG) };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return this.backupAndReset(
|
||||||
|
"Config was invalid or unsupported. Backed it up and loaded defaults. Press Ctrl+S to save a fresh config."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const migrated = migrateAskConfig(parsed);
|
||||||
|
if (migrated.migrated) {
|
||||||
|
await this.save(migrated.config);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
config: migrated.config,
|
||||||
|
notice: migrated.notice
|
||||||
|
? {
|
||||||
|
kind: "error",
|
||||||
|
text: migrated.notice,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AskConfigMigrationError) {
|
||||||
|
return this.backupAndReset(
|
||||||
|
error.reason === "migration_failed"
|
||||||
|
? "Config migration failed. Backed up old config and loaded defaults. Press Ctrl+S to save a fresh config."
|
||||||
|
: "Config was invalid or unsupported. Backed it up and loaded defaults. Press Ctrl+S to save a fresh config."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async backupAndReset(text: string): Promise<AskConfigLoadResult> {
|
||||||
|
const backupPath = createBackupPath(this.configPath, new Date());
|
||||||
|
await mkdir(dirname(this.configPath), { recursive: true });
|
||||||
|
await rename(this.configPath, backupPath);
|
||||||
|
return {
|
||||||
|
config: normalizeAskConfig(DEFAULT_ASK_CONFIG),
|
||||||
|
notice: {
|
||||||
|
kind: "error",
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let askConfigStore: AskConfigStore | undefined;
|
||||||
|
|
||||||
|
export function getAskConfigStore(): AskConfigStore {
|
||||||
|
askConfigStore ??= new AskConfigStore();
|
||||||
|
return askConfigStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAskConfigPath(): string {
|
||||||
|
return join(getAgentDir(), "extensions", "eko24ive-pi-ask.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackupPath(path: string, date: Date): string {
|
||||||
|
const timestamp = date.toISOString().replaceAll(":", "-").replace(/\./g, "-");
|
||||||
|
return path.replace(JSON_EXTENSION_PATTERN, `.${timestamp}.bak.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingFileError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
!!error &&
|
||||||
|
typeof error === "object" &&
|
||||||
|
"code" in error &&
|
||||||
|
error.code === "ENOENT"
|
||||||
|
);
|
||||||
|
}
|
||||||
+424
-152
@@ -1,16 +1,98 @@
|
|||||||
import { Key, matchesKey } from "@mariozechner/pi-tui";
|
import { Key, matchesKey } from "@mariozechner/pi-tui";
|
||||||
|
import type { AskConfig, AskConfigKeymaps } from "../config/schema.ts";
|
||||||
|
|
||||||
const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/;
|
const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/;
|
||||||
|
const LETTER_PATTERN = /^[a-z]$/;
|
||||||
|
const DIGIT_PATTERN = /^[0-9]$/;
|
||||||
|
const SYMBOL_PATTERN = /^[`\-=[\]\\;'.,/!@#$%^&*()_+|~{}:<>?]$/;
|
||||||
|
const FUNCTION_KEY_PATTERN = /^f([1-9]|1[0-2])$/;
|
||||||
|
const MODIFIER_ORDER = ["ctrl", "shift", "alt", "super"] as const;
|
||||||
|
const KNOWN_SPECIAL_KEYS = new Set([
|
||||||
|
"esc",
|
||||||
|
"enter",
|
||||||
|
"tab",
|
||||||
|
"space",
|
||||||
|
"backspace",
|
||||||
|
"delete",
|
||||||
|
"insert",
|
||||||
|
"clear",
|
||||||
|
"home",
|
||||||
|
"end",
|
||||||
|
"pageUp",
|
||||||
|
"pageDown",
|
||||||
|
"up",
|
||||||
|
"down",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
"f1",
|
||||||
|
"f2",
|
||||||
|
"f3",
|
||||||
|
"f4",
|
||||||
|
"f5",
|
||||||
|
"f6",
|
||||||
|
"f7",
|
||||||
|
"f8",
|
||||||
|
"f9",
|
||||||
|
"f10",
|
||||||
|
"f11",
|
||||||
|
"f12",
|
||||||
|
] as const);
|
||||||
|
const RESERVED_CONFIGURABLE_BINDINGS = new Set([
|
||||||
|
"?",
|
||||||
|
"tab",
|
||||||
|
"shift+tab",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
"up",
|
||||||
|
"down",
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
"6",
|
||||||
|
"7",
|
||||||
|
"8",
|
||||||
|
"9",
|
||||||
|
]);
|
||||||
|
const ACTION_LABEL_OVERRIDES: Partial<Record<AskKeyBindingId, string>> = {
|
||||||
|
nextTab: "Tab / →",
|
||||||
|
previousTab: "Shift+Tab / ←",
|
||||||
|
previousOption: "↑ / ↓",
|
||||||
|
nextOption: "↓",
|
||||||
|
numberShortcut: "1..9",
|
||||||
|
};
|
||||||
|
|
||||||
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];
|
||||||
|
|
||||||
export type AskKeyBindingKind = "command" | "affordance";
|
export type AskKeyBindingKind = "command" | "affordance";
|
||||||
|
export type AskConfigurableKeyAction = keyof AskConfigKeymaps;
|
||||||
|
export type AskFixedKeyBindingId =
|
||||||
|
| "settings"
|
||||||
|
| "nextTab"
|
||||||
|
| "previousTab"
|
||||||
|
| "previousOption"
|
||||||
|
| "nextOption"
|
||||||
|
| "numberShortcut"
|
||||||
|
| "fileReference";
|
||||||
|
export type AskKeyBindingId = AskConfigurableKeyAction | AskFixedKeyBindingId;
|
||||||
|
|
||||||
export interface AskKeyBinding {
|
export interface AskKeyBinding {
|
||||||
contexts: readonly string[];
|
contexts: readonly string[];
|
||||||
description: string;
|
description: string;
|
||||||
id: string;
|
id: AskKeyBindingId;
|
||||||
keys: readonly InputKey[];
|
keys: readonly InputKey[];
|
||||||
kind: AskKeyBindingKind;
|
kind: AskKeyBindingKind;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -23,64 +105,91 @@ export type FooterKeymapContext =
|
|||||||
| "input"
|
| "input"
|
||||||
| "note";
|
| "note";
|
||||||
|
|
||||||
export const ASK_KEY_BINDINGS = {
|
export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
|
||||||
help: {
|
cancel: "esc",
|
||||||
id: "help",
|
dismiss: "ctrl+c",
|
||||||
keys: ["?"],
|
toggle: "space",
|
||||||
kind: "command",
|
confirm: "enter",
|
||||||
label: "?",
|
optionNote: "n",
|
||||||
description: "Show this menu",
|
questionNote: "shift+n",
|
||||||
contexts: ["Main flow"],
|
};
|
||||||
},
|
|
||||||
cancel: {
|
const CONFIGURABLE_BINDING_METADATA: readonly ConfigurableBindingMetadata[] = [
|
||||||
|
{
|
||||||
id: "cancel",
|
id: "cancel",
|
||||||
keys: [Key.escape],
|
|
||||||
kind: "command",
|
kind: "command",
|
||||||
label: "Esc",
|
|
||||||
description: "Cancel flow or close/save editor draft",
|
description: "Cancel flow or close/save editor draft",
|
||||||
contexts: ["Main flow", "Editors"],
|
contexts: ["Main flow", "Editors"],
|
||||||
},
|
},
|
||||||
dismiss: {
|
{
|
||||||
id: "dismiss",
|
id: "dismiss",
|
||||||
keys: [Key.ctrl("c")],
|
|
||||||
kind: "command",
|
kind: "command",
|
||||||
label: "Ctrl+C",
|
|
||||||
description: "Dismiss entire ask flow immediately",
|
description: "Dismiss entire ask flow immediately",
|
||||||
contexts: ["Anywhere"],
|
contexts: ["Anywhere"],
|
||||||
},
|
},
|
||||||
nextTab: {
|
{
|
||||||
|
id: "toggle",
|
||||||
|
kind: "command",
|
||||||
|
description: "Toggle selected option",
|
||||||
|
contexts: ["Questions"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "confirm",
|
||||||
|
kind: "command",
|
||||||
|
description: "Confirm selection, continue, save, or submit",
|
||||||
|
contexts: ["Main flow", "Editors"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "optionNote",
|
||||||
|
kind: "command",
|
||||||
|
description: "Edit selected option note",
|
||||||
|
contexts: ["Question tabs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 modal",
|
||||||
|
contexts: ["Main flow", "Empty editor"],
|
||||||
|
},
|
||||||
|
{
|
||||||
id: "nextTab",
|
id: "nextTab",
|
||||||
keys: [Key.tab, Key.right],
|
keys: [Key.tab, Key.right],
|
||||||
kind: "command",
|
kind: "command",
|
||||||
label: "Tab / →",
|
|
||||||
description: "Switch to next tab",
|
description: "Switch to next tab",
|
||||||
contexts: ["Main flow", "Empty editor"],
|
contexts: ["Main flow", "Empty editor"],
|
||||||
},
|
},
|
||||||
previousTab: {
|
{
|
||||||
id: "previousTab",
|
id: "previousTab",
|
||||||
keys: [Key.shift("tab"), Key.left],
|
keys: [Key.shift("tab"), Key.left],
|
||||||
kind: "command",
|
kind: "command",
|
||||||
label: "Shift+Tab / ←",
|
|
||||||
description: "Switch to previous tab",
|
description: "Switch to previous tab",
|
||||||
contexts: ["Main flow", "Empty editor"],
|
contexts: ["Main flow", "Empty editor"],
|
||||||
},
|
},
|
||||||
previousOption: {
|
{
|
||||||
id: "previousOption",
|
id: "previousOption",
|
||||||
keys: [Key.up],
|
keys: [Key.up],
|
||||||
kind: "command",
|
kind: "command",
|
||||||
label: "↑ / ↓",
|
|
||||||
description: "Move between options/actions",
|
description: "Move between options/actions",
|
||||||
contexts: ["Main flow", "Empty editor"],
|
contexts: ["Main flow", "Empty editor"],
|
||||||
},
|
},
|
||||||
nextOption: {
|
{
|
||||||
id: "nextOption",
|
id: "nextOption",
|
||||||
keys: [Key.down],
|
keys: [Key.down],
|
||||||
kind: "command",
|
kind: "command",
|
||||||
label: "↓",
|
|
||||||
description: "Move to next option/action",
|
description: "Move to next option/action",
|
||||||
contexts: ["Main flow", "Empty editor"],
|
contexts: ["Main flow", "Empty editor"],
|
||||||
},
|
},
|
||||||
numberShortcut: {
|
{
|
||||||
id: "numberShortcut",
|
id: "numberShortcut",
|
||||||
keys: ["1..9"],
|
keys: ["1..9"],
|
||||||
kind: "command",
|
kind: "command",
|
||||||
@@ -88,149 +197,34 @@ export const ASK_KEY_BINDINGS = {
|
|||||||
description: "Quick-select option or submit action",
|
description: "Quick-select option or submit action",
|
||||||
contexts: ["Main flow", "Submit tab"],
|
contexts: ["Main flow", "Submit tab"],
|
||||||
},
|
},
|
||||||
toggle: {
|
{
|
||||||
id: "toggle",
|
|
||||||
keys: [Key.space],
|
|
||||||
kind: "command",
|
|
||||||
label: "Space",
|
|
||||||
description: "Toggle selected option",
|
|
||||||
contexts: ["Multi-select questions"],
|
|
||||||
},
|
|
||||||
confirm: {
|
|
||||||
id: "confirm",
|
|
||||||
keys: [Key.enter],
|
|
||||||
kind: "command",
|
|
||||||
label: "Enter",
|
|
||||||
description: "Confirm selection, continue, save, or submit",
|
|
||||||
contexts: ["Main flow", "Editors"],
|
|
||||||
},
|
|
||||||
optionNote: {
|
|
||||||
id: "optionNote",
|
|
||||||
keys: ["n"],
|
|
||||||
kind: "command",
|
|
||||||
label: "N",
|
|
||||||
description: "Edit selected option note",
|
|
||||||
contexts: ["Question tabs"],
|
|
||||||
},
|
|
||||||
questionNote: {
|
|
||||||
id: "questionNote",
|
|
||||||
keys: ["N"],
|
|
||||||
kind: "command",
|
|
||||||
label: "Shift+N",
|
|
||||||
description: "Edit question note",
|
|
||||||
contexts: ["Question tabs"],
|
|
||||||
},
|
|
||||||
fileReference: {
|
|
||||||
id: "fileReference",
|
id: "fileReference",
|
||||||
keys: ["@"],
|
keys: ["@"],
|
||||||
kind: "affordance",
|
kind: "affordance",
|
||||||
label: "@",
|
|
||||||
description: "Reference files while typing answers or notes",
|
description: "Reference files while typing answers or notes",
|
||||||
contexts: ["Editors"],
|
contexts: ["Editors"],
|
||||||
},
|
},
|
||||||
} as const satisfies Record<string, AskKeyBinding>;
|
|
||||||
|
|
||||||
export const ASK_KEYMAPS: readonly AskKeyBinding[] = [
|
|
||||||
ASK_KEY_BINDINGS.help,
|
|
||||||
ASK_KEY_BINDINGS.cancel,
|
|
||||||
ASK_KEY_BINDINGS.dismiss,
|
|
||||||
ASK_KEY_BINDINGS.nextTab,
|
|
||||||
ASK_KEY_BINDINGS.previousTab,
|
|
||||||
ASK_KEY_BINDINGS.previousOption,
|
|
||||||
ASK_KEY_BINDINGS.numberShortcut,
|
|
||||||
ASK_KEY_BINDINGS.toggle,
|
|
||||||
ASK_KEY_BINDINGS.confirm,
|
|
||||||
ASK_KEY_BINDINGS.optionNote,
|
|
||||||
ASK_KEY_BINDINGS.questionNote,
|
|
||||||
ASK_KEY_BINDINGS.fileReference,
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const footerHint = (
|
const footerHint = (
|
||||||
binding: AskKeyBinding,
|
binding: AskKeyBinding,
|
||||||
action: string,
|
action: string,
|
||||||
label = binding.label
|
label = binding.label
|
||||||
): string => `${label} ${action}`;
|
) => `${label} ${action}`;
|
||||||
const optionNavigationLabel = ASK_KEY_BINDINGS.previousOption.label.replaceAll(
|
|
||||||
" / ",
|
|
||||||
""
|
|
||||||
);
|
|
||||||
const tabNavigationLabel = "⇆";
|
|
||||||
const noteNavigationLabel = `${ASK_KEY_BINDINGS.optionNote.label}/${ASK_KEY_BINDINGS.questionNote.label}`;
|
|
||||||
|
|
||||||
export const ASK_FOOTER_HINTS_BY_CONTEXT: Record<
|
export function formatKeybindingLabel(key: string): string {
|
||||||
FooterKeymapContext,
|
return key
|
||||||
readonly string[]
|
.split("+")
|
||||||
> = {
|
.map((part) => {
|
||||||
input: [
|
if (part.length <= 1) {
|
||||||
footerHint(ASK_KEY_BINDINGS.confirm, "submit"),
|
return part.toUpperCase();
|
||||||
footerHint(ASK_KEY_BINDINGS.cancel, "close"),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.help, "help"),
|
|
||||||
],
|
|
||||||
note: [
|
|
||||||
footerHint(ASK_KEY_BINDINGS.confirm, "save"),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.cancel, "close"),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.help, "help"),
|
|
||||||
],
|
|
||||||
submit: [
|
|
||||||
footerHint(ASK_KEY_BINDINGS.numberShortcut, "hotkeys"),
|
|
||||||
footerHint(
|
|
||||||
ASK_KEY_BINDINGS.previousOption,
|
|
||||||
"select",
|
|
||||||
optionNavigationLabel
|
|
||||||
),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.confirm, "confirm"),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.cancel, "cancel"),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.help, "help"),
|
|
||||||
],
|
|
||||||
multi: [
|
|
||||||
footerHint(ASK_KEY_BINDINGS.nextTab, "tab", tabNavigationLabel),
|
|
||||||
footerHint(
|
|
||||||
ASK_KEY_BINDINGS.previousOption,
|
|
||||||
"select",
|
|
||||||
optionNavigationLabel
|
|
||||||
),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.toggle, "toggle"),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.confirm, "continue"),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.optionNote, "note", noteNavigationLabel),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.cancel, "dismiss"),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.help, "help"),
|
|
||||||
],
|
|
||||||
default: [
|
|
||||||
footerHint(ASK_KEY_BINDINGS.nextTab, "tab", tabNavigationLabel),
|
|
||||||
footerHint(
|
|
||||||
ASK_KEY_BINDINGS.previousOption,
|
|
||||||
"select",
|
|
||||||
optionNavigationLabel
|
|
||||||
),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.confirm, "confirm"),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.optionNote, "note", noteNavigationLabel),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.cancel, "dismiss"),
|
|
||||||
footerHint(ASK_KEY_BINDINGS.help, "help"),
|
|
||||||
],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const ASK_HELP_CLOSE_BINDINGS = [
|
|
||||||
ASK_KEY_BINDINGS.cancel,
|
|
||||||
ASK_KEY_BINDINGS.dismiss,
|
|
||||||
ASK_KEY_BINDINGS.help,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const ASK_HELP_CLOSE_HINT = `Press ${ASK_HELP_CLOSE_BINDINGS.map(
|
|
||||||
(binding) => binding.label
|
|
||||||
)
|
|
||||||
.map((label, index, labels) => {
|
|
||||||
if (index === 0) {
|
|
||||||
return label;
|
|
||||||
}
|
}
|
||||||
if (index === labels.length - 1) {
|
if (part === "pageUp" || part === "pageDown") {
|
||||||
return `or ${label}`;
|
return part;
|
||||||
}
|
}
|
||||||
return label;
|
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||||
})
|
})
|
||||||
.join(", ")} to close.`;
|
.join("+");
|
||||||
|
|
||||||
export function renderFooterKeymaps(context: FooterKeymapContext): string {
|
|
||||||
return ` ${ASK_FOOTER_HINTS_BY_CONTEXT[context].join(" · ")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchesBinding(data: string, binding: AskKeyBinding): boolean {
|
export function matchesBinding(data: string, binding: AskKeyBinding): boolean {
|
||||||
@@ -245,3 +239,281 @@ export function matchesBinding(data: string, binding: AskKeyBinding): boolean {
|
|||||||
export function matchesDigitShortcut(data: string): number | null {
|
export function matchesDigitShortcut(data: string): number | null {
|
||||||
return DIGIT_SHORTCUT_PATTERN.test(data) ? Number(data) : null;
|
return DIGIT_SHORTCUT_PATTERN.test(data) ? Number(data) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAskKeyBindings(
|
||||||
|
config: AskConfig
|
||||||
|
): Record<AskKeyBindingId, AskKeyBinding> {
|
||||||
|
const customizable: Record<AskConfigurableKeyAction, AskKeyBinding> = {
|
||||||
|
cancel: createCustomizableBinding(config, "cancel"),
|
||||||
|
dismiss: createCustomizableBinding(config, "dismiss"),
|
||||||
|
toggle: createCustomizableBinding(config, "toggle"),
|
||||||
|
confirm: createCustomizableBinding(config, "confirm"),
|
||||||
|
optionNote: createCustomizableBinding(config, "optionNote"),
|
||||||
|
questionNote: createCustomizableBinding(config, "questionNote"),
|
||||||
|
};
|
||||||
|
const fixed = Object.fromEntries(
|
||||||
|
FIXED_BINDING_METADATA.map((binding) => [
|
||||||
|
binding.id,
|
||||||
|
{
|
||||||
|
...binding,
|
||||||
|
label:
|
||||||
|
binding.label ??
|
||||||
|
ACTION_LABEL_OVERRIDES[binding.id] ??
|
||||||
|
binding.keys
|
||||||
|
.map((key) => formatKeybindingLabel(String(key)))
|
||||||
|
.join(" / "),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
) as Record<AskFixedKeyBindingId, AskKeyBinding>;
|
||||||
|
return {
|
||||||
|
...customizable,
|
||||||
|
...fixed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAskKeymaps(config: AskConfig): {
|
||||||
|
customizable: readonly AskKeyBinding[];
|
||||||
|
fixed: readonly AskKeyBinding[];
|
||||||
|
} {
|
||||||
|
const bindings = getAskKeyBindings(config);
|
||||||
|
return {
|
||||||
|
customizable: CONFIGURABLE_BINDING_METADATA.map(({ id }) => bindings[id]),
|
||||||
|
fixed: FIXED_BINDING_METADATA.map(({ id }) => bindings[id]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFooterKeymaps(
|
||||||
|
config: AskConfig,
|
||||||
|
context: FooterKeymapContext
|
||||||
|
): string {
|
||||||
|
const bindings = getAskKeyBindings(config);
|
||||||
|
const optionNavigationLabel = bindings.previousOption.label.replaceAll(
|
||||||
|
" / ",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const tabNavigationLabel = "⇆";
|
||||||
|
const noteNavigationLabel = `${bindings.optionNote.label}/${bindings.questionNote.label}`;
|
||||||
|
const hintsByContext: Record<FooterKeymapContext, readonly string[]> = {
|
||||||
|
input: [
|
||||||
|
footerHint(bindings.confirm, "submit"),
|
||||||
|
footerHint(bindings.cancel, "close"),
|
||||||
|
footerHint(bindings.settings, "settings"),
|
||||||
|
],
|
||||||
|
note: [
|
||||||
|
footerHint(bindings.confirm, "save"),
|
||||||
|
footerHint(bindings.cancel, "close"),
|
||||||
|
footerHint(bindings.settings, "settings"),
|
||||||
|
],
|
||||||
|
submit: [
|
||||||
|
footerHint(bindings.numberShortcut, "hotkeys"),
|
||||||
|
footerHint(bindings.previousOption, "select", optionNavigationLabel),
|
||||||
|
footerHint(bindings.confirm, "confirm"),
|
||||||
|
footerHint(bindings.cancel, "cancel"),
|
||||||
|
footerHint(bindings.settings, "settings"),
|
||||||
|
],
|
||||||
|
multi: [
|
||||||
|
footerHint(bindings.nextTab, "tab", tabNavigationLabel),
|
||||||
|
footerHint(bindings.previousOption, "select", optionNavigationLabel),
|
||||||
|
footerHint(bindings.toggle, "toggle"),
|
||||||
|
footerHint(bindings.confirm, "continue"),
|
||||||
|
footerHint(bindings.optionNote, "note", noteNavigationLabel),
|
||||||
|
footerHint(bindings.cancel, "dismiss"),
|
||||||
|
footerHint(bindings.settings, "settings"),
|
||||||
|
],
|
||||||
|
default: [
|
||||||
|
footerHint(bindings.nextTab, "tab", tabNavigationLabel),
|
||||||
|
footerHint(bindings.previousOption, "select", optionNavigationLabel),
|
||||||
|
footerHint(bindings.confirm, "confirm"),
|
||||||
|
footerHint(bindings.optionNote, "note", noteNavigationLabel),
|
||||||
|
footerHint(bindings.cancel, "dismiss"),
|
||||||
|
footerHint(bindings.settings, "settings"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return ` ${hintsByContext[context].join(" · ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConfiguredKeymaps(
|
||||||
|
keymaps: Partial<Record<AskConfigurableKeyAction, unknown>> | undefined
|
||||||
|
): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } {
|
||||||
|
if (!keymaps) {
|
||||||
|
return { ok: true, keymaps: { ...DEFAULT_ASK_KEYMAPS } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = {} as AskConfigKeymaps;
|
||||||
|
for (const action of Object.keys(
|
||||||
|
DEFAULT_ASK_KEYMAPS
|
||||||
|
) as AskConfigurableKeyAction[]) {
|
||||||
|
const rawValue = keymaps[action];
|
||||||
|
if (typeof rawValue !== "string") {
|
||||||
|
return { ok: false, error: `Missing keymap for ${action}.` };
|
||||||
|
}
|
||||||
|
const parsed = normalizeKeyId(rawValue);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Invalid keymap for ${action}: ${parsed.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (RESERVED_CONFIGURABLE_BINDINGS.has(parsed.keyId)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Reserved keymap for ${action}: ${formatKeybindingLabel(parsed.keyId)}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
normalized[action] = parsed.keyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Map<string, AskConfigurableKeyAction>();
|
||||||
|
for (const action of Object.keys(normalized) as AskConfigurableKeyAction[]) {
|
||||||
|
const key = normalized[action];
|
||||||
|
const existing = seen.get(key);
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Duplicate keymap ${formatKeybindingLabel(key)} for ${existing} and ${action}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
seen.set(key, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, keymaps: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKeyId(
|
||||||
|
rawKey: string
|
||||||
|
): { ok: true; keyId: string } | { ok: false; error: string } {
|
||||||
|
const normalizedInput = rawKey.trim();
|
||||||
|
if (!normalizedInput) {
|
||||||
|
return { ok: false, error: "empty binding" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = normalizedInput
|
||||||
|
.toLowerCase()
|
||||||
|
.split("+")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return { ok: false, error: "empty binding" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePart = parts.at(-1);
|
||||||
|
if (!basePart) {
|
||||||
|
return { ok: false, error: "missing key" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiers = new Set<ModifierName>();
|
||||||
|
for (const part of parts.slice(0, -1)) {
|
||||||
|
const modifier = normalizeModifierName(part);
|
||||||
|
if (!modifier) {
|
||||||
|
return { ok: false, error: `unsupported modifier ${part}` };
|
||||||
|
}
|
||||||
|
modifiers.add(modifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseKey = normalizeBaseKey(basePart);
|
||||||
|
if (!baseKey) {
|
||||||
|
return { ok: false, error: `unsupported key ${basePart}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifierPrefix = MODIFIER_ORDER.filter((modifier) =>
|
||||||
|
modifiers.has(modifier)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
keyId: [...modifierPrefix, baseKey].join("+"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCustomizableBinding(
|
||||||
|
config: AskConfig,
|
||||||
|
id: AskConfigurableKeyAction
|
||||||
|
): AskKeyBinding {
|
||||||
|
const binding = CONFIGURABLE_BINDING_METADATA.find(
|
||||||
|
(entry) => entry.id === id
|
||||||
|
);
|
||||||
|
if (!binding) {
|
||||||
|
throw new Error(`Unknown customizable ask key binding: ${id}`);
|
||||||
|
}
|
||||||
|
const key = config.keymaps[id];
|
||||||
|
return {
|
||||||
|
...binding,
|
||||||
|
keys: [key],
|
||||||
|
label: formatKeybindingLabel(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModifierName(part: string): ModifierName | undefined {
|
||||||
|
switch (part) {
|
||||||
|
case "ctrl":
|
||||||
|
case "control":
|
||||||
|
case "ctl":
|
||||||
|
return "ctrl";
|
||||||
|
case "shift":
|
||||||
|
return "shift";
|
||||||
|
case "alt":
|
||||||
|
case "option":
|
||||||
|
return "alt";
|
||||||
|
case "super":
|
||||||
|
case "cmd":
|
||||||
|
case "command":
|
||||||
|
case "meta":
|
||||||
|
case "win":
|
||||||
|
case "windows":
|
||||||
|
return "super";
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseKey(part: string): string | undefined {
|
||||||
|
const aliased = normalizeBaseKeyAlias(part);
|
||||||
|
if (
|
||||||
|
LETTER_PATTERN.test(aliased) ||
|
||||||
|
DIGIT_PATTERN.test(aliased) ||
|
||||||
|
SYMBOL_PATTERN.test(aliased)
|
||||||
|
) {
|
||||||
|
return aliased;
|
||||||
|
}
|
||||||
|
if (FUNCTION_KEY_PATTERN.test(aliased)) {
|
||||||
|
return aliased;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
KNOWN_SPECIAL_KEYS.has(
|
||||||
|
aliased as typeof KNOWN_SPECIAL_KEYS extends Set<infer T> ? T : never
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return aliased;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseKeyAlias(part: string): string {
|
||||||
|
switch (part) {
|
||||||
|
case "escape":
|
||||||
|
return "esc";
|
||||||
|
case "return":
|
||||||
|
return "enter";
|
||||||
|
case "spacebar":
|
||||||
|
return "space";
|
||||||
|
case "del":
|
||||||
|
return "delete";
|
||||||
|
case "ins":
|
||||||
|
return "insert";
|
||||||
|
case "pgup":
|
||||||
|
case "pageup":
|
||||||
|
return "pageUp";
|
||||||
|
case "pgdown":
|
||||||
|
case "pagedown":
|
||||||
|
return "pageDown";
|
||||||
|
case "arrowup":
|
||||||
|
return "up";
|
||||||
|
case "arrowdown":
|
||||||
|
return "down";
|
||||||
|
case "arrowleft":
|
||||||
|
return "left";
|
||||||
|
case "arrowright":
|
||||||
|
return "right";
|
||||||
|
default:
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
import { registerAskSettingsCommand } from "./ask-settings-command.ts";
|
import { registerAskSettingsCommand } from "./ask-settings-command.ts";
|
||||||
import { registerAskTool } from "./ask-tool.ts";
|
import { registerAskTool } from "./ask-tool.ts";
|
||||||
|
|
||||||
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const CONFIGURATION_DOC_PATH = resolve(
|
||||||
|
PACKAGE_ROOT,
|
||||||
|
"docs",
|
||||||
|
"configuration.md"
|
||||||
|
);
|
||||||
|
const PI_ASK_CONFIG_PROMPT = `When the user asks to configure, customize, debug, or explain @eko24ive/pi-ask settings or keymaps, first read ${CONFIGURATION_DOC_PATH} and follow it as the source of truth before editing config files.`;
|
||||||
|
|
||||||
export default function askExtension(pi: ExtensionAPI) {
|
export default function askExtension(pi: ExtensionAPI) {
|
||||||
|
pi.on("before_agent_start", async (event) => ({
|
||||||
|
systemPrompt: `${event.systemPrompt}\n\n${PI_ASK_CONFIG_PROMPT}`,
|
||||||
|
}));
|
||||||
registerAskTool(pi);
|
registerAskTool(pi);
|
||||||
registerAskSettingsCommand(pi);
|
registerAskSettingsCommand(pi);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { AskConfig } from "../config/schema.ts";
|
||||||
|
import { hasAnswerNotes } from "../state/answers.ts";
|
||||||
|
import { isQuestionAnswered, isSubmitTab } from "../state/selectors.ts";
|
||||||
|
import type { AskState } from "../types.ts";
|
||||||
|
|
||||||
|
export function maybeAutoSubmitState(
|
||||||
|
state: AskState,
|
||||||
|
config: AskConfig
|
||||||
|
): AskState {
|
||||||
|
if (!shouldAutoSubmit(state, config)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
completed: true,
|
||||||
|
mode: "submit",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAutoSubmit(state: AskState, config: AskConfig): boolean {
|
||||||
|
if (!config.behaviour.autoSubmitWhenAnsweredWithoutNotes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (state.completed || state.cancelled || !isSubmitTab(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const question of state.questions) {
|
||||||
|
if (!isQuestionAnswered(state, question.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasAnswerNotes(state.answers[question.id])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
+44
-14
@@ -1,5 +1,7 @@
|
|||||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
|
import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
|
||||||
|
import type { AskConfig } from "../config/schema.ts";
|
||||||
|
import { getAskConfigStore } from "../config/store.ts";
|
||||||
import { createInitialState } from "../state/create.ts";
|
import { createInitialState } from "../state/create.ts";
|
||||||
import {
|
import {
|
||||||
getEditorDraft,
|
getEditorDraft,
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
} from "../state/transitions.ts";
|
} from "../state/transitions.ts";
|
||||||
import { isEditingView } from "../state/view.ts";
|
import { isEditingView } from "../state/view.ts";
|
||||||
import type { AskParams, AskResult, AskState } from "../types.ts";
|
import type { AskParams, AskResult, AskState } from "../types.ts";
|
||||||
|
import { maybeAutoSubmitState } from "./auto-submit.ts";
|
||||||
import { createAskAutocompleteProvider } from "./autocomplete.ts";
|
import { createAskAutocompleteProvider } from "./autocomplete.ts";
|
||||||
import type { AskInputCommand } from "./input.ts";
|
import type { AskInputCommand } from "./input.ts";
|
||||||
import { getInputCommand } from "./input.ts";
|
import { getInputCommand } from "./input.ts";
|
||||||
@@ -41,25 +44,29 @@ type Theme = CustomCallbackArgs[1];
|
|||||||
type Keybindings = CustomCallbackArgs[2];
|
type Keybindings = CustomCallbackArgs[2];
|
||||||
type Done = (result: AskResult) => void;
|
type Done = (result: AskResult) => void;
|
||||||
type AskFlowParams = AskParams &
|
type AskFlowParams = AskParams &
|
||||||
Pick<ExtensionContext, "cwd"> & { ctx: ExtensionContext };
|
Pick<ExtensionContext, "cwd"> & { config: AskConfig; ctx: ExtensionContext };
|
||||||
|
|
||||||
interface AskFlowController {
|
interface AskFlowController {
|
||||||
|
config: AskConfig;
|
||||||
ctx: ExtensionContext;
|
ctx: ExtensionContext;
|
||||||
done: Done;
|
done: Done;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
helpOpen: boolean;
|
settingsOpen: boolean;
|
||||||
state: AskState;
|
state: AskState;
|
||||||
suppressAutoInputForSelection: boolean;
|
suppressAutoInputForSelection: boolean;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
tui: Tui;
|
tui: Tui;
|
||||||
|
unsubscribeConfig: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runAskFlow(
|
export async function runAskFlow(
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
params: AskParams
|
params: AskParams
|
||||||
): Promise<AskResult> {
|
): Promise<AskResult> {
|
||||||
|
const store = getAskConfigStore();
|
||||||
|
const config = await store.getConfig();
|
||||||
return ctx.ui.custom<AskResult>((...args) =>
|
return ctx.ui.custom<AskResult>((...args) =>
|
||||||
createAskFlowController(args, { ...params, cwd: ctx.cwd, ctx })
|
createAskFlowController(args, { ...params, config, cwd: ctx.cwd, ctx })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,16 +80,30 @@ function createAskFlowController(
|
|||||||
params: AskFlowParams
|
params: AskFlowParams
|
||||||
) {
|
) {
|
||||||
const controller: AskFlowController = {
|
const controller: AskFlowController = {
|
||||||
|
config: params.config,
|
||||||
ctx: params.ctx,
|
ctx: params.ctx,
|
||||||
done,
|
done,
|
||||||
editor: createEditor(tui, theme, params.cwd),
|
editor: createEditor(tui, theme, params.cwd),
|
||||||
helpOpen: false,
|
settingsOpen: false,
|
||||||
state: createInitialState(params),
|
state: createInitialState(params),
|
||||||
suppressAutoInputForSelection: false,
|
suppressAutoInputForSelection: false,
|
||||||
theme,
|
theme,
|
||||||
tui,
|
tui,
|
||||||
|
unsubscribeConfig: () => {
|
||||||
|
// replaced immediately after controller creation
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
controller.unsubscribeConfig = getAskConfigStore().subscribe((config) => {
|
||||||
|
controller.config = config;
|
||||||
|
controller.state = maybeAutoSubmitState(
|
||||||
|
controller.state,
|
||||||
|
controller.config
|
||||||
|
);
|
||||||
|
refresh(controller);
|
||||||
|
maybeFinish(controller);
|
||||||
|
});
|
||||||
|
|
||||||
controller.editor.onSubmit = (value) => submitEditor(controller, value);
|
controller.editor.onSubmit = (value) => submitEditor(controller, value);
|
||||||
syncSelection(controller);
|
syncSelection(controller);
|
||||||
|
|
||||||
@@ -94,6 +115,9 @@ function createAskFlowController(
|
|||||||
handleInput(data: string) {
|
handleInput(data: string) {
|
||||||
handleControllerInput(controller, data);
|
handleControllerInput(controller, data);
|
||||||
},
|
},
|
||||||
|
dispose() {
|
||||||
|
controller.unsubscribeConfig();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +126,7 @@ function renderController(
|
|||||||
width: number
|
width: number
|
||||||
): string[] {
|
): string[] {
|
||||||
return renderAskScreen({
|
return renderAskScreen({
|
||||||
|
config: controller.config,
|
||||||
editor: controller.editor,
|
editor: controller.editor,
|
||||||
state: controller.state,
|
state: controller.state,
|
||||||
theme: controller.theme,
|
theme: controller.theme,
|
||||||
@@ -112,6 +137,7 @@ function renderController(
|
|||||||
function handleControllerInput(controller: AskFlowController, data: string) {
|
function handleControllerInput(controller: AskFlowController, data: string) {
|
||||||
const command = getInputCommand(
|
const command = getInputCommand(
|
||||||
controller.state,
|
controller.state,
|
||||||
|
controller.config,
|
||||||
data,
|
data,
|
||||||
isEditingView(controller.state) ? controller.editor.getText() : ""
|
isEditingView(controller.state) ? controller.editor.getText() : ""
|
||||||
);
|
);
|
||||||
@@ -131,8 +157,8 @@ function handleEditingCommand(
|
|||||||
commitState(controller, dismissFlow(controller.state), { finish: true });
|
commitState(controller, dismissFlow(controller.state), { finish: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command.kind === "showHelp") {
|
if (command.kind === "showSettings") {
|
||||||
showHelpModal(controller);
|
showSettingsModal(controller);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command.kind === "editMoveTab") {
|
if (command.kind === "editMoveTab") {
|
||||||
@@ -190,8 +216,8 @@ function handleNavigationCommand(
|
|||||||
case "dismiss":
|
case "dismiss":
|
||||||
commitState(controller, dismissFlow(controller.state), { finish: true });
|
commitState(controller, dismissFlow(controller.state), { finish: true });
|
||||||
return;
|
return;
|
||||||
case "showHelp":
|
case "showSettings":
|
||||||
showHelpModal(controller);
|
showSettingsModal(controller);
|
||||||
return;
|
return;
|
||||||
case "ignore":
|
case "ignore":
|
||||||
case "editMoveTab":
|
case "editMoveTab":
|
||||||
@@ -253,6 +279,7 @@ function commitState(
|
|||||||
if (options.syncSelection !== false) {
|
if (options.syncSelection !== false) {
|
||||||
syncSelection(controller);
|
syncSelection(controller);
|
||||||
}
|
}
|
||||||
|
controller.state = maybeAutoSubmitState(controller.state, controller.config);
|
||||||
hydrateEditor(controller);
|
hydrateEditor(controller);
|
||||||
refresh(controller);
|
refresh(controller);
|
||||||
if (options.finish) {
|
if (options.finish) {
|
||||||
@@ -262,7 +289,10 @@ function commitState(
|
|||||||
|
|
||||||
function submitEditor(controller: AskFlowController, value: string) {
|
function submitEditor(controller: AskFlowController, value: string) {
|
||||||
controller.suppressAutoInputForSelection = false;
|
controller.suppressAutoInputForSelection = false;
|
||||||
controller.state = submitEditorDraft(controller.state, value);
|
controller.state = maybeAutoSubmitState(
|
||||||
|
submitEditorDraft(controller.state, value),
|
||||||
|
controller.config
|
||||||
|
);
|
||||||
controller.editor.setText("");
|
controller.editor.setText("");
|
||||||
refresh(controller);
|
refresh(controller);
|
||||||
maybeFinish(controller);
|
maybeFinish(controller);
|
||||||
@@ -283,13 +313,13 @@ function closeEditor(controller: AskFlowController) {
|
|||||||
refresh(controller);
|
refresh(controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showHelpModal(controller: AskFlowController) {
|
function showSettingsModal(controller: AskFlowController) {
|
||||||
if (controller.helpOpen) {
|
if (controller.settingsOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controller.helpOpen = true;
|
controller.settingsOpen = true;
|
||||||
showAskSettingsModal(controller.ctx.ui).finally(() => {
|
showAskSettingsModal(controller.ctx.ui).finally(() => {
|
||||||
controller.helpOpen = false;
|
controller.settingsOpen = false;
|
||||||
refresh(controller);
|
refresh(controller);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-34
@@ -1,5 +1,6 @@
|
|||||||
|
import type { AskConfig } from "../config/schema.ts";
|
||||||
import {
|
import {
|
||||||
ASK_KEY_BINDINGS,
|
getAskKeyBindings,
|
||||||
matchesBinding,
|
matchesBinding,
|
||||||
matchesDigitShortcut,
|
matchesDigitShortcut,
|
||||||
} from "../constants/keymaps.ts";
|
} from "../constants/keymaps.ts";
|
||||||
@@ -14,7 +15,7 @@ export type AskInputCommand =
|
|||||||
| { kind: "confirm" }
|
| { kind: "confirm" }
|
||||||
| { kind: "cancel" }
|
| { kind: "cancel" }
|
||||||
| { kind: "dismiss" }
|
| { kind: "dismiss" }
|
||||||
| { kind: "showHelp" }
|
| { kind: "showSettings" }
|
||||||
| { kind: "numberShortcut"; digit: number }
|
| { kind: "numberShortcut"; digit: number }
|
||||||
| { kind: "editMoveTab"; delta: 1 | -1 }
|
| { kind: "editMoveTab"; delta: 1 | -1 }
|
||||||
| { kind: "editMoveOption"; delta: 1 | -1 }
|
| { kind: "editMoveOption"; delta: 1 | -1 }
|
||||||
@@ -24,85 +25,79 @@ export type AskInputCommand =
|
|||||||
|
|
||||||
export function getInputCommand(
|
export function getInputCommand(
|
||||||
state: AskState,
|
state: AskState,
|
||||||
|
config: AskConfig,
|
||||||
data: string,
|
data: string,
|
||||||
editingText = ""
|
editingText = ""
|
||||||
): AskInputCommand {
|
): AskInputCommand {
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.dismiss)) {
|
const bindings = getAskKeyBindings(config);
|
||||||
|
if (matchesBinding(data, bindings.dismiss)) {
|
||||||
return { kind: "dismiss" };
|
return { kind: "dismiss" };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.help) && editingText.length === 0) {
|
if (matchesBinding(data, bindings.settings) && editingText.length === 0) {
|
||||||
return { kind: "showHelp" };
|
return { kind: "showSettings" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.view.kind === "input" || state.view.kind === "note") {
|
if (state.view.kind === "input" || state.view.kind === "note") {
|
||||||
return getEditingInputCommand(data, editingText);
|
return getEditingInputCommand(bindings, data, editingText);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getNavigationInputCommand(data);
|
return getNavigationInputCommand(bindings, data);
|
||||||
}
|
|
||||||
|
|
||||||
export function formatKeybindingLabel(key: string): string {
|
|
||||||
return key
|
|
||||||
.split("+")
|
|
||||||
.map((part) => {
|
|
||||||
if (part.length <= 1) {
|
|
||||||
return part.toUpperCase();
|
|
||||||
}
|
|
||||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
||||||
})
|
|
||||||
.join("+");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEditingInputCommand(
|
function getEditingInputCommand(
|
||||||
|
bindings: ReturnType<typeof getAskKeyBindings>,
|
||||||
data: string,
|
data: string,
|
||||||
editingText: string
|
editingText: string
|
||||||
): AskInputCommand {
|
): AskInputCommand {
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) {
|
if (matchesBinding(data, bindings.cancel)) {
|
||||||
return { kind: "editClose" };
|
return { kind: "editClose" };
|
||||||
}
|
}
|
||||||
if (editingText.length === 0) {
|
if (editingText.length === 0) {
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) {
|
if (matchesBinding(data, bindings.nextTab)) {
|
||||||
return { kind: "editMoveTab", delta: 1 };
|
return { kind: "editMoveTab", delta: 1 };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) {
|
if (matchesBinding(data, bindings.previousTab)) {
|
||||||
return { kind: "editMoveTab", delta: -1 };
|
return { kind: "editMoveTab", delta: -1 };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) {
|
if (matchesBinding(data, bindings.previousOption)) {
|
||||||
return { kind: "editMoveOption", delta: -1 };
|
return { kind: "editMoveOption", delta: -1 };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) {
|
if (matchesBinding(data, bindings.nextOption)) {
|
||||||
return { kind: "editMoveOption", delta: 1 };
|
return { kind: "editMoveOption", delta: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { kind: "delegateToEditor" };
|
return { kind: "delegateToEditor" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNavigationInputCommand(data: string): AskInputCommand {
|
function getNavigationInputCommand(
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) {
|
bindings: ReturnType<typeof getAskKeyBindings>,
|
||||||
|
data: string
|
||||||
|
): AskInputCommand {
|
||||||
|
if (matchesBinding(data, bindings.nextTab)) {
|
||||||
return { kind: "moveTab", delta: 1 };
|
return { kind: "moveTab", delta: 1 };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) {
|
if (matchesBinding(data, bindings.previousTab)) {
|
||||||
return { kind: "moveTab", delta: -1 };
|
return { kind: "moveTab", delta: -1 };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) {
|
if (matchesBinding(data, bindings.previousOption)) {
|
||||||
return { kind: "moveOption", delta: -1 };
|
return { kind: "moveOption", delta: -1 };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) {
|
if (matchesBinding(data, bindings.nextOption)) {
|
||||||
return { kind: "moveOption", delta: 1 };
|
return { kind: "moveOption", delta: 1 };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.toggle)) {
|
if (matchesBinding(data, bindings.toggle)) {
|
||||||
return { kind: "toggleMulti" };
|
return { kind: "toggleMulti" };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.confirm)) {
|
if (matchesBinding(data, bindings.confirm)) {
|
||||||
return { kind: "confirm" };
|
return { kind: "confirm" };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) {
|
if (matchesBinding(data, bindings.cancel)) {
|
||||||
return { kind: "cancel" };
|
return { kind: "cancel" };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.questionNote)) {
|
if (matchesBinding(data, bindings.questionNote)) {
|
||||||
return { kind: "openQuestionNote" };
|
return { kind: "openQuestionNote" };
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.optionNote)) {
|
if (matchesBinding(data, bindings.optionNote)) {
|
||||||
return { kind: "openOptionNote" };
|
return { kind: "openOptionNote" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-7
@@ -1,4 +1,5 @@
|
|||||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||||
|
import type { AskConfig } from "../config/schema.ts";
|
||||||
import {
|
import {
|
||||||
getCurrentQuestion,
|
getCurrentQuestion,
|
||||||
isQuestionAnswered,
|
isQuestionAnswered,
|
||||||
@@ -28,14 +29,15 @@ export function renderFrameHeader(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderFrameFooter(args: {
|
export function renderFrameFooter(args: {
|
||||||
|
config: AskConfig;
|
||||||
lines: string[];
|
lines: string[];
|
||||||
state: AskState;
|
state: AskState;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
width: number;
|
width: number;
|
||||||
}) {
|
}) {
|
||||||
const { lines, state, theme, width } = args;
|
const { config, lines, state, theme, width } = args;
|
||||||
const add = (text = "") => lines.push(truncateToWidth(text, width));
|
const add = (text = "") => lines.push(truncateToWidth(text, width));
|
||||||
const footer = renderFooter(state, width);
|
const footer = renderFooter(config, state, width);
|
||||||
if (footer.length > 0) {
|
if (footer.length > 0) {
|
||||||
add();
|
add();
|
||||||
for (const line of footer) {
|
for (const line of footer) {
|
||||||
@@ -178,17 +180,24 @@ function applyTabGrowth(
|
|||||||
range.end = nextIndex;
|
range.end = nextIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFooter(state: AskState, width: number): string[] {
|
function renderFooter(
|
||||||
|
config: AskConfig,
|
||||||
|
state: AskState,
|
||||||
|
width: number
|
||||||
|
): string[] {
|
||||||
let footer: string;
|
let footer: string;
|
||||||
if (state.view.kind === "input") {
|
if (state.view.kind === "input") {
|
||||||
footer = renderFooterText("input");
|
footer = renderFooterText(config, "input");
|
||||||
} else if (state.view.kind === "note") {
|
} else if (state.view.kind === "note") {
|
||||||
footer = renderFooterText("note");
|
footer = renderFooterText(config, "note");
|
||||||
} else if (isSubmitTab(state)) {
|
} else if (isSubmitTab(state)) {
|
||||||
footer = renderFooterText("submit");
|
footer = renderFooterText(config, "submit");
|
||||||
} else {
|
} else {
|
||||||
const question = getCurrentQuestion(state);
|
const question = getCurrentQuestion(state);
|
||||||
footer = renderFooterText(question?.type === "multi" ? "multi" : "default");
|
footer = renderFooterText(
|
||||||
|
config,
|
||||||
|
question?.type === "multi" ? "multi" : "default"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return wrapDelimitedFooterHints(footer, width);
|
return wrapDelimitedFooterHints(footer, width);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||||
|
import type { AskConfig } from "../config/schema.ts";
|
||||||
import {
|
import {
|
||||||
type FooterKeymapContext,
|
type FooterKeymapContext,
|
||||||
renderFooterKeymaps,
|
renderFooterKeymaps,
|
||||||
@@ -372,8 +373,11 @@ export function pushSavedNote(args: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderFooterText(mode: FooterKeymapContext): string {
|
export function renderFooterText(
|
||||||
return renderFooterKeymaps(mode);
|
config: AskConfig,
|
||||||
|
mode: FooterKeymapContext
|
||||||
|
): string {
|
||||||
|
return renderFooterKeymaps(config, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function padToVisibleWidth(text: string, width: number): string {
|
function padToVisibleWidth(text: string, width: number): string {
|
||||||
|
|||||||
+4
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AskConfig } from "../config/schema.ts";
|
||||||
import {
|
import {
|
||||||
getCurrentQuestion,
|
getCurrentQuestion,
|
||||||
getRenderableOptions,
|
getRenderableOptions,
|
||||||
@@ -10,12 +11,13 @@ import { renderSubmitScreen } from "./render-submit.ts";
|
|||||||
import type { QuestionRenderContext, Theme } from "./render-types.ts";
|
import type { QuestionRenderContext, Theme } from "./render-types.ts";
|
||||||
|
|
||||||
export function renderAskScreen(args: {
|
export function renderAskScreen(args: {
|
||||||
|
config: AskConfig;
|
||||||
state: AskState;
|
state: AskState;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
width: number;
|
width: number;
|
||||||
editor: QuestionRenderContext["editor"];
|
editor: QuestionRenderContext["editor"];
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const { state, theme, width, editor } = args;
|
const { config, state, theme, width, editor } = args;
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const question = getCurrentQuestion(state);
|
const question = getCurrentQuestion(state);
|
||||||
const options = getRenderableOptions(question);
|
const options = getRenderableOptions(question);
|
||||||
@@ -36,6 +38,6 @@ export function renderAskScreen(args: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFrameFooter({ lines, state, theme, width });
|
renderFrameFooter({ config, lines, state, theme, width });
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|||||||
+330
-45
@@ -1,81 +1,214 @@
|
|||||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
||||||
import {
|
import {
|
||||||
ASK_HELP_CLOSE_BINDINGS,
|
Key,
|
||||||
ASK_HELP_CLOSE_HINT,
|
matchesKey,
|
||||||
ASK_KEY_BINDINGS,
|
truncateToWidth,
|
||||||
ASK_KEYMAPS,
|
visibleWidth,
|
||||||
matchesBinding,
|
} from "@mariozechner/pi-tui";
|
||||||
} from "../constants/keymaps.ts";
|
import type { AskConfig } from "../config/schema.ts";
|
||||||
|
import type { AskConfigNotice } from "../config/store.ts";
|
||||||
|
import { type AskKeyBinding, getAskKeymaps } from "../constants/keymaps.ts";
|
||||||
import { wrapText } from "../text.ts";
|
import { wrapText } from "../text.ts";
|
||||||
|
import {
|
||||||
type SettingsTab = "keymaps" | "behaviour";
|
type AskSettingsState,
|
||||||
|
clearTransientNotice,
|
||||||
|
completeSave,
|
||||||
|
createAskSettingsState,
|
||||||
|
failSave,
|
||||||
|
isDirty,
|
||||||
|
moveBehaviourFocus,
|
||||||
|
moveSettingsTab,
|
||||||
|
requestDiscard,
|
||||||
|
setSaving,
|
||||||
|
shouldDiscard,
|
||||||
|
toggleAutoSubmit,
|
||||||
|
} from "./settings-state.ts";
|
||||||
|
|
||||||
interface Theme {
|
interface Theme {
|
||||||
bg(color: string, text: string): string;
|
bg(color: string, text: string): string;
|
||||||
fg(color: string, text: string): string;
|
fg(color: string, text: string): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TuiLike {
|
||||||
|
requestRender(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AskSettingsModalOptions {
|
||||||
|
configPath: string;
|
||||||
|
notice?: AskConfigNotice;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (config: AskConfig) => Promise<AskConfig>;
|
||||||
|
savedConfig: AskConfig;
|
||||||
|
tui: TuiLike;
|
||||||
|
}
|
||||||
|
|
||||||
const MIN_WIDTH = 24;
|
const MIN_WIDTH = 24;
|
||||||
const COMPACT_WIDTH = 46;
|
const COMPACT_WIDTH = 46;
|
||||||
const TAB_GAP = " ";
|
const TAB_GAP = " ";
|
||||||
|
const DISCARD_WINDOW_MS = 1000;
|
||||||
|
const SETTINGS_MODAL_KEYS = {
|
||||||
|
close: [Key.escape, Key.ctrl("c"), "?"],
|
||||||
|
nextTab: [Key.tab, Key.right],
|
||||||
|
previousTab: [Key.shift("tab"), Key.left],
|
||||||
|
up: [Key.up],
|
||||||
|
down: [Key.down],
|
||||||
|
toggle: [Key.space],
|
||||||
|
save: [Key.ctrl("s")],
|
||||||
|
} as const;
|
||||||
|
const BEHAVIOUR_ROWS = [
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Auto-submit completed ask flows when no question or option notes were added.",
|
||||||
|
getValue(config: AskConfig) {
|
||||||
|
return config.behaviour.autoSubmitWhenAnsweredWithoutNotes;
|
||||||
|
},
|
||||||
|
label: "Auto-submit when answered without notes",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SettingsTab = "keymaps" | "behaviour";
|
||||||
|
|
||||||
function padToWidth(text: string, width: number): string {
|
function padToWidth(text: string, width: number): string {
|
||||||
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
|
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AskSettingsModal {
|
export class AskSettingsModal {
|
||||||
private activeTab: SettingsTab = "keymaps";
|
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
private readonly configPath: string;
|
||||||
private readonly onClose: () => void;
|
private readonly onClose: () => void;
|
||||||
|
private readonly onSave: (config: AskConfig) => Promise<AskConfig>;
|
||||||
|
private state: AskSettingsState;
|
||||||
private readonly theme: Theme;
|
private readonly theme: Theme;
|
||||||
|
private readonly tui: TuiLike;
|
||||||
|
|
||||||
constructor(theme: Theme, onClose: () => void) {
|
constructor(theme: Theme, options: AskSettingsModalOptions) {
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
this.onClose = onClose;
|
this.configPath = options.configPath;
|
||||||
|
this.onClose = options.onClose;
|
||||||
|
this.onSave = options.onSave;
|
||||||
|
this.state = createAskSettingsState(options.savedConfig, options.notice);
|
||||||
|
this.tui = options.tui;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput(data: string): void {
|
handleInput(data: string): void {
|
||||||
if (
|
if (this.handleGlobalInput(data)) {
|
||||||
ASK_HELP_CLOSE_BINDINGS.some((binding) => matchesBinding(data, binding))
|
|
||||||
) {
|
|
||||||
this.close();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) {
|
if (this.state.activeTab === "behaviour") {
|
||||||
this.activeTab = this.activeTab === "keymaps" ? "behaviour" : "keymaps";
|
this.handleBehaviourInput(data);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) {
|
|
||||||
this.activeTab = this.activeTab === "behaviour" ? "keymaps" : "behaviour";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render(width: number): string[] {
|
render(width: number): string[] {
|
||||||
const layout = createLayout(this.theme, this.activeTab, width);
|
const layout = createLayout(this.theme, this.state.activeTab, width);
|
||||||
const lines = [...layout.headerLines];
|
const lines = [...layout.headerLines];
|
||||||
|
|
||||||
if (this.activeTab === "keymaps") {
|
if (this.state.activeTab === "keymaps") {
|
||||||
lines.push(...renderKeymapsTab(this.theme, layout));
|
lines.push(
|
||||||
|
...renderKeymapsTab(
|
||||||
|
this.theme,
|
||||||
|
layout,
|
||||||
|
this.state.savedConfig,
|
||||||
|
this.configPath,
|
||||||
|
this.state.notice
|
||||||
|
)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
lines.push(...renderBehaviourTab(this.theme, layout));
|
lines.push(...renderBehaviourTab(this.theme, this.state, layout));
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(`${layout.border(`├${"─".repeat(layout.innerW)}┤`)}`);
|
lines.push(...renderBottomPanel(this.theme, this.state, layout));
|
||||||
for (const hintLine of wrapText(ASK_HELP_CLOSE_HINT, layout.innerW - 2)) {
|
|
||||||
lines.push(layout.line(this.theme.fg("muted", hintLine)));
|
|
||||||
}
|
|
||||||
lines.push(`${layout.border(`╰${"─".repeat(layout.innerW)}╯`)}`);
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidate(): void {
|
invalidate(): void {
|
||||||
// Static modal; no async invalidation needed.
|
// State-driven modal; nothing cached across theme changes.
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleGlobalInput(data: string): boolean {
|
||||||
|
const closeRequested = matchesAny(data, SETTINGS_MODAL_KEYS.close);
|
||||||
|
if (!closeRequested) {
|
||||||
|
this.state = clearTransientNotice(this.state);
|
||||||
|
}
|
||||||
|
if (closeRequested) {
|
||||||
|
this.handleClose();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (matchesAny(data, SETTINGS_MODAL_KEYS.save)) {
|
||||||
|
this.save().catch(() => {
|
||||||
|
// save() handles state updates and rerendering on failure
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (matchesAny(data, SETTINGS_MODAL_KEYS.nextTab)) {
|
||||||
|
this.state = moveSettingsTab(this.state, 1);
|
||||||
|
this.refresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (matchesAny(data, SETTINGS_MODAL_KEYS.previousTab)) {
|
||||||
|
this.state = moveSettingsTab(this.state, -1);
|
||||||
|
this.refresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleBehaviourInput(data: string): void {
|
||||||
|
if (matchesAny(data, SETTINGS_MODAL_KEYS.up)) {
|
||||||
|
this.state = moveBehaviourFocus(this.state, -1, BEHAVIOUR_ROWS.length);
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesAny(data, SETTINGS_MODAL_KEYS.down)) {
|
||||||
|
this.state = moveBehaviourFocus(this.state, 1, BEHAVIOUR_ROWS.length);
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesAny(data, SETTINGS_MODAL_KEYS.toggle)) {
|
||||||
|
this.state = toggleAutoSubmit(this.state);
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleClose(): void {
|
||||||
|
if (!isDirty(this.state)) {
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (shouldDiscard(this.state, now)) {
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state = requestDiscard(this.state, now, DISCARD_WINDOW_MS);
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save(): Promise<void> {
|
||||||
|
if (this.state.saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state = setSaving(this.state);
|
||||||
|
this.refresh();
|
||||||
|
try {
|
||||||
|
const savedConfig = await this.onSave(this.state.draftConfig);
|
||||||
|
this.state = completeSave(this.state, savedConfig);
|
||||||
|
} catch (error) {
|
||||||
|
this.state = failSave(
|
||||||
|
this.state,
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(): void {
|
||||||
|
this.tui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
private close(): void {
|
private close(): void {
|
||||||
if (this.closed) {
|
if (this.closed) {
|
||||||
return;
|
return;
|
||||||
@@ -103,8 +236,8 @@ function createLayout(
|
|||||||
const compact = innerW < COMPACT_WIDTH;
|
const compact = innerW < COMPACT_WIDTH;
|
||||||
const keyW = compact ? 11 : 16;
|
const keyW = compact ? 11 : 16;
|
||||||
const descW = Math.max(8, innerW - keyW - 5);
|
const descW = Math.max(8, innerW - keyW - 5);
|
||||||
const border = (text: string) => theme.fg("border", text);
|
const border = (text: string) => theme.fg("muted", text);
|
||||||
const title = theme.fg("accent", " @eko24ive/pi-ask ");
|
const title = theme.fg("muted", " @eko24ive/pi-ask ");
|
||||||
const titleW = visibleWidth(title);
|
const titleW = visibleWidth(title);
|
||||||
const titlePad = Math.max(0, innerW - titleW);
|
const titlePad = Math.max(0, innerW - titleW);
|
||||||
const left = Math.floor(titlePad / 2);
|
const left = Math.floor(titlePad / 2);
|
||||||
@@ -126,11 +259,122 @@ function createLayout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] {
|
function renderBottomPanel(
|
||||||
|
theme: Theme,
|
||||||
|
state: AskSettingsState,
|
||||||
|
layout: SettingsLayout
|
||||||
|
): string[] {
|
||||||
|
const lines = [`${layout.border(`├${"─".repeat(layout.innerW)}┤`)}`];
|
||||||
|
const notice = state.notice ?? getDefaultNotice(state);
|
||||||
|
if (notice) {
|
||||||
|
for (const noticeLine of wrapText(notice.text, layout.innerW - 2)) {
|
||||||
|
lines.push(layout.line(colorNotice(theme, notice.kind, noticeLine)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const hintLine of wrapText(renderCloseHint(state), layout.innerW - 2)) {
|
||||||
|
lines.push(layout.line(theme.fg("muted", hintLine)));
|
||||||
|
}
|
||||||
|
lines.push(`${layout.border(`╰${"─".repeat(layout.innerW)}╯`)}`);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBehaviourTab(
|
||||||
|
theme: Theme,
|
||||||
|
state: AskSettingsState,
|
||||||
|
layout: SettingsLayout
|
||||||
|
): string[] {
|
||||||
|
return BEHAVIOUR_ROWS.flatMap((row, index) =>
|
||||||
|
renderBehaviourRow(theme, state, layout, row, index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBehaviourRow(
|
||||||
|
theme: Theme,
|
||||||
|
state: AskSettingsState,
|
||||||
|
layout: SettingsLayout,
|
||||||
|
row: (typeof BEHAVIOUR_ROWS)[number],
|
||||||
|
index: number
|
||||||
|
): string[] {
|
||||||
|
const focused = index === state.behaviourFocusIndex;
|
||||||
|
const dirty =
|
||||||
|
row.getValue(state.draftConfig) !== row.getValue(state.savedConfig);
|
||||||
|
const checked = row.getValue(state.draftConfig);
|
||||||
|
const marker = checked ? "[x]" : "[ ]";
|
||||||
|
let labelColor: "warning" | "accent" | "text" = "text";
|
||||||
|
if (dirty) {
|
||||||
|
labelColor = "warning";
|
||||||
|
} else if (focused) {
|
||||||
|
labelColor = "accent";
|
||||||
|
}
|
||||||
|
let label = `${marker} ${row.label}`;
|
||||||
|
label = theme.fg(labelColor, label);
|
||||||
|
if (focused) {
|
||||||
|
label = theme.bg("selectedBg", label);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
layout.line(truncateToWidth(label, layout.innerW - 1, "…")),
|
||||||
|
...wrapText(row.description, layout.innerW - 4).map((descLine) =>
|
||||||
|
layout.line(` ${theme.fg("dim", descLine)}`)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (index < BEHAVIOUR_ROWS.length - 1) {
|
||||||
|
lines.push(layout.line());
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKeymapsTab(
|
||||||
|
theme: Theme,
|
||||||
|
layout: SettingsLayout,
|
||||||
|
config: AskConfig,
|
||||||
|
configPath: string,
|
||||||
|
notice?: AskConfigNotice
|
||||||
|
): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const compact = layout.innerW < COMPACT_WIDTH;
|
const compact = layout.innerW < COMPACT_WIDTH;
|
||||||
|
const { customizable, fixed } = getAskKeymaps(config);
|
||||||
|
const headerLines = [
|
||||||
|
"Edit this config file to change customizable ask keymaps:",
|
||||||
|
configPath,
|
||||||
|
"Restart pi or run /reload after editing.",
|
||||||
|
];
|
||||||
|
|
||||||
for (const hint of ASK_KEYMAPS) {
|
for (const text of headerLines) {
|
||||||
|
for (const line of wrapText(text, layout.innerW - 2)) {
|
||||||
|
lines.push(
|
||||||
|
layout.line(theme.fg(text === configPath ? "accent" : "dim", line))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notice?.kind === "error") {
|
||||||
|
lines.push(layout.line());
|
||||||
|
for (const line of wrapText(
|
||||||
|
"Using default keymaps this session because configured keymaps were rejected.",
|
||||||
|
layout.innerW - 2
|
||||||
|
)) {
|
||||||
|
lines.push(layout.line(theme.fg("warning", line)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(layout.line());
|
||||||
|
lines.push(layout.line(theme.fg("muted", "Customizable")));
|
||||||
|
pushKeymapRows(lines, theme, layout, customizable, compact);
|
||||||
|
lines.push(
|
||||||
|
layout.line(theme.fg("muted", "─".repeat(Math.max(1, layout.innerW - 2))))
|
||||||
|
);
|
||||||
|
lines.push(layout.line(theme.fg("muted", "Fixed")));
|
||||||
|
pushKeymapRows(lines, theme, layout, fixed, compact);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushKeymapRows(
|
||||||
|
lines: string[],
|
||||||
|
theme: Theme,
|
||||||
|
layout: SettingsLayout,
|
||||||
|
keymaps: readonly AskKeyBinding[],
|
||||||
|
compact: boolean
|
||||||
|
) {
|
||||||
|
for (const hint of keymaps) {
|
||||||
if (compact) {
|
if (compact) {
|
||||||
lines.push(layout.line(theme.fg("accent", hint.label)));
|
lines.push(layout.line(theme.fg("accent", hint.label)));
|
||||||
for (const descLine of wrapText(hint.description, layout.innerW - 3)) {
|
for (const descLine of wrapText(hint.description, layout.innerW - 3)) {
|
||||||
@@ -138,7 +382,6 @@ function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyText = padToWidth(
|
const keyText = padToWidth(
|
||||||
truncateToWidth(hint.label, layout.keyW, "…"),
|
truncateToWidth(hint.label, layout.keyW, "…"),
|
||||||
layout.keyW
|
layout.keyW
|
||||||
@@ -151,14 +394,6 @@ function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] {
|
|||||||
layout.line(`${" ".repeat(layout.keyW)} ${theme.fg("dim", contexts)}`)
|
layout.line(`${" ".repeat(layout.keyW)} ${theme.fg("dim", contexts)}`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBehaviourTab(theme: Theme, layout: SettingsLayout): string[] {
|
|
||||||
return wrapText("Behaviour settings coming soon.", layout.innerW - 2).map(
|
|
||||||
(settingsLine) => layout.line(theme.fg("muted", settingsLine))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTabs(
|
function renderTabs(
|
||||||
@@ -179,3 +414,53 @@ function renderTabs(
|
|||||||
].join(TAB_GAP);
|
].join(TAB_GAP);
|
||||||
return truncateToWidth(tabs, width, "");
|
return truncateToWidth(tabs, width, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function colorNotice(
|
||||||
|
theme: Theme,
|
||||||
|
kind: AskConfigNotice["kind"],
|
||||||
|
text: string
|
||||||
|
): string {
|
||||||
|
switch (kind) {
|
||||||
|
case "error":
|
||||||
|
return theme.fg("error", text);
|
||||||
|
case "success":
|
||||||
|
return theme.fg("success", text);
|
||||||
|
case "warning":
|
||||||
|
return theme.fg("warning", text);
|
||||||
|
default:
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultNotice(
|
||||||
|
state: AskSettingsState
|
||||||
|
): AskConfigNotice | undefined {
|
||||||
|
if (state.saving) {
|
||||||
|
return { kind: "warning", text: "Saving…" };
|
||||||
|
}
|
||||||
|
if (isDirty(state)) {
|
||||||
|
return {
|
||||||
|
kind: "warning",
|
||||||
|
text: "Unsaved changes. Press Ctrl+S to save.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCloseHint(state: AskSettingsState): string {
|
||||||
|
if (isDirty(state)) {
|
||||||
|
return "Ctrl+S save · Esc/Ctrl+C/? twice discard";
|
||||||
|
}
|
||||||
|
return "Esc, Ctrl+C, or ? close";
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchableKey = Parameters<typeof matchesKey>[1] | string;
|
||||||
|
|
||||||
|
function matchesAny(data: string, keys: readonly MatchableKey[]): boolean {
|
||||||
|
return keys.some((key) => {
|
||||||
|
if (key.length === 1) {
|
||||||
|
return data === key;
|
||||||
|
}
|
||||||
|
return matchesKey(data, key as Parameters<typeof matchesKey>[1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { normalizeAskConfig } from "../config/defaults.ts";
|
||||||
|
import type { AskConfig } from "../config/schema.ts";
|
||||||
|
import type { AskConfigNotice } from "../config/store.ts";
|
||||||
|
|
||||||
|
export interface AskSettingsState {
|
||||||
|
activeTab: "keymaps" | "behaviour";
|
||||||
|
behaviourFocusIndex: number;
|
||||||
|
draftConfig: AskConfig;
|
||||||
|
notice?: AskConfigNotice;
|
||||||
|
pendingCloseUntil?: number;
|
||||||
|
savedConfig: AskConfig;
|
||||||
|
saving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAskSettingsState(
|
||||||
|
savedConfig: AskConfig,
|
||||||
|
notice?: AskConfigNotice
|
||||||
|
): AskSettingsState {
|
||||||
|
return {
|
||||||
|
activeTab: "keymaps",
|
||||||
|
behaviourFocusIndex: 0,
|
||||||
|
draftConfig: normalizeAskConfig(savedConfig),
|
||||||
|
notice,
|
||||||
|
savedConfig: normalizeAskConfig(savedConfig),
|
||||||
|
saving: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTransientNotice(
|
||||||
|
state: AskSettingsState
|
||||||
|
): AskSettingsState {
|
||||||
|
if (!state.notice || state.notice.kind === "error") {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
notice: undefined,
|
||||||
|
pendingCloseUntil: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeSave(
|
||||||
|
state: AskSettingsState,
|
||||||
|
savedConfig: AskConfig
|
||||||
|
): AskSettingsState {
|
||||||
|
const normalized = normalizeAskConfig(savedConfig);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
draftConfig: normalized,
|
||||||
|
notice: { kind: "success", text: "Saved" },
|
||||||
|
pendingCloseUntil: undefined,
|
||||||
|
savedConfig: normalized,
|
||||||
|
saving: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function failSave(
|
||||||
|
state: AskSettingsState,
|
||||||
|
message: string
|
||||||
|
): AskSettingsState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
notice: { kind: "error", text: message },
|
||||||
|
saving: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDirty(state: AskSettingsState): boolean {
|
||||||
|
return !deepEqual(state.draftConfig, state.savedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveBehaviourFocus(
|
||||||
|
state: AskSettingsState,
|
||||||
|
delta: 1 | -1,
|
||||||
|
rowCount: number
|
||||||
|
): AskSettingsState {
|
||||||
|
if (rowCount <= 0) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const next = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(rowCount - 1, state.behaviourFocusIndex + delta)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
behaviourFocusIndex: next,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveSettingsTab(
|
||||||
|
state: AskSettingsState,
|
||||||
|
delta: 1 | -1
|
||||||
|
): AskSettingsState {
|
||||||
|
if (delta === 1) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activeTab: state.activeTab === "keymaps" ? "behaviour" : "keymaps",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activeTab: state.activeTab === "behaviour" ? "keymaps" : "behaviour",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestDiscard(
|
||||||
|
state: AskSettingsState,
|
||||||
|
now: number,
|
||||||
|
windowMs: number
|
||||||
|
): AskSettingsState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
notice: {
|
||||||
|
kind: "warning",
|
||||||
|
text: "Unsaved changes. Press again to discard.",
|
||||||
|
},
|
||||||
|
pendingCloseUntil: now + windowMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSaving(state: AskSettingsState): AskSettingsState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
notice: undefined,
|
||||||
|
pendingCloseUntil: undefined,
|
||||||
|
saving: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldDiscard(state: AskSettingsState, now: number): boolean {
|
||||||
|
return !!state.pendingCloseUntil && now <= state.pendingCloseUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleAutoSubmit(state: AskSettingsState): AskSettingsState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
draftConfig: {
|
||||||
|
...state.draftConfig,
|
||||||
|
behaviour: {
|
||||||
|
...state.draftConfig.behaviour,
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes:
|
||||||
|
!state.draftConfig.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepEqual(left: AskConfig, right: AskConfig): boolean {
|
||||||
|
return (
|
||||||
|
left.behaviour.autoSubmitWhenAnsweredWithoutNotes ===
|
||||||
|
right.behaviour.autoSubmitWhenAnsweredWithoutNotes &&
|
||||||
|
left.keymaps.cancel === right.keymaps.cancel &&
|
||||||
|
left.keymaps.dismiss === right.keymaps.dismiss &&
|
||||||
|
left.keymaps.toggle === right.keymaps.toggle &&
|
||||||
|
left.keymaps.confirm === right.keymaps.confirm &&
|
||||||
|
left.keymaps.optionNote === right.keymaps.optionNote &&
|
||||||
|
left.keymaps.questionNote === right.keymaps.questionNote
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { getAskConfigPath, getAskConfigStore } from "../config/store.ts";
|
||||||
import { AskSettingsModal } from "./settings-modal.ts";
|
import { AskSettingsModal } from "./settings-modal.ts";
|
||||||
|
|
||||||
export function showAskSettingsModal(
|
export async function showAskSettingsModal(
|
||||||
ui: Pick<ExtensionContext, "ui">["ui"]
|
ui: Pick<ExtensionContext, "ui">["ui"]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const store = getAskConfigStore();
|
||||||
|
const { config, notice } = await store.ensureLoaded();
|
||||||
return ui.custom<void>(
|
return ui.custom<void>(
|
||||||
(_tui, theme, _keybindings, done) =>
|
(tui, theme, _keybindings, done) =>
|
||||||
new AskSettingsModal(theme, () => {
|
new AskSettingsModal(theme, {
|
||||||
|
configPath: getAskConfigPath(),
|
||||||
|
notice,
|
||||||
|
onClose: () => {
|
||||||
done();
|
done();
|
||||||
|
},
|
||||||
|
onSave: async (nextConfig) => store.save(nextConfig),
|
||||||
|
savedConfig: config,
|
||||||
|
tui,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
overlay: true,
|
overlay: true,
|
||||||
|
|||||||
@@ -127,27 +127,26 @@ function buildOptionDetailModel(
|
|||||||
const note = option.isCustomOption
|
const note = option.isCustomOption
|
||||||
? undefined
|
? undefined
|
||||||
: getOptionNote(state, question.id, option.value);
|
: getOptionNote(state, question.id, option.value);
|
||||||
const withGap = !option.isCustomOption;
|
|
||||||
|
|
||||||
if (noteOpen) {
|
if (noteOpen) {
|
||||||
return {
|
return {
|
||||||
kind: "editor",
|
kind: "editor",
|
||||||
placeholder: UI_TEXT.editorPlaceholderNote,
|
placeholder: UI_TEXT.editorPlaceholderNote,
|
||||||
withGap,
|
withGap: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (inputOpen) {
|
if (inputOpen) {
|
||||||
return {
|
return {
|
||||||
kind: "editor",
|
kind: "editor",
|
||||||
placeholder: UI_TEXT.editorPlaceholderInput,
|
placeholder: UI_TEXT.editorPlaceholderInput,
|
||||||
withGap,
|
withGap: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (customText) {
|
if (customText) {
|
||||||
return { kind: "custom-text", text: customText, withGap };
|
return { kind: "custom-text", text: customText, withGap: false };
|
||||||
}
|
}
|
||||||
if (note) {
|
if (note) {
|
||||||
return { kind: "saved-note", text: note, withGap };
|
return { kind: "saved-note", text: note, withGap: false };
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { registerAskSettingsCommand } from "../src/ask-settings-command.ts";
|
import { registerAskSettingsCommand } from "../src/ask-settings-command.ts";
|
||||||
|
|
||||||
@@ -14,6 +17,9 @@ function plainTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("registers /ask-settings and opens the shared modal overlay", async () => {
|
test("registers /ask-settings and opens the shared modal overlay", async () => {
|
||||||
|
const agentDir = await mkdtemp(join(tmpdir(), "pi-ask-command-"));
|
||||||
|
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||||
|
|
||||||
const commands = new Map<
|
const commands = new Map<
|
||||||
string,
|
string,
|
||||||
{ handler: (args: string, ctx: any) => Promise<void> }
|
{ handler: (args: string, ctx: any) => Promise<void> }
|
||||||
@@ -36,7 +42,12 @@ test("registers /ask-settings and opens the shared modal overlay", async () => {
|
|||||||
const done = () => {
|
const done = () => {
|
||||||
// test callback intentionally unused
|
// test callback intentionally unused
|
||||||
};
|
};
|
||||||
const component = callback(undefined, plainTheme(), undefined, done);
|
const tui = {
|
||||||
|
requestRender() {
|
||||||
|
// test render hook intentionally unused
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const component = callback(tui, plainTheme(), undefined, done);
|
||||||
customCalls.push({ options, lines: component.render(72) });
|
customCalls.push({ options, lines: component.render(72) });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
@@ -55,4 +66,7 @@ test("registers /ask-settings and opens the shared modal overlay", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert(customCalls[0]?.lines.join("\n").includes("Keymaps"));
|
assert(customCalls[0]?.lines.join("\n").includes("Keymaps"));
|
||||||
|
|
||||||
|
delete process.env.PI_CODING_AGENT_DIR;
|
||||||
|
await rm(agentDir, { force: true, recursive: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
|
||||||
|
import type { AskConfig } from "../src/config/schema.ts";
|
||||||
|
import { createInitialState } from "../src/state/create.ts";
|
||||||
|
import { saveNote } from "../src/state/transitions.ts";
|
||||||
|
import type { AskState } from "../src/types.ts";
|
||||||
|
import { maybeAutoSubmitState } from "../src/ui/auto-submit.ts";
|
||||||
|
|
||||||
|
const enabledConfig: AskConfig = {
|
||||||
|
...DEFAULT_ASK_CONFIG,
|
||||||
|
behaviour: {
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabledConfig: AskConfig = {
|
||||||
|
...DEFAULT_ASK_CONFIG,
|
||||||
|
behaviour: {
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function answeredSubmitState(): AskState {
|
||||||
|
return {
|
||||||
|
...createInitialState({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
label: "Q1",
|
||||||
|
options: [{ label: "Option A", value: "a" }],
|
||||||
|
prompt: "Pick one",
|
||||||
|
required: false,
|
||||||
|
type: "single",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
activeTabIndex: 1,
|
||||||
|
answers: {
|
||||||
|
q1: {
|
||||||
|
selected: [{ index: 1, label: "Option A", value: "a" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view: { kind: "submit" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("auto submit completes answered submit-tab state when enabled", () => {
|
||||||
|
const state = maybeAutoSubmitState(answeredSubmitState(), enabledConfig);
|
||||||
|
|
||||||
|
assert.equal(state.completed, true);
|
||||||
|
assert.equal(state.mode, "submit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("auto submit does nothing when disabled", () => {
|
||||||
|
const state = maybeAutoSubmitState(answeredSubmitState(), disabledConfig);
|
||||||
|
|
||||||
|
assert.equal(state.completed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("auto submit does not trigger when notes exist", () => {
|
||||||
|
const stateWithNote = saveNote(
|
||||||
|
{ ...answeredSubmitState(), view: { kind: "note", questionId: "q1" } },
|
||||||
|
"Need more detail"
|
||||||
|
);
|
||||||
|
const submitState: AskState = {
|
||||||
|
...stateWithNote,
|
||||||
|
activeTabIndex: 1,
|
||||||
|
view: { kind: "submit" },
|
||||||
|
};
|
||||||
|
const state = maybeAutoSubmitState(submitState, enabledConfig);
|
||||||
|
|
||||||
|
assert.equal(state.completed, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
|
||||||
|
import { AskConfigStore } from "../src/config/store.ts";
|
||||||
|
|
||||||
|
const DEFAULT_KEYMAPS_NOTICE_PATTERN =
|
||||||
|
/Using default ask keymaps for this session/;
|
||||||
|
|
||||||
|
async function makeTempPath(name: string): Promise<string> {
|
||||||
|
const root = await import("node:fs/promises").then(({ mkdtemp }) =>
|
||||||
|
mkdtemp(join(tmpdir(), name))
|
||||||
|
);
|
||||||
|
return join(root, "extensions", "eko24ive-pi-ask.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("config store uses in-memory defaults when file is missing", async () => {
|
||||||
|
const path = await makeTempPath("pi-ask-config-missing-");
|
||||||
|
const store = new AskConfigStore(path);
|
||||||
|
|
||||||
|
const result = await store.ensureLoaded();
|
||||||
|
|
||||||
|
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG);
|
||||||
|
await assert.rejects(readFile(path, "utf-8"));
|
||||||
|
await rm(join(path, "..", ".."), { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config store writes full normalized config on save", async () => {
|
||||||
|
const path = await makeTempPath("pi-ask-config-save-");
|
||||||
|
const store = new AskConfigStore(path);
|
||||||
|
|
||||||
|
await store.save({
|
||||||
|
behaviour: {
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||||
|
},
|
||||||
|
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = await readFile(path, "utf-8");
|
||||||
|
assert.deepEqual(JSON.parse(content), {
|
||||||
|
schemaVersion: 1,
|
||||||
|
behaviour: {
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||||
|
},
|
||||||
|
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
||||||
|
});
|
||||||
|
await rm(join(path, "..", ".."), { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config store backs up broken json and loads defaults", async () => {
|
||||||
|
const path = await makeTempPath("pi-ask-config-broken-");
|
||||||
|
await mkdir(join(path, ".."), { recursive: true });
|
||||||
|
await writeFile(path, "{bad json", "utf-8");
|
||||||
|
const store = new AskConfigStore(path);
|
||||||
|
|
||||||
|
const result = await store.ensureLoaded();
|
||||||
|
const dirEntries = await import("node:fs/promises").then(({ readdir }) =>
|
||||||
|
readdir(join(path, ".."))
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG);
|
||||||
|
assert.equal(
|
||||||
|
result.notice?.text,
|
||||||
|
"Config was invalid or unsupported. Backed it up and loaded defaults. Press Ctrl+S to save a fresh config."
|
||||||
|
);
|
||||||
|
assert(dirEntries.some((entry) => entry.includes(".bak.json")));
|
||||||
|
await assert.rejects(readFile(path, "utf-8"));
|
||||||
|
await rm(join(path, "..", ".."), { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config store loads current config version without rewriting", async () => {
|
||||||
|
const path = await makeTempPath("pi-ask-config-current-");
|
||||||
|
await mkdir(join(path, ".."), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path,
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
behaviour: { autoSubmitWhenAnsweredWithoutNotes: true },
|
||||||
|
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const store = new AskConfigStore(path);
|
||||||
|
|
||||||
|
const result = await store.ensureLoaded();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.deepEqual(result.config.keymaps, DEFAULT_ASK_CONFIG.keymaps);
|
||||||
|
await rm(join(path, "..", ".."), { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config store falls back only keymaps when configured keymaps are invalid", async () => {
|
||||||
|
const path = await makeTempPath("pi-ask-config-invalid-keymaps-");
|
||||||
|
await mkdir(join(path, ".."), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path,
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
behaviour: { autoSubmitWhenAnsweredWithoutNotes: true },
|
||||||
|
keymaps: {
|
||||||
|
cancel: "?",
|
||||||
|
dismiss: "ctrl+c",
|
||||||
|
toggle: "space",
|
||||||
|
confirm: "enter",
|
||||||
|
optionNote: "n",
|
||||||
|
questionNote: "shift+n",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const store = new AskConfigStore(path);
|
||||||
|
|
||||||
|
const result = await store.ensureLoaded();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.deepEqual(result.config.keymaps, DEFAULT_ASK_CONFIG.keymaps);
|
||||||
|
assert.match(result.notice?.text ?? "", DEFAULT_KEYMAPS_NOTICE_PATTERN);
|
||||||
|
await rm(join(path, "..", ".."), { force: true, recursive: true });
|
||||||
|
});
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { visibleWidth } from "@mariozechner/pi-tui";
|
|
||||||
import { ASK_HELP_CLOSE_HINT, ASK_KEYMAPS } from "../src/constants/keymaps.ts";
|
|
||||||
import { AskSettingsModal } from "../src/ui/settings-modal.ts";
|
|
||||||
|
|
||||||
const noop = () => {
|
|
||||||
// Test callback intentionally does nothing.
|
|
||||||
};
|
|
||||||
|
|
||||||
function plainTheme() {
|
|
||||||
return {
|
|
||||||
fg(_color: string, text: string) {
|
|
||||||
return text;
|
|
||||||
},
|
|
||||||
bg(_color: string, text: string) {
|
|
||||||
return text;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("settings modal renders compactly on phone-width screens", () => {
|
|
||||||
const modal = new AskSettingsModal(plainTheme(), noop);
|
|
||||||
const lines = modal.render(28);
|
|
||||||
|
|
||||||
assert(lines.length > ASK_KEYMAPS.length + 4);
|
|
||||||
assert(lines.every((line) => visibleWidth(line) <= 28));
|
|
||||||
assert(lines.join("\n").includes("@eko24ive/pi-ask"));
|
|
||||||
assert(lines.slice(-3, -1).join(" ").includes("Esc, Ctrl+C, or ?"));
|
|
||||||
assert(lines[1]?.includes("Keymaps"));
|
|
||||||
assert.equal(lines[2], "│ │");
|
|
||||||
assert(lines.includes("│ ? │"));
|
|
||||||
assert(lines.includes("│ Show this menu │"));
|
|
||||||
assert.equal(lines[0]?.includes("├"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings modal keeps context rows on wider screens", () => {
|
|
||||||
const modal = new AskSettingsModal(plainTheme(), noop);
|
|
||||||
const lines = modal.render(72);
|
|
||||||
|
|
||||||
assert(lines.length > ASK_KEYMAPS.length + 4);
|
|
||||||
assert(lines.join("\n").includes("Main flow"));
|
|
||||||
assert(lines.at(-2)?.includes(ASK_HELP_CLOSE_HINT));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings modal switches tabs and closes from configured shortcuts", () => {
|
|
||||||
let closed = 0;
|
|
||||||
const modal = new AskSettingsModal(plainTheme(), () => {
|
|
||||||
closed += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.handleInput("\t");
|
|
||||||
assert(
|
|
||||||
modal.render(72).join("\n").includes("Behaviour settings coming soon.")
|
|
||||||
);
|
|
||||||
assert(modal.render(72).join("\n").includes("Behaviour"));
|
|
||||||
|
|
||||||
modal.handleInput("\u001b[Z");
|
|
||||||
assert.equal(
|
|
||||||
modal.render(72).join("\n").includes("Behaviour settings coming soon."),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const input of ["?", "\u001b", "\u0003"]) {
|
|
||||||
const closable = new AskSettingsModal(plainTheme(), () => {
|
|
||||||
closed += 1;
|
|
||||||
});
|
|
||||||
closable.handleInput(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(closed, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings modal ignores enter and closes idempotently on dispose", () => {
|
|
||||||
let closed = 0;
|
|
||||||
const modal = new AskSettingsModal(plainTheme(), () => {
|
|
||||||
closed += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.handleInput("\r");
|
|
||||||
assert.equal(closed, 0);
|
|
||||||
|
|
||||||
modal.dispose();
|
|
||||||
modal.dispose();
|
|
||||||
assert.equal(closed, 1);
|
|
||||||
});
|
|
||||||
+63
-31
@@ -1,5 +1,6 @@
|
|||||||
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 { createInitialState } from "../src/state/create.ts";
|
import { createInitialState } from "../src/state/create.ts";
|
||||||
import {
|
import {
|
||||||
applyNumberShortcut,
|
applyNumberShortcut,
|
||||||
@@ -24,27 +25,27 @@ function inputState() {
|
|||||||
test("empty typing mode uses arrows and tab for navigation", () => {
|
test("empty typing mode uses arrows and tab for navigation", () => {
|
||||||
const input = inputState();
|
const input = inputState();
|
||||||
|
|
||||||
assert.deepEqual(getInputCommand(input, "\x1b[A", ""), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[A", ""), {
|
||||||
kind: "editMoveOption",
|
kind: "editMoveOption",
|
||||||
delta: -1,
|
delta: -1,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\x1b[B", ""), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[B", ""), {
|
||||||
kind: "editMoveOption",
|
kind: "editMoveOption",
|
||||||
delta: 1,
|
delta: 1,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\x1b[C", ""), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[C", ""), {
|
||||||
kind: "editMoveTab",
|
kind: "editMoveTab",
|
||||||
delta: 1,
|
delta: 1,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\x1b[D", ""), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[D", ""), {
|
||||||
kind: "editMoveTab",
|
kind: "editMoveTab",
|
||||||
delta: -1,
|
delta: -1,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\t", ""), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\t", ""), {
|
||||||
kind: "editMoveTab",
|
kind: "editMoveTab",
|
||||||
delta: 1,
|
delta: 1,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\x1b[Z", ""), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[Z", ""), {
|
||||||
kind: "editMoveTab",
|
kind: "editMoveTab",
|
||||||
delta: -1,
|
delta: -1,
|
||||||
});
|
});
|
||||||
@@ -53,22 +54,22 @@ test("empty typing mode uses arrows and tab for navigation", () => {
|
|||||||
test("non-empty typing mode keeps arrows and tab in editor", () => {
|
test("non-empty typing mode keeps arrows and tab in editor", () => {
|
||||||
const input = inputState();
|
const input = inputState();
|
||||||
|
|
||||||
assert.deepEqual(getInputCommand(input, "\x1b[A", "x"), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[A", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\x1b[B", "x"), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[B", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\x1b[C", "x"), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[C", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\x1b[D", "x"), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[D", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\t", "x"), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\t", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\x1b[Z", "x"), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[Z", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -87,23 +88,23 @@ test("empty note editing mode uses arrows and tab for navigation", () => {
|
|||||||
"q1"
|
"q1"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(getInputCommand(state, "\x1b[A", ""), {
|
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[A", ""), {
|
||||||
kind: "editMoveOption",
|
kind: "editMoveOption",
|
||||||
delta: -1,
|
delta: -1,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(state, "\x1b[B", ""), {
|
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[B", ""), {
|
||||||
kind: "editMoveOption",
|
kind: "editMoveOption",
|
||||||
delta: 1,
|
delta: 1,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(state, "\x1b[C", ""), {
|
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[C", ""), {
|
||||||
kind: "editMoveTab",
|
kind: "editMoveTab",
|
||||||
delta: 1,
|
delta: 1,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(state, "\x1b[D", ""), {
|
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[D", ""), {
|
||||||
kind: "editMoveTab",
|
kind: "editMoveTab",
|
||||||
delta: -1,
|
delta: -1,
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(state, "\t", ""), {
|
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\t", ""), {
|
||||||
kind: "editMoveTab",
|
kind: "editMoveTab",
|
||||||
delta: 1,
|
delta: 1,
|
||||||
});
|
});
|
||||||
@@ -123,19 +124,19 @@ test("non-empty note editing mode keeps arrows and tab in editor", () => {
|
|||||||
"q1"
|
"q1"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(getInputCommand(state, "\x1b[A", "x"), {
|
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[A", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(state, "\x1b[B", "x"), {
|
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[B", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(state, "\x1b[C", "x"), {
|
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[C", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(state, "\x1b[D", "x"), {
|
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[D", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(state, "\t", "x"), {
|
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\t", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -153,18 +154,18 @@ test("ctrl+c dismisses the flow from both navigation and editing modes", () => {
|
|||||||
const input = inputState();
|
const input = inputState();
|
||||||
const note = enterQuestionNoteMode(navigation, "q1");
|
const note = enterQuestionNoteMode(navigation, "q1");
|
||||||
|
|
||||||
assert.deepEqual(getInputCommand(navigation, "\u0003"), {
|
assert.deepEqual(getInputCommand(navigation, DEFAULT_ASK_CONFIG, "\u0003"), {
|
||||||
kind: "dismiss",
|
kind: "dismiss",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(input, "\u0003"), {
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\u0003"), {
|
||||||
kind: "dismiss",
|
kind: "dismiss",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(note, "\u0003"), {
|
assert.deepEqual(getInputCommand(note, DEFAULT_ASK_CONFIG, "\u0003"), {
|
||||||
kind: "dismiss",
|
kind: "dismiss",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("question mark opens keymap help outside non-empty editors", () => {
|
test("question mark opens ask settings outside non-empty editors", () => {
|
||||||
const navigation = createInitialState({
|
const navigation = createInitialState({
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -176,9 +177,13 @@ test("question mark opens keymap help outside non-empty editors", () => {
|
|||||||
});
|
});
|
||||||
const input = inputState();
|
const input = inputState();
|
||||||
|
|
||||||
assert.deepEqual(getInputCommand(navigation, "?"), { kind: "showHelp" });
|
assert.deepEqual(getInputCommand(navigation, DEFAULT_ASK_CONFIG, "?"), {
|
||||||
assert.deepEqual(getInputCommand(input, "?", ""), { kind: "showHelp" });
|
kind: "showSettings",
|
||||||
assert.deepEqual(getInputCommand(input, "?", "x"), {
|
});
|
||||||
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "?", ""), {
|
||||||
|
kind: "showSettings",
|
||||||
|
});
|
||||||
|
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "?", "x"), {
|
||||||
kind: "delegateToEditor",
|
kind: "delegateToEditor",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -194,10 +199,37 @@ test("note shortcuts use n for option notes and Shift+N for question notes", ()
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(getInputCommand(navigation, "n"), {
|
assert.deepEqual(getInputCommand(navigation, DEFAULT_ASK_CONFIG, "n"), {
|
||||||
kind: "openOptionNote",
|
kind: "openOptionNote",
|
||||||
});
|
});
|
||||||
assert.deepEqual(getInputCommand(navigation, "N"), {
|
assert.deepEqual(getInputCommand(navigation, DEFAULT_ASK_CONFIG, "N"), {
|
||||||
|
kind: "openQuestionNote",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom configured note shortcuts are used at runtime", () => {
|
||||||
|
const navigation = createInitialState({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
prompt: "Question?",
|
||||||
|
options: [{ value: "a", label: "A" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const config = {
|
||||||
|
...DEFAULT_ASK_CONFIG,
|
||||||
|
keymaps: {
|
||||||
|
...DEFAULT_ASK_CONFIG.keymaps,
|
||||||
|
optionNote: "x",
|
||||||
|
questionNote: "shift+x",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(getInputCommand(navigation, config, "x"), {
|
||||||
|
kind: "openOptionNote",
|
||||||
|
});
|
||||||
|
assert.deepEqual(getInputCommand(navigation, config, "X"), {
|
||||||
kind: "openQuestionNote",
|
kind: "openQuestionNote",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 { createInitialState } from "../src/state/create.ts";
|
import { createInitialState } from "../src/state/create.ts";
|
||||||
import { renderAskScreen } from "../src/ui/render.ts";
|
import { renderAskScreen } from "../src/ui/render.ts";
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ test("wide header keeps all tabs and framing arrows on the tab row", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lines = renderAskScreen({
|
const lines = renderAskScreen({
|
||||||
|
config: DEFAULT_ASK_CONFIG,
|
||||||
state,
|
state,
|
||||||
theme: plainTheme(),
|
theme: plainTheme(),
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -96,6 +98,7 @@ test("narrow tab strip keeps active middle tab visible", () => {
|
|||||||
state.activeTabIndex = 2;
|
state.activeTabIndex = 2;
|
||||||
|
|
||||||
const lines = renderAskScreen({
|
const lines = renderAskScreen({
|
||||||
|
config: DEFAULT_ASK_CONFIG,
|
||||||
state,
|
state,
|
||||||
theme: plainTheme(),
|
theme: plainTheme(),
|
||||||
width: 28,
|
width: 28,
|
||||||
@@ -133,6 +136,7 @@ test("narrow tab strip keeps submit tab visible when active", () => {
|
|||||||
state.view = { kind: "submit" };
|
state.view = { kind: "submit" };
|
||||||
|
|
||||||
const lines = renderAskScreen({
|
const lines = renderAskScreen({
|
||||||
|
config: DEFAULT_ASK_CONFIG,
|
||||||
state,
|
state,
|
||||||
theme: plainTheme(),
|
theme: plainTheme(),
|
||||||
width: 24,
|
width: 24,
|
||||||
@@ -190,6 +194,7 @@ test("tab strip avoids truncation at narrow boundary widths", () => {
|
|||||||
|
|
||||||
for (const [width, expected] of expectedByWidth) {
|
for (const [width, expected] of expectedByWidth) {
|
||||||
const lines = renderAskScreen({
|
const lines = renderAskScreen({
|
||||||
|
config: DEFAULT_ASK_CONFIG,
|
||||||
state,
|
state,
|
||||||
theme: plainTheme(),
|
theme: plainTheme(),
|
||||||
width,
|
width,
|
||||||
@@ -213,6 +218,7 @@ test("footer hints wrap into exact lines on narrow screens", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lines = renderAskScreen({
|
const lines = renderAskScreen({
|
||||||
|
config: DEFAULT_ASK_CONFIG,
|
||||||
state,
|
state,
|
||||||
theme: plainTheme(),
|
theme: plainTheme(),
|
||||||
width: 26,
|
width: 26,
|
||||||
@@ -223,7 +229,7 @@ test("footer hints wrap into exact lines on narrow screens", () => {
|
|||||||
"Space toggle",
|
"Space toggle",
|
||||||
"Enter continue",
|
"Enter continue",
|
||||||
"N/Shift+N note",
|
"N/Shift+N note",
|
||||||
"Esc dismiss · ? help",
|
"Esc dismiss · ? settings",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,16 +246,18 @@ test("footer keeps earlier hint chunk on the first wrapped line", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lines = renderAskScreen({
|
const lines = renderAskScreen({
|
||||||
|
config: DEFAULT_ASK_CONFIG,
|
||||||
state,
|
state,
|
||||||
theme: plainTheme(),
|
theme: plainTheme(),
|
||||||
width: 22,
|
width: 22,
|
||||||
editor: mockEditor(),
|
editor: mockEditor(),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(lines.at(-5), " ⇆ tab · ↑↓ select");
|
assert.equal(lines.at(-6), " ⇆ tab · ↑↓ select");
|
||||||
assert.deepEqual(lines.slice(-4, -1), [
|
assert.deepEqual(lines.slice(-5, -1), [
|
||||||
"Enter confirm",
|
"Enter confirm",
|
||||||
"N/Shift+N note",
|
"N/Shift+N note",
|
||||||
"Esc dismiss · ? help",
|
"Esc dismiss",
|
||||||
|
"? settings",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 {
|
import {
|
||||||
renderEditorBlock,
|
renderEditorBlock,
|
||||||
renderFooterText,
|
renderFooterText,
|
||||||
@@ -74,6 +75,28 @@ test("renderEditorBlock reapplies background after editor reset sequences", () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("editing footers do not advertise tab navigation", () => {
|
test("editing footers do not advertise tab navigation", () => {
|
||||||
assert.equal(renderFooterText("input"), " Enter submit · Esc close · ? help");
|
assert.equal(
|
||||||
assert.equal(renderFooterText("note"), " Enter save · Esc close · ? help");
|
renderFooterText(DEFAULT_ASK_CONFIG, "input"),
|
||||||
|
" Enter submit · Esc close · ? settings"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
renderFooterText(DEFAULT_ASK_CONFIG, "note"),
|
||||||
|
" Enter save · Esc close · ? settings"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editing footers use configured key labels", () => {
|
||||||
|
const config = {
|
||||||
|
...DEFAULT_ASK_CONFIG,
|
||||||
|
keymaps: {
|
||||||
|
...DEFAULT_ASK_CONFIG.keymaps,
|
||||||
|
confirm: "ctrl+k",
|
||||||
|
cancel: "q",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
renderFooterText(config, "note"),
|
||||||
|
" Ctrl+K save · Q close · ? settings"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createInitialState } from "../src/state/create.ts";
|
|||||||
import { getRenderableOptions } from "../src/state/selectors.ts";
|
import { getRenderableOptions } from "../src/state/selectors.ts";
|
||||||
import {
|
import {
|
||||||
applyNumberShortcut,
|
applyNumberShortcut,
|
||||||
|
enterOptionNoteMode,
|
||||||
enterQuestionNoteMode,
|
enterQuestionNoteMode,
|
||||||
} from "../src/state/transitions.ts";
|
} from "../src/state/transitions.ts";
|
||||||
import { renderQuestionScreen } from "../src/ui/render-question.ts";
|
import { renderQuestionScreen } from "../src/ui/render-question.ts";
|
||||||
@@ -121,6 +122,39 @@ test("open question note renders inline label and editor", () => {
|
|||||||
assert(!lines.some((line) => line.includes("Note:")));
|
assert(!lines.some((line) => line.includes("Note:")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("open option note renders flush below the option", () => {
|
||||||
|
const state = enterOptionNoteMode(
|
||||||
|
createInitialState({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
prompt: "Pick one",
|
||||||
|
options: [{ value: "a", label: "Option A" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
"q1",
|
||||||
|
"a"
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
renderQuestionScreen({
|
||||||
|
editor: mockEditor("", ["┌────┐", "", "└────┘"]),
|
||||||
|
lines,
|
||||||
|
options: getRenderableOptions(state.questions[0]),
|
||||||
|
question: state.questions[0],
|
||||||
|
state,
|
||||||
|
theme: mockTheme(),
|
||||||
|
width: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
const optionIndex = lines.findIndex((line) => line.includes("Option A"));
|
||||||
|
const inputIndex = lines.findIndex((line) => line.includes("Add a note..."));
|
||||||
|
|
||||||
|
assert.notEqual(optionIndex, -1);
|
||||||
|
assert.equal(inputIndex, optionIndex + 1);
|
||||||
|
});
|
||||||
|
|
||||||
test("selected multiline custom option renders full editor block", () => {
|
test("selected multiline custom option renders full editor block", () => {
|
||||||
let state = createInitialState({
|
let state = createInitialState({
|
||||||
questions: [
|
questions: [
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { visibleWidth } from "@mariozechner/pi-tui";
|
||||||
|
import type { AskConfig } from "../src/config/schema.ts";
|
||||||
|
import { AskSettingsModal } from "../src/ui/settings-modal.ts";
|
||||||
|
|
||||||
|
const savedConfig: AskConfig = {
|
||||||
|
behaviour: {
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||||
|
},
|
||||||
|
keymaps: {
|
||||||
|
cancel: "esc",
|
||||||
|
dismiss: "ctrl+c",
|
||||||
|
toggle: "space",
|
||||||
|
confirm: "enter",
|
||||||
|
optionNote: "n",
|
||||||
|
questionNote: "shift+n",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function plainTheme() {
|
||||||
|
return {
|
||||||
|
fg(_color: string, text: string) {
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
bg(_color: string, text: string) {
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModal(
|
||||||
|
options: {
|
||||||
|
onClose?: () => void;
|
||||||
|
onSave?: (config: AskConfig) => Promise<AskConfig>;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const onClose =
|
||||||
|
options.onClose ??
|
||||||
|
(() => {
|
||||||
|
// test callback intentionally unused
|
||||||
|
});
|
||||||
|
return new AskSettingsModal(plainTheme(), {
|
||||||
|
configPath: "/tmp/eko24ive-pi-ask.json",
|
||||||
|
notice: undefined,
|
||||||
|
onClose,
|
||||||
|
onSave: options.onSave ?? ((config) => Promise.resolve(config)),
|
||||||
|
savedConfig,
|
||||||
|
tui: {
|
||||||
|
requestRender() {
|
||||||
|
// no-op in tests
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("settings modal renders compactly on phone-width screens", () => {
|
||||||
|
const modal = createModal();
|
||||||
|
const lines = modal.render(28);
|
||||||
|
|
||||||
|
assert(lines.every((line) => visibleWidth(line) <= 28));
|
||||||
|
assert(lines.join("\n").includes("@eko24ive/pi-ask"));
|
||||||
|
assert(lines[1]?.includes("Keymaps"));
|
||||||
|
assert(lines.join("\n").includes("/reload after editing."));
|
||||||
|
assert(lines.join("\n").includes("Open ask settings modal"));
|
||||||
|
assert.equal(lines[0]?.includes("├"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings modal renders behaviour tab with checkbox and dirty notice", () => {
|
||||||
|
const modal = createModal();
|
||||||
|
modal.handleInput("\t");
|
||||||
|
modal.handleInput(" ");
|
||||||
|
const lines = modal.render(72);
|
||||||
|
const text = lines.join("\n");
|
||||||
|
|
||||||
|
assert(text.includes("Behaviour"));
|
||||||
|
assert(text.includes("[x] Auto-submit when answered without notes"));
|
||||||
|
assert(text.includes("Unsaved changes. Press Ctrl+S to save."));
|
||||||
|
assert(text.includes("Ctrl+S save · Esc/Ctrl+C/? twice discard"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings modal keymaps tab shows invalid-keymaps session warning", () => {
|
||||||
|
const modal = new AskSettingsModal(plainTheme(), {
|
||||||
|
configPath: "/tmp/eko24ive-pi-ask.json",
|
||||||
|
notice: {
|
||||||
|
kind: "error",
|
||||||
|
text: "Reserved keymap for cancel: ?. Using default ask keymaps for this session. Edit the config and restart pi or run /reload.",
|
||||||
|
},
|
||||||
|
onClose() {
|
||||||
|
// no-op in test
|
||||||
|
},
|
||||||
|
onSave: (config) => Promise.resolve(config),
|
||||||
|
savedConfig,
|
||||||
|
tui: {
|
||||||
|
requestRender() {
|
||||||
|
// no-op in tests
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = modal.render(72).join("\n");
|
||||||
|
assert(
|
||||||
|
text.includes(
|
||||||
|
"Using default keymaps this session because configured keymaps were"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(text.includes("rejected."));
|
||||||
|
assert(
|
||||||
|
text.includes(
|
||||||
|
"Reserved keymap for cancel: ?. Using default ask keymaps for this"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
text.includes("session. Edit the config and restart pi or run /reload.")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings modal saves and clears dirty state", async () => {
|
||||||
|
let saved: AskConfig | undefined;
|
||||||
|
const modal = createModal({
|
||||||
|
onSave: (config) => {
|
||||||
|
saved = config;
|
||||||
|
return Promise.resolve(config);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.handleInput("\t");
|
||||||
|
modal.handleInput(" ");
|
||||||
|
modal.handleInput("\u0013");
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
const text = modal.render(72).join("\n");
|
||||||
|
assert.equal(saved?.behaviour.autoSubmitWhenAnsweredWithoutNotes, true);
|
||||||
|
assert(text.includes("Saved"));
|
||||||
|
assert.equal(text.includes("Unsaved changes. Press Ctrl+S to save."), false);
|
||||||
|
assert.equal(
|
||||||
|
text.includes("[x] Auto-submit when answered without notes"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings modal requires double close when dirty", () => {
|
||||||
|
let closed = 0;
|
||||||
|
const modal = createModal({
|
||||||
|
onClose: () => {
|
||||||
|
closed += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.handleInput("\t");
|
||||||
|
modal.handleInput(" ");
|
||||||
|
modal.handleInput("\u001b");
|
||||||
|
assert.equal(closed, 0);
|
||||||
|
assert(modal.render(72).join("\n").includes("Press again to discard."));
|
||||||
|
|
||||||
|
modal.handleInput("\u001b");
|
||||||
|
assert.equal(closed, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings modal ignores enter and closes idempotently on dispose", () => {
|
||||||
|
let closed = 0;
|
||||||
|
const modal = createModal({
|
||||||
|
onClose: () => {
|
||||||
|
closed += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.handleInput("\r");
|
||||||
|
assert.equal(closed, 0);
|
||||||
|
|
||||||
|
modal.dispose();
|
||||||
|
modal.dispose();
|
||||||
|
assert.equal(closed, 1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user