char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Sombetter code line breakingbc405c3

main
18.6 KiB540 linesraw
1import { Signal } from "@char/aftercare";
2import type { ImageContent, UserMessage } from "@earendil-works/pi-ai";
3import { decodeBase64, encodeBase64 } from "@std/encoding/base64";
4import { type DraftImage, imageDraft } from "./image-drafts.ts";
5import type { CommandInfo } from "./conversation.tsx";
6import type { Place } from "./tree.tsx";
7import { computed, keyed, Scope, swap } from "./ui.ts";
8
9type Completion = { value: string; label: string; description: string; directory?: boolean };
10type Query = { kind: "command"; prefix: string } | {
11  kind: "reference";
12  prefix: string;
13  start: number;
14  end: number;
15};
16
17/** The reference containing the caret, including its unfinished suffix. */
18export function referenceAt(text: string, start: number, end = start): Query | null {
19  if (start !== end) return null;
20  const match = text.slice(0, start).match(/(?:^|\s|[([{])@([^\s]*)$/u);
21  if (!match) return null;
22  const prefix = match[1];
23  return {
24    kind: "reference",
25    prefix,
26    start: start - prefix.length,
27    end: start + (text.slice(start).match(/^[^\s]*/u)?.[0].length ?? 0),
28  };
29}
30
31const EMPTY: readonly never[] = [];
32const drafts = new Map<string, string>();
33let nextSendMenu = 0;
34
35function savedText(route: string): string {
36  if (drafts.has(route)) return drafts.get(route)!;
37  try {
38    return localStorage.getItem(`berry.draft.${route}`) ?? "";
39  } catch {
40    return "";
41  }
42}
43
44function saveText(route: string, text: string) {
45  try {
46    if (text === "") localStorage.removeItem(`berry.draft.${route}`);
47    else localStorage.setItem(`berry.draft.${route}`, text);
48    drafts.delete(route);
49  } catch {
50    // navigation still preserves drafts when browser storage is unavailable
51    drafts.set(route, text);
52  }
53}
54
55/** Text for the composer of the view a choice in the tree opens. */
56let handoff: { route: string; text: string } | null = null;
57
58/** Show `place`, and its text in its composer. */
59export function go(place: Place, setText: (text: string) => void) {
60  const draft = place.at === undefined ? "" : `${place.fork ? "/fork" : ""}@${place.at}`;
61  const route = `#/${place.tid}${draft}`;
62  if (route === location.hash) {
63    if (place.text !== undefined) setText(place.text);
64    return;
65  }
66  handoff = place.text === undefined ? null : { route, text: place.text };
67  location.hash = route;
68}
69
70/**
71 * `send` takes whatever is not a command that runs in the browser; a failure keeps the text. `abort`, where there is
72 * something to abort, stops the conversation while it is busy.
73 */
74export function composer(
75  scope: Scope,
76  route: string,
77  busy: Signal<boolean>,
78  commands: Signal<readonly CommandInfo[]>,
79  send: (text: string, followUp: boolean, images: readonly ImageContent[]) => Promise<void>,
80  notify: (message: string, error?: boolean) => void,
81  abort?: () => Promise<unknown>,
82  completeReferences?: (prefix: string) => Promise<readonly string[]>,
83  shellEnabled = new Signal(false),
84): {
85  element: Element;
86  setText(text: string): void;
87  restore(content: UserMessage["content"]): Promise<boolean>;
88} {
89  const input = (
90    <textarea
91      rows={1}
92      placeholder="message or /command"
93    />
94  ) as HTMLTextAreaElement;
95  // what submit would run as a shell command rather than send as a message
96  const inShellMode = () => shellEnabled.get() && input.value.trimStart().startsWith("!");
97  const syncShellMode = () => input.classList.toggle("shell", inShellMode());
98  scope.add(
99    shellEnabled.subscribeImmediate((enabled) => {
100      input.placeholder = enabled ? "message, /command, or !shell" : "message or /command";
101      syncShellMode();
102    }).unsubscribe,
103  );
104  const draft = imageDraft(route);
105  let pasting = 0;
106  const attachments = <div class="attachments" /> as HTMLElement;
107  const previews = keyed<DraftImage, string>(attachments, (image) => image.id, (image) => {
108    const url = URL.createObjectURL(image.blob);
109    const status = <span class="attachment-status" role="status" /> as HTMLElement;
110    const node = (
111      <div class="attachment">
112        <img src={url} alt={image.name} />
113        <span class="attachment-name">{image.name}</span>
114        {status}
115        <button
116          type="button"
117          class="danger"
118          aria-label={`Remove ${image.name}`}
119          title="Remove image"
120          _onclick={() => draft.remove([image.id]).catch((error) => notify(error.message, true))}
121        >
122          ×
123        </button>
124      </div>
125    ) as HTMLElement;
126    const update = (next: DraftImage) => {
127      node.dataset.status = next.status;
128      status.textContent = next.status === "loading" ? "saving…" : next.error ?? "";
129      status.hidden = status.textContent === "";
130    };
131    update(image);
132    return { node, update, dispose: () => URL.revokeObjectURL(url) };
133  });
134  scope.add(
135    draft.items.subscribeImmediate((items) => {
136      attachments.hidden = items.length === 0;
137      previews.sync(items);
138    }).unsubscribe,
139  );
140  scope.add(previews.dispose);
141  const draftError = <div class="attachment-error" role="alert" /> as HTMLElement;
142  scope.add(
143    draft.error.subscribeImmediate((error) => {
144      draftError.hidden = error === null;
145      draftError.textContent = error;
146    }).unsubscribe,
147  );
148  let query: Query | null = null;
149  let revision = 0;
150  let timer: ReturnType<typeof setTimeout> | undefined;
151  const matches = new Signal<readonly Completion[]>(EMPTY);
152  const sendOptionsOpen = new Signal(false);
153  const selected = new Signal(0);
154  const dismiss = () => {
155    query = null;
156    revision++;
157    clearTimeout(timer);
158    matches.set(EMPTY);
159  };
160  const refreshMatches = () => {
161    revision++;
162    clearTimeout(timer);
163    if (query?.kind === "command") {
164      selected.set(0);
165      matches.set(
166        commands.get().filter((c) => c.name.startsWith(query!.prefix)).map((c) => ({
167          value: `/${c.name} `,
168          label: `/${c.name}`,
169          description: c.description,
170        })),
171      );
172    } else if (query?.kind === "reference" && completeReferences) {
173      const current = revision;
174      const prefix = query.prefix;
175      timer = setTimeout(() => {
176        void completeReferences(prefix).then((paths) => {
177          if (current !== revision) return;
178          selected.set(0);
179          matches.set(paths.map((path) => ({
180            value: path,
181            label: `@${path}`,
182            description: "",
183            directory: path.endsWith("/"),
184          })));
185        }).catch(() => {});
186      }, 100);
187    } else {
188      matches.set(EMPTY);
189    }
190  };
191  const updateQuery = () => {
192    const previousKind = query?.kind;
193    syncShellMode();
194    query = inShellMode()
195      ? null
196      : input.value.match(/^\/(\S*)$/)
197      ? { kind: "command", prefix: input.value.slice(1) }
198      : referenceAt(input.value, input.selectionStart, input.selectionEnd);
199    if (query?.kind !== previousKind) matches.set(EMPTY);
200    refreshMatches();
201  };
202  scope.add(dismiss);
203  scope.add(commands.subscribe(refreshMatches).unsubscribe);
204
205  const resize = () => {
206    const frame = input.parentElement!;
207    // intermediate measurements must not expand the transcript and clamp its scroll position
208    frame.style.minHeight = `${frame.getBoundingClientRect().height}px`;
209    frame.classList.remove("stacked");
210    input.style.height = "auto";
211    input.style.overflowY = "hidden";
212    // only soft wrapping should move the controls below the text
213    const wrappedHeight = input.scrollHeight;
214    input.style.whiteSpace = "pre";
215    const wraps = wrappedHeight > input.scrollHeight;
216    input.style.whiteSpace = "";
217    frame.classList.toggle("stacked", wraps);
218    // allow for subpixel rounding in the native textarea
219    input.style.height = `${input.scrollHeight + 1}px`;
220    input.style.overflowY = "";
221    frame.style.minHeight = "";
222  };
223  const complete = (item: Completion) => {
224    if (!query) return;
225    const start = query.kind === "reference" ? query.start : 0;
226    const end = query.kind === "reference" ? query.end : input.value.length;
227    const suffix = query.kind === "reference" && !item.directory && end === input.value.length
228      ? " "
229      : "";
230    const replacement = item.value + suffix;
231    input.value = input.value.slice(0, start) + replacement + input.value.slice(end);
232    input.setSelectionRange(start + replacement.length, start + replacement.length);
233    saveText(route, input.value);
234    resize();
235    input.focus();
236    if (item.directory) updateQuery();
237    else dismiss();
238  };
239
240  const setText = (text: string) => {
241    input.value = text;
242    saveText(route, text);
243    syncShellMode();
244    dismiss();
245    // once it is laid out, which a new view is not yet
246    requestAnimationFrame(() => {
247      resize();
248      input.focus();
249    });
250  };
251  const saved = savedText(route);
252  if (saved !== "") setText(saved);
253  if (handoff?.route === `#/${route}`) {
254    setText(handoff.text);
255    handoff = null;
256  }
257
258  async function submit(followUp: boolean) {
259    sendOptionsOpen.set(false);
260    const original = input.value;
261    const text = original.trim();
262    if (
263      draft.submitting ||
264      (text === "" && !draft.loading.get() && draft.items.get().length === 0 && !pasting)
265    ) return;
266    draft.submitting = true;
267    try {
268      if (draft.loading.get()) await draft.ready;
269      if (pasting) throw new Error("Wait for pasted images to finish saving");
270      const captured = draft.items.get();
271      if (text === "" && captured.length === 0) return;
272      if (captured.some((image) => image.status !== "ready")) {
273        throw new Error("Wait for images to finish saving, or remove images with errors");
274      }
275      if (captured.length > 0 && draft.error.get()) throw new Error(draft.error.get()!);
276      const name = text.match(/^\/(\S+)/)?.[1];
277      if (name && captured.length > 0) throw new Error("Slash commands cannot include images");
278      if (shellEnabled.get() && text.startsWith("!") && captured.length > 0) {
279        throw new Error("Shell commands cannot include images");
280      }
281      const images: ImageContent[] = captured.length === 0 ? [] : await Promise.all(
282        captured.map(async (image) => ({
283          type: "image" as const,
284          mimeType: image.blob.type,
285          data: encodeBase64(await image.blob.arrayBuffer()),
286        })),
287      );
288      const local = commands.get().find((command) => command.run && command.name === name);
289      if (local) await local.run!();
290      else await send(text, followUp, images);
291      if (captured.length > 0) await draft.remove(captured.map((image) => image.id));
292      if (input.value === original) {
293        input.value = "";
294        if (savedText(route) === original) saveText(route, "");
295        dismiss();
296        syncShellMode();
297        resize();
298      }
299    } catch (error) {
300      notify(error instanceof Error ? error.message : String(error), true);
301    } finally {
302      draft.submitting = false;
303    }
304  }
305
306  input.addEventListener("paste", (event) => {
307    const clipboard = event.clipboardData;
308    if (!clipboard) return;
309    const files = [...clipboard.items].flatMap((item) => {
310      const file = item.kind === "file" && item.type.startsWith("image/") ? item.getAsFile() : null;
311      return file ? [file] : [];
312    });
313    if (files.length === 0) return;
314    event.preventDefault();
315    const text = clipboard.getData("text/plain");
316    if (text) {
317      input.setRangeText(text, input.selectionStart, input.selectionEnd, "end");
318      saveText(route, input.value);
319      resize();
320      updateQuery();
321    }
322    pasting++;
323    void draft.add(files).catch((error) => notify(error.message, true)).finally(() => pasting--);
324  });
325  input.addEventListener("input", () => {
326    saveText(route, input.value);
327    resize();
328    updateQuery();
329  });
330  input.addEventListener("click", updateQuery);
331  input.addEventListener("keyup", (event) => {
332    if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) updateQuery();
333  });
334  input.addEventListener("keydown", (event) => {
335    if (event.isComposing) return;
336    const list = matches.get();
337    if (event.key === "Escape" && query) {
338      event.preventDefault();
339      dismiss();
340    } else if (["ArrowDown", "ArrowUp"].includes(event.key) && list.length > 0) {
341      event.preventDefault();
342      selected.set(
343        (selected.get() + (event.key === "ArrowDown" ? 1 : -1) + list.length) % list.length,
344      );
345      suggestions.querySelectorAll("button")[selected.get()]?.scrollIntoView({ block: "nearest" });
346    } else if (
347      list.length > 0 && (event.key === "Tab" ||
348        (event.key === "Enter" && query?.kind === "reference" && !event.shiftKey && !event.altKey))
349    ) {
350      event.preventDefault();
351      complete(list[selected.get()]);
352    } else if (
353      event.key === "Enter" && !event.shiftKey && !event.isComposing &&
354      // an on-screen keyboard has no shift+enter for a new line; its buttons send instead
355      !matchMedia("(pointer: coarse)").matches
356    ) {
357      event.preventDefault();
358      void submit(event.altKey);
359    }
360  });
361
362  const suggestions = <div class="suggestions" hidden={matches.derive((m) => m.length === 0)} />;
363  swap(
364    scope,
365    suggestions,
366    matches,
367    (list, scope) =>
368      list.map((c, index) => (
369        <button
370          type="button"
371          class={computed(scope, [selected], () => selected.get() === index ? "selected" : "")}
372          _onmousedown={(event) => event.preventDefault()}
373          _onclick={() => complete(c)}
374        >
375          <span class="command" title={c.label}>{c.label}</span>
376          <span class="description">{c.description}</span>
377        </button>
378      )),
379  );
380  const sendMenuId = `composer-send-${++nextSendMenu}`;
381  const element = (
382    <div class="composer">
383      {suggestions}
384      {attachments}
385      {draftError}
386      <div class="composer-input">
387        {input}
388        <div class="composer-controls">
389          <div
390            class="actions"
391            // keep the keyboard up
392            _onmousedown={(event) => event.preventDefault()}
393          >
394            <button
395              type="button"
396              class="primary send"
397              aria-label="Send message"
398              title="Send message"
399              hidden={busy}
400              _onclick={() => void submit(false)}
401            >
402              send
403            </button>
404            <div class="send-options" hidden={computed(scope, [busy], () => !busy.get())}>
405              <button type="button" class="primary" _onclick={() => void submit(false)}>
406                steer
407              </button>
408              <button
409                type="button"
410                class="send-more"
411                aria-label="Other send options"
412                title="Other send options"
413                aria-expanded={sendOptionsOpen.derive(String)}
414                aria-controls={sendMenuId}
415                _onclick={() => sendOptionsOpen.set(!sendOptionsOpen.get())}
416              >
417                ▾
418              </button>
419              <div
420                id={sendMenuId}
421                class="send-menu"
422                role="group"
423                aria-label="Other send options"
424                hidden={sendOptionsOpen.derive((open) => !open)}
425              >
426                <button
427                  type="button"
428                  title="enqueue message for after response"
429                  _onclick={() => {
430                    void submit(true);
431                    input.focus({ preventScroll: true });
432                  }}
433                >
434                  follow up
435                </button>
436              </div>
437            </div>
438          </div>
439          {abort
440            ? (
441              <button
442                type="button"
443                class="abort"
444                aria-label="Abort response"
445                title="Abort response"
446                hidden={busy.derive((b) => !b)}
447                _onmousedown={(event) => event.preventDefault()}
448                _onclick={() => abort().catch((error) => notify(error.message, true))}
449              />
450            )
451            : ""}
452        </div>
453      </div>
454      <div class="hints">
455        <span hidden={busy}>
456          <kbd>enter</kbd> send
457        </span>
458        <span hidden={busy.derive((b) => !b)}>
459          <kbd>enter</kbd> steer
460        </span>
461        <span hidden={busy.derive((b) => !b)}>
462          <kbd>alt</kbd>
463          <kbd>enter</kbd> follow up
464        </span>
465        <span>
466          <kbd>shift</kbd>
467          <kbd>enter</kbd> new line
468        </span>
469        <span>
470          <kbd>/</kbd> commands · <kbd>@</kbd> files
471          <span hidden={shellEnabled.derive((enabled) => !enabled)}>
472            {" · "}
473            <kbd>!</kbd> shell · <kbd>!!</kbd> shell (silent)
474          </span>
475        </span>
476        <span>
477          <kbd>tab</kbd> complete
478        </span>
479      </div>
480    </div>
481  );
482  const sendOptions = element.querySelector(".send-options")!;
483  const more = sendOptions.querySelector<HTMLButtonElement>(".send-more")!;
484  const dismissSendOptions = (event: Event) => {
485    if (!sendOptions.contains(event.target as Node)) sendOptionsOpen.set(false);
486  };
487  const escapeSendOptions = (event: KeyboardEvent) => {
488    if (event.key !== "Escape" || !sendOptionsOpen.get()) return;
489    event.preventDefault();
490    event.stopPropagation();
491    const keyboard = sendOptions.contains(document.activeElement);
492    sendOptionsOpen.set(false);
493    (keyboard ? more : input).focus({ preventScroll: true });
494  };
495  document.addEventListener("pointerdown", dismissSendOptions);
496  document.addEventListener("focusin", dismissSendOptions);
497  document.addEventListener("keydown", escapeSendOptions, true);
498  const viewport = input.ownerDocument.defaultView!;
499  viewport.addEventListener("resize", resize);
500  scope.add(() => {
501    document.removeEventListener("pointerdown", dismissSendOptions);
502    document.removeEventListener("focusin", dismissSendOptions);
503    document.removeEventListener("keydown", escapeSendOptions, true);
504    viewport.removeEventListener("resize", resize);
505  });
506  scope.add(
507    busy.subscribe(() => {
508      sendOptionsOpen.set(false);
509      requestAnimationFrame(resize);
510    }).unsubscribe,
511  );
512  return {
513    element,
514    setText,
515    async restore(content) {
516      await draft.ready;
517      if (input.value !== "" || draft.items.get().length || draft.submitting || pasting) {
518        return false;
519      }
520      const blocks = typeof content === "string" ? [] : content;
521      const files = blocks.flatMap((block, index) =>
522        block.type === "image"
523          ? [
524            new File([decodeBase64(block.data)], `Restored image ${index + 1}`, {
525              type: block.mimeType,
526            }),
527          ]
528          : []
529      );
530      const saving = files.length ? draft.add(files) : Promise.resolve();
531      setText(
532        typeof content === "string"
533          ? content
534          : blocks.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n"),
535      );
536      await saving;
537      return true;
538    },
539  };
540}