@zem-sh/terminal 0.1.2 → 0.1.3

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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zem-sh/terminal",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Terminal emulator core — the Ghostty VT engine compiled to WebAssembly, GPU text rendering, ligature shaping, themes and font fallback",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -31,8 +31,17 @@ export type {
31
31
  TerminalShaderStageMode,
32
32
  TerminalShaderStageBackend,
33
33
  TerminalShaderStageSource,
34
+ TerminalAppSession,
34
35
  } from "./terminal/runtime/types"
35
36
 
37
+ export {
38
+ createTerminalAppSession,
39
+ getDefaultTerminalAppSession,
40
+ prewarmTerminalCore,
41
+ } from "./terminal/runtime/session"
42
+ export type { CreateTerminalAppSessionOptions } from "./terminal/runtime/session"
43
+ export { TERMINAL_WASM_URL } from "./terminal/wasm/wasm-asset"
44
+
36
45
  export {
37
46
  shaderStagesForPreset,
38
47
  shaderStagesForPresets,
@@ -56,7 +65,7 @@ export {
56
65
  TERMINAL_WASM_OPTIMIZE,
57
66
  TERMINAL_WASM_SIZE,
58
67
  TERMINAL_WASM_SHA256,
59
- } from "./terminal/wasm/embedded"
68
+ } from "./terminal/wasm/metadata"
60
69
 
61
70
  // Theme APIs
62
71
  export type { ThemeColor, ThemeTerminalColor, GhosttyTheme } from "./theme/ghostty"
@@ -119,7 +119,8 @@ import {
119
119
  type RuntimeAppApiRuntime,
120
120
  type RuntimeAppApiSharedState,
121
121
  } from "./create-runtime/runtime-app-api";
122
- export { createTerminalAppSession, getDefaultTerminalAppSession } from "./session";
122
+ export { createTerminalAppSession, getDefaultTerminalAppSession, prewarmTerminalCore } from "./session";
123
+ export type { CreateTerminalAppSessionOptions } from "./session";
123
124
  export type {
124
125
  TerminalAppElements,
125
126
  TerminalAppCallbacks,
@@ -740,6 +741,10 @@ export function createTerminalApp(options: TerminalAppOptions): TerminalApp {
740
741
  let fontPromise: Promise<void> | null = null;
741
742
  let fontError: Error | null = null;
742
743
  let fontLease: TerminalFontResourceLease | null = null;
744
+ let deferredFontLease: TerminalFontResourceLease | null = null;
745
+ // Bumped whenever the configured sources change so a late (deferred or
746
+ // superseded) acquisition cannot install fonts into the new generation.
747
+ let fontGeneration = 0;
743
748
 
744
749
  setShaderStages(options.shaderStages ?? []);
745
750
 
@@ -866,9 +871,14 @@ export function createTerminalApp(options: TerminalAppOptions): TerminalApp {
866
871
  }
867
872
 
868
873
  function releaseFontLease() {
869
- if (!fontLease) return;
870
- fontLease.release();
871
- fontLease = null;
874
+ if (fontLease) {
875
+ fontLease.release();
876
+ fontLease = null;
877
+ }
878
+ if (deferredFontLease) {
879
+ deferredFontLease.release();
880
+ deferredFontLease = null;
881
+ }
872
882
  }
873
883
 
874
884
  function clearFontRuntimeState() {
@@ -886,6 +896,7 @@ export function createTerminalApp(options: TerminalAppOptions): TerminalApp {
886
896
 
887
897
  async function setFontSources(sources: TerminalFontSource[]) {
888
898
  configuredFontSources = normalizeFontSources(sources, undefined);
899
+ fontGeneration += 1;
889
900
  fontPromise = null;
890
901
  fontError = null;
891
902
  releaseFontLease();
@@ -949,15 +960,80 @@ export function createTerminalApp(options: TerminalAppOptions): TerminalApp {
949
960
  appendLog(`[ui] line height ${next.toFixed(2)}`);
950
961
  }
951
962
 
963
+ /**
964
+ * Append late-loaded fallback faces (heavy symbol/emoji/CJK fonts marked
965
+ * `defer`) to the live font chain. Appending is safe: indices of the faces
966
+ * already installed do not move, and only the glyph-pick cache needs to be
967
+ * invalidated so previously-missing glyphs are re-resolved against the new
968
+ * entries on the next frame.
969
+ */
970
+ async function loadDeferredFonts(sources: TerminalFontSource[], generation: number) {
971
+ let lease: TerminalFontResourceLease | null = null;
972
+ try {
973
+ lease = await fontResourceStore.acquire(sources);
974
+ } catch {
975
+ return;
976
+ }
977
+ if (!lease.faces.length) {
978
+ lease.release();
979
+ return;
980
+ }
981
+ if (generation !== fontGeneration || !fontState.font) {
982
+ lease.release();
983
+ return;
984
+ }
985
+
986
+ releaseDeferredFontLease();
987
+ deferredFontLease = lease;
988
+
989
+ const entries: FontEntry[] = [];
990
+ for (let i = 0; i < lease.faces.length; i += 1) {
991
+ const face = lease.faces[i];
992
+ entries.push(createFontEntry(face.font, face.label));
993
+ }
994
+ if (!entries.length) return;
995
+
996
+ fontState.fonts.push(...entries);
997
+ fontState.fontPickCache.clear();
998
+ needsRender = true;
999
+ appendLog(`[ui] deferred fallback fonts loaded (+${entries.length})`);
1000
+ }
1001
+
1002
+ function releaseDeferredFontLease() {
1003
+ if (!deferredFontLease) return;
1004
+ deferredFontLease.release();
1005
+ deferredFontLease = null;
1006
+ }
1007
+
952
1008
  async function ensureFont() {
953
1009
  if (fontState.font || fontPromise) return fontPromise;
954
1010
  fontPromise = (async () => {
1011
+ const generation = fontGeneration;
1012
+ const configured = configuredFontSources;
1013
+ // Heavy fallback faces opt out of the critical path: paint with the
1014
+ // primary chain first, then append them in the background. If every
1015
+ // source is deferred (or none is), there is nothing to defer — load the
1016
+ // whole list up front.
1017
+ const deferredSources = configured.filter((source) => source.defer);
1018
+ const eagerSources = configured.filter((source) => !source.defer);
1019
+ const initialSources = eagerSources.length ? eagerSources : configured;
1020
+
955
1021
  let acquiredLease: TerminalFontResourceLease | null = null;
956
1022
  try {
957
- acquiredLease = await fontResourceStore.acquire(configuredFontSources);
1023
+ acquiredLease = await fontResourceStore.acquire(initialSources);
1024
+ if (generation !== fontGeneration) {
1025
+ acquiredLease.release();
1026
+ return;
1027
+ }
958
1028
  if (!acquiredLease.faces.length) {
959
1029
  acquiredLease.release();
960
1030
  acquiredLease = await fontResourceStore.acquire(FALLBACK_LOCAL_FONT_SOURCES);
1031
+ // A newer font selection superseded this load while the fallback
1032
+ // system fonts were being acquired.
1033
+ if (generation !== fontGeneration) {
1034
+ acquiredLease.release();
1035
+ return;
1036
+ }
961
1037
  }
962
1038
  if (!acquiredLease.faces.length) {
963
1039
  throw new Error("Unable to load any configured font source.");
@@ -988,12 +1064,26 @@ export function createTerminalApp(options: TerminalAppOptions): TerminalApp {
988
1064
  log("font loaded");
989
1065
  }
990
1066
  updateGrid();
1067
+
1068
+ // Start the deferred faces only after the first frame has had a
1069
+ // chance to paint; they are usually large font files.
1070
+ if (deferredSources.length && eagerSources.length) {
1071
+ const startDeferred = () => {
1072
+ void loadDeferredFonts(deferredSources, generation);
1073
+ };
1074
+ if (typeof requestAnimationFrame === "function") {
1075
+ requestAnimationFrame(startDeferred);
1076
+ } else {
1077
+ setTimeout(startDeferred, 16);
1078
+ }
1079
+ }
991
1080
  } catch (err) {
992
1081
  if (acquiredLease) {
993
1082
  acquiredLease.release();
994
1083
  } else {
995
1084
  releaseFontLease();
996
1085
  }
1086
+ if (generation !== fontGeneration) return;
997
1087
  clearFontRuntimeState();
998
1088
  const error = err instanceof Error ? err : new Error(String(err));
999
1089
  fontError = error;
@@ -1005,6 +1095,7 @@ export function createTerminalApp(options: TerminalAppOptions): TerminalApp {
1005
1095
  }
1006
1096
 
1007
1097
  cleanupFns.push(() => {
1098
+ fontGeneration += 1;
1008
1099
  releaseFontLease();
1009
1100
  clearFontRuntimeState();
1010
1101
  configuredFontSources = [];
@@ -268,9 +268,27 @@ function createUrlByteCache(
268
268
  };
269
269
  }
270
270
 
271
+ type LocalFontQuery = () => Promise<LocalFontFaceData[]>;
272
+
273
+ /** Resolve the Local Font Access query function once per environment. */
274
+ function resolveLocalFontQuery(): LocalFontQuery | null {
275
+ const globalAccess = globalThis as GlobalWithLocalFontAccess;
276
+ const nav = (globalAccess.navigator ??
277
+ (typeof navigator !== "undefined" ? navigator : undefined)) as NavigatorWithLocalFontAccess;
278
+ if (!nav) return null;
279
+ if (typeof globalAccess.queryLocalFonts === "function") {
280
+ return () => globalAccess.queryLocalFonts!();
281
+ }
282
+ if (typeof nav.queryLocalFonts === "function") {
283
+ return () => nav.queryLocalFonts!();
284
+ }
285
+ return null;
286
+ }
287
+
271
288
  async function tryLoadLocalFontBuffer(
272
289
  matchers: string[],
273
290
  label: string,
291
+ queryFonts?: LocalFontQuery,
274
292
  ): Promise<ArrayBuffer | null> {
275
293
  const normalizedMatchers = matchers.map((matcher) => matcher.toLowerCase()).filter(Boolean);
276
294
  if (!normalizedMatchers.length) return null;
@@ -279,13 +297,8 @@ async function tryLoadLocalFontBuffer(
279
297
  const nav = (globalAccess.navigator ??
280
298
  (typeof navigator !== "undefined" ? navigator : undefined)) as NavigatorWithLocalFontAccess;
281
299
  if (!nav) return null;
282
- const queryLocalFonts =
283
- typeof globalAccess.queryLocalFonts === "function"
284
- ? globalAccess.queryLocalFonts.bind(globalAccess)
285
- : typeof nav.queryLocalFonts === "function"
286
- ? nav.queryLocalFonts.bind(nav)
287
- : null;
288
- if (!queryLocalFonts) return null;
300
+ const loadFonts = queryFonts ?? resolveLocalFontQuery();
301
+ if (!loadFonts) return null;
289
302
 
290
303
  const sourceHint = detectStyleHint(`${label} ${normalizedMatchers.join(" ")}`);
291
304
  const queryPermission = nav.permissions?.query;
@@ -299,7 +312,7 @@ async function tryLoadLocalFontBuffer(
299
312
  }
300
313
 
301
314
  try {
302
- const fonts = await queryLocalFonts();
315
+ const fonts = await loadFonts();
303
316
  const matches = fonts.filter((font) => {
304
317
  const name =
305
318
  `${font.family ?? ""} ${font.fullName ?? ""} ${font.postscriptName ?? ""}`.toLowerCase();
@@ -393,6 +406,16 @@ export function createTerminalFontResourceStore(
393
406
  const leaseSources = new Map<number, Map<string, number>>();
394
407
  let totalCachedSourceBytes = 0;
395
408
  let nextLeaseId = 1;
409
+ // Enumerating installed fonts is expensive and the same list serves every
410
+ // local matcher. Since sources now load concurrently, share one query per
411
+ // store instead of issuing one per source.
412
+ let localFontListPromise: Promise<LocalFontFaceData[]> | null = null;
413
+ const queryLocalFontList = (): Promise<LocalFontFaceData[]> => {
414
+ if (localFontListPromise) return localFontListPromise;
415
+ const query = resolveLocalFontQuery();
416
+ localFontListPromise = query ? query().catch(() => []) : Promise.resolve([]);
417
+ return localFontListPromise;
418
+ };
396
419
 
397
420
  const removeSourceEntry = (key: string) => {
398
421
  const entry = sourceCache.get(key);
@@ -446,6 +469,7 @@ export function createTerminalFontResourceStore(
446
469
  return await tryLoadLocalFontBuffer(
447
470
  matchers,
448
471
  source.label ?? source.matchers[0] ?? "local-font",
472
+ queryLocalFontList,
449
473
  );
450
474
  }
451
475
  return await loadUrlBuffer(sourceKey, normalizeUrlKey(source.url));
@@ -552,42 +576,55 @@ export function createTerminalFontResourceStore(
552
576
  };
553
577
 
554
578
  const acquire = async (sources: TerminalFontSource[]): Promise<TerminalFontResourceLease> => {
555
- const faces: TerminalFontResourceFace[] = [];
556
- const sourceRefs = new Map<string, number>();
579
+ // Load and parse every source concurrently. The store already dedupes
580
+ // in-flight loads/parses; awaiting them one at a time serialized each
581
+ // network fetch and parse, which dominated first paint on a cold cache.
582
+ // `Promise.all` preserves source order, and the ordered chain matters:
583
+ // primary face first, fallbacks after.
584
+ const loaded = await Promise.all(
585
+ sources.map(async (source, sourceIndex) => {
586
+ const sourceKey = resolveSourceKey(source);
587
+ const baseLabel = sourceBaseLabel(source, sourceIndex);
588
+ let buffer: ArrayBuffer | null;
589
+ try {
590
+ buffer = await loadSourceBufferCached(source, sourceKey);
591
+ } catch {
592
+ buffer = null;
593
+ }
557
594
 
558
- for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) {
559
- const source = sources[sourceIndex];
560
- const sourceKey = resolveSourceKey(source);
561
- const baseLabel = sourceBaseLabel(source, sourceIndex);
562
- let buffer: ArrayBuffer | null;
563
- try {
564
- buffer = await loadSourceBufferCached(source, sourceKey);
565
- } catch {
566
- buffer = null;
567
- }
595
+ if (!buffer) {
596
+ if (source.type === "local") {
597
+ const prefix = source.required
598
+ ? "required local font missing"
599
+ : "optional local font missing";
600
+ console.warn(`[font] ${prefix} (${source.matchers.join(", ")})`);
601
+ }
602
+ return null;
603
+ }
568
604
 
569
- if (!buffer) {
570
- if (source.type === "local") {
571
- const prefix = source.required
572
- ? "required local font missing"
573
- : "optional local font missing";
574
- console.warn(`[font] ${prefix} (${source.matchers.join(", ")})`);
605
+ let parsedFaces: ParsedFontFace[];
606
+ try {
607
+ parsedFaces = await parseSourceFacesCached(sourceKey, buffer);
608
+ } catch {
609
+ parsedFaces = [];
575
610
  }
576
- continue;
577
- }
611
+ if (!parsedFaces.length) return null;
612
+ return { sourceKey, baseLabel, parsedFaces };
613
+ }),
614
+ );
578
615
 
579
- let parsedFaces: ParsedFontFace[];
580
- try {
581
- parsedFaces = await parseSourceFacesCached(sourceKey, buffer);
582
- } catch {
583
- parsedFaces = [];
584
- }
585
- if (!parsedFaces.length) continue;
616
+ const faces: TerminalFontResourceFace[] = [];
617
+ const sourceRefs = new Map<string, number>();
618
+ const usedAt = now();
619
+ for (let sourceIndex = 0; sourceIndex < loaded.length; sourceIndex += 1) {
620
+ const result = loaded[sourceIndex];
621
+ if (!result) continue;
622
+ const { sourceKey, baseLabel, parsedFaces } = result;
586
623
 
587
624
  const sourceEntry = sourceCache.get(sourceKey);
588
625
  if (sourceEntry) {
589
626
  sourceEntry.refCount += 1;
590
- sourceEntry.lastUsedAt = now();
627
+ sourceEntry.lastUsedAt = usedAt;
591
628
  }
592
629
  sourceRefs.set(sourceKey, (sourceRefs.get(sourceKey) ?? 0) + 1);
593
630
 
@@ -3,12 +3,19 @@ import { loadTerminalWasm, type TerminalWasm } from "../wasm";
3
3
  import { createTerminalFontResourceStore } from "./font-resource-store";
4
4
  import type { TerminalAppSession, TerminalWasmLogListener } from "./types";
5
5
 
6
+ export type CreateTerminalAppSessionOptions = {
7
+ /** URL of the `terminal.wasm` asset; defaults to the bundled package asset. */
8
+ wasmUrl?: string;
9
+ };
10
+
6
11
  /**
7
12
  * Create a new app session that lazily loads the WASM module and
8
13
  * initializes the WebGPU core on first use. Multiple panes can
9
14
  * share a single session to avoid duplicate resource loading.
10
15
  */
11
- export function createTerminalAppSession(): TerminalAppSession {
16
+ export function createTerminalAppSession(
17
+ options: CreateTerminalAppSessionOptions = {},
18
+ ): TerminalAppSession {
12
19
  let wasmPromise: Promise<TerminalWasm> | null = null;
13
20
  let webgpuCorePromise: Promise<WebGPUCoreState | null> | null = null;
14
21
  const wasmLogListeners = new Set<TerminalWasmLogListener>();
@@ -23,7 +30,7 @@ export function createTerminalAppSession(): TerminalAppSession {
23
30
  return {
24
31
  getWasm: () => {
25
32
  if (!wasmPromise) {
26
- wasmPromise = loadTerminalWasm({ log: forwardWasmLog });
33
+ wasmPromise = loadTerminalWasm({ log: forwardWasmLog, wasmUrl: options.wasmUrl });
27
34
  }
28
35
  return wasmPromise;
29
36
  },
@@ -55,3 +62,11 @@ export function getDefaultTerminalAppSession(): TerminalAppSession {
55
62
  }
56
63
  return defaultTerminalAppSession;
57
64
  }
65
+
66
+ /**
67
+ * Start loading (and compiling) the terminal WASM core for a session ahead of
68
+ * the first pane. Idempotent — the session caches its in-flight load.
69
+ */
70
+ export function prewarmTerminalCore(session?: TerminalAppSession): Promise<TerminalWasm> {
71
+ return (session ?? getDefaultTerminalAppSession()).getWasm();
72
+ }
@@ -147,6 +147,12 @@ export type TerminalUrlFontSource = {
147
147
  url: string;
148
148
  /** Human-readable label for debug/log output. */
149
149
  label?: string;
150
+ /**
151
+ * Load after the first frame instead of blocking startup. Use for large,
152
+ * rarely-needed fallback faces (symbol/emoji/CJK); the primary chain must
153
+ * stay eager.
154
+ */
155
+ defer?: boolean;
150
156
  };
151
157
 
152
158
  /** Font source loaded from an in-memory buffer. */
@@ -156,6 +162,8 @@ export type TerminalBufferFontSource = {
156
162
  data: TerminalFontBufferData;
157
163
  /** Human-readable label for debug/log output. */
158
164
  label?: string;
165
+ /** Load after the first frame instead of blocking startup. */
166
+ defer?: boolean;
159
167
  };
160
168
 
161
169
  /** Font source resolved from locally installed fonts via the Local Font Access API. */
@@ -167,6 +175,8 @@ export type TerminalLocalFontSource = {
167
175
  label?: string;
168
176
  /** If true, font loading fails when no local match is found. */
169
177
  required?: boolean;
178
+ /** Load after the first frame instead of blocking startup. */
179
+ defer?: boolean;
170
180
  };
171
181
 
172
182
  /**
@@ -0,0 +1,11 @@
1
+ // Auto-generated by build:wasm script. Do not edit by hand.
2
+ // Build provenance only — intentionally free of the binary so importing
3
+ // it does not pull the WASM into an application bundle.
4
+ // Source: wasm/zig-out/bin/terminal.wasm
5
+ export const GHOSTTY_VERSION: string = "1.3.2-dev";
6
+ export const GHOSTTY_COMMIT: string = "e1d31deaaed21aa9225afca78d778fb373c95852";
7
+ export const GHOSTTY_PATCH_SHA256: string = "252172b9c6868bccc0277ab5141e8725fe76a9b619788eccdc6e227686f80da0";
8
+ export const ZIG_VERSION: string = "0.15.2";
9
+ export const TERMINAL_WASM_OPTIMIZE: string = "ReleaseSafe";
10
+ export const TERMINAL_WASM_SIZE: number = 1042257;
11
+ export const TERMINAL_WASM_SHA256: string = "cb2873ac405e163941beb090d68c4acf990957fda51e42d72a1218baa67496de";
@@ -1,5 +1,4 @@
1
- import { decodeBase64Bytes } from "../../utils/base64";
2
- import { WASM_BASE64 } from "../embedded";
1
+ import { TERMINAL_WASM_URL } from "../wasm-asset";
3
2
  import { resolveWasmAbi } from "./abi";
4
3
  import { readKittyPlacements } from "./kitty";
5
4
  import { readRenderState } from "./render-state";
@@ -45,9 +44,9 @@ export class TerminalWasm {
45
44
  this.renderViewCaches = new Map();
46
45
  }
47
46
 
48
- /** Load and instantiate the embedded WASM module. */
47
+ /** Load and instantiate the terminal WASM module. */
49
48
  static async load(options: TerminalWasmOptions = {}): Promise<TerminalWasm> {
50
- const bytes = decodeBase64Bytes(WASM_BASE64);
49
+ const bytes = await loadWasmBytes(options.wasmUrl);
51
50
  let memory: WebAssembly.Memory | null = null;
52
51
  const log = options.log;
53
52
 
@@ -282,7 +281,25 @@ export class TerminalWasm {
282
281
  }
283
282
  }
284
283
 
285
- /** Load and instantiate the embedded WASM module (convenience function). */
284
+ /** Load and instantiate the terminal WASM module (convenience function). */
286
285
  export async function loadTerminalWasm(options: TerminalWasmOptions = {}): Promise<TerminalWasm> {
287
286
  return TerminalWasm.load(options);
288
287
  }
288
+
289
+ /**
290
+ * Fetch the WASM binary from its asset URL. The binary is a separate asset
291
+ * rather than an embedded base64 string so browsers can cache and compile it
292
+ * independently, and the JS bundle stays out of the multi-megabyte range.
293
+ */
294
+ async function loadWasmBytes(wasmUrl?: string): Promise<BufferSource> {
295
+ const url = wasmUrl ?? TERMINAL_WASM_URL;
296
+ const response = await fetch(url);
297
+ if (!response.ok) {
298
+ throw new Error(`failed to load terminal WASM from ${url}: HTTP ${response.status}`);
299
+ }
300
+ const buffer = await response.arrayBuffer();
301
+ if (!buffer.byteLength) {
302
+ throw new Error(`terminal WASM asset is empty: ${url}`);
303
+ }
304
+ return buffer;
305
+ }
@@ -176,6 +176,11 @@ export type TerminalWasmExports = WebAssembly.Exports & {
176
176
  /** Construction options for WASM runtime. */
177
177
  export type TerminalWasmOptions = {
178
178
  log?: (message: string) => void;
179
+ /**
180
+ * URL of the `terminal.wasm` asset. Defaults to the copy shipped in the
181
+ * package's `assets` directory (resolved by the bundler).
182
+ */
183
+ wasmUrl?: string;
179
184
  };
180
185
 
181
186
  export type RenderPtrs = {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * URL of the standalone terminal WASM asset, emitted next to the bundle by the
3
+ * consuming bundler (`new URL(..., import.meta.url)` is understood by Vite and
4
+ * most other modern bundlers). Kept in its own module so applications can pass
5
+ * an explicit `wasmUrl` when they copy the asset themselves.
6
+ */
7
+ export const TERMINAL_WASM_URL = new URL(
8
+ "../../../assets/terminal.wasm",
9
+ import.meta.url,
10
+ ).href;