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
|
### 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
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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",
|
"? 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 = {
|
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user