From b218b8b1889b163e5bd21fcb5083250921404a02 Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Tue, 5 May 2026 17:49:09 +0200 Subject: [PATCH] feat: add guarded config reset --- README.md | 4 +- docs/architecture.md | 2 +- docs/configuration.md | 3 +- docs/contract.md | 8 +-- src/ui/settings-list.ts | 113 +++++++++++++++++++++++++++++------- tests/settings-list.test.ts | 36 ++++++++++++ 6 files changed, 137 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 103dd31..9606a52 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ Fixed bindings: Review-tab shortcuts can optionally require the same number key twice via `behaviour.doublePressReviewShortcuts`. -You can edit the config file yourself, ask pi to edit it for you, or use `/ask-settings` to find the exact config path and toggle behaviour/notification settings. +You can edit the config file yourself, ask pi to edit it for you, or use `/ask-settings` to find the exact config path, toggle behaviour/notification settings, or reset config to defaults with a guarded double press. ```json { @@ -184,7 +184,7 @@ Accepted notation follows pi-tui key ids. Common aliases are normalized, for exa After installation, the extension registers the `ask_user` tool plus `/ask-settings`, `/answer`, `/answer:again`, and `/ask:replay` commands. -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 and notification settings are binary `on`/`off` toggles that save immediately; keymaps, notification channels, and extraction settings are changed by editing the shown config file path. +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 and notification settings are binary `on`/`off` toggles that save immediately, and the settings overlay includes a guarded double-press reset-to-defaults action; keymaps, notification channels, and extraction settings are changed by editing the shown config file path. ### Answer and replay commands diff --git a/docs/architecture.md b/docs/architecture.md index 2f7a6fd..9e577a3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,7 +78,7 @@ The codebase is split so the implementation reads through file boundaries and na - replay payload lookup scans only the current session branch and revalidates payloads before use - invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour, notification, or answer settings - invalid notification channels are skipped and fall back to the default bell channel if none are valid -- ask settings behaviour and notification enabled changes persist immediately from the settings list +- ask settings behaviour and notification enabled changes persist immediately from the settings list; config reset is guarded by a short double-press confirmation - when the ask config file is missing, the first ask use writes a default persisted config snapshot under `~/.pi/agent/extensions/` - legacy root config files move into `~/.pi/agent/extensions/` only when the current config file is absent - live config updates can affect an in-progress ask flow immediately diff --git a/docs/configuration.md b/docs/configuration.md index 98f0399..4c2b625 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -148,7 +148,7 @@ Notifications are best-effort external alerts emitted once per ask session, when - type: boolean - default: `true` - effect: enables or disables external ask notifications -- settings UI: this is the only notification field toggled by `/ask-settings` or `?` in the ask flow +- settings UI: this is the only notification field toggled individually by `/ask-settings` or `?` in the ask flow ### `notifications.channels` @@ -384,5 +384,6 @@ When editing this config for a user: - do not create duplicate bindings within a context or between `global` and `main`, `editor`, or `noteEditor` - preserve existing `notifications.channels` unless the user asks to change notification targets - only toggle `notifications.enabled` unless the user asks to configure channels +- use the settings reset action only when the user explicitly asks to restore pi-ask defaults - use a `cmux notify` command channel when the user asks for cmux notifications - after changing the file, tell the user to run `/reload` or restart pi diff --git a/docs/contract.md b/docs/contract.md index ecbb98d..022b28d 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -206,9 +206,9 @@ This document defines the stable external behavior. It does not explain internal - `/answer` extraction may use an internal `freeform: true` option for open-ended questions with no explicit choices; these render as user-input-only questions with the label `Type your answer:`, no numbered option row, and no selection caret; this marker is not part of the public `ask_user` tool contract - `/answer:again` command to replay the latest `/answer`-extracted form on the current branch - `/ask:replay` command to replay the latest real `ask_user` form on the current branch -- ask settings list with binary behaviour and notification toggles +- ask settings list with binary behaviour/notification toggles and a guarded reset-to-defaults action - `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay -- settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, and `Show footer hints` +- settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, and `Show footer hints`; resetting config to defaults requires pressing the reset action twice within a short confirmation window - `Keymaps` is a persisted, context-aware config section for global, main-flow, editor, note-editor, and settings-modal actions - the settings list shows the absolute config file path for customizing keymaps, notifications, and extraction settings - 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 @@ -242,7 +242,7 @@ Settings modal: - `settingsModal.close` closes settings; defaults: `Esc`, `Ctrl+C`, `?` - `settingsModal.nextOption` / `settingsModal.previousOption` move between settings; defaults: `Down`, `Up` -- `settingsModal.toggle` toggles the highlighted setting and saves immediately; defaults: `Enter`, `Space` +- `settingsModal.toggle` toggles the highlighted setting and saves immediately; on the reset action, the same binding must be pressed twice within a short confirmation window; defaults: `Enter`, `Space` Dirty dismiss: @@ -255,7 +255,7 @@ 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`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, `Show footer hints`, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks. +The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, `Show footer hints`, resetting config to defaults, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks. ## Notifications diff --git a/src/ui/settings-list.ts b/src/ui/settings-list.ts index a1683b5..69bcf68 100644 --- a/src/ui/settings-list.ts +++ b/src/ui/settings-list.ts @@ -3,6 +3,7 @@ import { visibleWidth, wrapTextWithAnsi, } from "@mariozechner/pi-tui"; +import { DEFAULT_ASK_CONFIG, normalizeAskConfig } from "../config/defaults.ts"; import type { AskConfig } from "../config/schema.ts"; import type { AskConfigNotice } from "../config/store.ts"; import { @@ -31,6 +32,7 @@ interface AskSettingsListOptions { const DESCRIPTION_LINE_COUNT = 3; const COMPACT_WIDTH = 40; +const RESET_CONFIRMATION_MS = 2000; const SETTINGS = [ { @@ -38,39 +40,53 @@ const SETTINGS = [ "Auto-submit completed ask flows when no question or option notes were added.", key: "autoSubmitWhenAnsweredWithoutNotes", label: "Auto-submit when answered without notes", + type: "toggle", }, { description: "Require a second cancel or dismiss action before discarding answered or drafted ask content.", key: "confirmDismissWhenDirty", label: "Confirm dismiss when dirty", + type: "toggle", }, { 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", + type: "toggle", }, { description: "Emit one external notification when the ask flow opens and waits for input.", key: "notifications.enabled", label: "Notifications", + type: "toggle", }, { description: "Show footer keymap hints at the bottom of the ask flow.", key: "showFooterHints", label: "Show footer hints", + type: "toggle", + }, + { + description: + "Reset behaviour, keymaps, notifications, and extraction settings to defaults. Press twice quickly to confirm.", + key: "resetConfig", + label: "Reset config to defaults", + type: "action", }, ] as const; -type SettingKey = (typeof SETTINGS)[number]["key"]; +type Setting = (typeof SETTINGS)[number]; +type SettingKey = Extract["key"]; export class AskSettingsList { private closed = false; private config: AskConfig; private error?: string; private focusIndex = 0; + private resetConfirmUntil = 0; private readonly configPath: string; private readonly notice?: AskConfigNotice; private readonly onClose: () => void; @@ -95,22 +111,15 @@ export class AskSettingsList { return; } if (matchesBinding(data, bindings.previousOption)) { - this.focusIndex = - this.focusIndex === 0 ? SETTINGS.length - 1 : this.focusIndex - 1; - this.tui.requestRender(); + this.moveFocus(-1); return; } if (matchesBinding(data, bindings.nextOption)) { - this.focusIndex = (this.focusIndex + 1) % SETTINGS.length; - this.tui.requestRender(); + this.moveFocus(1); return; } if (matchesBinding(data, bindings.toggle)) { - const setting = SETTINGS[this.focusIndex]; - if (setting) { - this.saveSetting(setting.key, !this.getSettingValue(setting.key)); - this.tui.requestRender(); - } + this.activateSelectedSetting(); } } @@ -144,6 +153,10 @@ export class AskSettingsList { lines.push(this.line("", innerWidth)); for (const [index, setting] of SETTINGS.entries()) { + if (setting.type === "action") { + lines.push(this.line("", innerWidth)); + lines.push(this.line("", innerWidth)); + } for (const settingLine of this.renderSetting( setting, index, @@ -240,16 +253,18 @@ export class AskSettingsList { } private renderSetting( - setting: (typeof SETTINGS)[number], + setting: Setting, index: number, innerWidth: number ): string[] { const selected = index === this.focusIndex; const prefix = selected ? this.theme.fg("accent", "❯ ") : " "; const continuationPrefix = " "; - const enabled = this.getSettingValue(setting.key); - const value = this.renderValue(enabled, selected); - const valueWidth = enabled ? 4 : 5; + if (setting.type === "action") { + return [center(this.renderSettingValue(setting, selected), innerWidth)]; + } + const value = this.renderSettingValue(setting, selected); + const valueWidth = visibleWidth(value); const labelWidth = Math.max( 1, innerWidth - visibleWidth(prefix) - valueWidth - 2 @@ -266,11 +281,21 @@ export class AskSettingsList { return [firstLine, ...continuationLines]; } - private renderValue(enabled: boolean, selected: boolean): string { - const value = enabled ? "on" : "off"; + private renderSettingValue(setting: Setting, selected: boolean): string { + if (setting.type === "action") { + return this.renderValue( + this.isResetConfirmationActive() ? "confirm reset" : "reset all", + selected + ); + } + return this.renderValue(this.getSettingValue(setting.key), selected); + } + + private renderValue(value: boolean | string, selected: boolean): string { + const valueText = formatSettingValue(value); const styledValue = selected - ? this.theme.bg("selectedBg", this.theme.fg("accent", value)) - : this.theme.fg("muted", value); + ? this.theme.bg("selectedBg", this.theme.fg("accent", valueText)) + : this.theme.fg("muted", valueText); return `${this.theme.fg("dim", "[")}${styledValue}${this.theme.fg("dim", "]")}`; } @@ -280,9 +305,42 @@ export class AskSettingsList { : this.config.behaviour[key]; } + private moveFocus(delta: 1 | -1): void { + this.resetConfirmUntil = 0; + this.focusIndex = + (this.focusIndex + delta + SETTINGS.length) % SETTINGS.length; + this.tui.requestRender(); + } + + private activateSelectedSetting(): void { + const setting = SETTINGS[this.focusIndex]; + if (!setting) { + return; + } + if (setting.type === "action") { + this.resetConfigWithConfirmation(); + return; + } + this.resetConfirmUntil = 0; + this.saveSetting(setting.key, !this.getSettingValue(setting.key)); + this.tui.requestRender(); + } + + private resetConfigWithConfirmation(): void { + if (!this.isResetConfirmationActive()) { + this.resetConfirmUntil = Date.now() + RESET_CONFIRMATION_MS; + this.tui.requestRender(); + return; + } + this.resetConfirmUntil = 0; + this.saveConfig(normalizeAskConfig(DEFAULT_ASK_CONFIG)); + } + + private isResetConfirmationActive(): boolean { + return Date.now() <= this.resetConfirmUntil; + } + private saveSetting(key: SettingKey, enabled: boolean): void { - this.error = undefined; - const previousConfig = this.config; const nextConfig = key === "notifications.enabled" ? { @@ -299,6 +357,12 @@ export class AskSettingsList { [key]: enabled, }, }; + this.saveConfig(nextConfig); + } + + private saveConfig(nextConfig: AskConfig): void { + this.error = undefined; + const previousConfig = this.config; this.config = nextConfig; this.onSave(nextConfig) .then((savedConfig) => { @@ -334,6 +398,13 @@ export class AskSettingsList { } } +function formatSettingValue(value: boolean | string): string { + if (typeof value === "string") { + return value; + } + return value ? "on" : "off"; +} + function center(text: string, width: number): string { const padding = Math.max(0, width - visibleWidth(text)); const left = Math.floor(padding / 2); diff --git a/tests/settings-list.test.ts b/tests/settings-list.test.ts index d4b0546..7c668f0 100644 --- a/tests/settings-list.test.ts +++ b/tests/settings-list.test.ts @@ -155,6 +155,42 @@ test("settings list uses configured navigation and close keys", async () => { assert.equal(saved?.behaviour.confirmDismissWhenDirty, false); }); +test("settings list resets config to defaults after double press", async () => { + let saveCount = 0; + let saved: AskConfig | undefined; + const customConfig: AskConfig = { + ...savedConfig, + behaviour: { + ...savedConfig.behaviour, + autoSubmitWhenAnsweredWithoutNotes: true, + showFooterHints: false, + }, + notifications: { + ...savedConfig.notifications, + enabled: false, + }, + }; + const list = createList({ + onSave: (config) => { + saveCount += 1; + saved = config; + return Promise.resolve(config); + }, + savedConfig: customConfig, + }); + + list.handleInput("\x1b[A"); + list.handleInput(" "); + assert.equal(saveCount, 0); + assert(list.render(72).join("\n").includes("[confirm reset]")); + + list.handleInput(" "); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(saveCount, 1); + assert.deepEqual(saved, DEFAULT_ASK_CONFIG); +}); + test("settings list closes with configured keys and dispose idempotently", () => { let closed = 0; const list = createList({