feat: add dirty-dismiss and footer hint settings
This commit is contained in:
@@ -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
|
||||
|
||||
+20
-2
@@ -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",
|
||||
|
||||
+5
-3
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+52
-7
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
+3
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user