From 18120e880119cff23e2fdc2cf49cdf3fbb512829 Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Tue, 28 Apr 2026 22:23:54 +0200 Subject: [PATCH] feat: add dirty-dismiss and footer hint settings --- docs/architecture.md | 3 +- docs/configuration.md | 22 +++++++- docs/contract.md | 8 ++- src/config/defaults.ts | 10 ++++ src/config/schema.ts | 4 ++ src/ui/controller.ts | 59 +++++++++++++++++--- src/ui/dismiss-guard.ts | 26 +++++++++ src/ui/render-frame.ts | 12 +++- src/ui/render.ts | 5 +- src/ui/settings-modal.ts | 26 ++++++++- src/ui/settings-state.ts | 11 +++- tests/auto-submit.test.ts | 2 + tests/config-store.test.ts | 20 ++++++- tests/dismiss-guard.test.ts | 72 ++++++++++++++++++++++++ tests/dismiss-notice-behavior.test.ts | 79 +++++++++++++++++++++++++++ tests/render-frame.test.ts | 32 +++++++++++ tests/settings-modal.test.ts | 6 ++ 17 files changed, 373 insertions(+), 24 deletions(-) create mode 100644 src/ui/dismiss-guard.ts create mode 100644 tests/dismiss-guard.test.ts create mode 100644 tests/dismiss-notice-behavior.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index c7d979f..04b8216 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,8 +37,9 @@ The codebase is split so the implementation reads through file boundaries and na ### 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/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/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 diff --git a/docs/configuration.md b/docs/configuration.md index 84afec2..da890af 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,7 +20,9 @@ Default path: { "schemaVersion": 1, "behaviour": { - "autoSubmitWhenAnsweredWithoutNotes": false + "autoSubmitWhenAnsweredWithoutNotes": false, + "confirmDismissWhenDirty": false, + "showFooterHints": true }, "keymaps": { "cancel": "esc", @@ -41,6 +43,20 @@ Default path: - default: `false` - 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` must contain all 6 configurable actions if present. @@ -151,7 +167,9 @@ Invalid keymaps include: { "schemaVersion": 1, "behaviour": { - "autoSubmitWhenAnsweredWithoutNotes": true + "autoSubmitWhenAnsweredWithoutNotes": true, + "confirmDismissWhenDirty": true, + "showFooterHints": false }, "keymaps": { "cancel": "q", diff --git a/docs/contract.md b/docs/contract.md index 5757727..b1ff37a 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -202,7 +202,7 @@ This document defines the stable external behavior. It does not explain internal - transcript-friendly call and result rendering - ask settings modal with `Keymaps` and `Behaviour` tabs - `?` 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` - 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 @@ -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 `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 - 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 @@ -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. -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. diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 6a5b323..1722f07 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -4,6 +4,8 @@ import type { AskConfig, AskConfigFileV1 } from "./schema.ts"; export const DEFAULT_ASK_CONFIG: AskConfig = { behaviour: { autoSubmitWhenAnsweredWithoutNotes: false, + confirmDismissWhenDirty: false, + showFooterHints: true, }, keymaps: { ...DEFAULT_ASK_KEYMAPS, @@ -18,6 +20,12 @@ export function normalizeAskConfig( autoSubmitWhenAnsweredWithoutNotes: 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: { ...DEFAULT_ASK_CONFIG.keymaps, @@ -33,6 +41,8 @@ export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 { behaviour: { autoSubmitWhenAnsweredWithoutNotes: normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes, + confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty, + showFooterHints: normalized.behaviour.showFooterHints, }, keymaps: { cancel: normalized.keymaps.cancel, diff --git a/src/config/schema.ts b/src/config/schema.ts index a4c31f0..12cc29f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -6,6 +6,8 @@ export const AskConfigFileV1Schema = Type.Object({ behaviour: Type.Optional( Type.Object({ autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()), + confirmDismissWhenDirty: Type.Optional(Type.Boolean()), + showFooterHints: Type.Optional(Type.Boolean()), }) ), keymaps: Type.Optional( @@ -34,6 +36,8 @@ export interface AskConfigKeymaps { export interface AskConfig { behaviour: { autoSubmitWhenAnsweredWithoutNotes: boolean; + confirmDismissWhenDirty: boolean; + showFooterHints: boolean; }; keymaps: AskConfigKeymaps; } diff --git a/src/ui/controller.ts b/src/ui/controller.ts index 7010b08..8d8e069 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -30,6 +30,11 @@ import { isEditingView } from "../state/view.ts"; import type { AskParams, AskResult, AskState } from "../types.ts"; import { maybeAutoSubmitState } from "./auto-submit.ts"; import { createAskAutocompleteProvider } from "./autocomplete.ts"; +import { + DIRTY_DISMISS_NOTICE, + shouldConfirmDirtyDismiss, + shouldDiscardAfterConfirmation, +} from "./dismiss-guard.ts"; import type { AskInputCommand } from "./input.ts"; import { getInputCommand } from "./input.ts"; import { renderAskScreen } from "./render.ts"; @@ -49,6 +54,7 @@ type AskFlowParams = AskParams & interface AskFlowController { config: AskConfig; ctx: ExtensionContext; + dismissNotice?: string; done: Done; editor: Editor; settingsOpen: boolean; @@ -82,6 +88,7 @@ function createAskFlowController( const controller: AskFlowController = { config: params.config, ctx: params.ctx, + dismissNotice: undefined, done, editor: createEditor(tui, theme, params.cwd), settingsOpen: false, @@ -128,6 +135,7 @@ function renderController( return renderAskScreen({ config: controller.config, editor: controller.editor, + footerNotice: getDismissNotice(controller), state: controller.state, theme: controller.theme, width, @@ -154,7 +162,7 @@ function handleEditingCommand( data: string ) { if (command.kind === "dismiss") { - commitState(controller, dismissFlow(controller.state), { finish: true }); + handleExitFlow(controller, dismissFlow(controller.state)); return; } if (command.kind === "showSettings") { @@ -205,7 +213,7 @@ function handleNavigationCommand( }); return; case "cancel": - commitState(controller, cancelFlow(controller.state), { finish: true }); + handleExitFlow(controller, cancelFlow(controller.state)); return; case "numberShortcut": commitState( @@ -214,7 +222,7 @@ function handleNavigationCommand( ); return; case "dismiss": - commitState(controller, dismissFlow(controller.state), { finish: true }); + handleExitFlow(controller, dismissFlow(controller.state)); return; case "showSettings": showSettingsModal(controller); @@ -274,6 +282,9 @@ function commitState( nextState: AskState, options: { finish?: boolean; syncSelection?: boolean } = {} ) { + if (nextState.activeTabIndex !== controller.state.activeTabIndex) { + clearDismissNotice(controller); + } controller.suppressAutoInputForSelection = false; controller.state = nextState; if (options.syncSelection !== false) { @@ -289,10 +300,11 @@ function commitState( function submitEditor(controller: AskFlowController, value: string) { controller.suppressAutoInputForSelection = false; - controller.state = maybeAutoSubmitState( - submitEditorDraft(controller.state, value), - controller.config - ); + const nextState = submitEditorDraft(controller.state, value); + if (nextState.activeTabIndex !== controller.state.activeTabIndex) { + clearDismissNotice(controller); + } + controller.state = maybeAutoSubmitState(nextState, controller.config); controller.editor.setText(""); refresh(controller); maybeFinish(controller); @@ -313,6 +325,39 @@ function closeEditor(controller: AskFlowController) { 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) { if (controller.settingsOpen) { return; diff --git a/src/ui/dismiss-guard.ts b/src/ui/dismiss-guard.ts new file mode 100644 index 0000000..e02ee3c --- /dev/null +++ b/src/ui/dismiss-guard.ts @@ -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; +} diff --git a/src/ui/render-frame.ts b/src/ui/render-frame.ts index faeb2fa..a36b70b 100644 --- a/src/ui/render-frame.ts +++ b/src/ui/render-frame.ts @@ -30,16 +30,21 @@ export function renderFrameHeader(args: { export function renderFrameFooter(args: { config: AskConfig; + footerNotice?: string; lines: string[]; state: AskState; theme: Theme; 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 footer = renderFooter(config, state, width); - if (footer.length > 0) { + const noticeLines = footerNotice ? wrapText(footerNotice, width) : []; + if (noticeLines.length > 0 || footer.length > 0) { add(); + for (const line of noticeLines) { + add(theme.fg("warning", line)); + } for (const line of footer) { add(theme.fg("dim", line)); } @@ -185,6 +190,9 @@ function renderFooter( state: AskState, width: number ): string[] { + if (!config.behaviour.showFooterHints) { + return []; + } let footer: string; if (state.view.kind === "input") { footer = renderFooterText(config, "input"); diff --git a/src/ui/render.ts b/src/ui/render.ts index b8dcc41..2a771ee 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -12,12 +12,13 @@ import type { QuestionRenderContext, Theme } from "./render-types.ts"; export function renderAskScreen(args: { config: AskConfig; + footerNotice?: string; state: AskState; theme: Theme; width: number; editor: QuestionRenderContext["editor"]; }): string[] { - const { config, state, theme, width, editor } = args; + const { config, footerNotice, state, theme, width, editor } = args; const lines: string[] = []; const question = getCurrentQuestion(state); 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; } diff --git a/src/ui/settings-modal.ts b/src/ui/settings-modal.ts index 084aad4..ca9a786 100644 --- a/src/ui/settings-modal.ts +++ b/src/ui/settings-modal.ts @@ -20,7 +20,7 @@ import { requestDiscard, setSaving, shouldDiscard, - toggleAutoSubmit, + toggleBehaviourSetting, } from "./settings-state.ts"; interface Theme { @@ -58,11 +58,29 @@ const BEHAVIOUR_ROWS = [ { description: "Auto-submit completed ask flows when no question or option notes were added.", + key: "autoSubmitWhenAnsweredWithoutNotes", getValue(config: AskConfig) { return config.behaviour.autoSubmitWhenAnsweredWithoutNotes; }, 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; type SettingsTab = "keymaps" | "behaviour"; @@ -168,7 +186,11 @@ export class AskSettingsModal { return; } 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(); } } diff --git a/src/ui/settings-state.ts b/src/ui/settings-state.ts index 6e300f2..8ab98a9 100644 --- a/src/ui/settings-state.ts +++ b/src/ui/settings-state.ts @@ -131,15 +131,17 @@ export function shouldDiscard(state: AskSettingsState, now: number): boolean { return !!state.pendingCloseUntil && now <= state.pendingCloseUntil; } -export function toggleAutoSubmit(state: AskSettingsState): AskSettingsState { +export function toggleBehaviourSetting( + state: AskSettingsState, + key: keyof AskConfig["behaviour"] +): AskSettingsState { return { ...state, draftConfig: { ...state.draftConfig, behaviour: { ...state.draftConfig.behaviour, - autoSubmitWhenAnsweredWithoutNotes: - !state.draftConfig.behaviour.autoSubmitWhenAnsweredWithoutNotes, + [key]: !state.draftConfig.behaviour[key], }, }, }; @@ -149,6 +151,9 @@ function deepEqual(left: AskConfig, right: AskConfig): boolean { return ( left.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.dismiss === right.keymaps.dismiss && left.keymaps.toggle === right.keymaps.toggle && diff --git a/tests/auto-submit.test.ts b/tests/auto-submit.test.ts index 44bdb88..3bc549a 100644 --- a/tests/auto-submit.test.ts +++ b/tests/auto-submit.test.ts @@ -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, }, }; diff --git a/tests/config-store.test.ts b/tests/config-store.test.ts index e08f15e..deb96f4 100644 --- a/tests/config-store.test.ts +++ b/tests/config-store.test.ts @@ -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 }); diff --git a/tests/dismiss-guard.test.ts b/tests/dismiss-guard.test.ts new file mode 100644 index 0000000..c1bb447 --- /dev/null +++ b/tests/dismiss-guard.test.ts @@ -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); +}); diff --git a/tests/dismiss-notice-behavior.test.ts b/tests/dismiss-notice-behavior.test.ts new file mode 100644 index 0000000..ddac49b --- /dev/null +++ b/tests/dismiss-notice-behavior.test.ts @@ -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); +}); diff --git a/tests/render-frame.test.ts b/tests/render-frame.test.ts index effb2bd..aec2d0d 100644 --- a/tests/render-frame.test.ts +++ b/tests/render-frame.test.ts @@ -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); +}); diff --git a/tests/settings-modal.test.ts b/tests/settings-modal.test.ts index 59a6731..d0740d1 100644 --- a/tests/settings-modal.test.ts +++ b/tests/settings-modal.test.ts @@ -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(