@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 CHANGED
@@ -5,13 +5,27 @@ Render `wasm32-wasi` terminal apps inside React with a clean, size-aware API.
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install tui-preview
8
+ npm install @dkkoval/tui-preview
9
9
  ```
10
10
 
11
+ Build libghostty wasm from the submodule before running the example:
12
+
13
+ ```bash
14
+ npm run build:ghostty-wasm
15
+ ```
16
+
17
+ This builds an external wasm wrapper (`wasm/ghostty-vt/build.zig` + `wasm/ghostty-vt/wrapper.zig`)
18
+ that imports Ghostty sources from `vendor/libghostty` without modifying submodule files.
19
+ The wrapper is intentionally minimal: ANSI parsing + viewport rendering + basic input responses.
20
+
21
+ By default this writes:
22
+ - `example/public/ghostty-vt.wasm` (dev/example)
23
+ - `dist/ghostty-vt.wasm` (library package asset)
24
+
11
25
  ## Modern API (v1)
12
26
 
13
27
  ```tsx
14
- import { TuiPreview } from "tui-preview";
28
+ import { TuiPreview } from "@dkkoval/tui-preview";
15
29
 
16
30
  function Demo() {
17
31
  return (
@@ -48,31 +62,25 @@ function Demo() {
48
62
  - `argv?: string[] | ((size) => string[])`
49
63
  - CLI args (without argv[0]).
50
64
  - For `fit="container"`, size is the fitted terminal size.
65
+ - `mode?: "interactive" | "static"` (default: `"interactive"`)
66
+ - `"interactive"`: keyboard/mouse-enabled terminal surface.
67
+ - `"static"`: non-interactive render surface.
51
68
  - `fit?: "container" | "none"` (default: `"container"`)
52
69
  - `"container"`: auto-size from container.
53
70
  - `"none"`: fixed terminal size from `size`.
54
71
  - `size?: { cols: number; rows: number }`
55
72
  - Required in practice for fixed mode; fallback/initial for container mode.
56
- - `terminal?: { fontSize, fontFamily, theme, cursorBlink, convertEol }`
73
+ - `terminal?: { fontSize, fontFamily, theme, convertEol }`
74
+ - `terminal.wasmUrl?: string | URL` (default: `"/ghostty-vt.wasm"`)
57
75
  - `interactive?: boolean` (default: `true`)
58
76
  - `env?: Record<string, string>`
59
77
  - `onExit?: (code: number) => void`
60
78
  - `onError?: (error: unknown) => void`
61
79
  - `onStatusChange?: ("loading" | "running" | "exited" | "error") => void`
62
80
 
63
- ## Legacy Compatibility
64
-
65
- Legacy props still work:
66
-
67
- - `app`, `args`
68
- - `cols`, `rows`
69
- - `fontSize`, `fontFamily`, `theme`
70
-
71
- They are translated internally to the modern API and emit a one-time deprecation warning.
72
-
73
81
  ## Notes
74
82
 
75
83
  - Package exports:
76
- - `tui-preview` (React component + public types)
77
- - `tui-preview/core` (advanced internals)
78
- - `ghostty-web` is pinned with semver (`^0.4.0`) for predictable behavior.
84
+ - `@dkkoval/tui-preview` (React component + public types)
85
+ - `@dkkoval/tui-preview/core` (advanced internals)
86
+ - libghostty source is tracked as a git submodule at `vendor/libghostty`.
@@ -1,46 +1,56 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
- import { loadGhostty } from "./core/ghostty.js";
4
- import { resolveTuiPreviewProps, warnLegacyPropsOnce } from "./core/normalize.js";
3
+ import { createMiniTerminalSurface, measureCellSize } from "./core/libghostty.js";
4
+ import { resolveTuiPreviewProps } from "./core/normalize.js";
5
5
  import { WasiBridge, instantiateApp } from "./core/wasi.js";
6
6
  export function TuiPreview(props) {
7
7
  const resolved = useMemo(() => resolveTuiPreviewProps(props), [props]);
8
8
  const wrapperRef = useRef(null);
9
9
  const containerRef = useRef(null);
10
- const termRef = useRef(null);
11
10
  const [status, setStatus] = useState("loading");
12
11
  const [errorMsg, setErrorMsg] = useState("");
13
12
  const cellSizeRef = useRef(null);
14
- const [termSize, setTermSize] = useState(resolved.size);
13
+ const [termSize, setTermSize] = useState(resolved.fit === "container" ? null : resolved.size);
15
14
  useEffect(() => {
16
- warnLegacyPropsOnce(resolved.usedLegacyProps);
17
- }, [resolved.usedLegacyProps]);
18
- useEffect(() => {
19
- setTermSize(resolved.size);
15
+ setTermSize(resolved.fit === "container" ? null : resolved.size);
20
16
  }, [resolved.fit, resolved.size.cols, resolved.size.rows]);
21
17
  useEffect(() => {
22
18
  if (resolved.fit !== "container")
23
19
  return;
24
20
  if (!wrapperRef.current)
25
21
  return;
22
+ const wrapper = wrapperRef.current;
23
+ const estimatedCell = measureCellSize(resolved.terminal.fontSize, resolved.terminal.fontFamily);
24
+ const updateFromPixels = (width, height) => {
25
+ const cellW = cellSizeRef.current?.w ?? estimatedCell.w;
26
+ const cellH = cellSizeRef.current?.h ?? estimatedCell.h;
27
+ const newCols = Math.max(1, Math.floor(width / cellW));
28
+ const newRows = Math.max(1, Math.floor(height / cellH));
29
+ setTermSize((prev) => {
30
+ if (prev && prev.cols === newCols && prev.rows === newRows)
31
+ return prev;
32
+ return { cols: newCols, rows: newRows };
33
+ });
34
+ };
35
+ const rect = wrapper.getBoundingClientRect();
36
+ if (rect.width > 0 && rect.height > 0) {
37
+ updateFromPixels(rect.width, rect.height);
38
+ }
26
39
  const observer = new ResizeObserver(([entry]) => {
27
40
  const { width, height } = entry.contentRect;
28
41
  if (width > 0 && height > 0) {
29
- const cellW = cellSizeRef.current?.w ?? resolved.terminal.fontSize * 0.6;
30
- const cellH = cellSizeRef.current?.h ?? resolved.terminal.fontSize * 1.2;
31
- setTermSize({
32
- cols: Math.max(1, Math.floor(width / cellW)),
33
- rows: Math.max(1, Math.floor(height / cellH)),
34
- });
42
+ updateFromPixels(width, height);
35
43
  }
36
44
  });
37
- observer.observe(wrapperRef.current);
45
+ observer.observe(wrapper);
38
46
  return () => observer.disconnect();
39
- }, [resolved.fit, resolved.terminal.fontSize]);
47
+ }, [resolved.fit, resolved.terminal.fontSize, resolved.terminal.fontFamily]);
40
48
  useEffect(() => {
41
49
  if (!termSize || !containerRef.current)
42
50
  return;
43
51
  let cancelled = false;
52
+ let disposeRenderSurface = null;
53
+ let activeBridge = null;
44
54
  const container = containerRef.current;
45
55
  const activeSize = termSize;
46
56
  const setStatusAndNotify = (next) => {
@@ -56,66 +66,72 @@ export function TuiPreview(props) {
56
66
  setErrorMsg("");
57
67
  async function setup() {
58
68
  try {
59
- const ghostty = await loadGhostty();
60
- if (cancelled)
61
- return;
62
- container.innerHTML = "";
63
- const term = new ghostty.Terminal({
69
+ let appCols = activeSize.cols;
70
+ let appRows = activeSize.rows;
71
+ const runOnce = async (surface) => {
72
+ const resolvedArgs = resolved.resolveArgv({ cols: appCols, rows: appRows });
73
+ const stdoutDecoder = new TextDecoder();
74
+ const stderrDecoder = new TextDecoder();
75
+ const flushSurfaceOutput = (data, decoder, bridge) => {
76
+ const decoded = decoder.decode(data, { stream: true });
77
+ if (decoded) {
78
+ surface.write(decoded);
79
+ }
80
+ for (const response of surface.drainResponses()) {
81
+ bridge.pushInput(response);
82
+ }
83
+ };
84
+ let bridge;
85
+ bridge = new WasiBridge({
86
+ args: [resolved.wasm.toString(), ...resolvedArgs],
87
+ env: {
88
+ COLUMNS: String(appCols),
89
+ LINES: String(appRows),
90
+ ...resolved.env,
91
+ },
92
+ stdout: (data) => flushSurfaceOutput(data, stdoutDecoder, bridge),
93
+ stderr: (data) => flushSurfaceOutput(data, stderrDecoder, bridge),
94
+ onExit: (code) => {
95
+ if (!cancelled) {
96
+ setStatusAndNotify("exited");
97
+ resolved.onExit?.(code);
98
+ }
99
+ },
100
+ });
101
+ activeBridge = bridge;
102
+ const wasmApp = await instantiateApp(resolved.wasm, bridge);
103
+ if (cancelled)
104
+ return;
105
+ await wasmApp.run();
106
+ };
107
+ const surface = await createMiniTerminalSurface({
108
+ container,
64
109
  cols: activeSize.cols,
65
110
  rows: activeSize.rows,
66
111
  fontSize: resolved.terminal.fontSize,
67
112
  fontFamily: resolved.terminal.fontFamily,
68
113
  theme: resolved.terminal.theme,
69
- disableStdin: !resolved.interactive,
70
- cursorBlink: resolved.terminal.cursorBlink,
71
114
  convertEol: resolved.terminal.convertEol,
72
- });
73
- termRef.current = term;
74
- term.open(container);
75
- // Static mode: hide cursor via DEC PM — app output only, no cursor chrome.
76
- if (resolved.mode === "static") {
77
- term.write("\x1b[?25l");
78
- }
79
- let appCols = term.cols;
80
- let appRows = term.rows;
81
- if (resolved.fit === "container") {
82
- const fitAddon = new ghostty.FitAddon();
83
- term.loadAddon(fitAddon);
84
- fitAddon.fit();
85
- appCols = term.cols;
86
- appRows = term.rows;
87
- if (wrapperRef.current && appCols > 0 && appRows > 0) {
88
- cellSizeRef.current = {
89
- w: wrapperRef.current.clientWidth / appCols,
90
- h: wrapperRef.current.clientHeight / appRows,
91
- };
92
- }
93
- }
94
- const resolvedArgs = resolved.resolveArgv({ cols: appCols, rows: appRows });
95
- const decoder = new TextDecoder();
96
- const bridge = new WasiBridge({
97
- args: [resolved.wasm.toString(), ...resolvedArgs],
98
- env: resolved.env,
99
- stdout: (data) => term.write(decoder.decode(data)),
100
- stderr: (data) => term.write(decoder.decode(data)),
101
- onExit: (code) => {
102
- if (!cancelled) {
103
- setStatusAndNotify("exited");
104
- resolved.onExit?.(code);
105
- }
115
+ interactive: resolved.mode !== "static" && resolved.interactive,
116
+ showCursor: resolved.mode !== "static",
117
+ wasmUrl: resolved.terminal.wasmUrl,
118
+ onInput: (data) => {
119
+ activeBridge?.pushInput(data);
106
120
  },
107
121
  });
108
- if (resolved.interactive) {
109
- term.onData((data) => bridge.pushInput(data));
110
- }
111
- const wasmApp = await instantiateApp(resolved.wasm, bridge);
112
- if (cancelled)
122
+ if (cancelled) {
123
+ surface.dispose();
113
124
  return;
125
+ }
126
+ disposeRenderSurface = () => surface.dispose();
127
+ appCols = surface.cols;
128
+ appRows = surface.rows;
129
+ cellSizeRef.current = surface.cellSize;
114
130
  setStatusAndNotify("running");
115
131
  queueMicrotask(() => {
116
132
  if (cancelled)
117
133
  return;
118
- void wasmApp.run().catch((runError) => {
134
+ void runOnce(surface).catch((runError) => {
119
135
  if (!cancelled) {
120
136
  setError(runError);
121
137
  }
@@ -131,8 +147,9 @@ export function TuiPreview(props) {
131
147
  setup();
132
148
  return () => {
133
149
  cancelled = true;
134
- termRef.current?.dispose();
135
- termRef.current = null;
150
+ activeBridge = null;
151
+ disposeRenderSurface?.();
152
+ disposeRenderSurface = null;
136
153
  };
137
154
  }, [
138
155
  resolved.mode,
@@ -148,17 +165,23 @@ export function TuiPreview(props) {
148
165
  resolved.terminal.fontSize,
149
166
  resolved.terminal.fontFamily,
150
167
  resolved.terminal.theme,
151
- resolved.terminal.cursorBlink,
168
+ resolved.terminal.wasmUrl,
152
169
  resolved.terminal.convertEol,
153
170
  ]);
154
171
  return (_jsxs("div", { ref: wrapperRef, className: props.className, style: {
155
172
  position: "relative",
156
- display: "inline-block",
173
+ display: resolved.fit === "container" ? "block" : "inline-block",
157
174
  background: resolved.terminal.theme?.background ?? "#1a1b26",
158
175
  borderRadius: 6,
159
176
  overflow: "hidden",
160
177
  ...props.style,
161
- }, children: [_jsx("div", { ref: containerRef, style: { display: status === "error" ? "none" : undefined } }), status === "loading" && (_jsx("div", { style: overlayStyle, children: "Loading\u2026" })), status === "error" && (_jsxs("div", { style: { ...overlayStyle, color: "#f7768e" }, children: ["Error: ", errorMsg] }))] }));
178
+ }, children: [_jsx("div", { ref: containerRef, style: {
179
+ display: status === "error" ? "none" : "flex",
180
+ justifyContent: "center",
181
+ alignItems: "center",
182
+ width: "100%",
183
+ height: "100%",
184
+ } }), status === "loading" && _jsx("div", { style: overlayStyle, children: "Loading\u2026" }), status === "error" && (_jsxs("div", { style: { ...overlayStyle, color: "#f7768e" }, children: ["Error: ", errorMsg] }))] }));
162
185
  }
163
186
  const overlayStyle = {
164
187
  padding: "1rem",
Binary file
@@ -1,3 +1,3 @@
1
- export { loadGhostty } from "./ghostty.js";
2
- export { resolveTuiPreviewProps, warnLegacyPropsOnce } from "./normalize.js";
1
+ export { createMiniTerminalSurface, loadLibGhostty, measureCellSize } from "./libghostty.js";
2
+ export { resolveTuiPreviewProps } from "./normalize.js";
3
3
  export { WasiBridge, WasiExitError, instantiateApp } from "./wasi.js";
@@ -1,3 +1,3 @@
1
- export { loadGhostty } from "./ghostty.js";
2
- export { resolveTuiPreviewProps, warnLegacyPropsOnce } from "./normalize.js";
1
+ export { createMiniTerminalSurface, loadLibGhostty, measureCellSize } from "./libghostty.js";
2
+ export { resolveTuiPreviewProps } from "./normalize.js";
3
3
  export { WasiBridge, WasiExitError, instantiateApp } from "./wasi.js";
@@ -0,0 +1,86 @@
1
+ import type { GhosttyTheme } from "../types.js";
2
+ interface LibGhosttyExports extends WebAssembly.Exports {
3
+ memory: WebAssembly.Memory;
4
+ ghostty_wasm_alloc_u8_array(len: number): number;
5
+ ghostty_wasm_free_u8_array(ptr: number, len: number): void;
6
+ ghostty_terminal_new(cols: number, rows: number): number;
7
+ ghostty_terminal_new_with_config(cols: number, rows: number, configPtr: number): number;
8
+ ghostty_terminal_free(handle: number): void;
9
+ ghostty_terminal_resize(handle: number, cols: number, rows: number): void;
10
+ ghostty_terminal_write(handle: number, dataPtr: number, dataLen: number): void;
11
+ ghostty_render_state_update(handle: number): number;
12
+ ghostty_render_state_get_cols(handle: number): number;
13
+ ghostty_render_state_get_rows(handle: number): number;
14
+ ghostty_render_state_is_row_dirty(handle: number, row: number): boolean;
15
+ ghostty_render_state_mark_clean(handle: number): void;
16
+ ghostty_render_state_get_viewport(handle: number, bufPtr: number, bufLen: number): number;
17
+ ghostty_terminal_has_response(handle: number): boolean;
18
+ ghostty_terminal_read_response(handle: number, bufPtr: number, bufLen: number): number;
19
+ }
20
+ export interface MiniTerminalSurfaceOptions {
21
+ container: HTMLElement;
22
+ /** Explicit cell dimensions — used directly when provided. */
23
+ cols?: number;
24
+ rows?: number;
25
+ /** Pixel dimensions — if provided, cols/rows are computed from font metrics. */
26
+ widthPx?: number;
27
+ heightPx?: number;
28
+ fontSize: number;
29
+ fontFamily: string;
30
+ theme?: Partial<GhosttyTheme>;
31
+ convertEol: boolean;
32
+ interactive: boolean;
33
+ showCursor: boolean;
34
+ onInput?: (data: string) => void;
35
+ wasmUrl?: string | URL;
36
+ }
37
+ export interface MiniTerminalSurface {
38
+ cols: number;
39
+ rows: number;
40
+ cellSize: {
41
+ w: number;
42
+ h: number;
43
+ };
44
+ write(text: string): void;
45
+ drainResponses(): string[];
46
+ dispose(): void;
47
+ }
48
+ declare class LibGhosttyRuntime {
49
+ private readonly wasm;
50
+ private readonly abi;
51
+ constructor(wasm: LibGhosttyExports, abi: {
52
+ cellSize: number;
53
+ terminalConfigSize: number;
54
+ });
55
+ createTerminal(cols: number, rows: number, theme: GhosttyTheme): LibGhosttyTerminal;
56
+ }
57
+ declare class LibGhosttyTerminal {
58
+ private readonly wasm;
59
+ private readonly handle;
60
+ cols: number;
61
+ rows: number;
62
+ private readonly cellSize;
63
+ private viewportPtr;
64
+ private viewportLen;
65
+ constructor(wasm: LibGhosttyExports, handle: number, cols: number, rows: number, cellSize: number);
66
+ write(textOrData: string | Uint8Array): void;
67
+ resize(cols: number, rows: number): void;
68
+ hasResponse(): boolean;
69
+ isDirty(): boolean;
70
+ readResponse(maxBytes?: number): string | null;
71
+ getViewportData(): {
72
+ cols: number;
73
+ rows: number;
74
+ buffer: Uint8Array;
75
+ };
76
+ dispose(): void;
77
+ private releaseViewport;
78
+ }
79
+ /** Measure monospace cell size for a given font. Cheap and synchronous. */
80
+ export declare function measureCellSize(fontSize: number, fontFamily: string): {
81
+ w: number;
82
+ h: number;
83
+ };
84
+ export declare function loadLibGhostty(wasmUrl?: string | URL): Promise<LibGhosttyRuntime>;
85
+ export declare function createMiniTerminalSurface(options: MiniTerminalSurfaceOptions): Promise<MiniTerminalSurface>;
86
+ export {};