feat: add guarded config reset
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user