@dkkoval/tui-preview 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/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/TuiPreview.d.ts +2 -0
- package/dist/TuiPreview.js +257 -0
- package/dist/__vite-browser-external-2447137e-BcPniuRQ.cjs +1 -0
- package/dist/__vite-browser-external-2447137e-DYxpcVy9.js +4 -0
- package/dist/core/ansi.d.ts +15 -0
- package/dist/core/ansi.js +181 -0
- package/dist/core/ghostty.d.ts +2 -0
- package/dist/core/ghostty.js +11 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +4 -0
- package/dist/core/normalize.d.ts +3 -0
- package/dist/core/normalize.js +89 -0
- package/dist/core/wasi.d.ts +34 -0
- package/dist/core/wasi.js +218 -0
- package/dist/ghostty-web-BfBVpf8G.js +2962 -0
- package/dist/ghostty-web-DkOZu5AZ.cjs +13 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +515 -0
- package/dist/types.d.ts +113 -0
- package/dist/types.js +1 -0
- package/dist/wasi.d.ts +1 -0
- package/dist/wasi.js +2 -0
- package/package.json +51 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const DEFAULT_SIZE = { cols: 80, rows: 24 };
|
|
2
|
+
const EMPTY_ENV = {};
|
|
3
|
+
const EMPTY_ARGV = [];
|
|
4
|
+
function isModernProps(props) {
|
|
5
|
+
return "wasm" in props;
|
|
6
|
+
}
|
|
7
|
+
function resolveArgvInput(argv) {
|
|
8
|
+
const value = argv ?? EMPTY_ARGV;
|
|
9
|
+
if (typeof value === "function") {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
return () => value;
|
|
13
|
+
}
|
|
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
|
+
export function resolveTuiPreviewProps(props) {
|
|
36
|
+
if (isModernProps(props)) {
|
|
37
|
+
const fit = props.fit ?? (props.size ? "none" : "container");
|
|
38
|
+
const size = fit === "none"
|
|
39
|
+
? {
|
|
40
|
+
cols: Math.max(1, props.size?.cols ?? DEFAULT_SIZE.cols),
|
|
41
|
+
rows: Math.max(1, props.size?.rows ?? DEFAULT_SIZE.rows),
|
|
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
|
+
return {
|
|
48
|
+
wasm: props.wasm,
|
|
49
|
+
env: props.env ?? EMPTY_ENV,
|
|
50
|
+
interactive: props.interactive ?? true,
|
|
51
|
+
mode: (props.mode ?? "terminal"),
|
|
52
|
+
fit,
|
|
53
|
+
size,
|
|
54
|
+
terminal: {
|
|
55
|
+
fontSize: props.terminal?.fontSize ?? 14,
|
|
56
|
+
fontFamily: props.terminal?.fontFamily ?? "monospace",
|
|
57
|
+
cursorBlink: props.terminal?.cursorBlink ?? true,
|
|
58
|
+
convertEol: props.terminal?.convertEol ?? true,
|
|
59
|
+
theme: props.terminal?.theme,
|
|
60
|
+
},
|
|
61
|
+
resolveArgv: resolveArgvInput(props.argv),
|
|
62
|
+
onExit: props.onExit,
|
|
63
|
+
onError: props.onError,
|
|
64
|
+
onStatusChange: props.onStatusChange,
|
|
65
|
+
usedLegacyProps: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const { fit, size } = resolveLegacySize(props);
|
|
69
|
+
return {
|
|
70
|
+
wasm: props.app,
|
|
71
|
+
env: props.env ?? EMPTY_ENV,
|
|
72
|
+
interactive: props.interactive ?? true,
|
|
73
|
+
mode: "terminal",
|
|
74
|
+
fit,
|
|
75
|
+
size,
|
|
76
|
+
terminal: {
|
|
77
|
+
fontSize: props.fontSize ?? 14,
|
|
78
|
+
fontFamily: props.fontFamily ?? "monospace",
|
|
79
|
+
cursorBlink: true,
|
|
80
|
+
convertEol: true,
|
|
81
|
+
theme: props.theme,
|
|
82
|
+
},
|
|
83
|
+
resolveArgv: resolveArgvInput(props.args),
|
|
84
|
+
onExit: props.onExit,
|
|
85
|
+
onError: props.onError,
|
|
86
|
+
onStatusChange: props.onStatusChange,
|
|
87
|
+
usedLegacyProps: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WASI bridge — connects a wasm32-wasi TUI app to a ghostty-web terminal.
|
|
3
|
+
*
|
|
4
|
+
* Implements a minimal WASI preview1 surface sufficient for interactive TUI apps:
|
|
5
|
+
* - fd_write (stdout/stderr → terminal)
|
|
6
|
+
* - fd_read (stdin ← keyboard input queue)
|
|
7
|
+
* - poll_oneoff (non-blocking stdin check, needed by crossterm/ratatui)
|
|
8
|
+
* - proc_exit
|
|
9
|
+
* - environ_get / environ_sizes_get
|
|
10
|
+
* - args_get / args_sizes_get
|
|
11
|
+
*/
|
|
12
|
+
import type { WasiOptions } from "../types.js";
|
|
13
|
+
export declare class WasiBridge {
|
|
14
|
+
private opts;
|
|
15
|
+
private inputQueue;
|
|
16
|
+
private memory;
|
|
17
|
+
constructor(opts: WasiOptions);
|
|
18
|
+
/** Push keyboard data from the terminal into the app's stdin */
|
|
19
|
+
pushInput(data: string | Uint8Array): void;
|
|
20
|
+
/** Attach the WASM instance's memory after instantiation */
|
|
21
|
+
attachMemory(memory: WebAssembly.Memory): void;
|
|
22
|
+
private view;
|
|
23
|
+
private u8;
|
|
24
|
+
get imports(): WebAssembly.ModuleImports;
|
|
25
|
+
private envEntries;
|
|
26
|
+
}
|
|
27
|
+
export declare class WasiExitError extends Error {
|
|
28
|
+
readonly code: number;
|
|
29
|
+
constructor(code: number);
|
|
30
|
+
}
|
|
31
|
+
/** Load and instantiate a WASM TUI app with a WasiBridge */
|
|
32
|
+
export declare function instantiateApp(source: string | URL, bridge: WasiBridge): Promise<{
|
|
33
|
+
run: () => Promise<void>;
|
|
34
|
+
}>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WASI bridge — connects a wasm32-wasi TUI app to a ghostty-web terminal.
|
|
3
|
+
*
|
|
4
|
+
* Implements a minimal WASI preview1 surface sufficient for interactive TUI apps:
|
|
5
|
+
* - fd_write (stdout/stderr → terminal)
|
|
6
|
+
* - fd_read (stdin ← keyboard input queue)
|
|
7
|
+
* - poll_oneoff (non-blocking stdin check, needed by crossterm/ratatui)
|
|
8
|
+
* - proc_exit
|
|
9
|
+
* - environ_get / environ_sizes_get
|
|
10
|
+
* - args_get / args_sizes_get
|
|
11
|
+
*/
|
|
12
|
+
const WASI_ESUCCESS = 0;
|
|
13
|
+
const WASI_EAGAIN = 6;
|
|
14
|
+
const WASI_BADF = 8;
|
|
15
|
+
const STDIN_FD = 0;
|
|
16
|
+
const STDOUT_FD = 1;
|
|
17
|
+
const STDERR_FD = 2;
|
|
18
|
+
export class WasiBridge {
|
|
19
|
+
opts;
|
|
20
|
+
inputQueue = [];
|
|
21
|
+
memory;
|
|
22
|
+
constructor(opts) {
|
|
23
|
+
this.opts = opts;
|
|
24
|
+
}
|
|
25
|
+
/** Push keyboard data from the terminal into the app's stdin */
|
|
26
|
+
pushInput(data) {
|
|
27
|
+
const chunk = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
28
|
+
this.inputQueue.push(chunk);
|
|
29
|
+
}
|
|
30
|
+
/** Attach the WASM instance's memory after instantiation */
|
|
31
|
+
attachMemory(memory) {
|
|
32
|
+
this.memory = memory;
|
|
33
|
+
}
|
|
34
|
+
view() {
|
|
35
|
+
return new DataView(this.memory.buffer);
|
|
36
|
+
}
|
|
37
|
+
u8() {
|
|
38
|
+
return new Uint8Array(this.memory.buffer);
|
|
39
|
+
}
|
|
40
|
+
// ── WASI imports object ────────────────────────────────────────────────
|
|
41
|
+
get imports() {
|
|
42
|
+
return {
|
|
43
|
+
args_sizes_get: (argcPtr, argvBufSizePtr) => {
|
|
44
|
+
const { args } = this.opts;
|
|
45
|
+
const enc = new TextEncoder();
|
|
46
|
+
const total = args.reduce((sum, arg) => sum + enc.encode(arg).length + 1, 0);
|
|
47
|
+
this.view().setUint32(argcPtr, args.length, true);
|
|
48
|
+
this.view().setUint32(argvBufSizePtr, total, true);
|
|
49
|
+
return WASI_ESUCCESS;
|
|
50
|
+
},
|
|
51
|
+
args_get: (argvPtr, argvBufPtr) => {
|
|
52
|
+
const enc = new TextEncoder();
|
|
53
|
+
const u8 = this.u8();
|
|
54
|
+
const view = this.view();
|
|
55
|
+
let bufOffset = argvBufPtr;
|
|
56
|
+
this.opts.args.forEach((arg, i) => {
|
|
57
|
+
const bytes = enc.encode(arg);
|
|
58
|
+
u8.set(bytes, bufOffset);
|
|
59
|
+
u8[bufOffset + bytes.length] = 0;
|
|
60
|
+
view.setUint32(argvPtr + i * 4, bufOffset, true);
|
|
61
|
+
bufOffset += bytes.length + 1;
|
|
62
|
+
});
|
|
63
|
+
return WASI_ESUCCESS;
|
|
64
|
+
},
|
|
65
|
+
environ_sizes_get: (countPtr, bufSizePtr) => {
|
|
66
|
+
const entries = this.envEntries();
|
|
67
|
+
const enc = new TextEncoder();
|
|
68
|
+
const total = entries.reduce((sum, entry) => sum + enc.encode(entry).length + 1, 0);
|
|
69
|
+
this.view().setUint32(countPtr, entries.length, true);
|
|
70
|
+
this.view().setUint32(bufSizePtr, total, true);
|
|
71
|
+
return WASI_ESUCCESS;
|
|
72
|
+
},
|
|
73
|
+
environ_get: (environPtr, environBufPtr) => {
|
|
74
|
+
const enc = new TextEncoder();
|
|
75
|
+
const u8 = this.u8();
|
|
76
|
+
const view = this.view();
|
|
77
|
+
let bufOffset = environBufPtr;
|
|
78
|
+
this.envEntries().forEach((entry, i) => {
|
|
79
|
+
const bytes = enc.encode(entry);
|
|
80
|
+
u8.set(bytes, bufOffset);
|
|
81
|
+
u8[bufOffset + bytes.length] = 0;
|
|
82
|
+
view.setUint32(environPtr + i * 4, bufOffset, true);
|
|
83
|
+
bufOffset += bytes.length + 1;
|
|
84
|
+
});
|
|
85
|
+
return WASI_ESUCCESS;
|
|
86
|
+
},
|
|
87
|
+
fd_write: (fd, iovsPtr, iovsLen, nwrittenPtr) => {
|
|
88
|
+
if (fd !== STDOUT_FD && fd !== STDERR_FD)
|
|
89
|
+
return WASI_BADF;
|
|
90
|
+
const view = this.view();
|
|
91
|
+
const u8 = this.u8();
|
|
92
|
+
let nwritten = 0;
|
|
93
|
+
const chunks = [];
|
|
94
|
+
for (let i = 0; i < iovsLen; i++) {
|
|
95
|
+
const ptr = view.getUint32(iovsPtr + i * 8, true);
|
|
96
|
+
const len = view.getUint32(iovsPtr + i * 8 + 4, true);
|
|
97
|
+
chunks.push(u8.slice(ptr, ptr + len));
|
|
98
|
+
nwritten += len;
|
|
99
|
+
}
|
|
100
|
+
const merged = new Uint8Array(nwritten);
|
|
101
|
+
let offset = 0;
|
|
102
|
+
for (const chunk of chunks) {
|
|
103
|
+
merged.set(chunk, offset);
|
|
104
|
+
offset += chunk.length;
|
|
105
|
+
}
|
|
106
|
+
if (fd === STDOUT_FD) {
|
|
107
|
+
this.opts.stdout(merged);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
this.opts.stderr(merged);
|
|
111
|
+
}
|
|
112
|
+
view.setUint32(nwrittenPtr, nwritten, true);
|
|
113
|
+
return WASI_ESUCCESS;
|
|
114
|
+
},
|
|
115
|
+
fd_read: (fd, iovsPtr, iovsLen, nreadPtr) => {
|
|
116
|
+
if (fd !== STDIN_FD)
|
|
117
|
+
return WASI_BADF;
|
|
118
|
+
const chunk = this.inputQueue.shift();
|
|
119
|
+
if (!chunk)
|
|
120
|
+
return WASI_EAGAIN;
|
|
121
|
+
const view = this.view();
|
|
122
|
+
const u8 = this.u8();
|
|
123
|
+
let nread = 0;
|
|
124
|
+
for (let i = 0; i < iovsLen && nread < chunk.length; i++) {
|
|
125
|
+
const ptr = view.getUint32(iovsPtr + i * 8, true);
|
|
126
|
+
const len = view.getUint32(iovsPtr + i * 8 + 4, true);
|
|
127
|
+
const toCopy = Math.min(len, chunk.length - nread);
|
|
128
|
+
u8.set(chunk.subarray(nread, nread + toCopy), ptr);
|
|
129
|
+
nread += toCopy;
|
|
130
|
+
}
|
|
131
|
+
view.setUint32(nreadPtr, nread, true);
|
|
132
|
+
return WASI_ESUCCESS;
|
|
133
|
+
},
|
|
134
|
+
poll_oneoff: (inPtr, outPtr, nsubscriptions, neventsPtr) => {
|
|
135
|
+
const view = this.view();
|
|
136
|
+
let nevents = 0;
|
|
137
|
+
for (let i = 0; i < nsubscriptions; i++) {
|
|
138
|
+
const subPtr = inPtr + i * 48;
|
|
139
|
+
const type = view.getUint8(subPtr + 8);
|
|
140
|
+
if (type === 0 && this.inputQueue.length > 0) {
|
|
141
|
+
const evPtr = outPtr + nevents * 32;
|
|
142
|
+
view.setBigUint64(evPtr, view.getBigUint64(subPtr, true), true);
|
|
143
|
+
view.setUint16(evPtr + 8, 0, true);
|
|
144
|
+
view.setUint8(evPtr + 10, type);
|
|
145
|
+
nevents++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
view.setUint32(neventsPtr, nevents, true);
|
|
149
|
+
return WASI_ESUCCESS;
|
|
150
|
+
},
|
|
151
|
+
proc_exit: (code) => {
|
|
152
|
+
this.opts.onExit(code);
|
|
153
|
+
throw new WasiExitError(code);
|
|
154
|
+
},
|
|
155
|
+
random_get: (bufPtr, bufLen) => {
|
|
156
|
+
crypto.getRandomValues(new Uint8Array(this.memory.buffer, bufPtr, bufLen));
|
|
157
|
+
return WASI_ESUCCESS;
|
|
158
|
+
},
|
|
159
|
+
// Stubs for calls TUI apps may make but we don't need to implement.
|
|
160
|
+
fd_close: () => WASI_ESUCCESS,
|
|
161
|
+
fd_seek: () => WASI_ESUCCESS,
|
|
162
|
+
fd_fdstat_get: (fd, ptr) => {
|
|
163
|
+
const view = this.view();
|
|
164
|
+
view.setUint8(ptr, fd <= 2 ? 2 : 0); // 2=char_device
|
|
165
|
+
return WASI_ESUCCESS;
|
|
166
|
+
},
|
|
167
|
+
fd_prestat_get: () => WASI_BADF,
|
|
168
|
+
fd_prestat_dir_name: () => WASI_BADF,
|
|
169
|
+
path_open: () => WASI_BADF,
|
|
170
|
+
sched_yield: () => WASI_ESUCCESS,
|
|
171
|
+
clock_time_get: (_id, _precision, timePtr) => {
|
|
172
|
+
const ns = BigInt(Date.now()) * 1000000n;
|
|
173
|
+
this.view().setBigUint64(timePtr, ns, true);
|
|
174
|
+
return WASI_ESUCCESS;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
envEntries() {
|
|
179
|
+
const base = {
|
|
180
|
+
TERM: "xterm-256color",
|
|
181
|
+
COLORTERM: "truecolor",
|
|
182
|
+
...this.opts.env,
|
|
183
|
+
};
|
|
184
|
+
return Object.entries(base).map(([k, v]) => `${k}=${v}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export class WasiExitError extends Error {
|
|
188
|
+
code;
|
|
189
|
+
constructor(code) {
|
|
190
|
+
super(`WASI exit: ${code}`);
|
|
191
|
+
this.code = code;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Load and instantiate a WASM TUI app with a WasiBridge */
|
|
195
|
+
export async function instantiateApp(source, bridge) {
|
|
196
|
+
const response = await fetch(source);
|
|
197
|
+
const bytes = await response.arrayBuffer();
|
|
198
|
+
const module = await WebAssembly.compile(bytes);
|
|
199
|
+
const importObject = {
|
|
200
|
+
wasi_snapshot_preview1: bridge.imports,
|
|
201
|
+
};
|
|
202
|
+
const instance = await WebAssembly.instantiate(module, importObject);
|
|
203
|
+
bridge.attachMemory(instance.exports.memory);
|
|
204
|
+
const _start = instance.exports._start;
|
|
205
|
+
if (!_start)
|
|
206
|
+
throw new Error("WASM module has no _start export");
|
|
207
|
+
return {
|
|
208
|
+
run: async () => {
|
|
209
|
+
try {
|
|
210
|
+
_start();
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
if (!(e instanceof WasiExitError))
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|