feat: add review shortcut confirmation setting
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}.`;
|
||||
}
|
||||
+122
-21
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user