feat: add guarded config reset

This commit is contained in:
Erik Kotsiuba
2026-05-06 12:44:37 +02:00
parent 808b62cc77
commit b218b8b188
6 changed files with 137 additions and 29 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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
+4 -4
View File
@@ -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
+92 -21
View File
@@ -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<Setting, { type: "toggle" }>["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);
+36
View File
@@ -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({