char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somadd timestamps to transcript5147173

main
38.0 KiB1155 linesraw
1import type {
2  AssistantMessage,
3  ImageContent,
4  SystemMessage,
5  ToolResultMessage,
6  UserMessage,
7} from "@earendil-works/pi-ai";
8import {
9  getCurrentSystemMessage,
10  getToolStateChanges,
11} from "@earendil-works/pi-ai/utils/transcript";
12import type {
13  AgentState,
14  ConversationView,
15  EntryRecord,
16  LiveState,
17} from "@earendil-works/pi-durable";
18import type { JsonValue } from "@berry/api";
19import type { ShellExecution } from "@berry/api/shell.ts";
20import type { CallStatus, ToolCall, ToolRenderer, ToolView } from "@berry/api/web.ts";
21import { code } from "./code.tsx";
22import { markdown, markdownView } from "./markdown.ts";
23import { toolRenderer } from "./renderers.ts";
24import { event } from "./event.tsx";
25import { isImageResult, toolResult } from "./tool-result.ts";
26import { type Handle, json, keyed, Scope } from "./ui.ts";
27import { watch } from "./client.ts";
28import { scroller, type Viewport } from "./scroller.tsx";
29
30type Block = AssistantMessage["content"][number];
31type ToolCallBlock = Extract<Block, { type: "toolCall" }>;
32type NestedCall = {
33  name: string;
34  extension: string;
35  args: JsonValue;
36  status: "running" | "ok" | "error" | "cancelled";
37  startedAt?: number;
38  durationMs?: number;
39  output?: string;
40  details?: JsonValue;
41  value?: JsonValue;
42  error?: string;
43};
44
45const EMPTY: readonly never[] = [];
46const IDLE: LiveState = {};
47const textOf = (content: readonly { type: string; text?: string }[]) =>
48  content
49    .flatMap((block) => block.type === "text" && block.text ? [block.text] : [])
50    .join("\n");
51
52const userText = ({ content }: UserMessage) =>
53  typeof content === "string" ? content : textOf(content);
54
55/** Give an element its text, hiding it while there is none. */
56function show(element: HTMLElement, text: string) {
57  if (element.textContent === text) return;
58  element.textContent = text;
59  element.hidden = text === "";
60}
61
62/**
63 * How a card shows its body until the reader opens it: a `preview`, cut at a height; or `closed`, its header alone,
64 * as a script's calls are, whose output tells what they did.
65 */
66type Rest = "preview" | "closed";
67
68/** `250ms`, `12s`, `3m 07s`, `1h 02m`. */
69function duration(ms: number): string {
70  if (ms < 1000) return `${Math.floor(ms)}ms`;
71  const seconds = Math.floor(ms / 1000);
72  const minutes = Math.floor(seconds / 60);
73  if (minutes === 0) return `${seconds}s`;
74  if (minutes < 60) {
75    return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
76  }
77  return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
78}
79
80// one observer for every card, rather than one each; every card measures before any lays itself out again, so that
81// the page lays out once rather than once a card
82const measures = new Map<Element, () => (() => void) | undefined>();
83const resizes = new ResizeObserver((entries) => {
84  const layouts = entries.map(({ target }) => measures.get(target)?.());
85  for (const layout of layouts) layout?.();
86});
87
88type Card = Handle<ToolCall> & {
89  update(call: ToolCall): void;
90  dispose(): void;
91};
92
93function cardFrame(
94  heading: Element[],
95  content: HTMLElement,
96  initial: Rest | "open" = "preview",
97  /** Called whenever it shows its body, which may build it. */
98  reveal = () => {},
99) {
100  const rest = initial === "open" ? "closed" : initial;
101  let view: Rest | "open" = initial;
102  let overflowing = false;
103
104  const header = (
105    <button
106      type="button"
107      class="tool-header"
108      _onclick={() => {
109        view = view === "open" ? rest : "open";
110        layout();
111      }}
112    >
113      {heading}
114    </button>
115  ) as HTMLElement;
116  const blur = (
117    <div class="tool-blur" aria-hidden="true">
118      {[0, 1, 2, 3].map(() => <span />)}
119    </div>
120  ) as HTMLElement;
121  const more = (
122    <button
123      type="button"
124      class="tool-more"
125      _onclick={(event) => {
126        view = "open";
127        layout();
128        if (event.detail === 0) header.focus();
129      }}
130    >
131      show all
132    </button>
133  ) as HTMLElement;
134  const less = (
135    <button
136      type="button"
137      class="tool-less"
138      _onclick={() => {
139        view = rest;
140        layout();
141        header.focus();
142      }}
143    >
144      show less
145    </button>
146  ) as HTMLElement;
147  const preview = (
148    <div class="tool-preview">
149      {content}
150      {blur}
151      {more}
152    </div>
153  ) as HTMLElement;
154  const element = (
155    <div class="tool">
156      {header}
157      {preview}
158      {less}
159    </div>
160  ) as HTMLElement;
161
162  function layout() {
163    if (view !== "closed") reveal();
164    element.dataset.view = view;
165    element.dataset.overflowing = String(overflowing);
166    header.setAttribute(
167      "aria-expanded",
168      String(view === "open" || (view === "preview" && !overflowing)),
169    );
170    more.hidden = view !== "preview" || !overflowing;
171    less.hidden = view !== "open";
172    blur.hidden = more.hidden;
173    content.inert = !more.hidden;
174  }
175  const measure = () => {
176    const next = content.offsetHeight > preview.clientHeight + 1;
177    if (next === overflowing) return;
178    return () => {
179      overflowing = next;
180      layout();
181    };
182  };
183
184  layout();
185  for (const observed of [content, preview]) {
186    measures.set(observed, measure);
187    resizes.observe(observed);
188  }
189  return {
190    element,
191    expand() {
192      view = "open";
193      layout();
194    },
195    dispose() {
196      for (const observed of [content, preview]) {
197        resizes.unobserve(observed);
198        measures.delete(observed);
199      }
200    },
201  };
202}
203
204function card(
205  call: ToolCall,
206  renderer: ToolRenderer | undefined,
207  makeBody: () => ToolView = () => renderer?.body?.() ?? defaultBody(),
208  initial: Rest | "open" = renderer?.initial ?? "preview",
209): Card {
210  let shown: ToolCall | null = null;
211  let ticking: ReturnType<typeof setInterval> | undefined;
212  const summary = <span class="tool-summary" /> as HTMLElement;
213  const time = <span class="tool-time" hidden /> as HTMLElement;
214  const status = <span class="tool-status" /> as HTMLElement;
215  const images = <div class="tool-images" />;
216  const gallery = keyed<ImageContent, number>(
217    images,
218    (_, index) => index,
219    (image, index) => {
220      const element = (
221        <img
222          class="image"
223          alt={`${call.name} image result ${index + 1}`}
224          loading="lazy"
225        />
226      ) as HTMLImageElement;
227      const update = (next: ImageContent) => {
228        element.src = `data:${next.mimeType};base64,${next.data}`;
229      };
230      update(image);
231      return { node: element, update };
232    },
233  );
234  const content = <div>{images}</div> as HTMLElement;
235  // a card resting closed builds its body the first time it opens
236  let body: ToolView | null = null;
237  const build = () => {
238    if (body) return;
239    body = makeBody();
240    content.prepend(body.element);
241    if (shown === null) return;
242    body.update(shown);
243    gallery.sync(shown.images ?? EMPTY);
244  };
245  const frame = cardFrame(
246    [<span class="tool-name">{call.name}</span>, summary, time, status],
247    content,
248    initial,
249    build,
250  );
251  // a running call's time ticks; an ended one keeps how long it took, where that is known
252  const tick = () => {
253    const { status, startedAt, durationMs } = shown!;
254    const ms = status === "running" && startedAt !== null ? Date.now() - startedAt : durationMs;
255    const timeout = renderer?.timeout?.(shown!.args) ?? null;
256    const elapsed = ms === null ? "" : duration(Math.max(0, ms));
257    show(time, elapsed && timeout !== null ? `${elapsed}/${duration(timeout * 1000)}` : elapsed);
258  };
259
260  function update(next: ToolCall) {
261    const previous = shown;
262    const keys = Object.keys(next) as (keyof ToolCall)[];
263    if (previous !== null && keys.every((key) => next[key] === previous[key])) {
264      return;
265    }
266    shown = next;
267    if (next.args !== previous?.args) {
268      summary.textContent = renderer?.summary?.(next.args) ?? "";
269    }
270    const outcome = next.status === "ok" ? renderer?.outcome?.(next) ?? null : null;
271    const state = outcome === null ? next.status : "error";
272    status.textContent = outcome ?? next.status;
273    if (next.status !== previous?.status) {
274      if (next.status === "running") ticking ??= setInterval(tick, 100);
275      else {
276        clearInterval(ticking);
277        ticking = undefined;
278      }
279    }
280    if (state !== frame.element.dataset.status) {
281      frame.element.dataset.status = state;
282      // a failure shows in full, unless the card rests closed: then its header says it failed
283      if (state === "error" && initial === "preview") frame.expand();
284    }
285    tick();
286    if (body === null) return;
287    body.update(next);
288    gallery.sync(next.images ?? EMPTY);
289  }
290
291  update(call);
292  return {
293    node: frame.element,
294    update,
295    dispose() {
296      clearInterval(ticking);
297      frame.dispose();
298      gallery.dispose();
299      body?.dispose?.();
300    },
301  };
302}
303
304function defaultBody(): ToolView {
305  const sections = [
306    [
307      "args",
308      (c: ToolCall) => c.args === null || json(c.args) === "{}" ? "" : json(c.args),
309    ],
310    ["output", (c: ToolCall) => c.output ?? ""],
311    [
312      "result",
313      (c: ToolCall) =>
314        c.result === null || isImageResult(c.result)
315          ? ""
316          : typeof c.result === "string"
317          ? c.result
318          : json(c.result),
319    ],
320    ["error", (c: ToolCall) => c.error ?? ""],
321  ] as const;
322  const pres = sections.map(([name, pick]) => ({
323    pick,
324    pre: <pre class={name} hidden /> as HTMLElement,
325  }));
326  return {
327    element: <div class="tool-body">{pres.map(({ pre }) => pre)}</div>,
328    update(call) {
329      for (const { pick, pre } of pres) show(pre, pick(call));
330    },
331  };
332}
333
334const SUBAGENT: ToolRenderer = {
335  initial: "open",
336  summary: (args) => (args as { task?: string } | null)?.task ?? null,
337  body() {
338    const link = <a class="tid" hidden>open subagent</a> as HTMLAnchorElement;
339    const model = <span class="note" hidden /> as HTMLElement;
340    const task = markdownView();
341    const answer = markdownView();
342    const error = <pre class="error" hidden /> as HTMLElement;
343    const fallback = <div>{task.element}{answer.element}</div> as HTMLElement;
344    const content = <div />;
345    const scope = new Scope();
346    let history: ReturnType<typeof transcript> | null = null;
347    const pane = scroller(
348      scope,
349      content,
350      () => history?.last() ?? null,
351      (pane) => history?.grow(pane),
352    );
353    pane.classList.add("subagent-history");
354    pane.tabIndex = 0;
355    pane.setAttribute("role", "region");
356    pane.setAttribute("aria-label", "Subagent transcript");
357    pane.hidden = true;
358    let tid: string | null = null;
359    let child: Scope | null = null;
360    let latest: ConversationView | null = null;
361    let agent: AgentState | undefined;
362    let frame = 0;
363    let current: ToolCall;
364    const release = () => {
365      child?.dispose();
366      child = null;
367      history = null;
368      latest = null;
369      frame = 0;
370      pane.hidden = true;
371      fallback.hidden = false;
372      content.replaceChildren();
373    };
374    const metadata = () => {
375      const args = current.args as {
376        model?: { provider?: string; modelId?: string };
377        thinkingLevel?: string;
378      } | null;
379      const selected = agent?.model ?? args?.model;
380      const level = agent?.thinkingLevel ?? args?.thinkingLevel;
381      show(
382        model,
383        [
384          selected?.modelId ? `${selected.provider}/${selected.modelId}` : "",
385          level ? `thinking: ${level}` : "",
386        ].filter(Boolean).join(" · "),
387      );
388    };
389    return {
390      element: (
391        <div class="tool-body subagent-body">
392          <div class="subagent-meta">{model}{link}</div>
393          {fallback}
394          {pane}
395          {error}
396        </div>
397      ),
398      update(call) {
399        current = call;
400        const args = call.args as { task?: string; conversation?: string } | null;
401        const result = call.result as { conversation?: string; answer?: string } | null;
402        const background = spawned(call.result, call.details);
403        const next = (call.details as { tid?: string } | null)?.tid ??
404          result?.conversation ?? args?.conversation ?? null;
405        task.update(args?.task ?? "", call.status !== "streaming");
406        task.element.hidden = call.status === "ok";
407        const text = typeof call.result === "string"
408          ? call.result.replace(
409            /\n\n\(subagent conversation [^\n]+; pass it as conversation to continue\)$/,
410            "",
411          )
412          : result?.answer ?? "";
413        answer.update(text, true);
414        answer.element.hidden = text === "" || call.status === "error";
415        show(error, call.error ?? (call.status === "error" ? text : ""));
416        if (next !== tid || (!background && call.status !== "running")) {
417          release();
418          if (next !== tid) agent = undefined;
419          tid = next;
420        }
421        link.hidden = tid === null;
422        if (tid === null) link.removeAttribute("href");
423        else link.href = `#/${tid}`;
424        if (tid !== null && child === null && (call.status === "running" || background)) {
425          child = new Scope();
426          history = transcript();
427          content.replaceChildren(history.element);
428          child.add(history.dispose);
429          child.add(() => cancelAnimationFrame(frame));
430          child.add(watch<ConversationView>(`conversation:${tid}`, (view) => {
431            latest = view;
432            agent = (view.docs["pi.agent"] as AgentState | undefined) ?? agent;
433            frame ||= requestAnimationFrame(() => {
434              frame = 0;
435              const live = (latest!.docs["pi.live"] ?? IDLE) as LiveState;
436              // a background child that went idle is no longer followed, leaving the link behind
437              if (background && live.run === undefined) return release();
438              history!.update(latest!.entries, live);
439              pane.hidden = false;
440              fallback.hidden = true;
441              metadata();
442            });
443          }));
444        }
445        metadata();
446      },
447      dispose() {
448        child?.dispose();
449        scope.dispose();
450      },
451    };
452  },
453};
454
455/** Whether a subagent call spawned a background child, which outlives the call. */
456const spawned = (result: unknown, details: unknown) =>
457  (result as { status?: string } | null)?.status === "running" ||
458  (details as { status?: string } | null)?.status === "running";
459
460const nestedCall = (record: NestedCall, finished: boolean): ToolCall => ({
461  name: record.name,
462  args: record.args,
463  // a script that ended, however it ended, leaves no foreground call running; a background
464  // subagent outlives its script, so its last record still says so
465  status: finished && record.status === "running" &&
466      !(record.name === "subagent" && spawned(record.value, record.details))
467    ? "cancelled"
468    : record.status,
469  output: record.output ?? null,
470  details: record.details ?? null,
471  result: record.value ?? null,
472  images: isImageResult(record.value) ? [record.value] : EMPTY,
473  error: record.error ?? null,
474  startedAt: record.startedAt ?? null,
475  durationMs: record.durationMs ?? null,
476});
477
478const CODEMODE: ToolRenderer = {
479  initial: "open",
480  summary: (args) =>
481    (args as { code?: string } | null)?.code
482      ?.split("\n")
483      .find((l) => l.trim()) ?? null,
484  body: codemodeBody,
485};
486
487function codemodeBody(): ToolView {
488  const source = <div class="script" /> as HTMLElement;
489  const nested = <div class="nested" />;
490  const output = <pre class="output" hidden /> as HTMLElement;
491  const inputCard = cardFrame([<span class="tool-name">input</span>], source);
492  const outputCard = cardFrame(
493    [<span class="tool-name">output</span>],
494    <div>{output}</div> as HTMLElement,
495  );
496  for (const frame of [inputCard, outputCard]) {
497    frame.element.classList.add("codemode-text");
498  }
499  let script: { text: string; streaming: boolean } | null = null;
500  const calls = keyed<{ extension: string; call: ToolCall }, number>(
501    nested,
502    (_, index) => index,
503    ({ extension, call }) => {
504      const renderer = call.name === "subagent" && extension === "subagent"
505        ? SUBAGENT
506        : toolRenderer(call.name, extension);
507      const shown = card(call, renderer, undefined, renderer === SUBAGENT ? "open" : "closed");
508      return {
509        node: shown.node,
510        update: ({ call }) => shown.update(call),
511        dispose: shown.dispose,
512      };
513    },
514  );
515  return {
516    element: (
517      <div class="tool-body">
518        {inputCard.element}
519        {nested}
520        {outputCard.element}
521      </div>
522    ),
523    update(call) {
524      const text = (call.args as { code?: string } | null)?.code ?? "";
525      inputCard.element.hidden = text === "";
526      const streaming = call.status === "streaming";
527      if (text !== script?.text || streaming !== script.streaming) {
528        script = { text, streaming };
529        // line numbers match the script's stack traces (`codemode.js:<line>`); highlighted once it is all written
530        source.replaceChildren(
531          code(text, streaming ? {} : { language: "javascript" }),
532        );
533      }
534      const finished = call.status !== "running" &&
535        call.status !== "streaming" &&
536        call.status !== "pending";
537      const records = (call.details as { calls?: NestedCall[] } | null)?.calls ?? EMPTY;
538      calls.sync(
539        records.map((record) => ({
540          extension: record.extension,
541          call: nestedCall(record, finished),
542        })),
543      );
544      const result = typeof call.result === "string" ? call.result : (call.error ?? "");
545      show(output, result.replace(/^Script (completed|failed)\n?/, ""));
546      outputCard.element.hidden = output.hidden;
547    },
548    dispose() {
549      calls.dispose();
550      inputCard.dispose();
551      outputCard.dispose();
552    },
553  };
554}
555
556function toolCard(call: ToolCall): Card {
557  return card(
558    call,
559    call.name === "codemode"
560      ? CODEMODE
561      : call.name === "subagent"
562      ? SUBAGENT
563      : toolRenderer(call.name),
564  );
565}
566
567function shellCall(run: ShellExecution): ToolCall {
568  return {
569    name: "bash",
570    args: { command: run.command },
571    status: run.status,
572    output: (run.details as { rawOutput?: string } | null)?.rawOutput ?? null,
573    details: run.details,
574    result: run.result,
575    error: run.error,
576    startedAt: run.startedAt,
577    durationMs: run.durationMs,
578  };
579}
580
581type Report = {
582  tid?: string;
583  task?: string;
584  status?: "completed" | "failed" | "stopped";
585  answer?: string;
586  error?: string;
587};
588
589/** A background subagent's report: what it was asked, how it ended, and a link back to it. */
590function reportCard(data: Report | null): Element {
591  const status = data?.status ?? "completed";
592  const text = (status === "failed" ? data?.error : data?.answer) ?? "";
593  return (
594    <div class="report">
595      <div class="subagent-meta">
596        <span class={status === "failed" ? "error" : ""}>subagent {status}</span>
597        {data?.tid ? <a class="tid" href={`#/${data.tid}`}>open subagent</a> : []}
598      </div>
599      {data?.task ? <div class="report-task">{data.task}</div> : []}
600      {text ? markdown(text) : []}
601    </div>
602  ) as HTMLElement;
603}
604
605function shellCard(run: ShellExecution): Card {
606  const shown = card(shellCall(run), toolRenderer("bash", "code"), undefined, "open");
607  const name = (shown.node as Element).querySelector(".tool-name")!;
608  name.textContent = run.excludeFromContext ? "!! shell" : "! shell";
609  return shown;
610}
611
612/** A block of a response, whether the response is still being generated, and whether the block is its newest. */
613type Shown = { block: Block; generating: boolean; last: boolean };
614
615function contentBlock(first: Shown, toolCall: (shown: Shown) => Handle<Shown>): Handle<Shown> {
616  if (first.block.type === "toolCall") return toolCall(first);
617  const body = markdownView();
618  if (first.block.type === "thinking") {
619    // one element while it streams, so it stays as the reader left it
620    const details = (
621      <details class="thinking" open hidden>
622        <summary>thinking</summary>
623        {body.element}
624      </details>
625    ) as HTMLElement;
626    const pending = (
627      <div class="thinking-pending" role="status" aria-label="thinking…" hidden>
628        {[..."thinking..."].map((letter, index) => (
629          <span
630            aria-hidden="true"
631            _also={(span) => {
632              span.style.animationDelay = `-${index * 60}ms`;
633            }}
634          >
635            {letter}
636          </span>
637        ))}
638      </div>
639    ) as HTMLElement;
640    const update = ({ block, last }: Shown) => {
641      // empty, as encrypted reasoning is, it shows only while it is being thought
642      const text = block.type === "thinking" && !block.redacted ? block.thinking.trim() : "";
643      body.update(text, !last);
644      details.hidden = text === "";
645      pending.hidden = text !== "" || !last;
646    };
647    update(first);
648    return {
649      node: (
650        <div class="block">
651          {details}
652          {pending}
653        </div>
654      ),
655      update,
656    };
657  }
658  const update = ({ block, last }: Shown) =>
659    body.update(block.type === "text" ? block.text : "", !last);
660  update(first);
661  return { node: <div class="block">{body.element}</div>, update };
662}
663
664function timestamp(value: number): HTMLTimeElement {
665  const time = document.createElement("time");
666  time.className = "message-time";
667  setTimestamp(time, value);
668  return time;
669}
670
671function setTimestamp(time: HTMLTimeElement, value: number) {
672  const date = new Date(value);
673  if (!Number.isFinite(date.getTime())) {
674    time.removeAttribute("datetime");
675    time.title = "";
676    time.textContent = "";
677    return;
678  }
679  const iso = date.toISOString();
680  if (time.dateTime === iso) return;
681  time.dateTime = iso;
682  time.title = date.toLocaleString();
683  time.textContent = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
684}
685
686/** A response; `update` draws its next revision, and whether it is still being generated. */
687function assistant(
688  message: AssistantMessage,
689  generating: boolean,
690  toolCall: (shown: Shown) => Handle<Shown>,
691) {
692  const blocks = <div class="blocks" />;
693  const time = timestamp(message.timestamp);
694  const error = <div class="error" hidden /> as HTMLElement;
695  const aborted = (
696    <div class="note" hidden>
697      aborted
698    </div>
699  ) as HTMLElement;
700  // a block keeps its node while it streams; a tool call keeps its card
701  const list = keyed<Shown, string>(
702    blocks,
703    ({ block }, index) => `${index}:${block.type}`,
704    (shown) => contentBlock(shown, toolCall),
705  );
706  const update = (message: AssistantMessage, generating: boolean) => {
707    const { content } = message;
708    setTimestamp(time, message.timestamp);
709    list.sync(
710      content.map((block, index) => ({
711        block,
712        generating,
713        last: generating && index === content.length - 1,
714      })),
715    );
716    show(
717      error,
718      message.stopReason === "error" ? (message.errorMessage ?? "error") : "",
719    );
720    aborted.hidden = message.stopReason !== "aborted";
721  };
722  update(message, generating);
723  return {
724    node: (
725      <div class="message assistant">
726        {blocks}
727        {error}
728        {aborted}
729        {time}
730      </div>
731    ),
732    update,
733    dispose: list.dispose,
734  };
735}
736
737function user(message: UserMessage): Element {
738  const content = typeof message.content === "string"
739    ? [{ type: "text" as const, text: message.content }]
740    : message.content;
741  return (
742    <div class="message user">
743      {content.map((block, index) =>
744        block.type === "text" ? <div class="text">{block.text}</div> : (
745          <img
746            class="image"
747            alt={`Attached image ${index + 1}`}
748            loading="lazy"
749            src={`data:${block.mimeType};base64,${block.data}`}
750          />
751        )
752      )}
753      {timestamp(message.timestamp)}
754    </div>
755  );
756}
757
758/** A `<details>` whose body is built the first time it opens, since most never are. */
759function lazyDetails(summary: Element, body: () => Node[], className?: string): HTMLElement {
760  const details = <details>{summary}</details> as HTMLDetailsElement;
761  if (className) details.className = className;
762  let built = false;
763  details.addEventListener("toggle", () => {
764    if (!details.open || built) return;
765    built = true;
766    details.append(...body());
767  });
768  return details;
769}
770
771type PromptChange = {
772  id: number;
773  before: SystemMessage | undefined;
774  messages: SystemMessage[];
775};
776
777function system() {
778  const summary = <summary /> as HTMLElement;
779  const body = <div /> as HTMLElement;
780  let current: PromptChange;
781  let built = false;
782  const renderBody = () => {
783    body.replaceChildren(...current.messages.flatMap((message) => [
784      ...Object.entries(message.sections ?? {}).flatMap(([key, value]) =>
785        value === null ? [] : [<h4>{key}</h4>, <pre>{value}</pre>]
786      ),
787      ...(message.content.length > 0
788        ? [
789          <pre>
790            {typeof message.content === "string" ? message.content : textOf(message.content)}
791          </pre>,
792        ]
793        : []),
794    ]));
795  };
796  const update = (change: PromptChange) => {
797    current = change;
798    const { before, messages } = change;
799    const after = getCurrentSystemMessage(before ? [before, ...messages] : messages);
800    const previous = before?.sections ?? {};
801    const sections = after?.sections ?? {};
802    const { toolsAdded, toolsRemoved } = getToolStateChanges(
803      before?.toolsAdded ?? [],
804      after?.toolsAdded ?? [],
805    );
806    const name = (key: string, sign = "") => <span class="system-name">{sign + key}</span>;
807    const group = (kind: string, names: Element[]) =>
808      names.length > 0
809        ? [
810          new Text(" "),
811          <span class="system-kind">{kind}</span>,
812          <span class="system-names">{names.flatMap((n) => [new Text(" "), n])}</span>,
813        ]
814        : [];
815    summary.replaceChildren(
816      <span class="system-label">prompt</span>,
817      <span class="system-groups">
818        {group(
819          "sections",
820          [
821            ...Object.entries(sections).flatMap(([key, value]) =>
822              previous[key] === value ? [] : [name(key, Object.hasOwn(previous, key) ? "" : "+")]
823            ),
824            ...Object.keys(previous).filter((key) => !Object.hasOwn(sections, key))
825              .map((key) => name(key, "-")),
826          ],
827        )}
828        {group("tools", [
829          ...toolsAdded.map((tool) => name(tool.name, "+")),
830          ...toolsRemoved.filter((tool) => !toolsAdded.some((added) => added.name === tool.name))
831            .map((tool) => name(tool.name, "-")),
832        ])}
833      </span>,
834    );
835    if (built) renderBody();
836  };
837  const node = lazyDetails(summary, () => {
838    built = true;
839    renderBody();
840    return [body];
841  }, "system");
842  return { node, update };
843}
844
845/** How many entries to draw at once, which is the window a transcript opens with. */
846const BATCH = 16;
847/** How much drawn history to keep above the reader before drawing more, in viewports. */
848const REACH = 1.5;
849
850/** The transcript of a conversation's active entries, then the response being generated; `update` draws a revision. */
851export function transcript() {
852  let results = new Map<string, ToolResultMessage>();
853  /** How each call's card draws itself again from its result and the live state. */
854  const calls = new Map<string, () => void>();
855  const prompts = new Map<number, ReturnType<typeof system>>();
856  const changes = new Map<number, PromptChange>();
857  let lastPrompt: PromptChange | undefined;
858  let promptState: SystemMessage | undefined;
859  let entries: readonly EntryRecord[] = EMPTY;
860  /** The entries that draw: a result shows on its call's card. */
861  let drawn: readonly EntryRecord[] = EMPTY;
862  let live = IDLE;
863
864  /** A call: as the model writes it, then its live slot while the round runs, then its result. */
865  function call(first: Shown): Handle<Shown> {
866    let current = first;
867    const id = (first.block as ToolCallBlock).id;
868    const state = (): ToolCall => {
869      const block = current.block as ToolCallBlock;
870      const result = results.get(id) ?? null;
871      const slot: (NonNullable<LiveState["tools"]>[number] & { startedAt?: number }) | null =
872        live.tools?.find((slot) => slot.callId === id) ?? null;
873      const status: CallStatus = result
874        ? result.isError ? "error" : "ok"
875        : current.generating
876        ? "streaming"
877        : slot?.status === "running"
878        ? "running"
879        : live.run !== undefined
880        ? "pending"
881        : "cancelled";
882      const details = (result?.details ?? slot?.details ?? null) as JsonValue | null;
883      const timing = details as { startedAt?: number; durationMs?: number } | null;
884      return {
885        name: block.name,
886        args: (block.arguments ?? null) as JsonValue | null,
887        status,
888        output: slot?.output ?? null,
889        details,
890        ...toolResult(result),
891        startedAt: slot?.startedAt ?? timing?.startedAt ?? null,
892        durationMs: result?.durationMs ?? timing?.durationMs ?? null,
893      };
894    };
895    const view = toolCard(state());
896    const refresh = () => view.update(state());
897    calls.set(id, refresh);
898    return {
899      node: view.node,
900      update(next) {
901        current = next;
902        refresh();
903      },
904      dispose() {
905        calls.delete(id);
906        view.dispose();
907      },
908    };
909  }
910
911  // committed entries never change, so their handles take no updates; only their calls' cards do
912  function entry(record: EntryRecord): Handle<EntryRecord> {
913    const message = record.model?.[0];
914    switch (record.kind) {
915      case "pi.user":
916        return { node: user(message as UserMessage) };
917      case "pi.assistant": {
918        // the response that streamed in keeps its nodes, and how the reader left them, once it commits
919        let view = live.generation?.message === undefined ? partial : null;
920        if (view) {
921          partial = null;
922          view.update(message as AssistantMessage, false);
923        } else view = assistant(message as AssistantMessage, false, call);
924        return { node: view.node, dispose: view.dispose };
925      }
926      case "pi.system": {
927        const view = system();
928        view.update(changes.get(record.id)!);
929        prompts.set(record.id, view);
930        return { node: view.node, dispose: () => prompts.delete(record.id) };
931      }
932      case "pi.reset":
933        return {
934          node: (
935            <div class="divider">
936              context reset
937              {message
938                ? lazyDetails(<summary>handoff</summary>, () => [
939                  markdown(userText(message as UserMessage)),
940                ])
941                : undefined}
942            </div>
943          ),
944        };
945      case "pi.compaction":
946        return {
947          node: lazyDetails(<summary class="divider">compacted</summary>, () => [
948            markdown(userText(message as UserMessage)),
949          ]),
950        };
951      case "berry.shell": {
952        const shown = shellCard(record.data as ShellExecution);
953        return { node: shown.node, dispose: shown.dispose };
954      }
955      case "berry.event":
956        return { node: event(record.data) ?? document.createComment("event") };
957      case "berry.subagent-report":
958        return { node: reportCard(record.data as Report | null) };
959      default:
960        return {
961          node: lazyDetails(
962            <summary>{record.kind}</summary>,
963            () => [<pre>{json(record.data ?? null)}</pre>],
964            "system",
965          ),
966        };
967    }
968  }
969
970  const list = <div class="entries" /> as HTMLElement;
971  const rows = keyed<EntryRecord, number>(list, (record) => record.id, entry);
972  /** The index in `drawn` of the oldest entry drawn; the ones above it wait, as padding, until the reader reaches them. */
973  let first = 0;
974  /** What an entry not drawn yet is taken to take, from the ones that are, so the scrollbar shows the history's size. */
975  let mean = 96;
976  let measured = false;
977  const pad = () => {
978    list.style.paddingTop = first === 0 ? "" : `${Math.round(first * mean)}px`;
979  };
980  const draw = () => {
981    pad();
982    rows.sync(drawn.slice(first));
983  };
984  /** Take the mean height of an entry from the ones drawn, for the ones that are not. */
985  const measure = () => {
986    const count = list.children.length;
987    const content = list.offsetHeight - first * mean;
988    if (count === 0 || content <= 0) return;
989    measured = true;
990    mean = content / count;
991  };
992  /** Run `draw`, changing what stands in for the rest, with the oldest entry drawn staying where the reader sees it. */
993  const holding = (pane: Viewport, draw: () => void) => {
994    const oldest = list.firstElementChild;
995    const before = oldest?.getBoundingClientRect().top ?? 0;
996    draw();
997    if (oldest !== null) pane.scrollTop += oldest.getBoundingClientRect().top - before;
998  };
999  /**
1000   * Draw more of the history as the reader reaches what is drawn, holding what they see in place. Only entries that are
1001   * drawn are laid out, so a long history costs what has been read rather than what it holds.
1002   */
1003  const grow = (pane: Viewport) => {
1004    if (pane.clientHeight === 0) return;
1005    // taken as soon as the history is drawn, so that it is not taken later, while the reader is up in it
1006    if (!measured) {
1007      holding(pane, () => {
1008        measure();
1009        pad();
1010      });
1011    }
1012    if (first === 0) return;
1013    // how far the drawn history starts above the viewport's top, which is negative once the reader is above all of it
1014    const reach = pane.scrollTop - first * mean;
1015    // a reader who has gone above what is drawn, as dragging the scrollbar to the top does, wants the entries there, not
1016    // a place kept over the gap: whoever is inside the drawn history keeps it, and one over the gap gets drawn to
1017    const wanted = reach < 0 ? 0 : pane.clientHeight * REACH;
1018    if (reach >= wanted) return;
1019    const batches = Math.ceil((wanted - reach) / (BATCH * mean));
1020    if (wanted > 0) {
1021      holding(pane, () => {
1022        first = Math.max(0, first - batches * BATCH);
1023        draw();
1024      });
1025    } else {
1026      first = Math.max(0, first - batches * BATCH);
1027      draw();
1028    }
1029  };
1030  const pending = <div class="pending" />;
1031  let partial: ReturnType<typeof assistant> | null = null;
1032  const shellPending = <div />;
1033  let shell: { taskId: number; card: Card } | null = null;
1034
1035  return {
1036    element: (
1037      <div class="transcript">
1038        {list}
1039        {pending}
1040        {shellPending}
1041      </div>
1042    ),
1043    grow,
1044    last(): Element | null {
1045      const row = shellPending.lastElementChild ?? pending.lastElementChild ??
1046        list.lastElementChild;
1047      if (!row?.classList.contains("assistant")) return row;
1048      return row.querySelector(":scope > .error:not([hidden]), :scope > .note:not([hidden])") ??
1049        row.querySelector(":scope > .blocks")?.lastElementChild ?? row;
1050    },
1051    update(
1052      nextEntries: readonly EntryRecord[],
1053      nextLive: LiveState,
1054      nextShell?: ShellExecution,
1055    ) {
1056      if (
1057        nextShell &&
1058        nextEntries.some(
1059          (entry) =>
1060            entry.kind === "berry.shell" &&
1061            (entry.data as ShellExecution).taskId === nextShell?.taskId,
1062        )
1063      ) {
1064        nextShell = undefined;
1065      }
1066      if (shell?.taskId !== nextShell?.taskId) {
1067        shell?.card.dispose();
1068        shell = nextShell ? { taskId: nextShell.taskId, card: shellCard(nextShell) } : null;
1069        shellPending.replaceChildren(...(shell ? [shell.card.node] : []));
1070      }
1071      if (shell && nextShell) shell.card.update(shellCall(nextShell));
1072      const moved = nextLive !== live;
1073      live = nextLive;
1074      // the calls whose result this revision changed, or every call when it folded the history again
1075      let answered: Set<string> | null = new Set();
1076      if (nextEntries !== entries) {
1077        // a revision mostly appends, and committed entries never change, so the old ends standing say the rest stands
1078        // too; only a revision that changes what came before folds the history again
1079        const appended = entries.length > 0 && nextEntries.length >= entries.length &&
1080          nextEntries[0] === entries[0] && nextEntries[entries.length - 1] === entries.at(-1);
1081        const from = appended ? entries.length : 0;
1082        if (!appended) {
1083          results = new Map();
1084          changes.clear();
1085          lastPrompt = undefined;
1086          promptState = undefined;
1087          drawn = EMPTY;
1088          answered = null;
1089        }
1090        entries = nextEntries;
1091        const added: EntryRecord[] = [];
1092        const changedPrompts = new Set<number>();
1093        for (const record of entries.slice(from)) {
1094          const messages = (record.model ?? []).filter((message) => message.role === "system");
1095          if (record.kind === "pi.system") {
1096            // reorder patches share a timestamp, but may arrive in separate revisions
1097            if (!lastPrompt || lastPrompt.messages[0]?.timestamp !== messages[0]?.timestamp) {
1098              lastPrompt = { id: record.id, before: promptState, messages: [] };
1099              changes.set(record.id, lastPrompt);
1100              added.push(record);
1101            }
1102            lastPrompt.messages.push(...messages);
1103            changedPrompts.add(lastPrompt.id);
1104          } else {
1105            lastPrompt = undefined;
1106            if (record.kind !== "pi.tool-result" && record.kind !== "berry.start") {
1107              added.push(record);
1108            }
1109          }
1110          if (messages.length) {
1111            promptState = getCurrentSystemMessage(
1112              promptState ? [promptState, ...messages] : messages,
1113            );
1114          }
1115          for (const message of record.model ?? []) {
1116            if (message.role !== "toolResult") continue;
1117            results.set(message.toolCallId, message);
1118            answered?.add(message.toolCallId);
1119          }
1120        }
1121        for (const id of appended ? changedPrompts : prompts.keys()) {
1122          const change = changes.get(id);
1123          if (change) prompts.get(id)?.update(change);
1124        }
1125        drawn = [...drawn, ...added];
1126        // a history drawn afresh opens at its newest entries, and takes its estimate of the rest again
1127        if (!appended) {
1128          first = Math.max(0, drawn.length - BATCH);
1129          measured = false;
1130        }
1131        draw();
1132      }
1133      // a call with its result is done with the live state
1134      for (const [id, refresh] of calls) {
1135        if (answered === null || answered.has(id) || (moved && !results.has(id))) refresh();
1136      }
1137      if (!moved) return;
1138      const message = (live.generation?.message ??
1139        null) as AssistantMessage | null;
1140      if (message === null) {
1141        partial?.dispose();
1142        partial = null;
1143        pending.replaceChildren();
1144      } else if (partial === null) {
1145        partial = assistant(message, true, call);
1146        pending.replaceChildren(partial.node);
1147      } else partial.update(message, true);
1148    },
1149    dispose() {
1150      rows.dispose();
1151      partial?.dispose();
1152      shell?.card.dispose();
1153    },
1154  };
1155}