char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somdirectly copy selection to clipboard to work around firefox bug871f159

main
9.8 KiB275 linesraw
1import type { Channel, ChannelMessage, View, ViewContext } from "@berry/api/web.ts";
2import type { Ghostty, Terminal } from "./ghostty.ts";
3import { Grid } from "./grid.ts";
4import { COLORS } from "./palette.ts";
5import { type ClientMessage, OUTPUT, type ServerMessage, SNAPSHOT } from "./protocol.ts";
6
7const FALLBACK = { width: 8, height: 18 };
8
9const css = ([r, g, b]: readonly number[]) => `rgb(${r} ${g} ${b})`;
10
11const zoomKey = (key: KeyboardEvent) =>
12  (key.ctrlKey || key.metaKey) &&
13  ["Equal", "Minus", "Digit0", "NumpadAdd", "NumpadSubtract", "Numpad0"].includes(key.code);
14
15/** A conversation's shell, drawn from the terminal state the server sends and the output that follows it. */
16export function terminalView(load: () => Promise<Ghostty>): View {
17  const rows = <div class="terminal-rows" /> as HTMLElement;
18  const canvas = <canvas class="terminal-grid" aria-hidden="true" /> as HTMLCanvasElement;
19  const grid = new Grid(rows, canvas);
20  const browser = document.defaultView!;
21  const probe = <span class="terminal-probe" aria-hidden="true">{"M".repeat(32)}
22  </span> as HTMLElement;
23  const input = (
24    <textarea
25      class="terminal-input"
26      aria-label="terminal input"
27      autocapitalize="off"
28      autocomplete="off"
29      spellcheck={false}
30    />
31  ) as HTMLTextAreaElement;
32  const screen = (
33    <div class="terminal-screen" tabIndex={-1} role="region" aria-label="terminal">
34      {canvas}
35      {rows}
36      {probe}
37      {input}
38    </div>
39  ) as HTMLElement;
40  const status = <p class="terminal-status-text" /> as HTMLElement;
41  const restart = (
42    <button type="button" hidden _onclick={() => send({ t: "start", ...size })}>
43      start a new shell
44    </button>
45  ) as HTMLButtonElement;
46  const bar = <div class="terminal-status" hidden>{status}{restart}</div> as HTMLElement;
47  const element = <div class="terminal-view">{screen}{bar}</div> as HTMLElement;
48  // the defaults the shell is told, so what it draws in them and what it draws explicitly agree
49  element.style.backgroundColor = css(COLORS.background);
50  screen.style.color = css(COLORS.foreground);
51
52  let context: ViewContext | null = null;
53  let ghostty: Ghostty | null = null;
54  let terminal: Terminal | null = null;
55  let channel: Channel | null = null;
56  let disposed = false;
57  // only opening the view starts a shell by itself; after an exit, or a restart of the server, starting is asked for
58  let autostart = true;
59  let retry = 0;
60  let frame = 0;
61  let cell = FALLBACK;
62  let size = { cols: 80, rows: 24 };
63
64  const send = (message: ClientMessage | Uint8Array) => channel?.send(message);
65
66  function tell(text: string | null, offerStart = false) {
67    bar.hidden = text === null;
68    status.textContent = text ?? "";
69    restart.hidden = !offerStart;
70  }
71
72  function draw() {
73    frame = 0;
74    // a freed terminal's memory may hold anything by now
75    if (!terminal || disposed) return;
76    grid.update(terminal.frame());
77  }
78  const redraw = () => void (disposed || (frame ||= requestAnimationFrame(draw)));
79
80  function measure() {
81    const box = probe.getBoundingClientRect();
82    cell = box.width > 0 ? { width: box.width / 32, height: box.height } : FALLBACK;
83  }
84
85  /** Fit the session to this view, which is the size it was opened, or last resized, at. */
86  function fit() {
87    if (disposed) return;
88    measure();
89    const box = screen.getBoundingClientRect();
90    grid.fit({
91      width: box.width || size.cols * cell.width,
92      height: box.height || size.rows * cell.height,
93      ratio: browser.devicePixelRatio || 1,
94      cell,
95    });
96    const next = box.width > 0
97      ? {
98        cols: Math.max(2, Math.floor(box.width / cell.width)),
99        rows: Math.max(1, Math.floor(box.height / cell.height)),
100      }
101      : size;
102    if (next.cols === size.cols && next.rows === size.rows) return;
103    size = next;
104    terminal?.resize(size.cols, size.rows);
105    send({ t: "resize", ...size });
106    redraw();
107  }
108  const resizing = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(fit);
109  resizing?.observe(screen);
110  // the probe changes size once the font loads, which changes how many cells fit
111  resizing?.observe(probe);
112  let resolution: MediaQueryList | undefined;
113  let ratioFrame = 0;
114  function watchRatio() {
115    resolution?.removeEventListener("change", ratioChanged);
116    resolution = browser.matchMedia(`(resolution: ${browser.devicePixelRatio || 1}dppx)`);
117    resolution.addEventListener("change", ratioChanged);
118  }
119  function ratioChanged() {
120    ratioFrame ||= requestAnimationFrame(() => {
121      ratioFrame = 0;
122      if (disposed) return;
123      watchRatio();
124      fit();
125    });
126  }
127  watchRatio();
128  browser.addEventListener("resize", fit);
129  document.fonts?.ready.then(fit);
130  element.addEventListener("focusin", () => grid.focus(true));
131  element.addEventListener("focusout", (event) => {
132    grid.focus(element.contains((event as FocusEvent).relatedTarget as Node | null));
133  });
134
135  function hear(message: ChannelMessage) {
136    retry = 0;
137    if (message instanceof Uint8Array) {
138      if (message[0] === SNAPSHOT) {
139        terminal?.free();
140        terminal = ghostty!.restore(message.subarray(1));
141        tell(null);
142        element.classList.remove("exited");
143        // the session takes this view's size, unless it already has it
144        terminal.resize(size.cols, size.rows);
145        send({ t: "resize", ...size });
146      } else if (message[0] === OUTPUT) terminal?.write(message.subarray(1));
147      redraw();
148      return;
149    }
150    const said = message as ServerMessage;
151    if (said.t === "none") {
152      if (autostart) send({ t: "start", ...size });
153      else tell("no shell is running here", true);
154    } else if (said.t === "exit") {
155      element.classList.add("exited");
156      tell(`the shell exited${said.code === null ? "" : ` with ${said.code}`}`, true);
157    } else if (said.t === "size") {
158      terminal?.resize(said.cols, said.rows);
159      redraw();
160    } else if (said.t === "error") tell(said.message, true);
161    autostart = false;
162  }
163
164  async function connect() {
165    if (disposed || !context) return;
166    try {
167      ghostty ??= await load();
168      const opened = channel = context.connect("session");
169      for await (const message of opened.messages) {
170        if (disposed || channel !== opened) return;
171        hear(message);
172      }
173      if (disposed || channel !== opened) return;
174      tell("disconnected: reconnecting…");
175    } catch (error) {
176      if (disposed) return;
177      tell(`${error instanceof Error ? error.message : String(error)}: reconnecting…`);
178    }
179    autostart = false;
180    setTimeout(connect, Math.min(10_000, 500 * 2 ** retry++));
181  }
182
183  const selected = () => {
184    const selection = getSelection();
185    return selection !== null && !selection.isCollapsed && screen.contains(selection.anchorNode);
186  };
187  // firefox inserts line breaks between positioned glyphs in a native copy.
188  screen.addEventListener("copy", (event) => {
189    const selection = getSelection();
190    const clipboard = (event as ClipboardEvent).clipboardData;
191    if (
192      !selection || selection.isCollapsed || !screen.contains(selection.anchorNode) || !clipboard
193    ) return;
194    clipboard.setData("text/plain", selection.toString());
195    event.preventDefault();
196  });
197
198  element.addEventListener("keydown", (event) => {
199    const key = event as KeyboardEvent;
200    if (!terminal || key.isComposing || key.key === "Process" || key.key === "Dead") return;
201    const command = key.ctrlKey || key.metaKey;
202    if (zoomKey(key)) return;
203    // the browser copies and pastes these; everything else is the shell's
204    if (command && key.code === "KeyC" && selected()) return;
205    if ((command && key.code === "KeyV") || (key.shiftKey && key.code === "Insert")) return;
206    const bytes = terminal.key(key);
207    if (bytes === null) return;
208    key.preventDefault();
209    send(bytes);
210    terminal.scroll("bottom");
211    redraw();
212    if (document.activeElement !== input) input.focus({ preventScroll: true });
213  });
214  element.addEventListener("keyup", (event) => {
215    if (zoomKey(event as KeyboardEvent)) return;
216    const bytes = terminal?.key(event as KeyboardEvent, "release");
217    if (bytes) send(bytes);
218  });
219  // what reaches the textarea without a key press of its own: an input method's composition, or a mobile keyboard
220  input.addEventListener("input", (event) => {
221    if (!input.value || (event as InputEvent).isComposing) return;
222    send(new TextEncoder().encode(input.value));
223    input.value = "";
224  });
225  input.addEventListener("compositionend", () => {
226    if (input.value) send(new TextEncoder().encode(input.value));
227    input.value = "";
228  });
229  element.addEventListener("paste", (event) => {
230    const text = (event as ClipboardEvent).clipboardData?.getData("text/plain");
231    if (!terminal || !text) return;
232    event.preventDefault();
233    send(terminal.paste(text));
234  });
235  let scrolled = 0;
236  screen.addEventListener("wheel", (event) => {
237    if ((event as WheelEvent).ctrlKey) return;
238    if (!terminal) return;
239    event.preventDefault();
240    scrolled += (event as WheelEvent).deltaY / cell.height;
241    const rows = Math.trunc(scrolled);
242    if (rows === 0) return;
243    scrolled -= rows;
244    terminal.scroll(rows);
245    redraw();
246  }, { passive: false });
247  screen.addEventListener("mouseup", () => {
248    if (!selected()) input.focus({ preventScroll: true });
249  });
250
251  return {
252    element,
253    update(next) {
254      const first = context === null;
255      context = next;
256      if (!first) return;
257      queueMicrotask(() => {
258        fit();
259        input.focus({ preventScroll: true });
260      });
261      void connect();
262    },
263    dispose() {
264      disposed = true;
265      resizing?.disconnect();
266      browser.removeEventListener("resize", fit);
267      resolution?.removeEventListener("change", ratioChanged);
268      cancelAnimationFrame(frame);
269      cancelAnimationFrame(ratioFrame);
270      channel?.close();
271      terminal?.free();
272      terminal = null;
273    },
274  };
275}