feat: add config-backed ask keymaps

This commit is contained in:
Erik Kotsiuba
2026-04-28 22:35:43 +02:00
parent 5c0d8964fb
commit e8a36b72ef
31 changed files with 2192 additions and 422 deletions
+36
View File
@@ -0,0 +1,36 @@
import type { AskConfig } from "../config/schema.ts";
import { hasAnswerNotes } from "../state/answers.ts";
import { isQuestionAnswered, isSubmitTab } from "../state/selectors.ts";
import type { AskState } from "../types.ts";
export function maybeAutoSubmitState(
state: AskState,
config: AskConfig
): AskState {
if (!shouldAutoSubmit(state, config)) {
return state;
}
return {
...state,
completed: true,
mode: "submit",
};
}
export function shouldAutoSubmit(state: AskState, config: AskConfig): boolean {
if (!config.behaviour.autoSubmitWhenAnsweredWithoutNotes) {
return false;
}
if (state.completed || state.cancelled || !isSubmitTab(state)) {
return false;
}
for (const question of state.questions) {
if (!isQuestionAnswered(state, question.id)) {
return false;
}
if (hasAnswerNotes(state.answers[question.id])) {
return false;
}
}
return true;
}
+44 -14
View File
@@ -1,5 +1,7 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
import type { AskConfig } from "../config/schema.ts";
import { getAskConfigStore } from "../config/store.ts";
import { createInitialState } from "../state/create.ts";
import {
getEditorDraft,
@@ -26,6 +28,7 @@ import {
} from "../state/transitions.ts";
import { isEditingView } from "../state/view.ts";
import type { AskParams, AskResult, AskState } from "../types.ts";
import { maybeAutoSubmitState } from "./auto-submit.ts";
import { createAskAutocompleteProvider } from "./autocomplete.ts";
import type { AskInputCommand } from "./input.ts";
import { getInputCommand } from "./input.ts";
@@ -41,25 +44,29 @@ type Theme = CustomCallbackArgs[1];
type Keybindings = CustomCallbackArgs[2];
type Done = (result: AskResult) => void;
type AskFlowParams = AskParams &
Pick<ExtensionContext, "cwd"> & { ctx: ExtensionContext };
Pick<ExtensionContext, "cwd"> & { config: AskConfig; ctx: ExtensionContext };
interface AskFlowController {
config: AskConfig;
ctx: ExtensionContext;
done: Done;
editor: Editor;
helpOpen: boolean;
settingsOpen: boolean;
state: AskState;
suppressAutoInputForSelection: boolean;
theme: Theme;
tui: Tui;
unsubscribeConfig: () => void;
}
export function runAskFlow(
export async function runAskFlow(
ctx: ExtensionContext,
params: AskParams
): Promise<AskResult> {
const store = getAskConfigStore();
const config = await store.getConfig();
return ctx.ui.custom<AskResult>((...args) =>
createAskFlowController(args, { ...params, cwd: ctx.cwd, ctx })
createAskFlowController(args, { ...params, config, cwd: ctx.cwd, ctx })
);
}
@@ -73,16 +80,30 @@ function createAskFlowController(
params: AskFlowParams
) {
const controller: AskFlowController = {
config: params.config,
ctx: params.ctx,
done,
editor: createEditor(tui, theme, params.cwd),
helpOpen: false,
settingsOpen: false,
state: createInitialState(params),
suppressAutoInputForSelection: false,
theme,
tui,
unsubscribeConfig: () => {
// replaced immediately after controller creation
},
};
controller.unsubscribeConfig = getAskConfigStore().subscribe((config) => {
controller.config = config;
controller.state = maybeAutoSubmitState(
controller.state,
controller.config
);
refresh(controller);
maybeFinish(controller);
});
controller.editor.onSubmit = (value) => submitEditor(controller, value);
syncSelection(controller);
@@ -94,6 +115,9 @@ function createAskFlowController(
handleInput(data: string) {
handleControllerInput(controller, data);
},
dispose() {
controller.unsubscribeConfig();
},
};
}
@@ -102,6 +126,7 @@ function renderController(
width: number
): string[] {
return renderAskScreen({
config: controller.config,
editor: controller.editor,
state: controller.state,
theme: controller.theme,
@@ -112,6 +137,7 @@ function renderController(
function handleControllerInput(controller: AskFlowController, data: string) {
const command = getInputCommand(
controller.state,
controller.config,
data,
isEditingView(controller.state) ? controller.editor.getText() : ""
);
@@ -131,8 +157,8 @@ function handleEditingCommand(
commitState(controller, dismissFlow(controller.state), { finish: true });
return;
}
if (command.kind === "showHelp") {
showHelpModal(controller);
if (command.kind === "showSettings") {
showSettingsModal(controller);
return;
}
if (command.kind === "editMoveTab") {
@@ -190,8 +216,8 @@ function handleNavigationCommand(
case "dismiss":
commitState(controller, dismissFlow(controller.state), { finish: true });
return;
case "showHelp":
showHelpModal(controller);
case "showSettings":
showSettingsModal(controller);
return;
case "ignore":
case "editMoveTab":
@@ -253,6 +279,7 @@ function commitState(
if (options.syncSelection !== false) {
syncSelection(controller);
}
controller.state = maybeAutoSubmitState(controller.state, controller.config);
hydrateEditor(controller);
refresh(controller);
if (options.finish) {
@@ -262,7 +289,10 @@ function commitState(
function submitEditor(controller: AskFlowController, value: string) {
controller.suppressAutoInputForSelection = false;
controller.state = submitEditorDraft(controller.state, value);
controller.state = maybeAutoSubmitState(
submitEditorDraft(controller.state, value),
controller.config
);
controller.editor.setText("");
refresh(controller);
maybeFinish(controller);
@@ -283,13 +313,13 @@ function closeEditor(controller: AskFlowController) {
refresh(controller);
}
function showHelpModal(controller: AskFlowController) {
if (controller.helpOpen) {
function showSettingsModal(controller: AskFlowController) {
if (controller.settingsOpen) {
return;
}
controller.helpOpen = true;
controller.settingsOpen = true;
showAskSettingsModal(controller.ctx.ui).finally(() => {
controller.helpOpen = false;
controller.settingsOpen = false;
refresh(controller);
});
}
+29 -34
View File
@@ -1,5 +1,6 @@
import type { AskConfig } from "../config/schema.ts";
import {
ASK_KEY_BINDINGS,
getAskKeyBindings,
matchesBinding,
matchesDigitShortcut,
} from "../constants/keymaps.ts";
@@ -14,7 +15,7 @@ export type AskInputCommand =
| { kind: "confirm" }
| { kind: "cancel" }
| { kind: "dismiss" }
| { kind: "showHelp" }
| { kind: "showSettings" }
| { kind: "numberShortcut"; digit: number }
| { kind: "editMoveTab"; delta: 1 | -1 }
| { kind: "editMoveOption"; delta: 1 | -1 }
@@ -24,85 +25,79 @@ export type AskInputCommand =
export function getInputCommand(
state: AskState,
config: AskConfig,
data: string,
editingText = ""
): AskInputCommand {
if (matchesBinding(data, ASK_KEY_BINDINGS.dismiss)) {
const bindings = getAskKeyBindings(config);
if (matchesBinding(data, bindings.dismiss)) {
return { kind: "dismiss" };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.help) && editingText.length === 0) {
return { kind: "showHelp" };
if (matchesBinding(data, bindings.settings) && editingText.length === 0) {
return { kind: "showSettings" };
}
if (state.view.kind === "input" || state.view.kind === "note") {
return getEditingInputCommand(data, editingText);
return getEditingInputCommand(bindings, data, editingText);
}
return getNavigationInputCommand(data);
}
export function formatKeybindingLabel(key: string): string {
return key
.split("+")
.map((part) => {
if (part.length <= 1) {
return part.toUpperCase();
}
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join("+");
return getNavigationInputCommand(bindings, data);
}
function getEditingInputCommand(
bindings: ReturnType<typeof getAskKeyBindings>,
data: string,
editingText: string
): AskInputCommand {
if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) {
if (matchesBinding(data, bindings.cancel)) {
return { kind: "editClose" };
}
if (editingText.length === 0) {
if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) {
if (matchesBinding(data, bindings.nextTab)) {
return { kind: "editMoveTab", delta: 1 };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) {
if (matchesBinding(data, bindings.previousTab)) {
return { kind: "editMoveTab", delta: -1 };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) {
if (matchesBinding(data, bindings.previousOption)) {
return { kind: "editMoveOption", delta: -1 };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) {
if (matchesBinding(data, bindings.nextOption)) {
return { kind: "editMoveOption", delta: 1 };
}
}
return { kind: "delegateToEditor" };
}
function getNavigationInputCommand(data: string): AskInputCommand {
if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) {
function getNavigationInputCommand(
bindings: ReturnType<typeof getAskKeyBindings>,
data: string
): AskInputCommand {
if (matchesBinding(data, bindings.nextTab)) {
return { kind: "moveTab", delta: 1 };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) {
if (matchesBinding(data, bindings.previousTab)) {
return { kind: "moveTab", delta: -1 };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) {
if (matchesBinding(data, bindings.previousOption)) {
return { kind: "moveOption", delta: -1 };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) {
if (matchesBinding(data, bindings.nextOption)) {
return { kind: "moveOption", delta: 1 };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.toggle)) {
if (matchesBinding(data, bindings.toggle)) {
return { kind: "toggleMulti" };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.confirm)) {
if (matchesBinding(data, bindings.confirm)) {
return { kind: "confirm" };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) {
if (matchesBinding(data, bindings.cancel)) {
return { kind: "cancel" };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.questionNote)) {
if (matchesBinding(data, bindings.questionNote)) {
return { kind: "openQuestionNote" };
}
if (matchesBinding(data, ASK_KEY_BINDINGS.optionNote)) {
if (matchesBinding(data, bindings.optionNote)) {
return { kind: "openOptionNote" };
}
+16 -7
View File
@@ -1,4 +1,5 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import type { AskConfig } from "../config/schema.ts";
import {
getCurrentQuestion,
isQuestionAnswered,
@@ -28,14 +29,15 @@ export function renderFrameHeader(args: {
}
export function renderFrameFooter(args: {
config: AskConfig;
lines: string[];
state: AskState;
theme: Theme;
width: number;
}) {
const { lines, state, theme, width } = args;
const { config, lines, state, theme, width } = args;
const add = (text = "") => lines.push(truncateToWidth(text, width));
const footer = renderFooter(state, width);
const footer = renderFooter(config, state, width);
if (footer.length > 0) {
add();
for (const line of footer) {
@@ -178,17 +180,24 @@ function applyTabGrowth(
range.end = nextIndex;
}
function renderFooter(state: AskState, width: number): string[] {
function renderFooter(
config: AskConfig,
state: AskState,
width: number
): string[] {
let footer: string;
if (state.view.kind === "input") {
footer = renderFooterText("input");
footer = renderFooterText(config, "input");
} else if (state.view.kind === "note") {
footer = renderFooterText("note");
footer = renderFooterText(config, "note");
} else if (isSubmitTab(state)) {
footer = renderFooterText("submit");
footer = renderFooterText(config, "submit");
} else {
const question = getCurrentQuestion(state);
footer = renderFooterText(question?.type === "multi" ? "multi" : "default");
footer = renderFooterText(
config,
question?.type === "multi" ? "multi" : "default"
);
}
return wrapDelimitedFooterHints(footer, width);
}
+6 -2
View File
@@ -1,5 +1,6 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import type { AskConfig } from "../config/schema.ts";
import {
type FooterKeymapContext,
renderFooterKeymaps,
@@ -372,8 +373,11 @@ export function pushSavedNote(args: {
);
}
export function renderFooterText(mode: FooterKeymapContext): string {
return renderFooterKeymaps(mode);
export function renderFooterText(
config: AskConfig,
mode: FooterKeymapContext
): string {
return renderFooterKeymaps(config, mode);
}
function padToVisibleWidth(text: string, width: number): string {
+4 -2
View File
@@ -1,3 +1,4 @@
import type { AskConfig } from "../config/schema.ts";
import {
getCurrentQuestion,
getRenderableOptions,
@@ -10,12 +11,13 @@ import { renderSubmitScreen } from "./render-submit.ts";
import type { QuestionRenderContext, Theme } from "./render-types.ts";
export function renderAskScreen(args: {
config: AskConfig;
state: AskState;
theme: Theme;
width: number;
editor: QuestionRenderContext["editor"];
}): string[] {
const { state, theme, width, editor } = args;
const { config, state, theme, width, editor } = args;
const lines: string[] = [];
const question = getCurrentQuestion(state);
const options = getRenderableOptions(question);
@@ -36,6 +38,6 @@ export function renderAskScreen(args: {
});
}
renderFrameFooter({ lines, state, theme, width });
renderFrameFooter({ config, lines, state, theme, width });
return lines;
}
+330 -45
View File
@@ -1,81 +1,214 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import {
ASK_HELP_CLOSE_BINDINGS,
ASK_HELP_CLOSE_HINT,
ASK_KEY_BINDINGS,
ASK_KEYMAPS,
matchesBinding,
} from "../constants/keymaps.ts";
Key,
matchesKey,
truncateToWidth,
visibleWidth,
} from "@mariozechner/pi-tui";
import type { AskConfig } from "../config/schema.ts";
import type { AskConfigNotice } from "../config/store.ts";
import { type AskKeyBinding, getAskKeymaps } from "../constants/keymaps.ts";
import { wrapText } from "../text.ts";
type SettingsTab = "keymaps" | "behaviour";
import {
type AskSettingsState,
clearTransientNotice,
completeSave,
createAskSettingsState,
failSave,
isDirty,
moveBehaviourFocus,
moveSettingsTab,
requestDiscard,
setSaving,
shouldDiscard,
toggleAutoSubmit,
} from "./settings-state.ts";
interface Theme {
bg(color: string, text: string): string;
fg(color: string, text: string): string;
}
interface TuiLike {
requestRender(): void;
}
interface AskSettingsModalOptions {
configPath: string;
notice?: AskConfigNotice;
onClose: () => void;
onSave: (config: AskConfig) => Promise<AskConfig>;
savedConfig: AskConfig;
tui: TuiLike;
}
const MIN_WIDTH = 24;
const COMPACT_WIDTH = 46;
const TAB_GAP = " ";
const DISCARD_WINDOW_MS = 1000;
const SETTINGS_MODAL_KEYS = {
close: [Key.escape, Key.ctrl("c"), "?"],
nextTab: [Key.tab, Key.right],
previousTab: [Key.shift("tab"), Key.left],
up: [Key.up],
down: [Key.down],
toggle: [Key.space],
save: [Key.ctrl("s")],
} as const;
const BEHAVIOUR_ROWS = [
{
description:
"Auto-submit completed ask flows when no question or option notes were added.",
getValue(config: AskConfig) {
return config.behaviour.autoSubmitWhenAnsweredWithoutNotes;
},
label: "Auto-submit when answered without notes",
},
] as const;
type SettingsTab = "keymaps" | "behaviour";
function padToWidth(text: string, width: number): string {
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
}
export class AskSettingsModal {
private activeTab: SettingsTab = "keymaps";
private closed = false;
private readonly configPath: string;
private readonly onClose: () => void;
private readonly onSave: (config: AskConfig) => Promise<AskConfig>;
private state: AskSettingsState;
private readonly theme: Theme;
private readonly tui: TuiLike;
constructor(theme: Theme, onClose: () => void) {
constructor(theme: Theme, options: AskSettingsModalOptions) {
this.theme = theme;
this.onClose = onClose;
this.configPath = options.configPath;
this.onClose = options.onClose;
this.onSave = options.onSave;
this.state = createAskSettingsState(options.savedConfig, options.notice);
this.tui = options.tui;
}
handleInput(data: string): void {
if (
ASK_HELP_CLOSE_BINDINGS.some((binding) => matchesBinding(data, binding))
) {
this.close();
if (this.handleGlobalInput(data)) {
return;
}
if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) {
this.activeTab = this.activeTab === "keymaps" ? "behaviour" : "keymaps";
return;
}
if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) {
this.activeTab = this.activeTab === "behaviour" ? "keymaps" : "behaviour";
if (this.state.activeTab === "behaviour") {
this.handleBehaviourInput(data);
}
}
render(width: number): string[] {
const layout = createLayout(this.theme, this.activeTab, width);
const layout = createLayout(this.theme, this.state.activeTab, width);
const lines = [...layout.headerLines];
if (this.activeTab === "keymaps") {
lines.push(...renderKeymapsTab(this.theme, layout));
if (this.state.activeTab === "keymaps") {
lines.push(
...renderKeymapsTab(
this.theme,
layout,
this.state.savedConfig,
this.configPath,
this.state.notice
)
);
} else {
lines.push(...renderBehaviourTab(this.theme, layout));
lines.push(...renderBehaviourTab(this.theme, this.state, layout));
}
lines.push(`${layout.border(`├${"─".repeat(layout.innerW)}┤`)}`);
for (const hintLine of wrapText(ASK_HELP_CLOSE_HINT, layout.innerW - 2)) {
lines.push(layout.line(this.theme.fg("muted", hintLine)));
}
lines.push(`${layout.border(`╰${"─".repeat(layout.innerW)}╯`)}`);
lines.push(...renderBottomPanel(this.theme, this.state, layout));
return lines;
}
invalidate(): void {
// Static modal; no async invalidation needed.
// State-driven modal; nothing cached across theme changes.
}
dispose(): void {
this.close();
}
private handleGlobalInput(data: string): boolean {
const closeRequested = matchesAny(data, SETTINGS_MODAL_KEYS.close);
if (!closeRequested) {
this.state = clearTransientNotice(this.state);
}
if (closeRequested) {
this.handleClose();
return true;
}
if (matchesAny(data, SETTINGS_MODAL_KEYS.save)) {
this.save().catch(() => {
// save() handles state updates and rerendering on failure
});
return true;
}
if (matchesAny(data, SETTINGS_MODAL_KEYS.nextTab)) {
this.state = moveSettingsTab(this.state, 1);
this.refresh();
return true;
}
if (matchesAny(data, SETTINGS_MODAL_KEYS.previousTab)) {
this.state = moveSettingsTab(this.state, -1);
this.refresh();
return true;
}
return false;
}
private handleBehaviourInput(data: string): void {
if (matchesAny(data, SETTINGS_MODAL_KEYS.up)) {
this.state = moveBehaviourFocus(this.state, -1, BEHAVIOUR_ROWS.length);
this.refresh();
return;
}
if (matchesAny(data, SETTINGS_MODAL_KEYS.down)) {
this.state = moveBehaviourFocus(this.state, 1, BEHAVIOUR_ROWS.length);
this.refresh();
return;
}
if (matchesAny(data, SETTINGS_MODAL_KEYS.toggle)) {
this.state = toggleAutoSubmit(this.state);
this.refresh();
}
}
private handleClose(): void {
if (!isDirty(this.state)) {
this.close();
return;
}
const now = Date.now();
if (shouldDiscard(this.state, now)) {
this.close();
return;
}
this.state = requestDiscard(this.state, now, DISCARD_WINDOW_MS);
this.refresh();
}
private async save(): Promise<void> {
if (this.state.saving) {
return;
}
this.state = setSaving(this.state);
this.refresh();
try {
const savedConfig = await this.onSave(this.state.draftConfig);
this.state = completeSave(this.state, savedConfig);
} catch (error) {
this.state = failSave(
this.state,
error instanceof Error ? error.message : String(error)
);
}
this.refresh();
}
private refresh(): void {
this.tui.requestRender();
}
private close(): void {
if (this.closed) {
return;
@@ -103,8 +236,8 @@ function createLayout(
const compact = innerW < COMPACT_WIDTH;
const keyW = compact ? 11 : 16;
const descW = Math.max(8, innerW - keyW - 5);
const border = (text: string) => theme.fg("border", text);
const title = theme.fg("accent", " @eko24ive/pi-ask ");
const border = (text: string) => theme.fg("muted", text);
const title = theme.fg("muted", " @eko24ive/pi-ask ");
const titleW = visibleWidth(title);
const titlePad = Math.max(0, innerW - titleW);
const left = Math.floor(titlePad / 2);
@@ -126,11 +259,122 @@ function createLayout(
};
}
function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] {
function renderBottomPanel(
theme: Theme,
state: AskSettingsState,
layout: SettingsLayout
): string[] {
const lines = [`${layout.border(`├${"─".repeat(layout.innerW)}┤`)}`];
const notice = state.notice ?? getDefaultNotice(state);
if (notice) {
for (const noticeLine of wrapText(notice.text, layout.innerW - 2)) {
lines.push(layout.line(colorNotice(theme, notice.kind, noticeLine)));
}
}
for (const hintLine of wrapText(renderCloseHint(state), layout.innerW - 2)) {
lines.push(layout.line(theme.fg("muted", hintLine)));
}
lines.push(`${layout.border(`╰${"─".repeat(layout.innerW)}╯`)}`);
return lines;
}
function renderBehaviourTab(
theme: Theme,
state: AskSettingsState,
layout: SettingsLayout
): string[] {
return BEHAVIOUR_ROWS.flatMap((row, index) =>
renderBehaviourRow(theme, state, layout, row, index)
);
}
function renderBehaviourRow(
theme: Theme,
state: AskSettingsState,
layout: SettingsLayout,
row: (typeof BEHAVIOUR_ROWS)[number],
index: number
): string[] {
const focused = index === state.behaviourFocusIndex;
const dirty =
row.getValue(state.draftConfig) !== row.getValue(state.savedConfig);
const checked = row.getValue(state.draftConfig);
const marker = checked ? "[x]" : "[ ]";
let labelColor: "warning" | "accent" | "text" = "text";
if (dirty) {
labelColor = "warning";
} else if (focused) {
labelColor = "accent";
}
let label = `${marker} ${row.label}`;
label = theme.fg(labelColor, label);
if (focused) {
label = theme.bg("selectedBg", label);
}
const lines = [
layout.line(truncateToWidth(label, layout.innerW - 1, "…")),
...wrapText(row.description, layout.innerW - 4).map((descLine) =>
layout.line(` ${theme.fg("dim", descLine)}`)
),
];
if (index < BEHAVIOUR_ROWS.length - 1) {
lines.push(layout.line());
}
return lines;
}
function renderKeymapsTab(
theme: Theme,
layout: SettingsLayout,
config: AskConfig,
configPath: string,
notice?: AskConfigNotice
): string[] {
const lines: string[] = [];
const compact = layout.innerW < COMPACT_WIDTH;
const { customizable, fixed } = getAskKeymaps(config);
const headerLines = [
"Edit this config file to change customizable ask keymaps:",
configPath,
"Restart pi or run /reload after editing.",
];
for (const hint of ASK_KEYMAPS) {
for (const text of headerLines) {
for (const line of wrapText(text, layout.innerW - 2)) {
lines.push(
layout.line(theme.fg(text === configPath ? "accent" : "dim", line))
);
}
}
if (notice?.kind === "error") {
lines.push(layout.line());
for (const line of wrapText(
"Using default keymaps this session because configured keymaps were rejected.",
layout.innerW - 2
)) {
lines.push(layout.line(theme.fg("warning", line)));
}
}
lines.push(layout.line());
lines.push(layout.line(theme.fg("muted", "Customizable")));
pushKeymapRows(lines, theme, layout, customizable, compact);
lines.push(
layout.line(theme.fg("muted", "─".repeat(Math.max(1, layout.innerW - 2))))
);
lines.push(layout.line(theme.fg("muted", "Fixed")));
pushKeymapRows(lines, theme, layout, fixed, compact);
return lines;
}
function pushKeymapRows(
lines: string[],
theme: Theme,
layout: SettingsLayout,
keymaps: readonly AskKeyBinding[],
compact: boolean
) {
for (const hint of keymaps) {
if (compact) {
lines.push(layout.line(theme.fg("accent", hint.label)));
for (const descLine of wrapText(hint.description, layout.innerW - 3)) {
@@ -138,7 +382,6 @@ function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] {
}
continue;
}
const keyText = padToWidth(
truncateToWidth(hint.label, layout.keyW, "…"),
layout.keyW
@@ -151,14 +394,6 @@ function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] {
layout.line(`${" ".repeat(layout.keyW)} ${theme.fg("dim", contexts)}`)
);
}
return lines;
}
function renderBehaviourTab(theme: Theme, layout: SettingsLayout): string[] {
return wrapText("Behaviour settings coming soon.", layout.innerW - 2).map(
(settingsLine) => layout.line(theme.fg("muted", settingsLine))
);
}
function renderTabs(
@@ -179,3 +414,53 @@ function renderTabs(
].join(TAB_GAP);
return truncateToWidth(tabs, width, "");
}
function colorNotice(
theme: Theme,
kind: AskConfigNotice["kind"],
text: string
): string {
switch (kind) {
case "error":
return theme.fg("error", text);
case "success":
return theme.fg("success", text);
case "warning":
return theme.fg("warning", text);
default:
return text;
}
}
function getDefaultNotice(
state: AskSettingsState
): AskConfigNotice | undefined {
if (state.saving) {
return { kind: "warning", text: "Saving…" };
}
if (isDirty(state)) {
return {
kind: "warning",
text: "Unsaved changes. Press Ctrl+S to save.",
};
}
return;
}
function renderCloseHint(state: AskSettingsState): string {
if (isDirty(state)) {
return "Ctrl+S save · Esc/Ctrl+C/? twice discard";
}
return "Esc, Ctrl+C, or ? close";
}
type MatchableKey = Parameters<typeof matchesKey>[1] | string;
function matchesAny(data: string, keys: readonly MatchableKey[]): boolean {
return keys.some((key) => {
if (key.length === 1) {
return data === key;
}
return matchesKey(data, key as Parameters<typeof matchesKey>[1]);
});
}
+159
View File
@@ -0,0 +1,159 @@
import { normalizeAskConfig } from "../config/defaults.ts";
import type { AskConfig } from "../config/schema.ts";
import type { AskConfigNotice } from "../config/store.ts";
export interface AskSettingsState {
activeTab: "keymaps" | "behaviour";
behaviourFocusIndex: number;
draftConfig: AskConfig;
notice?: AskConfigNotice;
pendingCloseUntil?: number;
savedConfig: AskConfig;
saving: boolean;
}
export function createAskSettingsState(
savedConfig: AskConfig,
notice?: AskConfigNotice
): AskSettingsState {
return {
activeTab: "keymaps",
behaviourFocusIndex: 0,
draftConfig: normalizeAskConfig(savedConfig),
notice,
savedConfig: normalizeAskConfig(savedConfig),
saving: false,
};
}
export function clearTransientNotice(
state: AskSettingsState
): AskSettingsState {
if (!state.notice || state.notice.kind === "error") {
return state;
}
return {
...state,
notice: undefined,
pendingCloseUntil: undefined,
};
}
export function completeSave(
state: AskSettingsState,
savedConfig: AskConfig
): AskSettingsState {
const normalized = normalizeAskConfig(savedConfig);
return {
...state,
draftConfig: normalized,
notice: { kind: "success", text: "Saved" },
pendingCloseUntil: undefined,
savedConfig: normalized,
saving: false,
};
}
export function failSave(
state: AskSettingsState,
message: string
): AskSettingsState {
return {
...state,
notice: { kind: "error", text: message },
saving: false,
};
}
export function isDirty(state: AskSettingsState): boolean {
return !deepEqual(state.draftConfig, state.savedConfig);
}
export function moveBehaviourFocus(
state: AskSettingsState,
delta: 1 | -1,
rowCount: number
): AskSettingsState {
if (rowCount <= 0) {
return state;
}
const next = Math.max(
0,
Math.min(rowCount - 1, state.behaviourFocusIndex + delta)
);
return {
...state,
behaviourFocusIndex: next,
};
}
export function moveSettingsTab(
state: AskSettingsState,
delta: 1 | -1
): AskSettingsState {
if (delta === 1) {
return {
...state,
activeTab: state.activeTab === "keymaps" ? "behaviour" : "keymaps",
};
}
return {
...state,
activeTab: state.activeTab === "behaviour" ? "keymaps" : "behaviour",
};
}
export function requestDiscard(
state: AskSettingsState,
now: number,
windowMs: number
): AskSettingsState {
return {
...state,
notice: {
kind: "warning",
text: "Unsaved changes. Press again to discard.",
},
pendingCloseUntil: now + windowMs,
};
}
export function setSaving(state: AskSettingsState): AskSettingsState {
return {
...state,
notice: undefined,
pendingCloseUntil: undefined,
saving: true,
};
}
export function shouldDiscard(state: AskSettingsState, now: number): boolean {
return !!state.pendingCloseUntil && now <= state.pendingCloseUntil;
}
export function toggleAutoSubmit(state: AskSettingsState): AskSettingsState {
return {
...state,
draftConfig: {
...state.draftConfig,
behaviour: {
...state.draftConfig.behaviour,
autoSubmitWhenAnsweredWithoutNotes:
!state.draftConfig.behaviour.autoSubmitWhenAnsweredWithoutNotes,
},
},
};
}
function deepEqual(left: AskConfig, right: AskConfig): boolean {
return (
left.behaviour.autoSubmitWhenAnsweredWithoutNotes ===
right.behaviour.autoSubmitWhenAnsweredWithoutNotes &&
left.keymaps.cancel === right.keymaps.cancel &&
left.keymaps.dismiss === right.keymaps.dismiss &&
left.keymaps.toggle === right.keymaps.toggle &&
left.keymaps.confirm === right.keymaps.confirm &&
left.keymaps.optionNote === right.keymaps.optionNote &&
left.keymaps.questionNote === right.keymaps.questionNote
);
}
+14 -4
View File
@@ -1,13 +1,23 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { getAskConfigPath, getAskConfigStore } from "../config/store.ts";
import { AskSettingsModal } from "./settings-modal.ts";
export function showAskSettingsModal(
export async function showAskSettingsModal(
ui: Pick<ExtensionContext, "ui">["ui"]
): Promise<void> {
const store = getAskConfigStore();
const { config, notice } = await store.ensureLoaded();
return ui.custom<void>(
(_tui, theme, _keybindings, done) =>
new AskSettingsModal(theme, () => {
done();
(tui, theme, _keybindings, done) =>
new AskSettingsModal(theme, {
configPath: getAskConfigPath(),
notice,
onClose: () => {
done();
},
onSave: async (nextConfig) => store.save(nextConfig),
savedConfig: config,
tui,
}),
{
overlay: true,
+4 -5
View File
@@ -127,27 +127,26 @@ function buildOptionDetailModel(
const note = option.isCustomOption
? undefined
: getOptionNote(state, question.id, option.value);
const withGap = !option.isCustomOption;
if (noteOpen) {
return {
kind: "editor",
placeholder: UI_TEXT.editorPlaceholderNote,
withGap,
withGap: false,
};
}
if (inputOpen) {
return {
kind: "editor",
placeholder: UI_TEXT.editorPlaceholderInput,
withGap,
withGap: false,
};
}
if (customText) {
return { kind: "custom-text", text: customText, withGap };
return { kind: "custom-text", text: customText, withGap: false };
}
if (note) {
return { kind: "saved-note", text: note, withGap };
return { kind: "saved-note", text: note, withGap: false };
}
return;
}