Files
pi-imgen/extensions/imgen/commands/presets.ts
T
mozempk aee92d284c cleanup: clear stale TODOs, tighten types, translate art-prompt hints
Every 'being written in parallel' / 'ASSUMED SIGNATURE' TODO is gone: each
referenced module now exists and each assumption was checked against it
rather than the comment simply deleted.

- logo/presets/retouch: renderer typed as the real Renderer contract instead
  of unknown / (...args: never[]).
- director: the ModelRegistry assumption is VERIFIED against the installed
  @earendil-works types (find() on ModelRegistry, getModel() on ModelRuntime).
- strings: the img2imgBug TODO had it backwards -- drawthings raises a graded
  message a static string cannot express, and this is the fallback.
- poster: an Italian hint was being spliced into an English art prompt, which
  degrades these models. New director.refineArtPrompt() folds the note in via
  the LLM and falls back to the old splice on any failure, so it can only
  improve on the previous behaviour. scrubArtPrompt still runs either way.

Remaining TODOs are one category only (Italian strings living in per-file
tables rather than ui/strings.ts) and are accurate, not stale.
2026-08-27 10:30:54 +02:00

1020 lines
36 KiB
TypeScript

/**
* /presets — i suoi "stili salvati": brand presets.
*
* A preset is the part of a job that does NOT change between events: his band's logo,
* his colours, his typefaces, the tone he wants the artwork to have. Every other
* command either applies one or starts fresh, so this file owns exactly two things:
*
* 1. the preset STORE — a merging, never-clobbering writer for ~/.pi/agent/pi-imgen.json
* 2. a guided Italian flow that fills a {@link Preset} in without ever asking him to
* name a typeface: he picks an *aria* (a mood) and gets a reviewed palette + a
* curated font pairing together.
*
* The store half is exported as plain functions so an LLM-facing tool in ../tools/ can
* drive exactly the same code the command drives — there must never be two ways to
* write a preset.
*
* Nothing here needs the diffusion backend or Typst: the preview is drawn with sharp,
* so /presets keeps working on a machine where Draw Things is not installed yet.
*/
import { existsSync, statSync } from "node:fs";
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
import sharp from "sharp";
import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { AutocompleteItem } from "@earendil-works/pi-tui";
import { configPath, expandTilde, loadConfig, type ImgenConfig, type Preset } from "../config.ts";
import type { Palette } from "../design/spec.ts";
import { PAIRINGS } from "../design/fonts.ts";
import {
DEFAULT_PALETTE_NAME,
PALETTES,
mergePresetDetailed,
normalizeHex,
paletteFor,
pairingFor,
} from "../design/palettes.ts";
import type { Backend } from "../backends/types.ts";
import type { DirectorContext, ParsedBrief } from "../design/director.ts";
import { foldAccents, normaliseSlug } from "../job.ts";
import { S, bullets, errorText, fill, list, menu } from "../ui/strings.ts";
import type { Renderer } from "./poster.ts";
// ---------------------------------------------------------------------------
// Dependencies — injected so index.ts owns construction and tests own the rest
// ---------------------------------------------------------------------------
/**
* The slice of design/director.ts this command uses: free text typed after the command
* is parsed once, cheaply, to pre-fill the form. `import * as director` satisfies it.
*/
export interface DirectorLike {
parseFreeText(text: string, ctx: DirectorContext, cfg?: ImgenConfig): Promise<ParsedBrief>;
parseFreeTextLocally(text: string): ParsedBrief;
}
export interface PresetsDeps {
/**
* The live config object. Saving MUTATES it in place (rather than replacing it) so
* that every other command holding the same reference sees the new preset at once.
*/
config: ImgenConfig;
/** Where pi-imgen.json lives. Defaults to pi's own agent dir (~/.pi/agent). */
configDir?: string;
/** Unused here — /presets must work before Draw Things is installed. Injected for symmetry. */
backend?: Backend;
/**
* Injected for symmetry with the other commands. A preset has no page to typeset, so
* this is never called — it is carried so index.ts can build ONE dependency object.
*/
renderer?: Renderer;
/** Used only to pre-fill the form from free text. Optional: a local fallback exists. */
director?: DirectorLike;
/** Called after the config file changed, so index.ts can re-derive anything cached. */
onConfigChange?: (config: ImgenConfig) => void;
}
/** Convenience alias: index.ts may pass a wider Deps object, structural typing allows it. */
export type Deps = PresetsDeps;
// ---------------------------------------------------------------------------
// The store: read / merge / write ~/.pi/agent/pi-imgen.json
// ---------------------------------------------------------------------------
type RawConfig = Record<string, unknown>;
const isRecord = (v: unknown): v is RawConfig =>
typeof v === "object" && v !== null && !Array.isArray(v);
function agentDir(deps: PresetsDeps): string {
return deps.configDir ?? getAgentDir();
}
/**
* Reads the file as it is on disk — NOT the DEFAULTS-merged view. Anything we do not
* understand (a key from a newer version, something he added by hand) survives the trip.
* A broken file throws instead of being overwritten: losing his settings is worse than
* refusing to save.
*/
async function readRaw(path: string): Promise<RawConfig> {
if (!existsSync(path)) return {};
let text: string;
try {
text = await readFile(path, "utf8");
} catch (e) {
throw new Error(errorText(S.errors.configUnreadable(path, (e as Error).message)));
}
if (!text.trim()) return {};
try {
const parsed: unknown = JSON.parse(text);
if (!isRecord(parsed)) throw new Error("not an object");
return parsed;
} catch (e) {
throw new Error(errorText(S.errors.configUnreadable(path, (e as Error).message)));
}
}
/** Atomic write: a half-written pi-imgen.json would take every command down with it. */
async function writeRaw(path: string, raw: RawConfig): Promise<void> {
const tmp = `${path}.tmp-${process.pid}`;
try {
await mkdir(dirname(path), { recursive: true });
await writeFile(tmp, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
await rename(tmp, path);
} catch (e) {
await unlink(tmp).catch(() => undefined);
throw new Error(errorText(S.errors.configWriteFailed(path, (e as Error).message)));
}
}
/**
* The one write path. Serialised per-file (tools run in parallel), read-modify-write so
* concurrent edits merge instead of clobbering, and it refreshes the in-memory config
* from disk afterwards so `deps.config` never drifts from the file.
*/
async function mutateConfig(deps: PresetsDeps, mutate: (raw: RawConfig) => void): Promise<string> {
const dir = agentDir(deps);
const path = configPath(dir);
return withFileMutationQueue(path, async () => {
const raw = await readRaw(path);
mutate(raw);
await writeRaw(path, raw);
const { config } = loadConfig(dir);
Object.assign(deps.config, config);
deps.onConfigChange?.(deps.config);
return path;
});
}
/** Drops undefined/empty fields so the saved JSON stays readable by hand. */
function toStored(preset: Preset): Preset {
const out: Preset = { label: preset.label };
if (preset.logo) out.logo = preset.logo;
if (preset.palette && Object.keys(preset.palette).length > 0) out.palette = { ...preset.palette };
if (preset.fonts && (preset.fonts.display || preset.fonts.body)) out.fonts = { ...preset.fonts };
if (preset.tone) out.tone = preset.tone;
return out;
}
/** Creates or replaces one preset, leaving every other key in the file untouched. */
export async function savePreset(
deps: PresetsDeps,
key: string,
preset: Preset,
opts: { makeDefault?: boolean } = {},
): Promise<string> {
// Checked HERE and not only in the form, so a preset written by a tool cannot smuggle
// in a logo that disappeared: a bad path only surfaces minutes into a render.
if (preset.logo && !existsSync(preset.logo)) {
throw new Error(errorText(S.errors.logoMissing(preset.logo)));
}
const stored = toStored(preset);
return mutateConfig(deps, (raw) => {
const presets = isRecord(raw.presets) ? raw.presets : {};
presets[key] = stored;
raw.presets = presets;
if (opts.makeDefault) raw.defaultPreset = key;
});
}
/** Removes one preset, and stops pointing the default at it. */
export async function removePreset(deps: PresetsDeps, key: string): Promise<string> {
return mutateConfig(deps, (raw) => {
const presets = isRecord(raw.presets) ? raw.presets : {};
delete presets[key];
raw.presets = presets;
if (raw.defaultPreset === key) raw.defaultPreset = null;
});
}
/** `null` means "start fresh every time". */
export async function setDefaultPreset(deps: PresetsDeps, key: string | null): Promise<string> {
if (key !== null && !deps.config.presets[key]) {
throw new Error(errorText(S.errors.presetUnknown(key)));
}
return mutateConfig(deps, (raw) => {
raw.defaultPreset = key;
});
}
// ---------------------------------------------------------------------------
// Preset model helpers
// ---------------------------------------------------------------------------
export interface PresetEntry {
/** The key inside `config.presets` — also what `Brief.preset` carries. */
key: string;
preset: Preset;
isDefault: boolean;
}
/** The config key for a human label: "La mia Band" -> "la-mia-band". "" when unusable. */
export function presetKey(label: string): string {
return normaliseSlug(label);
}
export function listPresets(cfg: ImgenConfig): PresetEntry[] {
return Object.entries(cfg.presets)
.map(([key, preset]) => ({ key, preset, isDefault: cfg.defaultPreset === key }))
.sort((a, b) => (a.preset.label || a.key).localeCompare(b.preset.label || b.key, "it"));
}
/**
* Case-, accent- and label-tolerant lookup: he types "la mia band" (or "La Mia Band"),
* the LLM types the key. Both must land on the same preset.
*/
export function resolvePresetName(cfg: ImgenConfig, query: string | undefined): string | undefined {
const want = (query ?? "").trim();
if (!want) return undefined;
const keys = Object.keys(cfg.presets);
if (cfg.presets[want]) return want;
const folded = foldAccents(want);
const slug = normaliseSlug(want);
const byKey = keys.find((k) => foldAccents(k) === folded || k === slug);
if (byKey) return byKey;
const byLabel = keys.find((k) => foldAccents(cfg.presets[k].label ?? "") === folded);
if (byLabel) return byLabel;
const byPrefix = keys.find(
(k) => k.startsWith(slug) || foldAccents(cfg.presets[k].label ?? "").startsWith(folded),
);
return byPrefix;
}
/** The four hex values a preset really renders with: repaired, WCAG-checked, never partial. */
export function resolvedPalette(preset: Preset): { palette: Palette; warnings: string[] } {
const merged = mergePresetDetailed(paletteFor(DEFAULT_PALETTE_NAME), preset);
return { palette: merged.palette, warnings: merged.warnings };
}
/** One mood name settles palette AND typefaces — that is the whole point of the moods. */
export function presetFromMood(label: string, moodName: string, base?: Preset): Preset {
const pairing = pairingFor(moodName);
const preset: Preset = {
...base,
label,
palette: { ...paletteFor(moodName) },
};
if (pairing) preset.fonts = { display: pairing.display, body: pairing.body };
return preset;
}
const LOGO_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
/**
* A preset whose logo does not exist is a poster that fails two minutes into a render,
* so the path is resolved and checked HERE, before anything is written.
* Accepts what a Finder drag-and-drop actually pastes: quotes and backslash-escaped spaces.
*/
export function validateLogo(input: string, cwd: string): string {
const cleaned = input
.trim()
.replace(/^['"]|['"]$/g, "")
.replace(/\\ /g, " ")
.trim();
const expanded = expandTilde(cleaned);
const abs = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
if (!existsSync(abs)) throw new Error(errorText(S.errors.logoMissing(abs)));
if (!statSync(abs).isFile()) throw new Error(fill(S.presets.logoNotAFile, { percorso: abs }));
if (!LOGO_EXTENSIONS.has(extname(abs).toLowerCase())) throw new Error(S.presets.logoBadType);
return abs;
}
/** The colours line, in his words: "Sfondo #F4F1EA, Testo #14161A, …". */
function paletteLine(palette: Palette): string {
const f = S.presets.colourFields;
return list([
`${f.bg} ${palette.bg}`,
`${f.ink} ${palette.ink}`,
`${f.accent} ${palette.accent}`,
`${f.scrim} ${palette.scrim}`,
]);
}
/** What one preset looks like on screen, and what the model reads back. */
export function describePreset(entry: PresetEntry): string {
const { key, preset, isDefault } = entry;
const { palette } = resolvedPalette(preset);
const head = fill(S.presets.detailTitle, { nome: preset.label || key });
const rows = [`${S.presets.fieldLabels.palette}: ${paletteLine(palette)}`];
const fonts = [preset.fonts?.display, preset.fonts?.body].filter((f): f is string => !!f);
if (fonts.length > 0) rows.push(`${S.presets.fieldLabels.fonts}: ${list(fonts)}`);
rows.push(`${S.presets.fieldLabels.logo}: ${preset.logo ?? S.presets.logoNone}`);
if (preset.tone) rows.push(`${S.presets.fieldLabels.tone}: ${preset.tone}`);
return `${head}${isDefault ? ` ${S.presets.isDefault}` : ""}\n${bullets(rows)}`;
}
/** The whole shelf, one line each. */
export function describeAll(cfg: ImgenConfig): string {
const entries = listPresets(cfg);
if (entries.length === 0) return `${S.presets.listEmpty}\n${S.presets.listHint}`;
const rows = entries.map((e) => {
const fonts = [e.preset.fonts?.display, e.preset.fonts?.body].filter((f): f is string => !!f);
// Hex codes stay out of the list: the detail view and the swatch carry the colours.
const bits: string[] = [];
if (e.preset.tone) bits.push(e.preset.tone);
if (fonts.length > 0) bits.push(list(fonts));
if (e.preset.logo) bits.push(`${S.presets.fieldLabels.logo}: ${basename(e.preset.logo)}`);
const tail = bits.length > 0 ? ` — ${bits.join(" · ")}` : "";
return `${e.preset.label || e.key}${tail}${e.isDefault ? ` ${S.presets.isDefault}` : ""}`;
});
return `${S.presets.listHeader}\n${bullets(rows)}`;
}
// ---------------------------------------------------------------------------
// The preview — a mock poster drawn from the palette, no typesetting involved
// ---------------------------------------------------------------------------
const SWATCH = { w: 768, h: 288 } as const;
/**
* Deliberately text-free: the swatch must render identically on a machine where none of
* the bundled families are installed yet, so it shows colour relationships (and the real
* logo) rather than lettering. Returns undefined when sharp cannot produce anything —
* a missing preview must never stop a save.
*/
export async function presetSwatch(preset: Preset): Promise<Buffer | undefined> {
try {
const { palette } = resolvedPalette(preset);
const { w, h } = SWATCH;
const chips = [palette.bg, palette.ink, palette.accent, palette.scrim];
const chipSvg = chips
.map(
(hex, i) =>
`<rect x="${420 + i * 84}" y="56" width="64" height="64" rx="8" fill="${hex}" ` +
`stroke="${palette.ink}" stroke-opacity="0.25" stroke-width="2"/>`,
)
.join("");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
<rect width="${w}" height="${h}" fill="${palette.bg}"/>
<rect width="${w}" height="14" fill="${palette.accent}"/>
<rect x="40" y="44" width="330" height="204" rx="10" fill="${palette.scrim}"/>
<rect x="68" y="78" width="264" height="30" rx="4" fill="${palette.ink}"/>
<rect x="68" y="124" width="196" height="16" rx="4" fill="${palette.ink}" fill-opacity="0.75"/>
<rect x="68" y="152" width="148" height="16" rx="4" fill="${palette.ink}" fill-opacity="0.75"/>
<rect x="68" y="196" width="104" height="14" rx="4" fill="${palette.accent}"/>
${chipSvg}
</svg>`;
const logo = preset.logo && existsSync(preset.logo) ? preset.logo : undefined;
if (!logo) return await sharp(Buffer.from(svg)).png().toBuffer();
const badge = await sharp(logo)
.resize({ width: 300, height: 92, fit: "inside", withoutEnlargement: true })
.png()
.toBuffer();
return await sharp(Buffer.from(svg))
.composite([{ input: badge, top: 156, left: 420 }])
.png()
.toBuffer();
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// Talking back: one text block always, one image block where the surface allows it
// ---------------------------------------------------------------------------
interface Report {
text: string;
preset?: Preset;
details?: Record<string, unknown>;
}
async function report(
pi: ExtensionAPI,
ctx: ExtensionContext,
{ text, preset, details }: Report,
): Promise<void> {
const content: (TextContent | ImageContent)[] = [];
if (preset && ctx.hasUI) {
const png = await presetSwatch(preset);
if (png) content.push({ type: "image", data: png.toString("base64"), mimeType: "image/png" });
}
content.push({ type: "text", text });
pi.sendMessage({ customType: "imgen-preset", content, display: true, details });
}
// ---------------------------------------------------------------------------
// Free text typed after the command
// ---------------------------------------------------------------------------
type Verb = "list" | "create" | "edit" | "remove" | "setDefault" | "show";
const VERBS: Record<string, Verb> = {
elenco: "list", lista: "list", list: "list", tutti: "list", vedi: "list",
nuovo: "create", nuova: "create", crea: "create", aggiungi: "create", salva: "create",
new: "create", create: "create",
modifica: "edit", cambia: "edit", correggi: "edit", edit: "edit",
cancella: "remove", elimina: "remove", rimuovi: "remove", togli: "remove",
delete: "remove", remove: "remove",
predefinito: "setDefault", preferito: "setDefault", default: "setDefault",
mostra: "show", apri: "show", show: "show",
};
function parseArgs(args: string): { verb?: Verb; rest: string } {
const text = args.trim();
if (!text) return { rest: "" };
const [first, ...others] = text.split(/\s+/);
const verb = VERBS[foldAccents(first)];
return verb ? { verb, rest: others.join(" ") } : { rest: text };
}
interface Seed {
label?: string;
tone?: string;
}
/**
* "/presets nuovo la mia band folk, caldo e artigianale" — the director splits that into
* a name and a tone. It is only a pre-fill: every field is still shown and editable.
*/
async function seedFrom(deps: PresetsDeps, ctx: ExtensionContext, text: string): Promise<Seed> {
const input = text.trim();
if (!input) return {};
let parsed: ParsedBrief | undefined;
if (deps.director) {
try {
parsed =
input.length > 12
? await deps.director.parseFreeText(input, ctx, deps.config)
: deps.director.parseFreeTextLocally(input);
} catch {
parsed = undefined;
}
}
const label = parsed?.fields.title?.trim();
const tone = parsed?.freeText?.trim();
if (label) return { label, tone: tone || undefined };
// No director, or it found nothing: first line names it, the rest is the tone.
const [head, ...tail] = input.split(/[\n,;]+/);
return { label: head.trim() || undefined, tone: tail.join(", ").trim() || undefined };
}
// ---------------------------------------------------------------------------
// Guided flow pieces. EVERY dialog can return undefined — that always means "stop".
// ---------------------------------------------------------------------------
const CANCELLED = Symbol("cancelled");
type Answer<T> = T | typeof CANCELLED;
const cancelled = <T,>(v: Answer<T>): v is typeof CANCELLED => v === CANCELLED;
/** Esc anywhere ends the flow, and he is told so — silence reads as a crash. */
function giveUp(ctx: ExtensionCommandContext): false {
ctx.ui.notify(S.common.cancelled, "info");
return false;
}
function moodOptions(): { options: string[]; pick(chosen: string | undefined): string | undefined } {
const labels = new Map<string, string>();
for (const entry of PALETTES) labels.set(`${entry.name} — ${entry.mood}`, entry.name);
return {
options: [...labels.keys()],
pick: (chosen) => (chosen === undefined ? undefined : labels.get(chosen)),
};
}
function pairingOptions(): {
options: string[];
pick(chosen: string | undefined): { display: string; body: string } | undefined;
} {
const labels = new Map<string, { display: string; body: string }>();
for (const p of PAIRINGS) labels.set(p.mood, { display: p.display, body: p.body });
return {
options: [...labels.keys()],
pick: (chosen) => (chosen === undefined ? undefined : labels.get(chosen)),
};
}
/** Asks for the four colours one at a time; empty keeps what the mood chose. */
async function askColours(ctx: ExtensionCommandContext, current: Palette): Promise<Answer<Palette>> {
const out: Palette = { ...current };
for (const key of ["bg", "ink", "accent", "scrim"] as const) {
const answer = await ctx.ui.input(
fill(S.presets.colourAsk, { campo: S.presets.colourFields[key] }),
out[key],
);
if (answer === undefined) return CANCELLED;
const typed = answer.trim();
if (!typed) continue;
const hex = normalizeHex(typed);
if (!hex) {
ctx.ui.notify(fill(S.presets.colourInvalid, { valore: typed }), "warning");
continue;
}
out[key] = hex;
}
return out;
}
/** Re-asks up to three times rather than throwing the whole flow away on a typo. */
async function askLogo(
ctx: ExtensionCommandContext,
current: string | undefined,
): Promise<Answer<string | undefined>> {
for (let attempt = 0; attempt < 3; attempt++) {
const answer = await ctx.ui.input(
S.presets.logoQuestion,
current ?? S.presets.logoPlaceholder,
);
if (answer === undefined) return CANCELLED;
const typed = answer.trim();
if (!typed) return current;
try {
return validateLogo(typed, ctx.cwd);
} catch (e) {
ctx.ui.notify((e as Error).message, "warning");
}
}
ctx.ui.notify(S.presets.logoHint, "info");
return current;
}
async function askName(
ctx: ExtensionCommandContext,
cfg: ImgenConfig,
seed?: string,
/** The preset being renamed: keeping its own name is not an overwrite. */
skipKey?: string,
): Promise<Answer<{ key: string; label: string }>> {
const answer = await ctx.ui.input(S.presets.nameQuestion, seed ?? S.presets.namePlaceholder);
if (answer === undefined) {
ctx.ui.notify(S.common.cancelled, "info");
return CANCELLED;
}
const label = answer.trim();
if (!label) {
ctx.ui.notify(S.presets.nameMissing, "warning");
return CANCELLED;
}
const key = presetKey(label);
if (!key) {
ctx.ui.notify(S.presets.nameBad, "warning");
return CANCELLED;
}
if (cfg.presets[key] && key !== skipKey) {
const ok = await ctx.ui.confirm(
S.presets.manageTitle,
fill(S.presets.overwriteQuestion, { nome: cfg.presets[key].label || key }),
);
if (!ok) return CANCELLED;
}
return { key, label };
}
// ---------------------------------------------------------------------------
// Flows
// ---------------------------------------------------------------------------
async function createFlow(
pi: ExtensionAPI,
deps: PresetsDeps,
ctx: ExtensionCommandContext,
seed: Seed = {},
): Promise<boolean> {
const named = await askName(ctx, deps.config, seed.label);
if (cancelled(named)) return false;
const moods = moodOptions();
const moodName = moods.pick(await ctx.ui.select(S.presets.moodQuestion, moods.options));
if (moodName === undefined) return giveUp(ctx);
const preset = presetFromMood(named.label, moodName);
const pairings = pairingOptions();
const fontChoice = await ctx.ui.select(S.presets.fontsQuestion, [
S.presets.fontsKeep,
...pairings.options,
]);
if (fontChoice === undefined) return giveUp(ctx);
if (fontChoice !== S.presets.fontsKeep) {
const picked = pairings.pick(fontChoice);
if (picked) preset.fonts = picked;
}
const base = resolvedPalette(preset).palette;
const wantsOwnColours = await ctx.ui.confirm(S.presets.coloursQuestion, paletteLine(base));
if (wantsOwnColours) {
const chosen = await askColours(ctx, base);
if (cancelled(chosen)) return giveUp(ctx);
preset.palette = chosen;
}
const logo = await askLogo(ctx, undefined);
if (cancelled(logo)) return giveUp(ctx);
if (logo) preset.logo = logo;
const tone = await ctx.ui.input(S.presets.toneQuestion, seed.tone ?? S.presets.tonePlaceholder);
if (tone === undefined) return giveUp(ctx);
if (tone.trim()) preset.tone = tone.trim();
return finishSave(pi, deps, ctx, named.key, preset, { announce: S.presets.saved });
}
async function editFlow(
pi: ExtensionAPI,
deps: PresetsDeps,
ctx: ExtensionCommandContext,
key: string,
): Promise<boolean> {
let preset: Preset = { ...deps.config.presets[key] };
let newKey = key;
let dirty = false;
const fields = menu(S.presets.editFields, S.presets.editOrder);
for (;;) {
const chosen = fields.pick(
await ctx.ui.select(fill(S.presets.editWhat, { nome: preset.label || newKey }), fields.options),
);
if (chosen === undefined) {
ctx.ui.notify(S.common.cancelled, "info");
return false;
}
if (chosen === "done") break;
if (chosen === "label") {
const named = await askName(ctx, deps.config, preset.label, newKey);
if (cancelled(named)) continue;
newKey = named.key;
preset.label = named.label;
dirty = true;
} else if (chosen === "mood") {
const moods = moodOptions();
const moodName = moods.pick(await ctx.ui.select(S.presets.moodQuestion, moods.options));
if (moodName === undefined) continue;
preset = presetFromMood(preset.label, moodName, preset);
dirty = true;
} else if (chosen === "colours") {
const chosenPalette = await askColours(ctx, resolvedPalette(preset).palette);
if (cancelled(chosenPalette)) continue;
preset.palette = chosenPalette;
dirty = true;
} else if (chosen === "fonts") {
const pairings = pairingOptions();
const picked = pairings.pick(await ctx.ui.select(S.presets.fontsQuestion, pairings.options));
if (picked === undefined) continue;
preset.fonts = picked;
dirty = true;
} else if (chosen === "logo") {
const logo = await askLogo(ctx, preset.logo);
if (cancelled(logo)) continue;
if (logo) preset.logo = logo;
else delete preset.logo;
dirty = true;
} else if (chosen === "tone") {
const tone = await ctx.ui.input(
S.presets.toneQuestion,
preset.tone ?? S.presets.tonePlaceholder,
);
if (tone === undefined) continue;
if (tone.trim()) preset.tone = tone.trim();
else delete preset.tone;
dirty = true;
}
}
if (!dirty) {
ctx.ui.notify(S.presets.nothingChanged, "info");
return false;
}
const saved = await finishSave(pi, deps, ctx, newKey, preset, { announce: S.presets.updated });
// Renaming moves the preset: drop the old key only once the new one is safely on disk.
if (saved && newKey !== key) await removePreset(deps, key);
return saved;
}
/** Summary + confirm + write + "shall I start from this one?" — shared by create and edit. */
async function finishSave(
pi: ExtensionAPI,
deps: PresetsDeps,
ctx: ExtensionCommandContext,
key: string,
preset: Preset,
opts: { announce: string },
): Promise<boolean> {
const { palette, warnings } = resolvedPalette(preset);
const stored: Preset = { ...preset, palette };
const summary = describePreset({ key, preset: stored, isDefault: deps.config.defaultPreset === key });
const notes = warnings.length > 0 ? `\n${S.presets.colourAdjusted}\n${bullets(warnings)}` : "";
const ok = await ctx.ui.confirm(
fill(S.presets.summaryTitle, { nome: stored.label || key }),
`${summary}${notes}\n\n${S.presets.confirmSave}`,
);
if (!ok) {
ctx.ui.notify(S.common.cancelled, "info");
return false;
}
const makeDefault =
deps.config.defaultPreset === key
? false
: await ctx.ui.confirm(S.presets.defaultQuestion, stored.label || key);
ctx.ui.setStatus(STATUS_KEY, S.progress.exporting);
const path = await savePreset(deps, key, stored, { makeDefault });
const lines = [
fill(opts.announce, { nome: stored.label || key }),
summary,
...(notes ? [notes.trim()] : []),
...(makeDefault ? [fill(S.presets.defaultSet, { nome: stored.label || key })] : []),
fill(S.presets.savedWhere, { dove: path }),
];
await report(pi, ctx, { text: lines.join("\n"), preset: stored, details: { key, preset: stored } });
ctx.ui.notify(fill(opts.announce, { nome: stored.label || key }), "info");
return true;
}
async function removeFlow(
pi: ExtensionAPI,
deps: PresetsDeps,
ctx: ExtensionCommandContext,
key: string,
): Promise<boolean> {
const label = deps.config.presets[key].label || key;
const ok = await ctx.ui.confirm(
S.presets.manageTitle,
fill(S.presets.deleteQuestion, { nome: label }),
);
if (!ok) {
ctx.ui.notify(S.common.cancelled, "info");
return false;
}
ctx.ui.setStatus(STATUS_KEY, S.progress.exporting);
const path = await removePreset(deps, key);
const text = `${fill(S.presets.deleted, { nome: label })}\n${fill(S.presets.savedWhere, { dove: path })}`;
await report(pi, ctx, { text, details: { key } });
ctx.ui.notify(fill(S.presets.deleted, { nome: label }), "info");
return true;
}
async function defaultFlow(
pi: ExtensionAPI,
deps: PresetsDeps,
ctx: ExtensionCommandContext,
): Promise<boolean> {
const entries = listPresets(deps.config);
if (entries.length === 0) {
await report(pi, ctx, { text: `${S.presets.listEmpty}\n${S.presets.listHint}` });
return false;
}
const labels = new Map<string, string | null>([[S.presets.defaultNone, null]]);
for (const e of entries) labels.set(e.preset.label || e.key, e.key);
const chosen = await ctx.ui.select(S.presets.title, [...labels.keys()]);
if (chosen === undefined) {
ctx.ui.notify(S.common.cancelled, "info");
return false;
}
const key = labels.get(chosen) ?? null;
ctx.ui.setStatus(STATUS_KEY, S.progress.exporting);
await setDefaultPreset(deps, key);
const text = key ? fill(S.presets.defaultSet, { nome: chosen }) : S.presets.defaultCleared;
await report(pi, ctx, { text, details: { defaultPreset: key } });
ctx.ui.notify(text, "info");
return true;
}
/** Picks a preset by name, or asks. Returns undefined when he backs out. */
async function pickPreset(
pi: ExtensionAPI,
deps: PresetsDeps,
ctx: ExtensionCommandContext,
hint: string,
): Promise<string | undefined> {
const entries = listPresets(deps.config);
if (entries.length === 0) {
await report(pi, ctx, { text: `${S.presets.listEmpty}\n${S.presets.listHint}` });
return undefined;
}
const direct = resolvePresetName(deps.config, hint);
if (direct) return direct;
const labels = new Map(entries.map((e) => [e.preset.label || e.key, e.key] as const));
const chosen = await ctx.ui.select(S.presets.pick, [...labels.keys()]);
if (chosen === undefined) {
ctx.ui.notify(S.common.cancelled, "info");
return undefined;
}
return labels.get(chosen);
}
// ---------------------------------------------------------------------------
// The command
// ---------------------------------------------------------------------------
const STATUS_KEY = "imgen-presets";
/**
* Headless (`--print`, JSON, any surface without dialogs): never hang waiting for an
* answer nobody can give. Reading works, and so does the one write that needs no
* question; everything else says where to go.
*/
async function runHeadless(
pi: ExtensionAPI,
deps: PresetsDeps,
ctx: ExtensionCommandContext,
verb: Verb | undefined,
rest: string,
): Promise<void> {
const target = resolvePresetName(deps.config, rest);
if (verb === "setDefault" && target) {
const path = await setDefaultPreset(deps, target);
const label = deps.config.presets[target].label || target;
await report(pi, ctx, {
text: `${fill(S.presets.defaultSet, { nome: label })}\n${fill(S.presets.savedWhere, { dove: path })}`,
details: { defaultPreset: target },
});
return;
}
if (verb === "show" || (!verb && target)) {
const key = target;
if (key) {
const entry: PresetEntry = {
key,
preset: deps.config.presets[key],
isDefault: deps.config.defaultPreset === key,
};
await report(pi, ctx, { text: describePreset(entry), details: { key } });
return;
}
}
const needsDialogs = verb === "create" || verb === "edit" || verb === "remove" || verb === "setDefault";
const text = needsDialogs
? `${S.presets.needsTerminal}\n\n${describeAll(deps.config)}`
: describeAll(deps.config);
await report(pi, ctx, { text, details: { presets: deps.config.presets } });
}
async function run(
pi: ExtensionAPI,
deps: PresetsDeps,
ctx: ExtensionCommandContext,
args: string,
): Promise<void> {
const { verb, rest } = parseArgs(args);
if (!ctx.hasUI) {
await runHeadless(pi, deps, ctx, verb, rest);
return;
}
switch (verb) {
case "list":
await report(pi, ctx, { text: describeAll(deps.config), details: { presets: deps.config.presets } });
return;
case "create":
await createFlow(pi, deps, ctx, await seedFrom(deps, ctx, rest));
return;
case "edit": {
const key = await pickPreset(pi, deps, ctx, rest);
if (key) await editFlow(pi, deps, ctx, key);
return;
}
case "remove": {
const key = await pickPreset(pi, deps, ctx, rest);
if (key) await removeFlow(pi, deps, ctx, key);
return;
}
case "setDefault":
await defaultFlow(pi, deps, ctx);
return;
case "show": {
const key = await pickPreset(pi, deps, ctx, rest);
if (!key) return;
const entry: PresetEntry = {
key,
preset: deps.config.presets[key],
isDefault: deps.config.defaultPreset === key,
};
await report(pi, ctx, { text: describePreset(entry), preset: entry.preset, details: { key } });
return;
}
default:
break;
}
// No verb. Free text that names a preset shows it; anything else is the start of a
// new one ("/presets la mia band"); nothing at all opens the menu.
const named = resolvePresetName(deps.config, rest);
if (named) {
const entry: PresetEntry = {
key: named,
preset: deps.config.presets[named],
isDefault: deps.config.defaultPreset === named,
};
await report(pi, ctx, { text: describePreset(entry), preset: entry.preset, details: { key: named } });
return;
}
if (rest) {
await createFlow(pi, deps, ctx, await seedFrom(deps, ctx, rest));
return;
}
await mainMenu(pi, deps, ctx);
}
async function mainMenu(
pi: ExtensionAPI,
deps: PresetsDeps,
ctx: ExtensionCommandContext,
): Promise<void> {
const actions = menu(S.presets.actions, S.presets.order);
for (;;) {
const chosen = actions.pick(await ctx.ui.select(S.presets.manageTitle, actions.options));
if (chosen === undefined || chosen === "close") {
ctx.ui.notify(S.common.cancelled, "info");
return;
}
switch (chosen) {
case "list":
await report(pi, ctx, {
text: `${describeAll(deps.config)}\n${S.presets.intro}`,
details: { presets: deps.config.presets },
});
break;
case "create":
await createFlow(pi, deps, ctx);
break;
case "edit": {
const key = await pickPreset(pi, deps, ctx, "");
if (key) await editFlow(pi, deps, ctx, key);
break;
}
case "remove": {
const key = await pickPreset(pi, deps, ctx, "");
if (key) await removeFlow(pi, deps, ctx, key);
break;
}
case "setDefault":
await defaultFlow(pi, deps, ctx);
break;
}
}
}
/**
* Registers `/presets`. Construction (config, backend, renderer, director) stays in
* index.ts; this file only ever reads what it is handed.
*/
export function registerPresets(pi: ExtensionAPI, deps: PresetsDeps): void {
pi.registerCommand("presets", {
description: S.commands.presets.description,
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
const want = foldAccents(prefix.trim());
const items: AutocompleteItem[] = [];
for (const key of Object.keys(VERBS)) {
if (key.startsWith(want)) items.push({ value: key, label: key });
}
for (const entry of listPresets(deps.config)) {
const label = entry.preset.label || entry.key;
if (foldAccents(label).includes(want) || entry.key.includes(want)) {
items.push({ value: entry.key, label, description: entry.preset.tone });
}
}
return items.length > 0 ? items.slice(0, 20) : null;
},
handler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
try {
await run(pi, deps, ctx, args);
} finally {
// A status line that outlives its command is a bug the user cannot clear.
ctx.ui.setStatus(STATUS_KEY, undefined);
}
},
});
}
export default registerPresets;