@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.
@@ -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 isModernProps(props) {
5
- return "wasm" in props;
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
- 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
- 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.app,
25
+ wasm: props.wasm,
72
26
  env: props.env ?? EMPTY_ENV,
73
- interactive: props.interactive ?? true,
74
- mode: "terminal",
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
- cursorBlink: true,
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.args),
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
  }
@@ -1,19 +1,23 @@
1
1
  /**
2
- * WASI bridge — connects a wasm32-wasi TUI app to a ghostty-web terminal.
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 (non-blocking stdin check, needed by crossterm/ratatui)
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 ghostty-web terminal.
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 (non-blocking stdin check, needed by crossterm/ratatui)
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.shift();
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: (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++;
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
- view.setUint32(neventsPtr, nevents, true);
149
- return WASI_ESUCCESS;
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: bridge.imports,
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 _start = instance.exports._start;
212
- if (!_start)
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, TuiPreviewLegacyProps, TuiPreviewModernProps, TuiPreviewProps, TuiPreviewStatus, TuiRuntimeSize, TuiTerminalOptions, WasiOptions, } from "./types.js";
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";