feat: add config-backed ask keymaps

This commit is contained in:
Erik Kotsiuba
2026-04-28 22:35:43 +02:00
parent 5c0d8964fb
commit e8a36b72ef
31 changed files with 2192 additions and 422 deletions
+45 -13
View File
@@ -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.
![Review tab with Elaborate action and expanded note preview](docs/media/feature-review-elaborate.png) ![Review tab with Elaborate action and expanded note preview](docs/media/feature-review-elaborate.png)
## 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
View File
@@ -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
View File
@@ -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
+175
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -23,6 +23,7 @@
"files": [ "files": [
"src/", "src/",
"skills/", "skills/",
"docs/configuration.md",
"README.md", "README.md",
"LICENSE", "LICENSE",
"CHANGELOG.md" "CHANGELOG.md"
+46
View File
@@ -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,
},
};
}
+80
View File
@@ -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,
};
}
+41
View File
@@ -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);
+165
View File
@@ -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"
);
}
+427 -155
View File
@@ -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"), if (part === "pageUp" || part === "pageDown") {
], return part;
note: [ }
footerHint(ASK_KEY_BINDINGS.confirm, "save"), return part.charAt(0).toUpperCase() + part.slice(1);
footerHint(ASK_KEY_BINDINGS.cancel, "close"), })
footerHint(ASK_KEY_BINDINGS.help, "help"), .join("+");
],
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) {
return `or ${label}`;
}
return label;
})
.join(", ")} to close.`;
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;
}
}
+13
View File
@@ -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);
} }
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
} }
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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]);
});
}
+159
View File
@@ -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
);
}
+14 -4
View File
@@ -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, {
done(); configPath: getAskConfigPath(),
notice,
onClose: () => {
done();
},
onSave: async (nextConfig) => store.save(nextConfig),
savedConfig: config,
tui,
}), }),
{ {
overlay: true, overlay: true,
+4 -5
View File
@@ -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;
} }
+15 -1
View File
@@ -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 });
}); });
+74
View File
@@ -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);
});
+125
View File
@@ -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 });
});
-86
View File
@@ -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
View File
@@ -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",
}); });
}); });
+12 -4
View File
@@ -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",
]); ]);
}); });
+25 -2
View File
@@ -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"
);
}); });
+34
View File
@@ -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: [
+174
View File
@@ -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);
});