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 | | `Tab` `Shift+Tab` / `←` `→` | Empty editor | Switch tabs without closing editor |
| Arrow keys / `Tab` | Non-empty editor | Stay in editor for cursor movement | | 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. 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, "schemaVersion": 1,
"behaviour": { "behaviour": {
"autoSubmitWhenAnsweredWithoutNotes": false "autoSubmitWhenAnsweredWithoutNotes": false,
"confirmDismissWhenDirty": true,
"doublePressReviewShortcuts": true,
"showFooterHints": true
}, },
"keymaps": { "keymaps": {
"cancel": "esc", "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. 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. 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 - 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 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` - `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
@@ -217,6 +217,7 @@ Main flow:
- `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs - `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs
- `Up`, `Down`: move between options - `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` - `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 - 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: 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. 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. 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 = { export const DEFAULT_ASK_CONFIG: AskConfig = {
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: false, autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: false, confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: true, showFooterHints: true,
}, },
keymaps: { keymaps: {
@@ -23,6 +24,9 @@ export function normalizeAskConfig(
confirmDismissWhenDirty: confirmDismissWhenDirty:
config?.behaviour?.confirmDismissWhenDirty ?? config?.behaviour?.confirmDismissWhenDirty ??
DEFAULT_ASK_CONFIG.behaviour.confirmDismissWhenDirty, DEFAULT_ASK_CONFIG.behaviour.confirmDismissWhenDirty,
doublePressReviewShortcuts:
config?.behaviour?.doublePressReviewShortcuts ??
DEFAULT_ASK_CONFIG.behaviour.doublePressReviewShortcuts,
showFooterHints: showFooterHints:
config?.behaviour?.showFooterHints ?? config?.behaviour?.showFooterHints ??
DEFAULT_ASK_CONFIG.behaviour.showFooterHints, DEFAULT_ASK_CONFIG.behaviour.showFooterHints,
@@ -42,6 +46,8 @@ export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
autoSubmitWhenAnsweredWithoutNotes: autoSubmitWhenAnsweredWithoutNotes:
normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes, normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes,
confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty, confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty,
doublePressReviewShortcuts:
normalized.behaviour.doublePressReviewShortcuts,
showFooterHints: normalized.behaviour.showFooterHints, showFooterHints: normalized.behaviour.showFooterHints,
}, },
keymaps: { keymaps: {
+2
View File
@@ -7,6 +7,7 @@ export const AskConfigFileV1Schema = Type.Object({
Type.Object({ Type.Object({
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()), autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
confirmDismissWhenDirty: Type.Optional(Type.Boolean()), confirmDismissWhenDirty: Type.Optional(Type.Boolean()),
doublePressReviewShortcuts: Type.Optional(Type.Boolean()),
showFooterHints: Type.Optional(Type.Boolean()), showFooterHints: Type.Optional(Type.Boolean()),
}) })
), ),
@@ -37,6 +38,7 @@ export interface AskConfig {
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: boolean; autoSubmitWhenAnsweredWithoutNotes: boolean;
confirmDismissWhenDirty: boolean; confirmDismissWhenDirty: boolean;
doublePressReviewShortcuts: boolean;
showFooterHints: boolean; showFooterHints: boolean;
}; };
keymaps: AskConfigKeymaps; keymaps: AskConfigKeymaps;
+67
View File
@@ -38,6 +38,10 @@ import {
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";
import {
getReviewShortcutHint,
resolveReviewShortcutDoublePress,
} from "./review-shortcuts.ts";
import { showAskSettingsModal } from "./show-settings-modal.ts"; import { showAskSettingsModal } from "./show-settings-modal.ts";
type CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0]; type CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0];
@@ -57,6 +61,7 @@ interface AskFlowController {
dismissNotice?: string; dismissNotice?: string;
done: Done; done: Done;
editor: Editor; editor: Editor;
pendingReviewShortcutActionIndex?: number;
settingsOpen: boolean; settingsOpen: boolean;
state: AskState; state: AskState;
suppressAutoInputForSelection: boolean; suppressAutoInputForSelection: boolean;
@@ -94,6 +99,7 @@ function createAskFlowController(
settingsOpen: false, settingsOpen: false,
state: createInitialState(params), state: createInitialState(params),
suppressAutoInputForSelection: false, suppressAutoInputForSelection: false,
pendingReviewShortcutActionIndex: undefined,
theme, theme,
tui, tui,
unsubscribeConfig: () => { unsubscribeConfig: () => {
@@ -136,6 +142,7 @@ function renderController(
config: controller.config, config: controller.config,
editor: controller.editor, editor: controller.editor,
footerNotice: getDismissNotice(controller), footerNotice: getDismissNotice(controller),
reviewShortcutHint: getActiveReviewShortcutHint(controller),
state: controller.state, state: controller.state,
theme: controller.theme, theme: controller.theme,
width, width,
@@ -193,12 +200,15 @@ function handleNavigationCommand(
) { ) {
switch (command.kind) { switch (command.kind) {
case "moveTab": case "moveTab":
clearReviewShortcutPending(controller);
commitState(controller, moveTab(controller.state, command.delta)); commitState(controller, moveTab(controller.state, command.delta));
return; return;
case "moveOption": case "moveOption":
clearReviewShortcutPending(controller);
commitState(controller, moveOption(controller.state, command.delta)); commitState(controller, moveOption(controller.state, command.delta));
return; return;
case "toggleMulti": case "toggleMulti":
clearReviewShortcutPending(controller);
handleToggleCurrentOption(controller); handleToggleCurrentOption(controller);
return; return;
case "openQuestionNote": case "openQuestionNote":
@@ -208,20 +218,27 @@ function handleNavigationCommand(
openOptionNote(controller); openOptionNote(controller);
return; return;
case "confirm": case "confirm":
clearReviewShortcutPending(controller);
commitState(controller, confirmCurrentSelection(controller.state), { commitState(controller, confirmCurrentSelection(controller.state), {
finish: true, finish: true,
}); });
return; return;
case "cancel": case "cancel":
clearReviewShortcutPending(controller);
handleExitFlow(controller, cancelFlow(controller.state)); handleExitFlow(controller, cancelFlow(controller.state));
return; return;
case "numberShortcut": case "numberShortcut":
if (handleReviewShortcutNumber(controller, command.digit)) {
return;
}
clearReviewShortcutPending(controller);
commitState( commitState(
controller, controller,
applyNumberShortcut(controller.state, command.digit) applyNumberShortcut(controller.state, command.digit)
); );
return; return;
case "dismiss": case "dismiss":
clearReviewShortcutPending(controller);
handleExitFlow(controller, dismissFlow(controller.state)); handleExitFlow(controller, dismissFlow(controller.state));
return; return;
case "showSettings": case "showSettings":
@@ -354,6 +371,56 @@ function clearDismissNotice(controller: AskFlowController) {
controller.dismissNotice = undefined; 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 { function getDismissNotice(controller: AskFlowController): string | undefined {
return controller.dismissNotice; 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({ tabs.push({
text: submitText, text: reviewText,
width: visibleWidth(submitText), width: visibleWidth(reviewText),
render: isSubmitTab(state) render: isSubmitTab(state)
? theme.bg("selectedBg", theme.fg("text", submitText)) ? theme.bg("selectedBg", theme.fg("text", reviewText))
: theme.fg("success", submitText), : theme.fg("success", reviewText),
}); });
const activeIndex = Math.min(state.activeTabIndex, tabs.length - 1); const activeIndex = Math.min(state.activeTabIndex, tabs.length - 1);
+17 -1
View File
@@ -13,7 +13,8 @@ export function renderSubmitScreen(
lines: string[], lines: string[],
state: AskState, state: AskState,
theme: Theme, theme: Theme,
width: number width: number,
reviewShortcutHint?: string
) { ) {
const model = buildReviewScreenModel(state, width); const model = buildReviewScreenModel(state, width);
if (model.layout === "wide") { if (model.layout === "wide") {
@@ -32,6 +33,7 @@ export function renderSubmitScreen(
)) { )) {
lines.push(line); lines.push(line);
} }
appendReviewShortcutHint(lines, reviewShortcutHint, theme, width);
return; return;
} }
@@ -40,6 +42,7 @@ export function renderSubmitScreen(
lines.push(...reviewLines); lines.push(...reviewLines);
lines.push(""); lines.push("");
lines.push(...actionLines); lines.push(...actionLines);
appendReviewShortcutHint(lines, reviewShortcutHint, theme, width);
} }
function renderSubmitReviewLines( function renderSubmitReviewLines(
@@ -150,3 +153,16 @@ function renderSubmitActions(
} }
return lines; 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: { export function renderAskScreen(args: {
config: AskConfig; config: AskConfig;
footerNotice?: string; footerNotice?: string;
reviewShortcutHint?: string;
state: AskState; state: AskState;
theme: Theme; theme: Theme;
width: number; width: number;
editor: QuestionRenderContext["editor"]; editor: QuestionRenderContext["editor"];
}): string[] { }): string[] {
const { config, footerNotice, state, theme, width, editor } = args; const {
config,
footerNotice,
reviewShortcutHint,
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);
@@ -26,7 +35,7 @@ export function renderAskScreen(args: {
renderFrameHeader({ lines, state, theme, width }); renderFrameHeader({ lines, state, theme, width });
if (isSubmitTab(state)) { if (isSubmitTab(state)) {
renderSubmitScreen(lines, state, theme, width); renderSubmitScreen(lines, state, theme, width, reviewShortcutHint);
} else if (question) { } else if (question) {
renderQuestionScreen({ renderQuestionScreen({
lines, 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 MIN_WIDTH = 24;
const COMPACT_WIDTH = 46; const COMPACT_WIDTH = 46;
const COMPACT_DESCRIPTION_LINES = 2;
const TAB_GAP = " "; const TAB_GAP = " ";
const DISCARD_WINDOW_MS = 1000; const DISCARD_WINDOW_MS = 1000;
const SETTINGS_MODAL_KEYS = { const SETTINGS_MODAL_KEYS = {
@@ -73,6 +74,15 @@ const BEHAVIOUR_ROWS = [
}, },
label: "Confirm dismiss when dirty", 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.", description: "Show footer keymap hints at the bottom of the ask flow.",
key: "showFooterHints", key: "showFooterHints",
@@ -305,8 +315,9 @@ function renderBehaviourTab(
state: AskSettingsState, state: AskSettingsState,
layout: SettingsLayout layout: SettingsLayout
): string[] { ): string[] {
const compact = layout.innerW < COMPACT_WIDTH;
return BEHAVIOUR_ROWS.flatMap((row, index) => 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, state: AskSettingsState,
layout: SettingsLayout, layout: SettingsLayout,
row: (typeof BEHAVIOUR_ROWS)[number], row: (typeof BEHAVIOUR_ROWS)[number],
index: number index: number,
compact: boolean
): string[] { ): string[] {
const focused = index === state.behaviourFocusIndex; const focused = index === state.behaviourFocusIndex;
const dirty = const dirty =
@@ -334,15 +346,23 @@ function renderBehaviourRow(
label = theme.bg("selectedBg", label); label = theme.bg("selectedBg", label);
} }
const descriptionLines = wrapText(row.description, layout.innerW - 4);
const lines = [ const lines = [
layout.line(truncateToWidth(label, layout.innerW - 1, "…")), layout.line(truncateToWidth(label, layout.innerW - 1, "…")),
...wrapText(row.description, layout.innerW - 4).map((descLine) => ...descriptionLines
layout.line(` ${theme.fg("dim", descLine)}`) .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()); 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; return lines;
} }
@@ -356,7 +376,38 @@ function renderKeymapsTab(
const lines: string[] = []; const lines: string[] = [];
const compact = layout.innerW < COMPACT_WIDTH; const compact = layout.innerW < COMPACT_WIDTH;
const { customizable, fixed } = getAskKeymaps(config); 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:", "Edit this config file to change customizable ask keymaps:",
configPath, configPath,
"Restart pi or run /reload after editing.", "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( function pushInvalidKeymapNotice(
"Using default keymaps this session because configured keymaps were rejected.", lines: string[],
layout.innerW - 2 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))); lines.push(layout.line(theme.fg("warning", line)));
} }
} }
lines.push(layout.line());
lines.push(layout.line(theme.fg("muted", "Customizable"))); function renderCompactKeymapSection(
pushKeymapRows(lines, theme, layout, customizable, compact); theme: Theme,
lines.push( layout: SettingsLayout,
layout.line(theme.fg("muted", "─".repeat(Math.max(1, layout.innerW - 2)))) title: string,
); keymaps: readonly AskKeyBinding[]
lines.push(layout.line(theme.fg("muted", "Fixed"))); ): string[] {
pushKeymapRows(lines, theme, layout, fixed, compact); 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; 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( function pushKeymapRows(
lines: string[], lines: string[],
theme: Theme, theme: Theme,
+2
View File
@@ -153,6 +153,8 @@ function deepEqual(left: AskConfig, right: AskConfig): boolean {
right.behaviour.autoSubmitWhenAnsweredWithoutNotes && right.behaviour.autoSubmitWhenAnsweredWithoutNotes &&
left.behaviour.confirmDismissWhenDirty === left.behaviour.confirmDismissWhenDirty ===
right.behaviour.confirmDismissWhenDirty && right.behaviour.confirmDismissWhenDirty &&
left.behaviour.doublePressReviewShortcuts ===
right.behaviour.doublePressReviewShortcuts &&
left.behaviour.showFooterHints === right.behaviour.showFooterHints && 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 &&
+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); 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( const state = applyNumberShortcut(
createInitialState({ createInitialState({
questions: [ questions: [
@@ -48,20 +48,20 @@ test("confirm dismiss when dirty is opt-in and respects dirty state", () => {
}), }),
1 1
); );
const enabledConfig = { const disabledConfig = {
...DEFAULT_ASK_CONFIG, ...DEFAULT_ASK_CONFIG,
behaviour: { behaviour: {
...DEFAULT_ASK_CONFIG.behaviour, ...DEFAULT_ASK_CONFIG.behaviour,
confirmDismissWhenDirty: true, confirmDismissWhenDirty: false,
}, },
}; };
assert.equal( assert.equal(
shouldConfirmDirtyDismiss({ config: DEFAULT_ASK_CONFIG, state }), shouldConfirmDirtyDismiss({ config: disabledConfig, state }),
false false
); );
assert.equal( assert.equal(
shouldConfirmDirtyDismiss({ config: enabledConfig, state }), shouldConfirmDirtyDismiss({ config: DEFAULT_ASK_CONFIG, state }),
true true
); );
}); });
+2 -2
View File
@@ -56,7 +56,7 @@ test("wide header keeps all tabs and framing arrows on the tab row", () => {
editor: mockEditor(), 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", () => { 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(), editor: mockEditor(),
}); });
assert.equal(lines[3], " ← ✔ Submit →"); assert.equal(lines[3], " ← ☰ Review →");
}); });
test("tab strip avoids truncation at narrow boundary widths", () => { 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", () => { test("submit screen shows notes only for answered questions in submit mode", () => {
let state = createInitialState({ let state = createInitialState({
questions: [ 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 = { const savedConfig: AskConfig = {
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: false, autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: false, confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: true, showFooterHints: true,
}, },
keymaps: { keymaps: {
@@ -59,12 +60,15 @@ function createModal(
test("settings modal renders compactly on phone-width screens", () => { test("settings modal renders compactly on phone-width screens", () => {
const modal = createModal(); const modal = createModal();
const lines = modal.render(28); const lines = modal.render(28);
const text = lines.join("\n");
assert(lines.every((line) => visibleWidth(line) <= 28)); 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[1]?.includes("Keymaps"));
assert(lines.join("\n").includes("/reload after editing.")); assert(text.includes("Edit keymaps there, then"));
assert(lines.join("\n").includes("Open ask settings modal")); assert(text.includes("? settings"));
assert(text.includes("Esc cancel"));
assert.equal(lines[0]?.includes("├"), false); 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("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] Confirm dismiss when dirty"));
assert(text.includes("[x] Double-press review shortcuts"));
assert(text.includes("[x] Show footer hints")); 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"));
}); });
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", () => { test("settings modal keymaps tab shows invalid-keymaps session warning", () => {
const modal = new AskSettingsModal(plainTheme(), { const modal = new AskSettingsModal(plainTheme(), {
configPath: "/tmp/eko24ive-pi-ask.json", 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"); 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.confirmDismissWhenDirty, true);
assert.equal(saved?.behaviour.doublePressReviewShortcuts, true);
assert.equal(saved?.behaviour.showFooterHints, true); 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);