feat(ui): add ask keymap help modal
This commit is contained in:
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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,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,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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user