@dkkoval/tui-preview 0.1.1 → 0.2.1
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/README.md +24 -16
- package/dist/TuiPreview.js +91 -68
- package/dist/core/ghostty-vt.wasm +0 -0
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +2 -2
- package/dist/core/libghostty.d.ts +86 -0
- package/dist/core/libghostty.js +678 -0
- package/dist/core/normalize.d.ts +0 -1
- package/dist/core/normalize.js +20 -67
- package/dist/core/wasi.d.ts +8 -2
- package/dist/core/wasi.js +131 -22
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -363
- package/dist/types.d.ts +8 -26
- package/package.json +9 -11
- package/dist/__vite-browser-external-2447137e-BcPniuRQ.cjs +0 -1
- package/dist/__vite-browser-external-2447137e-DYxpcVy9.js +0 -4
- package/dist/core/ghostty.d.ts +0 -2
- package/dist/core/ghostty.js +0 -11
- package/dist/ghostty-web-BfBVpf8G.js +0 -2962
- package/dist/ghostty-web-DkOZu5AZ.cjs +0 -13
- package/dist/index.cjs +0 -1
- package/dist/wasi.d.ts +0 -1
- package/dist/wasi.js +0 -2
package/dist/core/normalize.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
const DEFAULT_SIZE = { cols: 80, rows: 24 };
|
|
2
2
|
const EMPTY_ENV = {};
|
|
3
3
|
const EMPTY_ARGV = [];
|
|
4
|
-
function
|
|
5
|
-
|
|
4
|
+
function normalizeMode(mode) {
|
|
5
|
+
if (mode === "static") {
|
|
6
|
+
return "static";
|
|
7
|
+
}
|
|
8
|
+
return "interactive";
|
|
6
9
|
}
|
|
7
10
|
function resolveArgvInput(argv) {
|
|
8
11
|
const value = argv ?? EMPTY_ARGV;
|
|
@@ -11,80 +14,30 @@ function resolveArgvInput(argv) {
|
|
|
11
14
|
}
|
|
12
15
|
return () => value;
|
|
13
16
|
}
|
|
14
|
-
function resolveLegacySize(props) {
|
|
15
|
-
const hasExplicitSize = props.cols !== undefined || props.rows !== undefined;
|
|
16
|
-
if (!hasExplicitSize) {
|
|
17
|
-
return { fit: "container", size: DEFAULT_SIZE };
|
|
18
|
-
}
|
|
19
|
-
return {
|
|
20
|
-
fit: "none",
|
|
21
|
-
size: {
|
|
22
|
-
cols: Math.max(1, props.cols ?? DEFAULT_SIZE.cols),
|
|
23
|
-
rows: Math.max(1, props.rows ?? DEFAULT_SIZE.rows),
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
let warnedLegacyProps = false;
|
|
28
|
-
export function warnLegacyPropsOnce(usedLegacyProps) {
|
|
29
|
-
if (!usedLegacyProps || warnedLegacyProps)
|
|
30
|
-
return;
|
|
31
|
-
warnedLegacyProps = true;
|
|
32
|
-
console.warn("[tui-preview] Legacy props (`app`, `args`, `cols`, `rows`, `fontSize`, `fontFamily`, `theme`) are deprecated. " +
|
|
33
|
-
"Use `wasm`, `argv`, `fit`, `size`, and `terminal`.");
|
|
34
|
-
}
|
|
35
17
|
export function resolveTuiPreviewProps(props) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
: {
|
|
44
|
-
cols: Math.max(1, props.size?.cols ?? DEFAULT_SIZE.cols),
|
|
45
|
-
rows: Math.max(1, props.size?.rows ?? DEFAULT_SIZE.rows),
|
|
46
|
-
};
|
|
47
|
-
const mode = (props.mode ?? "terminal");
|
|
48
|
-
return {
|
|
49
|
-
wasm: props.wasm,
|
|
50
|
-
env: props.env ?? EMPTY_ENV,
|
|
51
|
-
interactive: mode === "static" ? false : (props.interactive ?? true),
|
|
52
|
-
mode,
|
|
53
|
-
fit,
|
|
54
|
-
size,
|
|
55
|
-
terminal: {
|
|
56
|
-
fontSize: props.terminal?.fontSize ?? 14,
|
|
57
|
-
fontFamily: props.terminal?.fontFamily ?? "monospace",
|
|
58
|
-
cursorBlink: props.terminal?.cursorBlink ?? true,
|
|
59
|
-
convertEol: props.terminal?.convertEol ?? true,
|
|
60
|
-
theme: props.terminal?.theme,
|
|
61
|
-
},
|
|
62
|
-
resolveArgv: resolveArgvInput(props.argv),
|
|
63
|
-
onExit: props.onExit,
|
|
64
|
-
onError: props.onError,
|
|
65
|
-
onStatusChange: props.onStatusChange,
|
|
66
|
-
usedLegacyProps: false,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
const { fit, size } = resolveLegacySize(props);
|
|
18
|
+
const fit = props.fit ?? (props.size ? "none" : "container");
|
|
19
|
+
const size = {
|
|
20
|
+
cols: Math.max(1, props.size?.cols ?? DEFAULT_SIZE.cols),
|
|
21
|
+
rows: Math.max(1, props.size?.rows ?? DEFAULT_SIZE.rows),
|
|
22
|
+
};
|
|
23
|
+
const mode = normalizeMode(props.mode);
|
|
70
24
|
return {
|
|
71
|
-
wasm: props.
|
|
25
|
+
wasm: props.wasm,
|
|
72
26
|
env: props.env ?? EMPTY_ENV,
|
|
73
|
-
interactive: props.interactive ?? true,
|
|
74
|
-
mode
|
|
27
|
+
interactive: mode === "static" ? false : (props.interactive ?? true),
|
|
28
|
+
mode,
|
|
75
29
|
fit,
|
|
76
30
|
size,
|
|
77
31
|
terminal: {
|
|
78
|
-
fontSize: props.fontSize ?? 14,
|
|
79
|
-
fontFamily: props.fontFamily ?? "monospace",
|
|
80
|
-
|
|
81
|
-
convertEol: true,
|
|
82
|
-
theme: props.theme,
|
|
32
|
+
fontSize: props.terminal?.fontSize ?? 14,
|
|
33
|
+
fontFamily: props.terminal?.fontFamily ?? "monospace",
|
|
34
|
+
wasmUrl: props.terminal?.wasmUrl,
|
|
35
|
+
convertEol: props.terminal?.convertEol ?? true,
|
|
36
|
+
theme: props.terminal?.theme,
|
|
83
37
|
},
|
|
84
|
-
resolveArgv: resolveArgvInput(props.
|
|
38
|
+
resolveArgv: resolveArgvInput(props.argv),
|
|
85
39
|
onExit: props.onExit,
|
|
86
40
|
onError: props.onError,
|
|
87
41
|
onStatusChange: props.onStatusChange,
|
|
88
|
-
usedLegacyProps: true,
|
|
89
42
|
};
|
|
90
43
|
}
|
package/dist/core/wasi.d.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WASI bridge — connects a wasm32-wasi TUI app to a
|
|
2
|
+
* WASI bridge — connects a wasm32-wasi TUI app to a terminal surface.
|
|
3
3
|
*
|
|
4
4
|
* Implements a minimal WASI preview1 surface sufficient for interactive TUI apps:
|
|
5
5
|
* - fd_write (stdout/stderr → terminal)
|
|
6
6
|
* - fd_read (stdin ← keyboard input queue)
|
|
7
|
-
* - poll_oneoff (
|
|
7
|
+
* - poll_oneoff (suspending stdin wait via JSPI, needed by crossterm/ratatui)
|
|
8
8
|
* - proc_exit
|
|
9
9
|
* - environ_get / environ_sizes_get
|
|
10
10
|
* - args_get / args_sizes_get
|
|
11
11
|
*/
|
|
12
12
|
import type { WasiOptions } from "../types.js";
|
|
13
|
+
/** Feature-detect JSPI (WebAssembly.Suspending / WebAssembly.promising). */
|
|
14
|
+
export declare const hasJSPI: boolean;
|
|
13
15
|
export declare class WasiBridge {
|
|
14
16
|
private opts;
|
|
15
17
|
private inputQueue;
|
|
16
18
|
private memory;
|
|
19
|
+
/** Resolvers waiting for input to arrive (used by suspending poll_oneoff). */
|
|
20
|
+
private inputWaiters;
|
|
17
21
|
constructor(opts: WasiOptions);
|
|
18
22
|
/** Push keyboard data from the terminal into the app's stdin */
|
|
19
23
|
pushInput(data: string | Uint8Array): void;
|
|
@@ -21,6 +25,8 @@ export declare class WasiBridge {
|
|
|
21
25
|
attachMemory(memory: WebAssembly.Memory): void;
|
|
22
26
|
private view;
|
|
23
27
|
private u8;
|
|
28
|
+
/** Wait until the input queue has data, or until the given timeout (ms). */
|
|
29
|
+
private waitForInput;
|
|
24
30
|
get imports(): WebAssembly.ModuleImports;
|
|
25
31
|
private envEntries;
|
|
26
32
|
}
|
package/dist/core/wasi.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WASI bridge — connects a wasm32-wasi TUI app to a
|
|
2
|
+
* WASI bridge — connects a wasm32-wasi TUI app to a terminal surface.
|
|
3
3
|
*
|
|
4
4
|
* Implements a minimal WASI preview1 surface sufficient for interactive TUI apps:
|
|
5
5
|
* - fd_write (stdout/stderr → terminal)
|
|
6
6
|
* - fd_read (stdin ← keyboard input queue)
|
|
7
|
-
* - poll_oneoff (
|
|
7
|
+
* - poll_oneoff (suspending stdin wait via JSPI, needed by crossterm/ratatui)
|
|
8
8
|
* - proc_exit
|
|
9
9
|
* - environ_get / environ_sizes_get
|
|
10
10
|
* - args_get / args_sizes_get
|
|
@@ -12,13 +12,20 @@
|
|
|
12
12
|
const WASI_ESUCCESS = 0;
|
|
13
13
|
const WASI_EAGAIN = 6;
|
|
14
14
|
const WASI_BADF = 8;
|
|
15
|
+
const WASI_EVENTTYPE_CLOCK = 0;
|
|
16
|
+
const WASI_EVENTTYPE_FD_READ = 1;
|
|
15
17
|
const STDIN_FD = 0;
|
|
16
18
|
const STDOUT_FD = 1;
|
|
17
19
|
const STDERR_FD = 2;
|
|
20
|
+
/** Feature-detect JSPI (WebAssembly.Suspending / WebAssembly.promising). */
|
|
21
|
+
export const hasJSPI = typeof WebAssembly.Suspending === "function" &&
|
|
22
|
+
typeof WebAssembly.promising === "function";
|
|
18
23
|
export class WasiBridge {
|
|
19
24
|
opts;
|
|
20
25
|
inputQueue = [];
|
|
21
26
|
memory;
|
|
27
|
+
/** Resolvers waiting for input to arrive (used by suspending poll_oneoff). */
|
|
28
|
+
inputWaiters = [];
|
|
22
29
|
constructor(opts) {
|
|
23
30
|
this.opts = opts;
|
|
24
31
|
}
|
|
@@ -26,6 +33,10 @@ export class WasiBridge {
|
|
|
26
33
|
pushInput(data) {
|
|
27
34
|
const chunk = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
28
35
|
this.inputQueue.push(chunk);
|
|
36
|
+
// Wake any suspended poll_oneoff calls.
|
|
37
|
+
for (const resolve of this.inputWaiters.splice(0)) {
|
|
38
|
+
resolve();
|
|
39
|
+
}
|
|
29
40
|
}
|
|
30
41
|
/** Attach the WASM instance's memory after instantiation */
|
|
31
42
|
attachMemory(memory) {
|
|
@@ -37,6 +48,24 @@ export class WasiBridge {
|
|
|
37
48
|
u8() {
|
|
38
49
|
return new Uint8Array(this.memory.buffer);
|
|
39
50
|
}
|
|
51
|
+
/** Wait until the input queue has data, or until the given timeout (ms). */
|
|
52
|
+
waitForInput(timeoutMs) {
|
|
53
|
+
if (this.inputQueue.length > 0)
|
|
54
|
+
return Promise.resolve();
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const waiter = () => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
resolve();
|
|
59
|
+
};
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
const idx = this.inputWaiters.indexOf(waiter);
|
|
62
|
+
if (idx >= 0)
|
|
63
|
+
this.inputWaiters.splice(idx, 1);
|
|
64
|
+
resolve();
|
|
65
|
+
}, timeoutMs);
|
|
66
|
+
this.inputWaiters.push(waiter);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
40
69
|
// ── WASI imports object ────────────────────────────────────────────────
|
|
41
70
|
get imports() {
|
|
42
71
|
return {
|
|
@@ -115,7 +144,7 @@ export class WasiBridge {
|
|
|
115
144
|
fd_read: (fd, iovsPtr, iovsLen, nreadPtr) => {
|
|
116
145
|
if (fd !== STDIN_FD)
|
|
117
146
|
return WASI_BADF;
|
|
118
|
-
const chunk = this.inputQueue
|
|
147
|
+
const chunk = this.inputQueue[0];
|
|
119
148
|
if (!chunk)
|
|
120
149
|
return WASI_EAGAIN;
|
|
121
150
|
const view = this.view();
|
|
@@ -128,26 +157,91 @@ export class WasiBridge {
|
|
|
128
157
|
u8.set(chunk.subarray(nread, nread + toCopy), ptr);
|
|
129
158
|
nread += toCopy;
|
|
130
159
|
}
|
|
160
|
+
if (nread >= chunk.length) {
|
|
161
|
+
this.inputQueue.shift();
|
|
162
|
+
}
|
|
163
|
+
else if (nread > 0) {
|
|
164
|
+
this.inputQueue[0] = chunk.subarray(nread);
|
|
165
|
+
}
|
|
131
166
|
view.setUint32(nreadPtr, nread, true);
|
|
132
167
|
return WASI_ESUCCESS;
|
|
133
168
|
},
|
|
134
|
-
poll_oneoff
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
169
|
+
// poll_oneoff — the critical syscall for event-loop TUI apps.
|
|
170
|
+
//
|
|
171
|
+
// With JSPI: wrapped as a WebAssembly.Suspending import so the WASM
|
|
172
|
+
// instance suspends while we await input, yielding to the browser
|
|
173
|
+
// event loop. This is what allows keyboard events to arrive.
|
|
174
|
+
//
|
|
175
|
+
// Without JSPI: synchronous non-blocking check (legacy/replay mode).
|
|
176
|
+
poll_oneoff: hasJSPI
|
|
177
|
+
? async (inPtr, outPtr, nsubscriptions, neventsPtr) => {
|
|
178
|
+
const view = this.view();
|
|
179
|
+
// Check if any FD_READ subscription can be satisfied immediately.
|
|
180
|
+
let hasFdRead = false;
|
|
181
|
+
let clockTimeoutNs = -1n;
|
|
182
|
+
for (let i = 0; i < nsubscriptions; i++) {
|
|
183
|
+
const subPtr = inPtr + i * 48;
|
|
184
|
+
const type = view.getUint8(subPtr + 8);
|
|
185
|
+
if (type === WASI_EVENTTYPE_FD_READ) {
|
|
186
|
+
hasFdRead = true;
|
|
187
|
+
}
|
|
188
|
+
if (type === WASI_EVENTTYPE_CLOCK) {
|
|
189
|
+
const timeout = view.getBigUint64(subPtr + 24, true);
|
|
190
|
+
if (clockTimeoutNs < 0n || timeout < clockTimeoutNs) {
|
|
191
|
+
clockTimeoutNs = timeout;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// If there's an FD_READ subscription and no data yet, suspend.
|
|
196
|
+
if (hasFdRead && this.inputQueue.length === 0) {
|
|
197
|
+
const timeoutMs = clockTimeoutNs >= 0n
|
|
198
|
+
? Number(clockTimeoutNs / 1000000n)
|
|
199
|
+
: 60_000;
|
|
200
|
+
await this.waitForInput(Math.max(1, Math.min(timeoutMs, 60_000)));
|
|
146
201
|
}
|
|
202
|
+
// Now fill out the events.
|
|
203
|
+
let nevents = 0;
|
|
204
|
+
for (let i = 0; i < nsubscriptions; i++) {
|
|
205
|
+
const subPtr = inPtr + i * 48;
|
|
206
|
+
const type = view.getUint8(subPtr + 8);
|
|
207
|
+
const userdata = view.getBigUint64(subPtr, true);
|
|
208
|
+
if (type === WASI_EVENTTYPE_FD_READ && this.inputQueue.length > 0) {
|
|
209
|
+
const evPtr = outPtr + nevents * 32;
|
|
210
|
+
view.setBigUint64(evPtr, userdata, true);
|
|
211
|
+
view.setUint16(evPtr + 8, 0, true); // error = 0
|
|
212
|
+
view.setUint8(evPtr + 10, type);
|
|
213
|
+
nevents++;
|
|
214
|
+
}
|
|
215
|
+
else if (type === WASI_EVENTTYPE_CLOCK) {
|
|
216
|
+
// Clock subscriptions always fire (we already waited).
|
|
217
|
+
const evPtr = outPtr + nevents * 32;
|
|
218
|
+
view.setBigUint64(evPtr, userdata, true);
|
|
219
|
+
view.setUint16(evPtr + 8, 0, true);
|
|
220
|
+
view.setUint8(evPtr + 10, type);
|
|
221
|
+
nevents++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
view.setUint32(neventsPtr, nevents, true);
|
|
225
|
+
return WASI_ESUCCESS;
|
|
147
226
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
227
|
+
: (inPtr, outPtr, nsubscriptions, neventsPtr) => {
|
|
228
|
+
// Fallback: synchronous non-blocking check (no JSPI).
|
|
229
|
+
const view = this.view();
|
|
230
|
+
let nevents = 0;
|
|
231
|
+
for (let i = 0; i < nsubscriptions; i++) {
|
|
232
|
+
const subPtr = inPtr + i * 48;
|
|
233
|
+
const type = view.getUint8(subPtr + 8);
|
|
234
|
+
if (type === WASI_EVENTTYPE_FD_READ && this.inputQueue.length > 0) {
|
|
235
|
+
const evPtr = outPtr + nevents * 32;
|
|
236
|
+
view.setBigUint64(evPtr, view.getBigUint64(subPtr, true), true);
|
|
237
|
+
view.setUint16(evPtr + 8, 0, true);
|
|
238
|
+
view.setUint8(evPtr + 10, type);
|
|
239
|
+
nevents++;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
view.setUint32(neventsPtr, nevents, true);
|
|
243
|
+
return WASI_ESUCCESS;
|
|
244
|
+
},
|
|
151
245
|
proc_exit: (code) => {
|
|
152
246
|
this.opts.onExit(code);
|
|
153
247
|
throw new WasiExitError(code);
|
|
@@ -199,22 +293,37 @@ export async function instantiateApp(source, bridge) {
|
|
|
199
293
|
let module = moduleCache.get(key);
|
|
200
294
|
if (!module) {
|
|
201
295
|
const response = await fetch(source);
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
throw new Error(`Failed to load app wasm: ${response.status} ${response.statusText}`);
|
|
298
|
+
}
|
|
202
299
|
const bytes = await response.arrayBuffer();
|
|
300
|
+
if (bytes.byteLength === 0) {
|
|
301
|
+
throw new Error("App wasm is empty.");
|
|
302
|
+
}
|
|
203
303
|
module = await WebAssembly.compile(bytes);
|
|
204
304
|
moduleCache.set(key, module);
|
|
205
305
|
}
|
|
306
|
+
const wasiImports = bridge.imports;
|
|
307
|
+
if (hasJSPI) {
|
|
308
|
+
wasiImports.poll_oneoff = new WebAssembly.Suspending(wasiImports.poll_oneoff);
|
|
309
|
+
}
|
|
206
310
|
const importObject = {
|
|
207
|
-
wasi_snapshot_preview1:
|
|
311
|
+
wasi_snapshot_preview1: wasiImports,
|
|
208
312
|
};
|
|
209
313
|
const instance = await WebAssembly.instantiate(module, importObject);
|
|
210
314
|
bridge.attachMemory(instance.exports.memory);
|
|
211
|
-
const
|
|
212
|
-
if (!
|
|
315
|
+
const rawStart = instance.exports._start;
|
|
316
|
+
if (!rawStart)
|
|
213
317
|
throw new Error("WASM module has no _start export");
|
|
318
|
+
// With JSPI, wrap _start so it returns a Promise that resolves when the
|
|
319
|
+
// WASM app finishes (or suspends on poll_oneoff and resumes later).
|
|
320
|
+
const _start = hasJSPI
|
|
321
|
+
? WebAssembly.promising(rawStart)
|
|
322
|
+
: rawStart;
|
|
214
323
|
return {
|
|
215
324
|
run: async () => {
|
|
216
325
|
try {
|
|
217
|
-
_start();
|
|
326
|
+
await _start();
|
|
218
327
|
}
|
|
219
328
|
catch (e) {
|
|
220
329
|
if (!(e instanceof WasiExitError))
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { TuiPreview } from "./TuiPreview.js";
|
|
2
|
-
export type { GhosttyTheme, ResolvedTuiPreviewOptions, TuiArgv, TuiFitMode, TuiRenderMode, TuiPreviewCommonProps,
|
|
3
|
-
export { WasiBridge, WasiExitError, instantiateApp } from "./wasi.js";
|
|
2
|
+
export type { GhosttyTheme, ResolvedTuiPreviewOptions, TuiArgv, TuiFitMode, TuiRenderMode, TuiPreviewCommonProps, TuiPreviewModernProps, TuiPreviewProps, TuiPreviewStatus, TuiRuntimeSize, TuiTerminalOptions, WasiOptions, } from "./types.js";
|
|
3
|
+
export { WasiBridge, WasiExitError, instantiateApp } from "./core/wasi.js";
|