feat(ui): add ask keymap help modal

This commit is contained in:
Erik Kotsiuba
2026-04-28 22:35:43 +02:00
parent 3c26bfa7c9
commit c9930b494f
23 changed files with 529 additions and 72 deletions
+1
View File
@@ -93,6 +93,7 @@ Ask the agent to elaborate on notes before finalizing choices.
| Key | Context | Effect | | Key | Context | Effect |
|-----------------------------|-----------------------------------------|---------------------------------------------| |-----------------------------|-----------------------------------------|---------------------------------------------|
| `?` | Ask flow / empty editor | Open key bindings modal |
| `Tab` `Shift+Tab` | Main flow | Switch tabs | | `Tab` `Shift+Tab` | Main flow | Switch tabs |
| `←` `→` | Main flow | Switch tabs | | `←` `→` | Main flow | Switch tabs |
| `↑` `↓` | Main flow | Move cursor | | `↑` `↓` | Main flow | Move cursor |
+2
View File
@@ -205,6 +205,7 @@ This document defines the stable external behavior. It does not explain internal
Main flow: Main flow:
- `?`: open the key bindings modal
- `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
- `Enter`: confirm or submit - `Enter`: confirm or submit
@@ -220,6 +221,7 @@ Editing flow:
- `Enter`: save and submit the current input - `Enter`: save and submit the current input
- `Esc`: save draft and close the editor - `Esc`: save draft and close the editor
- `Ctrl+C`: dismiss the entire flow immediately without saving the current editor draft - `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 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 - 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 - navigation resumes only after closing the editor with `Esc`, unless the editor is empty and the navigation keys above are used
+1 -1
View File
@@ -1,5 +1,6 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Text, truncateToWidth } from "@mariozechner/pi-tui"; import { Text, truncateToWidth } from "@mariozechner/pi-tui";
import { UI_DIMENSIONS } from "./constants/ui.ts";
import { renderResultText } from "./result.ts"; import { renderResultText } from "./result.ts";
import { createInitialState } from "./state/create.ts"; import { createInitialState } from "./state/create.ts";
import { collectValidationIssues } from "./state/normalize.ts"; import { collectValidationIssues } from "./state/normalize.ts";
@@ -10,7 +11,6 @@ import type {
AskResult, AskResult,
AskValidationIssue, AskValidationIssue,
} from "./types.ts"; } from "./types.ts";
import { UI_DIMENSIONS } from "./ui/constants.ts";
export const ASK_TOOL_DESCRIPTION = 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."; "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.";
+247
View File
@@ -0,0 +1,247 @@
import { Key, matchesKey } from "@mariozechner/pi-tui";
const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/;
type KeyId = Parameters<typeof matchesKey>[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<string, AskKeyBinding>;
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;
}
@@ -17,13 +17,6 @@ export const UI_TEXT = {
questionNoteTitle: "Note:", questionNoteTitle: "Note:",
reviewTitle: "Review answers", reviewTitle: "Review answers",
unanswered: "→ unanswered", 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...", editorPlaceholderInput: "Type answer...",
editorPlaceholderNote: "Add a note...", editorPlaceholderNote: "Add a note...",
} as const; } as const;
+1 -1
View File
@@ -1,4 +1,4 @@
import { ELABORATED_SUMMARY } from "./constants.ts"; import { ELABORATED_SUMMARY } from "./constants/text.ts";
import { isCustomOnlyAnswer } from "./state/answers.ts"; import { isCustomOnlyAnswer } from "./state/answers.ts";
import type { AskResult } from "./types.ts"; import type { AskResult } from "./types.ts";
+1 -1
View File
@@ -2,7 +2,7 @@ import {
CANCELLED_RESULT_TEXT, CANCELLED_RESULT_TEXT,
ELABORATED_RESULT_TEXT, ELABORATED_RESULT_TEXT,
SUBMITTED_RESULT_TEXT, SUBMITTED_RESULT_TEXT,
} from "./constants.ts"; } from "./constants/text.ts";
import { formatElaborationLines, formatResultLines } from "./result-format.ts"; import { formatElaborationLines, formatResultLines } from "./result-format.ts";
import type { AskResult } from "./types.ts"; import type { AskResult } from "./types.ts";
+1 -1
View File
@@ -3,7 +3,7 @@ import {
ELABORATED_SUMMARY, ELABORATED_SUMMARY,
ELABORATION_INSTRUCTION, ELABORATION_INSTRUCTION,
SUBMITTED_SUMMARY, SUBMITTED_SUMMARY,
} from "../constants.ts"; } from "../constants/text.ts";
import { formatElaborationLines, formatResultLines } from "../result-format.ts"; import { formatElaborationLines, formatResultLines } from "../result-format.ts";
import type { import type {
AskContinuationPayload, AskContinuationPayload,
+1 -1
View File
@@ -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 { import type {
AskDisplayOption, AskDisplayOption,
AskQuestion, AskQuestion,
+43 -2
View File
@@ -27,6 +27,7 @@ import {
import { isEditingView } from "../state/view.ts"; import { isEditingView } from "../state/view.ts";
import type { AskParams, AskResult, AskState } from "../types.ts"; import type { AskParams, AskResult, AskState } from "../types.ts";
import { createAskAutocompleteProvider } from "./autocomplete.ts"; import { createAskAutocompleteProvider } from "./autocomplete.ts";
import { AskHelpModal } from "./help-modal.ts";
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";
@@ -39,11 +40,14 @@ type Tui = CustomCallbackArgs[0];
type Theme = CustomCallbackArgs[1]; type Theme = CustomCallbackArgs[1];
type Keybindings = CustomCallbackArgs[2]; type Keybindings = CustomCallbackArgs[2];
type Done = (result: AskResult) => void; type Done = (result: AskResult) => void;
type AskFlowParams = AskParams & Pick<ExtensionContext, "cwd">; type AskFlowParams = AskParams &
Pick<ExtensionContext, "cwd"> & { ctx: ExtensionContext };
interface AskFlowController { interface AskFlowController {
ctx: ExtensionContext;
done: Done; done: Done;
editor: Editor; editor: Editor;
helpOpen: boolean;
state: AskState; state: AskState;
suppressAutoInputForSelection: boolean; suppressAutoInputForSelection: boolean;
theme: Theme; theme: Theme;
@@ -55,7 +59,7 @@ export function runAskFlow(
params: AskParams params: AskParams
): Promise<AskResult> { ): Promise<AskResult> {
return ctx.ui.custom<AskResult>((...args) => return ctx.ui.custom<AskResult>((...args) =>
createAskFlowController(args, { ...params, cwd: ctx.cwd }) createAskFlowController(args, { ...params, cwd: ctx.cwd, ctx })
); );
} }
@@ -69,8 +73,10 @@ function createAskFlowController(
params: AskFlowParams params: AskFlowParams
) { ) {
const controller: AskFlowController = { const controller: AskFlowController = {
ctx: params.ctx,
done, done,
editor: createEditor(tui, theme, params.cwd), editor: createEditor(tui, theme, params.cwd),
helpOpen: false,
state: createInitialState(params), state: createInitialState(params),
suppressAutoInputForSelection: false, suppressAutoInputForSelection: false,
theme, theme,
@@ -125,6 +131,10 @@ function handleEditingCommand(
commitState(controller, dismissFlow(controller.state), { finish: true }); commitState(controller, dismissFlow(controller.state), { finish: true });
return; return;
} }
if (command.kind === "showHelp") {
showHelpModal(controller);
return;
}
if (command.kind === "editMoveTab") { if (command.kind === "editMoveTab") {
commitSavedEditorNavigation(controller, moveTab, command.delta); commitSavedEditorNavigation(controller, moveTab, command.delta);
return; return;
@@ -180,6 +190,9 @@ function handleNavigationCommand(
case "dismiss": case "dismiss":
commitState(controller, dismissFlow(controller.state), { finish: true }); commitState(controller, dismissFlow(controller.state), { finish: true });
return; return;
case "showHelp":
showHelpModal(controller);
return;
case "ignore": case "ignore":
case "editMoveTab": case "editMoveTab":
case "editMoveOption": case "editMoveOption":
@@ -270,6 +283,34 @@ function closeEditor(controller: AskFlowController) {
refresh(controller); refresh(controller);
} }
function showHelpModal(controller: AskFlowController) {
if (controller.helpOpen) {
return;
}
controller.helpOpen = true;
controller.ctx.ui
.custom<void>(
(_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) { function refresh(controller: AskFlowController) {
controller.tui.requestRender(); controller.tui.requestRender();
} }
+101
View File
@@ -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();
}
}
+25 -31
View File
@@ -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"; import type { AskState } from "../types.ts";
const DIGIT_PATTERN = /^[1-9]$/;
export type AskInputCommand = export type AskInputCommand =
| { kind: "moveTab"; delta: 1 | -1 } | { kind: "moveTab"; delta: 1 | -1 }
| { kind: "moveOption"; delta: 1 | -1 } | { kind: "moveOption"; delta: 1 | -1 }
@@ -12,6 +14,7 @@ export type AskInputCommand =
| { kind: "confirm" } | { kind: "confirm" }
| { kind: "cancel" } | { kind: "cancel" }
| { kind: "dismiss" } | { kind: "dismiss" }
| { kind: "showHelp" }
| { kind: "numberShortcut"; digit: number } | { kind: "numberShortcut"; digit: number }
| { kind: "editMoveTab"; delta: 1 | -1 } | { kind: "editMoveTab"; delta: 1 | -1 }
| { kind: "editMoveOption"; delta: 1 | -1 } | { kind: "editMoveOption"; delta: 1 | -1 }
@@ -24,9 +27,12 @@ export function getInputCommand(
data: string, data: string,
editingText = "" editingText = ""
): AskInputCommand { ): AskInputCommand {
if (matchesKey(data, Key.ctrl("c"))) { if (matchesBinding(data, ASK_KEY_BINDINGS.dismiss)) {
return { kind: "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") { if (state.view.kind === "input" || state.view.kind === "note") {
return getEditingInputCommand(data, editingText); return getEditingInputCommand(data, editingText);
@@ -51,20 +57,20 @@ function getEditingInputCommand(
data: string, data: string,
editingText: string editingText: string
): AskInputCommand { ): AskInputCommand {
if (matchesKey(data, Key.escape)) { if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) {
return { kind: "editClose" }; return { kind: "editClose" };
} }
if (editingText.length === 0) { 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 }; 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 }; return { kind: "editMoveTab", delta: -1 };
} }
if (matchesKey(data, Key.up)) { if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) {
return { kind: "editMoveOption", delta: -1 }; return { kind: "editMoveOption", delta: -1 };
} }
if (matchesKey(data, Key.down)) { if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) {
return { kind: "editMoveOption", delta: 1 }; return { kind: "editMoveOption", delta: 1 };
} }
} }
@@ -72,48 +78,36 @@ function getEditingInputCommand(
} }
function getNavigationInputCommand(data: string): AskInputCommand { 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 }; 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 }; return { kind: "moveTab", delta: -1 };
} }
if (matchesKey(data, Key.up)) { if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) {
return { kind: "moveOption", delta: -1 }; return { kind: "moveOption", delta: -1 };
} }
if (matchesKey(data, Key.down)) { if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) {
return { kind: "moveOption", delta: 1 }; return { kind: "moveOption", delta: 1 };
} }
if (matchesKey(data, Key.space)) { if (matchesBinding(data, ASK_KEY_BINDINGS.toggle)) {
return { kind: "toggleMulti" }; return { kind: "toggleMulti" };
} }
if (matchesKey(data, Key.enter)) { if (matchesBinding(data, ASK_KEY_BINDINGS.confirm)) {
return { kind: "confirm" }; return { kind: "confirm" };
} }
if (matchesKey(data, Key.escape)) { if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) {
return { kind: "cancel" }; return { kind: "cancel" };
} }
if (isQuestionNoteShortcut(data)) { if (matchesBinding(data, ASK_KEY_BINDINGS.questionNote)) {
return { kind: "openQuestionNote" }; return { kind: "openQuestionNote" };
} }
if (isOptionNoteShortcut(data)) { if (matchesBinding(data, ASK_KEY_BINDINGS.optionNote)) {
return { kind: "openOptionNote" }; return { kind: "openOptionNote" };
} }
const digit = parseDigit(data); const digit = matchesDigitShortcut(data);
return digit === null return digit === null
? { kind: "ignore" } ? { kind: "ignore" }
: { kind: "numberShortcut", digit }; : { 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;
}
+8 -17
View File
@@ -1,9 +1,13 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; 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 { clamp } from "../math.ts";
import { wrapText } from "../text.ts"; import { wrapText } from "../text.ts";
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
type Theme = ExtensionContext["ui"]["theme"]; type Theme = ExtensionContext["ui"]["theme"];
type ThemeColor = type ThemeColor =
@@ -368,21 +372,8 @@ export function pushSavedNote(args: {
); );
} }
export function renderFooterText( export function renderFooterText(mode: FooterKeymapContext): string {
mode: "input" | "note" | "submit" | "multi" | "default" return renderFooterKeymaps(mode);
) {
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;
}
} }
function padToVisibleWidth(text: string, width: number): string { function padToVisibleWidth(text: string, width: number): string {
+1 -1
View File
@@ -1,5 +1,5 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { UI_DIMENSIONS } from "./constants.ts"; import { UI_DIMENSIONS } from "../constants/ui.ts";
import { import {
measurePreviewLeftWidth, measurePreviewLeftWidth,
mergeColumns, mergeColumns,
+1 -1
View File
@@ -1,6 +1,6 @@
import { truncateToWidth } from "@mariozechner/pi-tui"; import { truncateToWidth } from "@mariozechner/pi-tui";
import { UI_TEXT } from "../constants/ui.ts";
import type { AskState } from "../types.ts"; import type { AskState } from "../types.ts";
import { UI_TEXT } from "./constants.ts";
import { import {
mergeColumns, mergeColumns,
pushSavedNote, pushSavedNote,
+1 -1
View File
@@ -1,3 +1,4 @@
import { UI_DIMENSIONS, UI_TEXT } from "../../constants/ui.ts";
import { isOptionSelected } from "../../state/answers.ts"; import { isOptionSelected } from "../../state/answers.ts";
import { import {
getAnswer, getAnswer,
@@ -8,7 +9,6 @@ import {
isQuestionNoteOpen, isQuestionNoteOpen,
} from "../../state/selectors.ts"; } from "../../state/selectors.ts";
import type { AskDisplayOption } from "../../types.ts"; import type { AskDisplayOption } from "../../types.ts";
import { UI_DIMENSIONS, UI_TEXT } from "../constants.ts";
import type { QuestionRenderContext } from "../render-types.ts"; import type { QuestionRenderContext } from "../render-types.ts";
export type QuestionNoteModel = export type QuestionNoteModel =
+2 -2
View File
@@ -1,5 +1,6 @@
import { visibleWidth } from "@mariozechner/pi-tui"; 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 { isCustomOnlyAnswer } from "../../state/answers.ts";
import { import {
type ReviewAnswer, type ReviewAnswer,
@@ -7,7 +8,6 @@ import {
toReviewAnswer, toReviewAnswer,
} from "../../state/result.ts"; } from "../../state/result.ts";
import type { AskState } from "../../types.ts"; import type { AskState } from "../../types.ts";
import { UI_DIMENSIONS } from "../constants.ts";
export interface ReviewSelectionModel { export interface ReviewSelectionModel {
label: string; label: string;
+68
View File
@@ -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);
});
+19
View File
@@ -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", () => { test("note shortcuts use n for option notes and Shift+N for question notes", () => {
const navigation = createInitialState({ const navigation = createInitialState({
questions: [ questions: [
+2 -2
View File
@@ -223,7 +223,7 @@ test("footer hints wrap into exact lines on narrow screens", () => {
"Space toggle", "Space toggle",
"Enter continue", "Enter continue",
"N/Shift+N note", "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), [ assert.deepEqual(lines.slice(-4, -1), [
"Enter confirm", "Enter confirm",
"N/Shift+N note", "N/Shift+N note",
"Esc dismiss", "Esc dismiss · ? help",
]); ]);
}); });
+2 -2
View File
@@ -74,6 +74,6 @@ test("renderEditorBlock reapplies background after editor reset sequences", () =
}); });
test("editing footers do not advertise tab navigation", () => { test("editing footers do not advertise tab navigation", () => {
assert.equal(renderFooterText("input"), " Enter submit · Esc close"); assert.equal(renderFooterText("input"), " Enter submit · Esc close · ? help");
assert.equal(renderFooterText("note"), " Enter save · Esc close"); assert.equal(renderFooterText("note"), " Enter save · Esc close · ? help");
}); });
+1 -1
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { UI_DIMENSIONS } from "../src/constants/ui.ts";
import { createInitialState } from "../src/state/create.ts"; import { createInitialState } from "../src/state/create.ts";
import { import {
applyNumberShortcut, applyNumberShortcut,
@@ -7,7 +8,6 @@ import {
enterQuestionNoteMode, enterQuestionNoteMode,
saveNote, saveNote,
} from "../src/state/transitions.ts"; } from "../src/state/transitions.ts";
import { UI_DIMENSIONS } from "../src/ui/constants.ts";
import { renderSubmitScreen } from "../src/ui/render-submit.ts"; import { renderSubmitScreen } from "../src/ui/render-submit.ts";
function plainTheme() { function plainTheme() {