import { Signal } from "@char/aftercare"; import type { JsonValue } from "@berry/api"; import type { ConfigurationContext, ConfigurationSchema, FieldSchema } from "@berry/api/web.ts"; import { extensionRenderers } from "./renderers.ts"; import { folderInput, type FolderOptions } from "./folders.tsx"; import type { ProjectIndex } from "@berry/api/projects.ts"; import { api, watch } from "./client.ts"; import type { Shared } from "./conversation.tsx"; import { modelKey } from "./settings.tsx"; import { computed, type Scope } from "./ui.ts"; export type { ConfigurationSchema, FieldSchema } from "@berry/api/web.ts"; type Values = Record; export type ConfigurationLocation = Pick< ConfigurationContext, "tid" | "model" | "request" | "command" >; /** Bind custom or generated controls to configuration values and the selected model. */ export function configurationControls( scope: Scope, name: string, schema: ConfigurationSchema, location: Signal, values: Signal, apply: (patch: Values) => Values, generated: (save: (patch: Values) => Promise) => HTMLElement, status?: Signal<{ pending: number; error: string | null }>, ): HTMLElement { const view = extensionRenderers(name)?.configuration?.(); let form: HTMLElement | undefined; let disposed = false; let error: string | null = null; const renderDefault = () => form ??= generated(save); const draw = () => { if (disposed) return; view?.update({ ...location.get(), extension: name, schema, values: values.get(), saveStatus: status?.get().pending ? "saving" : error || status?.get().error ? "error" : "idle", error: error ?? status?.get().error ?? null, save: async (patch) => { await save(patch); }, renderDefault, }); }; const save = (patch: Values): Promise => { if (disposed || values.get() === null) { return Promise.reject(new Error("Configuration is not available.")); } error = null; try { // draft answers must be updated before a synchronous form submission can read them const next = apply(patch); values.set(next); draw(); return Promise.resolve(next); } catch (cause) { error = cause instanceof Error ? cause.message : String(cause); draw(); return Promise.reject(cause); } }; const element = view ?
{view.element}
as HTMLElement : renderDefault(); scope.add(() => { disposed = true; view?.dispose?.(); }); scope.add(location.subscribe(draw).unsubscribe); scope.add(values.subscribe(draw).unsubscribe); if (status) scope.add(status.subscribe(draw).unsubscribe); draw(); return element; } /** Choices a field's schema cannot list, as they are what the server knows now. */ export type LiveChoices = Readonly>>; /** The live choices of an extension's fields: the machines an environment can be, the models a subagent can run on. */ export function liveChoices( scope: Scope, extension: string, shared: Pick, ): LiveChoices { if (extension === "environment") return { environment: shared.machines }; if (extension === "subagent") { return { models: computed(scope, [shared.enabled], () => shared.enabled.get().map(modelKey)) }; } return {}; } /** One question of a configuration: a typebox property, of the few kinds a form draws. */ export function configurationField( key: string, schema: FieldSchema, value: JsonValue | undefined, answer: (value: JsonValue) => void, live?: { scope: Scope; choices: Signal }, folders?: FolderOptions, ) { if (schema.type === "array") return listField(key, schema, value, answer, live); const choices = schema.enum ?? schema.anyOf?.map((option) => option.const ?? null); const boolean = schema.type === "boolean"; const numeric = schema.type === "number" || schema.type === "integer"; const folder = folders ? folderInput(folders) : undefined; const input = (live || choices?.every((choice) => typeof choice === "string") ? ( ) : folder?.input ?? ) as | HTMLInputElement | HTMLSelectElement; // the choices as they are, and the value even once it is not among them, so it still shows let selected = ""; const draw = () => { if (!live) return; const names = live.choices.get(); const all = selected === "" || names.includes(selected) ? names : [...names, selected]; input.replaceChildren(...all.map((name) => )); input.value = selected; }; if (live) { live.scope.add(live.choices.subscribe(draw).unsubscribe); input.addEventListener("change", () => void (selected = input.value)); } if (numeric) (input as HTMLInputElement).step = schema.type === "integer" ? "1" : "any"; const read = (): JsonValue => boolean ? (input as HTMLInputElement).checked : numeric ? (input as HTMLInputElement).valueAsNumber : input.value; const update = (value: JsonValue | undefined) => { const next = value ?? schema.default ?? null; if (boolean) (input as HTMLInputElement).checked = next === true; else if (live) { selected = next === null ? "" : String(next); draw(); } else input.value = next === null ? "" : String(next); }; update(value); input.addEventListener(boolean || choices || live ? "change" : "input", () => answer(read())); const label = schema.title ?? key; return { element: folder ? (
{folder.element}
) : ( ), input, get value() { return read(); }, update, }; } /** A question with any number of answers among its choices, a box for each. */ function listField( key: string, schema: FieldSchema, value: JsonValue | undefined, answer: (value: JsonValue) => void, live?: { scope: Scope; choices: Signal }, ) { const fixed = (schema.items?.enum ?? schema.items?.anyOf?.map((option) => option.const) ?? []) .filter((choice) => typeof choice === "string"); const input =
as HTMLFieldSetElement; let selected: string[] = []; const read = () => [...input.querySelectorAll("input:checked")].map((box) => box.value); // the choices as they are, and the answers even once they are not among them, so they still show const draw = () => { const choices = live?.choices.get() ?? fixed; const all = [...choices, ...selected.filter((choice) => !choices.includes(choice))]; input.replaceChildren(...all.map((choice) => ( ))); }; if (live) live.scope.add(live.choices.subscribe(draw).unsubscribe); const update = (value: JsonValue | undefined) => { const next = value ?? schema.default; selected = Array.isArray(next) ? next.filter((item) => typeof item === "string") : []; draw(); }; update(value); input.addEventListener("change", () => answer(selected = read())); return { element: (
{schema.title ?? key} {input}
), input, get value(): JsonValue { return read(); }, update, }; } /** A live configuration draft whose edits become events when committed. */ export function configurationEditor( scope: Scope, tid: string, name: string, schema: ConfigurationSchema, saving: (save: Promise) => void, live: LiveChoices = {}, projects?: Signal, location = new Signal({ tid, model: null }), ) { const values = new Signal(null); const status = new Signal({ pending: 0, error: null as string | null }); let current: Values | null = null; let tail: Promise = Promise.resolve(); const edits = new Map(); const submitted = new Map(); const draw = () => values.set( current && { ...current, ...Object.fromEntries([...edits].map(([key, edit]) => [key, edit.value])), }, ); scope.add(watch(`configuration:${tid}:${name}`, (next) => { current = next; draw(); })); const element = configurationControls( scope, name, schema, location, values, (patch) => { for (const [key, value] of Object.entries(patch)) { if (JSON.stringify(value) === JSON.stringify(values.get()?.[key])) continue; if (!submitted.has(key) && JSON.stringify(value) === JSON.stringify(current?.[key])) { edits.delete(key); } else edits.set(key, { value }); } return { ...values.get()!, ...patch }; }, (save) => generatedEditor(scope, name, schema, values, save, live, status, () => current, projects), status, ); return { element, commit() { const batch = [...edits].filter(([key, edit]) => submitted.get(key) !== edit); if (!batch.length) return; for (const [key, edit] of batch) submitted.set(key, edit); status.set({ pending: status.get().pending + 1, error: null }); const operation = tail.catch(() => {}).then(() => api( "PATCH", `/api/conversations/${tid}/configuration/${encodeURIComponent(name)}`, Object.fromEntries(batch.map(([key, edit]) => [key, edit.value])), ) ).then((next) => { current = next; for (const [key, edit] of batch) { if (edits.get(key) === edit) edits.delete(key); if (submitted.get(key) === edit) submitted.delete(key); } draw(); status.set({ ...status.get(), error: null }); }).catch((cause) => { for (const [key, edit] of batch) { if (submitted.get(key) === edit) submitted.delete(key); } status.set({ ...status.get(), error: cause instanceof Error ? cause.message : String(cause), }); throw cause; }).finally(() => status.set({ ...status.get(), pending: status.get().pending - 1 })); tail = operation; saving(operation); return operation; }, }; } function generatedEditor( scope: Scope, name: string, schema: ConfigurationSchema, values: Signal, save: (patch: Values) => Promise, live: LiveChoices, remote: Signal<{ pending: number; error: string | null }>, baseline: () => Values | null, projects?: Signal, ) { let current: Values | null = null; const environment = new Signal("local"); const status =

as HTMLElement; const fields = Object.entries(schema.properties).map(([key, schema]) => { const choices = live[key]; const field = configurationField( key, schema, undefined, (value) => { const field = fields.find((field) => field.key === key)!.field; if ( !field.input.checkValidity() || (typeof value === "number" && !Number.isFinite(value)) ) { status.classList.add("error"); status.textContent = field.input.validationMessage || "enter a valid number"; return; } status.classList.remove("error"); status.textContent = ""; const patch: Values = { [key]: value }; if (name === "environment" && key === "environment") { environment.set(String(value)); fields.find(({ key }) => key === "cwd")?.field.update(""); patch.cwd = ""; } void save(patch).catch(() => {}); }, choices && { scope, choices }, name === "environment" && key === "cwd" ? { scope, environment, projects, base: () => { const saved = baseline(); return environment.get() === saved?.environment ? String(saved?.cwd || "") || null : null; }, } : undefined, ); field.input.addEventListener("keydown", (event) => { if ((event as KeyboardEvent).key === "Enter") { event.preventDefault(); (document.activeElement as HTMLElement | null)?.blur(); } }); return { key, field }; }); const element = (

{fields.map(({ field }) => field.element)} {status}
) as HTMLFieldSetElement; const draw = () => { if (!current) return; for (const { key, field } of fields) { if (!field.input.contains(document.activeElement)) { field.update(current[key]); } } }; scope.add( values.subscribeImmediate((values) => { current = values; if (name === "environment" && values) { environment.set(String(values.environment)); } element.disabled = current === null; draw(); }).unsubscribe, ); scope.add( remote.subscribeImmediate(({ pending, error }) => { status.classList.toggle("error", error !== null); status.textContent = error ?? (pending ? "applying configuration (pending if agent is busy)" : ""); }).unsubscribe, ); return element; }