@alchemy.run/sigil 0.0.0-alpha.4 → 0.0.0-alpha.6

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.
Files changed (138) hide show
  1. package/README.md +21 -9
  2. package/THIRD_PARTY_NOTICES.md +39 -1
  3. package/dist/Text-BobFKi74.d.ts +452 -0
  4. package/dist/ansi.d.ts +122 -131
  5. package/dist/ansi.js +86 -6
  6. package/dist/capabilities.d.ts +5 -0
  7. package/dist/capabilities.js +3 -0
  8. package/dist/cell-_ZVhbfl0.js +44 -0
  9. package/dist/color-CkbalRqK.js +2 -0
  10. package/dist/color-policy-BMzMwV7Q.d.ts +22 -0
  11. package/dist/color-policy-SVj1pYTA.js +560 -0
  12. package/dist/color-profile-DHhQHY55.js +36 -0
  13. package/dist/color-profile-u0Nhe9Nv.d.ts +97 -0
  14. package/dist/color.d.ts +21 -0
  15. package/dist/color.js +3 -0
  16. package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
  17. package/dist/detect-Bh4yGP6w.d.ts +186 -0
  18. package/dist/detect-BuTXtY6e.js +373 -0
  19. package/dist/env-YVw64yZS.js +9 -0
  20. package/dist/escapes-CB_6CWOE.d.ts +72 -0
  21. package/dist/geometry-BxXOzJgo.d.ts +11 -0
  22. package/dist/index-DZ88EXJv.d.ts +21 -0
  23. package/dist/index.d.ts +143 -498
  24. package/dist/index.js +1026 -2244
  25. package/dist/osc-CCH7xDoS.js +71 -0
  26. package/dist/osc-Cn0fw77g.d.ts +23 -0
  27. package/dist/paint-C19minOS.d.ts +81 -0
  28. package/dist/query-vaIeGOkH.d.ts +152 -0
  29. package/dist/router.d.ts +392 -0
  30. package/dist/router.js +709 -0
  31. package/dist/sample-Cqw1bjUL.js +445 -0
  32. package/dist/screen-BOSLQ8fF.d.ts +49 -0
  33. package/dist/screen-CiPytswf.js +342 -0
  34. package/dist/screen.d.ts +5 -0
  35. package/dist/screen.js +5 -0
  36. package/dist/semantic-text-style-DIMzC7xt.js +91 -0
  37. package/dist/serialize-BTkAZgw1.js +79 -0
  38. package/dist/session-aZr9O8h3.js +665 -0
  39. package/dist/sgr-BhwaWAJB.js +246 -0
  40. package/dist/store-CgrG9K4y.d.ts +72 -0
  41. package/dist/string-width-CijQwpIk.js +69 -0
  42. package/dist/strip-BvU4toXG.js +6 -0
  43. package/dist/terminal.d.ts +118 -0
  44. package/dist/terminal.js +2 -0
  45. package/dist/tokenize-AjqbvtiT.js +1242 -0
  46. package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
  47. package/dist/truncate-D31fhU6i.js +562 -0
  48. package/dist/use-focus-BzqAJi0n.js +1337 -0
  49. package/package.json +41 -9
  50. package/src/ansi/chalk.ts +5 -3
  51. package/src/ansi/escapes.ts +14 -0
  52. package/src/ansi/graphemes.ts +8 -0
  53. package/src/ansi/hyperlink.ts +44 -0
  54. package/src/ansi/index.ts +3 -1
  55. package/src/ansi/osc.ts +77 -0
  56. package/src/ansi/tokenize.ts +3 -4
  57. package/src/capabilities/color-policy.ts +34 -0
  58. package/src/capabilities/detect.ts +594 -0
  59. package/src/capabilities/index.ts +37 -0
  60. package/src/capabilities/query.ts +657 -0
  61. package/src/capabilities/store.ts +379 -0
  62. package/src/color/index.ts +3 -0
  63. package/src/color/paint.ts +169 -0
  64. package/src/color/palette.ts +48 -0
  65. package/src/color/sample.ts +323 -0
  66. package/src/color.ts +1 -0
  67. package/src/components/AnsiText.tsx +42 -0
  68. package/src/components/App.tsx +98 -10
  69. package/src/components/BackgroundContext.ts +2 -3
  70. package/src/components/Box.tsx +0 -8
  71. package/src/components/CursorContext.ts +1 -1
  72. package/src/components/Hyperlink.tsx +56 -0
  73. package/src/components/TerminalOscContext.ts +25 -0
  74. package/src/components/Text.tsx +22 -45
  75. package/src/components/Transform.tsx +1 -1
  76. package/src/dom.ts +11 -2
  77. package/src/global.d.ts +3 -0
  78. package/src/hooks/use-capabilities.ts +73 -0
  79. package/src/hooks/use-cursor.ts +2 -2
  80. package/src/hooks/use-terminal-osc.ts +59 -0
  81. package/src/index.ts +45 -1
  82. package/src/ink.tsx +213 -153
  83. package/src/{render-node-to-output.ts → paint-tree.ts} +75 -48
  84. package/src/reconciler.ts +24 -3
  85. package/src/render-background.ts +36 -15
  86. package/src/render-border.ts +94 -61
  87. package/src/render-frame.ts +83 -0
  88. package/src/render-to-string.ts +21 -6
  89. package/src/render.ts +15 -6
  90. package/src/router/components.tsx +343 -0
  91. package/src/router/context.ts +41 -0
  92. package/src/router/history.ts +194 -0
  93. package/src/router/hooks.tsx +391 -0
  94. package/src/router/index.ts +34 -0
  95. package/src/router/matcher.ts +571 -0
  96. package/src/screen/ansi.ts +184 -0
  97. package/src/screen/canvas.ts +160 -0
  98. package/src/screen/cell.ts +138 -0
  99. package/src/screen/color-profile.ts +47 -0
  100. package/src/screen/geometry.ts +9 -0
  101. package/src/screen/index.ts +6 -0
  102. package/src/screen/screen.ts +305 -0
  103. package/src/screen/serialize.ts +129 -0
  104. package/src/screen.ts +1 -0
  105. package/src/semantic-text-style.ts +118 -0
  106. package/src/squash-text-nodes.ts +2 -5
  107. package/src/structured-text.ts +325 -0
  108. package/src/styles.ts +19 -14
  109. package/src/terminal/index.ts +2 -0
  110. package/src/terminal/inline-presenter.ts +120 -0
  111. package/src/terminal/input.ts +86 -0
  112. package/src/terminal/render-scheduler.ts +37 -0
  113. package/src/terminal/screen-presenter.ts +188 -0
  114. package/src/terminal/session.ts +407 -0
  115. package/src/terminal.ts +1 -0
  116. package/src/testing/browser.ts +588 -0
  117. package/src/testing/emulators.ts +205 -0
  118. package/src/testing/explorer-app/index.html +12 -0
  119. package/src/testing/explorer-app/main.ts +381 -0
  120. package/src/testing/explorer-app/style.css +194 -0
  121. package/src/testing/explorer-app/tsconfig.json +15 -0
  122. package/src/testing/explorer-app/vite-env.d.ts +1 -0
  123. package/src/testing/index.ts +26 -0
  124. package/src/testing/keys.ts +56 -0
  125. package/src/testing/live.ts +85 -0
  126. package/src/testing/matchers.ts +70 -0
  127. package/src/testing/public.ts +94 -0
  128. package/src/testing/terminal.ts +349 -0
  129. package/src/testing/vitest.ts +157 -0
  130. package/src/transform-adapter.ts +14 -0
  131. package/src/wrap-text.ts +4 -0
  132. package/dist/sgr-CMfEpjSk.d.ts +0 -91
  133. package/dist/truncate-Cr6xVFMa.js +0 -2330
  134. package/src/ansi/supports-color.ts +0 -207
  135. package/src/colorize.ts +0 -60
  136. package/src/log-update.ts +0 -370
  137. package/src/output.ts +0 -308
  138. package/src/renderer.ts +0 -73
@@ -0,0 +1,205 @@
1
+ // Real terminal emulator engines behind one interface: Ghostty's VT core
2
+ // (compiled to WebAssembly) and xterm.js (headless). Apps under test talk to
3
+ // an actual emulator implementation — queries get genuine answers, wrapping
4
+ // and styling behave like a real terminal.
5
+ //
6
+ // Both engines are optional peer dependencies loaded on demand, so the main
7
+ // package stays lean for non-testing consumers.
8
+
9
+ export type EmulatorName = "ghostty" | "xterm";
10
+
11
+ export type EmulatorCell = {
12
+ text: string;
13
+ hyperlink: string | undefined;
14
+ bold: boolean;
15
+ italic: boolean;
16
+ underline: boolean;
17
+ inverse: boolean;
18
+ dim: boolean;
19
+ };
20
+
21
+ export type Emulator = {
22
+ readonly name: EmulatorName;
23
+
24
+ /**
25
+ Feeds application output into the emulator.
26
+ */
27
+ feed: (data: Uint8Array | string) => void;
28
+
29
+ /**
30
+ The emulator's own responses to queries (device attributes, colors, …),
31
+ to be written back to the application.
32
+ */
33
+ onResponse: (handler: (data: string) => void) => void;
34
+
35
+ resize: (columns: number, rows: number) => void;
36
+
37
+ /**
38
+ The visible screen as right-trimmed rows.
39
+ */
40
+ lines: () => string[];
41
+
42
+ cellAt: (x: number, y: number) => EmulatorCell | undefined;
43
+
44
+ cursor: () => { x: number; y: number };
45
+
46
+ /**
47
+ Flips the emulated OS color scheme (Ghostty only) — drives real color
48
+ scheme reports into the application when it enabled mode 2031.
49
+ */
50
+ setColorScheme?: (scheme: "dark" | "light") => void;
51
+
52
+ dispose: () => void;
53
+ };
54
+
55
+ export type EmulatorOptions = {
56
+ columns: number;
57
+ rows: number;
58
+ colorScheme: "dark" | "light";
59
+ };
60
+
61
+ const missingEngine = (pkg: string, cause: unknown): Error =>
62
+ new Error(
63
+ `The "${pkg}" package is required for this emulator backend. ` +
64
+ `Install it as a dev dependency: pnpm add -D ${pkg}`,
65
+ { cause },
66
+ );
67
+
68
+ const createGhosttyEmulator = async ({
69
+ columns,
70
+ rows,
71
+ colorScheme,
72
+ }: EmulatorOptions): Promise<Emulator> => {
73
+ const { createGhosttyTerminal } = await import("@slopus/ghostty-wasm/node").catch((error) => {
74
+ throw missingEngine("@slopus/ghostty-wasm", error);
75
+ });
76
+
77
+ const terminal = await createGhosttyTerminal({ cols: columns, rows, colorScheme });
78
+ const decoder = new TextDecoder();
79
+
80
+ return {
81
+ name: "ghostty",
82
+ feed: (data) => {
83
+ terminal.write(data);
84
+ },
85
+ onResponse: (handler) => {
86
+ terminal.onPtyWrite((data) => {
87
+ handler(decoder.decode(data));
88
+ });
89
+ },
90
+ resize: (nextColumns, nextRows) => {
91
+ terminal.resize(nextColumns, nextRows);
92
+ },
93
+ lines: () =>
94
+ terminal.snapshot().rows.map((row) =>
95
+ row.cells
96
+ .map((cell) => cell.text)
97
+ .join("")
98
+ .trimEnd(),
99
+ ),
100
+ cellAt: (x, y) => {
101
+ const row = terminal.snapshot().rows[y];
102
+ const cell = row?.cells.find((candidate) => candidate.x === x);
103
+ if (!cell) {
104
+ return undefined;
105
+ }
106
+
107
+ return {
108
+ text: cell.text,
109
+ hyperlink: cell.hyperlink ?? undefined,
110
+ bold: cell.style.bold,
111
+ italic: cell.style.italic,
112
+ underline: cell.style.underline !== "none",
113
+ inverse: cell.style.inverse,
114
+ dim: cell.style.dim,
115
+ };
116
+ },
117
+ cursor: () => {
118
+ const cursor = terminal.snapshot().cursor;
119
+ return { x: cursor?.x ?? 0, y: cursor?.y ?? 0 };
120
+ },
121
+ setColorScheme: (scheme) => {
122
+ terminal.setColorScheme(scheme);
123
+ },
124
+ dispose: () => {
125
+ terminal.dispose();
126
+ },
127
+ };
128
+ };
129
+
130
+ const createXtermEmulator = async ({ columns, rows }: EmulatorOptions): Promise<Emulator> => {
131
+ const xterm = await import("@xterm/headless").catch((error) => {
132
+ throw missingEngine("@xterm/headless", error);
133
+ });
134
+ // The package ships CJS; interop may nest the exports under `default`.
135
+ const TerminalConstructor = xterm.Terminal ?? xterm.default.Terminal;
136
+
137
+ const terminal = new TerminalConstructor({ cols: columns, rows, allowProposedApi: true });
138
+
139
+ // Unicode 11 widths: without this, emoji measure 1 cell while Sigil lays
140
+ // them out as 2, misaligning everything to their right.
141
+ const unicode = (await import("@xterm/addon-unicode11").catch(() => undefined)) as
142
+ | {
143
+ Unicode11Addon?: new () => { activate: (terminal: unknown) => void; dispose: () => void };
144
+ default?: {
145
+ Unicode11Addon?: new () => { activate: (terminal: unknown) => void; dispose: () => void };
146
+ };
147
+ }
148
+ | undefined;
149
+ const Unicode11 = unicode?.Unicode11Addon ?? unicode?.default?.Unicode11Addon;
150
+ if (Unicode11) {
151
+ terminal.loadAddon(new Unicode11());
152
+ terminal.unicode.activeVersion = "11";
153
+ }
154
+
155
+ return {
156
+ name: "xterm",
157
+ feed: (data) => {
158
+ terminal.write(data);
159
+ },
160
+ onResponse: (handler) => {
161
+ terminal.onData(handler);
162
+ },
163
+ resize: (nextColumns, nextRows) => {
164
+ terminal.resize(nextColumns, nextRows);
165
+ },
166
+ lines: () => {
167
+ const buffer = terminal.buffer.active;
168
+ const lines: string[] = [];
169
+ for (let y = 0; y < terminal.rows; y++) {
170
+ lines.push(
171
+ buffer
172
+ .getLine(buffer.baseY + y)
173
+ ?.translateToString(true)
174
+ .trimEnd() ?? "",
175
+ );
176
+ }
177
+
178
+ return lines;
179
+ },
180
+ cellAt: (x, y) => {
181
+ const buffer = terminal.buffer.active;
182
+ const cell = buffer.getLine(buffer.baseY + y)?.getCell(x);
183
+ if (!cell) {
184
+ return undefined;
185
+ }
186
+
187
+ return {
188
+ text: cell.getChars(),
189
+ hyperlink: undefined,
190
+ bold: cell.isBold() !== 0,
191
+ italic: cell.isItalic() !== 0,
192
+ underline: cell.isUnderline() !== 0,
193
+ inverse: cell.isInverse() !== 0,
194
+ dim: cell.isDim() !== 0,
195
+ };
196
+ },
197
+ cursor: () => ({ x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY }),
198
+ dispose: () => {
199
+ terminal.dispose();
200
+ },
201
+ };
202
+ };
203
+
204
+ export const createEmulator = (name: EmulatorName, options: EmulatorOptions): Promise<Emulator> =>
205
+ name === "ghostty" ? createGhosttyEmulator(options) : createXtermEmulator(options);
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>sigil explorer</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/main.ts"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,381 @@
1
+ // The explorer frontend — a Vite app. Terminals are real xterm.js instances
2
+ // with the full addon set (Unicode 11 widths so emoji align exactly like
3
+ // Sigil lays them out, clickable links, OSC 52 clipboard, sixel images, and
4
+ // WebGL + fit on the dedicated terminal view).
5
+ import { ClipboardAddon } from "@xterm/addon-clipboard";
6
+ import { FitAddon } from "@xterm/addon-fit";
7
+ import { ImageAddon } from "@xterm/addon-image";
8
+ import { Unicode11Addon } from "@xterm/addon-unicode11";
9
+ import { WebLinksAddon } from "@xterm/addon-web-links";
10
+ import { WebglAddon } from "@xterm/addon-webgl";
11
+ import { Terminal } from "@xterm/xterm";
12
+
13
+ import "@xterm/xterm/css/xterm.css";
14
+ import "./style.css";
15
+
16
+ type Config = {
17
+ mode: "explorer" | "terminal";
18
+ title: string;
19
+ columns: number;
20
+ rows: number;
21
+ tests: boolean;
22
+ entries: Array<{ id: string; title: string; group: string }>;
23
+ };
24
+
25
+ type SerializedTask = {
26
+ id: string;
27
+ name: string;
28
+ type: "suite" | "test";
29
+ state: string | undefined;
30
+ duration: number | undefined;
31
+ errors: string[];
32
+ tasks: SerializedTask[];
33
+ };
34
+
35
+ const root = document.getElementById("root")!;
36
+
37
+ const createTerminal = (
38
+ columns: number,
39
+ rows: number,
40
+ { webgl = false }: { webgl?: boolean } = {},
41
+ ): Terminal => {
42
+ const terminal = new Terminal({
43
+ cols: columns,
44
+ rows,
45
+ fontFamily: "monospace",
46
+ allowProposedApi: true,
47
+ linkHandler: {
48
+ activate: (_event, uri) => {
49
+ window.open(uri, "_blank");
50
+ },
51
+ },
52
+ });
53
+ terminal.loadAddon(new Unicode11Addon());
54
+ terminal.unicode.activeVersion = "11";
55
+ terminal.loadAddon(new WebLinksAddon((_event, uri) => window.open(uri, "_blank")));
56
+ terminal.loadAddon(new ClipboardAddon());
57
+ terminal.loadAddon(new ImageAddon());
58
+ if (webgl) {
59
+ try {
60
+ terminal.loadAddon(new WebglAddon());
61
+ } catch {
62
+ // WebGL unavailable — the DOM renderer takes over.
63
+ }
64
+ }
65
+
66
+ return terminal;
67
+ };
68
+
69
+ const terminalText = (terminal: Terminal): string => {
70
+ const buffer = terminal.buffer.active;
71
+ const lines: string[] = [];
72
+ for (let y = 0; y < terminal.rows; y++) {
73
+ lines.push(
74
+ buffer
75
+ .getLine(buffer.viewportY + y)
76
+ ?.translateToString(true)
77
+ .trimEnd() ?? "",
78
+ );
79
+ }
80
+
81
+ return lines.join("\n").replace(/\n+$/, "");
82
+ };
83
+
84
+ // ── Terminal view: one app, full window, fit + resize ───────────────────────
85
+
86
+ const terminalView = (config: Config, appId: string): void => {
87
+ const entry = config.entries.find((candidate) => candidate.id === appId);
88
+ const title = entry ? `${config.title} — ${entry.title}` : config.title;
89
+ document.title = title;
90
+
91
+ root.innerHTML = `<div class="terminal-view">
92
+ <header><span></span><a href="/">↩ index</a></header>
93
+ <div class="terminal-host"></div>
94
+ </div>`;
95
+ root.querySelector("header span")!.textContent = title;
96
+
97
+ const terminal = createTerminal(config.columns, config.rows, { webgl: true });
98
+ const fit = new FitAddon();
99
+ terminal.loadAddon(fit);
100
+ terminal.open(root.querySelector<HTMLElement>(".terminal-host")!);
101
+ terminal.focus();
102
+
103
+ const socket = new WebSocket(`ws://${location.host}/pty?app=${encodeURIComponent(appId)}`);
104
+ const send = (message: object): void => {
105
+ if (socket.readyState === WebSocket.OPEN) {
106
+ socket.send(JSON.stringify(message));
107
+ }
108
+ };
109
+
110
+ socket.onopen = () => {
111
+ fit.fit();
112
+ send({ type: "resize", columns: terminal.cols, rows: terminal.rows });
113
+ };
114
+ socket.onmessage = (event) => {
115
+ const message = JSON.parse(event.data as string) as {
116
+ type: string;
117
+ data?: string;
118
+ code?: number;
119
+ };
120
+ if (message.type === "data" && message.data !== undefined) {
121
+ terminal.write(message.data);
122
+ }
123
+
124
+ if (message.type === "exit") {
125
+ terminal.write(`\r\n[process exited ${message.code} — reload to restart]`);
126
+ }
127
+ };
128
+ terminal.onData((data) => {
129
+ send({ type: "data", data });
130
+ });
131
+
132
+ let resizeTimer: ReturnType<typeof setTimeout> | undefined;
133
+ new ResizeObserver(() => {
134
+ clearTimeout(resizeTimer);
135
+ resizeTimer = setTimeout(() => {
136
+ fit.fit();
137
+ send({ type: "resize", columns: terminal.cols, rows: terminal.rows });
138
+ }, 100);
139
+ }).observe(root.querySelector(".terminal-host")!);
140
+
141
+ // For browser automation: read the screen, send input directly.
142
+ Object.assign(window, {
143
+ sigil: {
144
+ term: terminal,
145
+ ws: socket,
146
+ send: (data: string) => send({ type: "data", data }),
147
+ text: () => terminalText(terminal),
148
+ },
149
+ });
150
+ };
151
+
152
+ // ── Explorer view: test tree + live sessions + examples ─────────────────────
153
+
154
+ const explorerView = (config: Config): void => {
155
+ document.title = config.title;
156
+
157
+ const groups = new Map<string, Config["entries"]>();
158
+ for (const entry of config.entries) {
159
+ groups.set(entry.group, [...(groups.get(entry.group) ?? []), entry]);
160
+ }
161
+
162
+ const exampleSections = [...groups.entries()]
163
+ .map(
164
+ ([group, entries]) =>
165
+ `<section><h2>${group}</h2><ul>` +
166
+ entries
167
+ .map(
168
+ (entry) =>
169
+ `<li><a href="/terminal?app=${encodeURIComponent(entry.id)}" target="_blank"></a></li>`,
170
+ )
171
+ .join("") +
172
+ "</ul></section>",
173
+ )
174
+ .join("");
175
+
176
+ root.innerHTML = `<div class="layout">
177
+ <div class="sidebar">
178
+ <h1></h1>
179
+ <p>Examples open as live terminals. Test runs stream their terminals into the panel on the right.</p>
180
+ ${config.tests ? '<section><h2>Tests <button id="run-all">run all</button><span id="run-state"></span></h2><div id="tests"></div></section>' : ""}
181
+ ${exampleSections}
182
+ </div>
183
+ <div class="main">
184
+ <h2>Live terminals</h2>
185
+ <p id="empty">Nothing yet — run a test on the left, or open an example.</p>
186
+ <div id="sessions"></div>
187
+ </div>
188
+ </div>`;
189
+ root.querySelector("h1")!.textContent = config.title;
190
+ // Entry titles set via textContent to avoid trusting server strings in HTML.
191
+ const links = [...root.querySelectorAll<HTMLAnchorElement>("section ul a")];
192
+ const flat = [...groups.values()].flat();
193
+ for (const [index, link] of links.entries()) {
194
+ link.textContent = flat[index]!.title;
195
+ }
196
+
197
+ // Sessions panel.
198
+ const sessionsContainer = document.getElementById("sessions")!;
199
+ const sessions = new Map<string, { term: Terminal; element: HTMLElement }>();
200
+
201
+ type Session = {
202
+ id: string;
203
+ title: string;
204
+ columns: number;
205
+ rows: number;
206
+ data?: string;
207
+ done?: boolean;
208
+ exitCode?: number;
209
+ };
210
+
211
+ const finishSession = (id: string, code: number | undefined): void => {
212
+ const session = sessions.get(id);
213
+ if (!session) {
214
+ return;
215
+ }
216
+
217
+ session.element.classList.add("done");
218
+ if (code !== 0 && code !== undefined) {
219
+ session.element.classList.add("failed");
220
+ }
221
+
222
+ session.element.querySelector(".status")!.textContent =
223
+ code === undefined ? "closed" : `exited ${code}`;
224
+ };
225
+
226
+ const createSession = (session: Session): void => {
227
+ document.getElementById("empty")!.style.display = "none";
228
+ const element = document.createElement("div");
229
+ element.className = "session";
230
+ element.innerHTML =
231
+ '<div class="bar"><span class="title"></span><span class="status">running</span></div><div class="body"></div>';
232
+ element.querySelector(".title")!.textContent = session.title;
233
+ sessionsContainer.prepend(element);
234
+ const term = createTerminal(session.columns, session.rows);
235
+ term.open(element.querySelector<HTMLElement>(".body")!);
236
+ if (session.data) {
237
+ term.write(session.data);
238
+ }
239
+
240
+ sessions.set(session.id, { term, element });
241
+ if (session.done) {
242
+ finishSession(session.id, session.exitCode);
243
+ }
244
+
245
+ while (sessionsContainer.children.length > 12) {
246
+ sessionsContainer.lastChild?.remove();
247
+ }
248
+ };
249
+
250
+ // Tests panel.
251
+ const testsContainer = document.getElementById("tests");
252
+ const run = (id?: string): void => {
253
+ void fetch(`/api/run${id ? `?task=${encodeURIComponent(id)}` : ""}`, { method: "POST" });
254
+ };
255
+
256
+ const renderTask = (task: SerializedTask): HTMLElement => {
257
+ const element = document.createElement("div");
258
+ element.className = "task";
259
+ const row = document.createElement("div");
260
+ row.className = "row";
261
+ const dot = document.createElement("span");
262
+ dot.className = `dot ${task.state === "pass" ? "pass" : task.state === "fail" ? "fail" : task.state === "run" ? "run" : ""}`;
263
+ const name = document.createElement("span");
264
+ name.className = "name";
265
+ name.textContent = task.name;
266
+ const time = document.createElement("span");
267
+ time.className = "time";
268
+ if (task.duration !== undefined) {
269
+ time.textContent = `${Math.round(task.duration)}ms`;
270
+ }
271
+
272
+ const runButton = document.createElement("button");
273
+ runButton.className = "run";
274
+ runButton.textContent = "▶";
275
+ runButton.onclick = () => {
276
+ run(task.id);
277
+ };
278
+
279
+ row.append(dot, name, time, runButton);
280
+ element.append(row);
281
+ for (const error of task.errors) {
282
+ const errorElement = document.createElement("div");
283
+ errorElement.className = "error";
284
+ errorElement.textContent = error;
285
+ element.append(errorElement);
286
+ }
287
+
288
+ if (task.tasks.length > 0) {
289
+ const children = document.createElement("div");
290
+ children.className = "children";
291
+ for (const child of task.tasks) {
292
+ children.append(renderTask(child));
293
+ }
294
+
295
+ element.append(children);
296
+ }
297
+
298
+ return element;
299
+ };
300
+
301
+ const renderTests = (files: SerializedTask[], running: boolean): void => {
302
+ if (!testsContainer) {
303
+ return;
304
+ }
305
+
306
+ testsContainer.replaceChildren(...files.map(renderTask));
307
+ document.getElementById("run-state")!.textContent = running ? "running…" : "";
308
+ (document.getElementById("run-all") as HTMLButtonElement).disabled = running;
309
+ };
310
+
311
+ document.getElementById("run-all")?.addEventListener("click", () => {
312
+ run();
313
+ });
314
+
315
+ const socket = new WebSocket(`ws://${location.host}/live/watch`);
316
+ socket.onmessage = (event) => {
317
+ const message = JSON.parse(event.data as string) as {
318
+ type: string;
319
+ id?: string;
320
+ title?: string;
321
+ code?: number;
322
+ data?: string;
323
+ sessions?: Session[];
324
+ tests?: SerializedTask[];
325
+ files?: SerializedTask[];
326
+ running?: boolean;
327
+ };
328
+ if (message.type === "init") {
329
+ for (const session of message.sessions ?? []) {
330
+ createSession(session);
331
+ }
332
+
333
+ if (message.tests) {
334
+ renderTests(message.tests, message.running ?? false);
335
+ }
336
+ }
337
+
338
+ if (message.type === "start") {
339
+ createSession(message as unknown as Session);
340
+ }
341
+
342
+ if (message.type === "data" && message.id !== undefined && message.data !== undefined) {
343
+ sessions.get(message.id)?.term.write(message.data);
344
+ }
345
+
346
+ if (message.type === "title" && message.id !== undefined) {
347
+ const session = sessions.get(message.id);
348
+ if (session) {
349
+ session.element.querySelector(".title")!.textContent = message.title ?? "";
350
+ }
351
+ }
352
+
353
+ if (message.type === "end" && message.id !== undefined) {
354
+ finishSession(message.id, message.code);
355
+ }
356
+ };
357
+
358
+ // For browser automation.
359
+ Object.assign(window, {
360
+ sigilExplorer: {
361
+ sessions,
362
+ text: (id: string) => {
363
+ const session = sessions.get(id);
364
+ return session ? terminalText(session.term) : undefined;
365
+ },
366
+ },
367
+ });
368
+ };
369
+
370
+ // ── Boot ────────────────────────────────────────────────────────────────────
371
+
372
+ const config = (await fetch("/api/config").then((response) => response.json())) as Config;
373
+ const params = new URLSearchParams(location.search);
374
+
375
+ if (location.pathname === "/terminal") {
376
+ terminalView(config, params.get("app") ?? config.entries[0]?.id ?? "");
377
+ } else if (config.mode === "terminal") {
378
+ terminalView(config, config.entries[0]?.id ?? "");
379
+ } else {
380
+ explorerView(config);
381
+ }