refactor: simplify ask settings UI
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<AskConfig>;
|
||||
} = {}
|
||||
) {
|
||||
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);
|
||||
});
|
||||
@@ -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<AskConfig>;
|
||||
} = {}
|
||||
) {
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user