diff --git a/README.md b/README.md index 3dc966a..7eb5336 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,9 @@ Once installed, this package gives the agent a native way to ask for clarificati - πŸ“ Question-level and option-level notes - πŸ‘€ Review tab with `Submit`, `Elaborate`, and `Cancel` - πŸ’¬ Elaboration flow to capture note-based clarification before final submission -- ⌨️ Fast keyboard-first interaction (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 @@ -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) -## 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 | |-----------------------------|-----------------------------------------|---------------------------------------------| @@ -98,27 +115,42 @@ Ask the agent to elaborate on notes before finalizing choices. | `←` `β†’` | Main flow | Switch tabs | | `↑` `↓` | Main flow | Move cursor | | `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` | | `↑` `↓` | 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 | | `Tab` `Shift+Tab` / `←` `β†’` | Empty editor | Switch tabs without closing editor | | 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 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. diff --git a/docs/README.md b/docs/README.md index 06876eb..4871da5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,13 +4,15 @@ This folder keeps only the documentation needed to understand and maintain the e ## 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 - `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 ## 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 ## Local test run diff --git a/docs/architecture.md b/docs/architecture.md index 38641df..c7d979f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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.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 -- `src/ui/controller.ts` β€” connects key input, editor lifecycle, and pure state transitions -- `src/ui/input.ts` β€” raw input to commands -- `src/ui/render.ts` and `src/ui/render-*.ts` β€” screen rendering +- `src/ui/controller.ts` β€” connects key input, editor lifecycle, live config subscription, and pure state transitions +- `src/ui/input.ts` β€” raw input to commands using resolved config-backed keymaps +- `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/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 - only selected option notes are emitted in the final result - 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 diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..84afec2 --- /dev/null +++ b/docs/configuration.md @@ -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 diff --git a/docs/contract.md b/docs/contract.md index e176c57..6605f73 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -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, `Elaborate` preview expands to all question notes and all option notes, including notes on unselected options - transcript-friendly call and result rendering -- ask settings modal with `Keymaps` and `Behaviour` tabs; `Behaviour` may be empty while unimplemented -- `/ask-settings` opens the same ask settings modal as `?` +- ask settings modal with `Keymaps` and `Behaviour` tabs +- `?` 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?"` ## Keyboard behavior @@ -208,25 +212,22 @@ This document defines the stable external behavior. It does not explain internal Main flow: - `?`: 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 - `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` -- `Space`: toggle the active option -- `n`: edit the active option note -- `Shift+N`: edit the current question note +- the following actions are configurable via persisted `keymaps`: `Enter` by default confirms or submits, `Esc` cancels, `Ctrl+C` dismisses, `Space` toggles, `n` edits the active option note, and `Shift+N` edits the current question note Editing flow: -- `Enter`: submit the current editor input and close the editor; in note editors this saves the note only and keeps the ask flow open -- `Esc`: save draft and close the editor -- `Ctrl+C`: dismiss the entire flow immediately without saving the current editor draft +- the configurable `confirm` binding submits the current editor input and closes the editor; in note editors this saves the note only and keeps the ask flow open +- the configurable `cancel` binding saves draft and closes the editor +- 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 +- 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 is empty, `Up`/`Down` move options and `Tab`/`Shift+Tab`/`Left`/`Right` move between tabs without requiring `Esc` first -- navigation resumes only after closing the editor with `Esc`, unless the editor is empty and the navigation keys above are used +- when editor is empty, `Up`/`Down` move options and `Tab`/`Shift+Tab`/`Left`/`Right` move between tabs without requiring the configurable cancel key first +- navigation resumes only after closing the editor with the configurable `cancel` binding, unless the editor is empty and the navigation keys above are used ## Non-interactive mode @@ -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. +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. ## Skill alignment (advisory) diff --git a/package.json b/package.json index 8e6727d..edc1128 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "files": [ "src/", "skills/", + "docs/configuration.md", "README.md", "LICENSE", "CHANGELOG.md" diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..6a5b323 --- /dev/null +++ b/src/config/defaults.ts @@ -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 | 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, + }, + }; +} diff --git a/src/config/migrate.ts b/src/config/migrate.ts new file mode 100644 index 0000000..dbdada3 --- /dev/null +++ b/src/config/migrate.ts @@ -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> + | 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, + }; +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..a4c31f0 --- /dev/null +++ b/src/config/schema.ts @@ -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; + +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); diff --git a/src/config/store.ts b/src/config/store.ts new file mode 100644 index 0000000..379a805 --- /dev/null +++ b/src/config/store.ts @@ -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; + 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 { + 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 { + return (await this.ensureLoaded()).config; + } + + async save(config: AskConfig): Promise { + 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 { + 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 { + 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" + ); +} diff --git a/src/constants/keymaps.ts b/src/constants/keymaps.ts index c4768be..38e4fc6 100644 --- a/src/constants/keymaps.ts +++ b/src/constants/keymaps.ts @@ -1,16 +1,98 @@ import { Key, matchesKey } from "@mariozechner/pi-tui"; +import type { AskConfig, AskConfigKeymaps } from "../config/schema.ts"; 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> = { + nextTab: "Tab / β†’", + previousTab: "Shift+Tab / ←", + previousOption: "↑ / ↓", + nextOption: "↓", + numberShortcut: "1..9", +}; type KeyId = Parameters[1]; type InputKey = KeyId | (string & {}); +type ConfigurableBindingMetadata = Omit & { + id: AskConfigurableKeyAction; +}; +type FixedBindingMetadata = Omit & { + id: AskFixedKeyBindingId; + keys: readonly InputKey[]; + label?: string; +}; + +type ModifierName = (typeof MODIFIER_ORDER)[number]; + export type 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 { contexts: readonly string[]; description: string; - id: string; + id: AskKeyBindingId; keys: readonly InputKey[]; kind: AskKeyBindingKind; label: string; @@ -23,64 +105,91 @@ export type FooterKeymapContext = | "input" | "note"; -export const ASK_KEY_BINDINGS = { - help: { - id: "help", - keys: ["?"], - kind: "command", - label: "?", - description: "Show this menu", - contexts: ["Main flow"], - }, - cancel: { +export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = { + cancel: "esc", + dismiss: "ctrl+c", + toggle: "space", + confirm: "enter", + optionNote: "n", + questionNote: "shift+n", +}; + +const CONFIGURABLE_BINDING_METADATA: readonly ConfigurableBindingMetadata[] = [ + { id: "cancel", - keys: [Key.escape], kind: "command", - label: "Esc", description: "Cancel flow or close/save editor draft", contexts: ["Main flow", "Editors"], }, - dismiss: { + { id: "dismiss", - keys: [Key.ctrl("c")], kind: "command", - label: "Ctrl+C", description: "Dismiss entire ask flow immediately", 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", keys: [Key.tab, Key.right], kind: "command", - label: "Tab / β†’", description: "Switch to next tab", contexts: ["Main flow", "Empty editor"], }, - previousTab: { + { id: "previousTab", keys: [Key.shift("tab"), Key.left], kind: "command", - label: "Shift+Tab / ←", description: "Switch to previous tab", contexts: ["Main flow", "Empty editor"], }, - previousOption: { + { id: "previousOption", keys: [Key.up], kind: "command", - label: "↑ / ↓", description: "Move between options/actions", contexts: ["Main flow", "Empty editor"], }, - nextOption: { + { id: "nextOption", keys: [Key.down], kind: "command", - label: "↓", description: "Move to next option/action", contexts: ["Main flow", "Empty editor"], }, - numberShortcut: { + { id: "numberShortcut", keys: ["1..9"], kind: "command", @@ -88,149 +197,34 @@ export const ASK_KEY_BINDINGS = { description: "Quick-select option or submit action", 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", keys: ["@"], kind: "affordance", - label: "@", description: "Reference files while typing answers or notes", contexts: ["Editors"], }, -} as const satisfies Record; - -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; const footerHint = ( binding: AskKeyBinding, action: string, label = binding.label -): string => `${label} ${action}`; -const optionNavigationLabel = ASK_KEY_BINDINGS.previousOption.label.replaceAll( - " / ", - "" -); -const tabNavigationLabel = "⇆"; -const noteNavigationLabel = `${ASK_KEY_BINDINGS.optionNote.label}/${ASK_KEY_BINDINGS.questionNote.label}`; +) => `${label} ${action}`; -export const ASK_FOOTER_HINTS_BY_CONTEXT: Record< - FooterKeymapContext, - readonly string[] -> = { - input: [ - footerHint(ASK_KEY_BINDINGS.confirm, "submit"), - footerHint(ASK_KEY_BINDINGS.cancel, "close"), - footerHint(ASK_KEY_BINDINGS.help, "help"), - ], - note: [ - footerHint(ASK_KEY_BINDINGS.confirm, "save"), - footerHint(ASK_KEY_BINDINGS.cancel, "close"), - footerHint(ASK_KEY_BINDINGS.help, "help"), - ], - submit: [ - footerHint(ASK_KEY_BINDINGS.numberShortcut, "hotkeys"), - footerHint( - ASK_KEY_BINDINGS.previousOption, - "select", - optionNavigationLabel - ), - footerHint(ASK_KEY_BINDINGS.confirm, "confirm"), - footerHint(ASK_KEY_BINDINGS.cancel, "cancel"), - footerHint(ASK_KEY_BINDINGS.help, "help"), - ], - multi: [ - footerHint(ASK_KEY_BINDINGS.nextTab, "tab", tabNavigationLabel), - footerHint( - ASK_KEY_BINDINGS.previousOption, - "select", - optionNavigationLabel - ), - footerHint(ASK_KEY_BINDINGS.toggle, "toggle"), - footerHint(ASK_KEY_BINDINGS.confirm, "continue"), - footerHint(ASK_KEY_BINDINGS.optionNote, "note", noteNavigationLabel), - footerHint(ASK_KEY_BINDINGS.cancel, "dismiss"), - footerHint(ASK_KEY_BINDINGS.help, "help"), - ], - default: [ - footerHint(ASK_KEY_BINDINGS.nextTab, "tab", tabNavigationLabel), - footerHint( - ASK_KEY_BINDINGS.previousOption, - "select", - optionNavigationLabel - ), - footerHint(ASK_KEY_BINDINGS.confirm, "confirm"), - footerHint(ASK_KEY_BINDINGS.optionNote, "note", noteNavigationLabel), - footerHint(ASK_KEY_BINDINGS.cancel, "dismiss"), - footerHint(ASK_KEY_BINDINGS.help, "help"), - ], -} as const; - -export const ASK_HELP_CLOSE_BINDINGS = [ - ASK_KEY_BINDINGS.cancel, - ASK_KEY_BINDINGS.dismiss, - ASK_KEY_BINDINGS.help, -] as const; - -export const ASK_HELP_CLOSE_HINT = `Press ${ASK_HELP_CLOSE_BINDINGS.map( - (binding) => binding.label -) - .map((label, index, labels) => { - if (index === 0) { - return label; - } - if (index === labels.length - 1) { - return `or ${label}`; - } - return label; - }) - .join(", ")} to close.`; - -export function renderFooterKeymaps(context: FooterKeymapContext): string { - return ` ${ASK_FOOTER_HINTS_BY_CONTEXT[context].join(" Β· ")}`; +export function formatKeybindingLabel(key: string): string { + return key + .split("+") + .map((part) => { + if (part.length <= 1) { + return part.toUpperCase(); + } + if (part === "pageUp" || part === "pageDown") { + return part; + } + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join("+"); } 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 { return DIGIT_SHORTCUT_PATTERN.test(data) ? Number(data) : null; } + +export function getAskKeyBindings( + config: AskConfig +): Record { + const customizable: Record = { + cancel: createCustomizableBinding(config, "cancel"), + dismiss: createCustomizableBinding(config, "dismiss"), + toggle: createCustomizableBinding(config, "toggle"), + confirm: createCustomizableBinding(config, "confirm"), + optionNote: createCustomizableBinding(config, "optionNote"), + questionNote: createCustomizableBinding(config, "questionNote"), + }; + const fixed = Object.fromEntries( + FIXED_BINDING_METADATA.map((binding) => [ + binding.id, + { + ...binding, + label: + binding.label ?? + ACTION_LABEL_OVERRIDES[binding.id] ?? + binding.keys + .map((key) => formatKeybindingLabel(String(key))) + .join(" / "), + }, + ]) + ) as Record; + 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 = { + 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> | 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(); + 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(); + 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 ? 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; + } +} diff --git a/src/index.ts b/src/index.ts index e9aed8d..27febd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,21 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { registerAskSettingsCommand } from "./ask-settings-command.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) { + pi.on("before_agent_start", async (event) => ({ + systemPrompt: `${event.systemPrompt}\n\n${PI_ASK_CONFIG_PROMPT}`, + })); registerAskTool(pi); registerAskSettingsCommand(pi); } diff --git a/src/ui/auto-submit.ts b/src/ui/auto-submit.ts new file mode 100644 index 0000000..46cd308 --- /dev/null +++ b/src/ui/auto-submit.ts @@ -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; +} diff --git a/src/ui/controller.ts b/src/ui/controller.ts index 10f039c..7010b08 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -1,5 +1,7 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; 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 { getEditorDraft, @@ -26,6 +28,7 @@ import { } from "../state/transitions.ts"; import { isEditingView } from "../state/view.ts"; import type { AskParams, AskResult, AskState } from "../types.ts"; +import { maybeAutoSubmitState } from "./auto-submit.ts"; import { createAskAutocompleteProvider } from "./autocomplete.ts"; import type { AskInputCommand } from "./input.ts"; import { getInputCommand } from "./input.ts"; @@ -41,25 +44,29 @@ type Theme = CustomCallbackArgs[1]; type Keybindings = CustomCallbackArgs[2]; type Done = (result: AskResult) => void; type AskFlowParams = AskParams & - Pick & { ctx: ExtensionContext }; + Pick & { config: AskConfig; ctx: ExtensionContext }; interface AskFlowController { + config: AskConfig; ctx: ExtensionContext; done: Done; editor: Editor; - helpOpen: boolean; + settingsOpen: boolean; state: AskState; suppressAutoInputForSelection: boolean; theme: Theme; tui: Tui; + unsubscribeConfig: () => void; } -export function runAskFlow( +export async function runAskFlow( ctx: ExtensionContext, params: AskParams ): Promise { + const store = getAskConfigStore(); + const config = await store.getConfig(); return ctx.ui.custom((...args) => - createAskFlowController(args, { ...params, cwd: ctx.cwd, ctx }) + createAskFlowController(args, { ...params, config, cwd: ctx.cwd, ctx }) ); } @@ -73,16 +80,30 @@ function createAskFlowController( params: AskFlowParams ) { const controller: AskFlowController = { + config: params.config, ctx: params.ctx, done, editor: createEditor(tui, theme, params.cwd), - helpOpen: false, + settingsOpen: false, state: createInitialState(params), suppressAutoInputForSelection: false, theme, 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); syncSelection(controller); @@ -94,6 +115,9 @@ function createAskFlowController( handleInput(data: string) { handleControllerInput(controller, data); }, + dispose() { + controller.unsubscribeConfig(); + }, }; } @@ -102,6 +126,7 @@ function renderController( width: number ): string[] { return renderAskScreen({ + config: controller.config, editor: controller.editor, state: controller.state, theme: controller.theme, @@ -112,6 +137,7 @@ function renderController( function handleControllerInput(controller: AskFlowController, data: string) { const command = getInputCommand( controller.state, + controller.config, data, isEditingView(controller.state) ? controller.editor.getText() : "" ); @@ -131,8 +157,8 @@ function handleEditingCommand( commitState(controller, dismissFlow(controller.state), { finish: true }); return; } - if (command.kind === "showHelp") { - showHelpModal(controller); + if (command.kind === "showSettings") { + showSettingsModal(controller); return; } if (command.kind === "editMoveTab") { @@ -190,8 +216,8 @@ function handleNavigationCommand( case "dismiss": commitState(controller, dismissFlow(controller.state), { finish: true }); return; - case "showHelp": - showHelpModal(controller); + case "showSettings": + showSettingsModal(controller); return; case "ignore": case "editMoveTab": @@ -253,6 +279,7 @@ function commitState( if (options.syncSelection !== false) { syncSelection(controller); } + controller.state = maybeAutoSubmitState(controller.state, controller.config); hydrateEditor(controller); refresh(controller); if (options.finish) { @@ -262,7 +289,10 @@ function commitState( function submitEditor(controller: AskFlowController, value: string) { controller.suppressAutoInputForSelection = false; - controller.state = submitEditorDraft(controller.state, value); + controller.state = maybeAutoSubmitState( + submitEditorDraft(controller.state, value), + controller.config + ); controller.editor.setText(""); refresh(controller); maybeFinish(controller); @@ -283,13 +313,13 @@ function closeEditor(controller: AskFlowController) { refresh(controller); } -function showHelpModal(controller: AskFlowController) { - if (controller.helpOpen) { +function showSettingsModal(controller: AskFlowController) { + if (controller.settingsOpen) { return; } - controller.helpOpen = true; + controller.settingsOpen = true; showAskSettingsModal(controller.ctx.ui).finally(() => { - controller.helpOpen = false; + controller.settingsOpen = false; refresh(controller); }); } diff --git a/src/ui/input.ts b/src/ui/input.ts index 3e36f4a..03485e4 100644 --- a/src/ui/input.ts +++ b/src/ui/input.ts @@ -1,5 +1,6 @@ +import type { AskConfig } from "../config/schema.ts"; import { - ASK_KEY_BINDINGS, + getAskKeyBindings, matchesBinding, matchesDigitShortcut, } from "../constants/keymaps.ts"; @@ -14,7 +15,7 @@ export type AskInputCommand = | { kind: "confirm" } | { kind: "cancel" } | { kind: "dismiss" } - | { kind: "showHelp" } + | { kind: "showSettings" } | { kind: "numberShortcut"; digit: number } | { kind: "editMoveTab"; delta: 1 | -1 } | { kind: "editMoveOption"; delta: 1 | -1 } @@ -24,85 +25,79 @@ export type AskInputCommand = export function getInputCommand( state: AskState, + config: AskConfig, data: string, editingText = "" ): AskInputCommand { - if (matchesBinding(data, ASK_KEY_BINDINGS.dismiss)) { + const bindings = getAskKeyBindings(config); + if (matchesBinding(data, bindings.dismiss)) { return { kind: "dismiss" }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.help) && editingText.length === 0) { - return { kind: "showHelp" }; + if (matchesBinding(data, bindings.settings) && editingText.length === 0) { + return { kind: "showSettings" }; } if (state.view.kind === "input" || state.view.kind === "note") { - return getEditingInputCommand(data, editingText); + return getEditingInputCommand(bindings, data, editingText); } - return getNavigationInputCommand(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("+"); + return getNavigationInputCommand(bindings, data); } function getEditingInputCommand( + bindings: ReturnType, data: string, editingText: string ): AskInputCommand { - if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) { + if (matchesBinding(data, bindings.cancel)) { return { kind: "editClose" }; } if (editingText.length === 0) { - if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) { + if (matchesBinding(data, bindings.nextTab)) { return { kind: "editMoveTab", delta: 1 }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) { + if (matchesBinding(data, bindings.previousTab)) { return { kind: "editMoveTab", delta: -1 }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) { + if (matchesBinding(data, bindings.previousOption)) { return { kind: "editMoveOption", delta: -1 }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) { + if (matchesBinding(data, bindings.nextOption)) { return { kind: "editMoveOption", delta: 1 }; } } return { kind: "delegateToEditor" }; } -function getNavigationInputCommand(data: string): AskInputCommand { - if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) { +function getNavigationInputCommand( + bindings: ReturnType, + data: string +): AskInputCommand { + if (matchesBinding(data, bindings.nextTab)) { return { kind: "moveTab", delta: 1 }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) { + if (matchesBinding(data, bindings.previousTab)) { return { kind: "moveTab", delta: -1 }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) { + if (matchesBinding(data, bindings.previousOption)) { return { kind: "moveOption", delta: -1 }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) { + if (matchesBinding(data, bindings.nextOption)) { return { kind: "moveOption", delta: 1 }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.toggle)) { + if (matchesBinding(data, bindings.toggle)) { return { kind: "toggleMulti" }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.confirm)) { + if (matchesBinding(data, bindings.confirm)) { return { kind: "confirm" }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) { + if (matchesBinding(data, bindings.cancel)) { return { kind: "cancel" }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.questionNote)) { + if (matchesBinding(data, bindings.questionNote)) { return { kind: "openQuestionNote" }; } - if (matchesBinding(data, ASK_KEY_BINDINGS.optionNote)) { + if (matchesBinding(data, bindings.optionNote)) { return { kind: "openOptionNote" }; } diff --git a/src/ui/render-frame.ts b/src/ui/render-frame.ts index c406fdb..faeb2fa 100644 --- a/src/ui/render-frame.ts +++ b/src/ui/render-frame.ts @@ -1,4 +1,5 @@ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import type { AskConfig } from "../config/schema.ts"; import { getCurrentQuestion, isQuestionAnswered, @@ -28,14 +29,15 @@ export function renderFrameHeader(args: { } export function renderFrameFooter(args: { + config: AskConfig; lines: string[]; state: AskState; theme: Theme; width: number; }) { - const { lines, state, theme, width } = args; + const { config, lines, state, theme, width } = args; const add = (text = "") => lines.push(truncateToWidth(text, width)); - const footer = renderFooter(state, width); + const footer = renderFooter(config, state, width); if (footer.length > 0) { add(); for (const line of footer) { @@ -178,17 +180,24 @@ function applyTabGrowth( range.end = nextIndex; } -function renderFooter(state: AskState, width: number): string[] { +function renderFooter( + config: AskConfig, + state: AskState, + width: number +): string[] { let footer: string; if (state.view.kind === "input") { - footer = renderFooterText("input"); + footer = renderFooterText(config, "input"); } else if (state.view.kind === "note") { - footer = renderFooterText("note"); + footer = renderFooterText(config, "note"); } else if (isSubmitTab(state)) { - footer = renderFooterText("submit"); + footer = renderFooterText(config, "submit"); } else { const question = getCurrentQuestion(state); - footer = renderFooterText(question?.type === "multi" ? "multi" : "default"); + footer = renderFooterText( + config, + question?.type === "multi" ? "multi" : "default" + ); } return wrapDelimitedFooterHints(footer, width); } diff --git a/src/ui/render-helpers.ts b/src/ui/render-helpers.ts index a484094..0eb2b52 100644 --- a/src/ui/render-helpers.ts +++ b/src/ui/render-helpers.ts @@ -1,5 +1,6 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import type { AskConfig } from "../config/schema.ts"; import { type FooterKeymapContext, renderFooterKeymaps, @@ -372,8 +373,11 @@ export function pushSavedNote(args: { ); } -export function renderFooterText(mode: FooterKeymapContext): string { - return renderFooterKeymaps(mode); +export function renderFooterText( + config: AskConfig, + mode: FooterKeymapContext +): string { + return renderFooterKeymaps(config, mode); } function padToVisibleWidth(text: string, width: number): string { diff --git a/src/ui/render.ts b/src/ui/render.ts index 337ad70..b8dcc41 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -1,3 +1,4 @@ +import type { AskConfig } from "../config/schema.ts"; import { getCurrentQuestion, getRenderableOptions, @@ -10,12 +11,13 @@ import { renderSubmitScreen } from "./render-submit.ts"; import type { QuestionRenderContext, Theme } from "./render-types.ts"; export function renderAskScreen(args: { + config: AskConfig; state: AskState; theme: Theme; width: number; editor: QuestionRenderContext["editor"]; }): string[] { - const { state, theme, width, editor } = args; + const { config, state, theme, width, editor } = args; const lines: string[] = []; const question = getCurrentQuestion(state); 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; } diff --git a/src/ui/settings-modal.ts b/src/ui/settings-modal.ts index ca6afbf..084aad4 100644 --- a/src/ui/settings-modal.ts +++ b/src/ui/settings-modal.ts @@ -1,81 +1,214 @@ -import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { - ASK_HELP_CLOSE_BINDINGS, - ASK_HELP_CLOSE_HINT, - ASK_KEY_BINDINGS, - ASK_KEYMAPS, - matchesBinding, -} from "../constants/keymaps.ts"; + Key, + matchesKey, + truncateToWidth, + visibleWidth, +} from "@mariozechner/pi-tui"; +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"; - -type SettingsTab = "keymaps" | "behaviour"; +import { + type AskSettingsState, + clearTransientNotice, + completeSave, + createAskSettingsState, + failSave, + isDirty, + moveBehaviourFocus, + moveSettingsTab, + requestDiscard, + setSaving, + shouldDiscard, + toggleAutoSubmit, +} from "./settings-state.ts"; interface Theme { bg(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; + savedConfig: AskConfig; + tui: TuiLike; +} + const MIN_WIDTH = 24; const COMPACT_WIDTH = 46; 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 { return text + " ".repeat(Math.max(0, width - visibleWidth(text))); } export class AskSettingsModal { - private activeTab: SettingsTab = "keymaps"; private closed = false; + private readonly configPath: string; private readonly onClose: () => void; + private readonly onSave: (config: AskConfig) => Promise; + private state: AskSettingsState; private readonly theme: Theme; + private readonly tui: TuiLike; - constructor(theme: Theme, onClose: () => void) { + constructor(theme: Theme, options: AskSettingsModalOptions) { 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 { - if ( - ASK_HELP_CLOSE_BINDINGS.some((binding) => matchesBinding(data, binding)) - ) { - this.close(); + if (this.handleGlobalInput(data)) { return; } - if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) { - this.activeTab = this.activeTab === "keymaps" ? "behaviour" : "keymaps"; - return; - } - if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) { - this.activeTab = this.activeTab === "behaviour" ? "keymaps" : "behaviour"; + if (this.state.activeTab === "behaviour") { + this.handleBehaviourInput(data); } } 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]; - if (this.activeTab === "keymaps") { - lines.push(...renderKeymapsTab(this.theme, layout)); + if (this.state.activeTab === "keymaps") { + lines.push( + ...renderKeymapsTab( + this.theme, + layout, + this.state.savedConfig, + this.configPath, + this.state.notice + ) + ); } else { - lines.push(...renderBehaviourTab(this.theme, layout)); + lines.push(...renderBehaviourTab(this.theme, this.state, layout)); } - lines.push(`${layout.border(`β”œ${"─".repeat(layout.innerW)}─`)}`); - 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)}β•―`)}`); + lines.push(...renderBottomPanel(this.theme, this.state, layout)); return lines; } invalidate(): void { - // Static modal; no async invalidation needed. + // State-driven modal; nothing cached across theme changes. } dispose(): void { 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 { + 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 { if (this.closed) { return; @@ -103,8 +236,8 @@ function createLayout( const compact = innerW < COMPACT_WIDTH; const keyW = compact ? 11 : 16; const descW = Math.max(8, innerW - keyW - 5); - const border = (text: string) => theme.fg("border", text); - const title = theme.fg("accent", " @eko24ive/pi-ask "); + const border = (text: string) => theme.fg("muted", text); + const title = theme.fg("muted", " @eko24ive/pi-ask "); const titleW = visibleWidth(title); const titlePad = Math.max(0, innerW - titleW); 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 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) { lines.push(layout.line(theme.fg("accent", hint.label))); for (const descLine of wrapText(hint.description, layout.innerW - 3)) { @@ -138,7 +382,6 @@ function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] { } continue; } - const keyText = padToWidth( truncateToWidth(hint.label, layout.keyW, "…"), layout.keyW @@ -151,14 +394,6 @@ function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] { 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( @@ -179,3 +414,53 @@ function renderTabs( ].join(TAB_GAP); 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[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[1]); + }); +} diff --git a/src/ui/settings-state.ts b/src/ui/settings-state.ts new file mode 100644 index 0000000..6e300f2 --- /dev/null +++ b/src/ui/settings-state.ts @@ -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 + ); +} diff --git a/src/ui/show-settings-modal.ts b/src/ui/show-settings-modal.ts index 8039d4f..17a3e4e 100644 --- a/src/ui/show-settings-modal.ts +++ b/src/ui/show-settings-modal.ts @@ -1,13 +1,23 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { getAskConfigPath, getAskConfigStore } from "../config/store.ts"; import { AskSettingsModal } from "./settings-modal.ts"; -export function showAskSettingsModal( +export async function showAskSettingsModal( ui: Pick["ui"] ): Promise { + const store = getAskConfigStore(); + const { config, notice } = await store.ensureLoaded(); return ui.custom( - (_tui, theme, _keybindings, done) => - new AskSettingsModal(theme, () => { - done(); + (tui, theme, _keybindings, done) => + new AskSettingsModal(theme, { + configPath: getAskConfigPath(), + notice, + onClose: () => { + done(); + }, + onSave: async (nextConfig) => store.save(nextConfig), + savedConfig: config, + tui, }), { overlay: true, diff --git a/src/ui/view-models/question.ts b/src/ui/view-models/question.ts index 490beb4..8998e7f 100644 --- a/src/ui/view-models/question.ts +++ b/src/ui/view-models/question.ts @@ -127,27 +127,26 @@ function buildOptionDetailModel( const note = option.isCustomOption ? undefined : getOptionNote(state, question.id, option.value); - const withGap = !option.isCustomOption; if (noteOpen) { return { kind: "editor", placeholder: UI_TEXT.editorPlaceholderNote, - withGap, + withGap: false, }; } if (inputOpen) { return { kind: "editor", placeholder: UI_TEXT.editorPlaceholderInput, - withGap, + withGap: false, }; } if (customText) { - return { kind: "custom-text", text: customText, withGap }; + return { kind: "custom-text", text: customText, withGap: false }; } if (note) { - return { kind: "saved-note", text: note, withGap }; + return { kind: "saved-note", text: note, withGap: false }; } return; } diff --git a/tests/ask-settings-command.test.ts b/tests/ask-settings-command.test.ts index 48d58fb..d97e32d 100644 --- a/tests/ask-settings-command.test.ts +++ b/tests/ask-settings-command.test.ts @@ -1,4 +1,7 @@ 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 { registerAskSettingsCommand } from "../src/ask-settings-command.ts"; @@ -14,6 +17,9 @@ function plainTheme() { } 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< string, { handler: (args: string, ctx: any) => Promise } @@ -36,7 +42,12 @@ test("registers /ask-settings and opens the shared modal overlay", async () => { const done = () => { // 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) }); 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")); + + delete process.env.PI_CODING_AGENT_DIR; + await rm(agentDir, { force: true, recursive: true }); }); diff --git a/tests/auto-submit.test.ts b/tests/auto-submit.test.ts new file mode 100644 index 0000000..44bdb88 --- /dev/null +++ b/tests/auto-submit.test.ts @@ -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); +}); diff --git a/tests/config-store.test.ts b/tests/config-store.test.ts new file mode 100644 index 0000000..e08f15e --- /dev/null +++ b/tests/config-store.test.ts @@ -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 { + 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 }); +}); diff --git a/tests/help-modal.test.ts b/tests/help-modal.test.ts deleted file mode 100644 index 8276c52..0000000 --- a/tests/help-modal.test.ts +++ /dev/null @@ -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); -}); diff --git a/tests/input.test.ts b/tests/input.test.ts index 5bf9f43..151d0c0 100644 --- a/tests/input.test.ts +++ b/tests/input.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts"; import { createInitialState } from "../src/state/create.ts"; import { applyNumberShortcut, @@ -24,27 +25,27 @@ function inputState() { test("empty typing mode uses arrows and tab for navigation", () => { const input = inputState(); - assert.deepEqual(getInputCommand(input, "\x1b[A", ""), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[A", ""), { kind: "editMoveOption", delta: -1, }); - assert.deepEqual(getInputCommand(input, "\x1b[B", ""), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[B", ""), { kind: "editMoveOption", delta: 1, }); - assert.deepEqual(getInputCommand(input, "\x1b[C", ""), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[C", ""), { kind: "editMoveTab", delta: 1, }); - assert.deepEqual(getInputCommand(input, "\x1b[D", ""), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[D", ""), { kind: "editMoveTab", delta: -1, }); - assert.deepEqual(getInputCommand(input, "\t", ""), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\t", ""), { kind: "editMoveTab", delta: 1, }); - assert.deepEqual(getInputCommand(input, "\x1b[Z", ""), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[Z", ""), { kind: "editMoveTab", 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", () => { const input = inputState(); - assert.deepEqual(getInputCommand(input, "\x1b[A", "x"), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[A", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(input, "\x1b[B", "x"), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[B", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(input, "\x1b[C", "x"), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[C", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(input, "\x1b[D", "x"), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[D", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(input, "\t", "x"), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\t", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(input, "\x1b[Z", "x"), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[Z", "x"), { kind: "delegateToEditor", }); }); @@ -87,23 +88,23 @@ test("empty note editing mode uses arrows and tab for navigation", () => { "q1" ); - assert.deepEqual(getInputCommand(state, "\x1b[A", ""), { + assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[A", ""), { kind: "editMoveOption", delta: -1, }); - assert.deepEqual(getInputCommand(state, "\x1b[B", ""), { + assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[B", ""), { kind: "editMoveOption", delta: 1, }); - assert.deepEqual(getInputCommand(state, "\x1b[C", ""), { + assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[C", ""), { kind: "editMoveTab", delta: 1, }); - assert.deepEqual(getInputCommand(state, "\x1b[D", ""), { + assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[D", ""), { kind: "editMoveTab", delta: -1, }); - assert.deepEqual(getInputCommand(state, "\t", ""), { + assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\t", ""), { kind: "editMoveTab", delta: 1, }); @@ -123,19 +124,19 @@ test("non-empty note editing mode keeps arrows and tab in editor", () => { "q1" ); - assert.deepEqual(getInputCommand(state, "\x1b[A", "x"), { + assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[A", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(state, "\x1b[B", "x"), { + assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[B", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(state, "\x1b[C", "x"), { + assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[C", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(state, "\x1b[D", "x"), { + assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[D", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(state, "\t", "x"), { + assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\t", "x"), { kind: "delegateToEditor", }); }); @@ -153,18 +154,18 @@ test("ctrl+c dismisses the flow from both navigation and editing modes", () => { const input = inputState(); const note = enterQuestionNoteMode(navigation, "q1"); - assert.deepEqual(getInputCommand(navigation, "\u0003"), { + assert.deepEqual(getInputCommand(navigation, DEFAULT_ASK_CONFIG, "\u0003"), { kind: "dismiss", }); - assert.deepEqual(getInputCommand(input, "\u0003"), { + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\u0003"), { kind: "dismiss", }); - assert.deepEqual(getInputCommand(note, "\u0003"), { + assert.deepEqual(getInputCommand(note, DEFAULT_ASK_CONFIG, "\u0003"), { 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({ questions: [ { @@ -176,9 +177,13 @@ test("question mark opens keymap help outside non-empty editors", () => { }); const input = inputState(); - assert.deepEqual(getInputCommand(navigation, "?"), { kind: "showHelp" }); - assert.deepEqual(getInputCommand(input, "?", ""), { kind: "showHelp" }); - assert.deepEqual(getInputCommand(input, "?", "x"), { + assert.deepEqual(getInputCommand(navigation, DEFAULT_ASK_CONFIG, "?"), { + kind: "showSettings", + }); + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "?", ""), { + kind: "showSettings", + }); + assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "?", "x"), { 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", }); - 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", }); }); diff --git a/tests/render-frame.test.ts b/tests/render-frame.test.ts index 8a3f7cb..effb2bd 100644 --- a/tests/render-frame.test.ts +++ b/tests/render-frame.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts"; import { createInitialState } from "../src/state/create.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({ + config: DEFAULT_ASK_CONFIG, state, theme: plainTheme(), width: 120, @@ -96,6 +98,7 @@ test("narrow tab strip keeps active middle tab visible", () => { state.activeTabIndex = 2; const lines = renderAskScreen({ + config: DEFAULT_ASK_CONFIG, state, theme: plainTheme(), width: 28, @@ -133,6 +136,7 @@ test("narrow tab strip keeps submit tab visible when active", () => { state.view = { kind: "submit" }; const lines = renderAskScreen({ + config: DEFAULT_ASK_CONFIG, state, theme: plainTheme(), width: 24, @@ -190,6 +194,7 @@ test("tab strip avoids truncation at narrow boundary widths", () => { for (const [width, expected] of expectedByWidth) { const lines = renderAskScreen({ + config: DEFAULT_ASK_CONFIG, state, theme: plainTheme(), width, @@ -213,6 +218,7 @@ test("footer hints wrap into exact lines on narrow screens", () => { }); const lines = renderAskScreen({ + config: DEFAULT_ASK_CONFIG, state, theme: plainTheme(), width: 26, @@ -223,7 +229,7 @@ test("footer hints wrap into exact lines on narrow screens", () => { "Space toggle", "Enter continue", "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({ + config: DEFAULT_ASK_CONFIG, state, theme: plainTheme(), width: 22, editor: mockEditor(), }); - assert.equal(lines.at(-5), " ⇆ tab Β· ↑↓ select"); - assert.deepEqual(lines.slice(-4, -1), [ + assert.equal(lines.at(-6), " ⇆ tab Β· ↑↓ select"); + assert.deepEqual(lines.slice(-5, -1), [ "Enter confirm", "N/Shift+N note", - "Esc dismiss Β· ? help", + "Esc dismiss", + "? settings", ]); }); diff --git a/tests/render-helpers.test.ts b/tests/render-helpers.test.ts index 796b3d0..d8c5ce4 100644 --- a/tests/render-helpers.test.ts +++ b/tests/render-helpers.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts"; import { renderEditorBlock, renderFooterText, @@ -74,6 +75,28 @@ test("renderEditorBlock reapplies background after editor reset sequences", () = }); test("editing footers do not advertise tab navigation", () => { - assert.equal(renderFooterText("input"), " Enter submit Β· Esc close Β· ? help"); - assert.equal(renderFooterText("note"), " Enter save Β· Esc close Β· ? help"); + assert.equal( + 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" + ); }); diff --git a/tests/render-question.test.ts b/tests/render-question.test.ts index 030cf36..3609adf 100644 --- a/tests/render-question.test.ts +++ b/tests/render-question.test.ts @@ -4,6 +4,7 @@ import { createInitialState } from "../src/state/create.ts"; import { getRenderableOptions } from "../src/state/selectors.ts"; import { applyNumberShortcut, + enterOptionNoteMode, enterQuestionNoteMode, } from "../src/state/transitions.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:"))); }); +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", () => { let state = createInitialState({ questions: [ diff --git a/tests/settings-modal.test.ts b/tests/settings-modal.test.ts new file mode 100644 index 0000000..59a6731 --- /dev/null +++ b/tests/settings-modal.test.ts @@ -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; + } = {} +) { + 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); +});