feat: add config-backed ask keymaps
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user