feat: add review shortcut confirmation setting

This commit is contained in:
Erik Kotsiuba
2026-04-29 02:40:29 +02:00
parent 2bf85b3f6f
commit 67891d147f
16 changed files with 385 additions and 54 deletions
+7 -1
View File
@@ -4,7 +4,8 @@ import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
export const DEFAULT_ASK_CONFIG: AskConfig = {
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: false,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: true,
},
keymaps: {
@@ -23,6 +24,9 @@ export function normalizeAskConfig(
confirmDismissWhenDirty:
config?.behaviour?.confirmDismissWhenDirty ??
DEFAULT_ASK_CONFIG.behaviour.confirmDismissWhenDirty,
doublePressReviewShortcuts:
config?.behaviour?.doublePressReviewShortcuts ??
DEFAULT_ASK_CONFIG.behaviour.doublePressReviewShortcuts,
showFooterHints:
config?.behaviour?.showFooterHints ??
DEFAULT_ASK_CONFIG.behaviour.showFooterHints,
@@ -42,6 +46,8 @@ export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
autoSubmitWhenAnsweredWithoutNotes:
normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes,
confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty,
doublePressReviewShortcuts:
normalized.behaviour.doublePressReviewShortcuts,
showFooterHints: normalized.behaviour.showFooterHints,
},
keymaps: {
+2
View File
@@ -7,6 +7,7 @@ export const AskConfigFileV1Schema = Type.Object({
Type.Object({
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
confirmDismissWhenDirty: Type.Optional(Type.Boolean()),
doublePressReviewShortcuts: Type.Optional(Type.Boolean()),
showFooterHints: Type.Optional(Type.Boolean()),
})
),
@@ -37,6 +38,7 @@ export interface AskConfig {
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: boolean;
confirmDismissWhenDirty: boolean;
doublePressReviewShortcuts: boolean;
showFooterHints: boolean;
};
keymaps: AskConfigKeymaps;
+67
View File
@@ -38,6 +38,10 @@ import {
import type { AskInputCommand } from "./input.ts";
import { getInputCommand } from "./input.ts";
import { renderAskScreen } from "./render.ts";
import {
getReviewShortcutHint,
resolveReviewShortcutDoublePress,
} from "./review-shortcuts.ts";
import { showAskSettingsModal } from "./show-settings-modal.ts";
type CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0];
@@ -57,6 +61,7 @@ interface AskFlowController {
dismissNotice?: string;
done: Done;
editor: Editor;
pendingReviewShortcutActionIndex?: number;
settingsOpen: boolean;
state: AskState;
suppressAutoInputForSelection: boolean;
@@ -94,6 +99,7 @@ function createAskFlowController(
settingsOpen: false,
state: createInitialState(params),
suppressAutoInputForSelection: false,
pendingReviewShortcutActionIndex: undefined,
theme,
tui,
unsubscribeConfig: () => {
@@ -136,6 +142,7 @@ function renderController(
config: controller.config,
editor: controller.editor,
footerNotice: getDismissNotice(controller),
reviewShortcutHint: getActiveReviewShortcutHint(controller),
state: controller.state,
theme: controller.theme,
width,
@@ -193,12 +200,15 @@ function handleNavigationCommand(
) {
switch (command.kind) {
case "moveTab":
clearReviewShortcutPending(controller);
commitState(controller, moveTab(controller.state, command.delta));
return;
case "moveOption":
clearReviewShortcutPending(controller);
commitState(controller, moveOption(controller.state, command.delta));
return;
case "toggleMulti":
clearReviewShortcutPending(controller);
handleToggleCurrentOption(controller);
return;
case "openQuestionNote":
@@ -208,20 +218,27 @@ function handleNavigationCommand(
openOptionNote(controller);
return;
case "confirm":
clearReviewShortcutPending(controller);
commitState(controller, confirmCurrentSelection(controller.state), {
finish: true,
});
return;
case "cancel":
clearReviewShortcutPending(controller);
handleExitFlow(controller, cancelFlow(controller.state));
return;
case "numberShortcut":
if (handleReviewShortcutNumber(controller, command.digit)) {
return;
}
clearReviewShortcutPending(controller);
commitState(
controller,
applyNumberShortcut(controller.state, command.digit)
);
return;
case "dismiss":
clearReviewShortcutPending(controller);
handleExitFlow(controller, dismissFlow(controller.state));
return;
case "showSettings":
@@ -354,6 +371,56 @@ function clearDismissNotice(controller: AskFlowController) {
controller.dismissNotice = undefined;
}
function clearReviewShortcutPending(controller: AskFlowController) {
controller.pendingReviewShortcutActionIndex = undefined;
}
function getActiveReviewShortcutHint(
controller: AskFlowController
): string | undefined {
if (
!(
isSubmitTab(controller.state) &&
controller.config.behaviour.doublePressReviewShortcuts
)
) {
return;
}
return getReviewShortcutHint(controller.pendingReviewShortcutActionIndex);
}
function handleReviewShortcutNumber(
controller: AskFlowController,
digit: number
): boolean {
if (
!(
isSubmitTab(controller.state) &&
controller.config.behaviour.doublePressReviewShortcuts
)
) {
return false;
}
const resolution = resolveReviewShortcutDoublePress(
digit,
controller.pendingReviewShortcutActionIndex
);
if (resolution.actionIndex === undefined) {
return false;
}
controller.pendingReviewShortcutActionIndex = resolution.pendingActionIndex;
const nextState = resolution.confirmed
? applyNumberShortcut(controller.state, digit)
: {
...controller.state,
activeSubmitActionIndex: resolution.actionIndex,
};
commitState(controller, nextState, { finish: resolution.confirmed });
return true;
}
function getDismissNotice(controller: AskFlowController): string | undefined {
return controller.dismissNotice;
}
+5 -5
View File
@@ -71,13 +71,13 @@ function renderTabs(state: AskState, theme: Theme, width: number): string {
};
});
const submitText = " ✔ Submit ";
const reviewText = " ☰ Review ";
tabs.push({
text: submitText,
width: visibleWidth(submitText),
text: reviewText,
width: visibleWidth(reviewText),
render: isSubmitTab(state)
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg("success", submitText),
? theme.bg("selectedBg", theme.fg("text", reviewText))
: theme.fg("success", reviewText),
});
const activeIndex = Math.min(state.activeTabIndex, tabs.length - 1);
+17 -1
View File
@@ -13,7 +13,8 @@ export function renderSubmitScreen(
lines: string[],
state: AskState,
theme: Theme,
width: number
width: number,
reviewShortcutHint?: string
) {
const model = buildReviewScreenModel(state, width);
if (model.layout === "wide") {
@@ -32,6 +33,7 @@ export function renderSubmitScreen(
)) {
lines.push(line);
}
appendReviewShortcutHint(lines, reviewShortcutHint, theme, width);
return;
}
@@ -40,6 +42,7 @@ export function renderSubmitScreen(
lines.push(...reviewLines);
lines.push("");
lines.push(...actionLines);
appendReviewShortcutHint(lines, reviewShortcutHint, theme, width);
}
function renderSubmitReviewLines(
@@ -150,3 +153,16 @@ function renderSubmitActions(
}
return lines;
}
function appendReviewShortcutHint(
lines: string[],
hint: string | undefined,
theme: Theme,
width: number
) {
if (!hint) {
return;
}
lines.push("");
pushWrappedText(lines, hint, width, theme, "dim", " ", " ");
}
+11 -2
View File
@@ -13,12 +13,21 @@ import type { QuestionRenderContext, Theme } from "./render-types.ts";
export function renderAskScreen(args: {
config: AskConfig;
footerNotice?: string;
reviewShortcutHint?: string;
state: AskState;
theme: Theme;
width: number;
editor: QuestionRenderContext["editor"];
}): string[] {
const { config, footerNotice, state, theme, width, editor } = args;
const {
config,
footerNotice,
reviewShortcutHint,
state,
theme,
width,
editor,
} = args;
const lines: string[] = [];
const question = getCurrentQuestion(state);
const options = getRenderableOptions(question);
@@ -26,7 +35,7 @@ export function renderAskScreen(args: {
renderFrameHeader({ lines, state, theme, width });
if (isSubmitTab(state)) {
renderSubmitScreen(lines, state, theme, width);
renderSubmitScreen(lines, state, theme, width, reviewShortcutHint);
} else if (question) {
renderQuestionScreen({
lines,
+43
View File
@@ -0,0 +1,43 @@
import { SUBMIT_CHOICES } from "../constants/text.ts";
const REVIEW_SHORTCUT_DIGIT_MIN = 1;
const REVIEW_SHORTCUT_DIGIT_MAX = 3;
export interface ReviewShortcutResolution {
actionIndex?: number;
confirmed: boolean;
pendingActionIndex?: number;
}
export function resolveReviewShortcutDoublePress(
digit: number,
pendingActionIndex?: number
): ReviewShortcutResolution {
if (digit < REVIEW_SHORTCUT_DIGIT_MIN || digit > REVIEW_SHORTCUT_DIGIT_MAX) {
return { confirmed: false };
}
const actionIndex = digit - 1;
if (pendingActionIndex === actionIndex) {
return { actionIndex, confirmed: true };
}
return {
actionIndex,
confirmed: false,
pendingActionIndex: actionIndex,
};
}
export function getReviewShortcutHint(pendingActionIndex?: number): string {
if (pendingActionIndex === undefined) {
return "Press 1, 2, or 3 twice to confirm a review action.";
}
const actionLabel = SUBMIT_CHOICES[pendingActionIndex];
if (!actionLabel) {
return "Press 1, 2, or 3 twice to confirm a review action.";
}
return `Press ${pendingActionIndex + 1} again to ${actionLabel}.`;
}
+128 -27
View File
@@ -43,6 +43,7 @@ interface AskSettingsModalOptions {
const MIN_WIDTH = 24;
const COMPACT_WIDTH = 46;
const COMPACT_DESCRIPTION_LINES = 2;
const TAB_GAP = " ";
const DISCARD_WINDOW_MS = 1000;
const SETTINGS_MODAL_KEYS = {
@@ -73,6 +74,15 @@ const BEHAVIOUR_ROWS = [
},
label: "Confirm dismiss when dirty",
},
{
description:
"Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.",
key: "doublePressReviewShortcuts",
getValue(config: AskConfig) {
return config.behaviour.doublePressReviewShortcuts;
},
label: "Double-press review shortcuts",
},
{
description: "Show footer keymap hints at the bottom of the ask flow.",
key: "showFooterHints",
@@ -305,8 +315,9 @@ function renderBehaviourTab(
state: AskSettingsState,
layout: SettingsLayout
): string[] {
const compact = layout.innerW < COMPACT_WIDTH;
return BEHAVIOUR_ROWS.flatMap((row, index) =>
renderBehaviourRow(theme, state, layout, row, index)
renderBehaviourRow(theme, state, layout, row, index, compact)
);
}
@@ -315,7 +326,8 @@ function renderBehaviourRow(
state: AskSettingsState,
layout: SettingsLayout,
row: (typeof BEHAVIOUR_ROWS)[number],
index: number
index: number,
compact: boolean
): string[] {
const focused = index === state.behaviourFocusIndex;
const dirty =
@@ -334,15 +346,23 @@ function renderBehaviourRow(
label = theme.bg("selectedBg", label);
}
const descriptionLines = wrapText(row.description, layout.innerW - 4);
const lines = [
layout.line(truncateToWidth(label, layout.innerW - 1, "…")),
...wrapText(row.description, layout.innerW - 4).map((descLine) =>
layout.line(` ${theme.fg("dim", descLine)}`)
),
...descriptionLines
.slice(0, compact ? COMPACT_DESCRIPTION_LINES : descriptionLines.length)
.map((descLine) => layout.line(` ${theme.fg("dim", descLine)}`)),
];
if (index < BEHAVIOUR_ROWS.length - 1) {
if (!compact && index < BEHAVIOUR_ROWS.length - 1) {
lines.push(layout.line());
}
if (
compact &&
descriptionLines.length > COMPACT_DESCRIPTION_LINES &&
index < BEHAVIOUR_ROWS.length - 1
) {
lines.push(layout.line(` ${theme.fg("dim", "…")}`));
}
return lines;
}
@@ -356,29 +376,18 @@ function renderKeymapsTab(
const lines: string[] = [];
const compact = layout.innerW < COMPACT_WIDTH;
const { customizable, fixed } = getAskKeymaps(config);
const headerLines = [
"Edit this config file to change customizable ask keymaps:",
configPath,
"Restart pi or run /reload after editing.",
];
for (const text of headerLines) {
for (const line of wrapText(text, layout.innerW - 2)) {
lines.push(
layout.line(theme.fg(text === configPath ? "accent" : "dim", line))
);
}
}
if (notice?.kind === "error") {
lines.push(layout.line());
for (const line of wrapText(
"Using default keymaps this session because configured keymaps were rejected.",
layout.innerW - 2
)) {
lines.push(layout.line(theme.fg("warning", line)));
}
}
pushKeymapHeaderLines(lines, theme, layout, configPath, compact);
pushInvalidKeymapNotice(lines, theme, layout, notice, compact);
lines.push(layout.line());
if (compact) {
lines.push(
...renderCompactKeymapSection(theme, layout, "Customizable", customizable)
);
lines.push(layout.line());
lines.push(...renderCompactKeymapSection(theme, layout, "Fixed", fixed));
return lines;
}
lines.push(layout.line(theme.fg("muted", "Customizable")));
pushKeymapRows(lines, theme, layout, customizable, compact);
lines.push(
@@ -389,6 +398,98 @@ function renderKeymapsTab(
return lines;
}
function pushKeymapHeaderLines(
lines: string[],
theme: Theme,
layout: SettingsLayout,
configPath: string,
compact: boolean
) {
const headerLines = compact
? [configPath, "Edit keymaps there, then /reload."]
: [
"Edit this config file to change customizable ask keymaps:",
configPath,
"Restart pi or run /reload after editing.",
];
for (const text of headerLines) {
for (const line of wrapText(text, layout.innerW - 2)) {
lines.push(
layout.line(theme.fg(text === configPath ? "accent" : "dim", line))
);
}
}
}
function pushInvalidKeymapNotice(
lines: string[],
theme: Theme,
layout: SettingsLayout,
notice: AskConfigNotice | undefined,
compact: boolean
) {
if (notice?.kind !== "error") {
return;
}
const warningText = compact
? "Invalid config; using default keymaps this session."
: "Using default keymaps this session because configured keymaps were rejected.";
for (const line of wrapText(warningText, layout.innerW - 2)) {
lines.push(layout.line(theme.fg("warning", line)));
}
}
function renderCompactKeymapSection(
theme: Theme,
layout: SettingsLayout,
title: string,
keymaps: readonly AskKeyBinding[]
): string[] {
const summary = keymaps
.map((hint) => `${hint.label} ${compactKeymapDescription(hint)}`)
.join(" · ");
const lines = [layout.line(theme.fg("muted", title))];
for (const line of wrapText(summary, layout.innerW - 2)) {
lines.push(layout.line(line));
}
return lines;
}
function compactKeymapDescription(hint: AskKeyBinding): string {
switch (hint.id) {
case "cancel":
return "cancel";
case "dismiss":
return "dismiss";
case "toggle":
return "toggle";
case "confirm":
return "confirm";
case "optionNote":
return "option note";
case "questionNote":
return "question note";
case "settings":
return "settings";
case "nextTab":
return "next tab";
case "previousTab":
return "prev tab";
case "previousOption":
return "move";
case "nextOption":
return "next";
case "numberShortcut":
return "quick select";
case "fileReference":
return "file refs";
default:
return hint.description.toLowerCase();
}
}
function pushKeymapRows(
lines: string[],
theme: Theme,
+2
View File
@@ -153,6 +153,8 @@ function deepEqual(left: AskConfig, right: AskConfig): boolean {
right.behaviour.autoSubmitWhenAnsweredWithoutNotes &&
left.behaviour.confirmDismissWhenDirty ===
right.behaviour.confirmDismissWhenDirty &&
left.behaviour.doublePressReviewShortcuts ===
right.behaviour.doublePressReviewShortcuts &&
left.behaviour.showFooterHints === right.behaviour.showFooterHints &&
left.keymaps.cancel === right.keymaps.cancel &&
left.keymaps.dismiss === right.keymaps.dismiss &&