char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somvirtualize transcript contentsba4e2f4

main
30.2 KiB863 linesraw
1import { effect, Signal } from "@char/aftercare";
2import type { Message, Usage } from "@earendil-works/pi-ai";
3import type {
4  AgentState,
5  ConversationView,
6  EntryRecord,
7  InboxState,
8  LiveState,
9  UsageState,
10} from "@earendil-works/pi-durable";
11import type { ProjectIndex } from "@berry/api/projects.ts";
12import type { ExtensionStartup } from "@berry/api/extension-startup.ts";
13import type { JsonValue } from "@berry/api";
14import type { HeaderContext } from "@berry/api/web.ts";
15import { userContent } from "@berry/api/images.ts";
16import type { ShellState } from "@berry/api/shell.ts";
17import { api, connected, watch } from "./client.ts";
18import { composer, go } from "./composer.tsx";
19import {
20  configurationEditor,
21  type ConfigurationLocation,
22  type ConfigurationSchema,
23  liveChoices,
24} from "./configuration.tsx";
25import { inbox } from "./inbox.tsx";
26import { type StoppedInput, stoppedInputs } from "./stopped-inputs.tsx";
27import { extensionRenderers, type WebEntry } from "./renderers.ts";
28import { headerWidgets } from "./header.ts";
29import { extensionView, viewHref, viewRoute } from "./views.ts";
30import { openChannel } from "./channel.ts";
31import { type ModelInfo, modelKey, type ModelRef, modelSelect } from "./settings.tsx";
32import { readWhenVisible } from "./read.ts";
33import { transcript } from "./transcript.tsx";
34import { scroller } from "./scroller.tsx";
35import { branchCommands, describe, type Move } from "./tree.tsx";
36import { computed, json, Scope, swap } from "./ui.ts";
37
38/** What every conversation view shares: the installed extensions, the available models, and those enabled. */
39export type Shared = {
40  readonly extensions: Signal<readonly ExtensionInfo[]>;
41  readonly models: Signal<readonly ModelInfo[]>;
42  readonly enabled: Signal<readonly ModelRef[]>;
43  /** The machines a conversation can work on: this one, then those settings name. */
44  readonly machines: Signal<readonly string[]>;
45  readonly projects: Signal<ProjectIndex>;
46  /** Fetch the available models again, as after logging in or out of a provider. */
47  readonly refreshModels: () => void;
48};
49export type ExtensionInfo = {
50  name: string;
51  directory?: string | null;
52  description: string | null;
53  status: "loaded" | "failed";
54  /** Why its last load failed. */
55  error: string | null;
56  /** Whether a version of it runs: one that failed to reload still runs the last that loaded. */
57  running: boolean;
58  /** The harness's own, rather than one on disk. */
59  builtin: boolean;
60  alwaysOn: boolean;
61  /** Whether a new conversation starts with it, until one is chosen from before. */
62  defaultEnabled: boolean;
63  web: WebEntry | null;
64  /** Names it went by before, which old history still calls it. */
65  formerly: string[];
66  configuration: ConfigurationSchema | null;
67  /** Its global editable prose default, when it registers any. */
68  promptProse: { default: string } | null;
69  startup?: ExtensionStartup | null;
70};
71export type CommandInfo = {
72  name: string;
73  extension: string;
74  description: string;
75  /** Runs here, in the browser, rather than on the server. */
76  run?: () => Promise<void>;
77};
78
79const EMPTY: readonly never[] = [];
80const IDLE: LiveState = {};
81
82function selectedNames(agent: AgentState): string[] {
83  const selection = agent.extensions;
84  if (Array.isArray(selection)) return selection;
85  // old conversations inherited subagent without storing its name
86  return [...new Set(["subagent", ...(selection?.add ?? [])])].filter(
87    (name) => !selection?.remove?.includes(name),
88  );
89}
90
91/** The running extensions this conversation has, selected or always on, with their frontends. */
92function withRenderers(installed: readonly ExtensionInfo[], agent: AgentState) {
93  const names = selectedNames(agent);
94  return installed.flatMap(({ name, running, alwaysOn }) => {
95    const renderers = extensionRenderers(name);
96    return running && (alwaysOn || names.includes(name)) && renderers ? [{ name, renderers }] : [];
97  });
98}
99
100const totalCost = (usage: UsageState | null) =>
101  [
102    ...Object.values(usage?.models ?? {}),
103    ...Object.values(usage?.tools ?? {}),
104  ].reduce((sum, entry) => sum + (entry.cost?.total ?? 0), 0);
105
106/** Compact token counts the way pi-coding-agent's footer does. */
107const formatTokens = (count: number): string => {
108  if (count < 1000) return String(count);
109  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
110  if (count < 1000000) return `${Math.round(count / 1000)}k`;
111  if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
112  return `${Math.round(count / 1000000)}M`;
113};
114
115/** The context a response saw, as pi's calculateContextTokens. */
116const contextTokensOf = (usage: Usage): number =>
117  usage.totalTokens ||
118  usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
119
120/** Rough chars/4 estimate of one model message, for messages past the last reported usage. */
121function estimateMessageTokens(message: Message): number {
122  const content = (message as { content: unknown }).content;
123  if (typeof content === "string") return Math.ceil(content.length / 4);
124  if (!Array.isArray(content)) return 0;
125  let chars = 0;
126  for (const block of content as unknown[]) {
127    if (typeof block === "string") chars += block.length;
128    else if (block !== null && typeof block === "object") {
129      const record = block as Record<string, unknown>;
130      chars += typeof record.text === "string" ? record.text.length : JSON.stringify(block).length;
131    }
132  }
133  return Math.ceil(chars / 4);
134}
135
136/**
137 * The latest assistant usage, plus estimated tokens of what followed it: a la
138 * pi-coding-agent's estimateProjectedContextTokens. Aborted and errored responses
139 * carry no valid usage, and all-zero usage means the provider reported nothing.
140 */
141function estimateContext(
142  entries: readonly EntryRecord[],
143): { tokens: number; latest?: Usage; durationMs?: number } | null {
144  const head = entries.findLast((entry) => entry.head !== undefined);
145  let trailing = 0;
146  for (let i = entries.length - 1; i >= 0; i--) {
147    const entry = entries[i];
148    const messages = entry.model ?? [];
149    for (let j = messages.length - 1; j >= 0; j--) {
150      const message = messages[j];
151      // retained responses measured the context before this head marker
152      if ((head === undefined || entry.id > head.id) && message.role === "assistant") {
153        const candidate = message.usage;
154        if (
155          message.stopReason !== "aborted" && message.stopReason !== "error" &&
156          candidate && contextTokensOf(candidate) > 0
157        ) {
158          return {
159            tokens: contextTokensOf(candidate) + trailing,
160            latest: candidate,
161            durationMs: message.durationMs,
162          };
163        }
164      }
165      trailing += estimateMessageTokens(message);
166    }
167  }
168  return head === undefined ? null : { tokens: trailing };
169}
170
171/** Cache hit rate of one response: its cache reads over its prompt tokens. */
172const cacheHitRate = (usage: Usage): number | undefined => {
173  const prompt = usage.input + usage.cacheRead + usage.cacheWrite;
174  return prompt > 0 ? (usage.cacheRead / prompt) * 100 : undefined;
175};
176
177type HeaderStatus = {
178  cost: number;
179  /** Estimated context tokens, or null before the first response. */
180  tokens: number | null;
181  /** The model's context window, or 0 when it is not known. */
182  window: number;
183  /** Latest response's cache hit rate, when it touched the cache. */
184  hitRate: number | undefined;
185};
186
187const contextText = (status: HeaderStatus): string => {
188  if (status.tokens === null) {
189    return status.window > 0 ? `?/${formatTokens(status.window)}` : "";
190  }
191  if (status.window <= 0) return formatTokens(status.tokens);
192  return `${formatTokens(status.tokens)}/${formatTokens(status.window)}`;
193};
194
195const contextClass = (status: HeaderStatus): string => {
196  if (status.tokens === null || status.window <= 0) return "";
197  const percent = (status.tokens / status.window) * 100;
198  if (percent > 90) return "ctx-high";
199  if (percent > 70) return "ctx-warn";
200  return "";
201};
202
203const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
204
205/** As pi-ai's clampThinkingLevel: the level itself if supported, else the next one up, else the next one down. */
206function clamp(levels: readonly string[], level: string): string {
207  if (levels.includes(level)) return level;
208  const at = LEVELS.indexOf(level);
209  return (
210    LEVELS.slice(at + 1).find((l) => levels.includes(l)) ??
211      LEVELS.slice(0, Math.max(at, 0)).findLast((l) => levels.includes(l)) ??
212      levels[0] ??
213      "off"
214  );
215}
216
217/** The thinking levels the chosen model supports; a level it lacks moves to its nearest. */
218export function thinkingSelect(
219  scope: Scope,
220  models: Signal<readonly ModelInfo[]>,
221  model: Signal<string>,
222  current: Signal<string>,
223  choose: (level: string) => void,
224): HTMLSelectElement {
225  const select = <select class="thinking-level" aria-label="Thinking level" /> as HTMLSelectElement;
226  const render = () => {
227    const known = models.get().find((m) => modelKey(m) === model.get());
228    const levels = known?.thinkingLevels ?? [current.get()];
229    select.replaceChildren(
230      ...levels.map((level) => <option value={level}>{level}</option>),
231    );
232    select.value = current.get();
233    select.disabled = levels.length === 1;
234    // not before the model is known: until then, every level looks unsupported
235    if (known && !levels.includes(current.get())) {
236      queueMicrotask(() => choose(clamp(levels, current.get())));
237    }
238  };
239  for (const source of [models, model, current]) {
240    scope.add(source.subscribe(render).unsubscribe);
241  }
242  render();
243  select.addEventListener("change", () => choose(select.value));
244  return select;
245}
246
247/** The conversation's bar: its agent's model, level and extensions, and what it has cost and holds. */
248function header(
249  scope: Scope,
250  tid: string,
251  shared: Shared,
252  notify: (message: string, error?: boolean) => void,
253  contextOf: (extension: string, agent: AgentState) => HeaderContext,
254  viewing: Signal<string>,
255) {
256  const configure = (change: object) =>
257    api("PATCH", `/api/conversations/${tid}/agent`, change).catch((error) =>
258      notify(error.message, true)
259    );
260  // the selects' own choices, which follow the agent
261  const model = new Signal("");
262  const thinking = new Signal("off");
263  let agent: AgentState = {};
264  const configurationAgent = new Signal(agent);
265  let usage: UsageState | null = null;
266  let entries: readonly EntryRecord[] = EMPTY;
267
268  const selected = <summary>none</summary> as HTMLElement;
269  const toggles = <div class="extension-options" />;
270  const boxes = new Map<string, HTMLInputElement>();
271  const configurations = new Map<
272    string,
273    { element: HTMLElement; alwaysOn: boolean; commit(): Promise<void> | undefined }
274  >();
275  const saves = new Set<Promise<unknown>>();
276  const saving = (save: Promise<unknown>) => {
277    saves.add(save);
278    void save.catch(() => {}).finally(() => saves.delete(save));
279  };
280  swap(scope, toggles, shared.extensions, (installed, own) => {
281    boxes.clear();
282    configurations.clear();
283    return installed
284      .filter(
285        (extension) => extension.running && (!extension.alwaysOn || extension.configuration),
286      )
287      .map((extension) => {
288        const box = (
289          <input
290            type="checkbox"
291            checked={selectedNames(agent).includes(extension.name)}
292            _onchange={function () {
293              const current = selectedNames(agent).filter(
294                (name) => name !== extension.name,
295              );
296              configure({
297                extensions: this.checked ? [...current, extension.name] : current,
298              });
299            }}
300          />
301        ) as HTMLInputElement;
302        if (!extension.alwaysOn) boxes.set(extension.name, box);
303        const editor = extension.configuration
304          ? configurationEditor(
305            own,
306            tid,
307            extension.name,
308            extension.configuration,
309            saving,
310            liveChoices(own, extension.name, shared),
311            shared.projects,
312            computed<ConfigurationLocation>(own, [configurationAgent], () =>
313              contextOf(extension.name, configurationAgent.get())),
314          )
315          : null;
316        const fields = editor?.element;
317        if (editor) {
318          editor.element.hidden = !extension.alwaysOn &&
319            !selectedNames(agent).includes(extension.name);
320          configurations.set(extension.name, {
321            ...editor,
322            alwaysOn: extension.alwaysOn,
323          });
324        }
325        return (
326          <div class="extension-choice">
327            {extension.alwaysOn ? [] : (
328              <label title={extension.description ?? ""}>
329                {box}
330                {extension.name}
331              </label>
332            )}
333            {fields ?? []}
334          </div>
335        );
336      });
337  });
338
339  const dropdown = <details class="extensions">{selected}{toggles}</details> as HTMLDetailsElement;
340  const commit = () => {
341    for (const editor of configurations.values()) {
342      void editor.commit()?.catch((error) => notify(error.message, true));
343    }
344  };
345  const outside = (event: MouseEvent) => {
346    if (!dropdown.open) return;
347    const target = event.target as Node;
348    if (!dropdown.contains(target)) {
349      dropdown.open = false;
350      commit();
351    } else if (selected.contains(target)) commit();
352  };
353  document.addEventListener("click", outside, true);
354  scope.add(() => document.removeEventListener("click", outside, true));
355
356  const cost = document.createTextNode("");
357  const widgets = headerWidgets();
358  scope.add(widgets.dispose);
359  const views = <span class="view-links" /> as HTMLElement;
360  const drawWidgets = () => {
361    const available = withRenderers(shared.extensions.get(), agent);
362    widgets.update(
363      available.flatMap(({ name, renderers }) =>
364        renderers.header ? [{ name, create: renderers.header }] : []
365      ),
366      (extension) => contextOf(extension, agent),
367    );
368    const open = viewing.get().split("/")[0];
369    views.replaceChildren(
370      ...available.flatMap(({ name, renderers: { view } }) =>
371        view
372          ? [
373            <a
374              href={name === open ? `#/${tid}` : viewHref(tid, name)}
375              title={name === open ? "return to conversation" : `open ${view.label}`}
376              aria-current={name === open ? "page" : "false"}
377            >
378              {view.label}
379            </a>,
380          ]
381          : []
382      ),
383    );
384  };
385  scope.add(viewing.subscribe(drawWidgets).unsubscribe);
386  scope.add(shared.extensions.subscribe(drawWidgets).unsubscribe);
387  drawWidgets();
388
389  const context = <span /> as HTMLElement;
390  const hit = document.createTextNode("");
391  const rate = document.createTextNode("");
392  const totals = (
393    <span class="usage">
394      {cost} {context}
395      {hit}
396      {rate}
397    </span>
398  ) as HTMLElement;
399  const drawTotals = () => {
400    const window = shared.models.get().find((m) => modelKey(m) === model.get())
401      ?.contextWindow ?? 0;
402    const estimate = estimateContext(entries);
403    const latest = estimate?.latest;
404    const touched = latest !== undefined && (latest.cacheRead > 0 || latest.cacheWrite > 0);
405    const status: HeaderStatus = {
406      cost: totalCost(usage),
407      tokens: estimate?.tokens ?? null,
408      window,
409      hitRate: latest !== undefined && touched ? cacheHitRate(latest) : undefined,
410    };
411    const durationMs = estimate?.durationMs ?? 0;
412    const perSecond = durationMs > 0 ? (estimate?.latest?.output ?? 0) / (durationMs / 1000) : NaN;
413    cost.data = `$${status.cost.toFixed(4)}`;
414    context.className = contextClass(status);
415    context.textContent = contextText(status);
416    hit.data = status.hitRate === undefined ? "" : ` CH${status.hitRate.toFixed(1)}%`;
417    rate.data = Number.isFinite(perSecond) && perSecond > 0 ? ` ${perSecond.toFixed(1)} tok/s` : "";
418  };
419  scope.add(shared.models.subscribe(drawTotals).unsubscribe);
420  scope.add(model.subscribe(drawTotals).unsubscribe);
421  drawTotals();
422
423  return {
424    element: (
425      <header class="bar conversation-header">
426        {modelSelect(scope, shared.models, shared.enabled, model, (chosen) =>
427          configure({ model: chosen }))}
428        {thinkingSelect(scope, shared.models, model, thinking, (level) =>
429          configure({ thinkingLevel: level }))}
430        {dropdown}
431        <span class="trailing">
432          {views}
433          {widgets.element}
434          {totals}
435        </span>
436      </header>
437    ),
438    async settle() {
439      await Promise.all([...saves]);
440    },
441    update(next: {
442      agent: AgentState;
443      usage: UsageState | null;
444      entries: readonly EntryRecord[];
445    }) {
446      if (next.agent !== agent) {
447        agent = next.agent;
448        configurationAgent.set(agent);
449        const names = selectedNames(agent);
450        selected.textContent = names.join(", ") || "none";
451        for (const [name, box] of boxes) box.checked = names.includes(name);
452        for (const [name, { element, alwaysOn }] of configurations) {
453          element.hidden = !alwaysOn && !names.includes(name);
454        }
455        // together, so neither select sees the other's old value
456        effect(() => {
457          model.set(agent.model ? modelKey(agent.model) : "");
458          thinking.set(agent.thinkingLevel ?? "off");
459        });
460        drawWidgets();
461      }
462      if (next.usage !== usage || next.entries !== entries) {
463        usage = next.usage;
464        entries = next.entries;
465        drawTotals();
466      }
467    },
468  };
469}
470
471/** A notice above the composer, which goes after a while. */
472export function notifier() {
473  const element = <div class="notice" hidden /> as HTMLDivElement;
474  let timer: ReturnType<typeof setTimeout> | undefined;
475  const notify = (message: string, error = false) => {
476    element.textContent = message;
477    element.classList.toggle("error", error);
478    element.hidden = false;
479    clearTimeout(timer);
480    timer = setTimeout(() => (element.hidden = true), error ? 12_000 : 6_000);
481  };
482  return { element, notify };
483}
484
485/** The conversation's toolbar, for the app's bar, and the transcript and composer, for its pane. */
486export function conversation(
487  scope: Scope,
488  tid: string,
489  shared: Shared,
490  /** The view open over it, as `<extension>` or `<extension>/<route>`, or `""`. */
491  viewing: Signal<string> = new Signal(""),
492): Element[] {
493  const { element: notice, notify } = notifier();
494  const busy = new Signal(false);
495  const shellEnabled = new Signal(false);
496  let shellState: ShellState = {};
497  const commands = new Signal<readonly CommandInfo[]>(EMPTY);
498  let aborting = false;
499  const { element: input, setText, restore } = composer(
500    scope,
501    tid,
502    busy,
503    commands,
504    async (text, followUp, images) => {
505      // busy configuration waits for a boundary; new inputs must be able to queue before it
506      if (!busy.get()) await top.settle();
507      const command = text.match(/^\/(\S+)\s*([\s\S]*)$/);
508      if (shellEnabled.get() && text.startsWith("!")) {
509        if (images.length) throw new Error("Shell commands cannot include images");
510        const excludeFromContext = text.startsWith("!!");
511        await api("POST", `/api/conversations/${tid}/shell`, {
512          command: text.slice(excludeFromContext ? 2 : 1).trim(),
513          excludeFromContext,
514        });
515      } else if (command) {
516        const { result } = await api<{ result: JsonValue }>(
517          "POST",
518          `/api/conversations/${tid}/commands/${command[1]}`,
519          { args: command[2] },
520        );
521        notify(
522          result === null
523            ? `/${command[1]} done`
524            : typeof result === "string"
525            ? result
526            : json(result),
527        );
528      } else {
529        // saved diff edits report on leaving the view; an idle message sends after them instead
530        if (!busy.get()) await view.flush?.();
531        const whenBusy = busy.get() ? followUp ? "followUp" : "steer" : undefined;
532        await api("POST", `/api/conversations/${tid}/messages`, {
533          content: userContent(text, images),
534          ...(whenBusy ? { whenBusy } : {}),
535        });
536      }
537    },
538    notify,
539    async () => {
540      if (aborting) return;
541      aborting = true;
542      try {
543        const { inputs = [] } = await api<{ inputs?: StoppedInput[] }>(
544          "POST",
545          `/api/conversations/${tid}/abort`,
546        );
547        await stopped.recover(inputs);
548      } finally {
549        aborting = false;
550      }
551    },
552    (prefix) =>
553      api<string[]>(
554        "GET",
555        `/api/conversations/${tid}/references?${new URLSearchParams({ prefix })}`,
556      ),
557    shellEnabled,
558  );
559  // a move along the tree shows where it went for as long as the conversation stays there
560  let moved: Move | null = null;
561  const arrivals = new Set<{ move: Move; arrive: () => void }>();
562  const point = <span class="point" /> as HTMLElement;
563  const undo = <button type="button">undo</button> as HTMLButtonElement;
564  const continuation = (
565    <div class="continuation" role="status" hidden>
566      {point}
567      {undo}
568    </div>
569  ) as HTMLElement;
570  const arrived = () => {
571    for (const arrival of arrivals) {
572      if (!arrival.move.landed(latestEntry)) continue;
573      arrivals.delete(arrival);
574      arrival.arrive();
575    }
576    if (moved !== null && latestEntry !== moved.at) moved = null;
577    continuation.hidden = moved === null;
578  };
579  undo.addEventListener("click", async () => {
580    const move = moved;
581    if (move === null || move.previous === null || undo.disabled) return;
582    undo.disabled = true;
583    try {
584      await api("POST", `/api/conversations/${tid}/branch`, { entryId: move.previous });
585      const textarea = input.querySelector("textarea")!;
586      if (move.how === "edit" && textarea.value === move.entry.text) setText("");
587      else textarea.focus();
588    } catch (error) {
589      undo.disabled = false;
590      notify(error instanceof Error ? error.message : String(error), true);
591    }
592  });
593  const local: CommandInfo[] = [
594    ...branchCommands({ tid }, (place) => go(place, setText), async (move) => {
595      await new Promise<void>((arrive) => {
596        if (move.landed(latestEntry)) arrive();
597        else arrivals.add({ move, arrive });
598      });
599      if (move.how === "regenerate") {
600        return notify("regenerating response");
601      }
602      if (move.at === move.previous) return;
603      moved = move;
604      const [label, text] = describe(move.entry);
605      continuation.dataset.kind = move.entry.kind;
606      point.replaceChildren(
607        <span class="verb">{move.how === "edit" ? "editing" : "continuing after"}</span>,
608        <span class="label">{label}</span>,
609        <span class="text">{text}</span>,
610      );
611      undo.hidden = move.previous === null;
612      undo.disabled = false;
613      continuation.hidden = false;
614    }),
615    {
616      name: "archive",
617      extension: "berry",
618      description: "archive this conversation",
619      run: async () => {
620        await api("PATCH", `/api/conversations/${tid}`, { archived: true });
621        notify(
622          "this conversation was moved to the archive. you can use /restore to restore it to the main conversation list",
623        );
624      },
625    },
626  ];
627  commands.set(local);
628  // the extensions' commands, asked for again whenever the conversation's extensions change
629  let names: string | null = null;
630  let commandRequest = 0;
631  const fetchCommands = async () => {
632    const request = ++commandRequest;
633    commands.set(local);
634    try {
635      const list = await api<CommandInfo[]>("GET", `/api/conversations/${tid}/commands`);
636      if (request !== commandRequest) return;
637      commands.set([
638        ...local,
639        ...list.filter((c) => !local.some((l) => l.name === c.name)),
640      ]);
641    } catch {
642      // keep local commands when the target cannot be reached
643    }
644  };
645
646  const command = async (name: string, args = "") =>
647    (
648      await api<{ result: JsonValue | null }>(
649        "POST",
650        `/api/conversations/${tid}/commands/${encodeURIComponent(name)}`,
651        { args },
652      )
653    ).result;
654  /** What an extension's frontend works with: its own handlers, and the user's commands. */
655  const contextOf = (extension: string, agent: AgentState): HeaderContext => {
656    const path = (name: string) =>
657      `/api/conversations/${tid}/extensions/${encodeURIComponent(extension)}/${
658        encodeURIComponent(name)
659      }`;
660    return {
661      tid,
662      model: agent.model ?? null,
663      request: async (name, args, options) =>
664        (
665          await api<{ result: JsonValue }>(
666            "POST",
667            path(name),
668            args === undefined ? {} : { args },
669            options?.signal,
670          )
671        ).result,
672      connect: (name, args) => openChannel(path(name), args),
673      command,
674    };
675  };
676  const top = header(scope, tid, shared, notify, contextOf, viewing);
677  const view = extensionView();
678  scope.add(view.dispose);
679  let agent: AgentState = {};
680  let latestEntry: number | null = null;
681  const drawView = () => {
682    if (latest === null) return;
683    const [name, ...route] = viewing.get().split("/");
684    const create = withRenderers(shared.extensions.get(), agent)
685      .find((extension) => extension.name === name)?.renderers.view?.create ?? null;
686    view.update(name ? { name, create } : null, {
687      ...contextOf(name, agent),
688      route: viewRoute(route.join("/")),
689      href: (next) => viewHref(tid, name, next),
690      go: (next) => void (location.hash = viewHref(tid, name, next)),
691      latest: latestEntry,
692    });
693  };
694  scope.add(viewing.subscribe(drawView).unsubscribe);
695  scope.add(shared.extensions.subscribe(drawView).unsubscribe);
696  const history = transcript();
697  scope.add(history.dispose);
698  const markRead = readWhenVisible(
699    scope,
700    (entryId) => api("POST", `/api/conversations/${tid}/read`, { entryId }),
701  );
702  const queued = inbox(scope, tid);
703  const stopped = stoppedInputs(scope, tid, restore, notify);
704  const activity = document.createTextNode("");
705  const working = (
706    <div class="working" role="status" aria-live="polite" hidden>
707      <span class="working-spinner" aria-hidden="true" />
708      {activity}
709    </div>
710  ) as HTMLElement;
711
712  // revisions are drawn a frame at a time: those arriving meanwhile, or while the page is hidden, draw as one
713  let latest: ConversationView | null = null;
714  let frame = 0;
715  const draw = () => {
716    frame = 0;
717    const { entries, docs } = latest!;
718    const live = (docs["pi.live"] ?? IDLE) as LiveState;
719    agent = (docs["pi.agent"] ?? {}) as AgentState;
720    latestEntry = entries.at(-1)?.id ?? null;
721    arrived();
722    top.update({
723      agent,
724      usage: (docs["pi.usage"] ?? null) as UsageState | null,
725      entries,
726    });
727    history.update(entries, live, shellState.run);
728    markRead(latest!);
729    busy.set(live.run !== undefined || shellState.run !== undefined);
730    const statuses = [];
731    if (busy.get()) statuses.push("working…");
732    if (live.compactions?.length) statuses.push("compacting context…");
733    const text = statuses.join(" · ");
734    if (activity.data !== text) activity.data = text;
735    working.hidden = statuses.length === 0;
736    shellEnabled.set(selectedNames(agent).includes("code"));
737    const binding = entries.findLast((entry) => {
738      if (entry.kind !== "berry.event") return false;
739      const { ext, event } = entry.data as { ext: string; event?: { type?: string } };
740      return (ext === "environment" && event?.type === "bind") ||
741        (["code", "coding"].includes(ext) && event?.type === "cd");
742    });
743    const selection = json([selectedNames(agent), binding?.id ?? null]);
744    if (selection !== names) {
745      names = selection;
746      void fetchCommands();
747    }
748    drawView();
749    const items = (docs["pi.inbox"] as InboxState | undefined)?.items ?? EMPTY;
750    queued.update(items);
751  };
752  scope.add(
753    watch<ConversationView>(`conversation:${tid}`, (view) => {
754      latest = view;
755      frame ||= requestAnimationFrame(draw);
756    }),
757  );
758  scope.add(
759    connected.subscribe((up) => {
760      if (up && latest) frame ||= requestAnimationFrame(draw);
761    }).unsubscribe,
762  );
763  scope.add(() => cancelAnimationFrame(frame));
764  scope.add(watch<ShellState>(`shell:${tid}`, (state) => {
765    shellState = state;
766    if (latest) frame ||= requestAnimationFrame(draw);
767  }));
768
769  return [
770    top.element,
771    <div class="pane conversation">
772      {scroller(scope, history.element, history.last, history.grow)}
773      {queued.element}
774      {stopped.element}
775      {continuation}
776      {notice}
777      {working}
778      {input}
779    </div>,
780    view.element,
781  ];
782}
783
784/**
785 * A branch not yet taken: `tid` as of its entry `at`, which sending a message continues from, or with `fork` starts a
786 * new conversation from.
787 */
788export function draft(
789  scope: Scope,
790  tid: string,
791  at: number,
792  fork: boolean,
793): Element[] {
794  const history = transcript();
795  scope.add(history.dispose);
796  const { element: notice, notify } = notifier();
797  api<{ entries: EntryRecord[] }>(
798    "GET",
799    `/api/conversations/${tid}/context?at=${at}`,
800  )
801    .then((context) => history.update(context.entries, IDLE))
802    .catch((error) => notify(error.message, true));
803
804  const commands = new Signal<readonly CommandInfo[]>(EMPTY);
805  const { element: input, setText } = composer(
806    scope,
807    `${tid}${fork ? "/fork" : ""}@${at}`,
808    new Signal(false),
809    commands,
810    async (text, _followUp, images) => {
811      const command = text.match(/^\/(\S+)/)?.[1];
812      if (command) {
813        throw new Error(
814          `/${command} needs a conversation: send a message to start this one`,
815        );
816      }
817      if (!fork) {
818        await api("POST", `/api/conversations/${tid}/branch`, { entryId: at });
819        await api("POST", `/api/conversations/${tid}/messages`, {
820          content: userContent(text, images),
821        });
822        return go({ tid }, setText);
823      }
824      const forked = await api<{ tid: string }>(
825        "POST",
826        `/api/conversations/${tid}/fork`,
827        {
828          entryId: at,
829        },
830      );
831      await api("POST", `/api/conversations/${forked.tid}/messages`, {
832        content: userContent(text, images),
833      });
834      go({ tid: forked.tid }, setText);
835    },
836    notify,
837    undefined,
838    (prefix) =>
839      api<string[]>(
840        "GET",
841        `/api/conversations/${tid}/references?${new URLSearchParams({ prefix, at: String(at) })}`,
842      ),
843  );
844  commands.set(
845    branchCommands({ tid, at, fork }, (place) => go(place, setText)),
846  );
847
848  return [
849    <header class="bar draft-header">
850      <span>{fork ? "new conversation from" : "new branch of"}</span>
851      <a href={`#/${tid}`} class="tid">
852        {tid}
853      </a>
854      <span class="spacer" />
855      <span class="note">draft conversation turn: send a message</span>
856    </header>,
857    <div class="pane conversation">
858      {scroller(scope, history.element, history.last, history.grow)}
859      {notice}
860      {input}
861    </div>,
862  ];
863}