mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Every touch put the transparent input field under the finger, so a long press on output selected in the empty field (Paste only) and never a word of the output; the field's focus also opened the keyboard, whose resize redrew all rows. Rows were replaced whenever output changed them, which dropped any selection. Now a long press on a character of the output is Chrome's own (word, handles, Copy); only a blank cell gets the field for Paste. Touch contextmenus are never cancelled. While output is selected rows are not redrawn (15 s after the last change, or until the selection ends or the user types, pastes or scrolls). Copy joins soft-wrapped rows (new Frame row flag from libghostty) and drops the blanks that fill rows.
1581 lines
58 KiB
TypeScript
1581 lines
58 KiB
TypeScript
import type { Held } from "@p2/app/v1";
|
||
import { BACKGROUND_CONTEXT, whenLive } from "@p2/app/v1";
|
||
import {
|
||
browserEnvironment,
|
||
CloseIcon,
|
||
HideIcon,
|
||
KeyboardIcon,
|
||
keepConnected,
|
||
PanelBottomIcon,
|
||
PanelSideIcon,
|
||
PlusIcon,
|
||
type ReconnectLink,
|
||
type SocketEvents,
|
||
} from "@p2/app/v1/web";
|
||
import { type CSSProperties, useEffect, useRef, useState } from "react";
|
||
import type { Terminals } from "./contract.ts";
|
||
import {
|
||
BOLD,
|
||
type Cell,
|
||
FAINT,
|
||
Ghostty,
|
||
INVERSE,
|
||
INVISIBLE,
|
||
ITALIC,
|
||
type KeyInput,
|
||
type MouseInput,
|
||
OVERLINE,
|
||
STRIKETHROUGH,
|
||
UNDERLINE,
|
||
type VtTerminal,
|
||
} from "./ghostty.ts";
|
||
import type { TerminalEvent, TerminalInfo, TerminalTheme } from "./protocol.ts";
|
||
import { typedChange } from "./terminal-input.ts";
|
||
import ghosttyWasm from "./vendor/ghostty/ghostty-vt.wasm?url";
|
||
import "./terminal-clipboard.css";
|
||
|
||
// Terminals of a session, in the workspace panel (owner only). The programs run in the terminal host on the session's
|
||
// machine (detached.ts); this view renders one with libghostty-vt into DOM rows, so text selection and
|
||
// copying are the browser's own, and sends keys, text and pastes back. A reconnect attaches again and starts from the
|
||
// host's snapshot.
|
||
//
|
||
// Typing goes through a hidden textarea. Keys that type nothing (Enter, arrows, Ctrl+C) are encoded from keydown.
|
||
// Text is taken from what changes in the textarea, not from keydown: on Android every key of an on-screen keyboard
|
||
// arrives as keyCode 229 ("Unidentified"), and the keyboard composes words in the field, corrects them and deletes
|
||
// from them. Example: Gboard types "teh" (sent as typed), then corrects it to "the ": the field goes from "teh" to
|
||
// "the ", which sends two Backspaces and "he ". The field keeps what was typed since the last key that moved the
|
||
// terminal's cursor some other way (Enter, arrows, a paste), so the keyboard's idea of the text matches the field. A
|
||
// field kept empty by cancelling every insertion does not: the keyboard deletes and corrects text the field never
|
||
// had, which changes nothing and sends nothing.
|
||
//
|
||
// Touch: a long press on output text is the browser's own (word selection, handles, Copy); a long press on a blank
|
||
// spot puts the textarea under the finger, so the browser offers Paste into it. Example: on "$ ls -la", a long press
|
||
// on "ls" selects "ls"; one right of the prompt's end gets the Paste menu. While output is selected, rows are not
|
||
// redrawn (`#held`): replacing a row's spans would drop the selection the moment the program printed.
|
||
|
||
/** Where terminals open: a tab of the workspace panel, or a panel below the chat. Kept per browser. */
|
||
export type TerminalPlacement = "side" | "bottom";
|
||
/** Main-thread time per animation frame for parsing output, and the largest piece parsed at once. */
|
||
const OUTPUT_BUDGET_MS = 8;
|
||
const OUTPUT_SLICE_BYTES = 64 * 1024;
|
||
/** Acknowledge output in batches: at least this much, or after this delay. */
|
||
const ACK_BATCH_BYTES = 64 * 1024;
|
||
const ACK_DELAY_MS = 50;
|
||
/** The server pings every 15 s (TERMINAL_PING_MS in service.ts): a socket silent for three of them is replaced. */
|
||
const SILENCE_TIMEOUT_MS = 45_000;
|
||
/** A touch held this long without moving is a long press (before Chrome's own, about 400 to 500 ms). */
|
||
const LONG_PRESS_MS = 350;
|
||
/** Selected output stays drawn as it is for this long after the selection last changed, despite new output. */
|
||
const SELECTION_HOLD_MS = 15_000;
|
||
const PLACEMENT = "p2-terminal-position";
|
||
|
||
export function terminalPlacement(): TerminalPlacement {
|
||
try {
|
||
return localStorage.getItem(PLACEMENT) === "bottom" ? "bottom" : "side";
|
||
} catch {
|
||
return "side";
|
||
}
|
||
}
|
||
|
||
export function setTerminalPlacement(placement: TerminalPlacement): void {
|
||
try {
|
||
localStorage.setItem(PLACEMENT, placement);
|
||
} catch {}
|
||
}
|
||
|
||
/** Whether the row of extra keys shows. Kept per browser; by default only on touch screens. */
|
||
const KEY_ROW = "p2-terminal-keys";
|
||
|
||
function keyRowShown(): boolean {
|
||
try {
|
||
const stored = localStorage.getItem(KEY_ROW);
|
||
if (stored !== null) return stored === "on";
|
||
} catch {}
|
||
return matchMedia("(pointer: coarse)").matches;
|
||
}
|
||
|
||
let ghostty: Promise<Ghostty> | undefined;
|
||
function loadGhostty(): Promise<Ghostty> {
|
||
ghostty ??= fetch(ghosttyWasm)
|
||
.then((response) => response.arrayBuffer())
|
||
.then((bytes) => Ghostty.load(bytes));
|
||
return ghostty;
|
||
}
|
||
|
||
const encoder = new TextEncoder();
|
||
const mac = /Mac|iPhone|iPad/.test(navigator.platform);
|
||
const NO_MODIFIERS = { shift: false, ctrl: false, alt: false };
|
||
|
||
function concat(parts: Uint8Array[]): Uint8Array {
|
||
const bytes = new Uint8Array(parts.reduce((length, part) => length + part.length, 0));
|
||
let offset = 0;
|
||
for (const part of parts) {
|
||
bytes.set(part, offset);
|
||
offset += part.length;
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
function hex(color: number): string {
|
||
return `#${color.toString(16).padStart(6, "0")}`;
|
||
}
|
||
|
||
/** A CSS color as 0xRRGGBB, resolved by the browser (custom properties, names, rgb()). */
|
||
function cssColor(element: HTMLElement, property: string, fallback: number): number {
|
||
const probe = document.createElement("span");
|
||
probe.style.color = `var(${property})`;
|
||
element.append(probe);
|
||
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(getComputedStyle(probe).color);
|
||
probe.remove();
|
||
return match ? (Number(match[1]) << 16) | (Number(match[2]) << 8) | Number(match[3]) : fallback;
|
||
}
|
||
|
||
/**
|
||
* Block elements (U+2580–U+259F) as rectangles of the cell in eighths, [left, top, right, bottom], or a shade's
|
||
* opacity. Font glyphs for them stop short of the line box and come from fallback fonts with other metrics, so half
|
||
* block art (Pi's logo) gets gaps and offsets; terminals draw these themselves.
|
||
*/
|
||
const BLOCKS: Record<string, [number, number, number, number][] | number> = {
|
||
"▀": [[0, 0, 8, 4]],
|
||
"▁": [[0, 7, 8, 8]],
|
||
"▂": [[0, 6, 8, 8]],
|
||
"▃": [[0, 5, 8, 8]],
|
||
"▄": [[0, 4, 8, 8]],
|
||
"▅": [[0, 3, 8, 8]],
|
||
"▆": [[0, 2, 8, 8]],
|
||
"▇": [[0, 1, 8, 8]],
|
||
"█": [[0, 0, 8, 8]],
|
||
"▉": [[0, 0, 7, 8]],
|
||
"▊": [[0, 0, 6, 8]],
|
||
"▋": [[0, 0, 5, 8]],
|
||
"▌": [[0, 0, 4, 8]],
|
||
"▍": [[0, 0, 3, 8]],
|
||
"▎": [[0, 0, 2, 8]],
|
||
"▏": [[0, 0, 1, 8]],
|
||
"▐": [[4, 0, 8, 8]],
|
||
"░": 0.25,
|
||
"▒": 0.5,
|
||
"▓": 0.75,
|
||
"▔": [[0, 0, 8, 1]],
|
||
"▕": [[7, 0, 8, 8]],
|
||
"▖": [[0, 4, 4, 8]],
|
||
"▗": [[4, 4, 8, 8]],
|
||
"▘": [[0, 0, 4, 4]],
|
||
"▙": [
|
||
[0, 0, 4, 8],
|
||
[4, 4, 8, 8],
|
||
],
|
||
"▚": [
|
||
[0, 0, 4, 4],
|
||
[4, 4, 8, 8],
|
||
],
|
||
"▛": [
|
||
[0, 0, 8, 4],
|
||
[0, 4, 4, 8],
|
||
],
|
||
"▜": [
|
||
[0, 0, 8, 4],
|
||
[4, 4, 8, 8],
|
||
],
|
||
"▝": [[4, 0, 8, 4]],
|
||
"▞": [
|
||
[4, 0, 8, 4],
|
||
[0, 4, 4, 8],
|
||
],
|
||
"▟": [
|
||
[4, 0, 8, 8],
|
||
[0, 4, 4, 8],
|
||
],
|
||
};
|
||
|
||
/** CSS background layers that draw a block element in `color` over a cell `cellWidth` pixels wide. */
|
||
function blockBackground(block: [number, number, number, number][] | number, color: string, cellWidth: number): string {
|
||
if (typeof block === "number")
|
||
return `linear-gradient(color-mix(in srgb, ${color} ${block * 100}%, transparent), color-mix(in srgb, ${color} ${block * 100}%, transparent))`;
|
||
return block
|
||
.map(([left, top, right, bottom]) => {
|
||
const x = `${(left / 8) * cellWidth}px`;
|
||
const y = `calc(var(--cell-height) * ${top / 8})`;
|
||
const width = `${((right - left) / 8) * cellWidth}px`;
|
||
const height = `calc(var(--cell-height) * ${(bottom - top) / 8})`;
|
||
return `linear-gradient(${color}, ${color}) ${x} ${y} / ${width} ${height} no-repeat`;
|
||
})
|
||
.join(", ");
|
||
}
|
||
|
||
/** Draw one row: ASCII runs of equal style as text, other characters in boxes of their cell width. */
|
||
function drawRow(element: HTMLElement, cells: readonly Cell[], cellWidth: number): void {
|
||
const fragment = document.createDocumentFragment();
|
||
let run: HTMLSpanElement | undefined;
|
||
let runKey = "";
|
||
for (const cell of cells) {
|
||
if (cell.width === 0) continue;
|
||
let color = cell.fg === -1 ? "" : hex(cell.fg);
|
||
let background = cell.bg === -1 ? "" : hex(cell.bg);
|
||
if (cell.flags & INVERSE) {
|
||
const foreground = color || "var(--term-fg)";
|
||
color = background || "var(--term-bg)";
|
||
background = foreground;
|
||
}
|
||
const text = cell.flags & INVISIBLE || !cell.text ? " " : cell.text;
|
||
const style = cell.flags & (BOLD | ITALIC | FAINT | UNDERLINE | STRIKETHROUGH | OVERLINE);
|
||
const key = `${color}|${background}|${style}`;
|
||
const ascii = cell.width === 1 && text.length === 1 && text.charCodeAt(0) < 0x7f;
|
||
if (ascii && run && key === runKey) {
|
||
run.textContent += text;
|
||
continue;
|
||
}
|
||
const block = BLOCKS[text];
|
||
if (block !== undefined) {
|
||
const span = document.createElement("span");
|
||
span.className = "cell block";
|
||
span.style.width = `${cell.width * cellWidth}px`;
|
||
const layers = blockBackground(block, color || "var(--term-fg)", cell.width * cellWidth);
|
||
span.style.background = background ? `${layers}, ${background}` : layers;
|
||
fragment.append(span);
|
||
run = undefined;
|
||
continue;
|
||
}
|
||
const span = document.createElement("span");
|
||
span.textContent = text;
|
||
if (color) span.style.color = color;
|
||
if (background) span.style.background = background;
|
||
if (style & BOLD) span.style.fontWeight = "bold";
|
||
if (style & ITALIC) span.style.fontStyle = "italic";
|
||
if (style & FAINT) span.style.opacity = "0.6";
|
||
const lines = [
|
||
style & UNDERLINE && "underline",
|
||
style & STRIKETHROUGH && "line-through",
|
||
style & OVERLINE && "overline",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
if (lines) span.style.textDecoration = lines;
|
||
if (!ascii) {
|
||
// Fallback fonts have other advances: keep every non-ASCII character on its cells.
|
||
span.className = "cell";
|
||
span.style.width = `${cell.width * cellWidth}px`;
|
||
run = undefined;
|
||
} else {
|
||
run = span;
|
||
runKey = key;
|
||
}
|
||
fragment.append(span);
|
||
}
|
||
element.replaceChildren(fragment);
|
||
}
|
||
|
||
type ViewState = {
|
||
terminals: TerminalInfo[];
|
||
current?: TerminalInfo;
|
||
connected: boolean;
|
||
error?: string;
|
||
/** The host runs older code than the server; restarting it ends `running` terminals. */
|
||
outdated?: { running?: number };
|
||
/** Closing the shown terminal waits for confirmation: its shell runs `program`. */
|
||
busy?: { terminalId: string; program?: string };
|
||
};
|
||
|
||
/** One connection to /terminal and the terminal it shows. */
|
||
class TerminalSession {
|
||
readonly #control: Held<typeof Terminals>;
|
||
#commands: Promise<unknown> = Promise.resolve();
|
||
readonly #screen: HTMLElement;
|
||
readonly #input: HTMLTextAreaElement;
|
||
readonly #onState: (state: ViewState) => void;
|
||
readonly #onTerminal: (terminalId: string) => void;
|
||
readonly #onEmpty: () => void;
|
||
readonly #modifiers: () => { ctrl: boolean; alt: boolean };
|
||
readonly #rows: HTMLElement;
|
||
readonly #cursor: HTMLElement;
|
||
/** Where the shown rows are in the scrollback, while scrolled up. */
|
||
readonly #scrollbar: HTMLElement;
|
||
#preferred?: string;
|
||
#state: ViewState = { terminals: [], connected: false };
|
||
#socket?: WebSocket;
|
||
#vt?: VtTerminal;
|
||
#snapshot = false;
|
||
/**
|
||
* Output bytes processed but not yet acknowledged to the host, which stops streaming to viewers too far behind and
|
||
* resyncs them with a snapshot (TERMINAL_OUTPUT_WINDOW in protocol.ts). Never discarded: the host counts on
|
||
* every byte being acknowledged eventually.
|
||
*/
|
||
#unacked = 0;
|
||
#ackTimer: ReturnType<typeof setTimeout> | undefined;
|
||
/**
|
||
* Output waiting to be parsed. Parsing everything as it arrived starved rendering during floods (`find /`): the
|
||
* screen only updated when the main thread found a gap. Output is parsed in slices within a time budget per animation
|
||
* frame and drawn in the same frame; acknowledgements follow parsing, so the host slows down for a busy browser.
|
||
*/
|
||
#queue: Uint8Array[] = [];
|
||
#pumpFrame = 0;
|
||
/** The terminal for the current `attached` exists; output before it waits. */
|
||
#ready = false;
|
||
#frame = 0;
|
||
#connection?: { stop(): void };
|
||
#stopped = false;
|
||
/** Open a terminal if the first listing has none (`start`); later reconnects only reattach. */
|
||
#autoOpen: boolean;
|
||
#cell = { width: 8, height: 16 };
|
||
#resize: ResizeObserver;
|
||
/** The app's light/dark switch, which programs in the terminal hear about through the host. */
|
||
#themeObserver: MutationObserver;
|
||
#scrolled = 0;
|
||
/** The mouse button held down in the program (reported presses). */
|
||
#pressed?: MouseInput["button"];
|
||
/**
|
||
* The finger on the screen: where it went down, whether it moved (a scroll, not a tap), its last position and speed
|
||
* (pixels per millisecond, positive while moving up) for the fling.
|
||
*/
|
||
#touch?: {
|
||
id: number;
|
||
start: { x: number; y: number };
|
||
moved: boolean;
|
||
longPressed: boolean;
|
||
x: number;
|
||
y: number;
|
||
time: number;
|
||
velocity: number;
|
||
};
|
||
#fling = 0;
|
||
#longPress?: ReturnType<typeof setTimeout>;
|
||
/** Rows are not redrawn while output is selected (`#holdForSelection`), until `#holdTimer` or `#release`. */
|
||
#held = false;
|
||
#holdTimer?: ReturnType<typeof setTimeout>;
|
||
/** The next frame redraws every row (after a resize, a snapshot or a theme change). */
|
||
#full = false;
|
||
/** The textarea's text the terminal got, with its cursor after it (see the top of this file). */
|
||
#typed = "";
|
||
#composing = false;
|
||
readonly #conversation: () => number;
|
||
readonly #selectionChange = () => {
|
||
this.#caretMoved();
|
||
this.#holdForSelection();
|
||
};
|
||
|
||
constructor(options: {
|
||
control: Held<typeof Terminals>;
|
||
/** The viewed conversation: new terminals open where it works. */
|
||
conversation: () => number;
|
||
terminalId?: string;
|
||
start: boolean;
|
||
screen: HTMLElement;
|
||
input: HTMLTextAreaElement;
|
||
onState: (state: ViewState) => void;
|
||
onTerminal: (terminalId: string) => void;
|
||
/** The last terminal was closed. */
|
||
onEmpty: () => void;
|
||
modifiers: () => { ctrl: boolean; alt: boolean };
|
||
}) {
|
||
this.#control = options.control;
|
||
this.#conversation = options.conversation;
|
||
if (options.terminalId) this.#preferred = options.terminalId;
|
||
this.#autoOpen = options.start;
|
||
this.#screen = options.screen;
|
||
this.#input = options.input;
|
||
this.#onState = options.onState;
|
||
this.#onTerminal = options.onTerminal;
|
||
this.#onEmpty = options.onEmpty;
|
||
this.#modifiers = options.modifiers;
|
||
this.#rows = document.createElement("div");
|
||
this.#rows.className = "terminal-rows";
|
||
this.#cursor = document.createElement("div");
|
||
this.#cursor.className = "terminal-cursor";
|
||
this.#scrollbar = document.createElement("div");
|
||
this.#scrollbar.className = "terminal-scrollbar";
|
||
this.#scrollbar.hidden = true;
|
||
this.#screen.replaceChildren(this.#rows, this.#cursor, this.#scrollbar);
|
||
this.#resize = new ResizeObserver(() => this.#fit());
|
||
this.#themeObserver = new MutationObserver(() =>
|
||
this.#request((control) => control.theme(this.#theme(), BACKGROUND_CONTEXT)),
|
||
);
|
||
// Native events: React's onBeforeInput is a polyfill without inputType.
|
||
const input = this.#input;
|
||
input.addEventListener("keydown", (event) => this.#keydown(event));
|
||
input.addEventListener("beforeinput", (event) => this.#beforeInput(event));
|
||
input.addEventListener("input", () => this.#typedChanged());
|
||
input.addEventListener("compositionstart", () => {
|
||
this.#composing = true;
|
||
});
|
||
input.addEventListener("compositionend", () => {
|
||
this.#composing = false;
|
||
this.#typedChanged();
|
||
});
|
||
// Wherever the terminal's cursor is now, the field starts empty.
|
||
input.addEventListener("focus", () => this.#clearInput());
|
||
input.addEventListener("paste", (event) => {
|
||
event.preventDefault();
|
||
this.paste(event.clipboardData?.getData("text/plain") ?? "");
|
||
});
|
||
const screen = this.#screen.parentElement ?? this.#screen;
|
||
screen.addEventListener("pointerdown", (event) => this.#pointerDown(event));
|
||
screen.addEventListener("pointermove", (event) => this.#pointerMove(event));
|
||
screen.addEventListener("pointerup", (event) => this.#pointerUp(event));
|
||
screen.addEventListener("pointercancel", () => {
|
||
clearTimeout(this.#longPress);
|
||
if (!this.#touch?.longPressed) delete input.dataset.nativeMenu;
|
||
this.#pressed = undefined;
|
||
this.#touch = undefined;
|
||
});
|
||
// The right button goes to programs that track the mouse. A long press (pointerType "touch") is never a right
|
||
// button: cancelling it would take the selection's Copy or the field's Paste menu away.
|
||
screen.addEventListener("contextmenu", (event) => {
|
||
if (
|
||
event.pointerType !== "touch" &&
|
||
!input.hasAttribute("data-native-menu") &&
|
||
window.getSelection()?.isCollapsed !== false &&
|
||
this.#vt?.mouseTracking() &&
|
||
!event.shiftKey
|
||
)
|
||
event.preventDefault();
|
||
});
|
||
screen.addEventListener("copy", (event) => this.#copy(event));
|
||
}
|
||
|
||
restartHost(): void {
|
||
this.#request((control) => control.restartHost({}, BACKGROUND_CONTEXT));
|
||
}
|
||
|
||
start(): void {
|
||
this.#measure();
|
||
this.#resize.observe(this.#screen);
|
||
this.#themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||
document.addEventListener("selectionchange", this.#selectionChange);
|
||
this.#connect();
|
||
}
|
||
|
||
stop(): void {
|
||
this.#stopped = true;
|
||
this.#connection?.stop();
|
||
clearTimeout(this.#longPress);
|
||
clearTimeout(this.#holdTimer);
|
||
clearTimeout(this.#ackTimer);
|
||
cancelAnimationFrame(this.#pumpFrame);
|
||
cancelAnimationFrame(this.#frame);
|
||
cancelAnimationFrame(this.#fling);
|
||
document.removeEventListener("selectionchange", this.#selectionChange);
|
||
this.#resize.disconnect();
|
||
this.#themeObserver.disconnect();
|
||
this.#vt?.free();
|
||
this.#vt = undefined;
|
||
}
|
||
|
||
open(): void {
|
||
const { cols, rows } = this.#size();
|
||
const conversation = this.#conversation();
|
||
this.#request((control) => control.open({ cols, rows, conversation }, BACKGROUND_CONTEXT), true);
|
||
}
|
||
|
||
attach(terminalId: string): void {
|
||
if (terminalId === this.#state.current?.id) return;
|
||
cancelAnimationFrame(this.#fling);
|
||
this.#clearInput();
|
||
this.#emit({ busy: undefined });
|
||
this.#request((control) => control.attach({ terminalId }, BACKGROUND_CONTEXT), true);
|
||
}
|
||
|
||
/** Kill the shown terminal's program; unless `force`, the host first reports a job running in it (`busy`). */
|
||
close(force = false): void {
|
||
this.#emit({ busy: undefined });
|
||
this.#request((control) => control.close(force ? { force: true } : {}, BACKGROUND_CONTEXT));
|
||
}
|
||
|
||
/** Keep the terminal after all. */
|
||
keep(): void {
|
||
this.#emit({ busy: undefined });
|
||
}
|
||
|
||
/** Keys from the on-screen key row. */
|
||
key(input: KeyInput): void {
|
||
this.#release();
|
||
const bytes = this.#vt?.encodeKey(input);
|
||
if (bytes) this.#send(bytes);
|
||
this.#clearInput();
|
||
}
|
||
|
||
/** Text from the key row. */
|
||
text(text: string): void {
|
||
this.#release();
|
||
this.#send(this.#encodeText(text).bytes);
|
||
this.#clearInput();
|
||
}
|
||
|
||
async pasteClipboard(): Promise<void> {
|
||
const terminal = this.#state.current?.id;
|
||
const text = await navigator.clipboard.readText();
|
||
// Permission prompts can outlive an attachment or a session switch.
|
||
if (!this.#stopped && terminal && terminal === this.#state.current?.id) this.paste(text);
|
||
}
|
||
|
||
paste(text: string): void {
|
||
this.#release();
|
||
const bytes = this.#vt?.encodePaste(text);
|
||
if (bytes) this.#send(bytes);
|
||
this.#clearInput();
|
||
delete this.#input.dataset.nativeMenu;
|
||
}
|
||
|
||
/** Typed text; a sticky Ctrl or Alt from the key row applies to a single character (`modified`). */
|
||
#encodeText(text: string): { bytes: Uint8Array; modified: boolean } {
|
||
const { ctrl, alt } = this.#modifiers();
|
||
if ((ctrl || alt) && [...text].length === 1) {
|
||
const code = /^[a-z]$/i.test(text) ? `Key${text.toUpperCase()}` : /^\d$/.test(text) ? `Digit${text}` : "";
|
||
const bytes = this.#vt?.encodeKey({ code, key: text, ctrl, alt });
|
||
if (bytes) return { bytes, modified: true };
|
||
}
|
||
return { bytes: encoder.encode(text), modified: false };
|
||
}
|
||
|
||
/** Empty the textarea: the terminal's cursor moved some other way than typing into it. */
|
||
#clearInput(): void {
|
||
this.#typed = "";
|
||
if (this.#input.value) this.#input.value = "";
|
||
}
|
||
|
||
/** Send what changed in the textarea; the terminal's cursor is after the text it got, as the textarea's is. */
|
||
#typedChanged(): void {
|
||
const value = this.#input.value;
|
||
const { deleted, inserted } = typedChange(this.#typed, value);
|
||
this.#typed = value;
|
||
const parts: Uint8Array[] = [];
|
||
const backspace = deleted ? this.#vt?.encodeKey({ code: "Backspace", key: "Backspace" }) : undefined;
|
||
if (backspace) for (let index = 0; index < deleted; index++) parts.push(backspace);
|
||
let modified = false;
|
||
if (inserted) {
|
||
const text = this.#encodeText(inserted);
|
||
parts.push(text.bytes);
|
||
modified = text.modified;
|
||
}
|
||
if (parts.length) this.#send(concat(parts));
|
||
// Ctrl+C typed with the key row's Ctrl leaves no "c" on the line, and Enter ends it; a long line starts over
|
||
// while nothing is composed, so the field stays small.
|
||
if (modified || inserted.includes("\r") || (!this.#composing && value.length > 500)) this.#clearInput();
|
||
}
|
||
|
||
/**
|
||
* The textarea's cursor moved without typing, e.g. Gboard's space bar slide: move the terminal's cursor as far and
|
||
* start over with an empty field; further slides then arrive as arrow keys.
|
||
*/
|
||
#caretMoved(): void {
|
||
const input = this.#input;
|
||
if (document.activeElement !== input || this.#composing || input.value !== this.#typed) return;
|
||
const { selectionStart, selectionEnd, value } = input;
|
||
if (selectionStart !== selectionEnd || selectionEnd === value.length) return;
|
||
const left = [...value.slice(selectionEnd)].length;
|
||
const arrow = this.#vt?.encodeKey({ code: "ArrowLeft", key: "ArrowLeft" });
|
||
if (arrow) this.#send(concat(new Array<Uint8Array>(left).fill(arrow)));
|
||
this.#clearInput();
|
||
}
|
||
|
||
/** The document's selection, when it is a range of the output rows. */
|
||
#outputSelection(): Range | undefined {
|
||
const selection = window.getSelection();
|
||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return undefined;
|
||
const range = selection.getRangeAt(0);
|
||
return range.intersectsNode(this.#rows) ? range : undefined;
|
||
}
|
||
|
||
/**
|
||
* Selected output keeps its rows: a redraw replaces a changed row's spans, and a selection in them is gone (Chrome
|
||
* then also drops its handles and Copy menu). Each change of the selection holds the rows for SELECTION_HOLD_MS more;
|
||
* a selection that ends (Copy, a tap elsewhere) draws what arrived meanwhile at once.
|
||
*/
|
||
#holdForSelection(): void {
|
||
clearTimeout(this.#holdTimer);
|
||
if (this.#outputSelection()) {
|
||
this.#held = true;
|
||
this.#holdTimer = setTimeout(() => this.#release(), SELECTION_HOLD_MS);
|
||
} else this.#release();
|
||
}
|
||
|
||
/** Draw again: the selection ended or expired, or the user typed, pasted or scrolled. */
|
||
#release(): void {
|
||
clearTimeout(this.#holdTimer);
|
||
if (!this.#held) return;
|
||
this.#held = false;
|
||
this.#schedule();
|
||
}
|
||
|
||
/**
|
||
* Copy selected output as a terminal does: a soft-wrapped line (a long command) as one line, without the blanks
|
||
* that fill each row to the terminal's width.
|
||
*/
|
||
#copy(event: ClipboardEvent): void {
|
||
const range = this.#outputSelection();
|
||
if (!range || !event.clipboardData || !this.#rows.contains(range.commonAncestorContainer)) return;
|
||
const rows = [...this.#rows.children].filter((row) => range.intersectsNode(row));
|
||
let text = "";
|
||
for (const [index, row] of rows.entries()) {
|
||
const part = document.createRange();
|
||
part.selectNodeContents(row);
|
||
if (row.contains(range.startContainer)) part.setStart(range.startContainer, range.startOffset);
|
||
if (row.contains(range.endContainer)) part.setEnd(range.endContainer, range.endOffset);
|
||
const last = index === rows.length - 1;
|
||
if (row.hasAttribute("data-wrapped") && !last) text += part.toString();
|
||
else text += part.toString().trimEnd() + (last ? "" : "\n");
|
||
}
|
||
event.preventDefault();
|
||
event.clipboardData.setData("text/plain", text);
|
||
}
|
||
|
||
/**
|
||
* Whether a character of the output is under a point: a long press there selects it natively; on a blank cell (the
|
||
* cursor's, the rest of a line) the textarea goes under the finger for Paste. Columns come from the row's spans:
|
||
* ASCII runs have a character per column, other characters are `.cell` boxes of their width (drawRow).
|
||
*/
|
||
#textAt(x: number, y: number): boolean {
|
||
const box = this.#rows.getBoundingClientRect();
|
||
const row = this.#rows.children[Math.floor((y - box.top) / this.#cell.height)];
|
||
const column = Math.floor((x - box.left) / this.#cell.width);
|
||
let start = 0;
|
||
for (const span of row?.children ?? []) {
|
||
const text = span.textContent ?? "";
|
||
const cell = span instanceof HTMLElement && span.classList.contains("cell");
|
||
const width = cell ? Math.round(Number.parseFloat(span.style.width) / this.#cell.width) : text.length;
|
||
if (column < start + width) return /\S/.test(cell ? text : (text[column - start] ?? ""));
|
||
start += width;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
#keydown(event: KeyboardEvent): void {
|
||
if (event.isComposing || event.keyCode === 229 || event.key === "Dead" || event.key === "Unidentified") return;
|
||
// The browser's own shortcuts: copy, paste, select all.
|
||
if (event.metaKey || (event.ctrlKey && event.shiftKey && (event.code === "KeyC" || event.code === "KeyV")))
|
||
return;
|
||
const named = event.key.length > 1;
|
||
const sticky = this.#modifiers();
|
||
// macOS types characters with Option; it is Alt only for keys that type nothing.
|
||
const alt = (event.altKey && !(mac && !named)) || sticky.alt;
|
||
const ctrl = event.ctrlKey || sticky.ctrl;
|
||
// Plain characters arrive as text input, which also covers on-screen keyboards and input methods.
|
||
if (!named && !ctrl && !alt) return;
|
||
const bytes = this.#vt?.encodeKey({
|
||
code: event.code,
|
||
key: event.key,
|
||
shift: event.shiftKey,
|
||
ctrl,
|
||
alt,
|
||
repeat: event.repeat,
|
||
});
|
||
if (!bytes) return;
|
||
event.preventDefault();
|
||
this.#send(bytes);
|
||
this.#clearInput();
|
||
}
|
||
|
||
/** Text, compositions, corrections and deletions change the textarea (`#typedChanged`); Enter does not. */
|
||
#beforeInput(event: InputEvent): void {
|
||
switch (event.inputType) {
|
||
// Enter from a keyboard whose keydown said nothing (keyCode 229).
|
||
case "insertLineBreak":
|
||
case "insertParagraph":
|
||
event.preventDefault();
|
||
this.key({ code: "Enter", key: "Enter" });
|
||
return;
|
||
case "historyUndo":
|
||
case "historyRedo":
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
}
|
||
|
||
/** A mouse event at a point in the window for the program, if it tracks the mouse and wants this event. */
|
||
#mouse(
|
||
input: Pick<MouseInput, "action" | "button">,
|
||
event: { clientX: number; clientY: number } & Partial<Pick<MouseEvent, "shiftKey" | "ctrlKey" | "altKey">>,
|
||
): boolean {
|
||
const vt = this.#vt;
|
||
if (!vt?.mouseTracking()) return false;
|
||
const box = this.#screen.getBoundingClientRect();
|
||
const bytes = vt.encodeMouse({
|
||
...input,
|
||
x: event.clientX - box.left,
|
||
y: event.clientY - box.top,
|
||
width: box.width,
|
||
height: box.height,
|
||
cellWidth: this.#cell.width,
|
||
cellHeight: this.#cell.height,
|
||
shift: event.shiftKey ?? false,
|
||
ctrl: event.ctrlKey ?? false,
|
||
alt: event.altKey ?? false,
|
||
});
|
||
if (bytes) this.#send(bytes);
|
||
return true;
|
||
}
|
||
|
||
// Mouse buttons go to programs that track the mouse, as in a terminal; Shift keeps the browser's text selection.
|
||
// A finger taps, long-presses for the native menu, or scrolls (`scroll`). Capture only once it moves: rows can be
|
||
// replaced while scrolling, but capture during a stationary long press interferes with Chrome's text menu. Never
|
||
// cancel a touch's pointerdown, touchstart or contextmenu: that takes away the long press's selection and menus.
|
||
#pointerDown(event: PointerEvent): void {
|
||
if (event.pointerType === "touch") {
|
||
cancelAnimationFrame(this.#fling);
|
||
// A second finger ends the scroll.
|
||
if (!event.isPrimary) {
|
||
clearTimeout(this.#longPress);
|
||
delete this.#input.dataset.nativeMenu;
|
||
this.#touch = undefined;
|
||
return;
|
||
}
|
||
clearTimeout(this.#longPress);
|
||
delete this.#input.dataset.nativeMenu;
|
||
// Leave an existing output selection alone: Chrome owns its Copy menu and handles.
|
||
if (this.#outputSelection()) return;
|
||
this.#scrolled = 0;
|
||
const { clientX: x, clientY: y } = event;
|
||
const touch = {
|
||
id: event.pointerId,
|
||
start: { x, y },
|
||
moved: false,
|
||
longPressed: false,
|
||
x,
|
||
y,
|
||
time: event.timeStamp,
|
||
velocity: 0,
|
||
};
|
||
this.#touch = touch;
|
||
// On output text the long press is Chrome's: it selects the word under the finger. The textarea must not be
|
||
// there, or Chrome selects in the (empty) field and offers only Paste.
|
||
const text = this.#textAt(x, y);
|
||
if (!text) {
|
||
// A blank spot: put a real, transparent editing target under the finger BEFORE Chrome's long-press deadline.
|
||
const box = (this.#input.parentElement ?? this.#screen).getBoundingClientRect();
|
||
this.#input.dataset.nativeMenu = "";
|
||
this.#input.style.transform = `translate(${x - box.left - this.#input.offsetLeft - 22}px, ${y - box.top - this.#input.offsetTop - 22}px)`;
|
||
}
|
||
// A long press neither scrolls when the finger then moves (it extends Chrome's selection) nor taps on lifting.
|
||
this.#longPress = setTimeout(() => {
|
||
if (this.#touch !== touch || touch.moved || this.#outputSelection()) return;
|
||
touch.longPressed = true;
|
||
if (!text) this.#input.focus({ preventScroll: true });
|
||
}, LONG_PRESS_MS);
|
||
return;
|
||
}
|
||
if (event.shiftKey || !this.#vt?.mouseTracking()) return;
|
||
const button = (["left", "middle", "right"] as const)[event.button];
|
||
if (!button) return;
|
||
// No text selection while the program has the mouse.
|
||
event.preventDefault();
|
||
this.#screen.setPointerCapture(event.pointerId);
|
||
this.#pressed = button;
|
||
this.#mouse({ action: "press", button }, event);
|
||
}
|
||
|
||
#pointerMove(event: PointerEvent): void {
|
||
if (event.pointerType !== "touch") {
|
||
this.#mouse({ action: "motion", ...(this.#pressed ? { button: this.#pressed } : {}) }, event);
|
||
return;
|
||
}
|
||
const touch = this.#touch;
|
||
if (!touch || event.pointerId !== touch.id) return;
|
||
if (!touch.moved && Math.hypot(event.clientX - touch.start.x, event.clientY - touch.start.y) <= 8) return;
|
||
if (touch.longPressed) return; // Native selection/menu gestures are not terminal scrolling.
|
||
clearTimeout(this.#longPress);
|
||
delete this.#input.dataset.nativeMenu;
|
||
if (!touch.moved) (this.#screen.parentElement ?? this.#screen).setPointerCapture(event.pointerId);
|
||
touch.moved = true;
|
||
const pixels = touch.y - event.clientY;
|
||
// Mostly the latest movement, so a finger that slows down before lifting doesn't fling.
|
||
touch.velocity = 0.8 * (pixels / Math.max(1, event.timeStamp - touch.time)) + 0.2 * touch.velocity;
|
||
touch.x = event.clientX;
|
||
touch.y = event.clientY;
|
||
touch.time = event.timeStamp;
|
||
this.scroll(pixels, touch, NO_MODIFIERS);
|
||
}
|
||
|
||
#pointerUp(event: PointerEvent): void {
|
||
if (event.pointerType === "touch") {
|
||
const touch = this.#touch;
|
||
if (!touch || event.pointerId !== touch.id) return;
|
||
this.#touch = undefined;
|
||
clearTimeout(this.#longPress);
|
||
if (touch.longPressed) return;
|
||
delete this.#input.dataset.nativeMenu;
|
||
if (touch.moved) this.#coast(touch, event.timeStamp);
|
||
else if (this.#mouse({ action: "press", button: "left" }, event))
|
||
this.#mouse({ action: "release", button: "left" }, event);
|
||
return;
|
||
}
|
||
const button = this.#pressed;
|
||
if (!button) return;
|
||
this.#pressed = undefined;
|
||
this.#mouse({ action: "release", button }, event);
|
||
}
|
||
|
||
/** A finger lifted while moving keeps scrolling, slowing down, until the next touch or input. */
|
||
#coast(touch: { x: number; y: number; time: number; velocity: number }, now: number): void {
|
||
if (now - touch.time > 80 || Math.abs(touch.velocity) < 0.3) return;
|
||
let velocity = touch.velocity;
|
||
let last = performance.now();
|
||
const step = (time: number) => {
|
||
const elapsed = Math.max(0, time - last);
|
||
last = time;
|
||
velocity *= 0.996 ** elapsed;
|
||
if (Math.abs(velocity) < 0.05 || !this.#vt) {
|
||
this.#fling = 0;
|
||
return;
|
||
}
|
||
this.scroll(velocity * elapsed, touch, NO_MODIFIERS);
|
||
this.#fling = requestAnimationFrame(step);
|
||
};
|
||
this.#fling = requestAnimationFrame(step);
|
||
}
|
||
|
||
/**
|
||
* Scroll by pixels at a point in the window, as Ghostty does: wheel events for programs that track the mouse, arrow
|
||
* keys for full-screen programs that don't (alternate scroll mode, on by default), else the scrollback.
|
||
*/
|
||
scroll(
|
||
pixels: number,
|
||
point: { x: number; y: number },
|
||
mods: { shift: boolean; ctrl: boolean; alt: boolean },
|
||
): void {
|
||
const vt = this.#vt;
|
||
if (!vt) return;
|
||
this.#release();
|
||
this.#scrolled += pixels;
|
||
const rows = Math.trunc(this.#scrolled / this.#cell.height);
|
||
if (!rows) return;
|
||
this.#scrolled -= rows * this.#cell.height;
|
||
const steps = Math.min(Math.abs(rows), 10);
|
||
if (vt.mouseTracking()) {
|
||
const button = rows < 0 ? "wheelUp" : "wheelDown";
|
||
for (let index = 0; index < steps; index++)
|
||
this.#mouse(
|
||
{ action: "press", button },
|
||
{ clientX: point.x, clientY: point.y, shiftKey: mods.shift, ctrlKey: mods.ctrl, altKey: mods.alt },
|
||
);
|
||
return;
|
||
}
|
||
if (vt.alternateScreen() && vt.mode(1007)) {
|
||
const code = rows < 0 ? "ArrowUp" : "ArrowDown";
|
||
for (let index = 0; index < steps; index++) this.key({ code, key: code });
|
||
return;
|
||
}
|
||
vt.scroll(rows);
|
||
this.#schedule();
|
||
}
|
||
|
||
#theme(): TerminalTheme {
|
||
return {
|
||
foreground: cssColor(this.#screen, "--term-fg", 0xe6e6e6),
|
||
background: cssColor(this.#screen, "--term-bg", 0x141414),
|
||
scheme: document.documentElement.dataset.theme === "light" ? "light" : "dark",
|
||
};
|
||
}
|
||
|
||
#emit(change: Partial<ViewState>): void {
|
||
this.#state = { ...this.#state, ...change };
|
||
this.#onState(this.#state);
|
||
}
|
||
|
||
/** Runs a call on the terminal control, in order; `attaching` first settles what this viewer owes for output. */
|
||
#request(call: (control: Held<typeof Terminals>) => Promise<unknown>, attaching = false): void {
|
||
// Attaching resets the host's counters: settle what this viewer owes for the previous terminal first.
|
||
if (attaching) this.#flushAck();
|
||
if (this.#socket?.readyState !== WebSocket.OPEN) return;
|
||
const request = () => call(this.#control);
|
||
this.#commands = this.#commands
|
||
.then(request, request)
|
||
.catch((error: unknown) => this.#emit({ error: error instanceof Error ? error.message : String(error) }));
|
||
}
|
||
|
||
#acknowledge(bytes: number): void {
|
||
this.#unacked += bytes;
|
||
if (this.#unacked >= ACK_BATCH_BYTES) this.#flushAck();
|
||
else this.#ackTimer ??= setTimeout(() => this.#flushAck(), ACK_DELAY_MS);
|
||
}
|
||
|
||
#flushAck(): void {
|
||
clearTimeout(this.#ackTimer);
|
||
this.#ackTimer = undefined;
|
||
if (this.#unacked === 0 || this.#socket?.readyState !== WebSocket.OPEN) return;
|
||
const bytes = this.#unacked;
|
||
this.#unacked = 0;
|
||
this.#request((control) => control.ack({ bytes }, BACKGROUND_CONTEXT));
|
||
}
|
||
|
||
#send(bytes: Uint8Array): void {
|
||
if (this.#socket?.readyState !== WebSocket.OPEN || !this.#state.current || this.#state.current.exited) return;
|
||
// Typing returns to the bottom, like a terminal, and stops a fling (whose own wheel events come from its frame,
|
||
// before it asks for the next).
|
||
cancelAnimationFrame(this.#fling);
|
||
this.#vt?.scroll("bottom");
|
||
this.#schedule();
|
||
this.#socket.send(bytes);
|
||
}
|
||
|
||
/**
|
||
* Keeps the channel open: retries with backoff, at once when the page comes back, and replaces a socket that missed
|
||
* the server's pings (an idle terminal sends nothing else, so a dead connection would otherwise look open).
|
||
*/
|
||
#connect(): void {
|
||
this.#connection = keepConnected({
|
||
open: (events) => this.#openChannel(events),
|
||
onConnected: (connected) => {
|
||
if (!connected) this.#emit({ connected: false });
|
||
},
|
||
canRetry: async () => !this.#stopped,
|
||
silenceTimeoutMs: SILENCE_TIMEOUT_MS,
|
||
environment: browserEnvironment("terminal"),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* One attempt: a channel token from the control, then the socket. Closing the returned link ends the attempt at once,
|
||
* whether the socket exists yet or not; an abandoned socket's late events are ignored.
|
||
*/
|
||
#openChannel(events: SocketEvents): ReconnectLink {
|
||
const handlers: ((reason: string) => void)[] = [];
|
||
let socket: WebSocket | undefined;
|
||
let ended = false;
|
||
const end = (reason: string): void => {
|
||
if (ended) return;
|
||
ended = true;
|
||
if (socket && socket.readyState <= WebSocket.OPEN) socket.close();
|
||
for (const handler of handlers) handler(reason);
|
||
};
|
||
void (async () => {
|
||
await whenLive(this.#control);
|
||
const { token } = await this.#control.channel({}, BACKGROUND_CONTEXT);
|
||
if (ended) return;
|
||
const channel = new WebSocket(
|
||
`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/terminal?${new URLSearchParams({ token })}`,
|
||
);
|
||
socket = channel;
|
||
channel.binaryType = "arraybuffer";
|
||
this.#socket = channel;
|
||
channel.onopen = () => {
|
||
if (ended) return;
|
||
events.opened();
|
||
this.#emit({ connected: true, error: undefined, outdated: undefined });
|
||
// First: terminals opened or attached afterwards answer color queries in these colors.
|
||
this.#request((control) => control.theme(this.#theme(), BACKGROUND_CONTEXT));
|
||
this.#request((control) => control.list({}, BACKGROUND_CONTEXT));
|
||
};
|
||
channel.onmessage = (message) => {
|
||
if (ended) return;
|
||
events.received();
|
||
if (message.data instanceof ArrayBuffer) {
|
||
this.#queue.push(new Uint8Array(message.data));
|
||
this.#pump();
|
||
return;
|
||
}
|
||
// The server's keepalive (TERMINAL_PING_MS in service.ts) only needed to arrive.
|
||
const event = JSON.parse(String(message.data)) as TerminalEvent | { type: "ping" };
|
||
if (event.type === "ping") return;
|
||
// A (re)attach starts over with a snapshot: unparsed output is stale, but the host counted it as sent.
|
||
if (event.type === "attached") this.#discardQueue();
|
||
void this.#event(event);
|
||
};
|
||
channel.onclose = (event) => {
|
||
events.closed(event.code, event.reason, event.wasClean);
|
||
end("socket closed");
|
||
};
|
||
})().catch((error: unknown) => {
|
||
if (ended) return;
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
this.#emit({ connected: false, error: message });
|
||
end(`no channel: ${message}`);
|
||
});
|
||
return {
|
||
onClose: (handler) => {
|
||
if (ended) handler("closed");
|
||
else handlers.push(handler);
|
||
},
|
||
close: (reason = "closed") => end(reason),
|
||
};
|
||
}
|
||
|
||
async #event(event: TerminalEvent): Promise<void> {
|
||
switch (event.type) {
|
||
case "list": {
|
||
this.#emit({ terminals: event.terminals, error: undefined });
|
||
const wanted = this.#preferred ?? this.#state.current?.id;
|
||
const running = event.terminals.filter((terminal) => !terminal.exited);
|
||
const target =
|
||
event.terminals.find((terminal) => terminal.id === wanted) ??
|
||
running.toSorted((a, b) => b.createdAt - a.createdAt)[0];
|
||
this.#preferred = undefined;
|
||
if (target) {
|
||
this.#emit({ current: undefined });
|
||
this.#request((control) => control.attach({ terminalId: target.id }, BACKGROUND_CONTEXT), true);
|
||
} else if (this.#autoOpen) this.open();
|
||
this.#autoOpen = false;
|
||
return;
|
||
}
|
||
case "terminals": {
|
||
// Another viewer or the agent opened, closed or renamed one. The shown terminal stays; only a panel showing
|
||
// none attaches the newest that runs (the agent just opened it).
|
||
const known = new Set(this.#state.terminals.map((terminal) => terminal.id));
|
||
this.#emit({ terminals: event.terminals });
|
||
const added = event.terminals.filter((terminal) => !known.has(terminal.id) && !terminal.exited);
|
||
if (!this.#state.current && added.length > 0) this.attach((added.at(-1) as TerminalInfo).id);
|
||
return;
|
||
}
|
||
case "reply":
|
||
return;
|
||
case "attached": {
|
||
const terminal = event.terminal;
|
||
const loaded = await loadGhostty();
|
||
this.#vt?.free();
|
||
this.#vt = loaded.terminal({ cols: terminal.cols, rows: terminal.rows, scrollback: 10_000 });
|
||
this.#snapshot = true;
|
||
this.#ready = true;
|
||
// A selection in the previous terminal's rows must not keep this one from being drawn.
|
||
this.#release();
|
||
this.#pump();
|
||
this.#emit({
|
||
current: terminal,
|
||
terminals: [...this.#state.terminals.filter((entry) => entry.id !== terminal.id), terminal].toSorted(
|
||
(a, b) => a.createdAt - b.createdAt,
|
||
),
|
||
});
|
||
this.#onTerminal(terminal.id);
|
||
return;
|
||
}
|
||
case "changed": {
|
||
const terminal = event.terminal;
|
||
this.#emit({
|
||
terminals: this.#state.terminals.map((entry) => (entry.id === terminal.id ? terminal : entry)),
|
||
...(this.#state.current?.id === terminal.id ? { current: terminal } : {}),
|
||
});
|
||
// Another viewer resized it: follow, so the screen stays consistent.
|
||
if (this.#vt && (this.#vt.cols !== terminal.cols || this.#vt.rows !== terminal.rows)) {
|
||
this.#vt.resize(terminal.cols, terminal.rows, this.#cell.width, this.#cell.height);
|
||
this.#schedule(true);
|
||
}
|
||
return;
|
||
}
|
||
case "busy":
|
||
if (this.#state.current?.id === event.terminalId)
|
||
this.#emit({
|
||
busy: { terminalId: event.terminalId, ...(event.program ? { program: event.program } : {}) },
|
||
});
|
||
return;
|
||
case "closed": {
|
||
const terminals = this.#state.terminals.filter((entry) => entry.id !== event.terminalId);
|
||
this.#emit({
|
||
terminals,
|
||
...(this.#state.busy?.terminalId === event.terminalId ? { busy: undefined } : {}),
|
||
});
|
||
if (!terminals.length) this.#onEmpty();
|
||
if (this.#state.current?.id !== event.terminalId) return;
|
||
this.#vt?.free();
|
||
this.#vt = undefined;
|
||
this.#rows.replaceChildren();
|
||
this.#cursor.hidden = true;
|
||
this.#emit({ current: undefined });
|
||
const next = terminals.filter((terminal) => !terminal.exited).at(-1);
|
||
if (next) this.attach(next.id);
|
||
return;
|
||
}
|
||
case "error":
|
||
this.#emit({ error: event.message });
|
||
return;
|
||
case "outdated":
|
||
this.#emit({ outdated: { ...(event.running === undefined ? {} : { running: event.running }) } });
|
||
return;
|
||
case "hello":
|
||
return;
|
||
}
|
||
}
|
||
|
||
#discardQueue(): void {
|
||
this.#ready = false;
|
||
const bytes = this.#queue.reduce((sum, chunk) => sum + chunk.length, 0);
|
||
this.#queue = [];
|
||
if (bytes) this.#acknowledge(bytes);
|
||
}
|
||
|
||
#pump(): void {
|
||
if (this.#pumpFrame || !this.#queue.length) return;
|
||
this.#pumpFrame = requestAnimationFrame(() => {
|
||
this.#pumpFrame = 0;
|
||
this.#parse();
|
||
});
|
||
}
|
||
|
||
/** Parse queued output for up to OUTPUT_BUDGET_MS, draw, and continue next frame if more is queued. */
|
||
#parse(): void {
|
||
const vt = this.#vt;
|
||
if (!vt || !this.#ready) return; // `attached` resumes.
|
||
const deadline = performance.now() + OUTPUT_BUDGET_MS;
|
||
let parsed = 0;
|
||
let snapshot = false;
|
||
while (this.#queue.length && performance.now() < deadline) {
|
||
let bytes = this.#queue[0] as Uint8Array;
|
||
if (bytes.length > OUTPUT_SLICE_BYTES) {
|
||
this.#queue[0] = bytes.subarray(OUTPUT_SLICE_BYTES);
|
||
bytes = bytes.subarray(0, OUTPUT_SLICE_BYTES);
|
||
} else this.#queue.shift();
|
||
vt.write(bytes);
|
||
parsed += bytes.length;
|
||
if (this.#snapshot) {
|
||
this.#snapshot = false;
|
||
snapshot = true;
|
||
}
|
||
}
|
||
if (parsed) {
|
||
this.#acknowledge(parsed);
|
||
// Draw in this frame rather than the next.
|
||
if (snapshot) this.#full = true;
|
||
cancelAnimationFrame(this.#frame);
|
||
this.#frame = 0;
|
||
this.#draw();
|
||
if (snapshot) this.#fit();
|
||
}
|
||
this.#pump();
|
||
}
|
||
|
||
#measure(): void {
|
||
const probe = document.createElement("span");
|
||
probe.textContent = "W".repeat(100);
|
||
probe.style.visibility = "hidden";
|
||
this.#rows.append(probe);
|
||
const box = probe.getBoundingClientRect();
|
||
probe.remove();
|
||
const lineHeight = Number.parseFloat(getComputedStyle(this.#screen).lineHeight);
|
||
this.#cell = {
|
||
width: box.width / 100 || 8,
|
||
height: Number.isFinite(lineHeight) ? lineHeight : box.height || 16,
|
||
};
|
||
this.#screen.style.setProperty("--cell-width", `${this.#cell.width}px`);
|
||
this.#screen.style.setProperty("--cell-height", `${this.#cell.height}px`);
|
||
}
|
||
|
||
#size(): { cols: number; rows: number } {
|
||
const style = getComputedStyle(this.#screen);
|
||
const width =
|
||
this.#screen.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight);
|
||
const height =
|
||
this.#screen.clientHeight - Number.parseFloat(style.paddingTop) - Number.parseFloat(style.paddingBottom);
|
||
return {
|
||
cols: Math.max(10, Math.min(1000, Math.floor(width / this.#cell.width))),
|
||
rows: Math.max(3, Math.min(1000, Math.floor(height / this.#cell.height))),
|
||
};
|
||
}
|
||
|
||
/** Size the terminal to the screen; the host resizes the program's terminal too. */
|
||
#fit(): void {
|
||
const vt = this.#vt;
|
||
if (!vt || !this.#state.current || this.#snapshot) return;
|
||
const { cols, rows } = this.#size();
|
||
if (cols === vt.cols && rows === vt.rows) return;
|
||
vt.resize(cols, rows, this.#cell.width, this.#cell.height);
|
||
this.#request((control) => control.resize({ cols, rows }, BACKGROUND_CONTEXT));
|
||
this.#schedule(true);
|
||
}
|
||
|
||
#schedule(all = false): void {
|
||
if (all) this.#full = true;
|
||
if (this.#frame) return;
|
||
this.#frame = requestAnimationFrame(() => {
|
||
this.#frame = 0;
|
||
this.#draw();
|
||
});
|
||
}
|
||
|
||
#draw(): void {
|
||
const vt = this.#vt;
|
||
// Libghostty keeps the changes until the next frame, so the release draws everything that arrived meanwhile.
|
||
if (!vt || (this.#held && this.#outputSelection())) return;
|
||
const all = this.#full;
|
||
this.#full = false;
|
||
const frame = vt.frame(all);
|
||
while (this.#rows.children.length < frame.rows) {
|
||
const row = document.createElement("div");
|
||
row.className = "terminal-row";
|
||
this.#rows.append(row);
|
||
}
|
||
while (this.#rows.children.length > frame.rows) this.#rows.lastElementChild?.remove();
|
||
for (const { y, cells, wrapped } of frame.changed) {
|
||
const row = this.#rows.children[y];
|
||
if (!(row instanceof HTMLElement)) continue;
|
||
drawRow(row, cells, this.#cell.width);
|
||
// Copying joins it with the next row (`#copy`).
|
||
row.toggleAttribute("data-wrapped", wrapped);
|
||
}
|
||
const cursor = frame.cursor;
|
||
this.#cursor.hidden = !cursor;
|
||
if (cursor) {
|
||
const left = `${cursor.x * this.#cell.width}px`;
|
||
const top = `${cursor.y * this.#cell.height}px`;
|
||
this.#cursor.style.transform = `translate(${left}, ${top})`;
|
||
this.#cursor.dataset.style = cursor.style;
|
||
if (cursor.color !== undefined) this.#cursor.style.setProperty("--cursor", hex(cursor.color));
|
||
// The input method's popup appears where the text goes.
|
||
if (!this.#input.hasAttribute("data-native-menu")) this.#input.style.transform = `translate(${left}, ${top})`;
|
||
}
|
||
const { total, offset, length } = vt.scrollbar();
|
||
const scrolledBack = length > 0 && offset + length < total;
|
||
this.#scrollbar.hidden = !scrolledBack;
|
||
if (scrolledBack) {
|
||
this.#scrollbar.style.top = `${(offset / total) * 100}%`;
|
||
this.#scrollbar.style.height = `${Math.max((length / total) * 100, 4)}%`;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Keys the on-screen keyboard lacks. Ctrl and Alt stay down for the next key. */
|
||
const KEYS: { label: string; input?: KeyInput; text?: string }[] = [
|
||
{ label: "Esc", input: { code: "Escape", key: "Escape" } },
|
||
{ label: "Tab", input: { code: "Tab", key: "Tab" } },
|
||
{ label: "↑", input: { code: "ArrowUp", key: "ArrowUp" } },
|
||
{ label: "↓", input: { code: "ArrowDown", key: "ArrowDown" } },
|
||
{ label: "←", input: { code: "ArrowLeft", key: "ArrowLeft" } },
|
||
{ label: "→", input: { code: "ArrowRight", key: "ArrowRight" } },
|
||
{ label: "|", text: "|" },
|
||
{ label: "~", text: "~" },
|
||
{ label: "/", text: "/" },
|
||
{ label: "-", text: "-" },
|
||
];
|
||
|
||
/** A tab's text: the name it was opened with, else its program's title, else its number (the agent's `-t`). */
|
||
function label(terminal: TerminalInfo): string {
|
||
// A host running older code numbers none.
|
||
const name = terminal.name || terminal.title || (terminal.index ? `Terminal ${terminal.index}` : "Terminal");
|
||
return terminal.exited ? `${name} (exited)` : name;
|
||
}
|
||
|
||
export function TerminalView({
|
||
control,
|
||
conversation = 1,
|
||
terminalId,
|
||
start = false,
|
||
onTerminal,
|
||
placement,
|
||
onMove,
|
||
onHide,
|
||
}: {
|
||
control?: Held<typeof Terminals>;
|
||
/** The viewed conversation: new terminals open where it works (a subagent's own location, else the session's). */
|
||
conversation?: number;
|
||
/** The terminal the route names; it may be gone. */
|
||
terminalId?: string;
|
||
/** Start a terminal if the session has none: the user just opened this view. */
|
||
start?: boolean;
|
||
/** The shown terminal, for the route. */
|
||
onTerminal: (terminalId: string) => void;
|
||
placement: TerminalPlacement;
|
||
/** Show the terminal (the shown one, if any) in the other place. */
|
||
onMove: (terminalId: string | undefined) => void;
|
||
/** Hide the panel; the terminals keep running. The panel also hides once its last terminal is closed. */
|
||
onHide?: () => void;
|
||
}) {
|
||
const frame = useRef<HTMLDivElement>(null);
|
||
const screen = useRef<HTMLDivElement>(null);
|
||
const input = useRef<HTMLTextAreaElement>(null);
|
||
const session = useRef<TerminalSession | undefined>(undefined);
|
||
const [state, setState] = useState<ViewState>({ terminals: [], connected: false });
|
||
const [ctrl, setCtrl] = useState(false);
|
||
const [alt, setAlt] = useState(false);
|
||
const [keys, setKeys] = useState(keyRowShown);
|
||
const [pasteNotice, setPasteNotice] = useState("");
|
||
const modifiers = useRef({ ctrl, alt });
|
||
modifiers.current = { ctrl, alt };
|
||
const onTerminalRef = useRef(onTerminal);
|
||
onTerminalRef.current = onTerminal;
|
||
const onHideRef = useRef(onHide);
|
||
onHideRef.current = onHide;
|
||
const initial = useRef(terminalId);
|
||
const initialStart = useRef(start);
|
||
const conversationRef = useRef(conversation);
|
||
conversationRef.current = conversation;
|
||
|
||
useEffect(() => {
|
||
if (!screen.current || !input.current || !control) return;
|
||
const current = new TerminalSession({
|
||
control,
|
||
conversation: () => conversationRef.current,
|
||
...(initial.current ? { terminalId: initial.current } : {}),
|
||
start: initialStart.current,
|
||
screen: screen.current,
|
||
input: input.current,
|
||
onState: setState,
|
||
onTerminal: (id) => onTerminalRef.current(id),
|
||
// The terminal button opens a new one when the panel shows again.
|
||
onEmpty: () => onHideRef.current?.(),
|
||
modifiers: () => {
|
||
const value = modifiers.current;
|
||
// Sticky modifiers apply once.
|
||
if (value.ctrl) setCtrl(false);
|
||
if (value.alt) setAlt(false);
|
||
return value;
|
||
},
|
||
});
|
||
session.current = current;
|
||
current.start();
|
||
return () => {
|
||
current.stop();
|
||
session.current = undefined;
|
||
};
|
||
}, [control]);
|
||
|
||
// Back or Forward to another terminal of this session.
|
||
useEffect(() => {
|
||
if (terminalId) session.current?.attach(terminalId);
|
||
}, [terminalId]);
|
||
|
||
// A tap or click focuses the input and opens the on-screen keyboard, unless it selected text to copy. On click, not
|
||
// pointerup: a tap's mousedown follows its pointerup and took the focus away again. A scroll is no click.
|
||
useEffect(() => {
|
||
const area = frame.current;
|
||
if (!area) return;
|
||
const focus = () => {
|
||
const element = input.current;
|
||
if (!element || element.hasAttribute("data-native-menu") || window.getSelection()?.isCollapsed === false)
|
||
return;
|
||
// Focused already, maybe with the keyboard hidden (Android Back): focusing again brings it back.
|
||
if (document.activeElement === element) element.blur();
|
||
element.focus({ preventScroll: true });
|
||
};
|
||
area.addEventListener("click", focus);
|
||
return () => area.removeEventListener("click", focus);
|
||
}, []);
|
||
|
||
// Buttons that keep the focus (and the on-screen keyboard) in the terminal.
|
||
const keepFocus = (event: React.PointerEvent) => event.preventDefault();
|
||
const current = state.current;
|
||
const pasteClipboard = () => {
|
||
void session.current?.pasteClipboard().then(
|
||
() => setPasteNotice(""),
|
||
() => setPasteNotice("Clipboard unavailable. Long-press the terminal to paste."),
|
||
);
|
||
};
|
||
const toggleKeys = () => {
|
||
setKeys(!keys);
|
||
setCtrl(false);
|
||
setAlt(false);
|
||
try {
|
||
localStorage.setItem(KEY_ROW, keys ? "off" : "on");
|
||
} catch {}
|
||
};
|
||
|
||
return (
|
||
<div className="terminal">
|
||
<div className="terminal-tabs">
|
||
{state.terminals.map((terminal) => (
|
||
<button
|
||
type="button"
|
||
key={terminal.id}
|
||
aria-pressed={terminal.id === current?.id}
|
||
className={terminal.exited ? "exited" : ""}
|
||
title={`${terminal.index}: ${terminal.env ? `${terminal.env}:${terminal.cwd}` : terminal.cwd}${terminal.agent ? " (opened by the agent)" : ""}`}
|
||
onPointerDown={keepFocus}
|
||
onClick={() => session.current?.attach(terminal.id)}
|
||
>
|
||
{terminal.agent && <span className="terminal-agent">Pi</span>}
|
||
{label(terminal)}
|
||
</button>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className="icon-button"
|
||
aria-label="New terminal"
|
||
onPointerDown={keepFocus}
|
||
onClick={() => session.current?.open()}
|
||
>
|
||
<PlusIcon size={16} />
|
||
</button>
|
||
<button type="button" aria-label="Paste into terminal" onPointerDown={keepFocus} onClick={pasteClipboard}>
|
||
Paste
|
||
</button>
|
||
<span className="spacer" />
|
||
<button
|
||
type="button"
|
||
className="icon-button"
|
||
aria-label="Extra keys"
|
||
aria-pressed={keys}
|
||
title={keys ? "Hide extra keys" : "Show extra keys"}
|
||
onPointerDown={keepFocus}
|
||
onClick={toggleKeys}
|
||
>
|
||
<KeyboardIcon />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="icon-button"
|
||
aria-label={placement === "side" ? "Move terminal to the bottom" : "Move terminal to the side"}
|
||
title={placement === "side" ? "Move to the bottom" : "Move to the side"}
|
||
onClick={() => onMove(current?.id)}
|
||
>
|
||
{placement === "side" ? <PanelBottomIcon /> : <PanelSideIcon />}
|
||
</button>
|
||
{current && (
|
||
<button
|
||
type="button"
|
||
className="icon-button"
|
||
aria-label={current.exited ? "Remove terminal" : "Close terminal"}
|
||
title={current.exited ? "Remove" : "Close (kills the program)"}
|
||
onClick={() => session.current?.close()}
|
||
>
|
||
<CloseIcon />
|
||
</button>
|
||
)}
|
||
{onHide && (
|
||
<button
|
||
type="button"
|
||
className="icon-button"
|
||
aria-label="Hide terminal"
|
||
title="Hide (keeps running)"
|
||
onClick={onHide}
|
||
>
|
||
<HideIcon />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{pasteNotice && (
|
||
<output className="notice workspace-note" aria-live="polite">
|
||
{pasteNotice}
|
||
</output>
|
||
)}
|
||
{(state.error || !state.connected || current?.exited) && (
|
||
<p className={`notice workspace-note ${state.error || current?.exited?.error ? "error" : ""}`}>
|
||
{state.error ??
|
||
(!state.connected
|
||
? "Connecting…"
|
||
: current?.exited?.error
|
||
? current.exited.error
|
||
: `Exited with code ${current?.exited?.code}`)}
|
||
</p>
|
||
)}
|
||
{state.busy && state.busy.terminalId === current?.id && (
|
||
<p className="notice workspace-note terminal-confirm">
|
||
<span>
|
||
{state.busy.program ? <code>{state.busy.program}</code> : "A program"} is running in this terminal.
|
||
Close it anyway?
|
||
</span>
|
||
<button type="button" className="text-action danger" onClick={() => session.current?.close(true)}>
|
||
Close
|
||
</button>
|
||
<button type="button" className="text-action" onClick={() => session.current?.keep()}>
|
||
Cancel
|
||
</button>
|
||
</p>
|
||
)}
|
||
{state.outdated && (
|
||
<p className="notice workspace-note terminal-outdated">
|
||
<span>
|
||
The terminal host runs older code.{" "}
|
||
{state.outdated.running === undefined
|
||
? "Restarting it ends its terminals."
|
||
: `Restarting it ends ${state.outdated.running} running terminal${state.outdated.running === 1 ? "" : "s"}.`}
|
||
</span>
|
||
<button type="button" className="text-action" onClick={() => session.current?.restartHost()}>
|
||
Restart
|
||
</button>
|
||
</p>
|
||
)}
|
||
{state.connected && !current && !state.terminals.some((terminal) => !terminal.exited) && (
|
||
<button type="button" className="button terminal-start" onClick={() => session.current?.open()}>
|
||
New terminal
|
||
</button>
|
||
)}
|
||
<div
|
||
className="terminal-screen"
|
||
ref={frame}
|
||
onWheel={(event) =>
|
||
session.current?.scroll(
|
||
event.deltaY,
|
||
{ x: event.clientX, y: event.clientY },
|
||
{ shift: event.shiftKey, ctrl: event.ctrlKey, alt: event.altKey },
|
||
)
|
||
}
|
||
>
|
||
<div className="terminal-content" ref={screen} />
|
||
<textarea
|
||
ref={input}
|
||
className="terminal-input"
|
||
aria-label="Terminal input"
|
||
autoCapitalize="off"
|
||
autoComplete="off"
|
||
autoCorrect="off"
|
||
spellCheck={false}
|
||
/>
|
||
</div>
|
||
{keys && (
|
||
<div className="terminal-keys">
|
||
<button type="button" aria-pressed={ctrl} onPointerDown={keepFocus} onClick={() => setCtrl(!ctrl)}>
|
||
Ctrl
|
||
</button>
|
||
<button type="button" aria-pressed={alt} onPointerDown={keepFocus} onClick={() => setAlt(!alt)}>
|
||
Alt
|
||
</button>
|
||
{KEYS.map((entry) => (
|
||
<button
|
||
type="button"
|
||
key={entry.label}
|
||
onPointerDown={keepFocus}
|
||
onClick={() => {
|
||
const terminal = session.current;
|
||
if (!terminal) return;
|
||
if (entry.input) {
|
||
terminal.key({ ...entry.input, ctrl, alt });
|
||
setCtrl(false);
|
||
setAlt(false);
|
||
} else if (entry.text) terminal.text(entry.text);
|
||
}}
|
||
>
|
||
{entry.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The terminal below the chat; its top edge drags to resize it. */
|
||
export function TerminalPanel(props: Omit<Parameters<typeof TerminalView>[0], "placement">) {
|
||
const panel = useRef<HTMLElement>(null);
|
||
const [height, setHeight] = useState(() => Number(localStorage.getItem("p2-terminal-height")) || undefined);
|
||
// Like the workspace's width: the handle captures the pointer, and only the CSS variable changes while dragging.
|
||
const startResize = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
const element = panel.current;
|
||
const parent = element?.parentElement;
|
||
if (!element || !parent || (event.pointerType === "mouse" && event.button !== 0)) return;
|
||
event.preventDefault();
|
||
const handle = event.currentTarget;
|
||
const pointer = event.pointerId;
|
||
handle.setPointerCapture(pointer);
|
||
const bottom = element.getBoundingClientRect().bottom;
|
||
const limit = parent.getBoundingClientRect().height * 0.85;
|
||
let last = height;
|
||
const move = (moved: PointerEvent) => {
|
||
if (moved.pointerId !== pointer) return;
|
||
last = Math.round(Math.min(Math.max(bottom - moved.clientY, 120), limit));
|
||
element.style.setProperty("--terminal-height", `${last}px`);
|
||
};
|
||
const end = (ended: PointerEvent) => {
|
||
if (ended.pointerId !== pointer) return;
|
||
handle.removeEventListener("pointermove", move);
|
||
handle.removeEventListener("pointerup", end);
|
||
handle.removeEventListener("pointercancel", end);
|
||
if (!last) return;
|
||
setHeight(last);
|
||
localStorage.setItem("p2-terminal-height", String(last));
|
||
};
|
||
handle.addEventListener("pointermove", move);
|
||
handle.addEventListener("pointerup", end);
|
||
handle.addEventListener("pointercancel", end);
|
||
};
|
||
return (
|
||
<section
|
||
className="terminal-panel"
|
||
aria-label="Terminal"
|
||
ref={panel}
|
||
style={height ? ({ "--terminal-height": `${height}px` } as CSSProperties) : undefined}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="terminal-resize"
|
||
aria-label="Drag to resize terminal"
|
||
tabIndex={-1}
|
||
onPointerDown={startResize}
|
||
/>
|
||
<TerminalView {...props} placement="bottom" />
|
||
</section>
|
||
);
|
||
}
|