@aurigax-ai/ghostty-web 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/GHOSTTY_COMMIT ADDED
@@ -0,0 +1 @@
1
+ c3203ea4b169a18eb2ccfe92847e426d8afea858
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Coder
4
+ Copyright (c) 2026 AurigaX
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,95 @@
1
- # Temporary Holding Version
1
+ # ghostty-web (AurigaX)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Ghostty](https://github.com/ghostty-org/ghostty)'s terminal engine in the browser, with an
4
+ [xterm.js](https://github.com/xtermjs/xterm.js)-shaped API and WebGL and canvas renderers. It is the engine
5
+ behind the Ghostty terminal in Ostia.
6
+
7
+ This is a hard fork of [coder/ghostty-web](https://github.com/coder/ghostty-web) (MIT, © Coder),
8
+ kept with its history. It does not track upstream. What changed:
9
+
10
+ - **Official, unpatched libghostty-vt.** ghostty-web built Ghostty with a patch that added its own
11
+ C functions. This fork builds Ghostty's published libghostty-vt API at a pinned commit
12
+ (`GHOSTTY_COMMIT`) and talks to it through `lib/abi.ts`, which reads struct layouts from
13
+ `ghostty_type_json()` so a field that moves fails loudly instead of reading garbage.
14
+ - **Host hooks.** JavaScript callbacks reach libghostty-vt through its growable function table
15
+ (`Abi.addCallback`), so the host can receive prompt marks (OSC 133), the working directory with
16
+ its host (OSC 7), notifications, clipboard writes and unknown sequences, and keep markers as
17
+ tracked grid references.
18
+ - **WebGL renderer.** `renderer: 'webgl'` (the default) draws with WebGL 2 from a glyph atlas,
19
+ rewriting only the rows that changed; without WebGL 2, or when its context is lost, the terminal
20
+ draws with the canvas renderer instead (`renderer: 'canvas'` asks for it). Both draw box drawing
21
+ and block elements from geometry, so they fill their cells.
22
+ - **pnpm and Vitest** instead of Bun; no demo server, benchmark or release automation.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ pnpm add @aurigax-ai/ghostty-web
28
+ ```
29
+
30
+ The package ships its TypeScript source and `ghostty-vt.wasm`; bundle it with Vite or similar.
31
+
32
+ ## Build
33
+
34
+ Needs [Zig](https://ziglang.org) 0.16.0 and pnpm. Use the official Zig tarball (as CI does): a
35
+ distribution's Zig built against its own LLVM produces a different, equally valid `ghostty-vt.wasm`,
36
+ and CI checks that the committed file matches the official build. Point `ZIG` at it if it is not the
37
+ `zig` on your PATH: `ZIG=/path/to/zig pnpm build:wasm`.
38
+
39
+ ```sh
40
+ pnpm install
41
+ pnpm build:wasm # clones Ghostty at GHOSTTY_COMMIT into vendor/ and builds ghostty-vt.wasm
42
+ pnpm test
43
+ pnpm build # dist/ with the library and the WASM
44
+ ```
45
+
46
+ libghostty-vt's API is not stable yet, so `GHOSTTY_COMMIT` is raised on purpose, with the tests as
47
+ the guard. The built `ghostty-vt.wasm` is committed so the package installs without Zig; the build
48
+ is reproducible and CI fails if the committed file differs from a fresh build of the pinned commit.
49
+ The package exports its TypeScript source (`lib/index.ts`), for bundlers such as Vite.
50
+
51
+ ## Host hooks
52
+
53
+ Besides the xterm.js-shaped API, `Terminal` exposes what libghostty-vt reports, at its exact place
54
+ in the output stream:
55
+
56
+ | Hook | What it reports |
57
+ | ------------------------- | ------------------------------------------------------------------------------------------------------ |
58
+ | `onSemanticPrompt` | OSC 133 prompt marks (`prompt-start`, `input-start`, `output-start`, `command-end` with the exit code) |
59
+ | `onPwdChange` | OSC 7 as sent: the raw `file://host/path` URI |
60
+ | `onDesktopNotification` | OSC 9 and OSC 777 notifications |
61
+ | `onUnknownOsc` | OSCs Ghostty does not implement, e.g. `633;E;…` |
62
+ | `onBell`, `onTitleChange` | Ghostty's own bell and title |
63
+ | `clipboardWriteHandler` | Decides OSC 52 writes (denied unless it returns true); reads are never answered |
64
+ | `registerMarker(offset)` | An xterm.js-style marker that follows its line through scrolling and reflow |
65
+ | `modes.mouseTrackingMode` | `none`, `x10`, `vt200`, `drag` or `any` |
66
+
67
+ The render loop runs only while something changes (output, input, scrolling, selection, cursor
68
+ blink) and sleeps otherwise.
69
+
70
+ ## Use
71
+
72
+ ```ts
73
+ import { Ghostty, Terminal, FitAddon } from '@aurigax-ai/ghostty-web';
74
+
75
+ const ghostty = await Ghostty.fromBytes(wasmBytes);
76
+ const term = new Terminal({ ghostty, fontSize: 14 });
77
+ const fit = new FitAddon();
78
+ term.loadAddon(fit);
79
+ term.open(element);
80
+ fit.fit();
81
+ term.onData((data) => pty.write(data));
82
+ pty.onData((data) => term.write(data));
83
+ ```
84
+
85
+ ## Release
86
+
87
+ Raise `version` in `package.json`, commit, and push a matching `v<version>` tag. `release.yml`
88
+ rebuilds the WASM, checks it matches the committed file, runs the tests and publishes to npm with
89
+ trusted publishing (no token). A version with a `-` (e.g. `0.2.0-rc.1`) is published under the
90
+ `next` tag.
91
+
92
+ ## License
93
+
94
+ MIT. See [LICENSE](./LICENSE). Ghostty is MIT licensed by Mitchell Hashimoto and the Ghostty
95
+ contributors.
Binary file
package/lib/abi.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Low-level access to the official libghostty-vt WebAssembly ABI.
3
+ *
4
+ * Struct layouts are read from `ghostty_type_json()` at load time instead of
5
+ * being hard-coded, so a Ghostty upgrade that moves a field fails loudly in
6
+ * `Abi.offset` rather than reading garbage.
7
+ */
8
+
9
+ export const GHOSTTY_SUCCESS = 0;
10
+ export const GHOSTTY_INVALID_VALUE = -2;
11
+ export const GHOSTTY_OUT_OF_SPACE = -3;
12
+ export const GHOSTTY_NO_VALUE = -4;
13
+
14
+ type Fn = (...args: any[]) => any;
15
+
16
+ export interface VtExports {
17
+ memory: WebAssembly.Memory;
18
+ __indirect_function_table: WebAssembly.Table;
19
+ [name: string]: Fn | WebAssembly.Memory | WebAssembly.Table;
20
+ }
21
+
22
+ interface FieldLayout {
23
+ offset: number;
24
+ size: number;
25
+ }
26
+
27
+ interface TypeLayout {
28
+ kind: string;
29
+ size: number;
30
+ fields?: Record<string, FieldLayout>;
31
+ bits?: Record<string, { lsb: number; width: number }>;
32
+ values?: Record<string, number>;
33
+ }
34
+
35
+ const I32 = 0x7f;
36
+ const I64 = 0x7e;
37
+
38
+ function leb(n: number): number[] {
39
+ const out: number[] = [];
40
+ let v = n;
41
+ do {
42
+ let byte = v & 0x7f;
43
+ v >>>= 7;
44
+ if (v !== 0) byte |= 0x80;
45
+ out.push(byte);
46
+ } while (v !== 0);
47
+ return out;
48
+ }
49
+
50
+ function section(id: number, body: number[]): number[] {
51
+ return [id, ...leb(body.length), ...body];
52
+ }
53
+
54
+ /**
55
+ * The bytes of a tiny module that imports one host function and re-exports it,
56
+ * which turns a JavaScript function into a WebAssembly function that can be
57
+ * placed in libghostty-vt's (growable) function table.
58
+ */
59
+ export function trampolineBytes(
60
+ params: ('i32' | 'i64')[],
61
+ result: 'i32' | null
62
+ ): Uint8Array<ArrayBuffer> {
63
+ const name = [1, 0x66];
64
+ const type = [
65
+ 1,
66
+ 0x60,
67
+ params.length,
68
+ ...params.map((p) => (p === 'i64' ? I64 : I32)),
69
+ ...(result ? [1, I32] : [0]),
70
+ ];
71
+ return new Uint8Array([
72
+ 0x00,
73
+ 0x61,
74
+ 0x73,
75
+ 0x6d,
76
+ 0x01,
77
+ 0x00,
78
+ 0x00,
79
+ 0x00,
80
+ ...section(1, type),
81
+ ...section(2, [1, 1, 0x65, ...name, 0x00, 0]),
82
+ ...section(7, [1, ...name, 0x00, 0]),
83
+ ]);
84
+ }
85
+
86
+ export class GhosttyCallError extends Error {
87
+ constructor(
88
+ readonly call: string,
89
+ readonly result: number
90
+ ) {
91
+ super(`${call} failed with result ${result}`);
92
+ }
93
+ }
94
+
95
+ export class Abi {
96
+ private readonly types: Record<string, TypeLayout>;
97
+ private readonly freeSlots: number[] = [];
98
+
99
+ constructor(readonly exports: VtExports) {
100
+ const ptr = this.fn('ghostty_type_json')() as number;
101
+ const bytes = new Uint8Array(exports.memory.buffer);
102
+ let end = ptr;
103
+ while (bytes[end] !== 0) end++;
104
+ this.types = JSON.parse(new TextDecoder().decode(bytes.subarray(ptr, end))).types;
105
+ }
106
+
107
+ fn(name: string): Fn {
108
+ const f = this.exports[name];
109
+ if (typeof f !== 'function') throw new Error(`libghostty-vt has no export ${name}`);
110
+ return f as Fn;
111
+ }
112
+
113
+ call(name: string, ...args: unknown[]): number {
114
+ return this.fn(name)(...args) as number;
115
+ }
116
+
117
+ check(name: string, ...args: unknown[]): void {
118
+ const result = this.call(name, ...args);
119
+ if (result !== GHOSTTY_SUCCESS) throw new GhosttyCallError(name, result);
120
+ }
121
+
122
+ sizeOf(type: string): number {
123
+ const layout = this.types[type];
124
+ if (!layout) throw new Error(`libghostty-vt has no type ${type}`);
125
+ return layout.size;
126
+ }
127
+
128
+ offset(type: string, field: string): number {
129
+ const f = this.types[type]?.fields?.[field];
130
+ if (!f) throw new Error(`libghostty-vt type ${type} has no field ${field}`);
131
+ return f.offset;
132
+ }
133
+
134
+ bits(type: string, field: string): { lsb: number; width: number } {
135
+ const b = this.types[type]?.bits?.[field];
136
+ if (!b) throw new Error(`libghostty-vt type ${type} has no bit field ${field}`);
137
+ return b;
138
+ }
139
+
140
+ enumValue(type: string, key: string): number {
141
+ const v = this.types[type]?.values?.[key];
142
+ if (v === undefined) throw new Error(`libghostty-vt enum ${type} has no value ${key}`);
143
+ return v;
144
+ }
145
+
146
+ bytes(): Uint8Array {
147
+ return new Uint8Array(this.exports.memory.buffer);
148
+ }
149
+
150
+ view(): DataView {
151
+ return new DataView(this.exports.memory.buffer);
152
+ }
153
+
154
+ alloc(len: number): number {
155
+ const ptr = this.call('ghostty_wasm_alloc', len);
156
+ if (ptr === 0) throw new GhosttyCallError('ghostty_wasm_alloc', -1);
157
+ this.bytes().fill(0, ptr, ptr + len);
158
+ return ptr;
159
+ }
160
+
161
+ free(ptr: number, len: number): void {
162
+ this.call('ghostty_wasm_free', ptr, len);
163
+ }
164
+
165
+ with<T>(len: number, use: (ptr: number) => T): T {
166
+ const ptr = this.alloc(len);
167
+ try {
168
+ return use(ptr);
169
+ } finally {
170
+ this.free(ptr, len);
171
+ }
172
+ }
173
+
174
+ /** Allocates a sized struct (its leading `size` field set), as GHOSTTY_INIT_SIZED does. */
175
+ withSized<T>(type: string, use: (ptr: number) => T): T {
176
+ const size = this.sizeOf(type);
177
+ return this.with(size, (ptr) => {
178
+ this.view().setUint32(ptr, size, true);
179
+ return use(ptr);
180
+ });
181
+ }
182
+
183
+ withBytes<T>(data: Uint8Array, use: (ptr: number, len: number) => T): T {
184
+ if (data.length === 0) return use(0, 0);
185
+ return this.with(data.length, (ptr) => {
186
+ this.bytes().set(data, ptr);
187
+ return use(ptr, data.length);
188
+ });
189
+ }
190
+
191
+ /** Runs a constructor that writes an opaque handle into an out slot and returns the handle. */
192
+ newHandle(name: string, construct: (slot: number) => number): number {
193
+ const slot = this.call('ghostty_wasm_alloc_opaque');
194
+ if (slot === 0) throw new GhosttyCallError('ghostty_wasm_alloc_opaque', -1);
195
+ try {
196
+ const result = construct(slot);
197
+ if (result !== GHOSTTY_SUCCESS) throw new GhosttyCallError(name, result);
198
+ return this.call('ghostty_wasm_take_opaque', slot);
199
+ } finally {
200
+ this.call('ghostty_wasm_free_opaque', slot);
201
+ }
202
+ }
203
+
204
+ string(ptr: number, len: number): string {
205
+ if (len === 0) return '';
206
+ return new TextDecoder().decode(this.bytes().slice(ptr, ptr + len));
207
+ }
208
+
209
+ /** Registers a JavaScript callback in the function table and returns its index (the C function pointer). */
210
+ addCallback(
211
+ params: ('i32' | 'i64')[],
212
+ result: 'i32' | null,
213
+ fn: (...args: any[]) => unknown
214
+ ): number {
215
+ const module = new WebAssembly.Module(trampolineBytes(params, result));
216
+ const instance = new WebAssembly.Instance(module, { e: { f: fn } });
217
+ const table = this.exports.__indirect_function_table;
218
+ const index = this.freeSlots.pop() ?? table.grow(1);
219
+ table.set(index, instance.exports.f as any);
220
+ return index;
221
+ }
222
+
223
+ removeCallback(index: number): void {
224
+ this.exports.__indirect_function_table.set(index, null);
225
+ this.freeSlots.push(index);
226
+ }
227
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * FitAddon - Auto-resize terminal to fit container
3
+ *
4
+ * Provides automatic terminal resizing to fit its container element.
5
+ * Compatible with xterm.js FitAddon API.
6
+ *
7
+ * Usage:
8
+ * ```typescript
9
+ * const fitAddon = new FitAddon();
10
+ * term.loadAddon(fitAddon);
11
+ * fitAddon.fit(); // Manual fit
12
+ * fitAddon.observeResize(); // Auto-fit on resize
13
+ * ```
14
+ */
15
+
16
+ import type { ITerminalAddon, ITerminalCore } from '../interfaces';
17
+
18
+ // ============================================================================
19
+ // Constants
20
+ // ============================================================================
21
+
22
+ const MINIMUM_COLS = 2;
23
+ const MINIMUM_ROWS = 1;
24
+ const DEFAULT_SCROLLBAR_WIDTH = 15; // Reserve space for future scrollback scrollbar
25
+ const RESIZE_DEBOUNCE_MS = 100; // Debounce time for ResizeObserver
26
+
27
+ // ============================================================================
28
+ // Types
29
+ // ============================================================================
30
+
31
+ export interface ITerminalDimensions {
32
+ cols: number;
33
+ rows: number;
34
+ }
35
+
36
+ // ============================================================================
37
+ // FitAddon Class
38
+ // ============================================================================
39
+
40
+ export class FitAddon implements ITerminalAddon {
41
+ private _terminal?: ITerminalCore;
42
+ private _resizeObserver?: ResizeObserver;
43
+ private _resizeDebounceTimer?: ReturnType<typeof setTimeout>;
44
+ private _lastCols?: number;
45
+ private _lastRows?: number;
46
+ private _isResizing: boolean = false;
47
+
48
+ /**
49
+ * Activate the addon (called by Terminal.loadAddon)
50
+ */
51
+ public activate(terminal: ITerminalCore): void {
52
+ this._terminal = terminal;
53
+ }
54
+
55
+ /**
56
+ * Dispose the addon and clean up resources
57
+ */
58
+ public dispose(): void {
59
+ // Disconnect ResizeObserver
60
+ if (this._resizeObserver) {
61
+ this._resizeObserver.disconnect();
62
+ this._resizeObserver = undefined;
63
+ }
64
+
65
+ // Clear pending debounce timer
66
+ if (this._resizeDebounceTimer) {
67
+ clearTimeout(this._resizeDebounceTimer);
68
+ this._resizeDebounceTimer = undefined;
69
+ }
70
+
71
+ // Clear stored dimensions
72
+ this._lastCols = undefined;
73
+ this._lastRows = undefined;
74
+
75
+ this._terminal = undefined;
76
+ }
77
+
78
+ /**
79
+ * Fit the terminal to its container
80
+ *
81
+ * Calculates optimal dimensions and resizes the terminal.
82
+ * Does nothing if dimensions cannot be calculated or haven't changed.
83
+ */
84
+ public fit(): void {
85
+ // Prevent re-entrant calls during resize
86
+ if (this._isResizing) {
87
+ return;
88
+ }
89
+
90
+ const dims = this.proposeDimensions();
91
+ if (!dims || !this._terminal) {
92
+ return;
93
+ }
94
+
95
+ // Access terminal to check current dimensions
96
+ const terminal = this._terminal as any;
97
+ const currentCols = terminal.cols;
98
+ const currentRows = terminal.rows;
99
+
100
+ // Check if dimensions actually changed (prevent feedback loops)
101
+ // Compare against BOTH proposed dimensions AND current terminal dimensions
102
+ if (
103
+ (dims.cols === this._lastCols && dims.rows === this._lastRows) ||
104
+ (dims.cols === currentCols && dims.rows === currentRows)
105
+ ) {
106
+ return;
107
+ }
108
+
109
+ // Store dimensions before resize
110
+ this._lastCols = dims.cols;
111
+ this._lastRows = dims.rows;
112
+
113
+ // Set flag to prevent re-entrant calls
114
+ this._isResizing = true;
115
+
116
+ try {
117
+ // Resize terminal
118
+ if (terminal.resize && typeof terminal.resize === 'function') {
119
+ terminal.resize(dims.cols, dims.rows);
120
+ }
121
+ } finally {
122
+ // Clear flag after a short delay to allow DOM to settle
123
+ setTimeout(() => {
124
+ this._isResizing = false;
125
+ }, 50);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Propose dimensions to fit the terminal to its container
131
+ *
132
+ * Calculates cols and rows based on:
133
+ * - Terminal container element dimensions (clientWidth/Height)
134
+ * - Terminal element padding
135
+ * - Font metrics (character cell size)
136
+ * - Scrollbar width reservation
137
+ *
138
+ * @returns Proposed dimensions or undefined if cannot calculate
139
+ */
140
+ public proposeDimensions(): ITerminalDimensions | undefined {
141
+ // Need terminal and its DOM element
142
+ if (!this._terminal?.element) {
143
+ return undefined;
144
+ }
145
+
146
+ // Access terminal internals to get renderer
147
+ const terminal = this._terminal as any;
148
+ const renderer = terminal.renderer;
149
+
150
+ if (!renderer || typeof renderer.getMetrics !== 'function') {
151
+ return undefined;
152
+ }
153
+
154
+ // Get font metrics from renderer
155
+ const metrics = renderer.getMetrics();
156
+ if (!metrics || metrics.width === 0 || metrics.height === 0) {
157
+ return undefined;
158
+ }
159
+
160
+ // Get terminal element (container) dimensions
161
+ // Use clientWidth/clientHeight to get the INSIDE dimensions (excluding padding)
162
+ const terminalElement = this._terminal.element;
163
+
164
+ // Check if we have clientWidth/clientHeight (DOM element required)
165
+ if (typeof terminalElement.clientWidth === 'undefined') {
166
+ return undefined;
167
+ }
168
+
169
+ const elementStyle = window.getComputedStyle(terminalElement);
170
+
171
+ // Get the actual content area (inside padding)
172
+ const paddingTop = Number.parseInt(elementStyle.getPropertyValue('padding-top')) || 0;
173
+ const paddingBottom = Number.parseInt(elementStyle.getPropertyValue('padding-bottom')) || 0;
174
+ const paddingLeft = Number.parseInt(elementStyle.getPropertyValue('padding-left')) || 0;
175
+ const paddingRight = Number.parseInt(elementStyle.getPropertyValue('padding-right')) || 0;
176
+
177
+ // Use clientWidth/clientHeight which gives us the inside dimensions
178
+ // This is stable and doesn't grow with content
179
+ const containerWidth = terminalElement.clientWidth;
180
+ const containerHeight = terminalElement.clientHeight;
181
+
182
+ // Check for invalid dimensions
183
+ if (containerWidth === 0 || containerHeight === 0) {
184
+ return undefined;
185
+ }
186
+
187
+ // Calculate available space (subtract padding since clientWidth includes padding)
188
+ const availableWidth = containerWidth - paddingLeft - paddingRight - DEFAULT_SCROLLBAR_WIDTH;
189
+ const availableHeight = containerHeight - paddingTop - paddingBottom;
190
+
191
+ // Calculate dimensions (enforce minimums)
192
+ const cols = Math.max(MINIMUM_COLS, Math.floor(availableWidth / metrics.width));
193
+ const rows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / metrics.height));
194
+
195
+ return { cols, rows };
196
+ }
197
+
198
+ /**
199
+ * Observe the terminal's container for resize events
200
+ *
201
+ * Sets up a ResizeObserver to automatically call fit() when the
202
+ * container size changes. Resize events are debounced to avoid
203
+ * excessive calls during window drag operations.
204
+ *
205
+ * Call dispose() to stop observing.
206
+ */
207
+ public observeResize(): void {
208
+ if (!this._terminal?.element) {
209
+ return;
210
+ }
211
+
212
+ // Already observing
213
+ if (this._resizeObserver) {
214
+ return;
215
+ }
216
+
217
+ // Create ResizeObserver that watches for external size changes
218
+ this._resizeObserver = new ResizeObserver((entries) => {
219
+ // Ignore resize events while we're actively resizing
220
+ if (this._isResizing) {
221
+ return;
222
+ }
223
+
224
+ // Only trigger if the observed element's content rect changed
225
+ const entry = entries[0];
226
+ if (!entry) return;
227
+
228
+ // Debounce resize events
229
+ if (this._resizeDebounceTimer) {
230
+ clearTimeout(this._resizeDebounceTimer);
231
+ }
232
+
233
+ this._resizeDebounceTimer = setTimeout(() => {
234
+ this.fit();
235
+ }, RESIZE_DEBOUNCE_MS);
236
+ });
237
+
238
+ // Observe the terminal element itself (the container we want to fit into)
239
+ // This gives us stable resize events when the CONTAINER changes, not when our canvas changes
240
+ this._resizeObserver.observe(this._terminal.element);
241
+ }
242
+ }