char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somnotify on complete message2cf0e3c

main
32.8 KiB990 linesraw
1import { Signal } from "@char/aftercare";
2import { api, watch } from "./client.ts";
3import type { ExtensionInfo, Shared } from "./conversation.tsx";
4import { computed, keyed, Scope, swap } from "./ui.ts";
5import { notificationsAvailable, notificationsOn, setNotifications } from "./notifications.ts";
6
7export type ModelRef = { provider: string; modelId: string };
8export type ModelInfo = {
9  provider: string;
10  id: string;
11  name: string;
12  input: ("text" | "image")[];
13  reasoning: boolean;
14  thinkingLevels: string[];
15  contextWindow: number;
16  cost: { input: number; output: number };
17};
18
19export const modelKey = (model: ModelRef | ModelInfo) =>
20  `${model.provider}/${"modelId" in model ? model.modelId : model.id}`;
21
22const tokens = (n: number) => (n >= 1_000_000 ? `${n / 1_000_000}M` : `${Math.round(n / 1000)}k`);
23const price = (n: number) => `$${Number(n.toFixed(2))}`;
24
25/** A choice among the enabled models, by provider. A model since disabled stays, marked, while it is chosen. */
26export function modelSelect(
27  scope: Scope,
28  models: Signal<readonly ModelInfo[]>,
29  enabled: Signal<readonly ModelRef[]>,
30  current: Signal<string>,
31  choose: (model: ModelRef) => void,
32): HTMLSelectElement {
33  const select = <select class="model" aria-label="Model" /> as HTMLSelectElement;
34  const render = () => {
35    const known = new Map(models.get().map((model) => [modelKey(model), model]));
36    const keys = enabled.get().map(modelKey);
37    const chosen = current.get();
38    if (chosen !== "" && !keys.includes(chosen)) keys.unshift(chosen);
39    const names = keys.map((key) => known.get(key)?.name ?? key.slice(key.indexOf("/") + 1));
40    // in the order settings give them; a provider shows only where it tells two of the same name apart
41    select.replaceChildren(
42      ...(chosen === "" ? [<option value="" disabled selected>pick a model</option>] : []),
43      ...keys.map((key, index) => {
44        const name = names[index];
45        const shared = names.indexOf(name) !== names.lastIndexOf(name);
46        const marks = [
47          !enabled.get().some((m) => modelKey(m) === key) && "not enabled",
48          !known.has(key) && "logged out",
49        ].filter(Boolean);
50        return (
51          <option value={key}>
52            {shared ? `${name} · ${key.slice(0, key.indexOf("/"))}` : name}
53            {marks.length > 0 ? ` (${marks.join(", ")})` : ""}
54          </option>
55        );
56      }),
57    );
58    select.value = chosen;
59  };
60  for (const source of [models, enabled, current]) scope.add(source.subscribe(render).unsubscribe);
61  render();
62  select.addEventListener("change", () => {
63    const slash = select.value.indexOf("/");
64    choose({ provider: select.value.slice(0, slash), modelId: select.value.slice(slash + 1) });
65  });
66  return select;
67}
68
69const save = (models: readonly ModelRef[]) =>
70  void api("PATCH", "/api/settings", {
71    models: models.map(({ provider, modelId }) => ({ provider, modelId })),
72  })
73    .catch(console.error);
74
75const refOf = (model: ModelInfo): ModelRef => ({ provider: model.provider, modelId: model.id });
76
77/** A model row's name, id, and what it offers; a logged-out model has only its key. */
78function describe(model: ModelInfo | undefined, key: string): Node[] {
79  if (model === undefined) {
80    return [
81      <span class="model-name">{key.slice(key.indexOf("/") + 1)}</span>,
82      <code>{key}</code>,
83      <span class="model-meta">
84        <span>logged out</span>
85      </span>,
86    ];
87  }
88  return [
89    <span class="model-name">{model.name}</span>,
90    <code>{model.id}</code>,
91    <span class="model-meta">
92      <span>{tokens(model.contextWindow)}</span>
93      <span>{price(model.cost.input)} / {price(model.cost.output)}</span>
94    </span>,
95  ];
96}
97
98/** The enabled models in the order conversations offer them; dragged, or moved with alt+↑/↓, to reorder. */
99function enabledList(
100  scope: Scope,
101  models: Signal<readonly ModelInfo[]>,
102  enabled: Signal<readonly ModelRef[]>,
103) {
104  const list = <ol class="enabled-list" />;
105  // the row a keyboard move should keep focus on once the new order comes back
106  let refocus: string | null = null;
107  const move = (from: number, to: number) => {
108    const order = [...enabled.get()];
109    if (to < 0 || to >= order.length || to === from) return;
110    const [moved] = order.splice(from, 1);
111    order.splice(to, 0, moved);
112    save(order);
113  };
114  const changed = new Signal(0);
115  for (const source of [models, enabled]) {
116    scope.add(source.subscribe(() => changed.set(changed.get() + 1)).unsubscribe);
117  }
118  swap(scope, list, changed, () => {
119    const known = new Map(models.get().map((model) => [modelKey(model), model]));
120    const rows = enabled.get().map((ref, index) => {
121      const key = modelKey(ref);
122      const row = (
123        <li class="model-row" tabIndex={0} dataset={{ key }}>
124          <span class="grip" title="drag to reorder" />
125          <input
126            type="checkbox"
127            checked
128            _onchange={() => save(enabled.get().filter((m) => modelKey(m) !== key))}
129          />
130          {describe(known.get(key), key)}
131        </li>
132      ) as HTMLLIElement;
133      // pointer-driven, as chordpad's track reordering: any pointer, one line where the row will land
134      row.addEventListener("pointerdown", (event) => {
135        if (event.button !== 0 || (event.target as Element).closest("input")) return;
136        // no text selection while dragging; the row still takes focus, for alt+↑/↓
137        event.preventDefault();
138        row.focus();
139        row.setPointerCapture(event.pointerId);
140        const all = [...list.children] as HTMLElement[];
141        let slot = index;
142        const mark = () =>
143          all.forEach((other, i) => {
144            const lands = slot !== index && slot !== index + 1;
145            other.classList.toggle("drop-before", lands && i === slot);
146            other.classList.toggle(
147              "drop-after",
148              lands && slot === all.length && i === all.length - 1,
149            );
150          });
151        const moved = (e: PointerEvent) => {
152          // the slot is how many rows have their middle above the pointer
153          slot = all.filter((other) => {
154            const box = other.getBoundingClientRect();
155            return box.top + box.height / 2 < e.clientY;
156          }).length;
157          mark();
158        };
159        const done = (e: PointerEvent) => {
160          row.removeEventListener("pointermove", moved);
161          row.removeEventListener("pointerup", done);
162          row.removeEventListener("pointercancel", done);
163          row.classList.remove("dragging");
164          document.body.classList.remove("reordering");
165          slot = e.type === "pointercancel" ? index : slot;
166          mark();
167          move(index, slot > index ? slot - 1 : slot);
168        };
169        row.classList.add("dragging");
170        document.body.classList.add("reordering");
171        row.addEventListener("pointermove", moved);
172        row.addEventListener("pointerup", done);
173        row.addEventListener("pointercancel", done);
174      });
175      row.addEventListener("keydown", (event) => {
176        if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
177        event.preventDefault();
178        refocus = key;
179        move(index, index + (event.key === "ArrowUp" ? -1 : 1));
180      });
181      return row;
182    });
183    queueMicrotask(() => {
184      if (refocus === null) return;
185      rows.find((row) => row.dataset.key === refocus)?.focus();
186      refocus = null;
187    });
188    return rows;
189  });
190  return list;
191}
192
193type ProviderInfo = {
194  id: string;
195  name: string;
196  apiKey: { name: string } | null;
197  oauth: { name: string; label: string | null; subscription: boolean } | null;
198  /** How it is configured, if it is. */
199  auth: { type: "api_key" | "oauth"; source?: string } | null;
200  /** Whether berry holds its login, and so can log it out. */
201  stored: boolean;
202};
203
204type LoginState = {
205  status: "running" | "done" | "failed" | "cancelled";
206  events: (
207    | { type: "info"; message: string; links?: { url: string; label?: string }[] }
208    | { type: "auth_url"; url: string; instructions?: string }
209    | { type: "device_code"; userCode: string; verificationUri: string }
210    | { type: "progress"; message: string }
211  )[];
212  prompt:
213    | { id: number; type: "text" | "secret" | "manual_code"; message: string; placeholder?: string }
214    | {
215      id: number;
216      type: "select";
217      message: string;
218      options: { id: string; label: string; description?: string }[];
219    }
220    | null;
221  error: string | null;
222};
223
224const link = (url: string, label?: string) => (
225  <a href={url} target="_blank" rel="noopener noreferrer">{label ?? url}</a>
226);
227
228/** A login as it runs on the server: what it says, what it asks, and how it ended. */
229function loginDialog(provider: ProviderInfo, type: "api_key" | "oauth", settled: () => void) {
230  const scope = new Scope();
231  const dialog = <dialog class="dialog" /> as HTMLDialogElement;
232  const state = new Signal<LoginState | null>(null);
233  let id: string | null = null;
234  const running = () => state.get()?.status === "running" || state.get() === null;
235  const cancel = () => id && api("POST", `/api/logins/${id}/cancel`).catch(console.error);
236  const close = () => {
237    scope.dispose();
238    dialog.close();
239    dialog.remove();
240    settled();
241  };
242  // escape cancels a running login, and closes one that has ended
243  dialog.addEventListener("cancel", (event) => {
244    event.preventDefault();
245    if (running()) void cancel();
246    else close();
247  });
248
249  // each part follows its own slice, so an event arriving mid-answer leaves the question as typed
250  const events = <div class="login-events" />;
251  swap(
252    scope,
253    events,
254    state.derive((login) => login?.events ?? null),
255    (list) =>
256      list === null ? <p class="desc">starting…</p> : list.map((event) => {
257        if (event.type === "auth_url") {
258          return (
259            <p>
260              {event.instructions ?? "sign in on this page:"}{" "}
261              {link(event.url, "open the sign-in page")}
262            </p>
263          );
264        }
265        if (event.type === "device_code") {
266          return (
267            <p>
268              enter <code class="user-code">{event.userCode}</code> at {link(event.verificationUri)}
269            </p>
270          );
271        }
272        if (event.type === "progress") return <p class="desc">{event.message}</p>;
273        return <p>{event.message} {(event.links ?? []).map((l) => link(l.url, l.label))}</p>;
274      }),
275  );
276
277  const question = <div />;
278  swap(scope, question, state.derive((login) => login?.prompt ?? null), (prompt) => {
279    if (prompt === null) return null;
280    if (prompt.type === "select") {
281      return (
282        <div class="question">
283          <p>{prompt.message}</p>
284          {prompt.options.map((option) => (
285            <button
286              type="button"
287              class="option"
288              _onclick={() => answer(prompt.id, option.id)}
289            >
290              <b>{option.label}</b>
291              {option.description ? <span>{option.description}</span> : undefined}
292            </button>
293          ))}
294        </div>
295      );
296    }
297    const input = (
298      // masked by css rather than type="password": a provider's key is not this site's password, and a
299      // password field's form would have the browser offer to save it
300      <input
301        type="text"
302        class={prompt.type === "secret" ? "secret" : ""}
303        placeholder={prompt.placeholder ?? ""}
304        autocomplete="off"
305        spellcheck={false}
306      />
307    ) as HTMLInputElement;
308    queueMicrotask(() => input.focus());
309    return (
310      <form
311        class="question"
312        _onsubmit={(event) => {
313          event.preventDefault();
314          answer(prompt.id, input.value);
315        }}
316      >
317        <label>{prompt.message}</label>
318        {input}
319        <button type="submit" class="primary">continue</button>
320      </form>
321    );
322  });
323
324  const outcome = <div />;
325  swap(
326    scope,
327    outcome,
328    state.derive((login) => login ?? null),
329    (login) =>
330      login?.status === "done"
331        ? <p class="ok">logged in</p>
332        : login?.status === "failed"
333        ? <p class="error">{login.error ?? "the login failed"}</p>
334        : login?.status === "cancelled"
335        ? <p class="desc">cancelled</p>
336        : null,
337  );
338
339  const answer = (prompt: number, value: string) =>
340    void api("POST", `/api/logins/${id}/answer`, { prompt, value }).catch(console.error);
341
342  const actions = <div class="dialog-actions" />;
343  swap(
344    scope,
345    actions,
346    state.derive(() => running()),
347    (busy) =>
348      busy
349        ? <button type="button" _onclick={() => void cancel()}>cancel</button>
350        : <button type="button" class="primary" _onclick={close}>close</button>,
351  );
352
353  dialog.append(
354    <h2>log in to {provider.name}</h2>,
355    <p class="desc">{type === "oauth" ? provider.oauth!.name : provider.apiKey!.name}</p>,
356    events,
357    question,
358    outcome,
359    actions,
360  );
361  document.body.append(dialog);
362  dialog.showModal();
363
364  api<{ login: string }>("POST", `/api/providers/${provider.id}/login`, { type })
365    .then(({ login }) => {
366      id = login;
367      scope.add(watch<LoginState>(`login:${login}`, (value) => state.set(value)));
368    })
369    .catch((error) =>
370      state.set({
371        status: "failed",
372        events: [],
373        prompt: null,
374        error: error instanceof Error ? error.message : String(error),
375      })
376    );
377}
378
379/** The providers: those configured first, with how; the rest folded away, with their logins. */
380function providerList(scope: Scope, shared: Shared): Element {
381  const providers = new Signal<readonly ProviderInfo[]>([]);
382  const refresh = () => {
383    api<ProviderInfo[]>("GET", "/api/providers").then((list) => providers.set(list)).catch(
384      console.error,
385    );
386    shared.refreshModels();
387  };
388  refresh();
389
390  const how = (provider: ProviderInfo) =>
391    provider.auth?.type === "oauth"
392      ? "signed in"
393      : provider.stored
394      ? "api key"
395      : provider.auth?.source ?? "configured";
396  const logins = (provider: ProviderInfo) => [
397    ...(provider.oauth
398      ? [
399        <button
400          type="button"
401          title={provider.oauth.name}
402          _onclick={() => loginDialog(provider, "oauth", refresh)}
403        >
404          {provider.oauth.label ?? (provider.oauth.subscription ? "subscription" : "sign in")}
405        </button>,
406      ]
407      : []),
408    ...(provider.apiKey
409      ? [
410        <button
411          type="button"
412          title={provider.apiKey.name}
413          _onclick={() => loginDialog(provider, "api_key", refresh)}
414        >
415          api key
416        </button>,
417      ]
418      : []),
419  ];
420  const row = (provider: ProviderInfo) => (
421    <div class="provider-row">
422      <span class="model-name">{provider.name}</span>
423      <code>{provider.id}</code>
424      <span class="model-meta">
425        {provider.auth ? <span class="ok">{how(provider)}</span> : undefined}
426      </span>
427      <span class="actions">
428        {provider.stored
429          ? (
430            <button
431              type="button"
432              class="danger"
433              _onclick={() =>
434                api("POST", `/api/providers/${provider.id}/logout`).then(refresh).catch(
435                  console.error,
436                )}
437            >
438              log out
439            </button>
440          )
441          : provider.auth
442          ? undefined
443          : logins(provider)}
444      </span>
445    </div>
446  );
447
448  const list = <div class="provider-list" />;
449  swap(scope, list, providers, (all) => {
450    const configured = all.filter((provider) => provider.auth !== null);
451    const others = all.filter((provider) => provider.auth === null);
452    return [
453      ...configured.map(row),
454      <details class="provider">
455        <summary>
456          <span class="provider-name">other providers</span>
457          <span class="count">{String(others.length)}</span>
458        </summary>
459        {others.map(row)}
460      </details>,
461    ];
462  });
463  return list;
464}
465
466/** The extensions on disk and how each one's last load went, with a reload. */
467function extensionList(scope: Scope, shared: Shared): Element {
468  const busy = (button: HTMLButtonElement, work: Promise<unknown>) => {
469    button.disabled = true;
470    return work.finally(() => (button.disabled = false));
471  };
472  const duration = (ms: number) =>
473    ms < 1000 ? `${ms.toFixed(1)} ms` : `${(ms / 1000).toFixed(2)} s`;
474  type ProseState = {
475    details: HTMLDetailsElement;
476    fieldset: HTMLFieldSetElement;
477    textarea: HTMLTextAreaElement;
478    status: HTMLElement;
479    dirty: boolean;
480    defaultText: string;
481  };
482  const editors = new Map<string, ProseState>();
483  let overrides: Record<string, string> | null = null;
484  let saving = Promise.resolve();
485  const draw = (name: string) => {
486    const state = editors.get(name);
487    if (!state) return;
488    if (overrides === null) {
489      state.fieldset.disabled = true;
490      return;
491    }
492    state.fieldset.disabled = false;
493    const effective = overrides[name] ?? state.defaultText;
494    if (!state.dirty && state.textarea !== document.activeElement) {
495      state.textarea.value = effective;
496    }
497  };
498  scope.add(
499    watch<{ systemPrompt?: { promptProse?: Record<string, string> } }>("settings", (value) => {
500      overrides = value.systemPrompt?.promptProse ?? {};
501      for (const name of editors.keys()) draw(name);
502    }),
503  );
504  const proseEditor = (name: string, prose: { default: string } | null): Element | undefined => {
505    if (!prose) return undefined;
506    const cached = editors.get(name);
507    if (cached) {
508      if (cached.defaultText !== prose.default) {
509        cached.defaultText = prose.default;
510        draw(name);
511      }
512      return cached.details;
513    }
514    const textarea = <textarea rows={6} /> as HTMLTextAreaElement;
515    const status = (
516      <p
517        class="configuration-status"
518        role="status"
519        aria-live="polite"
520      />
521    ) as HTMLElement;
522    const fieldset = (
523      <fieldset class="system-prompt" disabled>
524        <label class="field">
525          <span>prompt prose</span>
526          {textarea}
527          {status}
528        </label>
529      </fieldset>
530    ) as HTMLFieldSetElement;
531    const details = (
532      <details>
533        <summary>system prompt</summary>
534        {fieldset}
535      </details>
536    ) as HTMLDetailsElement;
537    const state: ProseState = {
538      details,
539      fieldset,
540      textarea,
541      status,
542      dirty: false,
543      defaultText: prose.default,
544    };
545    editors.set(name, state);
546    textarea.setAttribute("aria-label", `${name} prompt prose`);
547    textarea.addEventListener("input", () => {
548      state.dirty = true;
549      status.textContent = "";
550    });
551    textarea.addEventListener("blur", () => {
552      if (!state.dirty) {
553        draw(name);
554        return;
555      }
556      const value = textarea.value;
557      saving = saving.then(async () => {
558        const effective = overrides?.[name] ?? state.defaultText;
559        if (value !== effective) {
560          status.classList.remove("error");
561          status.textContent = "saving…";
562          try {
563            const saved = await api<{ systemPrompt: { promptProse?: Record<string, string> } }>(
564              "PATCH",
565              "/api/settings",
566              { systemPrompt: { promptProse: { [name]: value } } },
567            );
568            overrides = saved.systemPrompt.promptProse ?? {};
569            status.textContent = "saved";
570          } catch (cause) {
571            status.classList.add("error");
572            status.textContent = cause instanceof Error ? cause.message : String(cause);
573            return;
574          }
575        }
576        if (textarea.value === value) state.dirty = false;
577        draw(name);
578      });
579    });
580    draw(name);
581    return details;
582  };
583  const list = <div class="provider-list extension-list" />;
584  const listed = shared.extensions.derive((all) =>
585    all.filter((extension) => !extension.builtin || extension.promptProse)
586  );
587  const rows = keyed<ExtensionInfo, string>(
588    list,
589    (extension) => extension.name,
590    (initial) => {
591      const node = <div class="extension" /> as HTMLDivElement;
592      const head = <div class="provider-row" /> as HTMLDivElement;
593      node.append(head);
594      let shown: Element | undefined;
595      const update = (extension: ExtensionInfo) => {
596        head.replaceChildren(
597          <span class="model-name">{extension.name}</span>,
598          <span class="description">{extension.description ?? ""}</span>,
599          ...(extension.startup
600            ? [
601              <details class="extension-startup">
602                <summary>
603                  {extension.startup.status === "failed" ? "startup failed" : "startup"}{" "}
604                  {duration(extension.startup.totalMs)}
605                </summary>
606                <dl>
607                  {extension.startup.phases.map((phase) => (
608                    <>
609                      <dt>
610                        {phase.name === "import"
611                          ? "module import"
612                          : phase.name === "frontend"
613                          ? phase.source === "cache"
614                            ? "frontend cache restore"
615                            : phase.source === "build"
616                            ? "frontend build"
617                            : "frontend"
618                          : "installation"}
619                      </dt>
620                      <dd>{duration(phase.durationMs)}</dd>
621                    </>
622                  ))}
623                </dl>
624              </details>,
625            ]
626            : []),
627          <span class={extension.status === "loaded" ? "ok" : "error"}>
628            {extension.status === "loaded"
629              ? "loaded"
630              : extension.running
631              ? "reload failed"
632              : "failed"}
633          </span>,
634          ...(extension.builtin ? [] : [
635            <span class="actions">
636              <button
637                type="button"
638                _onclick={function () {
639                  void busy(this, api("POST", `/api/extensions/${extension.name}/reload`)).catch(
640                    console.error,
641                  );
642                }}
643              >
644                reload
645              </button>
646            </span>,
647          ]),
648          ...(extension.directory
649            ? [<code title={extension.directory}>{extension.directory}</code>]
650            : []),
651        );
652        const failed = node.querySelector("pre.error");
653        if (extension.error) {
654          if (failed) failed.textContent = extension.error;
655          else node.append(<pre class="error">{extension.error}</pre>);
656        } else failed?.remove();
657        // the same editor node while its registration persists, so a focused draft keeps focus
658        const editor = proseEditor(extension.name, extension.promptProse);
659        if (editor !== shown) {
660          shown?.remove();
661          shown = editor;
662          if (editor) node.insertBefore(editor, node.querySelector("pre.error") ?? null);
663        }
664      };
665      update(initial);
666      return { node, update };
667    },
668  );
669  const sync = () => rows.sync(listed.get());
670  sync();
671  scope.add(listed.subscribe(sync).unsubscribe);
672  scope.add(rows.dispose);
673  return list;
674}
675
676type EnvironmentSettings = { ssh: string; deno?: string };
677
678/** The machines conversations may work on besides this one, and a form for another. */
679function environmentList(scope: Scope): Element {
680  const configured = new Signal<Readonly<Record<string, EnvironmentSettings>>>({});
681  const error = <p class="error" /> as HTMLParagraphElement;
682  let allowed = true;
683  const local = (
684    <input
685      type="checkbox"
686      checked
687      _onchange={async function () {
688        this.disabled = true;
689        try {
690          const saved = await api<{ allowLocalEnvironment?: boolean }>("PATCH", "/api/settings", {
691            allowLocalEnvironment: this.checked,
692          });
693          allowed = saved.allowLocalEnvironment !== false;
694          error.textContent = "";
695        } catch (cause) {
696          error.textContent = (cause as Error).message;
697        } finally {
698          this.checked = allowed;
699          this.disabled = false;
700        }
701      }}
702    />
703  ) as HTMLInputElement;
704  scope.add(
705    watch<{ environments?: Record<string, EnvironmentSettings>; allowLocalEnvironment?: boolean }>(
706      "settings",
707      (value) => {
708        configured.set(value.environments ?? {});
709        allowed = value.allowLocalEnvironment !== false;
710        local.checked = allowed;
711      },
712    ),
713  );
714  const change = (next: Record<string, EnvironmentSettings>) =>
715    api("PATCH", "/api/settings", { environments: next }).then(
716      () => (error.textContent = "", true),
717      (cause) => (error.textContent = cause.message, false),
718    );
719  const list = <div class="provider-list environment-list" />;
720  swap(
721    scope,
722    list,
723    configured,
724    (all) =>
725      Object.entries(all).map(([name, { ssh, deno }]) => (
726        <div class="environment-row">
727          <span class="model-name" title={name}>{name}</span>
728          <code title={ssh}>{ssh}</code>
729          <code title={deno ?? "deno"}>{deno ?? "deno"}</code>
730          <button
731            type="button"
732            class="danger"
733            _onclick={() => {
734              const { [name]: _, ...rest } = configured.get();
735              void change(rest);
736            }}
737          >
738            remove
739          </button>
740        </div>
741      )),
742  );
743  const name = <input placeholder="name" required /> as HTMLInputElement;
744  const ssh = <input placeholder="ssh destination" required /> as HTMLInputElement;
745  const deno = <input placeholder="remote deno path (default: deno on PATH)" /> as HTMLInputElement;
746  return (
747    <div class="environments">
748      <label class="local-environment">{local} allow conversations to work on this machine</label>
749      <p class="desc">when off, they can only use the ssh environments below</p>
750      {list}
751      <form
752        class="environment-form"
753        _onsubmit={async (event) => {
754          event.preventDefault();
755          const added = await change({
756            ...configured.get(),
757            [name.value.trim()]: {
758              ssh: ssh.value.trim(),
759              ...(deno.value.trim() ? { deno: deno.value.trim() } : {}),
760            },
761          });
762          if (added) name.value = ssh.value = deno.value = "";
763        }}
764      >
765        {name}
766        {ssh}
767        {deno}
768        <button type="submit" class="primary">add</button>
769      </form>
770      {error}
771    </div>
772  );
773}
774
775type SystemPromptSettings = {
776  userName: string;
777  generalGuidelines: string;
778  promptProse?: Record<string, string>;
779};
780
781/** The switch for finished-turn announcements, whose permission the browser grants only from a click like it. */
782function notificationSettings(): Element {
783  const available = notificationsAvailable();
784  const input = (
785    <input
786      type="checkbox"
787      checked={available && notificationsOn()}
788      disabled={!available}
789    />
790  ) as HTMLInputElement;
791  const status = <p class="desc" role="status" /> as HTMLElement;
792  status.textContent = available ? "" : "needs a secure context (https / localhost)";
793  input.addEventListener("change", async () => {
794    const wanted = input.checked;
795    const on = await setNotifications(wanted);
796    input.checked = on;
797    status.textContent = wanted && !on ? "the browser blocked notifications for this page" : "";
798  });
799  return (
800    <div class="notify">
801      <label class="notify-row">
802        {input}
803        <span>notify when a conversation finishes a turn</span>
804      </label>
805      {status}
806    </div>
807  ) as HTMLElement;
808}
809
810function systemPromptEditor(scope: Scope): Element {
811  let current: SystemPromptSettings | null = null;
812  let saving = Promise.resolve();
813  const dirty = new Set<keyof SystemPromptSettings>();
814  const status = <p class="configuration-status" role="status" aria-live="polite" /> as HTMLElement;
815  const fields = ([
816    ["userName", "user's name"],
817    ["generalGuidelines", "general guidelines"],
818  ] as const).map(([key, label]) => {
819    const input = (key === "userName" ? <input /> : <textarea rows={8} />) as
820      | HTMLInputElement
821      | HTMLTextAreaElement;
822    input.addEventListener("input", () => {
823      dirty.add(key);
824      status.textContent = "";
825    });
826    input.addEventListener("blur", () => {
827      if (!current || !dirty.has(key)) {
828        draw();
829        return;
830      }
831      const value = input.value;
832      saving = saving.then(async () => {
833        if (!current) return;
834        if (value !== current[key]) {
835          status.classList.remove("error");
836          status.textContent = "saving…";
837          try {
838            const saved = await api<{ systemPrompt: SystemPromptSettings }>(
839              "PATCH",
840              "/api/settings",
841              { systemPrompt: { [key]: value } },
842            );
843            current = saved.systemPrompt;
844            status.textContent = "saved";
845          } catch (cause) {
846            status.classList.add("error");
847            status.textContent = cause instanceof Error ? cause.message : String(cause);
848            return;
849          }
850        }
851        if (input.value === value) dirty.delete(key);
852        draw();
853      });
854    });
855    return {
856      key,
857      input,
858      element: (
859        <label class="field">
860          <span>{label}</span>
861          {input}
862        </label>
863      ),
864    };
865  });
866  const draw = () => {
867    if (!current) return;
868    for (const { key, input } of fields) {
869      if (!dirty.has(key) && input !== document.activeElement) input.value = current[key];
870    }
871  };
872  const fieldset = (
873    <fieldset class="system-prompt" disabled>
874      {fields.map(({ element }) => element)}
875      {status}
876    </fieldset>
877  ) as HTMLFieldSetElement;
878  scope.add(watch<{ systemPrompt?: SystemPromptSettings }>("settings", (value) => {
879    current = value.systemPrompt ?? null;
880    fieldset.disabled = current === null;
881    draw();
882  }));
883  return fieldset;
884}
885
886/** Which of the logged-in providers' models conversations may use, and in what order they are offered. */
887export function settings(scope: Scope, shared: Shared): Element[] {
888  const { models, enabled } = shared;
889  const filter = new Signal("");
890  const providers = <div class="model-list" />;
891  const expanded = new Set<string>();
892  const changed = new Signal(0);
893  for (const source of [models, enabled, filter]) {
894    scope.add(source.subscribe(() => changed.set(changed.get() + 1)).unsubscribe);
895  }
896  swap(scope, providers, changed, () => {
897    const query = filter.get().trim().toLowerCase();
898    const on = new Set(enabled.get().map(modelKey));
899    const matches = models.get().filter((model) =>
900      query === "" || `${model.provider}/${model.id} ${model.name}`.toLowerCase().includes(query)
901    );
902    return [...Map.groupBy(matches, (model) => model.provider)].map(([provider, all]) => {
903      const count = all.filter((model) => on.has(modelKey(model))).length;
904      return (
905        <details
906          class="provider"
907          open={query !== "" || expanded.has(provider)}
908          _ontoggle={function () {
909            if (query !== "" || !this.isConnected) return;
910            if (this.open) expanded.add(provider);
911            else expanded.delete(provider);
912          }}
913        >
914          <summary>
915            <span class="provider-name">{provider}</span>
916            <span class="count">{`${count} of ${all.length} enabled`}</span>
917          </summary>
918          {all.map((model) => (
919            <label class="model-row">
920              <input
921                type="checkbox"
922                checked={on.has(modelKey(model))}
923                _onchange={function () {
924                  const rest = enabled.get().filter((m) => modelKey(m) !== modelKey(model));
925                  save(this.checked ? [...rest, refOf(model)] : rest);
926                }}
927              />
928              {describe(model, modelKey(model))}
929            </label>
930          ))}
931        </details>
932      );
933    });
934  });
935
936  return [
937    <header class="bar">
938      <span class="bar-title">settings</span>
939    </header>,
940    <div class="pane settings">
941      <h2>enabled models</h2>
942      <p class="desc">
943        drag to reorder, uncheck to remove
944      </p>
945      <section
946        class="enabled"
947        hidden={computed(scope, [enabled], () => enabled.get().length === 0)}
948      >
949        {enabledList(scope, models, enabled)}
950      </section>
951      <h2>environments</h2>
952      <p class="desc">
953        remote machines for conversations
954      </p>
955      {environmentList(scope)}
956      <h2>system prompt</h2>
957      <p class="desc">
958        <code>$USER</code> will be interpolated in guidelines to the configured name
959      </p>
960      {systemPromptEditor(scope)}
961      <h2>extensions</h2>
962      <p class="desc">
963        from <code>…/berry/extensions/</code> and <code>~/.config/berry/extensions/</code>;{" "}
964        extensions auto-reload on file change
965      </p>
966      {extensionList(scope, shared)}
967      <h2>notifications</h2>
968      <p class="desc">
969        (only while open in a tab)
970      </p>
971      {notificationSettings()}
972      <h2>available models</h2>
973      <p class="desc">all models from logged-in providers</p>
974      <input
975        type="search"
976        class="filter"
977        placeholder="filter models"
978        _oninput={function () {
979          filter.set(this.value);
980        }}
981      />
982      {providers}
983      <h2>provider logins</h2>
984      <p class="desc">
985        logins shared with pi: <code>~/.pi/agent/auth.json</code>
986      </p>
987      {providerList(scope, shared)}
988    </div>,
989  ];
990}