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
+8 -3
View File
@@ -121,7 +121,9 @@ Fixed bindings:
| `Tab` `Shift+Tab` / `←` `→` | Empty editor | Switch tabs without closing editor |
| Arrow keys / `Tab` | Non-empty editor | Stay in editor for cursor movement |
Config file: `~/.pi/.../extensions/eko24ive-pi-ask.json`
Review-tab shortcuts can optionally require the same number key twice via `behaviour.doublePressReviewShortcuts`.
Config file: `~/.pi/.../eko24ive-pi-ask.json`
You can edit this file yourself, ask pi to edit it for you, or use the read-only `Keymaps` tab to find the exact path and active bindings.
@@ -129,7 +131,10 @@ You can edit this file yourself, ask pi to edit it for you, or use the read-only
{
"schemaVersion": 1,
"behaviour": {
"autoSubmitWhenAnsweredWithoutNotes": false
"autoSubmitWhenAnsweredWithoutNotes": false,
"confirmDismissWhenDirty": true,
"doublePressReviewShortcuts": true,
"showFooterHints": true
},
"keymaps": {
"cancel": "esc",
@@ -148,7 +153,7 @@ Accepted notation follows pi-tui key ids. Common aliases are normalized, for exa
After installation, the extension registers the `ask_user` tool and `/ask-settings` command.
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. The ask settings modal is available both from `?` in the ask flow and from the `/ask-settings` command, with `Keymaps` and `Behaviour` tabs. `Keymaps` is read-only and shows the active resolved bindings plus the config file path. Behaviour settings are saved explicitly with `Ctrl+S`.
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. The ask settings modal is available both from `?` in the ask flow and from the `/ask-settings` command, with `Keymaps` and `Behaviour` tabs. `Keymaps` is read-only and shows the active resolved bindings plus the config file path. Behaviour settings are saved explicitly with `Ctrl+S`, including whether review-tab number shortcuts require a double press.
You can also talk to pi to configure this extension. When asked to customize pi-ask settings or keymaps, the agent is instructed to read the bundled `docs/configuration.md` guide first and then edit the config file accordingly.
+3 -2
View File
@@ -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 persisted user settings for `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, and `Show footer hints`
- `Behaviour` exposes persisted user settings for `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, 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
@@ -217,6 +217,7 @@ Main flow:
- `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs
- `Up`, `Down`: move between options
- `1..9`: select or toggle the matching option; on the review tab, `1`, `2`, and `3` trigger `Submit`, `Elaborate`, and `Cancel`
- when `Double-press review shortcuts` is enabled, review-tab `1`, `2`, and `3` require the same key twice without a timeout, and the review screen shows an inline hint for the pending action
- the following actions are configurable via persisted `keymaps`: `Enter` by default confirms or submits, `Esc` cancels, `Ctrl+C` dismisses, `Space` toggles, `n` edits the active option note, and `Shift+N` edits the current question note
Editing flow:
@@ -238,7 +239,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`, `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 ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `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.
+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}.`;
}
+122 -21
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,7 +376,38 @@ function renderKeymapsTab(
const lines: string[] = [];
const compact = layout.innerW < COMPACT_WIDTH;
const { customizable, fixed } = getAskKeymaps(config);
const headerLines = [
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(
layout.line(theme.fg("muted", "─".repeat(Math.max(1, layout.innerW - 2))))
);
lines.push(layout.line(theme.fg("muted", "Fixed")));
pushKeymapRows(lines, theme, layout, fixed, compact);
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.",
@@ -369,26 +420,76 @@ function renderKeymapsTab(
);
}
}
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
)) {
}
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));
}
lines.push(layout.line());
lines.push(layout.line(theme.fg("muted", "Customizable")));
pushKeymapRows(lines, theme, layout, customizable, compact);
lines.push(
layout.line(theme.fg("muted", "─".repeat(Math.max(1, layout.innerW - 2))))
);
lines.push(layout.line(theme.fg("muted", "Fixed")));
pushKeymapRows(lines, theme, layout, fixed, compact);
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 &&
+5 -5
View File
@@ -35,7 +35,7 @@ test("dirty flow state is true for saved answers or editor drafts", () => {
assert.equal(hasDirtyFlowState(noteEditing, "draft note"), true);
});
test("confirm dismiss when dirty is opt-in and respects dirty state", () => {
test("confirm dismiss when dirty respects dirty state and config", () => {
const state = applyNumberShortcut(
createInitialState({
questions: [
@@ -48,20 +48,20 @@ test("confirm dismiss when dirty is opt-in and respects dirty state", () => {
}),
1
);
const enabledConfig = {
const disabledConfig = {
...DEFAULT_ASK_CONFIG,
behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
confirmDismissWhenDirty: true,
confirmDismissWhenDirty: false,
},
};
assert.equal(
shouldConfirmDirtyDismiss({ config: DEFAULT_ASK_CONFIG, state }),
shouldConfirmDirtyDismiss({ config: disabledConfig, state }),
false
);
assert.equal(
shouldConfirmDirtyDismiss({ config: enabledConfig, state }),
shouldConfirmDirtyDismiss({ config: DEFAULT_ASK_CONFIG, state }),
true
);
});
+2 -2
View File
@@ -56,7 +56,7 @@ test("wide header keeps all tabs and framing arrows on the tab row", () => {
editor: mockEditor(),
});
assert.equal(lines[3], " ← ☐ One ☐ Two ✔ Submit →");
assert.equal(lines[3], " ← ☐ One ☐ Two ☰ Review →");
});
test("narrow tab strip keeps active middle tab visible", () => {
@@ -143,7 +143,7 @@ test("narrow tab strip keeps submit tab visible when active", () => {
editor: mockEditor(),
});
assert.equal(lines[3], " ← ✔ Submit →");
assert.equal(lines[3], " ← ☰ Review →");
});
test("tab strip avoids truncation at narrow boundary widths", () => {
+28
View File
@@ -111,6 +111,34 @@ test("submit screen stacks review above actions on narrow screens", () => {
]);
});
test("submit screen can show a review shortcut hint below the actions", () => {
const state = createInitialState({
questions: [
{
id: "q1",
label: "Color",
prompt: "Pick one color.",
options: [{ value: "blue", label: "Blue" }],
},
],
});
const lines: string[] = [];
renderSubmitScreen(
lines,
state,
plainTheme(),
UI_DIMENSIONS.submitWideMinWidth - 14,
"Press 1, 2, or 3 twice to confirm a review action."
);
assert.equal(
lines.includes(" Press 1, 2, or 3 twice to confirm a review"),
true
);
assert.equal(lines.includes(" action."), true);
});
test("submit screen shows notes only for answered questions in submit mode", () => {
let state = createInitialState({
questions: [
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getReviewShortcutHint,
resolveReviewShortcutDoublePress,
} from "../src/ui/review-shortcuts.ts";
test("review shortcut double press requires the same digit twice", () => {
const first = resolveReviewShortcutDoublePress(2);
assert.equal(first.confirmed, false);
assert.equal(first.actionIndex, 1);
assert.equal(first.pendingActionIndex, 1);
const second = resolveReviewShortcutDoublePress(2, first.pendingActionIndex);
assert.equal(second.confirmed, true);
assert.equal(second.actionIndex, 1);
assert.equal(second.pendingActionIndex, undefined);
});
test("review shortcut double press switches pending action when digit changes", () => {
const first = resolveReviewShortcutDoublePress(1);
const second = resolveReviewShortcutDoublePress(3, first.pendingActionIndex);
assert.equal(second.confirmed, false);
assert.equal(second.actionIndex, 2);
assert.equal(second.pendingActionIndex, 2);
});
test("review shortcut hint reflects pending confirmation state", () => {
assert.equal(
getReviewShortcutHint(),
"Press 1, 2, or 3 twice to confirm a review action."
);
assert.equal(getReviewShortcutHint(1), "Press 2 again to Elaborate.");
});
+22 -6
View File
@@ -7,7 +7,8 @@ import { AskSettingsModal } from "../src/ui/settings-modal.ts";
const savedConfig: AskConfig = {
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: false,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: true,
},
keymaps: {
@@ -59,12 +60,15 @@ function createModal(
test("settings modal renders compactly on phone-width screens", () => {
const modal = createModal();
const lines = modal.render(28);
const text = lines.join("\n");
assert(lines.every((line) => visibleWidth(line) <= 28));
assert(lines.join("\n").includes("@eko24ive/pi-ask"));
assert(lines.length <= 24);
assert(text.includes("@eko24ive/pi-ask"));
assert(lines[1]?.includes("Keymaps"));
assert(lines.join("\n").includes("/reload after editing."));
assert(lines.join("\n").includes("Open ask settings modal"));
assert(text.includes("Edit keymaps there, then"));
assert(text.includes("? settings"));
assert(text.includes("Esc cancel"));
assert.equal(lines[0]?.includes("├"), false);
});
@@ -77,12 +81,23 @@ 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] Confirm dismiss when dirty"));
assert(text.includes("[x] Double-press review shortcuts"));
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"));
});
test("settings modal keeps behaviour tab compact on narrow screens", () => {
const modal = createModal();
modal.handleInput("\t");
const lines = modal.render(36);
assert(lines.every((line) => visibleWidth(line) <= 36));
assert(lines.length <= 21);
assert(lines.join("\n").includes("Show footer hints"));
});
test("settings modal keymaps tab shows invalid-keymaps session warning", () => {
const modal = new AskSettingsModal(plainTheme(), {
configPath: "/tmp/eko24ive-pi-ask.json",
@@ -135,7 +150,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.confirmDismissWhenDirty, true);
assert.equal(saved?.behaviour.doublePressReviewShortcuts, true);
assert.equal(saved?.behaviour.showFooterHints, true);
assert(text.includes("Saved"));
assert.equal(text.includes("Unsaved changes. Press Ctrl+S to save."), false);