1import type { GhosttyModule } from "./ghostty/module.ts";
2import { Terminal, type TerminalEvents, type TerminalOptions } from "./ghostty/terminal.ts";
3import { TerminalView, type TerminalViewOptions } from "./renderer/dom.ts";
4
5export interface MountTerminalOptions
6 extends
7 Partial<TerminalOptions>,
8 Omit<TerminalEvents, "onOutput">,
9 Omit<TerminalViewOptions, "onData"> {
10 /** Keystrokes, paste, mouse reports, and terminal query responses. Forward to the pty. */
11 onData?: (bytes: Uint8Array) => void;
12 /** Called after a fitted container changes the terminal's cell geometry. */
13 onResize?: (size: { cols: number; rows: number }) => void;
14}
15
16export interface TerminalHandle {
17 readonly terminal: Terminal;
18 readonly view: TerminalView;
19 /** Initial font loading, measurement, fit, and resize reporting have settled. */
20 readonly ready: Promise<void>;
21 /** Feed pty output into the emulator and schedule its DOM frame. */
22 write(data: string | Uint8Array): void;
23 /** Release the renderer lease and every native terminal resource. Idempotent. */
24 dispose(): void;
25}
26
27/**
28 * Mount the complete DOM terminal. Transport stays caller-owned: pass pty output
29 * to `write` and forward `onData` bytes back to the pty.
30 */
31export function mountTerminal(
32 container: HTMLElement,
33 module: GhosttyModule,
34 options: MountTerminalOptions = {},
35): TerminalHandle {
36 const terminal = new Terminal(
37 module,
38 {
39 cols: options.cols ?? 80,
40 rows: options.rows ?? 24,
41 ...(options.maxScrollbackBytes !== undefined
42 ? { maxScrollbackBytes: options.maxScrollbackBytes }
43 : {}),
44 ...(options.shellIntegration !== undefined
45 ? { shellIntegration: options.shellIntegration }
46 : {}),
47 ...(options.commandLimit !== undefined ? { commandLimit: options.commandLimit } : {}),
48 },
49 {
50 onOutput: (bytes) => options.onData?.(bytes),
51 onBell: () => options.onBell?.(),
52 onTitleChanged: (title) => options.onTitleChanged?.(title),
53 onPwdChanged: (pwd) => options.onPwdChanged?.(pwd),
54 onShellMark: (mark) => options.onShellMark?.(mark),
55 onCommandStart: (command) => options.onCommandStart?.(command),
56 onCommandEnd: (command) => options.onCommandEnd?.(command),
57 onClipboardWrite: (write) => options.onClipboardWrite?.(write),
58 },
59 );
60 let view: TerminalView | null = null;
61 try {
62 view = new TerminalView(container, terminal, {
63 onData: (bytes) => options.onData?.(bytes),
64 ...(options.onBeforeKey ? { onBeforeKey: options.onBeforeKey } : {}),
65 ...(options.visible !== undefined ? { visible: options.visible } : {}),
66 ...(options.scrollback !== undefined ? { scrollback: options.scrollback } : {}),
67 ...(options.scrollbar !== undefined ? { scrollbar: options.scrollbar } : {}),
68 ...(options.links !== undefined ? { links: options.links } : {}),
69 ...(options.mouse !== undefined ? { mouse: options.mouse } : {}),
70 ...(options.stickyScroll !== undefined ? { stickyScroll: options.stickyScroll } : {}),
71 ...(options.predict !== undefined ? { predict: options.predict } : {}),
72 });
73 view.observeResize((size) => options.onResize?.(size));
74 } catch (error) {
75 view?.dispose();
76 terminal.free();
77 throw error;
78 }
79
80 const mountedView = view;
81 let disposed = false;
82 return {
83 terminal,
84 view: mountedView,
85 ready: mountedView.ready,
86 write: (data) => {
87 let failed = false;
88 let writeError: unknown;
89 try {
90 terminal.write(data);
91 } catch (error) {
92 failed = true;
93 writeError = error;
94 }
95 try {
96 if (!disposed) mountedView.scheduleFrame();
97 } catch (error) {
98 if (!failed) throw error;
99 }
100 if (failed) throw writeError;
101 },
102 dispose: () => {
103 if (disposed) return;
104 disposed = true;
105 mountedView.dispose();
106 terminal.free();
107 },
108 };
109}