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