@cplieger/web-terminal-ui 1.0.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.
- package/LICENSE +692 -0
- package/README.md +76 -0
- package/css/00-tokens.css +33 -0
- package/css/01-reset.css +73 -0
- package/css/02-app.css +730 -0
- package/css/MANIFEST +5 -0
- package/package.json +58 -0
- package/scaffold/index.html +52 -0
- package/src/composition.ts +171 -0
- package/src/index.ts +10 -0
- package/src/mount.ts +798 -0
- package/src/predict.ts +183 -0
- package/src/status.ts +173 -0
- package/src/viewport.ts +98 -0
package/src/mount.ts
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
// @cplieger/web-terminal-ui — reference touch-first browser terminal UI.
|
|
2
|
+
//
|
|
3
|
+
// mount() wires the @cplieger/web-terminal-engine engine to a fixed DOM scaffold
|
|
4
|
+
// (see scaffold/index.html for the required element ids) and owns the
|
|
5
|
+
// touch-first input model: a display-only #term-output, a hidden <textarea>
|
|
6
|
+
// that owns the keyboard + IME + local typing buffer, a mobile key toolbar,
|
|
7
|
+
// a viewport-clamped context menu, predictive echo, and viewport/keyboard-
|
|
8
|
+
// inset handling. This is the full default UI extracted from vibecli; a
|
|
9
|
+
// consumer either mounts it as-is on the scaffold or builds its own UI on the
|
|
10
|
+
// engine directly.
|
|
11
|
+
//
|
|
12
|
+
// Single-instance per page: the module holds the one terminal's DOM refs and
|
|
13
|
+
// state, matching a terminal-per-page model. Call mount() exactly once.
|
|
14
|
+
|
|
15
|
+
import { render, keyboard, scroll, connection } from "@cplieger/web-terminal-engine";
|
|
16
|
+
import * as viewport from "./viewport.js";
|
|
17
|
+
import * as composition from "./composition.js";
|
|
18
|
+
import * as status from "./status.js";
|
|
19
|
+
import * as predict from "./predict.js";
|
|
20
|
+
|
|
21
|
+
const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
22
|
+
|
|
23
|
+
export interface MountOptions {
|
|
24
|
+
/** WebSocket endpoint path the engine connects to. Default "/ws"
|
|
25
|
+
* (vibekit serves the shell at "/api/shell/ws", vibecli at "/ws"). */
|
|
26
|
+
wsPath?: string;
|
|
27
|
+
/** CSS font shorthand awaited before the first resize is sent, so the
|
|
28
|
+
* server is sized against the real web font's cell metrics rather than a
|
|
29
|
+
* fallback. Default '14px "MonaspiceNe NFM"'. */
|
|
30
|
+
fontReady?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TerminalUI {
|
|
34
|
+
/** Focus the terminal input (opens the soft keyboard on touch). */
|
|
35
|
+
focus(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_WS_PATH = "/ws";
|
|
39
|
+
const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
|
|
40
|
+
|
|
41
|
+
// Single-character placeholder kept in the hidden textarea so iOS soft
|
|
42
|
+
// keyboards have something to "delete" when the user holds Backspace.
|
|
43
|
+
// iOS only fires repeating `input` events with
|
|
44
|
+
// inputType="deleteContentBackward" when the textarea has content to
|
|
45
|
+
// delete; with a perpetually empty textarea, holding Backspace deletes
|
|
46
|
+
// one char and stops because iOS sees nothing more to remove. The
|
|
47
|
+
// placeholder itself is invisible (textarea has opacity:0) and we strip
|
|
48
|
+
// it out of every send. NBSP chosen specifically so screen-reader
|
|
49
|
+
// announcement of the input state stays empty-ish rather than "space".
|
|
50
|
+
const INPUT_PLACEHOLDER = "\u00A0";
|
|
51
|
+
|
|
52
|
+
const TAP_MOVEMENT_PX = 10;
|
|
53
|
+
const LONG_PRESS_MS = 500;
|
|
54
|
+
const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
|
|
55
|
+
|
|
56
|
+
const encoder = new TextEncoder();
|
|
57
|
+
|
|
58
|
+
// --- DOM refs (assigned in mount() from the scaffold) ---
|
|
59
|
+
let outputEl!: HTMLElement;
|
|
60
|
+
let termWrap!: HTMLElement;
|
|
61
|
+
let input!: HTMLTextAreaElement;
|
|
62
|
+
let compositionViewEl!: HTMLElement;
|
|
63
|
+
let ctxMenu!: HTMLElement;
|
|
64
|
+
let ctrlBtn: HTMLElement | null = null;
|
|
65
|
+
|
|
66
|
+
// --- Mutable UI state ---
|
|
67
|
+
let ctrlArmed = false;
|
|
68
|
+
let fontsLoaded = false;
|
|
69
|
+
let wsOpen = false;
|
|
70
|
+
let lastPointerType = "mouse";
|
|
71
|
+
let pointerDownX = 0;
|
|
72
|
+
let pointerDownY = 0;
|
|
73
|
+
let longPressTimer = 0;
|
|
74
|
+
let longPressOrigin = { x: 0, y: 0 };
|
|
75
|
+
|
|
76
|
+
// iOS Safari shows a system "Paste" permission toast every time
|
|
77
|
+
// navigator.clipboard.readText() is called — by design, and unavoidable
|
|
78
|
+
// from JavaScript. iOS users get a one-tap paste via the native
|
|
79
|
+
// long-press callout (which routes through the textarea's paste event
|
|
80
|
+
// handler in composition.ts without ever calling readText), so we omit
|
|
81
|
+
// the Paste button from our custom menu on iOS to steer them there.
|
|
82
|
+
let isIOS = false;
|
|
83
|
+
|
|
84
|
+
function getRequired(id: string): HTMLElement {
|
|
85
|
+
const el = document.getElementById(id);
|
|
86
|
+
if (!el) {
|
|
87
|
+
throw new Error(`web-terminal-ui: missing required scaffold element #${id}`);
|
|
88
|
+
}
|
|
89
|
+
return el;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function send(bytes: string): void {
|
|
93
|
+
// Suppress backspace (DEL) when the predicted cursor is at column 0
|
|
94
|
+
// — there's nothing left to delete. This mimics the natural brake
|
|
95
|
+
// that iOS's textarea provided (stops key-repeat when empty).
|
|
96
|
+
if (bytes === "\x7f" && predict.get().col === 0 && predict.get().active) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const buf = encoder.encode(bytes);
|
|
100
|
+
predict.applyInput(buf);
|
|
101
|
+
if (!connection.sendBinary(buf)) {
|
|
102
|
+
/* connection not ready — drop silently */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Sticky Ctrl modifier ---
|
|
107
|
+
// The iOS virtual keyboard has no Ctrl key, so control sequences
|
|
108
|
+
// (Ctrl+C, Ctrl+L = clear screen, Ctrl+X, ...) are otherwise
|
|
109
|
+
// unreachable on touch. The toolbar's Ctrl button arms a one-shot
|
|
110
|
+
// modifier: tap it, then tap a letter on the virtual keyboard and that
|
|
111
|
+
// keystroke is sent as its C0 control byte. Auto-disarms after one
|
|
112
|
+
// printable character.
|
|
113
|
+
function setCtrlArmed(on: boolean): void {
|
|
114
|
+
ctrlArmed = on;
|
|
115
|
+
ctrlBtn?.classList.toggle("armed", on);
|
|
116
|
+
ctrlBtn?.setAttribute("aria-pressed", on ? "true" : "false");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Map one printable character to its Ctrl+<char> C0 control byte,
|
|
120
|
+
// mirroring the Ctrl handling in @cplieger/web-terminal-engine's keyboard mapper.
|
|
121
|
+
function ctrlByteFor(ch: string): string | null {
|
|
122
|
+
const code = ch.toLowerCase().charCodeAt(0);
|
|
123
|
+
if (code >= 97 && code <= 122) {
|
|
124
|
+
return String.fromCharCode(code - 96); // a–z → 0x01–0x1a
|
|
125
|
+
}
|
|
126
|
+
switch (ch) {
|
|
127
|
+
case " ":
|
|
128
|
+
case "@":
|
|
129
|
+
return "\x00";
|
|
130
|
+
case "[":
|
|
131
|
+
return "\x1b";
|
|
132
|
+
case "\\":
|
|
133
|
+
return "\x1c";
|
|
134
|
+
case "]":
|
|
135
|
+
return "\x1d";
|
|
136
|
+
case "^":
|
|
137
|
+
return "\x1e";
|
|
138
|
+
case "_":
|
|
139
|
+
return "\x1f";
|
|
140
|
+
case "?":
|
|
141
|
+
return "\x7f";
|
|
142
|
+
default:
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Apply a one-shot armed Ctrl to freshly-typed text: a single printable
|
|
148
|
+
// character becomes its control byte; longer input (paste) just disarms
|
|
149
|
+
// and passes through unchanged.
|
|
150
|
+
function applyStickyCtrl(data: string): string {
|
|
151
|
+
if (!ctrlArmed) {
|
|
152
|
+
return data;
|
|
153
|
+
}
|
|
154
|
+
setCtrlArmed(false);
|
|
155
|
+
if (data.length === 1) {
|
|
156
|
+
return ctrlByteFor(data) ?? data;
|
|
157
|
+
}
|
|
158
|
+
return data;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resetInputPlaceholder(): void {
|
|
162
|
+
input.value = INPUT_PLACEHOLDER;
|
|
163
|
+
// Cursor at the end so the next typed char appends after the
|
|
164
|
+
// placeholder rather than before.
|
|
165
|
+
try {
|
|
166
|
+
input.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
|
|
167
|
+
} catch {
|
|
168
|
+
// Ignore browsers that throw on setSelectionRange against a hidden
|
|
169
|
+
// input (some older WebKit builds).
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Keydown handler — attached to the textarea (the single keyboard target,
|
|
174
|
+
// desktop and touch alike); #term-output is display-only and never focused.
|
|
175
|
+
function handleKeydown(ev: KeyboardEvent): void {
|
|
176
|
+
// While composing (IME), let the browser pump composition events;
|
|
177
|
+
// keydown bytes during composition would duplicate the composed text.
|
|
178
|
+
if (composition.isComposing()) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Ctrl+Shift+C / Ctrl+Shift+V — desktop clipboard shortcuts. Handled
|
|
183
|
+
// before the generic mapper because they take browser-side selection
|
|
184
|
+
// and clipboard, not server-bound key sequences.
|
|
185
|
+
if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
|
186
|
+
if (ev.code === "KeyC") {
|
|
187
|
+
const sel = window.getSelection()?.toString();
|
|
188
|
+
if (sel) {
|
|
189
|
+
void navigator.clipboard.writeText(sel).catch(() => {
|
|
190
|
+
/* ignore */
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
ev.preventDefault();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (ev.code === "KeyV") {
|
|
197
|
+
navigator.clipboard
|
|
198
|
+
.readText()
|
|
199
|
+
.then((text) => {
|
|
200
|
+
send(bracketTextForPaste(prepareTextForTerminal(text)));
|
|
201
|
+
})
|
|
202
|
+
.catch(() => {
|
|
203
|
+
/* ignore */
|
|
204
|
+
});
|
|
205
|
+
ev.preventDefault();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const result = mapKeyboardEvent(ev);
|
|
211
|
+
switch (result.kind) {
|
|
212
|
+
case "send":
|
|
213
|
+
ev.preventDefault();
|
|
214
|
+
send(result.bytes);
|
|
215
|
+
return;
|
|
216
|
+
case "scroll-up": {
|
|
217
|
+
ev.preventDefault();
|
|
218
|
+
const h = termWrap.clientHeight;
|
|
219
|
+
termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
case "scroll-down": {
|
|
223
|
+
ev.preventDefault();
|
|
224
|
+
const h = termWrap.clientHeight;
|
|
225
|
+
termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
case "ignore":
|
|
229
|
+
// Defer to the browser; the `input` listener will pick up any
|
|
230
|
+
// printable character produced by the keystroke.
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Focus the textarea (on page load and after visibility changes).
|
|
236
|
+
function focusTerminal(): void {
|
|
237
|
+
input.focus({ preventScroll: true });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Send the first resize only when BOTH fonts are loaded AND the WS is
|
|
241
|
+
// open. Either can happen first depending on network/cache conditions.
|
|
242
|
+
function maybeSendFirstResize(): void {
|
|
243
|
+
if (!fontsLoaded || !wsOpen) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
render.updateFontMetrics();
|
|
247
|
+
connection.sendResize(); // sends only if size changed
|
|
248
|
+
const sz = render.computeSize();
|
|
249
|
+
predict.setDimensions(sz.cols, sz.rows);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function hideCtxMenu(): void {
|
|
253
|
+
ctxMenu.classList.remove("visible");
|
|
254
|
+
ctxMenu.innerHTML = "";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function showCtxMenu(x: number, y: number): void {
|
|
258
|
+
hideCtxMenu();
|
|
259
|
+
|
|
260
|
+
const sel = window.getSelection()?.toString();
|
|
261
|
+
if (sel) {
|
|
262
|
+
const copyBtn = document.createElement("button");
|
|
263
|
+
copyBtn.textContent = "Copy";
|
|
264
|
+
copyBtn.addEventListener("click", () => {
|
|
265
|
+
void navigator.clipboard.writeText(sel).catch(() => {
|
|
266
|
+
/* ignore */
|
|
267
|
+
});
|
|
268
|
+
hideCtxMenu();
|
|
269
|
+
});
|
|
270
|
+
ctxMenu.appendChild(copyBtn);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const selectAllBtn = document.createElement("button");
|
|
274
|
+
selectAllBtn.textContent = "Select All";
|
|
275
|
+
selectAllBtn.addEventListener("click", () => {
|
|
276
|
+
const s = window.getSelection();
|
|
277
|
+
if (s) {
|
|
278
|
+
s.selectAllChildren(outputEl);
|
|
279
|
+
}
|
|
280
|
+
hideCtxMenu();
|
|
281
|
+
});
|
|
282
|
+
ctxMenu.appendChild(selectAllBtn);
|
|
283
|
+
|
|
284
|
+
if (!isIOS || lastPointerType !== "touch") {
|
|
285
|
+
const pasteBtn = document.createElement("button");
|
|
286
|
+
pasteBtn.textContent = "Paste";
|
|
287
|
+
pasteBtn.addEventListener("click", () => {
|
|
288
|
+
navigator.clipboard
|
|
289
|
+
.readText()
|
|
290
|
+
.then((text) => {
|
|
291
|
+
send(bracketTextForPaste(prepareTextForTerminal(text)));
|
|
292
|
+
})
|
|
293
|
+
.catch(() => {
|
|
294
|
+
/* ignore */
|
|
295
|
+
});
|
|
296
|
+
hideCtxMenu();
|
|
297
|
+
});
|
|
298
|
+
ctxMenu.appendChild(pasteBtn);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Don't show an empty menu (iOS without selection has nothing to offer).
|
|
302
|
+
if (ctxMenu.childElementCount === 0) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Make it visible (so it has measurable dimensions) then clamp it
|
|
307
|
+
// inside the viewport, so it never opens off-screen near the right or
|
|
308
|
+
// bottom edge. position:fixed means x/y are already viewport
|
|
309
|
+
// coordinates. Setting left/top after adding the class is flash-free:
|
|
310
|
+
// it is all one synchronous task, so the browser paints only the
|
|
311
|
+
// clamped position.
|
|
312
|
+
ctxMenu.classList.add("visible");
|
|
313
|
+
const margin = 8;
|
|
314
|
+
const left = Math.max(margin, Math.min(x, window.innerWidth - ctxMenu.offsetWidth - margin));
|
|
315
|
+
const top = Math.max(margin, Math.min(y, window.innerHeight - ctxMenu.offsetHeight - margin));
|
|
316
|
+
ctxMenu.style.left = `${left}px`;
|
|
317
|
+
ctxMenu.style.top = `${top}px`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function mount(opts: MountOptions = {}): TerminalUI {
|
|
321
|
+
const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
|
|
322
|
+
const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
|
|
323
|
+
|
|
324
|
+
// --- DOM refs ---
|
|
325
|
+
outputEl = getRequired("term-output");
|
|
326
|
+
termWrap = getRequired("term");
|
|
327
|
+
input = getRequired("term-input") as HTMLTextAreaElement;
|
|
328
|
+
compositionViewEl = getRequired("composition-view");
|
|
329
|
+
ctxMenu = getRequired("ctx-menu");
|
|
330
|
+
ctrlBtn = document.getElementById("kb-ctrl");
|
|
331
|
+
|
|
332
|
+
isIOS =
|
|
333
|
+
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
334
|
+
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|
335
|
+
|
|
336
|
+
// --- Initialize layers ---
|
|
337
|
+
status.init();
|
|
338
|
+
|
|
339
|
+
render.init({
|
|
340
|
+
output: outputEl,
|
|
341
|
+
termWrap,
|
|
342
|
+
// Keep the helper textarea + IME composition view glued to the
|
|
343
|
+
// cursor on every render so iOS keyboard focus and IME candidate
|
|
344
|
+
// popups target the right area. Also re-render the predicted-cursor
|
|
345
|
+
// overlay so its position stays consistent with the just-drawn
|
|
346
|
+
// server cursor.
|
|
347
|
+
onCursorMove: () => {
|
|
348
|
+
composition.positionCompositionView();
|
|
349
|
+
const p = predict.get();
|
|
350
|
+
render.setPredictedCursor(p.row, p.col, p.active);
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
render.updateFontMetrics();
|
|
354
|
+
|
|
355
|
+
// predict redraws on every change to its predicted-cursor state.
|
|
356
|
+
predict.subscribe(() => {
|
|
357
|
+
const p = predict.get();
|
|
358
|
+
render.setPredictedCursor(p.row, p.col, p.active);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
composition.init({
|
|
362
|
+
textarea: input,
|
|
363
|
+
compositionView: compositionViewEl,
|
|
364
|
+
getCursorPx: render.getCursorPx,
|
|
365
|
+
send,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
scroll.init({
|
|
369
|
+
scrollEl: termWrap,
|
|
370
|
+
onUserScrollChange(scrolledUp) {
|
|
371
|
+
// Toggle .scrolled-up on the toolbar so CSS can show/hide the
|
|
372
|
+
// scroll-bottom button and grow the pill vertically.
|
|
373
|
+
const toolbar = document.getElementById("key-toolbar");
|
|
374
|
+
if (toolbar) {
|
|
375
|
+
toolbar.classList.toggle("scrolled-up", scrolledUp);
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
connection.init({
|
|
381
|
+
computeSize: render.computeSize,
|
|
382
|
+
getHaveThrough: render.getHighestIndex,
|
|
383
|
+
onResumeBounds: render.noteResumeBounds,
|
|
384
|
+
wsPath,
|
|
385
|
+
onMessage(msg) {
|
|
386
|
+
if (msg.type === "screen") {
|
|
387
|
+
render.handleScreen(msg);
|
|
388
|
+
if (fontsLoaded) {
|
|
389
|
+
const ld = document.getElementById("loading");
|
|
390
|
+
if (ld) {
|
|
391
|
+
ld.classList.add("fade");
|
|
392
|
+
ld.addEventListener("transitionend", () => {
|
|
393
|
+
ld.remove();
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
|
|
398
|
+
} else if (msg.type === "scroll") {
|
|
399
|
+
render.handleScroll(msg);
|
|
400
|
+
} else if (msg.type === "title") {
|
|
401
|
+
document.title = msg.title;
|
|
402
|
+
} else if (msg.type === "modes") {
|
|
403
|
+
render.updateReverseVideo();
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
onOpen() {
|
|
407
|
+
status.open();
|
|
408
|
+
// Do NOT call render.resetScreen() here. resetScreen flips the
|
|
409
|
+
// firstScreen flag, which causes the next screen frame to wipe
|
|
410
|
+
// the entire #term-output DOM (including all scrollback above
|
|
411
|
+
// the live viewport). On reconnect (e.g. iPad screen dim/wake)
|
|
412
|
+
// that destroys history the user could otherwise scroll up to.
|
|
413
|
+
// The server forces a full repaint via builder.Reset() on resume,
|
|
414
|
+
// which the client renders as row replacements within the live
|
|
415
|
+
// zone — scrollback DOM stays intact. The very first onOpen of
|
|
416
|
+
// page load still wipes correctly because firstScreen defaults
|
|
417
|
+
// to true at module load (only resetScrollback + resetScreen on
|
|
418
|
+
// onServerRestart explicitly trigger a full reset).
|
|
419
|
+
wsOpen = true;
|
|
420
|
+
maybeSendFirstResize();
|
|
421
|
+
},
|
|
422
|
+
onConnecting() {
|
|
423
|
+
status.reconnecting();
|
|
424
|
+
},
|
|
425
|
+
onClose() {
|
|
426
|
+
status.closed();
|
|
427
|
+
},
|
|
428
|
+
onOutboxFull() {
|
|
429
|
+
// The user kept typing through a long disconnect and we've
|
|
430
|
+
// capped the buffer. Surface visibly so they don't keep typing
|
|
431
|
+
// into the void.
|
|
432
|
+
status.closed();
|
|
433
|
+
},
|
|
434
|
+
onServerRestart() {
|
|
435
|
+
// Wipe the now-stale scrollback DOM and the live viewport so the
|
|
436
|
+
// user doesn't see ghost input from the previous server boot;
|
|
437
|
+
// the next screen frame from the fresh server populates the
|
|
438
|
+
// viewport. Banner explains why.
|
|
439
|
+
render.resetScrollback();
|
|
440
|
+
render.resetScreen();
|
|
441
|
+
predict.reset();
|
|
442
|
+
status.restarted();
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// --- Input handling ---
|
|
447
|
+
resetInputPlaceholder();
|
|
448
|
+
|
|
449
|
+
input.addEventListener("input", (e: Event) => {
|
|
450
|
+
// While IME composition is in progress, the textarea fires `input`
|
|
451
|
+
// events for each composing keystroke. composition.ts owns sending
|
|
452
|
+
// the final composed text in compositionend; we must NOT send the
|
|
453
|
+
// intermediate input value (it would duplicate the composition).
|
|
454
|
+
if (composition.isComposing()) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const ev = e as InputEvent;
|
|
459
|
+
const inputType = ev.inputType;
|
|
460
|
+
|
|
461
|
+
if (inputType === "deleteContentBackward") {
|
|
462
|
+
// Handled by keydown — just re-pad the placeholder so iOS
|
|
463
|
+
// key-repeat keeps firing (it needs content to delete).
|
|
464
|
+
resetInputPlaceholder();
|
|
465
|
+
return;
|
|
466
|
+
} else if (inputType === "deleteContentForward") {
|
|
467
|
+
resetInputPlaceholder();
|
|
468
|
+
return;
|
|
469
|
+
} else if (inputType === "deleteWordBackward") {
|
|
470
|
+
resetInputPlaceholder();
|
|
471
|
+
return;
|
|
472
|
+
} else if (inputType === "deleteWordForward") {
|
|
473
|
+
resetInputPlaceholder();
|
|
474
|
+
return;
|
|
475
|
+
} else if (typeof ev.data === "string" && ev.data.length > 0) {
|
|
476
|
+
// insertText / insertFromPaste / insertReplacementText etc. The
|
|
477
|
+
// `data` property carries exactly the new content; using it
|
|
478
|
+
// sidesteps having to diff against the placeholder.
|
|
479
|
+
//
|
|
480
|
+
// iOS Safari can deliver U+00A0 (NBSP) instead of U+0020 for the
|
|
481
|
+
// spacebar/autocorrect. Normalize so the shell receives an honest
|
|
482
|
+
// space byte. (Native paste is bracketed in composition.ts's paste
|
|
483
|
+
// handler before this fires; the insertFromPaste data here is a
|
|
484
|
+
// fallback for browsers that don't raise a separate paste event.)
|
|
485
|
+
send(applyStickyCtrl(ev.data.replace(/\u00A0/g, " ")));
|
|
486
|
+
} else {
|
|
487
|
+
// Fallback: anything in the textarea past the placeholder is new
|
|
488
|
+
// content. Covers browsers that don't populate inputType / data
|
|
489
|
+
// (older WebKit).
|
|
490
|
+
const v = input.value;
|
|
491
|
+
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
492
|
+
send(applyStickyCtrl(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " ")));
|
|
493
|
+
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
494
|
+
send(applyStickyCtrl(v.replace(/\u00A0/g, " ")));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
resetInputPlaceholder();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// Focus state on the terminal element, for CSS targeting (e.g. dimming
|
|
501
|
+
// the cursor when focus is elsewhere). Pattern from xterm.js.
|
|
502
|
+
input.addEventListener("focus", () => {
|
|
503
|
+
termWrap.classList.add("focus");
|
|
504
|
+
});
|
|
505
|
+
input.addEventListener("blur", () => {
|
|
506
|
+
// Restore the placeholder so the held-Backspace iOS path stays
|
|
507
|
+
// primed for the next focus. Also clears any leftover screen-reader
|
|
508
|
+
// text (xterm.js convention).
|
|
509
|
+
resetInputPlaceholder();
|
|
510
|
+
termWrap.classList.remove("focus");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// The textarea is the single keyboard target (desktop and touch alike);
|
|
514
|
+
// #term-output is display-only and never focused.
|
|
515
|
+
input.addEventListener("keydown", handleKeydown);
|
|
516
|
+
|
|
517
|
+
// --- Focus strategy ---
|
|
518
|
+
// One element, one job. #term-output is display + native selection only
|
|
519
|
+
// and is NEVER focused; the textarea owns the keyboard, the local typing
|
|
520
|
+
// buffer, and IME. Because the editable element is never the scroll
|
|
521
|
+
// content, the first touch-drag scrolls instead of placing a caret and a
|
|
522
|
+
// tap on a sparse screen still lands on the full-viewport scroll surface;
|
|
523
|
+
// because the display is not re-rendered as editable, a selection
|
|
524
|
+
// survives a redraw. Typed text, paste, and IME all arrive through the
|
|
525
|
+
// textarea's own input / paste / composition listeners (the latter two
|
|
526
|
+
// live in composition.ts).
|
|
527
|
+
|
|
528
|
+
// Touch tap: focus the hidden textarea to trigger iOS keyboard.
|
|
529
|
+
// On iOS Safari, focusing a hidden textarea from a `click` handler
|
|
530
|
+
// only opens the virtual keyboard if the click event arrives within
|
|
531
|
+
// iOS's user-gesture window. During heavy DOM mutations (streaming),
|
|
532
|
+
// the click event timing slips past that window — symptom: "while the
|
|
533
|
+
// agent is thinking I can't open the keyboard; only when the turn ends
|
|
534
|
+
// does tap work." `pointerup` fires synchronously with the user's
|
|
535
|
+
// finger lift and captures the gesture before iOS layout work can defer
|
|
536
|
+
// it, so focus() lands while the gesture is still "live". We track the
|
|
537
|
+
// start position from pointerdown and only treat the pointerup as a tap
|
|
538
|
+
// (vs scroll) when total movement is sub-threshold.
|
|
539
|
+
termWrap.addEventListener(
|
|
540
|
+
"pointerdown",
|
|
541
|
+
(e) => {
|
|
542
|
+
lastPointerType = e.pointerType;
|
|
543
|
+
pointerDownX = e.clientX;
|
|
544
|
+
pointerDownY = e.clientY;
|
|
545
|
+
},
|
|
546
|
+
{ passive: true },
|
|
547
|
+
);
|
|
548
|
+
termWrap.addEventListener(
|
|
549
|
+
"pointerup",
|
|
550
|
+
(e) => {
|
|
551
|
+
if (e.pointerType !== "touch") {
|
|
552
|
+
return; // mouse/trackpad handled by the click listener below
|
|
553
|
+
}
|
|
554
|
+
const dx = Math.abs(e.clientX - pointerDownX);
|
|
555
|
+
const dy = Math.abs(e.clientY - pointerDownY);
|
|
556
|
+
if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
|
|
557
|
+
return; // user was scrolling, not tapping
|
|
558
|
+
}
|
|
559
|
+
const sel = window.getSelection();
|
|
560
|
+
if (sel && sel.toString().length > 0) {
|
|
561
|
+
return; // preserve selection (long-tap selected text)
|
|
562
|
+
}
|
|
563
|
+
// Synchronous focus inside pointerup keeps us inside iOS's
|
|
564
|
+
// user-gesture window even when the streaming flush queue is busy.
|
|
565
|
+
input.focus({ preventScroll: true });
|
|
566
|
+
},
|
|
567
|
+
{ passive: true },
|
|
568
|
+
);
|
|
569
|
+
termWrap.addEventListener("click", (e) => {
|
|
570
|
+
// Terminal links: URLs detected by render.ts's linkifySpans are wrapped
|
|
571
|
+
// in <a class="term-link" target="_blank">. In a contenteditable element
|
|
572
|
+
// the browser treats link clicks as cursor-placement, not navigation.
|
|
573
|
+
// Intercept and open explicitly.
|
|
574
|
+
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
|
|
575
|
+
if (link) {
|
|
576
|
+
e.preventDefault();
|
|
577
|
+
window.open(link.href, "_blank", "noopener,noreferrer");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (lastPointerType === "touch") {
|
|
582
|
+
// Touch path is handled by pointerup above. The click listener
|
|
583
|
+
// is kept so non-pointer-event environments (very old iOS
|
|
584
|
+
// builds without PointerEvent support) still get a focus hook.
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// Mouse/trackpad: a plain click focuses the textarea so the user can
|
|
588
|
+
// type. Never steal focus mid-selection though — a drag-select ends
|
|
589
|
+
// with a click, and grabbing focus would collapse the selection the
|
|
590
|
+
// user just made and still wants to copy.
|
|
591
|
+
const sel = window.getSelection();
|
|
592
|
+
if (sel && sel.toString().length > 0) {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
focusTerminal();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// --- Viewport ---
|
|
599
|
+
// Centralized handling of iOS keyboard, window resize, and font-load
|
|
600
|
+
// reflows. Whenever the viewport settles, font metrics are remeasured
|
|
601
|
+
// and a resize is sent to the server. Snap-back-to-bottom (if the user
|
|
602
|
+
// was at the bottom before the transition) is handled inside viewport.ts.
|
|
603
|
+
viewport.init({
|
|
604
|
+
termWrap,
|
|
605
|
+
onSettled() {
|
|
606
|
+
render.updateFontMetrics();
|
|
607
|
+
// Only send resize if fonts are loaded — otherwise we'd send the
|
|
608
|
+
// wrong size (fallback font metrics) which causes the snap.
|
|
609
|
+
if (fontsLoaded) {
|
|
610
|
+
connection.sendResize();
|
|
611
|
+
}
|
|
612
|
+
composition.positionCompositionView();
|
|
613
|
+
const sz = render.computeSize();
|
|
614
|
+
predict.setDimensions(sz.cols, sz.rows);
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// Only wait for the Regular weight — it determines cell size.
|
|
619
|
+
// Bold/Italic load lazily when first used; style pop is barely noticeable.
|
|
620
|
+
const regularFont = document.fonts.load(fontReady).then(() => {
|
|
621
|
+
/* discard result */
|
|
622
|
+
});
|
|
623
|
+
void regularFont.then(() => {
|
|
624
|
+
fontsLoaded = true;
|
|
625
|
+
requestAnimationFrame(() => {
|
|
626
|
+
maybeSendFirstResize();
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// Connect immediately — the WS open triggers maybeSendFirstResize.
|
|
631
|
+
render.updateFontMetrics();
|
|
632
|
+
composition.positionCompositionView();
|
|
633
|
+
connection.connect();
|
|
634
|
+
focusTerminal();
|
|
635
|
+
|
|
636
|
+
document.addEventListener("visibilitychange", () => {
|
|
637
|
+
if (document.visibilityState === "visible") {
|
|
638
|
+
connection.reconnectNow();
|
|
639
|
+
focusTerminal();
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
window.addEventListener("pageshow", () => {
|
|
643
|
+
connection.reconnectNow();
|
|
644
|
+
focusTerminal();
|
|
645
|
+
});
|
|
646
|
+
// Network came back (cellular↔wifi handoff, tunnel reconnect): re-establish
|
|
647
|
+
// at once rather than waiting out the reconnect backoff. reconnectNow tears
|
|
648
|
+
// down any zombie socket first, and resume-by-index backfills what was missed.
|
|
649
|
+
window.addEventListener("online", () => {
|
|
650
|
+
connection.reconnectNow();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// --- Scroll-to-bottom (inside toolbar grid) ---
|
|
654
|
+
const scrollBtn = document.getElementById("scroll-bottom");
|
|
655
|
+
if (scrollBtn) {
|
|
656
|
+
// pointerdown (like the other toolbar keys) so touch devices enter
|
|
657
|
+
// :active and show press feedback; preventDefault keeps focus on the
|
|
658
|
+
// terminal. click is kept for keyboard activation of the desktop FAB.
|
|
659
|
+
// scrollToBottom() is idempotent, so the pointerdown+click pair on a
|
|
660
|
+
// mouse press is harmless.
|
|
661
|
+
scrollBtn.addEventListener("pointerdown", (e) => {
|
|
662
|
+
e.preventDefault();
|
|
663
|
+
scroll.scrollToBottom();
|
|
664
|
+
});
|
|
665
|
+
scrollBtn.addEventListener("click", () => {
|
|
666
|
+
scroll.scrollToBottom();
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// --- Context menu ---
|
|
671
|
+
termWrap.addEventListener("contextmenu", (e: MouseEvent) => {
|
|
672
|
+
e.preventDefault();
|
|
673
|
+
showCtxMenu(e.clientX, e.clientY);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// Long-press to trigger the same menu on touch devices that don't
|
|
677
|
+
// already provide a native callout. iOS Safari shows its own
|
|
678
|
+
// long-press selection callout (Select / Copy / Paste) which fully
|
|
679
|
+
// covers the use case AND would race our custom menu (two stacked
|
|
680
|
+
// menus appear if the user long-presses an existing selection), so
|
|
681
|
+
// we skip our timer on iOS and let the native callout do its job.
|
|
682
|
+
// Android Chrome fires `contextmenu` on long-press release, but the
|
|
683
|
+
// timer also helps on browsers that don't, e.g. some Windows touch
|
|
684
|
+
// devices.
|
|
685
|
+
if (!isIOS) {
|
|
686
|
+
termWrap.addEventListener(
|
|
687
|
+
"touchstart",
|
|
688
|
+
(e: TouchEvent) => {
|
|
689
|
+
if (e.touches.length !== 1) {
|
|
690
|
+
if (longPressTimer) {
|
|
691
|
+
clearTimeout(longPressTimer);
|
|
692
|
+
longPressTimer = 0;
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- touches.length checked above
|
|
697
|
+
const t = e.touches[0]!;
|
|
698
|
+
longPressOrigin = { x: t.clientX, y: t.clientY };
|
|
699
|
+
longPressTimer = window.setTimeout(() => {
|
|
700
|
+
longPressTimer = 0;
|
|
701
|
+
showCtxMenu(longPressOrigin.x, longPressOrigin.y);
|
|
702
|
+
}, LONG_PRESS_MS);
|
|
703
|
+
},
|
|
704
|
+
{ passive: true },
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
termWrap.addEventListener(
|
|
708
|
+
"touchmove",
|
|
709
|
+
(e: TouchEvent) => {
|
|
710
|
+
if (!longPressTimer || e.touches.length !== 1) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- touches.length checked above
|
|
714
|
+
const t = e.touches[0]!;
|
|
715
|
+
const dx = t.clientX - longPressOrigin.x;
|
|
716
|
+
const dy = t.clientY - longPressOrigin.y;
|
|
717
|
+
if (dx * dx + dy * dy > LONG_PRESS_MOVE_THRESHOLD_PX * LONG_PRESS_MOVE_THRESHOLD_PX) {
|
|
718
|
+
clearTimeout(longPressTimer);
|
|
719
|
+
longPressTimer = 0;
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
{ passive: true },
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
termWrap.addEventListener(
|
|
726
|
+
"touchend",
|
|
727
|
+
() => {
|
|
728
|
+
if (longPressTimer) {
|
|
729
|
+
clearTimeout(longPressTimer);
|
|
730
|
+
longPressTimer = 0;
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
{ passive: true },
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
termWrap.addEventListener(
|
|
737
|
+
"touchcancel",
|
|
738
|
+
() => {
|
|
739
|
+
if (longPressTimer) {
|
|
740
|
+
clearTimeout(longPressTimer);
|
|
741
|
+
longPressTimer = 0;
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
{ passive: true },
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
document.addEventListener("click", () => {
|
|
749
|
+
hideCtxMenu();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// --- Mobile key toolbar ---
|
|
753
|
+
const keyToolbar = document.getElementById("key-toolbar");
|
|
754
|
+
if (keyToolbar) {
|
|
755
|
+
requestAnimationFrame(() =>
|
|
756
|
+
requestAnimationFrame(() => {
|
|
757
|
+
keyToolbar.classList.remove("no-transition");
|
|
758
|
+
}),
|
|
759
|
+
);
|
|
760
|
+
const toggleBtn = document.getElementById("kb-toggle");
|
|
761
|
+
toggleBtn?.addEventListener("pointerdown", (e) => {
|
|
762
|
+
e.preventDefault();
|
|
763
|
+
keyToolbar.classList.toggle("collapsed");
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const keyMap: Record<string, string> = {
|
|
767
|
+
"kb-up": "\x1b[A",
|
|
768
|
+
"kb-down": "\x1b[B",
|
|
769
|
+
"kb-left": "\x1b[D",
|
|
770
|
+
"kb-right": "\x1b[C",
|
|
771
|
+
"kb-esc": "\x1b",
|
|
772
|
+
"kb-tab": "\t",
|
|
773
|
+
"kb-enter": "\r",
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
for (const [id, seq] of Object.entries(keyMap)) {
|
|
777
|
+
document.getElementById(id)?.addEventListener("pointerdown", (e) => {
|
|
778
|
+
e.preventDefault();
|
|
779
|
+
setCtrlArmed(false);
|
|
780
|
+
send(seq);
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Sticky Ctrl: tap to arm/disarm. preventDefault keeps focus on the
|
|
785
|
+
// terminal so the iOS virtual keyboard stays up for the next tap.
|
|
786
|
+
ctrlBtn?.addEventListener("pointerdown", (e) => {
|
|
787
|
+
e.preventDefault();
|
|
788
|
+
setCtrlArmed(!ctrlArmed);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// --- Copy feedback toast ---
|
|
793
|
+
document.addEventListener("copy", () => {
|
|
794
|
+
status.toast("Copied");
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
return { focus: focusTerminal };
|
|
798
|
+
}
|