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
+10
View File
@@ -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,
+4
View File
@@ -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
View File
@@ -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;
+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: {
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
View File
@@ -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;
}
+24 -2
View File
@@ -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();
}
}
+8 -3
View File
@@ -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 &&