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
-29
View File
@@ -1,29 +0,0 @@
export const UI_DIMENSIONS = {
boxMinWidth: 10,
callLabelTruncateWidth: 50,
editorContentPadding: 5,
editorIndentedPadding: 7,
editorMinWidth: 8,
previewWideMinWidth: 90,
previewMinRightWidth: 24,
previewLeftMinWidth: 22,
previewLeftMaxWidth: 34,
previewLeftRatio: 0.34,
submitWideMinWidth: 64,
submitMinReviewWidth: 24,
} as const;
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;
+43 -2
View File
@@ -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<ExtensionContext, "cwd">;
type AskFlowParams = AskParams &
Pick<ExtensionContext, "cwd"> & { 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<AskResult> {
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
) {
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<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) {
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";
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;
}
+8 -17
View File
@@ -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 {
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 =
+2 -2
View File
@@ -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;