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
+46
View File
@@ -0,0 +1,46 @@
import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts";
import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
export const DEFAULT_ASK_CONFIG: AskConfig = {
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: false,
},
keymaps: {
...DEFAULT_ASK_KEYMAPS,
},
};
export function normalizeAskConfig(
config?: Partial<AskConfigFileV1> | AskConfig
): AskConfig {
return {
behaviour: {
autoSubmitWhenAnsweredWithoutNotes:
config?.behaviour?.autoSubmitWhenAnsweredWithoutNotes ??
DEFAULT_ASK_CONFIG.behaviour.autoSubmitWhenAnsweredWithoutNotes,
},
keymaps: {
...DEFAULT_ASK_CONFIG.keymaps,
...(config?.keymaps ?? {}),
},
};
}
export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
const normalized = normalizeAskConfig(config);
return {
schemaVersion: 1,
behaviour: {
autoSubmitWhenAnsweredWithoutNotes:
normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes,
},
keymaps: {
cancel: normalized.keymaps.cancel,
dismiss: normalized.keymaps.dismiss,
toggle: normalized.keymaps.toggle,
confirm: normalized.keymaps.confirm,
optionNote: normalized.keymaps.optionNote,
questionNote: normalized.keymaps.questionNote,
},
};
}
+80
View File
@@ -0,0 +1,80 @@
import {
type AskConfigurableKeyAction,
normalizeConfiguredKeymaps,
} from "../constants/keymaps.ts";
import { normalizeAskConfig } from "./defaults.ts";
import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
import { validateAskConfigFileV1 } from "./schema.ts";
export class AskConfigMigrationError extends Error {
readonly reason: "invalid_or_unsupported" | "migration_failed";
constructor(
message: string,
reason: "invalid_or_unsupported" | "migration_failed"
) {
super(message);
this.reason = reason;
}
}
export interface AskConfigMigrationResult {
config: AskConfig;
migrated: boolean;
notice?: string;
}
export function migrateAskConfig(raw: unknown): AskConfigMigrationResult {
if (!(raw && typeof raw === "object")) {
throw new AskConfigMigrationError(
"Config was invalid or unsupported.",
"invalid_or_unsupported"
);
}
if (!("schemaVersion" in raw)) {
throw new AskConfigMigrationError(
"Config was invalid or unsupported.",
"invalid_or_unsupported"
);
}
if ((raw as { schemaVersion?: unknown }).schemaVersion !== 1) {
throw new AskConfigMigrationError(
"Config was invalid or unsupported.",
"invalid_or_unsupported"
);
}
if (!validateAskConfigFileV1.Check(raw)) {
throw new AskConfigMigrationError(
"Config was invalid or unsupported.",
"invalid_or_unsupported"
);
}
const config = normalizeAskConfig(raw as AskConfigFileV1);
const keymapsResult = normalizeConfiguredKeymaps(
(raw as AskConfigFileV1).keymaps as
| Partial<Record<AskConfigurableKeyAction, unknown>>
| undefined
);
if (!keymapsResult.ok) {
return {
config: {
...config,
keymaps: normalizeAskConfig().keymaps,
},
migrated: false,
notice: `${keymapsResult.error} Using default ask keymaps for this session. Edit the config and restart pi or run /reload.`,
};
}
return {
config: {
...config,
keymaps: keymapsResult.keymaps,
},
migrated: false,
};
}
+41
View File
@@ -0,0 +1,41 @@
import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
export const AskConfigFileV1Schema = Type.Object({
schemaVersion: Type.Literal(1),
behaviour: Type.Optional(
Type.Object({
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
})
),
keymaps: Type.Optional(
Type.Object({
cancel: Type.Optional(Type.String()),
dismiss: Type.Optional(Type.String()),
toggle: Type.Optional(Type.String()),
confirm: Type.Optional(Type.String()),
optionNote: Type.Optional(Type.String()),
questionNote: Type.Optional(Type.String()),
})
),
});
export type AskConfigFileV1 = Static<typeof AskConfigFileV1Schema>;
export interface AskConfigKeymaps {
cancel: string;
confirm: string;
dismiss: string;
optionNote: string;
questionNote: string;
toggle: string;
}
export interface AskConfig {
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: boolean;
};
keymaps: AskConfigKeymaps;
}
export const validateAskConfigFileV1 = Compile(AskConfigFileV1Schema);
+165
View File
@@ -0,0 +1,165 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
const JSON_EXTENSION_PATTERN = /\.json$/u;
import { getAgentDir } from "@mariozechner/pi-coding-agent";
import {
DEFAULT_ASK_CONFIG,
normalizeAskConfig,
toAskConfigFileV1,
} from "./defaults.ts";
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
import type { AskConfig } from "./schema.ts";
export interface AskConfigNotice {
kind: "error" | "warning" | "success";
text: string;
}
interface AskConfigLoadResult {
config: AskConfig;
notice?: AskConfigNotice;
}
export class AskConfigStore {
private config?: AskConfig;
private loadPromise?: Promise<AskConfigLoadResult>;
private notice?: AskConfigNotice;
private readonly listeners = new Set<(config: AskConfig) => void>();
private readonly configPath: string;
constructor(configPath = getAskConfigPath()) {
this.configPath = configPath;
}
subscribe(onChange: (config: AskConfig) => void): () => void {
this.listeners.add(onChange);
return () => {
this.listeners.delete(onChange);
};
}
async ensureLoaded(): Promise<AskConfigLoadResult> {
if (this.config) {
return { config: this.config, notice: this.notice };
}
if (!this.loadPromise) {
this.loadPromise = this.loadFromDisk();
}
const result = await this.loadPromise;
this.config = result.config;
this.notice = result.notice;
this.loadPromise = undefined;
return result;
}
async getConfig(): Promise<AskConfig> {
return (await this.ensureLoaded()).config;
}
async save(config: AskConfig): Promise<AskConfig> {
const normalized = normalizeAskConfig(config);
const content = JSON.stringify(
toAskConfigFileV1(normalized),
null,
2
).concat("\n");
await mkdir(dirname(this.configPath), { recursive: true });
await writeFile(this.configPath, content, "utf-8");
this.setConfig(normalized);
return normalized;
}
setConfig(config: AskConfig): void {
this.config = normalizeAskConfig(config);
this.notice = undefined;
for (const listener of this.listeners) {
listener(this.config);
}
}
private async loadFromDisk(): Promise<AskConfigLoadResult> {
let content: string;
try {
content = await readFile(this.configPath, "utf-8");
} catch (error) {
if (isMissingFileError(error)) {
return { config: normalizeAskConfig(DEFAULT_ASK_CONFIG) };
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return this.backupAndReset(
"Config was invalid or unsupported. Backed it up and loaded defaults. Press Ctrl+S to save a fresh config."
);
}
try {
const migrated = migrateAskConfig(parsed);
if (migrated.migrated) {
await this.save(migrated.config);
}
return {
config: migrated.config,
notice: migrated.notice
? {
kind: "error",
text: migrated.notice,
}
: undefined,
};
} catch (error) {
if (error instanceof AskConfigMigrationError) {
return this.backupAndReset(
error.reason === "migration_failed"
? "Config migration failed. Backed up old config and loaded defaults. Press Ctrl+S to save a fresh config."
: "Config was invalid or unsupported. Backed it up and loaded defaults. Press Ctrl+S to save a fresh config."
);
}
throw error;
}
}
private async backupAndReset(text: string): Promise<AskConfigLoadResult> {
const backupPath = createBackupPath(this.configPath, new Date());
await mkdir(dirname(this.configPath), { recursive: true });
await rename(this.configPath, backupPath);
return {
config: normalizeAskConfig(DEFAULT_ASK_CONFIG),
notice: {
kind: "error",
text,
},
};
}
}
let askConfigStore: AskConfigStore | undefined;
export function getAskConfigStore(): AskConfigStore {
askConfigStore ??= new AskConfigStore();
return askConfigStore;
}
export function getAskConfigPath(): string {
return join(getAgentDir(), "extensions", "eko24ive-pi-ask.json");
}
function createBackupPath(path: string, date: Date): string {
const timestamp = date.toISOString().replaceAll(":", "-").replace(/\./g, "-");
return path.replace(JSON_EXTENSION_PATTERN, `.${timestamp}.bak.json`);
}
function isMissingFileError(error: unknown): boolean {
return (
!!error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
);
}
+427 -155
View File
@@ -1,16 +1,98 @@
import { Key, matchesKey } from "@mariozechner/pi-tui";
import type { AskConfig, AskConfigKeymaps } from "../config/schema.ts";
const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/;
const LETTER_PATTERN = /^[a-z]$/;
const DIGIT_PATTERN = /^[0-9]$/;
const SYMBOL_PATTERN = /^[`\-=[\]\\;'.,/!@#$%^&*()_+|~{}:<>?]$/;
const FUNCTION_KEY_PATTERN = /^f([1-9]|1[0-2])$/;
const MODIFIER_ORDER = ["ctrl", "shift", "alt", "super"] as const;
const KNOWN_SPECIAL_KEYS = new Set([
"esc",
"enter",
"tab",
"space",
"backspace",
"delete",
"insert",
"clear",
"home",
"end",
"pageUp",
"pageDown",
"up",
"down",
"left",
"right",
"f1",
"f2",
"f3",
"f4",
"f5",
"f6",
"f7",
"f8",
"f9",
"f10",
"f11",
"f12",
] as const);
const RESERVED_CONFIGURABLE_BINDINGS = new Set([
"?",
"tab",
"shift+tab",
"left",
"right",
"up",
"down",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
]);
const ACTION_LABEL_OVERRIDES: Partial<Record<AskKeyBindingId, string>> = {
nextTab: "Tab / →",
previousTab: "Shift+Tab / ←",
previousOption: "↑ / ↓",
nextOption: "↓",
numberShortcut: "1..9",
};
type KeyId = Parameters<typeof matchesKey>[1];
type InputKey = KeyId | (string & {});
type ConfigurableBindingMetadata = Omit<AskKeyBinding, "keys" | "label"> & {
id: AskConfigurableKeyAction;
};
type FixedBindingMetadata = Omit<AskKeyBinding, "keys" | "label"> & {
id: AskFixedKeyBindingId;
keys: readonly InputKey[];
label?: string;
};
type ModifierName = (typeof MODIFIER_ORDER)[number];
export type AskKeyBindingKind = "command" | "affordance";
export type AskConfigurableKeyAction = keyof AskConfigKeymaps;
export type AskFixedKeyBindingId =
| "settings"
| "nextTab"
| "previousTab"
| "previousOption"
| "nextOption"
| "numberShortcut"
| "fileReference";
export type AskKeyBindingId = AskConfigurableKeyAction | AskFixedKeyBindingId;
export interface AskKeyBinding {
contexts: readonly string[];
description: string;
id: string;
id: AskKeyBindingId;
keys: readonly InputKey[];
kind: AskKeyBindingKind;
label: string;
@@ -23,64 +105,91 @@ export type FooterKeymapContext =
| "input"
| "note";
export const ASK_KEY_BINDINGS = {
help: {
id: "help",
keys: ["?"],
kind: "command",
label: "?",
description: "Show this menu",
contexts: ["Main flow"],
},
cancel: {
export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
cancel: "esc",
dismiss: "ctrl+c",
toggle: "space",
confirm: "enter",
optionNote: "n",
questionNote: "shift+n",
};
const CONFIGURABLE_BINDING_METADATA: readonly ConfigurableBindingMetadata[] = [
{
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: "toggle",
kind: "command",
description: "Toggle selected option",
contexts: ["Questions"],
},
{
id: "confirm",
kind: "command",
description: "Confirm selection, continue, save, or submit",
contexts: ["Main flow", "Editors"],
},
{
id: "optionNote",
kind: "command",
description: "Edit selected option note",
contexts: ["Question tabs"],
},
{
id: "questionNote",
kind: "command",
description: "Edit question note",
contexts: ["Question tabs"],
},
] as const;
const FIXED_BINDING_METADATA: readonly FixedBindingMetadata[] = [
{
id: "settings",
keys: ["?"],
kind: "command",
description: "Open ask settings modal",
contexts: ["Main flow", "Empty editor"],
},
{
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",
@@ -88,149 +197,34 @@ export const ASK_KEY_BINDINGS = {
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}`;
) => `${label} ${action}`;
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 formatKeybindingLabel(key: string): string {
return key
.split("+")
.map((part) => {
if (part.length <= 1) {
return part.toUpperCase();
}
if (part === "pageUp" || part === "pageDown") {
return part;
}
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join("+");
}
export function matchesBinding(data: string, binding: AskKeyBinding): boolean {
@@ -245,3 +239,281 @@ export function matchesBinding(data: string, binding: AskKeyBinding): boolean {
export function matchesDigitShortcut(data: string): number | null {
return DIGIT_SHORTCUT_PATTERN.test(data) ? Number(data) : null;
}
export function getAskKeyBindings(
config: AskConfig
): Record<AskKeyBindingId, AskKeyBinding> {
const customizable: Record<AskConfigurableKeyAction, AskKeyBinding> = {
cancel: createCustomizableBinding(config, "cancel"),
dismiss: createCustomizableBinding(config, "dismiss"),
toggle: createCustomizableBinding(config, "toggle"),
confirm: createCustomizableBinding(config, "confirm"),
optionNote: createCustomizableBinding(config, "optionNote"),
questionNote: createCustomizableBinding(config, "questionNote"),
};
const fixed = Object.fromEntries(
FIXED_BINDING_METADATA.map((binding) => [
binding.id,
{
...binding,
label:
binding.label ??
ACTION_LABEL_OVERRIDES[binding.id] ??
binding.keys
.map((key) => formatKeybindingLabel(String(key)))
.join(" / "),
},
])
) as Record<AskFixedKeyBindingId, AskKeyBinding>;
return {
...customizable,
...fixed,
};
}
export function getAskKeymaps(config: AskConfig): {
customizable: readonly AskKeyBinding[];
fixed: readonly AskKeyBinding[];
} {
const bindings = getAskKeyBindings(config);
return {
customizable: CONFIGURABLE_BINDING_METADATA.map(({ id }) => bindings[id]),
fixed: FIXED_BINDING_METADATA.map(({ id }) => bindings[id]),
};
}
export function renderFooterKeymaps(
config: AskConfig,
context: FooterKeymapContext
): string {
const bindings = getAskKeyBindings(config);
const optionNavigationLabel = bindings.previousOption.label.replaceAll(
" / ",
""
);
const tabNavigationLabel = "⇆";
const noteNavigationLabel = `${bindings.optionNote.label}/${bindings.questionNote.label}`;
const hintsByContext: Record<FooterKeymapContext, readonly string[]> = {
input: [
footerHint(bindings.confirm, "submit"),
footerHint(bindings.cancel, "close"),
footerHint(bindings.settings, "settings"),
],
note: [
footerHint(bindings.confirm, "save"),
footerHint(bindings.cancel, "close"),
footerHint(bindings.settings, "settings"),
],
submit: [
footerHint(bindings.numberShortcut, "hotkeys"),
footerHint(bindings.previousOption, "select", optionNavigationLabel),
footerHint(bindings.confirm, "confirm"),
footerHint(bindings.cancel, "cancel"),
footerHint(bindings.settings, "settings"),
],
multi: [
footerHint(bindings.nextTab, "tab", tabNavigationLabel),
footerHint(bindings.previousOption, "select", optionNavigationLabel),
footerHint(bindings.toggle, "toggle"),
footerHint(bindings.confirm, "continue"),
footerHint(bindings.optionNote, "note", noteNavigationLabel),
footerHint(bindings.cancel, "dismiss"),
footerHint(bindings.settings, "settings"),
],
default: [
footerHint(bindings.nextTab, "tab", tabNavigationLabel),
footerHint(bindings.previousOption, "select", optionNavigationLabel),
footerHint(bindings.confirm, "confirm"),
footerHint(bindings.optionNote, "note", noteNavigationLabel),
footerHint(bindings.cancel, "dismiss"),
footerHint(bindings.settings, "settings"),
],
};
return ` ${hintsByContext[context].join(" · ")}`;
}
export function normalizeConfiguredKeymaps(
keymaps: Partial<Record<AskConfigurableKeyAction, unknown>> | undefined
): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } {
if (!keymaps) {
return { ok: true, keymaps: { ...DEFAULT_ASK_KEYMAPS } };
}
const normalized = {} as AskConfigKeymaps;
for (const action of Object.keys(
DEFAULT_ASK_KEYMAPS
) as AskConfigurableKeyAction[]) {
const rawValue = keymaps[action];
if (typeof rawValue !== "string") {
return { ok: false, error: `Missing keymap for ${action}.` };
}
const parsed = normalizeKeyId(rawValue);
if (!parsed.ok) {
return {
ok: false,
error: `Invalid keymap for ${action}: ${parsed.error}`,
};
}
if (RESERVED_CONFIGURABLE_BINDINGS.has(parsed.keyId)) {
return {
ok: false,
error: `Reserved keymap for ${action}: ${formatKeybindingLabel(parsed.keyId)}.`,
};
}
normalized[action] = parsed.keyId;
}
const seen = new Map<string, AskConfigurableKeyAction>();
for (const action of Object.keys(normalized) as AskConfigurableKeyAction[]) {
const key = normalized[action];
const existing = seen.get(key);
if (existing) {
return {
ok: false,
error: `Duplicate keymap ${formatKeybindingLabel(key)} for ${existing} and ${action}.`,
};
}
seen.set(key, action);
}
return { ok: true, keymaps: normalized };
}
function normalizeKeyId(
rawKey: string
): { ok: true; keyId: string } | { ok: false; error: string } {
const normalizedInput = rawKey.trim();
if (!normalizedInput) {
return { ok: false, error: "empty binding" };
}
const parts = normalizedInput
.toLowerCase()
.split("+")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) {
return { ok: false, error: "empty binding" };
}
const basePart = parts.at(-1);
if (!basePart) {
return { ok: false, error: "missing key" };
}
const modifiers = new Set<ModifierName>();
for (const part of parts.slice(0, -1)) {
const modifier = normalizeModifierName(part);
if (!modifier) {
return { ok: false, error: `unsupported modifier ${part}` };
}
modifiers.add(modifier);
}
const baseKey = normalizeBaseKey(basePart);
if (!baseKey) {
return { ok: false, error: `unsupported key ${basePart}` };
}
const modifierPrefix = MODIFIER_ORDER.filter((modifier) =>
modifiers.has(modifier)
);
return {
ok: true,
keyId: [...modifierPrefix, baseKey].join("+"),
};
}
function createCustomizableBinding(
config: AskConfig,
id: AskConfigurableKeyAction
): AskKeyBinding {
const binding = CONFIGURABLE_BINDING_METADATA.find(
(entry) => entry.id === id
);
if (!binding) {
throw new Error(`Unknown customizable ask key binding: ${id}`);
}
const key = config.keymaps[id];
return {
...binding,
keys: [key],
label: formatKeybindingLabel(key),
};
}
function normalizeModifierName(part: string): ModifierName | undefined {
switch (part) {
case "ctrl":
case "control":
case "ctl":
return "ctrl";
case "shift":
return "shift";
case "alt":
case "option":
return "alt";
case "super":
case "cmd":
case "command":
case "meta":
case "win":
case "windows":
return "super";
default:
return;
}
}
function normalizeBaseKey(part: string): string | undefined {
const aliased = normalizeBaseKeyAlias(part);
if (
LETTER_PATTERN.test(aliased) ||
DIGIT_PATTERN.test(aliased) ||
SYMBOL_PATTERN.test(aliased)
) {
return aliased;
}
if (FUNCTION_KEY_PATTERN.test(aliased)) {
return aliased;
}
if (
KNOWN_SPECIAL_KEYS.has(
aliased as typeof KNOWN_SPECIAL_KEYS extends Set<infer T> ? T : never
)
) {
return aliased;
}
return;
}
function normalizeBaseKeyAlias(part: string): string {
switch (part) {
case "escape":
return "esc";
case "return":
return "enter";
case "spacebar":
return "space";
case "del":
return "delete";
case "ins":
return "insert";
case "pgup":
case "pageup":
return "pageUp";
case "pgdown":
case "pagedown":
return "pageDown";
case "arrowup":
return "up";
case "arrowdown":
return "down";
case "arrowleft":
return "left";
case "arrowright":
return "right";
default:
return part;
}
}
+13
View File
@@ -1,8 +1,21 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { registerAskSettingsCommand } from "./ask-settings-command.ts";
import { registerAskTool } from "./ask-tool.ts";
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const CONFIGURATION_DOC_PATH = resolve(
PACKAGE_ROOT,
"docs",
"configuration.md"
);
const PI_ASK_CONFIG_PROMPT = `When the user asks to configure, customize, debug, or explain @eko24ive/pi-ask settings or keymaps, first read ${CONFIGURATION_DOC_PATH} and follow it as the source of truth before editing config files.`;
export default function askExtension(pi: ExtensionAPI) {
pi.on("before_agent_start", async (event) => ({
systemPrompt: `${event.systemPrompt}\n\n${PI_ASK_CONFIG_PROMPT}`,
}));
registerAskTool(pi);
registerAskSettingsCommand(pi);
}
+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;
}