@hex1b/web-terminal 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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -0
  3. package/dist/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2 +0 -0
  4. package/dist/fonts/cascadia-mono-nf/LICENSE.txt +94 -0
  5. package/dist/fonts/cascadia-mono-nf/README.md +29 -0
  6. package/dist/history-state.d.ts +27 -0
  7. package/dist/history-state.d.ts.map +1 -0
  8. package/dist/history-state.js +194 -0
  9. package/dist/history-state.js.map +1 -0
  10. package/dist/index.d.ts +5 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +4 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/input-policy.d.ts +42 -0
  15. package/dist/input-policy.d.ts.map +1 -0
  16. package/dist/input-policy.js +143 -0
  17. package/dist/input-policy.js.map +1 -0
  18. package/dist/mouse-input.d.ts +26 -0
  19. package/dist/mouse-input.d.ts.map +1 -0
  20. package/dist/mouse-input.js +356 -0
  21. package/dist/mouse-input.js.map +1 -0
  22. package/dist/protocol.d.ts +17 -0
  23. package/dist/protocol.d.ts.map +1 -0
  24. package/dist/protocol.js +270 -0
  25. package/dist/protocol.js.map +1 -0
  26. package/dist/renderer.d.ts +113 -0
  27. package/dist/renderer.d.ts.map +1 -0
  28. package/dist/renderer.js +595 -0
  29. package/dist/renderer.js.map +1 -0
  30. package/dist/selection-input.d.ts +36 -0
  31. package/dist/selection-input.d.ts.map +1 -0
  32. package/dist/selection-input.js +69 -0
  33. package/dist/selection-input.js.map +1 -0
  34. package/dist/selection-ui.d.ts +24 -0
  35. package/dist/selection-ui.d.ts.map +1 -0
  36. package/dist/selection-ui.js +113 -0
  37. package/dist/selection-ui.js.map +1 -0
  38. package/dist/terminal-font.d.ts +23 -0
  39. package/dist/terminal-font.d.ts.map +1 -0
  40. package/dist/terminal-font.js +85 -0
  41. package/dist/terminal-font.js.map +1 -0
  42. package/dist/terminal-sizing.d.ts +8 -0
  43. package/dist/terminal-sizing.d.ts.map +1 -0
  44. package/dist/terminal-sizing.js +38 -0
  45. package/dist/terminal-sizing.js.map +1 -0
  46. package/dist/terminal-theme.d.ts +2 -0
  47. package/dist/terminal-theme.d.ts.map +1 -0
  48. package/dist/terminal-theme.js +39 -0
  49. package/dist/terminal-theme.js.map +1 -0
  50. package/dist/terminal-worker.d.ts +2 -0
  51. package/dist/terminal-worker.d.ts.map +1 -0
  52. package/dist/terminal-worker.js +285 -0
  53. package/dist/terminal-worker.js.map +1 -0
  54. package/dist/types.d.ts +312 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +2 -0
  57. package/dist/types.js.map +1 -0
  58. package/dist/validation.d.ts +3 -0
  59. package/dist/validation.d.ts.map +1 -0
  60. package/dist/validation.js +7 -0
  61. package/dist/validation.js.map +1 -0
  62. package/dist/web-terminal.d.ts +51 -0
  63. package/dist/web-terminal.d.ts.map +1 -0
  64. package/dist/web-terminal.js +728 -0
  65. package/dist/web-terminal.js.map +1 -0
  66. package/dist/wire-types.d.ts +218 -0
  67. package/dist/wire-types.d.ts.map +1 -0
  68. package/dist/wire-types.js +2 -0
  69. package/dist/wire-types.js.map +1 -0
  70. package/package.json +42 -0
@@ -0,0 +1,728 @@
1
+ import { captureMouse } from "./mouse-input.js";
2
+ import { normalizeFont } from "./terminal-font.js";
3
+ import { dimensions, normalizeSizing, requestedGrid, fittedScale } from "./terminal-sizing.js";
4
+ import { HistoryState } from "./history-state.js";
5
+ import { terminalThemeCss } from "./terminal-theme.js";
6
+ import { InputPolicy, InputRoute, TerminalAction, inputModifiers } from "./input-policy.js";
7
+ import { assertCommandSize } from "./protocol.js";
8
+ import { SelectionUI } from "./selection-ui.js";
9
+ import { errorMessage, isRecord } from "./validation.js";
10
+ export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js";
11
+ function requiredElement(root, selector, type) {
12
+ const element = root.querySelector(selector);
13
+ if (!(element instanceof type))
14
+ throw new Error(`Missing terminal element: ${selector}`);
15
+ return element;
16
+ }
17
+ /**
18
+ * First-party HWT1 client. Owns only the element it appends, not the caller's
19
+ * container or the server terminal. The HWT1 wire and this spike API evolve together.
20
+ */
21
+ export class WebTerminal {
22
+ element;
23
+ #options;
24
+ // DOM and worker fields are initialized by mount before a handle is returned.
25
+ #worker;
26
+ #surface;
27
+ #canvas;
28
+ #input;
29
+ #mouse;
30
+ #observer;
31
+ #listeners = new AbortController();
32
+ #size = { width: 0, height: 0 };
33
+ #sizing = normalizeSizing();
34
+ #geometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };
35
+ #peer = { id: null, primaryId: null, isPrimary: false };
36
+ #connected = false;
37
+ #disposed = false;
38
+ #hasGeometry = false;
39
+ #resizeTimer;
40
+ #lastRequested;
41
+ #compositionTimer;
42
+ #ready = Promise.withResolvers();
43
+ #readyTimer;
44
+ #stats = {};
45
+ #screenText = "";
46
+ #history;
47
+ #highlights;
48
+ #inspection;
49
+ #inspectionError = "";
50
+ #copySerial = 0;
51
+ #copying = false;
52
+ #policy;
53
+ #actions;
54
+ #clipboardAction = false;
55
+ #inputSerial = 0;
56
+ #selectionUI;
57
+ #selectionOverlay;
58
+ #selectionUIError = "";
59
+ #canvasSize = { width: 0, height: 0 };
60
+ /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */
61
+ static async mount(container, options) {
62
+ if (!(container instanceof HTMLElement))
63
+ throw new TypeError("A terminal container HTMLElement is required");
64
+ if (!options?.url)
65
+ throw new TypeError("A terminal WebSocket URL is required");
66
+ if (options.signal?.aborted)
67
+ throw options.signal.reason;
68
+ if (!window.isSecureContext || !navigator.gpu)
69
+ throw new Error("WebTerminal requires WebGPU over HTTPS or localhost");
70
+ if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||
71
+ !HTMLCanvasElement.prototype.transferControlToOffscreen) {
72
+ throw new Error("WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas");
73
+ }
74
+ const terminal = new WebTerminal(options);
75
+ try {
76
+ await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);
77
+ return terminal;
78
+ }
79
+ catch (error) {
80
+ terminal.dispose();
81
+ throw error;
82
+ }
83
+ }
84
+ constructor(options) {
85
+ this.#options = options;
86
+ if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&
87
+ (typeof options.workerUrl !== "string" || !options.workerUrl.trim()))
88
+ throw new TypeError("workerUrl must be a nonempty URL string or URL");
89
+ if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== "function" ||
90
+ options.onSelectionUI.constructor.name === "AsyncFunction"))
91
+ throw new TypeError("onSelectionUI must be a synchronous event handler");
92
+ this.#policy = new InputPolicy(options);
93
+ this.#actions = new Map(Object.entries(options.actions ?? {}));
94
+ this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());
95
+ this.element = document.createElement("div");
96
+ this.element.className = "hex1b-terminal";
97
+ this.element.tabIndex = -1;
98
+ this.element.style.cssText = "width:100%;height:100%;min-width:0;min-height:0;contain:strict";
99
+ }
100
+ get geometry() { return { ...this.#geometry }; }
101
+ get peer() { return { ...this.#peer }; }
102
+ get connected() { return this.#connected; }
103
+ get stats() { return { ...this.#stats }; }
104
+ get screenText() { return this.#screenText; }
105
+ get sizing() { return { ...this.#sizing }; }
106
+ get inputBindings() { return this.#policy.bindings; }
107
+ get viewport() {
108
+ const viewport = this.#history.viewport;
109
+ return { ...viewport, followTail: viewport.following,
110
+ offset: viewport.available ? viewport.liveTop - viewport.top : 0 };
111
+ }
112
+ get selection() {
113
+ const selection = this.#history.selection;
114
+ return { ...selection, active: selection.status === "valid", pending: selection.status === "pending",
115
+ copying: this.#copying, copyError: this.#inspectionError };
116
+ }
117
+ #start(container) {
118
+ if (this.#options.signal?.aborted)
119
+ throw this.#options.signal.reason;
120
+ const url = new URL(this.#options.url, location.href);
121
+ if (url.protocol === "https:")
122
+ url.protocol = "wss:";
123
+ if (url.protocol === "http:")
124
+ url.protocol = "ws:";
125
+ if (!["ws:", "wss:"].includes(url.protocol))
126
+ throw new TypeError("A ws: or wss: URL is required");
127
+ const scale = this.#options.scale === undefined || this.#options.scale === "auto"
128
+ ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;
129
+ if (!Number.isFinite(scale) || scale < 0.5 || scale > 3)
130
+ throw new RangeError("Backing scale must be 0.5-3 or 'auto'");
131
+ const font = normalizeFont(this.#options.font, location.href);
132
+ this.#sizing = normalizeSizing(this.#options.sizing);
133
+ const shadow = this.element.attachShadow({ mode: "open" });
134
+ shadow.innerHTML = `
135
+ <style>
136
+ ${terminalThemeCss}
137
+ :host { display: block; }
138
+ .viewport { position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; }
139
+ .surface { position: relative; flex: none; overflow: hidden; }
140
+ canvas { display: block; width: 100%; height: 100%; user-select: none; touch-action: none; }
141
+ .highlights { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
142
+ .highlight { position: absolute; background: var(--cp-view-accent); opacity: .3; }
143
+ .selection-ui-slot { position: absolute; inset: 0; display: block; pointer-events: none; font: 11px/1.4 var(--cp-view-font-family); color: var(--cp-view-text); }
144
+ .inspection { position: absolute; right: 4px; bottom: 4px; left: 4px; display: flex; flex-wrap: wrap; gap: 4px; justify-content: end; align-items: center; pointer-events: none; font: 11px/1.4 var(--cp-view-font-family); }
145
+ .inspection [hidden] { display: none; }
146
+ .inspection button, .inspection-message { font: inherit; background: var(--cp-view-surface); color: var(--cp-view-text); border: 1px solid var(--cp-view-border-strong); border-radius: .625rem; padding: 4px 8px; }
147
+ .inspection button { pointer-events: auto; cursor: pointer; }
148
+ .inspection button:disabled { opacity: .6; cursor: not-allowed; }
149
+ .inspection button:hover { background: var(--cp-view-accent-soft); }
150
+ .inspection button:focus-visible { outline: 2px solid var(--cp-view-accent); outline-offset: 2px; }
151
+ .inspection-message { color: var(--cp-view-text-muted); max-width: 100%; }
152
+ .inspection-message[data-level=error] { color: var(--cp-view-danger); }
153
+ textarea { position: absolute; width: 1px; height: 1px; opacity: 0; left: 0; top: 0; padding: 0; border: 0; resize: none; }
154
+ </style>
155
+ <div class="viewport"><div class="surface">
156
+ <canvas aria-hidden="true"></canvas>
157
+ <div class="highlights" part="selection-highlights" aria-hidden="true"></div>
158
+ <textarea autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
159
+ <slot name="selection-ui" class="selection-ui-slot"></slot>
160
+ </div><div class="inspection">
161
+ <span class="inspection-message" role="status" aria-live="polite" hidden></span>
162
+ <button class="copy-selection" part="selection-copy-button" hidden disabled>Copy</button>
163
+ <button class="return-live" hidden>Return to live</button>
164
+ </div></div>`;
165
+ this.#surface = requiredElement(shadow, ".surface", HTMLDivElement);
166
+ this.#canvas = requiredElement(shadow, "canvas", HTMLCanvasElement);
167
+ this.#input = requiredElement(shadow, "textarea", HTMLTextAreaElement);
168
+ this.#highlights = requiredElement(shadow, ".highlights", HTMLDivElement);
169
+ this.#inspection = requiredElement(shadow, ".inspection", HTMLDivElement);
170
+ this.#selectionOverlay = document.createElement("div");
171
+ this.#selectionOverlay.slot = "selection-ui";
172
+ this.#selectionOverlay.className = "hex1b-selection-overlay";
173
+ this.#selectionOverlay.style.cssText = "position:relative;width:100%;height:100%;pointer-events:none";
174
+ this.element.append(this.#selectionOverlay);
175
+ this.#selectionUI = new SelectionUI({
176
+ element: this.element, overlay: this.#selectionOverlay,
177
+ button: requiredElement(this.#inspection, ".copy-selection", HTMLButtonElement), signal: this.#listeners.signal,
178
+ getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,
179
+ canvasSize: this.#canvasSize, connected: this.#connected, readOnly: !!this.#options.readOnly }),
180
+ runAction: this.runAction.bind(this),
181
+ onSelectionUI: this.#options.onSelectionUI,
182
+ reportError: error => {
183
+ this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : "";
184
+ this.#selectionOverlay.hidden = !!error;
185
+ this.#renderInspectionStatus();
186
+ if (error)
187
+ this.#options.onStatus?.(this.#selectionUIError, "error");
188
+ }
189
+ });
190
+ this.#selectionUI.refresh();
191
+ this.#input.setAttribute("aria-label", this.#options.label || "Terminal input. Click outside to use page controls.");
192
+ this.#input.disabled = true;
193
+ container.append(this.element);
194
+ const inspect = (operation) => {
195
+ try {
196
+ this.#inspectionError = "";
197
+ operation();
198
+ }
199
+ catch (error) {
200
+ this.#inspectionError = errorMessage(error);
201
+ this.#inspectionChanged();
202
+ }
203
+ };
204
+ this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {
205
+ state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: !!this.#options.readOnly,
206
+ selection: this.selection }),
207
+ begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),
208
+ extend: point => inspect(() => this.#history.extend(point)),
209
+ scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),
210
+ end: cancelled => this.#history.endGesture(cancelled),
211
+ resolve: input => this.#resolveInput(input),
212
+ execute: (decision, input) => this.#executeInputAction(decision, input)
213
+ });
214
+ this.#bindKeyboard();
215
+ requiredElement(this.#inspection, ".return-live", HTMLButtonElement).addEventListener("click", () => {
216
+ this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));
217
+ }, { signal: this.#listeners.signal });
218
+ requiredElement(this.#inspection, ".copy-selection", HTMLButtonElement).addEventListener("click", () => {
219
+ this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));
220
+ }, { signal: this.#listeners.signal });
221
+ requiredElement(shadow, ".viewport", HTMLDivElement).addEventListener("pointerdown", event => {
222
+ if (event.composedPath().includes(this.#selectionOverlay))
223
+ return;
224
+ if ((event.target instanceof Element && event.target.closest("button")) ||
225
+ event.target === this.#canvas || event.target === this.#input)
226
+ return;
227
+ event.preventDefault();
228
+ this.focus();
229
+ }, { signal: this.#listeners.signal });
230
+ this.#observer = new ResizeObserver(entries => {
231
+ const { width, height } = entries[0].contentRect;
232
+ const changed = width !== this.#size.width || height !== this.#size.height;
233
+ this.#size = { width, height };
234
+ this.#fit();
235
+ if (changed)
236
+ this.#queueResize();
237
+ });
238
+ // Observe the caller's outer box, never the fitted inner surface.
239
+ this.#observer.observe(container);
240
+ window.addEventListener("pagehide", () => this.dispose(), { signal: this.#listeners.signal });
241
+ this.#options.signal?.addEventListener("abort", () => this.dispose(), { once: true, signal: this.#listeners.signal });
242
+ this.#readyTimer = setTimeout(() => this.#fail(new Error("Timed out waiting for the terminal's first frame")), 30000);
243
+ this.#worker = this.#options.workerUrl === undefined
244
+ ? new Worker(new URL("./terminal-worker.js", import.meta.url), { type: "module", name: "Hex1b WebTerminal" })
245
+ : new Worker(new URL(this.#options.workerUrl, location.href), { type: "module", name: "Hex1b WebTerminal" });
246
+ this.#worker.addEventListener("message", (event) => this.#message(event.data));
247
+ this.#worker.addEventListener("error", event => {
248
+ event.preventDefault();
249
+ this.#fail(new Error(event.message || "Terminal worker failed"));
250
+ });
251
+ this.#worker.addEventListener("messageerror", () => this.#fail(new Error("Terminal worker message could not be decoded")));
252
+ const canvas = this.#canvas.transferControlToOffscreen();
253
+ this.#post({ type: "init", canvas, url: url.href, scale, font }, [canvas]);
254
+ }
255
+ #message(message) {
256
+ if (this.#disposed)
257
+ return;
258
+ if (message.type === "connected") {
259
+ this.#connected = true;
260
+ this.#input.disabled = !this.#canInput();
261
+ }
262
+ else if (message.type === "disconnected") {
263
+ this.#disconnect();
264
+ }
265
+ else if (message.type === "status") {
266
+ if (message.level === "error") {
267
+ this.#disconnect();
268
+ this.#ready.reject(new Error(message.message));
269
+ }
270
+ this.#options.onStatus?.(message.message, message.level);
271
+ }
272
+ else if (message.type === "geometry") {
273
+ const first = !this.#hasGeometry;
274
+ const geometryChanged = first || ["columns", "rows", "cellWidth", "cellHeight", "mouseTracking"]
275
+ .some(field => this.#geometry[field] !== message[field]);
276
+ this.#geometry = {
277
+ columns: message.columns, rows: message.rows,
278
+ cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking
279
+ };
280
+ this.#hasGeometry = true;
281
+ const oldPeer = this.#peer;
282
+ this.#peer = message.peer;
283
+ this.#input.disabled = !this.#canInput();
284
+ if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement)
285
+ this.focus();
286
+ this.#mouse?.update(message.columns, message.rows, message.mouseTracking);
287
+ if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary)
288
+ this.#fit();
289
+ if (!this.#peer.isPrimary) {
290
+ clearTimeout(this.#resizeTimer);
291
+ this.#resizeTimer = undefined;
292
+ this.#lastRequested = undefined;
293
+ }
294
+ if (first || (!oldPeer.isPrimary && this.#peer.isPrimary))
295
+ this.#queueResize(true);
296
+ if (this.#lastRequested === `${message.columns}x${message.rows}`)
297
+ this.#lastRequested = undefined;
298
+ if (Object.hasOwn(message, "history")) {
299
+ this.#screenText = message.text;
300
+ this.#history.accept(message.history, message.revision);
301
+ }
302
+ if (geometryChanged)
303
+ this.#options.onGeometry?.(this.geometry);
304
+ if (first)
305
+ this.#options.onSizingChange?.(this.sizing);
306
+ if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {
307
+ this.#options.onRoleChange?.(this.peer);
308
+ }
309
+ }
310
+ else if (message.type === "history") {
311
+ this.#screenText = message.text;
312
+ this.#history.accept(message.history, message.revision);
313
+ }
314
+ else if (message.type === "stats") {
315
+ this.#stats = message.stats;
316
+ if (message.text !== undefined)
317
+ this.#screenText = message.text;
318
+ if (message.stats.revision > 0 && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {
319
+ clearTimeout(this.#readyTimer);
320
+ this.#ready.resolve(this);
321
+ }
322
+ this.#options.onStats?.(this.stats, message.text);
323
+ }
324
+ }
325
+ #fit() {
326
+ const width = this.#geometry.columns * this.#geometry.cellWidth;
327
+ const height = this.#geometry.rows * this.#geometry.cellHeight;
328
+ const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);
329
+ this.#surface.style.width = `${width * scale}px`;
330
+ this.#surface.style.height = `${height * scale}px`;
331
+ // Overlay positions use layout pixels, before any ancestor CSS transforms.
332
+ const style = getComputedStyle(this.#surface);
333
+ this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };
334
+ this.#selectionUI?.refresh();
335
+ const dpr = window.devicePixelRatio || 1;
336
+ this.#post({ type: "viewport", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });
337
+ }
338
+ #fittedGrid() {
339
+ return requestedGrid(this.#size, this.#geometry, this.#sizing);
340
+ }
341
+ #queueResize(includeFixed = false) {
342
+ if (this.#sizing.mode === "fixed" && !includeFixed)
343
+ return;
344
+ if (!this.#hasGeometry || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed)
345
+ return;
346
+ // Throttle (rather than debounce) so dragging a primary view updates peers live.
347
+ this.#resizeTimer = setTimeout(() => {
348
+ this.#resizeTimer = undefined;
349
+ const grid = this.#fittedGrid();
350
+ if (!grid || !this.#peer.isPrimary || !this.#connected)
351
+ return;
352
+ const key = `${grid.columns}x${grid.rows}`;
353
+ if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested)
354
+ return;
355
+ this.resize(grid.columns, grid.rows);
356
+ }, 50);
357
+ }
358
+ #post(message, transfer = []) {
359
+ this.#worker?.postMessage(message, transfer);
360
+ }
361
+ #send(command) {
362
+ if (!this.#connected || this.#disposed)
363
+ throw new Error("Terminal view is not connected");
364
+ this.#post({ type: "command", command });
365
+ }
366
+ #inputCommand(command) {
367
+ if (this.#disposed || !this.#canInput())
368
+ return;
369
+ assertCommandSize(command);
370
+ this.#inputSerial++;
371
+ if (["input", "paste", "key"].includes(command.type) && this.viewport.available) {
372
+ this.#mouse?.cancel();
373
+ if (this.selection.status !== "none")
374
+ this.clearSelection();
375
+ if (!this.viewport.following || this.viewport.pending)
376
+ this.scrollToLive();
377
+ }
378
+ this.#send(command);
379
+ }
380
+ #inspectionChanged() {
381
+ const viewport = this.viewport;
382
+ const selection = this.selection;
383
+ if (selection.status === "invalidated")
384
+ this.#mouse?.cancel();
385
+ if (this.#highlights) {
386
+ this.#highlights.replaceChildren(...selection.ranges.map(range => {
387
+ const element = document.createElement("span");
388
+ element.className = "highlight";
389
+ element.setAttribute("part", "selection-highlight");
390
+ element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;
391
+ return element;
392
+ }));
393
+ const live = requiredElement(this.#inspection, ".return-live", HTMLButtonElement);
394
+ live.hidden = !viewport.available || (viewport.following && !viewport.pending);
395
+ live.disabled = !this.#connected;
396
+ this.#renderInspectionStatus();
397
+ }
398
+ if (!this.#disposed)
399
+ this.#selectionUI?.refresh();
400
+ this.#options.onViewportChange?.(viewport);
401
+ this.#options.onSelectionChange?.(selection);
402
+ }
403
+ scrollLines(delta) { this.#history.scroll(delta); }
404
+ scrollToLive() { this.#history.live(); }
405
+ clearSelection() { this.#inspectionError = ""; this.#history.clear(); }
406
+ /** Re-notifies selection UI hosts after an external styling/policy change. */
407
+ refreshSelectionUI() {
408
+ if (this.#disposed)
409
+ throw new Error("Terminal view is disposed");
410
+ this.#selectionUI?.refresh(true);
411
+ }
412
+ #renderInspectionStatus() {
413
+ if (!this.#inspection)
414
+ return;
415
+ const selection = this.selection;
416
+ const viewport = this.viewport;
417
+ const status = requiredElement(this.#inspection, ".inspection-message", HTMLSpanElement);
418
+ status.textContent = this.#selectionUIError || this.#inspectionError ||
419
+ (selection.status === "unavailable" ? "" : selection.message) ||
420
+ (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : "");
421
+ status.hidden = !status.textContent;
422
+ status.dataset.level = this.#selectionUIError || this.#inspectionError ||
423
+ selection.status === "invalidated" ? "error" : "info";
424
+ }
425
+ #actionFailed(error) {
426
+ const failure = error instanceof Error ? error : new Error(String(error));
427
+ this.#inspectionError = `Input action failed: ${failure.message}`;
428
+ this.#inspectionChanged();
429
+ this.#options.onInputError?.(failure);
430
+ }
431
+ async copySelection({ clear = false } = {}) {
432
+ if (typeof clear !== "boolean")
433
+ throw new TypeError("Copy clear must be a boolean");
434
+ const serial = ++this.#copySerial;
435
+ const selectionId = this.selection.requestId;
436
+ const generation = this.viewport.generation;
437
+ this.#inspectionError = "";
438
+ try {
439
+ if (!this.#connected || this.#disposed)
440
+ throw new Error("Terminal view is not connected");
441
+ if (!navigator.clipboard?.write || typeof ClipboardItem !== "function") {
442
+ throw new Error("Clipboard writing is unavailable. Use a secure browser context with clipboard permission.");
443
+ }
444
+ const text = this.#history.copy();
445
+ this.#copying = true;
446
+ this.#inspectionChanged();
447
+ // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.
448
+ const item = new ClipboardItem({ "text/plain": text.then(value => new Blob([value], { type: "text/plain" })) });
449
+ const [value] = await Promise.all([text, navigator.clipboard.write([item])]);
450
+ if (clear && serial === this.#copySerial && this.selection.status === "valid" &&
451
+ this.selection.requestId === selectionId && this.viewport.generation === generation)
452
+ this.clearSelection();
453
+ return value;
454
+ }
455
+ catch (error) {
456
+ if (serial === this.#copySerial) {
457
+ this.#history.cancelCopy(error);
458
+ this.#inspectionError = `Copy failed: ${errorMessage(error)}`;
459
+ }
460
+ throw error;
461
+ }
462
+ finally {
463
+ if (serial === this.#copySerial)
464
+ this.#copying = false;
465
+ this.#inspectionChanged();
466
+ }
467
+ }
468
+ #canInput() {
469
+ return this.#connected && this.#hasGeometry && !this.#options.readOnly &&
470
+ (this.#peer.id !== null || this.#peer.isPrimary);
471
+ }
472
+ get inputContext() {
473
+ return Object.freeze({
474
+ terminal: this, selection: this.selection, viewport: this.viewport,
475
+ buffer: this.viewport.buffer ?? null, mouseCaptured: this.#geometry.mouseTracking !== 0,
476
+ historical: !this.viewport.following || this.viewport.pending,
477
+ readOnly: !!this.#options.readOnly, connected: this.#connected, peer: this.peer
478
+ });
479
+ }
480
+ #resolveInput(input) {
481
+ try {
482
+ return this.#policy.resolve(Object.freeze(input), this.inputContext);
483
+ }
484
+ catch (error) {
485
+ this.#actionFailed(error);
486
+ return { route: InputRoute.Consume };
487
+ }
488
+ }
489
+ #executeInputAction(decision, input) {
490
+ this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));
491
+ }
492
+ async runAction(action, args, input) {
493
+ return this.#performAction(action, args, input);
494
+ }
495
+ async #performAction(action, args, input) {
496
+ if (this.#disposed)
497
+ throw new Error("Terminal view is disposed");
498
+ this.#inspectionError = "";
499
+ if (typeof action === "function")
500
+ return action(this.inputContext, args, input);
501
+ const customAction = this.#actions.get(action);
502
+ if (customAction)
503
+ return customAction(this.inputContext, args, input);
504
+ switch (action) {
505
+ case TerminalAction.CopySelection:
506
+ if (args === undefined)
507
+ return this.copySelection();
508
+ if (!isRecord(args))
509
+ throw new TypeError("Copy options must be an object");
510
+ if (args.clear !== undefined && typeof args.clear !== "boolean")
511
+ throw new TypeError("Copy clear must be a boolean");
512
+ return this.copySelection({ clear: args.clear });
513
+ case TerminalAction.PasteClipboard: return this.pasteClipboard();
514
+ case TerminalAction.ClearSelection: return this.clearSelection();
515
+ case TerminalAction.ScrollToLive: return this.scrollToLive();
516
+ case TerminalAction.ScrollLines:
517
+ if (typeof args !== "number")
518
+ throw new RangeError("Scroll delta must be a signed 32-bit integer");
519
+ return this.scrollLines(args);
520
+ case TerminalAction.CopyOrPaste:
521
+ if (this.#clipboardAction)
522
+ throw new Error("A clipboard action is still in progress. Try again when it finishes.");
523
+ this.#clipboardAction = true;
524
+ try {
525
+ if (this.selection.active || (this.selection.pending && this.selection.canExtend))
526
+ return await this.copySelection({ clear: true });
527
+ if (!this.#options.readOnly)
528
+ return await this.pasteClipboard();
529
+ return;
530
+ }
531
+ finally {
532
+ this.#clipboardAction = false;
533
+ }
534
+ default: throw new TypeError(`Unknown terminal action: ${action}`);
535
+ }
536
+ }
537
+ /** Sends an explicit paste through the producer's mode-aware input encoder. */
538
+ paste(text) {
539
+ if (typeof text !== "string")
540
+ throw new TypeError("Paste text must be a string");
541
+ if (!this.#canInput())
542
+ throw new Error("Terminal view does not accept input");
543
+ if (text)
544
+ this.#inputCommand({ type: "paste", text });
545
+ }
546
+ async pasteClipboard() {
547
+ if (!this.#canInput())
548
+ throw new Error("Terminal view does not accept input");
549
+ if (!navigator.clipboard?.readText)
550
+ throw new Error("Clipboard reading is unavailable. Use the browser's paste shortcut instead.");
551
+ const serial = this.#inputSerial;
552
+ const generation = this.viewport.generation;
553
+ const selectionId = this.selection.requestId;
554
+ const focused = document.activeElement;
555
+ const text = await navigator.clipboard.readText();
556
+ if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||
557
+ selectionId !== this.selection.requestId || document.activeElement !== focused)
558
+ throw new Error("Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.");
559
+ this.paste(text);
560
+ return text;
561
+ }
562
+ #forwardInput(input) {
563
+ if (input.type === "key") {
564
+ if (input.meta)
565
+ throw new Error("Meta has no terminal key encoding; bind this shortcut to a local action instead.");
566
+ this.#inputCommand({ type: "key", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });
567
+ }
568
+ else if (input.type === "paste") {
569
+ if (this.#canInput())
570
+ this.paste(input.text);
571
+ }
572
+ else if (input.type === "text") {
573
+ this.#inputCommand({ type: "input", text: input.text });
574
+ }
575
+ }
576
+ #dispatchInput(input, event, allowApplication = true) {
577
+ const decision = this.#resolveInput(input);
578
+ // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.
579
+ if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume)
580
+ return;
581
+ if (decision.route === InputRoute.Browser ||
582
+ (decision.route === InputRoute.Continue && input.type === "key"))
583
+ return;
584
+ event?.preventDefault();
585
+ event?.stopPropagation();
586
+ if (decision.action !== undefined)
587
+ this.#executeInputAction(decision, input);
588
+ else if (decision.route !== InputRoute.Consume) {
589
+ try {
590
+ this.#forwardInput(input);
591
+ }
592
+ catch (error) {
593
+ this.#actionFailed(error);
594
+ }
595
+ }
596
+ }
597
+ #bindKeyboard() {
598
+ const input = this.#input;
599
+ const options = { signal: this.#listeners.signal };
600
+ let composing = false;
601
+ let compositionCommit = null;
602
+ this.element.addEventListener("keydown", event => {
603
+ if (event.defaultPrevented)
604
+ return;
605
+ if (event.composedPath().includes(this.#selectionOverlay))
606
+ return;
607
+ const target = event.composedPath()[0];
608
+ if (!this.#connected || event.isComposing || composing || event.key === "Process" || event.key === "Dead")
609
+ return;
610
+ if (event.getModifierState("AltGraph"))
611
+ return;
612
+ this.#dispatchInput({ type: "key", key: event.key, code: event.code, repeat: event.repeat,
613
+ ...inputModifiers(event) }, event, target === input || target === this.element);
614
+ }, { ...options, capture: true });
615
+ input.addEventListener("paste", event => {
616
+ if (event.defaultPrevented || !this.#connected || !event.clipboardData)
617
+ return;
618
+ this.#dispatchInput({ type: "paste", text: event.clipboardData.getData("text/plain") }, event);
619
+ input.value = "";
620
+ }, options);
621
+ input.addEventListener("compositionstart", () => {
622
+ composing = true;
623
+ compositionCommit = null;
624
+ clearTimeout(this.#compositionTimer);
625
+ }, options);
626
+ input.addEventListener("compositionend", event => {
627
+ composing = false;
628
+ // Accommodate browsers placing the final input before or after compositionend.
629
+ compositionCommit = typeof event.data === "string" ? event.data : input.value;
630
+ this.#compositionTimer = setTimeout(() => {
631
+ if (compositionCommit)
632
+ this.#dispatchInput({ type: "text", text: compositionCommit });
633
+ compositionCommit = null;
634
+ input.value = "";
635
+ }, 0);
636
+ }, options);
637
+ input.addEventListener("input", event => {
638
+ const inputEvent = event instanceof InputEvent ? event : undefined;
639
+ if (inputEvent?.isComposing || composing)
640
+ return;
641
+ clearTimeout(this.#compositionTimer);
642
+ const text = compositionCommit ?? inputEvent?.data ?? input.value;
643
+ compositionCommit = null;
644
+ if (text && inputEvent?.inputType !== "insertFromPaste")
645
+ this.#dispatchInput({ type: "text", text }, event);
646
+ input.value = "";
647
+ }, options);
648
+ }
649
+ focus() {
650
+ if (this.#disposed)
651
+ return;
652
+ const target = this.#canInput() ? this.#input : this.element;
653
+ target.focus({ preventScroll: true });
654
+ }
655
+ /** Request HMP1 primary explicitly; peer notifications confirm the result. */
656
+ requestPrimary() {
657
+ if (this.#size.width <= 0 || this.#size.height <= 0)
658
+ throw new Error("Show the terminal container before taking primary");
659
+ const grid = this.#fittedGrid();
660
+ if (!grid)
661
+ throw new Error("Show the terminal container before taking primary");
662
+ if (this.#peer.id === null) {
663
+ if (!this.#peer.isPrimary)
664
+ throw new Error("Waiting for the HMP1 connection");
665
+ this.resize(grid.columns, grid.rows);
666
+ return;
667
+ }
668
+ this.#send({ type: "requestPrimary", ...grid });
669
+ }
670
+ /** Request a grid; never reflow locally before the authoritative response. */
671
+ resize(columns, rows) {
672
+ const grid = dimensions(columns, rows);
673
+ if (!this.#peer.isPrimary)
674
+ throw new Error("Only the primary view can request a terminal resize");
675
+ this.#send({ type: "resize", ...grid });
676
+ this.#lastRequested = `${columns}x${rows}`;
677
+ }
678
+ /** Change the primary's sizing policy; applied grid dimensions still come from the server. */
679
+ setSizing(sizing) {
680
+ const next = normalizeSizing(sizing, this.#sizing.fontSize);
681
+ if (!this.#connected || this.#disposed)
682
+ throw new Error("Terminal view is not connected");
683
+ if (!this.#peer.isPrimary)
684
+ throw new Error("Only the primary view can change terminal sizing");
685
+ this.#sizing = next;
686
+ clearTimeout(this.#resizeTimer);
687
+ this.#resizeTimer = undefined;
688
+ this.#lastRequested = undefined;
689
+ this.#fit();
690
+ this.#queueResize(true);
691
+ this.#options.onSizingChange?.(this.sizing);
692
+ }
693
+ resync() { this.#send({ type: "resync" }); }
694
+ #disconnect() {
695
+ this.#inputSerial++;
696
+ this.#connected = false;
697
+ if (this.#input)
698
+ this.#input.disabled = true;
699
+ this.#mouse?.update(1, 1, 0);
700
+ this.#history.disconnect();
701
+ this.#inspectionChanged();
702
+ clearTimeout(this.#resizeTimer);
703
+ this.#resizeTimer = undefined;
704
+ }
705
+ #fail(error) {
706
+ this.#disconnect();
707
+ this.#ready.reject(error);
708
+ this.#options.onStatus?.(error.message, "error");
709
+ this.#worker?.terminate();
710
+ }
711
+ /** Detach this view. The server-side shared terminal is not terminated. */
712
+ dispose() {
713
+ if (this.#disposed)
714
+ return;
715
+ this.#disposed = true;
716
+ this.#disconnect();
717
+ this.#ready.reject(new DOMException("Terminal view was disposed", "AbortError"));
718
+ clearTimeout(this.#readyTimer);
719
+ clearTimeout(this.#compositionTimer);
720
+ this.#observer?.disconnect();
721
+ this.#mouse?.dispose();
722
+ this.#listeners.abort();
723
+ this.#post({ type: "stop" });
724
+ this.#worker?.terminate();
725
+ this.element.remove();
726
+ }
727
+ }
728
+ //# sourceMappingURL=web-terminal.js.map