1import { GhosttyModule, mountTerminal, type TerminalHandle } from "@clo/terminal";
2import wasmUrl from "@clo/terminal/ghostty-vt.wasm?url";
3import "@clo/terminal/terminal.css";
4
5import { isTerminalLinkActivation } from "~/terminal-links";
6import { readLocalApi } from "~/localApi";
7import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
8
9export interface TerminalTheme {
10 foreground: string;
11 background: string;
12 cursor: string;
13 palette: readonly string[];
14 selectionBackground: string;
15}
16
17export interface TerminalSurfaceOptions {
18 theme: TerminalTheme;
19 font?: { family?: string; size: number };
20 visible?: boolean;
21 onData: (data: string) => void;
22 onResize: (cols: number, rows: number) => void;
23 beforeKey: (event: KeyboardEvent) => boolean;
24 onLinkActivate: (text: string, event: MouseEvent) => void;
25 validatePath?: (path: string) => boolean;
26 canActivateLink?: (text: string) => boolean;
27 onContextMenu?: (event: MouseEvent) => void;
28}
29
30let modulePromise: Promise<GhosttyModule> | null = null;
31const ANSI_NAMES = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"];
32const ANSI_TOKENS = [...ANSI_NAMES, ...ANSI_NAMES.map((name) => `bright-${name}`)];
33const decoder = new TextDecoder();
34
35function applyTheme(mount: HTMLElement, theme: TerminalTheme) {
36 const style = mount.style;
37 style.setProperty("--clo-term-fg", theme.foreground);
38 style.setProperty("--clo-term-bg", theme.background);
39 style.setProperty("--clo-term-cursor", theme.cursor);
40 style.setProperty("--clo-term-selection", theme.selectionBackground);
41 theme.palette.forEach((color, index) =>
42 style.setProperty(`--clo-term-ansi-${ANSI_TOKENS[index]}`, color),
43 );
44}
45
46export class TerminalSurface {
47 readonly input: HTMLTextAreaElement;
48 private readonly cleanups: (() => void)[] = [];
49 private disposed = false;
50 private replaying = false;
51
52 private constructor(
53 private readonly mount: HTMLElement,
54 private readonly handle: TerminalHandle,
55 private readonly options: TerminalSurfaceOptions,
56 container: HTMLElement,
57 ) {
58 this.input = handle.view.input;
59 this.input.setAttribute("aria-label", mount.getAttribute("aria-label") ?? "Terminal input");
60 const beforeKey = (event: KeyboardEvent) => {
61 if (event.isComposing || event.keyCode === 229) return;
62 const copy =
63 isTerminalLinkActivation(event) && !event.altKey && event.key.toLowerCase() === "c";
64 const selection = copy ? this.getSelection() : "";
65 if (selection !== "") {
66 event.preventDefault();
67 event.stopImmediatePropagation();
68 this.copyText(selection);
69 }
70 };
71 const contextMenu = (event: MouseEvent) => {
72 if (event.defaultPrevented) return;
73 if (options.onContextMenu) {
74 options.onContextMenu(event);
75 return;
76 }
77 const api = readLocalApi();
78 const text = this.getSelection();
79 if (!api || text === "") return;
80 event.preventDefault();
81 void api.contextMenu
82 .show([{ id: "copy", label: "Copy" }], { x: event.clientX, y: event.clientY })
83 .then((action) => {
84 if (action === "copy" && !this.disposed) this.copyText(text);
85 })
86 .catch((error: unknown) => {
87 if (!this.disposed)
88 this.write(
89 `\r\n[terminal] ${error instanceof Error ? error.message : "Unable to open terminal menu"}\r\n`,
90 );
91 });
92 };
93 mount.addEventListener("keydown", beforeKey, true);
94 mount.addEventListener("contextmenu", contextMenu);
95 const focus = () => this.focus();
96 container.addEventListener("focus", focus);
97 this.cleanups.push(() => mount.removeEventListener("keydown", beforeKey, true));
98 this.cleanups.push(() => mount.removeEventListener("contextmenu", contextMenu));
99 this.cleanups.push(() => container.removeEventListener("focus", focus));
100 }
101
102 static async create(container: HTMLElement, options: TerminalSurfaceOptions) {
103 modulePromise ??= GhosttyModule.fetch(wasmUrl).catch((error: unknown) => {
104 modulePromise = null;
105 throw error;
106 });
107 const module = await modulePromise;
108 const mount = container.ownerDocument.createElement("div");
109 mount.style.position = "absolute";
110 mount.style.inset = "0";
111 const label = container.getAttribute("aria-label");
112 if (label !== null) mount.setAttribute("aria-label", label);
113 applyTheme(mount, options.theme);
114 const style = mount.style;
115 style.setProperty("--clo-term-font-size", `${options.font?.size ?? 12}px`);
116 if (options.font?.family) style.setProperty("--clo-term-font", options.font.family);
117 container.append(mount);
118 let surface: TerminalSurface | undefined;
119 let handle: TerminalHandle | undefined;
120 try {
121 handle = mountTerminal(mount, module, {
122 visible: options.visible !== false,
123 onData: (bytes) => {
124 if (!surface?.replaying) options.onData(decoder.decode(bytes));
125 },
126 onResize: ({ cols, rows }) => options.onResize(cols, rows),
127 onBeforeKey: (event) => options.beforeKey(event),
128 maxScrollbackBytes: 16 * 1024 * 1024,
129 links: {
130 ...(options.validatePath ? { validate: options.validatePath } : {}),
131 requireModifier: true,
132 onActivate: (link, event) => {
133 if (!isTerminalLinkActivation(event)) return;
134 if (options.canActivateLink?.(link.target) === false) return;
135 const target =
136 link.kind === "path" && link.line !== null
137 ? `${link.target}:${link.line}${link.column !== null ? `:${link.column}` : ""}`
138 : link.target;
139 options.onLinkActivate(target, event);
140 },
141 },
142 });
143 surface = new TerminalSurface(mount, handle, options, container);
144 if (container.ownerDocument.activeElement === container) surface.focus();
145 await handle.ready;
146 return surface;
147 } catch (error) {
148 if (surface) surface.dispose();
149 else {
150 handle?.dispose();
151 mount.remove();
152 }
153 throw error;
154 }
155 }
156
157 private copyText(text: string) {
158 void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => {
159 if (!this.disposed)
160 this.write(
161 `\r\n[terminal] ${error instanceof Error ? error.message : "Unable to copy terminal selection"}\r\n`,
162 );
163 });
164 }
165
166 write(data: string) {
167 if (this.disposed) return;
168 this.handle.write(data);
169 }
170
171 resetAndWrite(data: string) {
172 if (this.disposed) return;
173 this.clearSelection();
174 this.replaying = true;
175 try {
176 this.handle.write(`\x1bc\x1b[3J${data}`);
177 this.handle.terminal.scrollToBottom();
178 } finally {
179 this.replaying = false;
180 }
181 }
182
183 setTheme(theme: TerminalTheme) {
184 applyTheme(this.mount, theme);
185 this.handle.view.refreshMetrics();
186 }
187
188 setFont(font: { family?: string; size: number }) {
189 this.mount.style.setProperty("--clo-term-font-size", `${font.size}px`);
190 if (font.family) this.mount.style.setProperty("--clo-term-font", font.family);
191 else this.mount.style.removeProperty("--clo-term-font");
192 this.handle.view.refreshMetrics();
193 }
194
195 fit() {
196 this.handle.view.refreshMetrics();
197 return this.handle.view.fit();
198 }
199 resendSize() {
200 const { cols, rows } = this.fit();
201 this.options.onResize(cols, rows);
202 }
203 focus() {
204 this.handle.view.focus();
205 }
206 setVisible(visible: boolean) {
207 this.handle.view.setVisible(visible);
208 }
209 isAtBottom() {
210 const scrollbar = this.handle.terminal.scrollbar();
211 return scrollbar.offset >= scrollbar.total - scrollbar.len;
212 }
213 scrollToBottom() {
214 this.handle.terminal.scrollToBottom();
215 this.handle.view.scheduleFrame();
216 }
217
218 getSelection() {
219 const selection = this.mount.ownerDocument.getSelection();
220 if (
221 !selection ||
222 !this.mount.contains(selection.anchorNode) ||
223 !this.mount.contains(selection.focusNode)
224 )
225 return "";
226 return selection.toString();
227 }
228
229 hasSelection() {
230 return this.getSelection() !== "";
231 }
232
233 getSelectionPosition() {
234 const selection = this.mount.ownerDocument.getSelection();
235 if (!selection || !this.hasSelection()) return null;
236 const row = (node: Node | null) =>
237 (node instanceof Element ? node : node?.parentElement)?.closest<HTMLElement>(".clo-term-row")
238 ?.dataset.screenRow;
239 const start = row(selection.anchorNode);
240 const end = row(selection.focusNode);
241 if (start === undefined || end === undefined) return null;
242 return {
243 start: { y: Math.min(Number(start), Number(end)) },
244 end: { y: Math.max(Number(start), Number(end)) },
245 };
246 }
247
248 clearSelection() {
249 if (this.hasSelection()) this.mount.ownerDocument.getSelection()?.removeAllRanges();
250 }
251
252 selectAll() {
253 const rows = this.mount.querySelector(".clo-term-rows");
254 if (!rows) return;
255 const range = this.mount.ownerDocument.createRange();
256 range.selectNodeContents(rows);
257 const selection = this.mount.ownerDocument.getSelection();
258 selection?.removeAllRanges();
259 selection?.addRange(range);
260 }
261
262 paste(text: string) {
263 if (!this.input.readOnly) {
264 this.input.focus({ preventScroll: true });
265 this.options.onData(decoder.decode(this.handle.terminal.encodePaste(text)));
266 }
267 }
268
269 dispose() {
270 if (this.disposed) return;
271 this.disposed = true;
272 this.cleanups.forEach((cleanup) => cleanup());
273 this.handle.dispose();
274 this.mount.remove();
275 }
276}