feat: add config-backed ask keymaps
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user