feat: add review shortcut confirmation setting
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user