@aurigax-ai/ghostty-web 0.0.0-stage → 0.1.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 +1 -0
- package/LICENSE +22 -0
- package/README.md +90 -2
- package/ghostty-vt.wasm +0 -0
- package/lib/abi.ts +227 -0
- package/lib/addons/fit.ts +242 -0
- package/lib/buffer.ts +402 -0
- package/lib/event-emitter.ts +27 -0
- package/lib/ghostty.ts +1135 -0
- package/lib/index.ts +110 -0
- package/lib/input-handler.ts +1104 -0
- package/lib/interfaces.ts +218 -0
- package/lib/link-detector.ts +215 -0
- package/lib/marker.ts +55 -0
- package/lib/providers/osc8-link-provider.ts +144 -0
- package/lib/providers/url-regex-provider.ts +150 -0
- package/lib/renderer.ts +958 -0
- package/lib/selection-manager.ts +1052 -0
- package/lib/terminal.ts +2026 -0
- package/lib/types.ts +648 -0
- package/package.json +70 -4
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,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ghostty-web (AurigaX)
|
|
2
2
|
|
|
3
|
-
|
|
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 a canvas renderer. 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
|
+
- **pnpm and Vitest** instead of Bun; no demo server, benchmark or release automation.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm add @aurigax-ai/ghostty-web
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The package ships its TypeScript source and `ghostty-vt.wasm`; bundle it with Vite or similar.
|
|
27
|
+
|
|
28
|
+
## Build
|
|
29
|
+
|
|
30
|
+
Needs [Zig](https://ziglang.org) 0.16.0 and pnpm. Use the official Zig tarball (as CI does): a
|
|
31
|
+
distribution's Zig built against its own LLVM produces a different, equally valid `ghostty-vt.wasm`,
|
|
32
|
+
and CI checks that the committed file matches the official build. Point `ZIG` at it if it is not the
|
|
33
|
+
`zig` on your PATH: `ZIG=/path/to/zig pnpm build:wasm`.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pnpm install
|
|
37
|
+
pnpm build:wasm # clones Ghostty at GHOSTTY_COMMIT into vendor/ and builds ghostty-vt.wasm
|
|
38
|
+
pnpm test
|
|
39
|
+
pnpm build # dist/ with the library and the WASM
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
libghostty-vt's API is not stable yet, so `GHOSTTY_COMMIT` is raised on purpose, with the tests as
|
|
43
|
+
the guard. The built `ghostty-vt.wasm` is committed so the package installs without Zig; the build
|
|
44
|
+
is reproducible and CI fails if the committed file differs from a fresh build of the pinned commit.
|
|
45
|
+
The package exports its TypeScript source (`lib/index.ts`), for bundlers such as Vite.
|
|
46
|
+
|
|
47
|
+
## Host hooks
|
|
48
|
+
|
|
49
|
+
Besides the xterm.js-shaped API, `Terminal` exposes what libghostty-vt reports, at its exact place
|
|
50
|
+
in the output stream:
|
|
51
|
+
|
|
52
|
+
| Hook | What it reports |
|
|
53
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
54
|
+
| `onSemanticPrompt` | OSC 133 prompt marks (`prompt-start`, `input-start`, `output-start`, `command-end` with the exit code) |
|
|
55
|
+
| `onPwdChange` | OSC 7 as sent: the raw `file://host/path` URI |
|
|
56
|
+
| `onDesktopNotification` | OSC 9 and OSC 777 notifications |
|
|
57
|
+
| `onUnknownOsc` | OSCs Ghostty does not implement, e.g. `633;E;…` |
|
|
58
|
+
| `onBell`, `onTitleChange` | Ghostty's own bell and title |
|
|
59
|
+
| `clipboardWriteHandler` | Decides OSC 52 writes (denied unless it returns true); reads are never answered |
|
|
60
|
+
| `registerMarker(offset)` | An xterm.js-style marker that follows its line through scrolling and reflow |
|
|
61
|
+
| `modes.mouseTrackingMode` | `none`, `x10`, `vt200`, `drag` or `any` |
|
|
62
|
+
|
|
63
|
+
The render loop runs only while something changes (output, input, scrolling, selection, cursor
|
|
64
|
+
blink) and sleeps otherwise.
|
|
65
|
+
|
|
66
|
+
## Use
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { Ghostty, Terminal, FitAddon } from '@aurigax-ai/ghostty-web';
|
|
70
|
+
|
|
71
|
+
const ghostty = await Ghostty.fromBytes(wasmBytes);
|
|
72
|
+
const term = new Terminal({ ghostty, fontSize: 14 });
|
|
73
|
+
const fit = new FitAddon();
|
|
74
|
+
term.loadAddon(fit);
|
|
75
|
+
term.open(element);
|
|
76
|
+
fit.fit();
|
|
77
|
+
term.onData((data) => pty.write(data));
|
|
78
|
+
pty.onData((data) => term.write(data));
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Release
|
|
82
|
+
|
|
83
|
+
Raise `version` in `package.json`, commit, and push a matching `v<version>` tag. `release.yml`
|
|
84
|
+
rebuilds the WASM, checks it matches the committed file, runs the tests and publishes to npm with
|
|
85
|
+
trusted publishing (no token). A version with a `-` (e.g. `0.2.0-rc.1`) is published under the
|
|
86
|
+
`next` tag.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT. See [LICENSE](./LICENSE). Ghostty is MIT licensed by Mitchell Hashimoto and the Ghostty
|
|
91
|
+
contributors.
|
package/ghostty-vt.wasm
ADDED
|
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
|
+
}
|