feat: add dirty-dismiss and footer hint settings

This commit is contained in:
Erik Kotsiuba
2026-04-28 22:35:43 +02:00
parent 85962d985d
commit 18120e8801
17 changed files with 373 additions and 24 deletions
+2
View File
@@ -10,6 +10,7 @@ import { maybeAutoSubmitState } from "../src/ui/auto-submit.ts";
const enabledConfig: AskConfig = {
...DEFAULT_ASK_CONFIG,
behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
autoSubmitWhenAnsweredWithoutNotes: true,
},
};
@@ -17,6 +18,7 @@ const enabledConfig: AskConfig = {
const disabledConfig: AskConfig = {
...DEFAULT_ASK_CONFIG,
behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
autoSubmitWhenAnsweredWithoutNotes: false,
},
};
+18 -2
View File
@@ -34,6 +34,8 @@ test("config store writes full normalized config on save", async () => {
await store.save({
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
showFooterHints: false,
},
keymaps: DEFAULT_ASK_CONFIG.keymaps,
});
@@ -43,6 +45,8 @@ test("config store writes full normalized config on save", async () => {
schemaVersion: 1,
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
showFooterHints: false,
},
keymaps: DEFAULT_ASK_CONFIG.keymaps,
});
@@ -77,7 +81,11 @@ test("config store loads current config version without rewriting", async () =>
path,
JSON.stringify({
schemaVersion: 1,
behaviour: { autoSubmitWhenAnsweredWithoutNotes: true },
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
showFooterHints: false,
},
keymaps: DEFAULT_ASK_CONFIG.keymaps,
})
);
@@ -89,6 +97,8 @@ test("config store loads current config version without rewriting", async () =>
result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes,
true
);
assert.equal(result.config.behaviour.confirmDismissWhenDirty, true);
assert.equal(result.config.behaviour.showFooterHints, false);
assert.deepEqual(result.config.keymaps, DEFAULT_ASK_CONFIG.keymaps);
await rm(join(path, "..", ".."), { force: true, recursive: true });
});
@@ -100,7 +110,11 @@ test("config store falls back only keymaps when configured keymaps are invalid",
path,
JSON.stringify({
schemaVersion: 1,
behaviour: { autoSubmitWhenAnsweredWithoutNotes: true },
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
showFooterHints: false,
},
keymaps: {
cancel: "?",
dismiss: "ctrl+c",
@@ -119,6 +133,8 @@ test("config store falls back only keymaps when configured keymaps are invalid",
result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes,
true
);
assert.equal(result.config.behaviour.confirmDismissWhenDirty, true);
assert.equal(result.config.behaviour.showFooterHints, false);
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 });
+72
View File
@@ -0,0 +1,72 @@
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,
enterQuestionNoteMode,
} from "../src/state/transitions.ts";
import {
hasDirtyFlowState,
shouldConfirmDirtyDismiss,
shouldDiscardAfterConfirmation,
} from "../src/ui/dismiss-guard.ts";
test("dirty flow state is false for untouched ask flow", () => {
const state = createInitialState({
questions: [
{ id: "q1", prompt: "Question?", options: [{ value: "a", label: "A" }] },
],
});
assert.equal(hasDirtyFlowState(state), false);
});
test("dirty flow state is true for saved answers or editor drafts", () => {
const base = createInitialState({
questions: [
{ id: "q1", prompt: "Question?", options: [{ value: "a", label: "A" }] },
],
});
const answered = applyNumberShortcut(base, 1);
const noteEditing = enterQuestionNoteMode(base, "q1");
assert.equal(hasDirtyFlowState(answered), true);
assert.equal(hasDirtyFlowState(noteEditing, "draft note"), true);
});
test("confirm dismiss when dirty is opt-in and respects dirty state", () => {
const state = applyNumberShortcut(
createInitialState({
questions: [
{
id: "q1",
prompt: "Question?",
options: [{ value: "a", label: "A" }],
},
],
}),
1
);
const enabledConfig = {
...DEFAULT_ASK_CONFIG,
behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
confirmDismissWhenDirty: true,
},
};
assert.equal(
shouldConfirmDirtyDismiss({ config: DEFAULT_ASK_CONFIG, state }),
false
);
assert.equal(
shouldConfirmDirtyDismiss({ config: enabledConfig, state }),
true
);
});
test("dismiss confirmation is active while pending", () => {
assert.equal(shouldDiscardAfterConfirmation(false), false);
assert.equal(shouldDiscardAfterConfirmation(true), true);
});
+79
View File
@@ -0,0 +1,79 @@
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 { moveOption, moveTab } from "../src/state/transitions.ts";
import { renderAskScreen } from "../src/ui/render.ts";
function mockEditor() {
return {
getText() {
return "";
},
render() {
return [];
},
} as never;
}
function plainTheme() {
return {
fg(_color: string, text: string) {
return text;
},
bg(_color: string, text: string) {
return text;
},
bold(text: string) {
return text;
},
} as never;
}
const notice =
"Unsaved ask answers or drafts. Press cancel/dismiss again to discard.";
test("dismiss notice stays visible on same tab and clears after tab change", () => {
const state = createInitialState({
questions: [
{
id: "q1",
label: "One",
prompt: "One",
options: [{ value: "a", label: "A" }],
},
{
id: "q2",
label: "Two",
prompt: "Two",
options: [{ value: "b", label: "B" }],
},
],
});
const config = {
...DEFAULT_ASK_CONFIG,
behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
confirmDismissWhenDirty: true,
},
};
const sameTabLines = renderAskScreen({
config,
footerNotice: notice,
state: moveOption(state, 1),
theme: plainTheme(),
width: 80,
editor: mockEditor(),
});
assert.equal(sameTabLines.join("\n").includes(notice), true);
const nextTabLines = renderAskScreen({
config,
state: moveTab(state, 1),
theme: plainTheme(),
width: 80,
editor: mockEditor(),
});
assert.equal(nextTabLines.join("\n").includes(notice), false);
});
+32
View File
@@ -261,3 +261,35 @@ test("footer keeps earlier hint chunk on the first wrapped line", () => {
"? settings",
]);
});
test("footer hints can be hidden without affecting frame rendering", () => {
const state = createInitialState({
questions: [
{
id: "q1",
label: "One",
prompt: "One",
options: [{ value: "a", label: "A" }],
},
],
});
const config = {
...DEFAULT_ASK_CONFIG,
behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
showFooterHints: false,
},
};
const lines = renderAskScreen({
config,
state,
theme: plainTheme(),
width: 22,
editor: mockEditor(),
});
assert.equal(lines.at(-1), "──────────────────────");
assert.equal(lines.join("\n").includes("? settings"), false);
assert.equal(lines.join("\n").includes("Enter confirm"), false);
});
+6
View File
@@ -7,6 +7,8 @@ import { AskSettingsModal } from "../src/ui/settings-modal.ts";
const savedConfig: AskConfig = {
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: false,
showFooterHints: true,
},
keymaps: {
cancel: "esc",
@@ -75,6 +77,8 @@ test("settings modal renders behaviour tab with checkbox and dirty notice", () =
assert(text.includes("Behaviour"));
assert(text.includes("[x] Auto-submit when answered without notes"));
assert(text.includes("[ ] Confirm dismiss when dirty"));
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"));
});
@@ -131,6 +135,8 @@ test("settings modal saves and clears dirty state", async () => {
const text = modal.render(72).join("\n");
assert.equal(saved?.behaviour.autoSubmitWhenAnsweredWithoutNotes, true);
assert.equal(saved?.behaviour.confirmDismissWhenDirty, false);
assert.equal(saved?.behaviour.showFooterHints, true);
assert(text.includes("Saved"));
assert.equal(text.includes("Unsaved changes. Press Ctrl+S to save."), false);
assert.equal(