diff --git a/README.md b/README.md index 7eb5336..91fd621 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/contract.md b/docs/contract.md index b1ff37a..4784b37 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -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. diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 1722f07..b2c14cc 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -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: { diff --git a/src/config/schema.ts b/src/config/schema.ts index 12cc29f..823ff23 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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; diff --git a/src/ui/controller.ts b/src/ui/controller.ts index 8d8e069..966a8f5 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -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[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; } diff --git a/src/ui/render-frame.ts b/src/ui/render-frame.ts index a36b70b..d981aea 100644 --- a/src/ui/render-frame.ts +++ b/src/ui/render-frame.ts @@ -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); diff --git a/src/ui/render-submit.ts b/src/ui/render-submit.ts index ca04289..98a0cbb 100644 --- a/src/ui/render-submit.ts +++ b/src/ui/render-submit.ts @@ -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", " ", " "); +} diff --git a/src/ui/render.ts b/src/ui/render.ts index 2a771ee..802a03c 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -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, diff --git a/src/ui/review-shortcuts.ts b/src/ui/review-shortcuts.ts new file mode 100644 index 0000000..d73f99f --- /dev/null +++ b/src/ui/review-shortcuts.ts @@ -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}.`; +} diff --git a/src/ui/settings-modal.ts b/src/ui/settings-modal.ts index ca9a786..dc73a17 100644 --- a/src/ui/settings-modal.ts +++ b/src/ui/settings-modal.ts @@ -43,6 +43,7 @@ interface AskSettingsModalOptions { const MIN_WIDTH = 24; const COMPACT_WIDTH = 46; +const COMPACT_DESCRIPTION_LINES = 2; const TAB_GAP = " "; const DISCARD_WINDOW_MS = 1000; const SETTINGS_MODAL_KEYS = { @@ -73,6 +74,15 @@ const BEHAVIOUR_ROWS = [ }, label: "Confirm dismiss when dirty", }, + { + description: + "Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.", + key: "doublePressReviewShortcuts", + getValue(config: AskConfig) { + return config.behaviour.doublePressReviewShortcuts; + }, + label: "Double-press review shortcuts", + }, { description: "Show footer keymap hints at the bottom of the ask flow.", key: "showFooterHints", @@ -305,8 +315,9 @@ function renderBehaviourTab( state: AskSettingsState, layout: SettingsLayout ): string[] { + const compact = layout.innerW < COMPACT_WIDTH; return BEHAVIOUR_ROWS.flatMap((row, index) => - renderBehaviourRow(theme, state, layout, row, index) + renderBehaviourRow(theme, state, layout, row, index, compact) ); } @@ -315,7 +326,8 @@ function renderBehaviourRow( state: AskSettingsState, layout: SettingsLayout, row: (typeof BEHAVIOUR_ROWS)[number], - index: number + index: number, + compact: boolean ): string[] { const focused = index === state.behaviourFocusIndex; const dirty = @@ -334,15 +346,23 @@ function renderBehaviourRow( label = theme.bg("selectedBg", label); } + const descriptionLines = wrapText(row.description, layout.innerW - 4); const lines = [ layout.line(truncateToWidth(label, layout.innerW - 1, "…")), - ...wrapText(row.description, layout.innerW - 4).map((descLine) => - layout.line(` ${theme.fg("dim", descLine)}`) - ), + ...descriptionLines + .slice(0, compact ? COMPACT_DESCRIPTION_LINES : descriptionLines.length) + .map((descLine) => layout.line(` ${theme.fg("dim", descLine)}`)), ]; - if (index < BEHAVIOUR_ROWS.length - 1) { + if (!compact && index < BEHAVIOUR_ROWS.length - 1) { lines.push(layout.line()); } + if ( + compact && + descriptionLines.length > COMPACT_DESCRIPTION_LINES && + index < BEHAVIOUR_ROWS.length - 1 + ) { + lines.push(layout.line(` ${theme.fg("dim", "…")}`)); + } return lines; } @@ -356,29 +376,18 @@ function renderKeymapsTab( const lines: string[] = []; const compact = layout.innerW < COMPACT_WIDTH; const { customizable, fixed } = getAskKeymaps(config); - const headerLines = [ - "Edit this config file to change customizable ask keymaps:", - configPath, - "Restart pi or run /reload after editing.", - ]; - for (const text of headerLines) { - for (const line of wrapText(text, layout.innerW - 2)) { - lines.push( - layout.line(theme.fg(text === configPath ? "accent" : "dim", line)) - ); - } - } - if (notice?.kind === "error") { - lines.push(layout.line()); - for (const line of wrapText( - "Using default keymaps this session because configured keymaps were rejected.", - layout.innerW - 2 - )) { - lines.push(layout.line(theme.fg("warning", line))); - } - } + pushKeymapHeaderLines(lines, theme, layout, configPath, compact); + pushInvalidKeymapNotice(lines, theme, layout, notice, compact); lines.push(layout.line()); + if (compact) { + lines.push( + ...renderCompactKeymapSection(theme, layout, "Customizable", customizable) + ); + lines.push(layout.line()); + lines.push(...renderCompactKeymapSection(theme, layout, "Fixed", fixed)); + return lines; + } lines.push(layout.line(theme.fg("muted", "Customizable"))); pushKeymapRows(lines, theme, layout, customizable, compact); lines.push( @@ -389,6 +398,98 @@ function renderKeymapsTab( return lines; } +function pushKeymapHeaderLines( + lines: string[], + theme: Theme, + layout: SettingsLayout, + configPath: string, + compact: boolean +) { + const headerLines = compact + ? [configPath, "Edit keymaps there, then /reload."] + : [ + "Edit this config file to change customizable ask keymaps:", + configPath, + "Restart pi or run /reload after editing.", + ]; + + for (const text of headerLines) { + for (const line of wrapText(text, layout.innerW - 2)) { + lines.push( + layout.line(theme.fg(text === configPath ? "accent" : "dim", line)) + ); + } + } +} + +function pushInvalidKeymapNotice( + lines: string[], + theme: Theme, + layout: SettingsLayout, + notice: AskConfigNotice | undefined, + compact: boolean +) { + if (notice?.kind !== "error") { + return; + } + + const warningText = compact + ? "Invalid config; using default keymaps this session." + : "Using default keymaps this session because configured keymaps were rejected."; + for (const line of wrapText(warningText, layout.innerW - 2)) { + lines.push(layout.line(theme.fg("warning", line))); + } +} + +function renderCompactKeymapSection( + theme: Theme, + layout: SettingsLayout, + title: string, + keymaps: readonly AskKeyBinding[] +): string[] { + const summary = keymaps + .map((hint) => `${hint.label} ${compactKeymapDescription(hint)}`) + .join(" · "); + const lines = [layout.line(theme.fg("muted", title))]; + for (const line of wrapText(summary, layout.innerW - 2)) { + lines.push(layout.line(line)); + } + return lines; +} + +function compactKeymapDescription(hint: AskKeyBinding): string { + switch (hint.id) { + case "cancel": + return "cancel"; + case "dismiss": + return "dismiss"; + case "toggle": + return "toggle"; + case "confirm": + return "confirm"; + case "optionNote": + return "option note"; + case "questionNote": + return "question note"; + case "settings": + return "settings"; + case "nextTab": + return "next tab"; + case "previousTab": + return "prev tab"; + case "previousOption": + return "move"; + case "nextOption": + return "next"; + case "numberShortcut": + return "quick select"; + case "fileReference": + return "file refs"; + default: + return hint.description.toLowerCase(); + } +} + function pushKeymapRows( lines: string[], theme: Theme, diff --git a/src/ui/settings-state.ts b/src/ui/settings-state.ts index 8ab98a9..92d6dcc 100644 --- a/src/ui/settings-state.ts +++ b/src/ui/settings-state.ts @@ -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 && diff --git a/tests/dismiss-guard.test.ts b/tests/dismiss-guard.test.ts index c1bb447..491b970 100644 --- a/tests/dismiss-guard.test.ts +++ b/tests/dismiss-guard.test.ts @@ -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 ); }); diff --git a/tests/render-frame.test.ts b/tests/render-frame.test.ts index aec2d0d..873d1c1 100644 --- a/tests/render-frame.test.ts +++ b/tests/render-frame.test.ts @@ -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", () => { diff --git a/tests/render-submit.test.ts b/tests/render-submit.test.ts index b0abb7b..b3495ae 100644 --- a/tests/render-submit.test.ts +++ b/tests/render-submit.test.ts @@ -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: [ diff --git a/tests/review-shortcuts.test.ts b/tests/review-shortcuts.test.ts new file mode 100644 index 0000000..559dccb --- /dev/null +++ b/tests/review-shortcuts.test.ts @@ -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."); +}); diff --git a/tests/settings-modal.test.ts b/tests/settings-modal.test.ts index d0740d1..c2d0cb7 100644 --- a/tests/settings-modal.test.ts +++ b/tests/settings-modal.test.ts @@ -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);