feat: add config-backed ask keymaps

This commit is contained in:
Erik Kotsiuba
2026-04-28 22:35:43 +02:00
parent 5c0d8964fb
commit e8a36b72ef
31 changed files with 2192 additions and 422 deletions
+15 -1
View File
@@ -1,4 +1,7 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { registerAskSettingsCommand } from "../src/ask-settings-command.ts";
@@ -14,6 +17,9 @@ function plainTheme() {
}
test("registers /ask-settings and opens the shared modal overlay", async () => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-ask-command-"));
process.env.PI_CODING_AGENT_DIR = agentDir;
const commands = new Map<
string,
{ handler: (args: string, ctx: any) => Promise<void> }
@@ -36,7 +42,12 @@ test("registers /ask-settings and opens the shared modal overlay", async () => {
const done = () => {
// test callback intentionally unused
};
const component = callback(undefined, plainTheme(), undefined, done);
const tui = {
requestRender() {
// test render hook intentionally unused
},
};
const component = callback(tui, plainTheme(), undefined, done);
customCalls.push({ options, lines: component.render(72) });
return Promise.resolve();
},
@@ -55,4 +66,7 @@ test("registers /ask-settings and opens the shared modal overlay", async () => {
},
});
assert(customCalls[0]?.lines.join("\n").includes("Keymaps"));
delete process.env.PI_CODING_AGENT_DIR;
await rm(agentDir, { force: true, recursive: true });
});
+74
View File
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
import type { AskConfig } from "../src/config/schema.ts";
import { createInitialState } from "../src/state/create.ts";
import { saveNote } from "../src/state/transitions.ts";
import type { AskState } from "../src/types.ts";
import { maybeAutoSubmitState } from "../src/ui/auto-submit.ts";
const enabledConfig: AskConfig = {
...DEFAULT_ASK_CONFIG,
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true,
},
};
const disabledConfig: AskConfig = {
...DEFAULT_ASK_CONFIG,
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: false,
},
};
function answeredSubmitState(): AskState {
return {
...createInitialState({
questions: [
{
id: "q1",
label: "Q1",
options: [{ label: "Option A", value: "a" }],
prompt: "Pick one",
required: false,
type: "single",
},
],
}),
activeTabIndex: 1,
answers: {
q1: {
selected: [{ index: 1, label: "Option A", value: "a" }],
},
},
view: { kind: "submit" },
};
}
test("auto submit completes answered submit-tab state when enabled", () => {
const state = maybeAutoSubmitState(answeredSubmitState(), enabledConfig);
assert.equal(state.completed, true);
assert.equal(state.mode, "submit");
});
test("auto submit does nothing when disabled", () => {
const state = maybeAutoSubmitState(answeredSubmitState(), disabledConfig);
assert.equal(state.completed, false);
});
test("auto submit does not trigger when notes exist", () => {
const stateWithNote = saveNote(
{ ...answeredSubmitState(), view: { kind: "note", questionId: "q1" } },
"Need more detail"
);
const submitState: AskState = {
...stateWithNote,
activeTabIndex: 1,
view: { kind: "submit" },
};
const state = maybeAutoSubmitState(submitState, enabledConfig);
assert.equal(state.completed, false);
});
+125
View File
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
import { AskConfigStore } from "../src/config/store.ts";
const DEFAULT_KEYMAPS_NOTICE_PATTERN =
/Using default ask keymaps for this session/;
async function makeTempPath(name: string): Promise<string> {
const root = await import("node:fs/promises").then(({ mkdtemp }) =>
mkdtemp(join(tmpdir(), name))
);
return join(root, "extensions", "eko24ive-pi-ask.json");
}
test("config store uses in-memory defaults when file is missing", async () => {
const path = await makeTempPath("pi-ask-config-missing-");
const store = new AskConfigStore(path);
const result = await store.ensureLoaded();
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG);
await assert.rejects(readFile(path, "utf-8"));
await rm(join(path, "..", ".."), { force: true, recursive: true });
});
test("config store writes full normalized config on save", async () => {
const path = await makeTempPath("pi-ask-config-save-");
const store = new AskConfigStore(path);
await store.save({
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true,
},
keymaps: DEFAULT_ASK_CONFIG.keymaps,
});
const content = await readFile(path, "utf-8");
assert.deepEqual(JSON.parse(content), {
schemaVersion: 1,
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true,
},
keymaps: DEFAULT_ASK_CONFIG.keymaps,
});
await rm(join(path, "..", ".."), { force: true, recursive: true });
});
test("config store backs up broken json and loads defaults", async () => {
const path = await makeTempPath("pi-ask-config-broken-");
await mkdir(join(path, ".."), { recursive: true });
await writeFile(path, "{bad json", "utf-8");
const store = new AskConfigStore(path);
const result = await store.ensureLoaded();
const dirEntries = await import("node:fs/promises").then(({ readdir }) =>
readdir(join(path, ".."))
);
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."
);
assert(dirEntries.some((entry) => entry.includes(".bak.json")));
await assert.rejects(readFile(path, "utf-8"));
await rm(join(path, "..", ".."), { force: true, recursive: true });
});
test("config store loads current config version without rewriting", async () => {
const path = await makeTempPath("pi-ask-config-current-");
await mkdir(join(path, ".."), { recursive: true });
await writeFile(
path,
JSON.stringify({
schemaVersion: 1,
behaviour: { autoSubmitWhenAnsweredWithoutNotes: true },
keymaps: DEFAULT_ASK_CONFIG.keymaps,
})
);
const store = new AskConfigStore(path);
const result = await store.ensureLoaded();
assert.equal(
result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes,
true
);
assert.deepEqual(result.config.keymaps, DEFAULT_ASK_CONFIG.keymaps);
await rm(join(path, "..", ".."), { force: true, recursive: true });
});
test("config store falls back only keymaps when configured keymaps are invalid", async () => {
const path = await makeTempPath("pi-ask-config-invalid-keymaps-");
await mkdir(join(path, ".."), { recursive: true });
await writeFile(
path,
JSON.stringify({
schemaVersion: 1,
behaviour: { autoSubmitWhenAnsweredWithoutNotes: true },
keymaps: {
cancel: "?",
dismiss: "ctrl+c",
toggle: "space",
confirm: "enter",
optionNote: "n",
questionNote: "shift+n",
},
})
);
const store = new AskConfigStore(path);
const result = await store.ensureLoaded();
assert.equal(
result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes,
true
);
assert.deepEqual(result.config.keymaps, DEFAULT_ASK_CONFIG.keymaps);
assert.match(result.notice?.text ?? "", DEFAULT_KEYMAPS_NOTICE_PATTERN);
await rm(join(path, "..", ".."), { force: true, recursive: true });
});
-86
View File
@@ -1,86 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { visibleWidth } from "@mariozechner/pi-tui";
import { ASK_HELP_CLOSE_HINT, ASK_KEYMAPS } from "../src/constants/keymaps.ts";
import { AskSettingsModal } from "../src/ui/settings-modal.ts";
const noop = () => {
// Test callback intentionally does nothing.
};
function plainTheme() {
return {
fg(_color: string, text: string) {
return text;
},
bg(_color: string, text: string) {
return text;
},
};
}
test("settings modal renders compactly on phone-width screens", () => {
const modal = new AskSettingsModal(plainTheme(), noop);
const lines = modal.render(28);
assert(lines.length > ASK_KEYMAPS.length + 4);
assert(lines.every((line) => visibleWidth(line) <= 28));
assert(lines.join("\n").includes("@eko24ive/pi-ask"));
assert(lines.slice(-3, -1).join(" ").includes("Esc, Ctrl+C, or ?"));
assert(lines[1]?.includes("Keymaps"));
assert.equal(lines[2], "│ │");
assert(lines.includes("│ ? │"));
assert(lines.includes("│ Show this menu │"));
assert.equal(lines[0]?.includes("├"), false);
});
test("settings modal keeps context rows on wider screens", () => {
const modal = new AskSettingsModal(plainTheme(), noop);
const lines = modal.render(72);
assert(lines.length > ASK_KEYMAPS.length + 4);
assert(lines.join("\n").includes("Main flow"));
assert(lines.at(-2)?.includes(ASK_HELP_CLOSE_HINT));
});
test("settings modal switches tabs and closes from configured shortcuts", () => {
let closed = 0;
const modal = new AskSettingsModal(plainTheme(), () => {
closed += 1;
});
modal.handleInput("\t");
assert(
modal.render(72).join("\n").includes("Behaviour settings coming soon.")
);
assert(modal.render(72).join("\n").includes("Behaviour"));
modal.handleInput("\u001b[Z");
assert.equal(
modal.render(72).join("\n").includes("Behaviour settings coming soon."),
false
);
for (const input of ["?", "\u001b", "\u0003"]) {
const closable = new AskSettingsModal(plainTheme(), () => {
closed += 1;
});
closable.handleInput(input);
}
assert.equal(closed, 3);
});
test("settings modal ignores enter and closes idempotently on dispose", () => {
let closed = 0;
const modal = new AskSettingsModal(plainTheme(), () => {
closed += 1;
});
modal.handleInput("\r");
assert.equal(closed, 0);
modal.dispose();
modal.dispose();
assert.equal(closed, 1);
});
+63 -31
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
import { createInitialState } from "../src/state/create.ts";
import {
applyNumberShortcut,
@@ -24,27 +25,27 @@ function inputState() {
test("empty typing mode uses arrows and tab for navigation", () => {
const input = inputState();
assert.deepEqual(getInputCommand(input, "\x1b[A", ""), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[A", ""), {
kind: "editMoveOption",
delta: -1,
});
assert.deepEqual(getInputCommand(input, "\x1b[B", ""), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[B", ""), {
kind: "editMoveOption",
delta: 1,
});
assert.deepEqual(getInputCommand(input, "\x1b[C", ""), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[C", ""), {
kind: "editMoveTab",
delta: 1,
});
assert.deepEqual(getInputCommand(input, "\x1b[D", ""), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[D", ""), {
kind: "editMoveTab",
delta: -1,
});
assert.deepEqual(getInputCommand(input, "\t", ""), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\t", ""), {
kind: "editMoveTab",
delta: 1,
});
assert.deepEqual(getInputCommand(input, "\x1b[Z", ""), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[Z", ""), {
kind: "editMoveTab",
delta: -1,
});
@@ -53,22 +54,22 @@ test("empty typing mode uses arrows and tab for navigation", () => {
test("non-empty typing mode keeps arrows and tab in editor", () => {
const input = inputState();
assert.deepEqual(getInputCommand(input, "\x1b[A", "x"), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[A", "x"), {
kind: "delegateToEditor",
});
assert.deepEqual(getInputCommand(input, "\x1b[B", "x"), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[B", "x"), {
kind: "delegateToEditor",
});
assert.deepEqual(getInputCommand(input, "\x1b[C", "x"), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[C", "x"), {
kind: "delegateToEditor",
});
assert.deepEqual(getInputCommand(input, "\x1b[D", "x"), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[D", "x"), {
kind: "delegateToEditor",
});
assert.deepEqual(getInputCommand(input, "\t", "x"), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\t", "x"), {
kind: "delegateToEditor",
});
assert.deepEqual(getInputCommand(input, "\x1b[Z", "x"), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\x1b[Z", "x"), {
kind: "delegateToEditor",
});
});
@@ -87,23 +88,23 @@ test("empty note editing mode uses arrows and tab for navigation", () => {
"q1"
);
assert.deepEqual(getInputCommand(state, "\x1b[A", ""), {
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[A", ""), {
kind: "editMoveOption",
delta: -1,
});
assert.deepEqual(getInputCommand(state, "\x1b[B", ""), {
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[B", ""), {
kind: "editMoveOption",
delta: 1,
});
assert.deepEqual(getInputCommand(state, "\x1b[C", ""), {
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[C", ""), {
kind: "editMoveTab",
delta: 1,
});
assert.deepEqual(getInputCommand(state, "\x1b[D", ""), {
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[D", ""), {
kind: "editMoveTab",
delta: -1,
});
assert.deepEqual(getInputCommand(state, "\t", ""), {
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\t", ""), {
kind: "editMoveTab",
delta: 1,
});
@@ -123,19 +124,19 @@ test("non-empty note editing mode keeps arrows and tab in editor", () => {
"q1"
);
assert.deepEqual(getInputCommand(state, "\x1b[A", "x"), {
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[A", "x"), {
kind: "delegateToEditor",
});
assert.deepEqual(getInputCommand(state, "\x1b[B", "x"), {
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[B", "x"), {
kind: "delegateToEditor",
});
assert.deepEqual(getInputCommand(state, "\x1b[C", "x"), {
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[C", "x"), {
kind: "delegateToEditor",
});
assert.deepEqual(getInputCommand(state, "\x1b[D", "x"), {
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\x1b[D", "x"), {
kind: "delegateToEditor",
});
assert.deepEqual(getInputCommand(state, "\t", "x"), {
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "\t", "x"), {
kind: "delegateToEditor",
});
});
@@ -153,18 +154,18 @@ test("ctrl+c dismisses the flow from both navigation and editing modes", () => {
const input = inputState();
const note = enterQuestionNoteMode(navigation, "q1");
assert.deepEqual(getInputCommand(navigation, "\u0003"), {
assert.deepEqual(getInputCommand(navigation, DEFAULT_ASK_CONFIG, "\u0003"), {
kind: "dismiss",
});
assert.deepEqual(getInputCommand(input, "\u0003"), {
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "\u0003"), {
kind: "dismiss",
});
assert.deepEqual(getInputCommand(note, "\u0003"), {
assert.deepEqual(getInputCommand(note, DEFAULT_ASK_CONFIG, "\u0003"), {
kind: "dismiss",
});
});
test("question mark opens keymap help outside non-empty editors", () => {
test("question mark opens ask settings outside non-empty editors", () => {
const navigation = createInitialState({
questions: [
{
@@ -176,9 +177,13 @@ test("question mark opens keymap help outside non-empty editors", () => {
});
const input = inputState();
assert.deepEqual(getInputCommand(navigation, "?"), { kind: "showHelp" });
assert.deepEqual(getInputCommand(input, "?", ""), { kind: "showHelp" });
assert.deepEqual(getInputCommand(input, "?", "x"), {
assert.deepEqual(getInputCommand(navigation, DEFAULT_ASK_CONFIG, "?"), {
kind: "showSettings",
});
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "?", ""), {
kind: "showSettings",
});
assert.deepEqual(getInputCommand(input, DEFAULT_ASK_CONFIG, "?", "x"), {
kind: "delegateToEditor",
});
});
@@ -194,10 +199,37 @@ test("note shortcuts use n for option notes and Shift+N for question notes", ()
],
});
assert.deepEqual(getInputCommand(navigation, "n"), {
assert.deepEqual(getInputCommand(navigation, DEFAULT_ASK_CONFIG, "n"), {
kind: "openOptionNote",
});
assert.deepEqual(getInputCommand(navigation, "N"), {
assert.deepEqual(getInputCommand(navigation, DEFAULT_ASK_CONFIG, "N"), {
kind: "openQuestionNote",
});
});
test("custom configured note shortcuts are used at runtime", () => {
const navigation = createInitialState({
questions: [
{
id: "q1",
prompt: "Question?",
options: [{ value: "a", label: "A" }],
},
],
});
const config = {
...DEFAULT_ASK_CONFIG,
keymaps: {
...DEFAULT_ASK_CONFIG.keymaps,
optionNote: "x",
questionNote: "shift+x",
},
};
assert.deepEqual(getInputCommand(navigation, config, "x"), {
kind: "openOptionNote",
});
assert.deepEqual(getInputCommand(navigation, config, "X"), {
kind: "openQuestionNote",
});
});
+12 -4
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
import { createInitialState } from "../src/state/create.ts";
import { renderAskScreen } from "../src/ui/render.ts";
@@ -48,6 +49,7 @@ test("wide header keeps all tabs and framing arrows on the tab row", () => {
});
const lines = renderAskScreen({
config: DEFAULT_ASK_CONFIG,
state,
theme: plainTheme(),
width: 120,
@@ -96,6 +98,7 @@ test("narrow tab strip keeps active middle tab visible", () => {
state.activeTabIndex = 2;
const lines = renderAskScreen({
config: DEFAULT_ASK_CONFIG,
state,
theme: plainTheme(),
width: 28,
@@ -133,6 +136,7 @@ test("narrow tab strip keeps submit tab visible when active", () => {
state.view = { kind: "submit" };
const lines = renderAskScreen({
config: DEFAULT_ASK_CONFIG,
state,
theme: plainTheme(),
width: 24,
@@ -190,6 +194,7 @@ test("tab strip avoids truncation at narrow boundary widths", () => {
for (const [width, expected] of expectedByWidth) {
const lines = renderAskScreen({
config: DEFAULT_ASK_CONFIG,
state,
theme: plainTheme(),
width,
@@ -213,6 +218,7 @@ test("footer hints wrap into exact lines on narrow screens", () => {
});
const lines = renderAskScreen({
config: DEFAULT_ASK_CONFIG,
state,
theme: plainTheme(),
width: 26,
@@ -223,7 +229,7 @@ test("footer hints wrap into exact lines on narrow screens", () => {
"Space toggle",
"Enter continue",
"N/Shift+N note",
"Esc dismiss · ? help",
"Esc dismiss · ? settings",
]);
});
@@ -240,16 +246,18 @@ test("footer keeps earlier hint chunk on the first wrapped line", () => {
});
const lines = renderAskScreen({
config: DEFAULT_ASK_CONFIG,
state,
theme: plainTheme(),
width: 22,
editor: mockEditor(),
});
assert.equal(lines.at(-5), " ⇆ tab · ↑↓ select");
assert.deepEqual(lines.slice(-4, -1), [
assert.equal(lines.at(-6), " ⇆ tab · ↑↓ select");
assert.deepEqual(lines.slice(-5, -1), [
"Enter confirm",
"N/Shift+N note",
"Esc dismiss · ? help",
"Esc dismiss",
"? settings",
]);
});
+25 -2
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
import {
renderEditorBlock,
renderFooterText,
@@ -74,6 +75,28 @@ test("renderEditorBlock reapplies background after editor reset sequences", () =
});
test("editing footers do not advertise tab navigation", () => {
assert.equal(renderFooterText("input"), " Enter submit · Esc close · ? help");
assert.equal(renderFooterText("note"), " Enter save · Esc close · ? help");
assert.equal(
renderFooterText(DEFAULT_ASK_CONFIG, "input"),
" Enter submit · Esc close · ? settings"
);
assert.equal(
renderFooterText(DEFAULT_ASK_CONFIG, "note"),
" Enter save · Esc close · ? settings"
);
});
test("editing footers use configured key labels", () => {
const config = {
...DEFAULT_ASK_CONFIG,
keymaps: {
...DEFAULT_ASK_CONFIG.keymaps,
confirm: "ctrl+k",
cancel: "q",
},
};
assert.equal(
renderFooterText(config, "note"),
" Ctrl+K save · Q close · ? settings"
);
});
+34
View File
@@ -4,6 +4,7 @@ import { createInitialState } from "../src/state/create.ts";
import { getRenderableOptions } from "../src/state/selectors.ts";
import {
applyNumberShortcut,
enterOptionNoteMode,
enterQuestionNoteMode,
} from "../src/state/transitions.ts";
import { renderQuestionScreen } from "../src/ui/render-question.ts";
@@ -121,6 +122,39 @@ test("open question note renders inline label and editor", () => {
assert(!lines.some((line) => line.includes("Note:")));
});
test("open option note renders flush below the option", () => {
const state = enterOptionNoteMode(
createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
options: [{ value: "a", label: "Option A" }],
},
],
}),
"q1",
"a"
);
const lines: string[] = [];
renderQuestionScreen({
editor: mockEditor("", ["┌────┐", "", "└────┘"]),
lines,
options: getRenderableOptions(state.questions[0]),
question: state.questions[0],
state,
theme: mockTheme(),
width: 80,
});
const optionIndex = lines.findIndex((line) => line.includes("Option A"));
const inputIndex = lines.findIndex((line) => line.includes("Add a note..."));
assert.notEqual(optionIndex, -1);
assert.equal(inputIndex, optionIndex + 1);
});
test("selected multiline custom option renders full editor block", () => {
let state = createInitialState({
questions: [
+174
View File
@@ -0,0 +1,174 @@
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,
},
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);
assert(lines.every((line) => visibleWidth(line) <= 28));
assert(lines.join("\n").includes("@eko24ive/pi-ask"));
assert(lines[1]?.includes("Keymaps"));
assert(lines.join("\n").includes("/reload after editing."));
assert(lines.join("\n").includes("Open ask settings modal"));
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("Unsaved changes. Press Ctrl+S to save."));
assert(text.includes("Ctrl+S save · Esc/Ctrl+C/? twice discard"));
});
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(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);
});