From 69c1ca3803fd96cb38567cc9f3a5556c9604453c Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Sat, 2 May 2026 16:09:08 +0200 Subject: [PATCH] refactor: simplify ask settings UI --- README.md | 8 +- docs/architecture.md | 5 +- docs/contract.md | 16 +- src/ask-settings-command.ts | 6 +- src/config/store.ts | 6 +- src/constants/keymaps.ts | 2 +- src/ui/controller.ts | 4 +- src/ui/settings-list.ts | 329 ++++++++++ src/ui/settings-modal.ts | 589 ------------------ src/ui/settings-state.ts | 166 ----- ...how-settings-modal.ts => show-settings.ts} | 6 +- tests/ask-settings-command.test.ts | 8 +- tests/config-store.test.ts | 2 +- tests/settings-list.test.ts | 137 ++++ tests/settings-modal.test.ts | 196 ------ 15 files changed, 499 insertions(+), 981 deletions(-) create mode 100644 src/ui/settings-list.ts delete mode 100644 src/ui/settings-modal.ts delete mode 100644 src/ui/settings-state.ts rename src/ui/{show-settings-modal.ts => show-settings.ts} (84%) create mode 100644 tests/settings-list.test.ts delete mode 100644 tests/settings-modal.test.ts diff --git a/README.md b/README.md index 91fd621..14dca21 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Ask the agent to elaborate on notes before finalizing choices. ## Default key bindings -Open the ask settings modal with `?` during the ask flow, or with the `/ask-settings` command from pi. +Open ask settings with `?` during the ask flow, or with the `/ask-settings` command from pi. Customizable via config: @@ -110,7 +110,7 @@ Fixed bindings: | Key | Context | Effect | |-----------------------------|-----------------------------------------|---------------------------------------------| -| `?` | Ask flow / empty editor | Open ask settings modal | +| `?` | Ask flow / empty editor | Open ask settings | | `Tab` `Shift+Tab` | Main flow | Switch tabs | | `←` `→` | Main flow | Switch tabs | | `↑` `↓` | Main flow | Move cursor | @@ -125,7 +125,7 @@ Review-tab shortcuts can optionally require the same number key twice via `behav Config file: `~/.pi/.../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. +You can edit this file yourself, ask pi to edit it for you, or use `/ask-settings` to find the exact config path and toggle behaviour settings. ```json { @@ -153,7 +153,7 @@ Accepted notation follows pi-tui key ids. Common aliases are normalized, for exa 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. 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`, including whether review-tab number shortcuts require a double press. +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 are available both from `?` in the ask flow and from the `/ask-settings` command. Behaviour settings are binary `on`/`off` toggles that save immediately; customizable ask keymaps are changed by editing the shown config file path. 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. diff --git a/docs/architecture.md b/docs/architecture.md index ac500e9..112336b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,8 +41,7 @@ The codebase is split so the implementation reads through file boundaries and na - `src/ui/input.ts` — raw input to commands using resolved config-backed keymaps - `src/ui/dismiss-guard.ts` — pure helpers for dirty-flow exit confirmation behavior - `src/ui/render.ts` and `src/ui/render-*.ts` — screen rendering, including config-backed footer/keymap hints -- `src/ui/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/settings-list.ts` / `src/ui/show-settings.ts` — lightweight ask settings list rendering and launcher - `src/ui/constants.ts` and `src/ui/render-types.ts` — rendering constants/contracts - `src/ask-component.ts` — thin custom UI export @@ -71,7 +70,7 @@ The codebase is split so the implementation reads through file boundaries and na - 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 modal changes on explicit save +- ask settings behaviour changes persist immediately from the settings list - when the ask config file is missing, the first ask use writes a default persisted config snapshot - live config updates can affect an in-progress ask flow immediately diff --git a/docs/contract.md b/docs/contract.md index 4784b37..c8891f3 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -200,11 +200,11 @@ 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 -- `?` in the ask flow and `/ask-settings` in pi open the same ask settings modal -- `Behaviour` exposes persisted user settings for `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, and `Show footer hints` +- ask settings list with binary behaviour toggles +- `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay +- behaviour settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, and `Show footer hints` - `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 +- the settings list shows the absolute config file path for changing customizable ask keymaps - 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?"` @@ -212,8 +212,8 @@ 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 +- `?`: open ask settings +- inside ask settings, `Esc`, `Ctrl+C`, and `?` close it - `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs - `Up`, `Down`: move between options - `1..9`: select or toggle the matching option; on the review tab, `1`, `2`, and `3` trigger `Submit`, `Elaborate`, and `Cancel` @@ -227,8 +227,8 @@ Editing flow: - when `Confirm dismiss when dirty` is enabled, cancelling or dismissing a dirty ask flow requires the same action a second time - the dirty-dismiss warning stays visible until the user changes tabs in the ask flow - the configurable `dismiss` binding dismisses the entire flow immediately without saving the current editor draft when no dirty-dismiss confirmation is pending -- `?`: 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 +- `?`: open ask settings when the editor is empty; otherwise enter `?` as text +- inside ask settings, behaviour changes save immediately without explicit save feedback - when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing - when editor is empty, `Up`/`Down` move options and `Tab`/`Shift+Tab`/`Left`/`Right` move between tabs without requiring the configurable cancel key first - navigation resumes only after closing the editor with the configurable `cancel` binding, unless the editor is empty and the navigation keys above are used diff --git a/src/ask-settings-command.ts b/src/ask-settings-command.ts index 31de2a1..664377d 100644 --- a/src/ask-settings-command.ts +++ b/src/ask-settings-command.ts @@ -2,13 +2,13 @@ import type { ExtensionAPI, ExtensionCommandContext, } from "@mariozechner/pi-coding-agent"; -import { showAskSettingsModal } from "./ui/show-settings-modal.ts"; +import { showAskSettings } from "./ui/show-settings.ts"; export function registerAskSettingsCommand(pi: ExtensionAPI) { pi.registerCommand("ask-settings", { - description: "Open the ask settings modal", + description: "Open ask settings", handler: async (_args: string, ctx: ExtensionCommandContext) => { - await showAskSettingsModal(ctx.ui); + await showAskSettings(ctx.ui); }, }); } diff --git a/src/config/store.ts b/src/config/store.ts index c8b980b..1997788 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -104,7 +104,7 @@ export class AskConfigStore { 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." + "Config was invalid or unsupported. Backed it up and loaded defaults. Change any behaviour setting or edit the config file to save a fresh config." ); } @@ -126,8 +126,8 @@ export class AskConfigStore { 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." + ? "Config migration failed. Backed up old config and loaded defaults. Change any behaviour setting or edit the config file to save a fresh config." + : "Config was invalid or unsupported. Backed it up and loaded defaults. Change any behaviour setting or edit the config file to save a fresh config." ); } throw error; diff --git a/src/constants/keymaps.ts b/src/constants/keymaps.ts index 38e4fc6..14db4b7 100644 --- a/src/constants/keymaps.ts +++ b/src/constants/keymaps.ts @@ -158,7 +158,7 @@ const FIXED_BINDING_METADATA: readonly FixedBindingMetadata[] = [ id: "settings", keys: ["?"], kind: "command", - description: "Open ask settings modal", + description: "Open ask settings", contexts: ["Main flow", "Empty editor"], }, { diff --git a/src/ui/controller.ts b/src/ui/controller.ts index 966a8f5..c469468 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -42,7 +42,7 @@ import { getReviewShortcutHint, resolveReviewShortcutDoublePress, } from "./review-shortcuts.ts"; -import { showAskSettingsModal } from "./show-settings-modal.ts"; +import { showAskSettings } from "./show-settings.ts"; type CustomCallback = Parameters[0]; type CustomCallbackArgs = CustomCallback extends (...args: infer T) => unknown @@ -430,7 +430,7 @@ function showSettingsModal(controller: AskFlowController) { return; } controller.settingsOpen = true; - showAskSettingsModal(controller.ctx.ui).finally(() => { + showAskSettings(controller.ctx.ui).finally(() => { controller.settingsOpen = false; refresh(controller); }); diff --git a/src/ui/settings-list.ts b/src/ui/settings-list.ts new file mode 100644 index 0000000..3def58f --- /dev/null +++ b/src/ui/settings-list.ts @@ -0,0 +1,329 @@ +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@mariozechner/pi-tui"; +import type { AskConfig } from "../config/schema.ts"; +import type { AskConfigNotice } from "../config/store.ts"; + +interface Theme { + bg(color: string, text: string): string; + fg(color: string, text: string): string; +} + +interface TuiLike { + requestRender(): void; +} + +interface AskSettingsListOptions { + configPath: string; + notice?: AskConfigNotice; + onClose: () => void; + onSave: (config: AskConfig) => Promise; + savedConfig: AskConfig; + tui: TuiLike; +} + +const DESCRIPTION_LINE_COUNT = 3; +const COMPACT_WIDTH = 40; + +const BEHAVIOUR_SETTINGS = [ + { + description: + "Auto-submit completed ask flows when no question or option notes were added.", + key: "autoSubmitWhenAnsweredWithoutNotes", + label: "Auto-submit when answered without notes", + }, + { + description: + "Require a second cancel or dismiss action before discarding answered or drafted ask content.", + key: "confirmDismissWhenDirty", + label: "Confirm dismiss when dirty", + }, + { + description: + "Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.", + key: "doublePressReviewShortcuts", + label: "Double-press review shortcuts", + }, + { + description: "Show footer keymap hints at the bottom of the ask flow.", + key: "showFooterHints", + label: "Show footer hints", + }, +] as const; + +type BehaviourKey = (typeof BEHAVIOUR_SETTINGS)[number]["key"]; + +export class AskSettingsList { + private closed = false; + private config: AskConfig; + private error?: string; + private focusIndex = 0; + private readonly configPath: string; + private readonly notice?: AskConfigNotice; + private readonly onClose: () => void; + private readonly onSave: (config: AskConfig) => Promise; + private readonly theme: Theme; + private readonly tui: TuiLike; + + constructor(theme: Theme, options: AskSettingsListOptions) { + this.theme = theme; + this.config = options.savedConfig; + this.configPath = options.configPath; + this.notice = options.notice; + this.onClose = options.onClose; + this.onSave = options.onSave; + this.tui = options.tui; + } + + handleInput(data: string): void { + if ( + data === "?" || + matchesKey(data, Key.ctrl("c")) || + matchesKey(data, Key.escape) + ) { + this.close(); + return; + } + if (matchesKey(data, Key.up)) { + this.focusIndex = + this.focusIndex === 0 + ? BEHAVIOUR_SETTINGS.length - 1 + : this.focusIndex - 1; + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.down)) { + this.focusIndex = (this.focusIndex + 1) % BEHAVIOUR_SETTINGS.length; + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.enter) || data === " ") { + const setting = BEHAVIOUR_SETTINGS[this.focusIndex]; + if (setting) { + this.saveBehaviour(setting.key, !this.config.behaviour[setting.key]); + this.tui.requestRender(); + } + } + } + + render(width: number): string[] { + const innerWidth = Math.max(24, width - 2); + const lines = [ + this.topBorder(innerWidth), + this.line( + center(this.theme.fg("accent", "Ask Settings"), innerWidth), + innerWidth + ), + this.line("", innerWidth), + ]; + + this.appendWrapped( + lines, + this.theme.fg( + "muted", + "Edit this config file to change customizable ask keymaps:" + ), + innerWidth + ); + this.appendWrapped( + lines, + this.theme.fg("accent", this.configPath), + innerWidth, + { + center: true, + } + ); + + lines.push(this.line("", innerWidth)); + for (const [index, setting] of BEHAVIOUR_SETTINGS.entries()) { + for (const settingLine of this.renderSetting( + setting, + index, + innerWidth + )) { + lines.push(this.line(settingLine, innerWidth)); + } + if (innerWidth < COMPACT_WIDTH && index < BEHAVIOUR_SETTINGS.length - 1) { + lines.push(this.line("", innerWidth)); + } + } + + this.appendSelectedDescription(lines, innerWidth); + this.appendNotice(lines, innerWidth); + + lines.push(this.line("", innerWidth)); + this.appendFooter(lines, innerWidth); + lines.push(this.bottomBorder(innerWidth)); + return lines.map((line) => truncateToWidth(line, width)); + } + + invalidate(): void { + // State-driven component; nothing cached across theme changes. + } + + dispose(): void { + this.close(); + } + + private appendFooter(lines: string[], innerWidth: number): void { + if (innerWidth < COMPACT_WIDTH) { + lines.push( + this.line(this.theme.fg("dim", " Enter/Space to change"), innerWidth) + ); + lines.push(this.line(this.theme.fg("dim", " Esc to close"), innerWidth)); + return; + } + this.appendWrapped( + lines, + this.theme.fg("dim", "Enter/Space to change · Esc to close"), + innerWidth + ); + } + + private appendSelectedDescription(lines: string[], innerWidth: number): void { + const selectedSetting = BEHAVIOUR_SETTINGS[this.focusIndex]; + if (!selectedSetting) { + return; + } + lines.push(this.line("", innerWidth)); + const descriptionLines = wrapTextWithAnsi( + this.theme.fg("muted", selectedSetting.description), + innerWidth - 2 + ).slice(0, DESCRIPTION_LINE_COUNT); + for (const line of descriptionLines) { + lines.push(this.line(` ${line}`, innerWidth)); + } + for ( + let index = descriptionLines.length; + index < DESCRIPTION_LINE_COUNT; + index++ + ) { + lines.push(this.line("", innerWidth)); + } + } + + private appendNotice(lines: string[], innerWidth: number): void { + if (this.notice?.kind === "error") { + lines.push(this.line("", innerWidth)); + this.appendWrapped( + lines, + this.theme.fg("warning", this.notice.text), + innerWidth + ); + } + if (this.error) { + lines.push(this.line("", innerWidth)); + this.appendWrapped(lines, this.theme.fg("error", this.error), innerWidth); + } + } + + private appendWrapped( + lines: string[], + content: string, + innerWidth: number, + options: { center?: boolean } = {} + ): void { + for (const line of wrapTextWithAnsi(content, innerWidth - 2)) { + const contentLine = options.center + ? center(line, innerWidth) + : ` ${line}`; + lines.push(this.line(contentLine, innerWidth)); + } + } + + private renderSetting( + setting: (typeof BEHAVIOUR_SETTINGS)[number], + index: number, + innerWidth: number + ): string[] { + const selected = index === this.focusIndex; + const prefix = selected ? this.theme.fg("accent", "❯ ") : " "; + const continuationPrefix = " "; + const value = this.renderValue( + this.config.behaviour[setting.key], + selected + ); + const valueWidth = this.config.behaviour[setting.key] ? 4 : 5; + const labelWidth = Math.max( + 1, + innerWidth - visibleWidth(prefix) - valueWidth - 2 + ); + const labelLines = wrapTextWithAnsi(setting.label, labelWidth); + const firstLabelLine = labelLines[0] ?? ""; + const gap = " ".repeat( + Math.max(1, labelWidth - visibleWidth(firstLabelLine) + 1) + ); + const firstLine = `${prefix}${firstLabelLine}${gap}${value}`; + const continuationLines = labelLines + .slice(1) + .map((line) => `${continuationPrefix}${line}`); + return [firstLine, ...continuationLines]; + } + + private renderValue(enabled: boolean, selected: boolean): string { + const value = enabled ? "on" : "off"; + const styledValue = selected + ? this.theme.bg("selectedBg", this.theme.fg("accent", value)) + : this.theme.fg("muted", value); + return `${this.theme.fg("dim", "[")}${styledValue}${this.theme.fg("dim", "]")}`; + } + + private saveBehaviour(key: BehaviourKey, enabled: boolean): void { + this.error = undefined; + const previousConfig = this.config; + const nextConfig = { + ...this.config, + behaviour: { + ...this.config.behaviour, + [key]: enabled, + }, + }; + this.config = nextConfig; + this.onSave(nextConfig) + .then((savedConfig) => { + this.config = savedConfig; + this.tui.requestRender(); + }) + .catch((error: unknown) => { + this.config = previousConfig; + this.error = error instanceof Error ? error.message : String(error); + this.tui.requestRender(); + }); + } + + private topBorder(innerWidth: number): string { + return this.theme.fg("muted", `╭${"─".repeat(innerWidth)}╮`); + } + + private bottomBorder(innerWidth: number): string { + return this.theme.fg("muted", `╰${"─".repeat(innerWidth)}╯`); + } + + private line(content: string, innerWidth: number): string { + const clipped = truncateToWidth(content, innerWidth, "…"); + return `${this.theme.fg("muted", "│")}${padToWidth(clipped, innerWidth)}${this.theme.fg("muted", "│")}`; + } + + private close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.onClose(); + } +} + +function center(text: string, width: number): string { + const padding = Math.max(0, width - visibleWidth(text)); + const left = Math.floor(padding / 2); + const right = padding - left; + return `${" ".repeat(left)}${text}${" ".repeat(right)}`; +} + +function padToWidth(text: string, width: number): string { + return `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`; +} diff --git a/src/ui/settings-modal.ts b/src/ui/settings-modal.ts deleted file mode 100644 index dc73a17..0000000 --- a/src/ui/settings-modal.ts +++ /dev/null @@ -1,589 +0,0 @@ -import { - 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"; -import { - type AskSettingsState, - clearTransientNotice, - completeSave, - createAskSettingsState, - failSave, - isDirty, - moveBehaviourFocus, - moveSettingsTab, - requestDiscard, - setSaving, - shouldDiscard, - toggleBehaviourSetting, -} 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 COMPACT_DESCRIPTION_LINES = 2; -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.", - key: "autoSubmitWhenAnsweredWithoutNotes", - getValue(config: AskConfig) { - return config.behaviour.autoSubmitWhenAnsweredWithoutNotes; - }, - label: "Auto-submit when answered without notes", - }, - { - description: - "Require a second cancel or dismiss action before discarding answered or drafted ask content.", - key: "confirmDismissWhenDirty", - getValue(config: AskConfig) { - return config.behaviour.confirmDismissWhenDirty; - }, - label: "Confirm dismiss when dirty", - }, - { - description: - "Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.", - key: "doublePressReviewShortcuts", - getValue(config: AskConfig) { - return config.behaviour.doublePressReviewShortcuts; - }, - label: "Double-press review shortcuts", - }, - { - description: "Show footer keymap hints at the bottom of the ask flow.", - key: "showFooterHints", - getValue(config: AskConfig) { - return config.behaviour.showFooterHints; - }, - label: "Show footer hints", - }, -] 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 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, options: AskSettingsModalOptions) { - this.theme = theme; - 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 (this.handleGlobalInput(data)) { - return; - } - if (this.state.activeTab === "behaviour") { - this.handleBehaviourInput(data); - } - } - - render(width: number): string[] { - const layout = createLayout(this.theme, this.state.activeTab, width); - const lines = [...layout.headerLines]; - - 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, this.state, layout)); - } - - lines.push(...renderBottomPanel(this.theme, this.state, layout)); - return lines; - } - - invalidate(): void { - // 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)) { - const row = BEHAVIOUR_ROWS[this.state.behaviourFocusIndex]; - if (!row) { - return; - } - this.state = toggleBehaviourSetting(this.state, row.key); - 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; - } - this.closed = true; - this.onClose(); - } -} - -interface SettingsLayout { - border: (text: string) => string; - descW: number; - headerLines: string[]; - innerW: number; - keyW: number; - line: (content?: string) => string; -} - -function createLayout( - theme: Theme, - activeTab: SettingsTab, - width: number -): SettingsLayout { - const innerW = Math.max(MIN_WIDTH, width - 2); - const compact = innerW < COMPACT_WIDTH; - const keyW = compact ? 11 : 16; - const descW = Math.max(8, innerW - keyW - 5); - 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); - const right = titlePad - left; - const line = (content = "") => - `${border("│")}${padToWidth(` ${content}`, innerW)}${border("│")}`; - - return { - border, - descW, - headerLines: [ - `${border(`╭${"─".repeat(left)}`)}${title}${border(`${"─".repeat(right)}╮`)}`, - line(renderTabs(theme, activeTab, innerW - 1)), - line(), - ], - innerW, - keyW, - line, - }; -} - -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[] { - const compact = layout.innerW < COMPACT_WIDTH; - return BEHAVIOUR_ROWS.flatMap((row, index) => - renderBehaviourRow(theme, state, layout, row, index, compact) - ); -} - -function renderBehaviourRow( - theme: Theme, - state: AskSettingsState, - layout: SettingsLayout, - row: (typeof BEHAVIOUR_ROWS)[number], - index: number, - compact: boolean -): 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 descriptionLines = wrapText(row.description, layout.innerW - 4); - const lines = [ - layout.line(truncateToWidth(label, layout.innerW - 1, "…")), - ...descriptionLines - .slice(0, compact ? COMPACT_DESCRIPTION_LINES : descriptionLines.length) - .map((descLine) => layout.line(` ${theme.fg("dim", descLine)}`)), - ]; - if (!compact && index < BEHAVIOUR_ROWS.length - 1) { - lines.push(layout.line()); - } - if ( - compact && - descriptionLines.length > COMPACT_DESCRIPTION_LINES && - index < BEHAVIOUR_ROWS.length - 1 - ) { - lines.push(layout.line(` ${theme.fg("dim", "…")}`)); - } - 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); - - pushKeymapHeaderLines(lines, theme, layout, configPath, compact); - pushInvalidKeymapNotice(lines, theme, layout, notice, compact); - lines.push(layout.line()); - if (compact) { - lines.push( - ...renderCompactKeymapSection(theme, layout, "Customizable", customizable) - ); - lines.push(layout.line()); - lines.push(...renderCompactKeymapSection(theme, layout, "Fixed", fixed)); - return lines; - } - 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 pushKeymapHeaderLines( - lines: string[], - theme: Theme, - layout: SettingsLayout, - configPath: string, - compact: boolean -) { - const headerLines = compact - ? [configPath, "Edit keymaps there, then /reload."] - : [ - "Edit this config file to change customizable ask keymaps:", - configPath, - "Restart pi or run /reload after editing.", - ]; - - 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)) - ); - } - } -} - -function pushInvalidKeymapNotice( - lines: string[], - theme: Theme, - layout: SettingsLayout, - notice: AskConfigNotice | undefined, - compact: boolean -) { - if (notice?.kind !== "error") { - return; - } - - const warningText = compact - ? "Invalid config; using default keymaps this session." - : "Using default keymaps this session because configured keymaps were rejected."; - for (const line of wrapText(warningText, layout.innerW - 2)) { - lines.push(layout.line(theme.fg("warning", line))); - } -} - -function renderCompactKeymapSection( - theme: Theme, - layout: SettingsLayout, - title: string, - keymaps: readonly AskKeyBinding[] -): string[] { - const summary = keymaps - .map((hint) => `${hint.label} ${compactKeymapDescription(hint)}`) - .join(" · "); - const lines = [layout.line(theme.fg("muted", title))]; - for (const line of wrapText(summary, layout.innerW - 2)) { - lines.push(layout.line(line)); - } - return lines; -} - -function compactKeymapDescription(hint: AskKeyBinding): string { - switch (hint.id) { - case "cancel": - return "cancel"; - case "dismiss": - return "dismiss"; - case "toggle": - return "toggle"; - case "confirm": - return "confirm"; - case "optionNote": - return "option note"; - case "questionNote": - return "question note"; - case "settings": - return "settings"; - case "nextTab": - return "next tab"; - case "previousTab": - return "prev tab"; - case "previousOption": - return "move"; - case "nextOption": - return "next"; - case "numberShortcut": - return "quick select"; - case "fileReference": - return "file refs"; - default: - return hint.description.toLowerCase(); - } -} - -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)) { - lines.push(layout.line(` ${theme.fg("text", descLine)}`)); - } - continue; - } - const keyText = padToWidth( - truncateToWidth(hint.label, layout.keyW, "…"), - layout.keyW - ); - const key = theme.fg("accent", keyText); - const desc = truncateToWidth(hint.description, layout.descW, "…"); - lines.push(layout.line(`${key} ${desc}`)); - const contexts = hint.contexts.join(", "); - lines.push( - layout.line(`${" ".repeat(layout.keyW)} ${theme.fg("dim", contexts)}`) - ); - } -} - -function renderTabs( - theme: Theme, - activeTab: SettingsTab, - width: number -): string { - const renderTab = (tab: SettingsTab, label: string) => { - const text = ` ${label} `; - return activeTab === tab - ? theme.bg("selectedBg", theme.fg("accent", text)) - : theme.fg("muted", text); - }; - - const tabs = [ - renderTab("keymaps", "Keymaps"), - renderTab("behaviour", "Behaviour"), - ].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 deleted file mode 100644 index 92d6dcc..0000000 --- a/src/ui/settings-state.ts +++ /dev/null @@ -1,166 +0,0 @@ -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 toggleBehaviourSetting( - state: AskSettingsState, - key: keyof AskConfig["behaviour"] -): AskSettingsState { - return { - ...state, - draftConfig: { - ...state.draftConfig, - behaviour: { - ...state.draftConfig.behaviour, - [key]: !state.draftConfig.behaviour[key], - }, - }, - }; -} - -function deepEqual(left: AskConfig, right: AskConfig): boolean { - return ( - left.behaviour.autoSubmitWhenAnsweredWithoutNotes === - right.behaviour.autoSubmitWhenAnsweredWithoutNotes && - left.behaviour.confirmDismissWhenDirty === - right.behaviour.confirmDismissWhenDirty && - left.behaviour.doublePressReviewShortcuts === - right.behaviour.doublePressReviewShortcuts && - left.behaviour.showFooterHints === right.behaviour.showFooterHints && - 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.ts similarity index 84% rename from src/ui/show-settings-modal.ts rename to src/ui/show-settings.ts index 17a3e4e..87177a8 100644 --- a/src/ui/show-settings-modal.ts +++ b/src/ui/show-settings.ts @@ -1,15 +1,15 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { getAskConfigPath, getAskConfigStore } from "../config/store.ts"; -import { AskSettingsModal } from "./settings-modal.ts"; +import { AskSettingsList } from "./settings-list.ts"; -export async function showAskSettingsModal( +export async function showAskSettings( ui: Pick["ui"] ): Promise { const store = getAskConfigStore(); const { config, notice } = await store.ensureLoaded(); return ui.custom( (tui, theme, _keybindings, done) => - new AskSettingsModal(theme, { + new AskSettingsList(theme, { configPath: getAskConfigPath(), notice, onClose: () => { diff --git a/tests/ask-settings-command.test.ts b/tests/ask-settings-command.test.ts index d97e32d..fbde97f 100644 --- a/tests/ask-settings-command.test.ts +++ b/tests/ask-settings-command.test.ts @@ -16,7 +16,7 @@ function plainTheme() { }; } -test("registers /ask-settings and opens the shared modal overlay", async () => { +test("registers /ask-settings and opens the shared settings overlay", async () => { const agentDir = await mkdtemp(join(tmpdir(), "pi-ask-command-")); process.env.PI_CODING_AGENT_DIR = agentDir; @@ -65,7 +65,11 @@ test("registers /ask-settings and opens the shared modal overlay", async () => { width: 72, }, }); - assert(customCalls[0]?.lines.join("\n").includes("Keymaps")); + const text = customCalls[0]?.lines.join("\n") ?? ""; + assert(text.includes("Ask Settings")); + assert( + text.includes("Edit this config file to change customizable ask keymaps:") + ); delete process.env.PI_CODING_AGENT_DIR; await rm(agentDir, { force: true, recursive: true }); diff --git a/tests/config-store.test.ts b/tests/config-store.test.ts index 3e5b6cd..6481fc4 100644 --- a/tests/config-store.test.ts +++ b/tests/config-store.test.ts @@ -73,7 +73,7 @@ test("config store backs up broken json and loads defaults", async () => { 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." + "Config was invalid or unsupported. Backed it up and loaded defaults. Change any behaviour setting or edit the config file to save a fresh config." ); assert(dirEntries.some((entry) => entry.includes(".bak.json"))); await assert.rejects(readFile(path, "utf-8")); diff --git a/tests/settings-list.test.ts b/tests/settings-list.test.ts new file mode 100644 index 0000000..ff2d172 --- /dev/null +++ b/tests/settings-list.test.ts @@ -0,0 +1,137 @@ +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 { AskSettingsList } from "../src/ui/settings-list.ts"; + +const savedConfig: AskConfig = { + behaviour: { + autoSubmitWhenAnsweredWithoutNotes: false, + confirmDismissWhenDirty: true, + doublePressReviewShortcuts: true, + showFooterHints: true, + }, + keymaps: { + cancel: "esc", + dismiss: "ctrl+c", + toggle: "space", + confirm: "enter", + optionNote: "n", + questionNote: "shift+n", + }, +}; + +function plainTheme() { + return { + bg(_color: string, text: string) { + return text; + }, + fg(_color: string, text: string) { + return text; + }, + }; +} + +function createList( + options: { + onClose?: () => void; + onSave?: (config: AskConfig) => Promise; + } = {} +) { + const onClose = + options.onClose ?? + (() => { + // test callback intentionally unused + }); + return new AskSettingsList(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 list renders behaviour settings and config path", () => { + const list = createList(); + const text = list.render(72).join("\n"); + + assert(text.includes("╭")); + assert(text.includes("Ask Settings")); + assert(text.includes("Auto-submit when answered without notes")); + assert(text.includes("[off]")); + assert(text.includes("Confirm dismiss when dirty")); + assert(text.includes("on")); + assert( + text.includes("Edit this config file to change customizable ask keymaps:") + ); + assert(text.includes("/tmp/eko24ive-pi-ask.json")); + assert(text.includes("Esc to close")); + assert.equal(text.includes("Esc to cancel"), false); + assert.equal(text.includes("Keymaps"), false); + assert.equal(text.includes("Ctrl+S"), false); + assert.equal(text.includes("Saved"), false); +}); + +test("settings list stays within narrow render width", () => { + const list = createList(); + const lines = list.render(28); + + assert(lines.every((line) => visibleWidth(line) <= 28)); + const text = lines.join("\n"); + assert(text.includes("/tmp/eko24ive-pi-ask")); + assert(text.includes("n")); +}); + +test("settings list saves behaviour changes immediately without success feedback", async () => { + let saved: AskConfig | undefined; + const list = createList({ + onSave: (config) => { + saved = config; + return Promise.resolve(config); + }, + }); + + list.handleInput(" "); + await new Promise((resolve) => setImmediate(resolve)); + + const text = list.render(72).join("\n"); + assert.equal(saved?.behaviour.autoSubmitWhenAnsweredWithoutNotes, true); + assert.equal(saved?.behaviour.confirmDismissWhenDirty, true); + assert.equal(saved?.behaviour.doublePressReviewShortcuts, true); + assert.equal(saved?.behaviour.showFooterHints, true); + assert.equal(text.includes("Saved"), false); +}); + +test("settings list shows save failures and reverts the toggle", async () => { + const list = createList({ + onSave: () => Promise.reject(new Error("disk nope")), + }); + + list.handleInput(" "); + await new Promise((resolve) => setImmediate(resolve)); + + const text = list.render(72).join("\n"); + assert(text.includes("disk nope")); + assert(text.includes("Auto-submit when answered without notes")); + assert(text.includes("[off]")); +}); + +test("settings list closes with escape, ctrl-c, question mark, and dispose idempotently", () => { + let closed = 0; + const list = createList({ + onClose: () => { + closed += 1; + }, + }); + + list.handleInput("?"); + list.handleInput("\u0003"); + list.dispose(); + assert.equal(closed, 1); +}); diff --git a/tests/settings-modal.test.ts b/tests/settings-modal.test.ts deleted file mode 100644 index c2d0cb7..0000000 --- a/tests/settings-modal.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -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, - confirmDismissWhenDirty: true, - doublePressReviewShortcuts: true, - showFooterHints: true, - }, - 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); - const text = lines.join("\n"); - - assert(lines.every((line) => visibleWidth(line) <= 28)); - assert(lines.length <= 24); - assert(text.includes("@eko24ive/pi-ask")); - assert(lines[1]?.includes("Keymaps")); - assert(text.includes("Edit keymaps there, then")); - assert(text.includes("? settings")); - assert(text.includes("Esc cancel")); - 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("[x] Confirm dismiss when dirty")); - assert(text.includes("[x] Double-press review shortcuts")); - assert(text.includes("[x] Show footer hints")); - assert(text.includes("Unsaved changes. Press Ctrl+S to save.")); - assert(text.includes("Ctrl+S save · Esc/Ctrl+C/? twice discard")); -}); - -test("settings modal keeps behaviour tab compact on narrow screens", () => { - const modal = createModal(); - modal.handleInput("\t"); - const lines = modal.render(36); - - assert(lines.every((line) => visibleWidth(line) <= 36)); - assert(lines.length <= 21); - assert(lines.join("\n").includes("Show footer hints")); -}); - -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.equal(saved?.behaviour.confirmDismissWhenDirty, true); - assert.equal(saved?.behaviour.doublePressReviewShortcuts, true); - assert.equal(saved?.behaviour.showFooterHints, 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); -});