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 -1
View File
@@ -37,8 +37,9 @@ The codebase is split so the implementation reads through file boundaries and na
### UI ### UI
- `src/ui/controller.ts` — connects key input, editor lifecycle, live config subscription, and pure state transitions - `src/ui/controller.ts` — connects key input, editor lifecycle, live config subscription, dirty-dismiss confirmation, and pure state transitions
- `src/ui/input.ts` — raw input to commands using resolved config-backed keymaps - `src/ui/input.ts` — raw input to commands using resolved config-backed keymaps
- `src/ui/dismiss-guard.ts` — pure helpers for dirty-flow exit confirmation behavior
- `src/ui/render.ts` and `src/ui/render-*.ts` — screen rendering, including config-backed footer/keymap hints - `src/ui/render.ts` and `src/ui/render-*.ts` — screen rendering, including config-backed footer/keymap hints
- `src/ui/settings-state.ts` — pure settings-modal draft/dirty/notice state - `src/ui/settings-state.ts` — pure settings-modal draft/dirty/notice state
- `src/ui/settings-modal.ts` / `src/ui/show-settings-modal.ts` — ask settings modal rendering and launcher - `src/ui/settings-modal.ts` / `src/ui/show-settings-modal.ts` — ask settings modal rendering and launcher
+20 -2
View File
@@ -20,7 +20,9 @@ Default path:
{ {
"schemaVersion": 1, "schemaVersion": 1,
"behaviour": { "behaviour": {
"autoSubmitWhenAnsweredWithoutNotes": false "autoSubmitWhenAnsweredWithoutNotes": false,
"confirmDismissWhenDirty": false,
"showFooterHints": true
}, },
"keymaps": { "keymaps": {
"cancel": "esc", "cancel": "esc",
@@ -41,6 +43,20 @@ Default path:
- default: `false` - default: `false`
- effect: when enabled, a fully answered ask flow with no notes can auto-submit from the review tab - effect: when enabled, a fully answered ask flow with no notes can auto-submit from the review tab
### `behaviour.confirmDismissWhenDirty`
- type: boolean
- default: `false`
- effect: when enabled, discarding a dirty ask flow requires a second cancel/dismiss action
- the warning stays visible until the user changes tabs in the ask flow
- dirty means there are saved answers/notes or unsaved editor draft text
### `behaviour.showFooterHints`
- type: boolean
- default: `true`
- effect: when disabled, the ask flow hides the footer keymap hints
## Keymaps ## Keymaps
`keymaps` must contain all 6 configurable actions if present. `keymaps` must contain all 6 configurable actions if present.
@@ -151,7 +167,9 @@ Invalid keymaps include:
{ {
"schemaVersion": 1, "schemaVersion": 1,
"behaviour": { "behaviour": {
"autoSubmitWhenAnsweredWithoutNotes": true "autoSubmitWhenAnsweredWithoutNotes": true,
"confirmDismissWhenDirty": true,
"showFooterHints": false
}, },
"keymaps": { "keymaps": {
"cancel": "q", "cancel": "q",
+5 -3
View File
@@ -202,7 +202,7 @@ This document defines the stable external behavior. It does not explain internal
- transcript-friendly call and result rendering - transcript-friendly call and result rendering
- ask settings modal with `Keymaps` and `Behaviour` tabs - ask settings modal with `Keymaps` and `Behaviour` tabs
- `?` in the ask flow and `/ask-settings` in pi open the same ask settings modal - `?` in the ask flow and `/ask-settings` in pi open the same ask settings modal
- `Behaviour` exposes `Auto-submit when answered without notes` as a persisted user setting - `Behaviour` exposes persisted user settings for `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, and `Show footer hints`
- `Keymaps` is a persisted config section for `cancel`, `dismiss`, `toggle`, `confirm`, `optionNote`, and `questionNote` - `Keymaps` is a persisted config section for `cancel`, `dismiss`, `toggle`, `confirm`, `optionNote`, and `questionNote`
- the `Keymaps` tab is read-only and shows the active bindings plus the config file path - the `Keymaps` tab is read-only and shows the active bindings plus the config file path
- 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 - 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
@@ -223,7 +223,9 @@ Editing flow:
- the configurable `confirm` binding submits the current editor input and closes the editor; in note editors this saves the note only and keeps the ask flow open - the configurable `confirm` binding submits the current editor input and closes the editor; in note editors this saves the note only and keeps the ask flow open
- the configurable `cancel` binding saves draft and closes the editor - the configurable `cancel` binding saves draft and closes the editor
- the configurable `dismiss` binding dismisses the entire flow immediately without saving the current editor draft - when `Confirm dismiss when dirty` is enabled, cancelling or dismissing a dirty ask flow requires the same action a second time
- the dirty-dismiss warning stays visible until the user changes tabs in the ask flow
- the configurable `dismiss` binding dismisses the entire flow immediately without saving the current editor draft when no dirty-dismiss confirmation is pending
- `?`: open the ask settings modal on the `Keymaps` tab when the editor is empty; otherwise enter `?` as text - `?`: open the ask settings modal on the `Keymaps` tab when the editor is empty; otherwise enter `?` as text
- inside the ask settings modal, `Ctrl+S` saves the current draft config without closing the modal - inside the ask settings modal, `Ctrl+S` saves the current draft config without closing the modal
- when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing - when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing
@@ -236,7 +238,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. 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` 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`, `Show footer hints`, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks.
The fallback message includes normalized pending questions and options so the caller can re-ask them manually. `details.questions` still contains normalized question metadata, while `details.answers` stays empty until a user responds. The fallback message includes normalized pending questions and options so the caller can re-ask them manually. `details.questions` still contains normalized question metadata, while `details.answers` stays empty until a user responds.
+10
View File
@@ -4,6 +4,8 @@ import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
export const DEFAULT_ASK_CONFIG: AskConfig = { export const DEFAULT_ASK_CONFIG: AskConfig = {
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: false, autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: false,
showFooterHints: true,
}, },
keymaps: { keymaps: {
...DEFAULT_ASK_KEYMAPS, ...DEFAULT_ASK_KEYMAPS,
@@ -18,6 +20,12 @@ export function normalizeAskConfig(
autoSubmitWhenAnsweredWithoutNotes: autoSubmitWhenAnsweredWithoutNotes:
config?.behaviour?.autoSubmitWhenAnsweredWithoutNotes ?? config?.behaviour?.autoSubmitWhenAnsweredWithoutNotes ??
DEFAULT_ASK_CONFIG.behaviour.autoSubmitWhenAnsweredWithoutNotes, DEFAULT_ASK_CONFIG.behaviour.autoSubmitWhenAnsweredWithoutNotes,
confirmDismissWhenDirty:
config?.behaviour?.confirmDismissWhenDirty ??
DEFAULT_ASK_CONFIG.behaviour.confirmDismissWhenDirty,
showFooterHints:
config?.behaviour?.showFooterHints ??
DEFAULT_ASK_CONFIG.behaviour.showFooterHints,
}, },
keymaps: { keymaps: {
...DEFAULT_ASK_CONFIG.keymaps, ...DEFAULT_ASK_CONFIG.keymaps,
@@ -33,6 +41,8 @@ export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: autoSubmitWhenAnsweredWithoutNotes:
normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes, normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes,
confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty,
showFooterHints: normalized.behaviour.showFooterHints,
}, },
keymaps: { keymaps: {
cancel: normalized.keymaps.cancel, cancel: normalized.keymaps.cancel,
+4
View File
@@ -6,6 +6,8 @@ export const AskConfigFileV1Schema = Type.Object({
behaviour: Type.Optional( behaviour: Type.Optional(
Type.Object({ Type.Object({
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()), autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
confirmDismissWhenDirty: Type.Optional(Type.Boolean()),
showFooterHints: Type.Optional(Type.Boolean()),
}) })
), ),
keymaps: Type.Optional( keymaps: Type.Optional(
@@ -34,6 +36,8 @@ export interface AskConfigKeymaps {
export interface AskConfig { export interface AskConfig {
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: boolean; autoSubmitWhenAnsweredWithoutNotes: boolean;
confirmDismissWhenDirty: boolean;
showFooterHints: boolean;
}; };
keymaps: AskConfigKeymaps; keymaps: AskConfigKeymaps;
} }
+52 -7
View File
@@ -30,6 +30,11 @@ import { isEditingView } from "../state/view.ts";
import type { AskParams, AskResult, AskState } from "../types.ts"; import type { AskParams, AskResult, AskState } from "../types.ts";
import { maybeAutoSubmitState } from "./auto-submit.ts"; import { maybeAutoSubmitState } from "./auto-submit.ts";
import { createAskAutocompleteProvider } from "./autocomplete.ts"; import { createAskAutocompleteProvider } from "./autocomplete.ts";
import {
DIRTY_DISMISS_NOTICE,
shouldConfirmDirtyDismiss,
shouldDiscardAfterConfirmation,
} from "./dismiss-guard.ts";
import type { AskInputCommand } from "./input.ts"; import type { AskInputCommand } from "./input.ts";
import { getInputCommand } from "./input.ts"; import { getInputCommand } from "./input.ts";
import { renderAskScreen } from "./render.ts"; import { renderAskScreen } from "./render.ts";
@@ -49,6 +54,7 @@ type AskFlowParams = AskParams &
interface AskFlowController { interface AskFlowController {
config: AskConfig; config: AskConfig;
ctx: ExtensionContext; ctx: ExtensionContext;
dismissNotice?: string;
done: Done; done: Done;
editor: Editor; editor: Editor;
settingsOpen: boolean; settingsOpen: boolean;
@@ -82,6 +88,7 @@ function createAskFlowController(
const controller: AskFlowController = { const controller: AskFlowController = {
config: params.config, config: params.config,
ctx: params.ctx, ctx: params.ctx,
dismissNotice: undefined,
done, done,
editor: createEditor(tui, theme, params.cwd), editor: createEditor(tui, theme, params.cwd),
settingsOpen: false, settingsOpen: false,
@@ -128,6 +135,7 @@ function renderController(
return renderAskScreen({ return renderAskScreen({
config: controller.config, config: controller.config,
editor: controller.editor, editor: controller.editor,
footerNotice: getDismissNotice(controller),
state: controller.state, state: controller.state,
theme: controller.theme, theme: controller.theme,
width, width,
@@ -154,7 +162,7 @@ function handleEditingCommand(
data: string data: string
) { ) {
if (command.kind === "dismiss") { if (command.kind === "dismiss") {
commitState(controller, dismissFlow(controller.state), { finish: true }); handleExitFlow(controller, dismissFlow(controller.state));
return; return;
} }
if (command.kind === "showSettings") { if (command.kind === "showSettings") {
@@ -205,7 +213,7 @@ function handleNavigationCommand(
}); });
return; return;
case "cancel": case "cancel":
commitState(controller, cancelFlow(controller.state), { finish: true }); handleExitFlow(controller, cancelFlow(controller.state));
return; return;
case "numberShortcut": case "numberShortcut":
commitState( commitState(
@@ -214,7 +222,7 @@ function handleNavigationCommand(
); );
return; return;
case "dismiss": case "dismiss":
commitState(controller, dismissFlow(controller.state), { finish: true }); handleExitFlow(controller, dismissFlow(controller.state));
return; return;
case "showSettings": case "showSettings":
showSettingsModal(controller); showSettingsModal(controller);
@@ -274,6 +282,9 @@ function commitState(
nextState: AskState, nextState: AskState,
options: { finish?: boolean; syncSelection?: boolean } = {} options: { finish?: boolean; syncSelection?: boolean } = {}
) { ) {
if (nextState.activeTabIndex !== controller.state.activeTabIndex) {
clearDismissNotice(controller);
}
controller.suppressAutoInputForSelection = false; controller.suppressAutoInputForSelection = false;
controller.state = nextState; controller.state = nextState;
if (options.syncSelection !== false) { if (options.syncSelection !== false) {
@@ -289,10 +300,11 @@ function commitState(
function submitEditor(controller: AskFlowController, value: string) { function submitEditor(controller: AskFlowController, value: string) {
controller.suppressAutoInputForSelection = false; controller.suppressAutoInputForSelection = false;
controller.state = maybeAutoSubmitState( const nextState = submitEditorDraft(controller.state, value);
submitEditorDraft(controller.state, value), if (nextState.activeTabIndex !== controller.state.activeTabIndex) {
controller.config clearDismissNotice(controller);
); }
controller.state = maybeAutoSubmitState(nextState, controller.config);
controller.editor.setText(""); controller.editor.setText("");
refresh(controller); refresh(controller);
maybeFinish(controller); maybeFinish(controller);
@@ -313,6 +325,39 @@ function closeEditor(controller: AskFlowController) {
refresh(controller); refresh(controller);
} }
function handleExitFlow(controller: AskFlowController, nextState: AskState) {
if (!shouldRequestDismissConfirmation(controller)) {
commitState(controller, nextState, { finish: true });
return;
}
if (shouldDiscardAfterConfirmation(!!controller.dismissNotice)) {
commitState(controller, nextState, { finish: true });
return;
}
controller.dismissNotice = DIRTY_DISMISS_NOTICE;
refresh(controller);
}
function shouldRequestDismissConfirmation(
controller: AskFlowController
): boolean {
return shouldConfirmDirtyDismiss({
config: controller.config,
state: controller.state,
editingText: isEditingView(controller.state)
? controller.editor.getText()
: "",
});
}
function clearDismissNotice(controller: AskFlowController) {
controller.dismissNotice = undefined;
}
function getDismissNotice(controller: AskFlowController): string | undefined {
return controller.dismissNotice;
}
function showSettingsModal(controller: AskFlowController) { function showSettingsModal(controller: AskFlowController) {
if (controller.settingsOpen) { if (controller.settingsOpen) {
return; return;
+26
View File
@@ -0,0 +1,26 @@
import type { AskConfig } from "../config/schema.ts";
import type { AskState } from "../types.ts";
export const DIRTY_DISMISS_NOTICE =
"Unsaved ask answers or drafts. Press cancel/dismiss again to discard.";
export function shouldConfirmDirtyDismiss(args: {
config: AskConfig;
state: AskState;
editingText?: string;
}): boolean {
if (!args.config.behaviour.confirmDismissWhenDirty) {
return false;
}
return hasDirtyFlowState(args.state, args.editingText ?? "");
}
export function hasDirtyFlowState(state: AskState, editingText = ""): boolean {
return Object.keys(state.answers).length > 0 || editingText.length > 0;
}
export function shouldDiscardAfterConfirmation(
confirmationPending: boolean
): boolean {
return confirmationPending;
}
+10 -2
View File
@@ -30,16 +30,21 @@ export function renderFrameHeader(args: {
export function renderFrameFooter(args: { export function renderFrameFooter(args: {
config: AskConfig; config: AskConfig;
footerNotice?: string;
lines: string[]; lines: string[];
state: AskState; state: AskState;
theme: Theme; theme: Theme;
width: number; width: number;
}) { }) {
const { config, lines, state, theme, width } = args; const { config, footerNotice, lines, state, theme, width } = args;
const add = (text = "") => lines.push(truncateToWidth(text, width)); const add = (text = "") => lines.push(truncateToWidth(text, width));
const footer = renderFooter(config, state, width); const footer = renderFooter(config, state, width);
if (footer.length > 0) { const noticeLines = footerNotice ? wrapText(footerNotice, width) : [];
if (noticeLines.length > 0 || footer.length > 0) {
add(); add();
for (const line of noticeLines) {
add(theme.fg("warning", line));
}
for (const line of footer) { for (const line of footer) {
add(theme.fg("dim", line)); add(theme.fg("dim", line));
} }
@@ -185,6 +190,9 @@ function renderFooter(
state: AskState, state: AskState,
width: number width: number
): string[] { ): string[] {
if (!config.behaviour.showFooterHints) {
return [];
}
let footer: string; let footer: string;
if (state.view.kind === "input") { if (state.view.kind === "input") {
footer = renderFooterText(config, "input"); footer = renderFooterText(config, "input");
+3 -2
View File
@@ -12,12 +12,13 @@ import type { QuestionRenderContext, Theme } from "./render-types.ts";
export function renderAskScreen(args: { export function renderAskScreen(args: {
config: AskConfig; config: AskConfig;
footerNotice?: string;
state: AskState; state: AskState;
theme: Theme; theme: Theme;
width: number; width: number;
editor: QuestionRenderContext["editor"]; editor: QuestionRenderContext["editor"];
}): string[] { }): string[] {
const { config, state, theme, width, editor } = args; const { config, footerNotice, state, theme, width, editor } = args;
const lines: string[] = []; const lines: string[] = [];
const question = getCurrentQuestion(state); const question = getCurrentQuestion(state);
const options = getRenderableOptions(question); const options = getRenderableOptions(question);
@@ -38,6 +39,6 @@ export function renderAskScreen(args: {
}); });
} }
renderFrameFooter({ config, lines, state, theme, width }); renderFrameFooter({ config, footerNotice, lines, state, theme, width });
return lines; return lines;
} }
+24 -2
View File
@@ -20,7 +20,7 @@ import {
requestDiscard, requestDiscard,
setSaving, setSaving,
shouldDiscard, shouldDiscard,
toggleAutoSubmit, toggleBehaviourSetting,
} from "./settings-state.ts"; } from "./settings-state.ts";
interface Theme { interface Theme {
@@ -58,11 +58,29 @@ const BEHAVIOUR_ROWS = [
{ {
description: description:
"Auto-submit completed ask flows when no question or option notes were added.", "Auto-submit completed ask flows when no question or option notes were added.",
key: "autoSubmitWhenAnsweredWithoutNotes",
getValue(config: AskConfig) { getValue(config: AskConfig) {
return config.behaviour.autoSubmitWhenAnsweredWithoutNotes; return config.behaviour.autoSubmitWhenAnsweredWithoutNotes;
}, },
label: "Auto-submit when answered without notes", label: "Auto-submit when answered without notes",
}, },
{
description:
"Require a second cancel or dismiss action before discarding answered or drafted ask content.",
key: "confirmDismissWhenDirty",
getValue(config: AskConfig) {
return config.behaviour.confirmDismissWhenDirty;
},
label: "Confirm dismiss when dirty",
},
{
description: "Show footer keymap hints at the bottom of the ask flow.",
key: "showFooterHints",
getValue(config: AskConfig) {
return config.behaviour.showFooterHints;
},
label: "Show footer hints",
},
] as const; ] as const;
type SettingsTab = "keymaps" | "behaviour"; type SettingsTab = "keymaps" | "behaviour";
@@ -168,7 +186,11 @@ export class AskSettingsModal {
return; return;
} }
if (matchesAny(data, SETTINGS_MODAL_KEYS.toggle)) { if (matchesAny(data, SETTINGS_MODAL_KEYS.toggle)) {
this.state = toggleAutoSubmit(this.state); const row = BEHAVIOUR_ROWS[this.state.behaviourFocusIndex];
if (!row) {
return;
}
this.state = toggleBehaviourSetting(this.state, row.key);
this.refresh(); this.refresh();
} }
} }
+8 -3
View File
@@ -131,15 +131,17 @@ export function shouldDiscard(state: AskSettingsState, now: number): boolean {
return !!state.pendingCloseUntil && now <= state.pendingCloseUntil; return !!state.pendingCloseUntil && now <= state.pendingCloseUntil;
} }
export function toggleAutoSubmit(state: AskSettingsState): AskSettingsState { export function toggleBehaviourSetting(
state: AskSettingsState,
key: keyof AskConfig["behaviour"]
): AskSettingsState {
return { return {
...state, ...state,
draftConfig: { draftConfig: {
...state.draftConfig, ...state.draftConfig,
behaviour: { behaviour: {
...state.draftConfig.behaviour, ...state.draftConfig.behaviour,
autoSubmitWhenAnsweredWithoutNotes: [key]: !state.draftConfig.behaviour[key],
!state.draftConfig.behaviour.autoSubmitWhenAnsweredWithoutNotes,
}, },
}, },
}; };
@@ -149,6 +151,9 @@ function deepEqual(left: AskConfig, right: AskConfig): boolean {
return ( return (
left.behaviour.autoSubmitWhenAnsweredWithoutNotes === left.behaviour.autoSubmitWhenAnsweredWithoutNotes ===
right.behaviour.autoSubmitWhenAnsweredWithoutNotes && right.behaviour.autoSubmitWhenAnsweredWithoutNotes &&
left.behaviour.confirmDismissWhenDirty ===
right.behaviour.confirmDismissWhenDirty &&
left.behaviour.showFooterHints === right.behaviour.showFooterHints &&
left.keymaps.cancel === right.keymaps.cancel && left.keymaps.cancel === right.keymaps.cancel &&
left.keymaps.dismiss === right.keymaps.dismiss && left.keymaps.dismiss === right.keymaps.dismiss &&
left.keymaps.toggle === right.keymaps.toggle && left.keymaps.toggle === right.keymaps.toggle &&
+2
View File
@@ -10,6 +10,7 @@ import { maybeAutoSubmitState } from "../src/ui/auto-submit.ts";
const enabledConfig: AskConfig = { const enabledConfig: AskConfig = {
...DEFAULT_ASK_CONFIG, ...DEFAULT_ASK_CONFIG,
behaviour: { behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
autoSubmitWhenAnsweredWithoutNotes: true, autoSubmitWhenAnsweredWithoutNotes: true,
}, },
}; };
@@ -17,6 +18,7 @@ const enabledConfig: AskConfig = {
const disabledConfig: AskConfig = { const disabledConfig: AskConfig = {
...DEFAULT_ASK_CONFIG, ...DEFAULT_ASK_CONFIG,
behaviour: { behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
autoSubmitWhenAnsweredWithoutNotes: false, autoSubmitWhenAnsweredWithoutNotes: false,
}, },
}; };
+18 -2
View File
@@ -34,6 +34,8 @@ test("config store writes full normalized config on save", async () => {
await store.save({ await store.save({
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true, autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
showFooterHints: false,
}, },
keymaps: DEFAULT_ASK_CONFIG.keymaps, keymaps: DEFAULT_ASK_CONFIG.keymaps,
}); });
@@ -43,6 +45,8 @@ test("config store writes full normalized config on save", async () => {
schemaVersion: 1, schemaVersion: 1,
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true, autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
showFooterHints: false,
}, },
keymaps: DEFAULT_ASK_CONFIG.keymaps, keymaps: DEFAULT_ASK_CONFIG.keymaps,
}); });
@@ -77,7 +81,11 @@ test("config store loads current config version without rewriting", async () =>
path, path,
JSON.stringify({ JSON.stringify({
schemaVersion: 1, schemaVersion: 1,
behaviour: { autoSubmitWhenAnsweredWithoutNotes: true }, behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
showFooterHints: false,
},
keymaps: DEFAULT_ASK_CONFIG.keymaps, keymaps: DEFAULT_ASK_CONFIG.keymaps,
}) })
); );
@@ -89,6 +97,8 @@ test("config store loads current config version without rewriting", async () =>
result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes, result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes,
true 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.deepEqual(result.config.keymaps, DEFAULT_ASK_CONFIG.keymaps);
await rm(join(path, "..", ".."), { force: true, recursive: true }); 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, path,
JSON.stringify({ JSON.stringify({
schemaVersion: 1, schemaVersion: 1,
behaviour: { autoSubmitWhenAnsweredWithoutNotes: true }, behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
showFooterHints: false,
},
keymaps: { keymaps: {
cancel: "?", cancel: "?",
dismiss: "ctrl+c", dismiss: "ctrl+c",
@@ -119,6 +133,8 @@ test("config store falls back only keymaps when configured keymaps are invalid",
result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes, result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes,
true 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.deepEqual(result.config.keymaps, DEFAULT_ASK_CONFIG.keymaps);
assert.match(result.notice?.text ?? "", DEFAULT_KEYMAPS_NOTICE_PATTERN); assert.match(result.notice?.text ?? "", DEFAULT_KEYMAPS_NOTICE_PATTERN);
await rm(join(path, "..", ".."), { force: true, recursive: true }); 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", "? 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 = { const savedConfig: AskConfig = {
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: false, autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: false,
showFooterHints: true,
}, },
keymaps: { keymaps: {
cancel: "esc", cancel: "esc",
@@ -75,6 +77,8 @@ test("settings modal renders behaviour tab with checkbox and dirty notice", () =
assert(text.includes("Behaviour")); assert(text.includes("Behaviour"));
assert(text.includes("[x] Auto-submit when answered without notes")); 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("Unsaved changes. Press Ctrl+S to save."));
assert(text.includes("Ctrl+S save · Esc/Ctrl+C/? twice discard")); 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"); const text = modal.render(72).join("\n");
assert.equal(saved?.behaviour.autoSubmitWhenAnsweredWithoutNotes, true); assert.equal(saved?.behaviour.autoSubmitWhenAnsweredWithoutNotes, true);
assert.equal(saved?.behaviour.confirmDismissWhenDirty, false);
assert.equal(saved?.behaviour.showFooterHints, true);
assert(text.includes("Saved")); assert(text.includes("Saved"));
assert.equal(text.includes("Unsaved changes. Press Ctrl+S to save."), false); assert.equal(text.includes("Unsaved changes. Press Ctrl+S to save."), false);
assert.equal( assert.equal(