diff --git a/README.md b/README.md index 57688f2..85450df 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Ask the agent to elaborate on notes before finalizing choices. | Key | Context | Effect | |-----------------------------|-----------------------------------------|---------------------------------------------| +| `?` | Ask flow / empty editor | Open key bindings modal | | `Tab` `Shift+Tab` | Main flow | Switch tabs | | `←` `→` | Main flow | Switch tabs | | `↑` `↓` | Main flow | Move cursor | diff --git a/docs/contract.md b/docs/contract.md index 78ae08a..48cd4d9 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -205,6 +205,7 @@ This document defines the stable external behavior. It does not explain internal Main flow: +- `?`: open the key bindings modal - `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs - `Up`, `Down`: move between options - `Enter`: confirm or submit @@ -220,6 +221,7 @@ Editing flow: - `Enter`: save and submit the current input - `Esc`: save draft and close the editor - `Ctrl+C`: dismiss the entire flow immediately without saving the current editor draft +- `?`: open the key bindings modal when the editor is empty; otherwise enter `?` as text - when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing - when editor is empty, `Up`/`Down` move options and `Tab`/`Shift+Tab`/`Left`/`Right` move between tabs without requiring `Esc` first - navigation resumes only after closing the editor with `Esc`, unless the editor is empty and the navigation keys above are used diff --git a/src/ask-tool-helpers.ts b/src/ask-tool-helpers.ts index 92d397a..78dc0c1 100644 --- a/src/ask-tool-helpers.ts +++ b/src/ask-tool-helpers.ts @@ -1,5 +1,6 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { UI_DIMENSIONS } from "./constants/ui.ts"; import { renderResultText } from "./result.ts"; import { createInitialState } from "./state/create.ts"; import { collectValidationIssues } from "./state/normalize.ts"; @@ -10,7 +11,6 @@ import type { AskResult, AskValidationIssue, } from "./types.ts"; -import { UI_DIMENSIONS } from "./ui/constants.ts"; export const ASK_TOOL_DESCRIPTION = "Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. Supports single-select, multi-select, and preview-pane questions. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough."; diff --git a/src/constants/keymaps.ts b/src/constants/keymaps.ts new file mode 100644 index 0000000..c4768be --- /dev/null +++ b/src/constants/keymaps.ts @@ -0,0 +1,247 @@ +import { Key, matchesKey } from "@mariozechner/pi-tui"; + +const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/; + +type KeyId = Parameters[1]; +type InputKey = KeyId | (string & {}); + +export type AskKeyBindingKind = "command" | "affordance"; + +export interface AskKeyBinding { + contexts: readonly string[]; + description: string; + id: string; + keys: readonly InputKey[]; + kind: AskKeyBindingKind; + label: string; +} + +export type FooterKeymapContext = + | "default" + | "multi" + | "submit" + | "input" + | "note"; + +export const ASK_KEY_BINDINGS = { + help: { + id: "help", + keys: ["?"], + kind: "command", + label: "?", + description: "Show this menu", + contexts: ["Main flow"], + }, + cancel: { + id: "cancel", + keys: [Key.escape], + kind: "command", + label: "Esc", + description: "Cancel flow or close/save editor draft", + contexts: ["Main flow", "Editors"], + }, + dismiss: { + id: "dismiss", + keys: [Key.ctrl("c")], + kind: "command", + label: "Ctrl+C", + description: "Dismiss entire ask flow immediately", + contexts: ["Anywhere"], + }, + nextTab: { + id: "nextTab", + keys: [Key.tab, Key.right], + kind: "command", + label: "Tab / →", + description: "Switch to next tab", + contexts: ["Main flow", "Empty editor"], + }, + previousTab: { + id: "previousTab", + keys: [Key.shift("tab"), Key.left], + kind: "command", + label: "Shift+Tab / ←", + description: "Switch to previous tab", + contexts: ["Main flow", "Empty editor"], + }, + previousOption: { + id: "previousOption", + keys: [Key.up], + kind: "command", + label: "↑ / ↓", + description: "Move between options/actions", + contexts: ["Main flow", "Empty editor"], + }, + nextOption: { + id: "nextOption", + keys: [Key.down], + kind: "command", + label: "↓", + description: "Move to next option/action", + contexts: ["Main flow", "Empty editor"], + }, + numberShortcut: { + id: "numberShortcut", + keys: ["1..9"], + kind: "command", + label: "1..9", + description: "Quick-select option or submit action", + contexts: ["Main flow", "Submit tab"], + }, + toggle: { + id: "toggle", + keys: [Key.space], + kind: "command", + label: "Space", + description: "Toggle selected option", + contexts: ["Multi-select questions"], + }, + confirm: { + id: "confirm", + keys: [Key.enter], + kind: "command", + label: "Enter", + description: "Confirm selection, continue, save, or submit", + contexts: ["Main flow", "Editors"], + }, + optionNote: { + id: "optionNote", + keys: ["n"], + kind: "command", + label: "N", + description: "Edit selected option note", + contexts: ["Question tabs"], + }, + questionNote: { + id: "questionNote", + keys: ["N"], + kind: "command", + label: "Shift+N", + description: "Edit question note", + contexts: ["Question tabs"], + }, + fileReference: { + id: "fileReference", + keys: ["@"], + kind: "affordance", + label: "@", + description: "Reference files while typing answers or notes", + contexts: ["Editors"], + }, +} as const satisfies Record; + +export const ASK_KEYMAPS: readonly AskKeyBinding[] = [ + ASK_KEY_BINDINGS.help, + ASK_KEY_BINDINGS.cancel, + ASK_KEY_BINDINGS.dismiss, + ASK_KEY_BINDINGS.nextTab, + ASK_KEY_BINDINGS.previousTab, + ASK_KEY_BINDINGS.previousOption, + ASK_KEY_BINDINGS.numberShortcut, + ASK_KEY_BINDINGS.toggle, + ASK_KEY_BINDINGS.confirm, + ASK_KEY_BINDINGS.optionNote, + ASK_KEY_BINDINGS.questionNote, + ASK_KEY_BINDINGS.fileReference, +] as const; + +const footerHint = ( + binding: AskKeyBinding, + action: string, + label = binding.label +): string => `${label} ${action}`; +const optionNavigationLabel = ASK_KEY_BINDINGS.previousOption.label.replaceAll( + " / ", + "" +); +const tabNavigationLabel = "⇆"; +const noteNavigationLabel = `${ASK_KEY_BINDINGS.optionNote.label}/${ASK_KEY_BINDINGS.questionNote.label}`; + +export const ASK_FOOTER_HINTS_BY_CONTEXT: Record< + FooterKeymapContext, + readonly string[] +> = { + input: [ + footerHint(ASK_KEY_BINDINGS.confirm, "submit"), + footerHint(ASK_KEY_BINDINGS.cancel, "close"), + footerHint(ASK_KEY_BINDINGS.help, "help"), + ], + note: [ + footerHint(ASK_KEY_BINDINGS.confirm, "save"), + footerHint(ASK_KEY_BINDINGS.cancel, "close"), + footerHint(ASK_KEY_BINDINGS.help, "help"), + ], + submit: [ + footerHint(ASK_KEY_BINDINGS.numberShortcut, "hotkeys"), + footerHint( + ASK_KEY_BINDINGS.previousOption, + "select", + optionNavigationLabel + ), + footerHint(ASK_KEY_BINDINGS.confirm, "confirm"), + footerHint(ASK_KEY_BINDINGS.cancel, "cancel"), + footerHint(ASK_KEY_BINDINGS.help, "help"), + ], + multi: [ + footerHint(ASK_KEY_BINDINGS.nextTab, "tab", tabNavigationLabel), + footerHint( + ASK_KEY_BINDINGS.previousOption, + "select", + optionNavigationLabel + ), + footerHint(ASK_KEY_BINDINGS.toggle, "toggle"), + footerHint(ASK_KEY_BINDINGS.confirm, "continue"), + footerHint(ASK_KEY_BINDINGS.optionNote, "note", noteNavigationLabel), + footerHint(ASK_KEY_BINDINGS.cancel, "dismiss"), + footerHint(ASK_KEY_BINDINGS.help, "help"), + ], + default: [ + footerHint(ASK_KEY_BINDINGS.nextTab, "tab", tabNavigationLabel), + footerHint( + ASK_KEY_BINDINGS.previousOption, + "select", + optionNavigationLabel + ), + footerHint(ASK_KEY_BINDINGS.confirm, "confirm"), + footerHint(ASK_KEY_BINDINGS.optionNote, "note", noteNavigationLabel), + footerHint(ASK_KEY_BINDINGS.cancel, "dismiss"), + footerHint(ASK_KEY_BINDINGS.help, "help"), + ], +} as const; + +export const ASK_HELP_CLOSE_BINDINGS = [ + ASK_KEY_BINDINGS.cancel, + ASK_KEY_BINDINGS.dismiss, + ASK_KEY_BINDINGS.help, +] as const; + +export const ASK_HELP_CLOSE_HINT = `Press ${ASK_HELP_CLOSE_BINDINGS.map( + (binding) => binding.label +) + .map((label, index, labels) => { + if (index === 0) { + return label; + } + if (index === labels.length - 1) { + return `or ${label}`; + } + return label; + }) + .join(", ")} to close.`; + +export function renderFooterKeymaps(context: FooterKeymapContext): string { + return ` ${ASK_FOOTER_HINTS_BY_CONTEXT[context].join(" · ")}`; +} + +export function matchesBinding(data: string, binding: AskKeyBinding): boolean { + return binding.keys.some((key) => { + if (key.length === 1) { + return data === key; + } + return matchesKey(data, key as KeyId); + }); +} + +export function matchesDigitShortcut(data: string): number | null { + return DIGIT_SHORTCUT_PATTERN.test(data) ? Number(data) : null; +} diff --git a/src/constants.ts b/src/constants/text.ts similarity index 100% rename from src/constants.ts rename to src/constants/text.ts diff --git a/src/ui/constants.ts b/src/constants/ui.ts similarity index 59% rename from src/ui/constants.ts rename to src/constants/ui.ts index d209b72..177470c 100644 --- a/src/ui/constants.ts +++ b/src/constants/ui.ts @@ -17,13 +17,6 @@ export const UI_TEXT = { questionNoteTitle: "Note:", reviewTitle: "Review answers", unanswered: "→ unanswered", - footerInput: " Enter submit · Esc close", - footerNote: " Enter save · Esc close", - footerSubmit: " 1..3 hotkeys · ↑↓ select · Enter confirm · Esc cancel", - footerMulti: - " ⇆ tab · ↑↓ select · Space toggle · Enter continue · N/Shift+N note · Esc dismiss", - footerDefault: - " ⇆ tab · ↑↓ select · Enter confirm · N/Shift+N note · Esc dismiss", editorPlaceholderInput: "Type answer...", editorPlaceholderNote: "Add a note...", } as const; diff --git a/src/result-format.ts b/src/result-format.ts index de454e4..d60960e 100644 --- a/src/result-format.ts +++ b/src/result-format.ts @@ -1,4 +1,4 @@ -import { ELABORATED_SUMMARY } from "./constants.ts"; +import { ELABORATED_SUMMARY } from "./constants/text.ts"; import { isCustomOnlyAnswer } from "./state/answers.ts"; import type { AskResult } from "./types.ts"; diff --git a/src/result.ts b/src/result.ts index 494121a..b0e3418 100644 --- a/src/result.ts +++ b/src/result.ts @@ -2,7 +2,7 @@ import { CANCELLED_RESULT_TEXT, ELABORATED_RESULT_TEXT, SUBMITTED_RESULT_TEXT, -} from "./constants.ts"; +} from "./constants/text.ts"; import { formatElaborationLines, formatResultLines } from "./result-format.ts"; import type { AskResult } from "./types.ts"; diff --git a/src/state/result.ts b/src/state/result.ts index 79d99df..0ab9398 100644 --- a/src/state/result.ts +++ b/src/state/result.ts @@ -3,7 +3,7 @@ import { ELABORATED_SUMMARY, ELABORATION_INSTRUCTION, SUBMITTED_SUMMARY, -} from "../constants.ts"; +} from "../constants/text.ts"; import { formatElaborationLines, formatResultLines } from "../result-format.ts"; import type { AskContinuationPayload, diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 71ff34e..6897290 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -1,4 +1,4 @@ -import { OTHER_OPTION_LABEL, OTHER_OPTION_VALUE } from "../constants.ts"; +import { OTHER_OPTION_LABEL, OTHER_OPTION_VALUE } from "../constants/text.ts"; import type { AskDisplayOption, AskQuestion, diff --git a/src/ui/controller.ts b/src/ui/controller.ts index 4407555..54b0535 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -27,6 +27,7 @@ import { import { isEditingView } from "../state/view.ts"; import type { AskParams, AskResult, AskState } from "../types.ts"; import { createAskAutocompleteProvider } from "./autocomplete.ts"; +import { AskHelpModal } from "./help-modal.ts"; import type { AskInputCommand } from "./input.ts"; import { getInputCommand } from "./input.ts"; import { renderAskScreen } from "./render.ts"; @@ -39,11 +40,14 @@ type Tui = CustomCallbackArgs[0]; type Theme = CustomCallbackArgs[1]; type Keybindings = CustomCallbackArgs[2]; type Done = (result: AskResult) => void; -type AskFlowParams = AskParams & Pick; +type AskFlowParams = AskParams & + Pick & { ctx: ExtensionContext }; interface AskFlowController { + ctx: ExtensionContext; done: Done; editor: Editor; + helpOpen: boolean; state: AskState; suppressAutoInputForSelection: boolean; theme: Theme; @@ -55,7 +59,7 @@ export function runAskFlow( params: AskParams ): Promise { return ctx.ui.custom((...args) => - createAskFlowController(args, { ...params, cwd: ctx.cwd }) + createAskFlowController(args, { ...params, cwd: ctx.cwd, ctx }) ); } @@ -69,8 +73,10 @@ function createAskFlowController( params: AskFlowParams ) { const controller: AskFlowController = { + ctx: params.ctx, done, editor: createEditor(tui, theme, params.cwd), + helpOpen: false, state: createInitialState(params), suppressAutoInputForSelection: false, theme, @@ -125,6 +131,10 @@ function handleEditingCommand( commitState(controller, dismissFlow(controller.state), { finish: true }); return; } + if (command.kind === "showHelp") { + showHelpModal(controller); + return; + } if (command.kind === "editMoveTab") { commitSavedEditorNavigation(controller, moveTab, command.delta); return; @@ -180,6 +190,9 @@ function handleNavigationCommand( case "dismiss": commitState(controller, dismissFlow(controller.state), { finish: true }); return; + case "showHelp": + showHelpModal(controller); + return; case "ignore": case "editMoveTab": case "editMoveOption": @@ -270,6 +283,34 @@ function closeEditor(controller: AskFlowController) { refresh(controller); } +function showHelpModal(controller: AskFlowController) { + if (controller.helpOpen) { + return; + } + controller.helpOpen = true; + controller.ctx.ui + .custom( + (_tui, _theme, _keybindings, done) => + new AskHelpModal(controller.theme, () => { + done(); + }), + { + overlay: true, + overlayOptions: { + anchor: "center", + margin: 1, + maxHeight: "90%", + minWidth: 26, + width: 72, + }, + } + ) + .finally(() => { + controller.helpOpen = false; + refresh(controller); + }); +} + function refresh(controller: AskFlowController) { controller.tui.requestRender(); } diff --git a/src/ui/help-modal.ts b/src/ui/help-modal.ts new file mode 100644 index 0000000..dcb2571 --- /dev/null +++ b/src/ui/help-modal.ts @@ -0,0 +1,101 @@ +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { + ASK_HELP_CLOSE_BINDINGS, + ASK_HELP_CLOSE_HINT, + ASK_KEYMAPS, + matchesBinding, +} from "../constants/keymaps.ts"; +import { wrapText } from "../text.ts"; + +interface Theme { + bg(color: string, text: string): string; + fg(color: string, text: string): string; +} + +const MIN_WIDTH = 24; +const COMPACT_WIDTH = 46; + +function padToWidth(text: string, width: number): string { + return text + " ".repeat(Math.max(0, width - visibleWidth(text))); +} + +export class AskHelpModal { + private closed = false; + private readonly onClose: () => void; + private readonly theme: Theme; + + constructor(theme: Theme, onClose: () => void) { + this.theme = theme; + this.onClose = onClose; + } + + handleInput(data: string): void { + if ( + ASK_HELP_CLOSE_BINDINGS.some((binding) => matchesBinding(data, binding)) + ) { + this.close(); + } + } + + render(width: number): string[] { + const innerW = Math.max(MIN_WIDTH, width - 2); + const compact = innerW < COMPACT_WIDTH; + const keyW = compact ? 11 : 16; + const descW = Math.max(8, innerW - keyW - 5); + const border = (text: string) => this.theme.fg("border", text); + const title = this.theme.fg("accent", " @eko24ive/pi-ask "); + const titleW = visibleWidth(title); + const titlePad = Math.max(0, innerW - titleW); + const left = Math.floor(titlePad / 2); + const right = titlePad - left; + const line = (content = "") => + `${border("│")}${padToWidth(` ${content}`, innerW)}${border("│")}`; + + const lines = [ + `${border(`╭${"─".repeat(left)}`)}${title}${border(`${"─".repeat(right)}╮`)}`, + `${border(`├${"─".repeat(innerW)}┤`)}`, + ]; + + for (const hint of ASK_KEYMAPS) { + if (compact) { + lines.push(line(this.theme.fg("accent", hint.label))); + for (const descLine of wrapText(hint.description, innerW - 3)) { + lines.push(line(` ${this.theme.fg("text", descLine)}`)); + } + continue; + } + + const keyText = padToWidth(truncateToWidth(hint.label, keyW, "…"), keyW); + const key = this.theme.fg("accent", keyText); + const desc = truncateToWidth(hint.description, descW, "…"); + lines.push(line(`${key} ${desc}`)); + const contexts = hint.contexts.join(", "); + lines.push( + line(`${" ".repeat(keyW)} ${this.theme.fg("dim", contexts)}`) + ); + } + + lines.push(`${border(`├${"─".repeat(innerW)}┤`)}`); + for (const hintLine of wrapText(ASK_HELP_CLOSE_HINT, innerW - 2)) { + lines.push(line(this.theme.fg("muted", hintLine))); + } + lines.push(`${border(`╰${"─".repeat(innerW)}╯`)}`); + return lines; + } + + invalidate(): void { + // Static modal; no async invalidation needed. + } + + dispose(): void { + this.close(); + } + + private close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.onClose(); + } +} diff --git a/src/ui/input.ts b/src/ui/input.ts index e673b2d..3e36f4a 100644 --- a/src/ui/input.ts +++ b/src/ui/input.ts @@ -1,8 +1,10 @@ -import { Key, matchesKey } from "@mariozechner/pi-tui"; +import { + ASK_KEY_BINDINGS, + matchesBinding, + matchesDigitShortcut, +} from "../constants/keymaps.ts"; import type { AskState } from "../types.ts"; -const DIGIT_PATTERN = /^[1-9]$/; - export type AskInputCommand = | { kind: "moveTab"; delta: 1 | -1 } | { kind: "moveOption"; delta: 1 | -1 } @@ -12,6 +14,7 @@ export type AskInputCommand = | { kind: "confirm" } | { kind: "cancel" } | { kind: "dismiss" } + | { kind: "showHelp" } | { kind: "numberShortcut"; digit: number } | { kind: "editMoveTab"; delta: 1 | -1 } | { kind: "editMoveOption"; delta: 1 | -1 } @@ -24,9 +27,12 @@ export function getInputCommand( data: string, editingText = "" ): AskInputCommand { - if (matchesKey(data, Key.ctrl("c"))) { + if (matchesBinding(data, ASK_KEY_BINDINGS.dismiss)) { return { kind: "dismiss" }; } + if (matchesBinding(data, ASK_KEY_BINDINGS.help) && editingText.length === 0) { + return { kind: "showHelp" }; + } if (state.view.kind === "input" || state.view.kind === "note") { return getEditingInputCommand(data, editingText); @@ -51,20 +57,20 @@ function getEditingInputCommand( data: string, editingText: string ): AskInputCommand { - if (matchesKey(data, Key.escape)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) { return { kind: "editClose" }; } if (editingText.length === 0) { - if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) { return { kind: "editMoveTab", delta: 1 }; } - if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) { return { kind: "editMoveTab", delta: -1 }; } - if (matchesKey(data, Key.up)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) { return { kind: "editMoveOption", delta: -1 }; } - if (matchesKey(data, Key.down)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) { return { kind: "editMoveOption", delta: 1 }; } } @@ -72,48 +78,36 @@ function getEditingInputCommand( } function getNavigationInputCommand(data: string): AskInputCommand { - if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) { return { kind: "moveTab", delta: 1 }; } - if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) { return { kind: "moveTab", delta: -1 }; } - if (matchesKey(data, Key.up)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) { return { kind: "moveOption", delta: -1 }; } - if (matchesKey(data, Key.down)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) { return { kind: "moveOption", delta: 1 }; } - if (matchesKey(data, Key.space)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.toggle)) { return { kind: "toggleMulti" }; } - if (matchesKey(data, Key.enter)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.confirm)) { return { kind: "confirm" }; } - if (matchesKey(data, Key.escape)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) { return { kind: "cancel" }; } - if (isQuestionNoteShortcut(data)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.questionNote)) { return { kind: "openQuestionNote" }; } - if (isOptionNoteShortcut(data)) { + if (matchesBinding(data, ASK_KEY_BINDINGS.optionNote)) { return { kind: "openOptionNote" }; } - const digit = parseDigit(data); + const digit = matchesDigitShortcut(data); return digit === null ? { kind: "ignore" } : { kind: "numberShortcut", digit }; } - -function isOptionNoteShortcut(data: string): boolean { - return data === "n"; -} - -function isQuestionNoteShortcut(data: string): boolean { - return data === "N"; -} - -function parseDigit(data: string): number | null { - return DIGIT_PATTERN.test(data) ? Number(data) : null; -} diff --git a/src/ui/render-helpers.ts b/src/ui/render-helpers.ts index 19c2394..a484094 100644 --- a/src/ui/render-helpers.ts +++ b/src/ui/render-helpers.ts @@ -1,9 +1,13 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import { NO_PREVIEW_TEXT } from "../constants.ts"; +import { + type FooterKeymapContext, + renderFooterKeymaps, +} from "../constants/keymaps.ts"; +import { NO_PREVIEW_TEXT } from "../constants/text.ts"; +import { UI_DIMENSIONS, UI_TEXT } from "../constants/ui.ts"; import { clamp } from "../math.ts"; import { wrapText } from "../text.ts"; -import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts"; type Theme = ExtensionContext["ui"]["theme"]; type ThemeColor = @@ -368,21 +372,8 @@ export function pushSavedNote(args: { ); } -export function renderFooterText( - mode: "input" | "note" | "submit" | "multi" | "default" -) { - switch (mode) { - case "input": - return UI_TEXT.footerInput; - case "note": - return UI_TEXT.footerNote; - case "submit": - return UI_TEXT.footerSubmit; - case "multi": - return UI_TEXT.footerMulti; - default: - return UI_TEXT.footerDefault; - } +export function renderFooterText(mode: FooterKeymapContext): string { + return renderFooterKeymaps(mode); } function padToVisibleWidth(text: string, width: number): string { diff --git a/src/ui/render-question.ts b/src/ui/render-question.ts index fef880c..baeac17 100644 --- a/src/ui/render-question.ts +++ b/src/ui/render-question.ts @@ -1,5 +1,5 @@ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import { UI_DIMENSIONS } from "./constants.ts"; +import { UI_DIMENSIONS } from "../constants/ui.ts"; import { measurePreviewLeftWidth, mergeColumns, diff --git a/src/ui/render-submit.ts b/src/ui/render-submit.ts index f33ab7f..ca04289 100644 --- a/src/ui/render-submit.ts +++ b/src/ui/render-submit.ts @@ -1,6 +1,6 @@ import { truncateToWidth } from "@mariozechner/pi-tui"; +import { UI_TEXT } from "../constants/ui.ts"; import type { AskState } from "../types.ts"; -import { UI_TEXT } from "./constants.ts"; import { mergeColumns, pushSavedNote, diff --git a/src/ui/view-models/question.ts b/src/ui/view-models/question.ts index f25916b..490beb4 100644 --- a/src/ui/view-models/question.ts +++ b/src/ui/view-models/question.ts @@ -1,3 +1,4 @@ +import { UI_DIMENSIONS, UI_TEXT } from "../../constants/ui.ts"; import { isOptionSelected } from "../../state/answers.ts"; import { getAnswer, @@ -8,7 +9,6 @@ import { isQuestionNoteOpen, } from "../../state/selectors.ts"; import type { AskDisplayOption } from "../../types.ts"; -import { UI_DIMENSIONS, UI_TEXT } from "../constants.ts"; import type { QuestionRenderContext } from "../render-types.ts"; export type QuestionNoteModel = diff --git a/src/ui/view-models/review.ts b/src/ui/view-models/review.ts index 49e65d7..6e552f1 100644 --- a/src/ui/view-models/review.ts +++ b/src/ui/view-models/review.ts @@ -1,5 +1,6 @@ import { visibleWidth } from "@mariozechner/pi-tui"; -import { SUBMIT_CHOICES } from "../../constants.ts"; +import { SUBMIT_CHOICES } from "../../constants/text.ts"; +import { UI_DIMENSIONS } from "../../constants/ui.ts"; import { isCustomOnlyAnswer } from "../../state/answers.ts"; import { type ReviewAnswer, @@ -7,7 +8,6 @@ import { toReviewAnswer, } from "../../state/result.ts"; import type { AskState } from "../../types.ts"; -import { UI_DIMENSIONS } from "../constants.ts"; export interface ReviewSelectionModel { label: string; diff --git a/tests/help-modal.test.ts b/tests/help-modal.test.ts new file mode 100644 index 0000000..4207b64 --- /dev/null +++ b/tests/help-modal.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { visibleWidth } from "@mariozechner/pi-tui"; +import { ASK_HELP_CLOSE_HINT, ASK_KEYMAPS } from "../src/constants/keymaps.ts"; +import { AskHelpModal } from "../src/ui/help-modal.ts"; + +const noop = () => { + // Test callback intentionally does nothing. +}; + +function plainTheme() { + return { + fg(_color: string, text: string) { + return text; + }, + bg(_color: string, text: string) { + return text; + }, + }; +} + +test("help modal renders compactly on phone-width screens", () => { + const modal = new AskHelpModal(plainTheme(), noop); + const lines = modal.render(28); + + assert(lines.length > ASK_KEYMAPS.length + 4); + assert(lines.every((line) => visibleWidth(line) <= 28)); + assert(lines.join("\n").includes("@eko24ive/pi-ask")); + assert(lines.slice(-3, -1).join(" ").includes("Esc, Ctrl+C, or ?")); + assert(lines.includes("│ ? │")); + assert(lines.includes("│ Show this menu │")); +}); + +test("help modal keeps context rows on wider screens", () => { + const modal = new AskHelpModal(plainTheme(), noop); + const lines = modal.render(72); + + assert(lines.length > ASK_KEYMAPS.length + 4); + assert(lines.join("\n").includes("Main flow")); + assert(lines.at(-2)?.includes(ASK_HELP_CLOSE_HINT)); +}); + +test("help modal closes from configured keyboard shortcuts", () => { + for (const input of ["?", "\u001b", "\u0003"]) { + let closed = 0; + const modal = new AskHelpModal(plainTheme(), () => { + closed += 1; + }); + + modal.handleInput(input); + + assert.equal(closed, 1); + } +}); + +test("help modal ignores enter and closes idempotently on dispose", () => { + let closed = 0; + const modal = new AskHelpModal(plainTheme(), () => { + closed += 1; + }); + + modal.handleInput("\r"); + assert.equal(closed, 0); + + modal.dispose(); + modal.dispose(); + assert.equal(closed, 1); +}); diff --git a/tests/input.test.ts b/tests/input.test.ts index ea6a00f..5bf9f43 100644 --- a/tests/input.test.ts +++ b/tests/input.test.ts @@ -164,6 +164,25 @@ test("ctrl+c dismisses the flow from both navigation and editing modes", () => { }); }); +test("question mark opens keymap help outside non-empty editors", () => { + const navigation = createInitialState({ + questions: [ + { + id: "q1", + prompt: "Question?", + options: [{ value: "a", label: "A" }], + }, + ], + }); + const input = inputState(); + + assert.deepEqual(getInputCommand(navigation, "?"), { kind: "showHelp" }); + assert.deepEqual(getInputCommand(input, "?", ""), { kind: "showHelp" }); + assert.deepEqual(getInputCommand(input, "?", "x"), { + kind: "delegateToEditor", + }); +}); + test("note shortcuts use n for option notes and Shift+N for question notes", () => { const navigation = createInitialState({ questions: [ diff --git a/tests/render-frame.test.ts b/tests/render-frame.test.ts index c41b374..8a3f7cb 100644 --- a/tests/render-frame.test.ts +++ b/tests/render-frame.test.ts @@ -223,7 +223,7 @@ test("footer hints wrap into exact lines on narrow screens", () => { "Space toggle", "Enter continue", "N/Shift+N note", - "Esc dismiss", + "Esc dismiss · ? help", ]); }); @@ -250,6 +250,6 @@ test("footer keeps earlier hint chunk on the first wrapped line", () => { assert.deepEqual(lines.slice(-4, -1), [ "Enter confirm", "N/Shift+N note", - "Esc dismiss", + "Esc dismiss · ? help", ]); }); diff --git a/tests/render-helpers.test.ts b/tests/render-helpers.test.ts index 5048119..796b3d0 100644 --- a/tests/render-helpers.test.ts +++ b/tests/render-helpers.test.ts @@ -74,6 +74,6 @@ test("renderEditorBlock reapplies background after editor reset sequences", () = }); test("editing footers do not advertise tab navigation", () => { - assert.equal(renderFooterText("input"), " Enter submit · Esc close"); - assert.equal(renderFooterText("note"), " Enter save · Esc close"); + assert.equal(renderFooterText("input"), " Enter submit · Esc close · ? help"); + assert.equal(renderFooterText("note"), " Enter save · Esc close · ? help"); }); diff --git a/tests/render-submit.test.ts b/tests/render-submit.test.ts index 4f4a94e..b0abb7b 100644 --- a/tests/render-submit.test.ts +++ b/tests/render-submit.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { UI_DIMENSIONS } from "../src/constants/ui.ts"; import { createInitialState } from "../src/state/create.ts"; import { applyNumberShortcut, @@ -7,7 +8,6 @@ import { enterQuestionNoteMode, saveNote, } from "../src/state/transitions.ts"; -import { UI_DIMENSIONS } from "../src/ui/constants.ts"; import { renderSubmitScreen } from "../src/ui/render-submit.ts"; function plainTheme() {