remobi 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.
@@ -0,0 +1,1637 @@
1
+ import { defaultConfig, defineConfig } from "./config.mjs";
2
+
3
+ //#region src/actions/registry.ts
4
+ function createActionRegistry() {
5
+ const handlers = /* @__PURE__ */ new Map();
6
+ let sendQueue = Promise.resolve();
7
+ function register(type, handler) {
8
+ handlers.set(type, handler);
9
+ }
10
+ async function execute(action, context) {
11
+ const handler = handlers.get(action.type);
12
+ if (!handler) return false;
13
+ if (action.type === "send") {
14
+ const current = sendQueue.then(async () => {
15
+ await handler(action, context);
16
+ });
17
+ sendQueue = current.catch(() => {});
18
+ await current;
19
+ return true;
20
+ }
21
+ if (action.type === "paste") {
22
+ await sendQueue;
23
+ await handler(action, context);
24
+ return true;
25
+ }
26
+ await handler(action, context);
27
+ return true;
28
+ }
29
+ return {
30
+ register,
31
+ execute
32
+ };
33
+ }
34
+ function createDefaultActionRegistry() {
35
+ const registry = createActionRegistry();
36
+ let pasteQueue = Promise.resolve();
37
+ registry.register("send", (action, context) => {
38
+ if (action.type !== "send") return;
39
+ return context.sendText(action.data).then(() => context.focusIfNeeded());
40
+ });
41
+ registry.register("paste", (_action, context) => {
42
+ if (!navigator.clipboard || typeof navigator.clipboard.readText !== "function") {
43
+ context.focusIfNeeded();
44
+ return;
45
+ }
46
+ const runPaste = async () => {
47
+ try {
48
+ const text = await navigator.clipboard.readText();
49
+ if (!text) return;
50
+ if (context.sendRawText) {
51
+ await context.sendRawText(text);
52
+ return;
53
+ }
54
+ await context.sendText(text);
55
+ } catch {} finally {
56
+ context.focusIfNeeded();
57
+ }
58
+ };
59
+ const current = pasteQueue.then(runPaste);
60
+ pasteQueue = current.catch(() => {});
61
+ return current;
62
+ });
63
+ registry.register("ctrl-modifier", (_action, context) => {
64
+ if (context.toggleCtrlModifier) context.toggleCtrlModifier();
65
+ else context.focusIfNeeded();
66
+ });
67
+ registry.register("drawer-toggle", (_action, context) => {
68
+ if (context.openDrawer) context.openDrawer();
69
+ else context.focusIfNeeded();
70
+ });
71
+ registry.register("combo-picker", (_action, context) => {
72
+ if (context.openComboPicker) context.openComboPicker({
73
+ sendText: async (data) => {
74
+ await registry.execute({
75
+ type: "send",
76
+ data
77
+ }, {
78
+ ...context,
79
+ sendText: context.sendRawText ?? context.sendText
80
+ });
81
+ },
82
+ focusIfNeeded: context.focusIfNeeded
83
+ });
84
+ else context.focusIfNeeded();
85
+ });
86
+ return registry;
87
+ }
88
+
89
+ //#endregion
90
+ //#region src/util/dom.ts
91
+ /** Create an element with optional attributes and children */
92
+ function el(tag, attrs, ...children) {
93
+ const element = document.createElement(tag);
94
+ if (attrs) for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, value);
95
+ for (const child of children) if (typeof child === "string") element.appendChild(document.createTextNode(child));
96
+ else element.appendChild(child);
97
+ return element;
98
+ }
99
+ /** Create a button with label and aria-label */
100
+ function btn(label, ariaLabel) {
101
+ const button = el("button");
102
+ button.textContent = label;
103
+ if (ariaLabel) button.setAttribute("aria-label", ariaLabel);
104
+ return button;
105
+ }
106
+
107
+ //#endregion
108
+ //#region src/util/haptic.ts
109
+ /** Short haptic vibration — no-op on devices without vibration API */
110
+ function haptic() {
111
+ if (navigator.vibrate) navigator.vibrate(10);
112
+ }
113
+
114
+ //#endregion
115
+ //#region src/controls/combo-picker.ts
116
+ const NAMED_KEYS = [
117
+ "pagedown",
118
+ "pageup",
119
+ "return",
120
+ "escape",
121
+ "backspace",
122
+ "delete",
123
+ "enter",
124
+ "space",
125
+ "tab",
126
+ "home",
127
+ "end",
128
+ "left",
129
+ "right",
130
+ "down",
131
+ "up",
132
+ "pgdn",
133
+ "pgup",
134
+ "del",
135
+ "esc",
136
+ "bs"
137
+ ];
138
+ function parseComboTokens(value) {
139
+ const trimmed = value.trim();
140
+ if (trimmed.length === 0) return null;
141
+ let keyToken = null;
142
+ let prefix = "";
143
+ for (const key of NAMED_KEYS) {
144
+ const pattern = new RegExp(`(?:^|[+\\-\\s])(${key})$`, "i");
145
+ const match = trimmed.match(pattern);
146
+ if (!match || match.index === void 0) continue;
147
+ const matchedKey = match[1];
148
+ if (!matchedKey) continue;
149
+ const keyStart = match.index + match[0].length - matchedKey.length;
150
+ keyToken = matchedKey;
151
+ prefix = trimmed.slice(0, keyStart);
152
+ break;
153
+ }
154
+ if (!keyToken) {
155
+ keyToken = trimmed[trimmed.length - 1] ?? "";
156
+ prefix = trimmed.slice(0, -1);
157
+ }
158
+ return {
159
+ modifiers: prefix.split(/[+\-\s]+/).map((token) => token.trim()).filter((token) => token.length > 0),
160
+ key: keyToken
161
+ };
162
+ }
163
+ function resolveBaseKey(key, keyLower) {
164
+ if (key.length === 1) return {
165
+ ok: true,
166
+ data: key
167
+ };
168
+ if (keyLower === "enter" || keyLower === "return") return {
169
+ ok: true,
170
+ data: "\r"
171
+ };
172
+ if (keyLower === "tab") return {
173
+ ok: true,
174
+ data: " "
175
+ };
176
+ if (keyLower === "space") return {
177
+ ok: true,
178
+ data: " "
179
+ };
180
+ if (keyLower === "esc" || keyLower === "escape") return {
181
+ ok: true,
182
+ data: "\x1B"
183
+ };
184
+ if (keyLower === "backspace" || keyLower === "bs") return {
185
+ ok: true,
186
+ data: ""
187
+ };
188
+ if (keyLower === "delete" || keyLower === "del") return {
189
+ ok: true,
190
+ data: "\x1B[3~"
191
+ };
192
+ if (keyLower === "up") return {
193
+ ok: true,
194
+ data: "\x1B[A"
195
+ };
196
+ if (keyLower === "down") return {
197
+ ok: true,
198
+ data: "\x1B[B"
199
+ };
200
+ if (keyLower === "right") return {
201
+ ok: true,
202
+ data: "\x1B[C"
203
+ };
204
+ if (keyLower === "left") return {
205
+ ok: true,
206
+ data: "\x1B[D"
207
+ };
208
+ if (keyLower === "home") return {
209
+ ok: true,
210
+ data: "\x1B[H"
211
+ };
212
+ if (keyLower === "end") return {
213
+ ok: true,
214
+ data: "\x1B[F"
215
+ };
216
+ if (keyLower === "pageup" || keyLower === "pgup") return {
217
+ ok: true,
218
+ data: "\x1B[5~"
219
+ };
220
+ if (keyLower === "pagedown" || keyLower === "pgdn") return {
221
+ ok: true,
222
+ data: "\x1B[6~"
223
+ };
224
+ return {
225
+ ok: false,
226
+ error: "Unknown key. Try one character, Enter, Tab, Space, Esc, arrows, Home/End, PgUp/PgDn."
227
+ };
228
+ }
229
+ function applyCtrl(base, key, keyLower) {
230
+ if (base.length !== 1) return {
231
+ ok: false,
232
+ error: "Ctrl supports single characters (for Enter use M-Enter)."
233
+ };
234
+ if (keyLower === "space") return {
235
+ ok: true,
236
+ data: "\0"
237
+ };
238
+ if (key.length !== 1) return {
239
+ ok: false,
240
+ error: "Unsupported Ctrl combo for this key."
241
+ };
242
+ if (key === "[") return {
243
+ ok: true,
244
+ data: "\x1B"
245
+ };
246
+ if (key === "\\") return {
247
+ ok: true,
248
+ data: ""
249
+ };
250
+ if (key === "]") return {
251
+ ok: true,
252
+ data: ""
253
+ };
254
+ if (key === "6") return {
255
+ ok: true,
256
+ data: ""
257
+ };
258
+ if (key === "-" || key === "/") return {
259
+ ok: true,
260
+ data: ""
261
+ };
262
+ if (key === "8") return {
263
+ ok: true,
264
+ data: ""
265
+ };
266
+ const code = key.charCodeAt(0);
267
+ if (code >= 65 && code <= 90 || code >= 97 && code <= 122) return {
268
+ ok: true,
269
+ data: String.fromCharCode(code & 31)
270
+ };
271
+ return {
272
+ ok: false,
273
+ error: "Unsupported Ctrl combo for this key."
274
+ };
275
+ }
276
+ function parseComboInput(value) {
277
+ const tokens = parseComboTokens(value);
278
+ if (!tokens) return {
279
+ ok: false,
280
+ error: "Type a combo like C-s, M-Enter, or C-[."
281
+ };
282
+ let ctrl = false;
283
+ let alt = false;
284
+ for (const modifier of tokens.modifiers) {
285
+ const token = modifier.toLowerCase();
286
+ if (token === "c" || token === "ctrl" || token === "control") {
287
+ ctrl = true;
288
+ continue;
289
+ }
290
+ if (token === "m" || token === "meta" || token === "alt" || token === "a") {
291
+ alt = true;
292
+ continue;
293
+ }
294
+ if (token === "s" || token === "shift") continue;
295
+ return {
296
+ ok: false,
297
+ error: `Unknown modifier: ${modifier}`
298
+ };
299
+ }
300
+ const keyToken = tokens.key;
301
+ if (!keyToken) return {
302
+ ok: false,
303
+ error: "Missing key in combo."
304
+ };
305
+ const keyLower = keyToken.toLowerCase();
306
+ const base = resolveBaseKey(keyToken, keyLower);
307
+ if (!base.ok) return base;
308
+ let data = base.data;
309
+ if (ctrl) {
310
+ const next = applyCtrl(data, keyToken, keyLower);
311
+ if (!next.ok) return next;
312
+ data = next.data;
313
+ }
314
+ if (alt) data = `\x1b${data}`;
315
+ return {
316
+ ok: true,
317
+ data
318
+ };
319
+ }
320
+ function createComboPicker() {
321
+ const backdrop = el("div", { id: "wt-combo-backdrop" });
322
+ const panel = el("div", { id: "wt-combo-panel" });
323
+ const title = el("h3");
324
+ title.textContent = "Send combo";
325
+ const description = el("p");
326
+ description.textContent = "Examples: C-s, C-[, M-Enter, Alt-x";
327
+ const input = el("input", {
328
+ type: "text",
329
+ placeholder: "Combo",
330
+ "aria-label": "Combo input",
331
+ autocomplete: "off",
332
+ autocorrect: "off",
333
+ autocapitalize: "off",
334
+ spellcheck: "false"
335
+ });
336
+ const error = el("p", { class: "wt-combo-error" });
337
+ const actions = el("div", { class: "wt-combo-actions" });
338
+ const cancelButton = el("button", { type: "button" }, "Cancel");
339
+ const sendButton = el("button", { type: "button" }, "Send");
340
+ actions.appendChild(cancelButton);
341
+ actions.appendChild(sendButton);
342
+ panel.appendChild(title);
343
+ panel.appendChild(description);
344
+ panel.appendChild(input);
345
+ panel.appendChild(error);
346
+ panel.appendChild(actions);
347
+ backdrop.appendChild(panel);
348
+ let currentDispatch = null;
349
+ function clearError() {
350
+ error.textContent = "";
351
+ }
352
+ function setError(message) {
353
+ error.textContent = message;
354
+ }
355
+ function closeAndFocus() {
356
+ const dispatch = currentDispatch;
357
+ backdrop.style.display = "none";
358
+ currentDispatch = null;
359
+ clearError();
360
+ input.value = "";
361
+ if (dispatch) dispatch.focusIfNeeded();
362
+ }
363
+ async function submit() {
364
+ const dispatch = currentDispatch;
365
+ if (!dispatch) return;
366
+ const parsed = parseComboInput(input.value);
367
+ if (!parsed.ok) {
368
+ setError(parsed.error);
369
+ return;
370
+ }
371
+ backdrop.style.display = "none";
372
+ currentDispatch = null;
373
+ clearError();
374
+ input.value = "";
375
+ try {
376
+ await dispatch.sendText(parsed.data);
377
+ } catch (errorValue) {
378
+ console.error("remobi: combo send failed", errorValue);
379
+ } finally {
380
+ dispatch.focusIfNeeded();
381
+ }
382
+ }
383
+ function open(dispatch) {
384
+ currentDispatch = dispatch;
385
+ clearError();
386
+ input.value = "";
387
+ backdrop.style.display = "flex";
388
+ setTimeout(() => input.focus(), 0);
389
+ }
390
+ function close() {
391
+ closeAndFocus();
392
+ }
393
+ backdrop.addEventListener("click", (event) => {
394
+ if (event.target !== backdrop) return;
395
+ haptic();
396
+ closeAndFocus();
397
+ });
398
+ cancelButton.addEventListener("click", (event) => {
399
+ event.preventDefault();
400
+ haptic();
401
+ closeAndFocus();
402
+ });
403
+ sendButton.addEventListener("click", (event) => {
404
+ event.preventDefault();
405
+ haptic();
406
+ submit();
407
+ });
408
+ input.addEventListener("keydown", (event) => {
409
+ if (event.key === "Enter") {
410
+ event.preventDefault();
411
+ haptic();
412
+ submit();
413
+ return;
414
+ }
415
+ if (event.key === "Escape") {
416
+ event.preventDefault();
417
+ haptic();
418
+ closeAndFocus();
419
+ }
420
+ });
421
+ return {
422
+ element: backdrop,
423
+ open,
424
+ close
425
+ };
426
+ }
427
+
428
+ //#endregion
429
+ //#region src/util/keyboard.ts
430
+ /** Threshold in pixels — if the gap between innerHeight and viewport height exceeds this, the keyboard is open */
431
+ const KB_THRESHOLD = 150;
432
+ /** Check whether the virtual keyboard appears to be open */
433
+ function isKeyboardOpen() {
434
+ const vp = window.visualViewport;
435
+ if (!vp) return false;
436
+ return window.innerHeight - vp.height > KB_THRESHOLD;
437
+ }
438
+ /** Focus terminal only if the keyboard was already visible */
439
+ function conditionalFocus(term, kbWasOpen) {
440
+ if (kbWasOpen) term.focus();
441
+ }
442
+
443
+ //#endregion
444
+ //#region src/util/terminal.ts
445
+ /** Send data to the terminal as if the user typed it */
446
+ function sendData(term, data) {
447
+ term.input(data, true);
448
+ }
449
+ /** Trigger xterm resize via window resize event */
450
+ function resizeTerm() {
451
+ window.dispatchEvent(new Event("resize"));
452
+ }
453
+ /**
454
+ * Wait for `window.term` to become available (ttyd sets it).
455
+ * Resolves with the terminal instance, rejects after maxRetries (default 100 = 10s).
456
+ */
457
+ function waitForTerm(maxRetries = 100) {
458
+ return new Promise((resolve, reject) => {
459
+ let attempts = 0;
460
+ function check() {
461
+ if (window.term) resolve(window.term);
462
+ else if (attempts >= maxRetries) reject(/* @__PURE__ */ new Error(`waitForTerm: window.term not available after ${maxRetries * 100}ms`));
463
+ else {
464
+ attempts += 1;
465
+ setTimeout(check, 100);
466
+ }
467
+ }
468
+ check();
469
+ });
470
+ }
471
+
472
+ //#endregion
473
+ //#region src/controls/floating-buttons.ts
474
+ function createGroupButton(term, def, config, hooks, actions, openDrawer, openComboPicker) {
475
+ const button = el("button");
476
+ button.textContent = def.label;
477
+ button.setAttribute("aria-label", def.description);
478
+ button.addEventListener("click", (e) => {
479
+ e.preventDefault();
480
+ const kbWasOpen = isKeyboardOpen();
481
+ haptic();
482
+ async function sendWithHooks(data) {
483
+ const before = await hooks.runBeforeSendData({
484
+ term,
485
+ config,
486
+ source: "floating-buttons",
487
+ actionType: def.action.type,
488
+ kbWasOpen,
489
+ data
490
+ });
491
+ if (before.blocked) return;
492
+ sendData(term, before.data);
493
+ await hooks.runAfterSendData({
494
+ term,
495
+ config,
496
+ source: "floating-buttons",
497
+ actionType: def.action.type,
498
+ kbWasOpen,
499
+ data: before.data
500
+ });
501
+ }
502
+ actions.execute(def.action, {
503
+ term,
504
+ kbWasOpen,
505
+ focusIfNeeded: () => conditionalFocus(term, kbWasOpen),
506
+ sendText: sendWithHooks,
507
+ sendRawText: sendWithHooks,
508
+ openDrawer,
509
+ openComboPicker
510
+ }).catch((error) => {
511
+ console.error("remobi: floating button action failed", error);
512
+ conditionalFocus(term, kbWasOpen);
513
+ });
514
+ });
515
+ return button;
516
+ }
517
+ /**
518
+ * Create one container element per floating button group.
519
+ * Each group is positioned via CSS classes (`wt-floating-group`, `wt-floating-${position}`)
520
+ * and rendered as a row or column depending on `direction` (default: row).
521
+ */
522
+ function createFloatingButtons(term, groups, config, hooks, actions, openDrawer, openComboPicker) {
523
+ const elements = [];
524
+ for (const group of groups) {
525
+ const container = el("div", { class: `wt-floating-group wt-floating-${group.position}${group.direction === "column" ? " wt-floating-column" : ""}` });
526
+ for (const def of group.buttons) container.appendChild(createGroupButton(term, def, config, hooks, actions, openDrawer, openComboPicker));
527
+ elements.push(container);
528
+ }
529
+ return { elements };
530
+ }
531
+
532
+ //#endregion
533
+ //#region src/controls/font-size.ts
534
+ /** Change terminal font size by delta, clamped to config range */
535
+ function changeFontSize(term, delta, font) {
536
+ const current = term.options.fontSize;
537
+ const next = Math.max(font.sizeRange[0], Math.min(font.sizeRange[1], current + delta));
538
+ if (next !== current) {
539
+ term.options.fontSize = next;
540
+ resizeTerm();
541
+ }
542
+ }
543
+ /** Create the font size controls (-, +) and help button */
544
+ function createFontControls(term, font) {
545
+ const container = el("div", { id: "wt-font-controls" });
546
+ const btnMinus = btn("−", "Decrease font size");
547
+ const btnPlus = btn("+", "Increase font size");
548
+ const btnHelp = btn("?", "Help");
549
+ container.appendChild(btnMinus);
550
+ container.appendChild(btnPlus);
551
+ container.appendChild(btnHelp);
552
+ btnMinus.addEventListener("click", (e) => {
553
+ e.preventDefault();
554
+ haptic();
555
+ changeFontSize(term, -2, font);
556
+ });
557
+ btnPlus.addEventListener("click", (e) => {
558
+ e.preventDefault();
559
+ haptic();
560
+ changeFontSize(term, 2, font);
561
+ });
562
+ return {
563
+ element: container,
564
+ helpButton: btnHelp
565
+ };
566
+ }
567
+
568
+ //#endregion
569
+ //#region src/controls/help.ts
570
+ /** Create a table row with two cells — textContent auto-escapes */
571
+ function row(left, right) {
572
+ return el("tr", {}, el("td", {}, left), el("td", {}, right));
573
+ }
574
+ function renderButtonTable(title, buttons) {
575
+ const frag = document.createDocumentFragment();
576
+ frag.appendChild(el("h2", {}, title));
577
+ const table = el("table");
578
+ for (const button of buttons) table.appendChild(row(button.label || button.id || "Unnamed", button.description || "No description"));
579
+ frag.appendChild(table);
580
+ return frag;
581
+ }
582
+ function renderGestures(config) {
583
+ const frag = document.createDocumentFragment();
584
+ frag.appendChild(el("h2", {}, "Gestures"));
585
+ const table = el("table");
586
+ if (config.gestures.swipe.enabled) {
587
+ table.appendChild(row("Swipe right", config.gestures.swipe.rightLabel));
588
+ table.appendChild(row("Swipe left", config.gestures.swipe.leftLabel));
589
+ }
590
+ if (config.gestures.pinch.enabled) table.appendChild(row("Pinch in/out", "Decrease/increase font size"));
591
+ if (config.gestures.scroll.enabled) if (config.gestures.scroll.strategy === "wheel") {
592
+ table.appendChild(row("Finger drag", "Send wheel scroll events to terminal apps"));
593
+ table.appendChild(row("Side ▲ ▼", "Send wheel-up / wheel-down at terminal centre"));
594
+ } else {
595
+ table.appendChild(row("Finger drag", "Send PageUp / PageDown keys"));
596
+ table.appendChild(row("Side ▲ ▼", "Send PageUp / PageDown keys"));
597
+ }
598
+ if (table.rows.length === 0) table.appendChild(row("None", "All gestures are disabled in config"));
599
+ frag.appendChild(table);
600
+ return frag;
601
+ }
602
+ /** Build the help overlay content as a DocumentFragment — no innerHTML */
603
+ function buildHelpContent(config) {
604
+ const topRightButtons = [{
605
+ id: "font-size",
606
+ label: "− / +",
607
+ description: "Decrease / increase font size",
608
+ action: {
609
+ type: "send",
610
+ data: ""
611
+ }
612
+ }, {
613
+ id: "help",
614
+ label: "?",
615
+ description: "Open this help screen",
616
+ action: {
617
+ type: "send",
618
+ data: ""
619
+ }
620
+ }];
621
+ const frag = document.createDocumentFragment();
622
+ const closeBtn = el("button", { class: "wt-help-close" }, "×");
623
+ frag.appendChild(closeBtn);
624
+ frag.appendChild(renderButtonTable("Toolbar — Row 1", config.toolbar.row1));
625
+ frag.appendChild(renderButtonTable("Toolbar — Row 2", config.toolbar.row2));
626
+ frag.appendChild(renderButtonTable("Drawer Buttons", config.drawer.buttons));
627
+ frag.appendChild(renderGestures(config));
628
+ frag.appendChild(renderButtonTable("Top-Right Controls", topRightButtons));
629
+ if (config.floatingButtons.length > 0) {
630
+ const groups = config.floatingButtons;
631
+ if (groups.length === 1 && groups[0] !== void 0) frag.appendChild(renderButtonTable("Floating Buttons", groups[0].buttons));
632
+ else for (const group of groups) frag.appendChild(renderButtonTable(`Floating Buttons (${group.position})`, group.buttons));
633
+ }
634
+ return frag;
635
+ }
636
+ /** Create the help overlay and wire the help button */
637
+ function createHelpOverlay(term, helpButton, config) {
638
+ const overlay = el("div", { id: "wt-help" });
639
+ overlay.appendChild(buildHelpContent(config));
640
+ function open() {
641
+ overlay.style.display = "block";
642
+ }
643
+ function close() {
644
+ overlay.style.display = "none";
645
+ }
646
+ overlay.addEventListener("click", (e) => {
647
+ const target = e.target;
648
+ if (!(target instanceof HTMLElement)) return;
649
+ if (target === overlay || target.classList.contains("wt-help-close")) {
650
+ const kbWasOpen = isKeyboardOpen();
651
+ haptic();
652
+ close();
653
+ conditionalFocus(term, kbWasOpen);
654
+ }
655
+ });
656
+ helpButton.addEventListener("click", (e) => {
657
+ e.preventDefault();
658
+ haptic();
659
+ open();
660
+ });
661
+ return {
662
+ element: overlay,
663
+ open,
664
+ close
665
+ };
666
+ }
667
+
668
+ //#endregion
669
+ //#region src/gestures/lock.ts
670
+ /** Create a gesture lock in the unclaimed state */
671
+ function createGestureLock() {
672
+ return { current: "none" };
673
+ }
674
+ /** Try to claim the lock. Succeeds only if no gesture owns it yet. */
675
+ function tryLock(lock, type) {
676
+ if (lock.current !== "none") return false;
677
+ lock.current = type;
678
+ return true;
679
+ }
680
+ /** Release the lock back to unclaimed */
681
+ function resetLock(lock) {
682
+ lock.current = "none";
683
+ }
684
+
685
+ //#endregion
686
+ //#region src/gestures/scroll.ts
687
+ /** SGR mouse wheel escape sequence for a given direction */
688
+ function scrollSeq(direction, x, y) {
689
+ return `\x1b[\x3c${direction === "up" ? 64 : 65};${x};${y}M`;
690
+ }
691
+ /** Page navigation key sequence for a given direction */
692
+ function pageSeq(direction) {
693
+ return direction === "up" ? "\x1B[5~" : "\x1B[6~";
694
+ }
695
+ function clamp(value, min, max) {
696
+ return Math.min(max, Math.max(min, value));
697
+ }
698
+ function terminalGrid(screenRect, term) {
699
+ const colsFromTerm = term.cols;
700
+ const rowsFromTerm = term.rows;
701
+ if (typeof colsFromTerm === "number" && typeof rowsFromTerm === "number") {
702
+ if (colsFromTerm > 0 && rowsFromTerm > 0) return {
703
+ cols: Math.round(colsFromTerm),
704
+ rows: Math.round(rowsFromTerm)
705
+ };
706
+ }
707
+ const measure = document.querySelector(".xterm-char-measure-element");
708
+ if (measure instanceof HTMLElement) {
709
+ const measureRect = measure.getBoundingClientRect();
710
+ if (measureRect.width > 0 && measureRect.height > 0) return {
711
+ cols: Math.max(1, Math.round(screenRect.width / measureRect.width)),
712
+ rows: Math.max(1, Math.round(screenRect.height / measureRect.height))
713
+ };
714
+ }
715
+ return {
716
+ cols: 80,
717
+ rows: 24
718
+ };
719
+ }
720
+ function touchToCell(touch, screen, term) {
721
+ const rect = screen.getBoundingClientRect();
722
+ const { cols, rows } = terminalGrid(rect, term);
723
+ const width = Math.max(1, rect.width);
724
+ const height = Math.max(1, rect.height);
725
+ const relX = clamp(touch.clientX - rect.left, 0, width);
726
+ const relY = clamp(touch.clientY - rect.top, 0, height);
727
+ return {
728
+ x: clamp(Math.floor(relX / width * cols) + 1, 1, cols),
729
+ y: clamp(Math.floor(relY / height * rows) + 1, 1, rows)
730
+ };
731
+ }
732
+ /** Attach single-finger vertical scroll to the xterm screen */
733
+ function attachScrollGesture(term, config, lock, isDrawerOpen) {
734
+ let startY = 0;
735
+ let lastY = 0;
736
+ let accDelta = 0;
737
+ let lastWheelAt = 0;
738
+ let screenEl = null;
739
+ function onTouchStart(e) {
740
+ if (!(e instanceof TouchEvent)) return;
741
+ if (e.touches.length === 1) {
742
+ const t = e.touches[0];
743
+ if (!t) return;
744
+ startY = t.clientY;
745
+ lastY = t.clientY;
746
+ accDelta = 0;
747
+ }
748
+ }
749
+ function onTouchMove(e) {
750
+ if (!(e instanceof TouchEvent)) return;
751
+ if (e.touches.length !== 1 || isDrawerOpen()) return;
752
+ const t = e.touches[0];
753
+ if (!t) return;
754
+ const y = t.clientY;
755
+ const totalDy = y - startY;
756
+ if (lock.current === "none" && Math.abs(totalDy) > config.sensitivity) {
757
+ if (!tryLock(lock, "scroll")) return;
758
+ }
759
+ if (lock.current !== "scroll") return;
760
+ e.preventDefault();
761
+ const moveDy = y - lastY;
762
+ lastY = y;
763
+ accDelta += moveDy;
764
+ while (Math.abs(accDelta) >= config.sensitivity) {
765
+ const dir = accDelta < 0 ? "down" : "up";
766
+ if (config.strategy === "keys") sendData(term, pageSeq(dir));
767
+ else {
768
+ const now = Date.now();
769
+ if (now - lastWheelAt < config.wheelIntervalMs) break;
770
+ lastWheelAt = now;
771
+ const screen = screenEl;
772
+ if (!screen) break;
773
+ const { x, y: row } = touchToCell(t, screen, term);
774
+ sendData(term, scrollSeq(dir, x, row));
775
+ }
776
+ accDelta -= (accDelta < 0 ? -1 : 1) * config.sensitivity;
777
+ }
778
+ }
779
+ function onTouchEnd(e) {
780
+ if (!(e instanceof TouchEvent)) return;
781
+ if (lock.current === "scroll") resetLock(lock);
782
+ }
783
+ function attach() {
784
+ const screen = document.querySelector(".xterm-screen");
785
+ if (!(screen instanceof HTMLElement)) {
786
+ setTimeout(attach, 200);
787
+ return;
788
+ }
789
+ screenEl = screen;
790
+ screen.addEventListener("touchstart", onTouchStart, { passive: true });
791
+ screen.addEventListener("touchmove", onTouchMove, { passive: false });
792
+ screen.addEventListener("touchend", onTouchEnd, { passive: true });
793
+ screen.addEventListener("touchcancel", onTouchEnd, { passive: true });
794
+ }
795
+ attach();
796
+ }
797
+
798
+ //#endregion
799
+ //#region src/controls/scroll-buttons.ts
800
+ const LONG_PRESS_DELAY = 300;
801
+ const REPEAT_INTERVAL = 100;
802
+ const FADE_TIMEOUT = 2e3;
803
+ /** Create floating scroll buttons (PgUp ▲ / PgDn ▼) */
804
+ function createScrollButtons(term, config) {
805
+ const container = el("div", { id: "wt-scroll-buttons" });
806
+ const upBtn = el("button", { "aria-label": "Page Up" }, "▲");
807
+ const downBtn = el("button", { "aria-label": "Page Down" }, "▼");
808
+ container.appendChild(upBtn);
809
+ container.appendChild(downBtn);
810
+ function targetCell() {
811
+ const active = term.buffer?.active;
812
+ if (active && typeof active.cursorX === "number" && typeof active.cursorY === "number") return {
813
+ x: Math.max(1, active.cursorX + 1),
814
+ y: Math.max(1, active.cursorY + 1)
815
+ };
816
+ const cols = typeof term.cols === "number" && term.cols > 0 ? Math.round(term.cols) : 80;
817
+ const rows = typeof term.rows === "number" && term.rows > 0 ? Math.round(term.rows) : 24;
818
+ return {
819
+ x: Math.max(1, Math.floor((cols + 1) / 2)),
820
+ y: Math.max(1, Math.floor((rows + 1) / 2))
821
+ };
822
+ }
823
+ function sequence(direction) {
824
+ if (config.strategy === "keys") return pageSeq(direction);
825
+ const { x, y } = targetCell();
826
+ return scrollSeq(direction, x, y);
827
+ }
828
+ function wireButton(button, direction) {
829
+ let repeatTimer;
830
+ let delayTimer;
831
+ function send() {
832
+ const kbWasOpen = isKeyboardOpen();
833
+ sendData(term, sequence(direction));
834
+ conditionalFocus(term, kbWasOpen);
835
+ }
836
+ function startRepeat() {
837
+ delayTimer = setTimeout(() => {
838
+ repeatTimer = setInterval(send, REPEAT_INTERVAL);
839
+ }, LONG_PRESS_DELAY);
840
+ }
841
+ function stopRepeat() {
842
+ if (delayTimer !== void 0) {
843
+ clearTimeout(delayTimer);
844
+ delayTimer = void 0;
845
+ }
846
+ if (repeatTimer !== void 0) {
847
+ clearInterval(repeatTimer);
848
+ repeatTimer = void 0;
849
+ }
850
+ }
851
+ button.addEventListener("touchstart", (e) => {
852
+ e.preventDefault();
853
+ send();
854
+ startRepeat();
855
+ resetFade();
856
+ });
857
+ button.addEventListener("touchend", () => stopRepeat());
858
+ button.addEventListener("touchcancel", () => stopRepeat());
859
+ button.addEventListener("click", () => {
860
+ send();
861
+ resetFade();
862
+ });
863
+ }
864
+ wireButton(upBtn, "up");
865
+ wireButton(downBtn, "down");
866
+ let fadeTimer;
867
+ function resetFade() {
868
+ container.classList.add("wt-active");
869
+ if (fadeTimer !== void 0) clearTimeout(fadeTimer);
870
+ fadeTimer = setTimeout(() => {
871
+ container.classList.remove("wt-active");
872
+ }, FADE_TIMEOUT);
873
+ }
874
+ return { element: container };
875
+ }
876
+
877
+ //#endregion
878
+ //#region src/drawer/drawer.ts
879
+ /** Create the command drawer with backdrop */
880
+ function createDrawer(term, buttons, config) {
881
+ const actionRegistry = config.actions ?? createDefaultActionRegistry();
882
+ const hooks = config.hooks;
883
+ const appConfig = config.appConfig;
884
+ const backdrop = el("div", { id: "wt-backdrop" });
885
+ const drawer = el("div", { id: "wt-drawer" });
886
+ const handle = el("div", { id: "wt-drawer-handle" });
887
+ const grid = el("div", { id: "wt-drawer-grid" });
888
+ drawer.appendChild(handle);
889
+ drawer.appendChild(grid);
890
+ let drawerOpen = false;
891
+ for (const buttonDef of buttons) {
892
+ const button = el("button");
893
+ button.textContent = buttonDef.label;
894
+ button.addEventListener("click", (e) => {
895
+ e.preventDefault();
896
+ const kbWasOpen = isKeyboardOpen();
897
+ haptic();
898
+ close();
899
+ async function sendWithHooks(data) {
900
+ const before = await hooks.runBeforeSendData({
901
+ term,
902
+ config: appConfig,
903
+ source: "drawer",
904
+ actionType: buttonDef.action.type,
905
+ kbWasOpen,
906
+ data
907
+ });
908
+ if (before.blocked) return;
909
+ sendData(term, before.data);
910
+ await hooks.runAfterSendData({
911
+ term,
912
+ config: appConfig,
913
+ source: "drawer",
914
+ actionType: buttonDef.action.type,
915
+ kbWasOpen,
916
+ data: before.data
917
+ });
918
+ }
919
+ actionRegistry.execute(buttonDef.action, {
920
+ term,
921
+ kbWasOpen,
922
+ focusIfNeeded: () => conditionalFocus(term, kbWasOpen),
923
+ sendText: sendWithHooks,
924
+ sendRawText: sendWithHooks,
925
+ openComboPicker: config.openComboPicker
926
+ }).catch((error) => {
927
+ console.error("remobi: drawer action execution failed", error);
928
+ conditionalFocus(term, kbWasOpen);
929
+ });
930
+ });
931
+ grid.appendChild(button);
932
+ }
933
+ function open() {
934
+ backdrop.style.display = "block";
935
+ drawer.classList.add("open");
936
+ drawerOpen = true;
937
+ }
938
+ function close() {
939
+ drawer.classList.remove("open");
940
+ backdrop.style.display = "none";
941
+ drawerOpen = false;
942
+ }
943
+ function isOpen() {
944
+ return drawerOpen;
945
+ }
946
+ backdrop.addEventListener("click", () => {
947
+ const kbWasOpen = isKeyboardOpen();
948
+ haptic();
949
+ close();
950
+ conditionalFocus(term, kbWasOpen);
951
+ });
952
+ let handleStartY = 0;
953
+ handle.addEventListener("touchstart", (e) => {
954
+ const touch = e.touches[0];
955
+ if (touch) handleStartY = touch.clientY;
956
+ }, { passive: true });
957
+ handle.addEventListener("touchmove", (e) => {
958
+ const touch = e.touches[0];
959
+ if (!touch) return;
960
+ const dy = touch.clientY - handleStartY;
961
+ if (dy > 0) drawer.style.transform = `translateY(${dy}px)`;
962
+ }, { passive: true });
963
+ handle.addEventListener("touchend", (e) => {
964
+ const touch = e.changedTouches[0];
965
+ if (!touch) return;
966
+ const kbWasOpen = isKeyboardOpen();
967
+ const dy = touch.clientY - handleStartY;
968
+ drawer.style.transform = "";
969
+ if (dy > 60) {
970
+ close();
971
+ conditionalFocus(term, kbWasOpen);
972
+ }
973
+ }, { passive: true });
974
+ return {
975
+ backdrop,
976
+ drawer,
977
+ open,
978
+ close,
979
+ isOpen
980
+ };
981
+ }
982
+
983
+ //#endregion
984
+ //#region src/gestures/pinch.ts
985
+ /** Calculate distance between two touch points */
986
+ function touchDistance(t1, t2) {
987
+ const dx = t1.clientX - t2.clientX;
988
+ const dy = t1.clientY - t2.clientY;
989
+ return Math.sqrt(dx * dx + dy * dy);
990
+ }
991
+ /** Clamp font size to configured range */
992
+ function clampFontSize(size, range) {
993
+ return Math.max(range[0], Math.min(range[1], size));
994
+ }
995
+ /** Attach pinch-to-zoom gesture to the xterm screen */
996
+ function attachPinchGestures(term, font, lock) {
997
+ let pinchStartDist = 0;
998
+ let pinchBaseFontSize = 0;
999
+ function onPinchStart(e) {
1000
+ if (e.touches.length === 2) {
1001
+ const t0 = e.touches[0];
1002
+ const t1 = e.touches[1];
1003
+ if (!t0 || !t1) return;
1004
+ pinchStartDist = touchDistance(t0, t1);
1005
+ pinchBaseFontSize = term.options.fontSize;
1006
+ }
1007
+ }
1008
+ function onPinchMove(e) {
1009
+ if (e.touches.length !== 2) return;
1010
+ if (lock.current === "scroll") return;
1011
+ if (pinchStartDist === 0) return;
1012
+ const t0 = e.touches[0];
1013
+ const t1 = e.touches[1];
1014
+ if (!t0 || !t1) return;
1015
+ const ratio = touchDistance(t0, t1) / pinchStartDist;
1016
+ if (lock.current === "none" && Math.abs(ratio - 1) > .05) {
1017
+ if (!tryLock(lock, "pinch")) return;
1018
+ }
1019
+ if (lock.current !== "pinch") return;
1020
+ e.preventDefault();
1021
+ const newSize = clampFontSize(Math.round(pinchBaseFontSize * ratio), font.sizeRange);
1022
+ if (newSize !== term.options.fontSize) {
1023
+ term.options.fontSize = newSize;
1024
+ resizeTerm();
1025
+ }
1026
+ }
1027
+ function onTouchEnd() {
1028
+ if (lock.current === "pinch") resetLock(lock);
1029
+ }
1030
+ function attach() {
1031
+ const screen = document.querySelector(".xterm-screen");
1032
+ if (!screen) {
1033
+ setTimeout(attach, 200);
1034
+ return;
1035
+ }
1036
+ screen.addEventListener("touchstart", (e) => onPinchStart(e), { passive: true });
1037
+ screen.addEventListener("touchmove", (e) => onPinchMove(e), { passive: false });
1038
+ screen.addEventListener("touchend", () => onTouchEnd(), { passive: true });
1039
+ }
1040
+ attach();
1041
+ }
1042
+
1043
+ //#endregion
1044
+ //#region src/gestures/swipe.ts
1045
+ /** Result of swipe validity check — pure logic, no side effects */
1046
+ function isValidSwipe(dx, dy, dt, config) {
1047
+ const absDx = Math.abs(dx);
1048
+ const absDy = Math.abs(dy);
1049
+ if (absDx > config.threshold && dt < config.maxDuration && absDx > absDy * 2) return dx > 0 ? "right" : "left";
1050
+ return null;
1051
+ }
1052
+ /** Create the swipe indicator element */
1053
+ function createSwipeIndicator() {
1054
+ const indicator = el("div", { id: "wt-swipe-indicator" });
1055
+ let timer = 0;
1056
+ function show(arrow) {
1057
+ indicator.textContent = arrow;
1058
+ indicator.style.opacity = "1";
1059
+ clearTimeout(timer);
1060
+ timer = window.setTimeout(() => {
1061
+ indicator.style.opacity = "0";
1062
+ }, 300);
1063
+ }
1064
+ return {
1065
+ element: indicator,
1066
+ show
1067
+ };
1068
+ }
1069
+ /** Attach swipe gesture detection to the xterm screen */
1070
+ function attachSwipeGestures(term, config, isDrawerOpen) {
1071
+ const { element: indicator, show } = createSwipeIndicator();
1072
+ let startX = 0;
1073
+ let startY = 0;
1074
+ let startTime = 0;
1075
+ function onTouchStart(e) {
1076
+ if (isDrawerOpen() || e.touches.length !== 1) return;
1077
+ const touch = e.touches[0];
1078
+ if (!touch) return;
1079
+ startX = touch.clientX;
1080
+ startY = touch.clientY;
1081
+ startTime = Date.now();
1082
+ }
1083
+ function onTouchEnd(e) {
1084
+ if (isDrawerOpen() || e.changedTouches.length !== 1) return;
1085
+ const touch = e.changedTouches[0];
1086
+ if (!touch) return;
1087
+ const direction = isValidSwipe(touch.clientX - startX, touch.clientY - startY, Date.now() - startTime, config);
1088
+ if (direction === "right") {
1089
+ sendData(term, config.right);
1090
+ show("◀");
1091
+ haptic();
1092
+ } else if (direction === "left") {
1093
+ sendData(term, config.left);
1094
+ show("▶");
1095
+ haptic();
1096
+ }
1097
+ }
1098
+ function attach() {
1099
+ const screen = document.querySelector(".xterm-screen");
1100
+ if (!screen) {
1101
+ setTimeout(attach, 200);
1102
+ return;
1103
+ }
1104
+ screen.addEventListener("touchstart", (e) => onTouchStart(e), { passive: true });
1105
+ screen.addEventListener("touchend", (e) => onTouchEnd(e), { passive: true });
1106
+ }
1107
+ attach();
1108
+ return indicator;
1109
+ }
1110
+
1111
+ //#endregion
1112
+ //#region src/hooks/registry.ts
1113
+ function logHookError(name, error) {
1114
+ console.error(`remobi: hook '${name}' failed`, error);
1115
+ }
1116
+ function createHookRegistry() {
1117
+ const hooks = {
1118
+ beforeSendData: [],
1119
+ afterSendData: [],
1120
+ overlayInitStart: [],
1121
+ overlayReady: [],
1122
+ toolbarCreated: [],
1123
+ drawerCreated: []
1124
+ };
1125
+ function on(name, hook) {
1126
+ hooks[name].push(hook);
1127
+ return { dispose() {
1128
+ const index = hooks[name].indexOf(hook);
1129
+ if (index >= 0) hooks[name].splice(index, 1);
1130
+ } };
1131
+ }
1132
+ async function runBeforeSendData(context) {
1133
+ let nextData = context.data;
1134
+ for (const hook of hooks.beforeSendData) try {
1135
+ const result = await hook({
1136
+ ...context,
1137
+ data: nextData
1138
+ });
1139
+ if (result?.block) return {
1140
+ blocked: true,
1141
+ data: nextData
1142
+ };
1143
+ if (typeof result?.data === "string") nextData = result.data;
1144
+ } catch (error) {
1145
+ logHookError("beforeSendData", error);
1146
+ }
1147
+ return {
1148
+ blocked: false,
1149
+ data: nextData
1150
+ };
1151
+ }
1152
+ async function runAfterSendData(context) {
1153
+ for (const hook of hooks.afterSendData) try {
1154
+ await hook(context);
1155
+ } catch (error) {
1156
+ logHookError("afterSendData", error);
1157
+ }
1158
+ }
1159
+ async function runOverlayInitStart(context) {
1160
+ for (const hook of hooks.overlayInitStart) try {
1161
+ await hook(context);
1162
+ } catch (error) {
1163
+ logHookError("overlayInitStart", error);
1164
+ }
1165
+ }
1166
+ async function runOverlayReady(context) {
1167
+ for (const hook of hooks.overlayReady) try {
1168
+ await hook(context);
1169
+ } catch (error) {
1170
+ logHookError("overlayReady", error);
1171
+ }
1172
+ }
1173
+ async function runToolbarCreated(context) {
1174
+ for (const hook of hooks.toolbarCreated) try {
1175
+ await hook(context);
1176
+ } catch (error) {
1177
+ logHookError("toolbarCreated", error);
1178
+ }
1179
+ }
1180
+ async function runDrawerCreated(context) {
1181
+ for (const hook of hooks.drawerCreated) try {
1182
+ await hook(context);
1183
+ } catch (error) {
1184
+ logHookError("drawerCreated", error);
1185
+ }
1186
+ }
1187
+ return {
1188
+ on,
1189
+ runBeforeSendData,
1190
+ runAfterSendData,
1191
+ runOverlayInitStart,
1192
+ runOverlayReady,
1193
+ runToolbarCreated,
1194
+ runDrawerCreated
1195
+ };
1196
+ }
1197
+
1198
+ //#endregion
1199
+ //#region src/reconnect.ts
1200
+ /** Find the ttyd WebSocket from the interceptor array */
1201
+ function findTtydSocket() {
1202
+ const sockets = window.__remobiSockets;
1203
+ if (!sockets) return void 0;
1204
+ return sockets.find((ws) => ws.url.endsWith("/ws"));
1205
+ }
1206
+ /** Create the reconnect overlay DOM (hidden by default) */
1207
+ function createOverlay(onReconnect) {
1208
+ const overlay = el("div", {
1209
+ id: "remobi-reconnect-overlay",
1210
+ style: [
1211
+ "display:none",
1212
+ "position:fixed",
1213
+ "inset:0",
1214
+ "z-index:10000",
1215
+ "background:rgba(30,30,46,0.92)",
1216
+ "color:#cdd6f4",
1217
+ "font-family:sans-serif",
1218
+ "justify-content:center",
1219
+ "align-items:center",
1220
+ "flex-direction:column",
1221
+ "gap:16px"
1222
+ ].join(";")
1223
+ });
1224
+ const message = el("div", { style: "font-size:1.4rem;font-weight:600" });
1225
+ message.textContent = "Connection lost";
1226
+ const button = el("button", { style: [
1227
+ "padding:10px 28px",
1228
+ "font-size:1rem",
1229
+ "border:none",
1230
+ "border-radius:8px",
1231
+ "background:#cba6f7",
1232
+ "color:#1e1e2e",
1233
+ "cursor:pointer",
1234
+ "font-weight:600"
1235
+ ].join(";") });
1236
+ button.type = "button";
1237
+ button.textContent = "Reconnect";
1238
+ button.addEventListener("click", (event) => {
1239
+ event.stopPropagation();
1240
+ onReconnect();
1241
+ });
1242
+ overlay.addEventListener("click", () => {
1243
+ onReconnect();
1244
+ });
1245
+ overlay.appendChild(message);
1246
+ overlay.appendChild(button);
1247
+ return {
1248
+ element: overlay,
1249
+ button
1250
+ };
1251
+ }
1252
+ /**
1253
+ * Set up reconnect detection and overlay.
1254
+ *
1255
+ * Watches the ttyd WebSocket for close/error events. Falls back to
1256
+ * navigator.onLine + visibilitychange if no WebSocket found.
1257
+ * Returns a dispose function that removes listeners and DOM.
1258
+ */
1259
+ function setupReconnect(_term, config) {
1260
+ if (!config.enabled) return () => {};
1261
+ let disconnected = false;
1262
+ let reconnectTriggered = false;
1263
+ function triggerReconnect() {
1264
+ if (!disconnected || reconnectTriggered) return;
1265
+ reconnectTriggered = true;
1266
+ location.reload();
1267
+ }
1268
+ const { element: overlay, button } = createOverlay(triggerReconnect);
1269
+ document.body.appendChild(overlay);
1270
+ function onDisconnect() {
1271
+ if (disconnected) return;
1272
+ disconnected = true;
1273
+ overlay.style.display = "flex";
1274
+ button.focus();
1275
+ }
1276
+ function onOnline() {
1277
+ if (disconnected) triggerReconnect();
1278
+ }
1279
+ function onVisibilityChange() {
1280
+ if (document.visibilityState === "visible" && disconnected) triggerReconnect();
1281
+ }
1282
+ const ws = findTtydSocket();
1283
+ if (ws) {
1284
+ ws.addEventListener("close", onDisconnect);
1285
+ ws.addEventListener("error", onDisconnect);
1286
+ } else {
1287
+ window.addEventListener("offline", onDisconnect);
1288
+ document.addEventListener("visibilitychange", onVisibilityChange);
1289
+ }
1290
+ window.addEventListener("online", onOnline);
1291
+ return () => {
1292
+ if (ws) {
1293
+ ws.removeEventListener("close", onDisconnect);
1294
+ ws.removeEventListener("error", onDisconnect);
1295
+ } else {
1296
+ window.removeEventListener("offline", onDisconnect);
1297
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1298
+ }
1299
+ window.removeEventListener("online", onOnline);
1300
+ overlay.remove();
1301
+ };
1302
+ }
1303
+
1304
+ //#endregion
1305
+ //#region src/theme/apply.ts
1306
+ /** Apply a theme to the xterm.js terminal instance */
1307
+ function applyTheme(term, theme) {
1308
+ term.options.theme = { ...theme };
1309
+ }
1310
+
1311
+ //#endregion
1312
+ //#region src/toolbar/toolbar.ts
1313
+ /** Create the ctrl modifier state manager */
1314
+ function createCtrlState() {
1315
+ return {
1316
+ active: false,
1317
+ disposer: null,
1318
+ buttonEl: null
1319
+ };
1320
+ }
1321
+ /** Activate ctrl sticky modifier */
1322
+ function activateCtrl(state, term, theme) {
1323
+ if (!state.buttonEl) return;
1324
+ state.active = true;
1325
+ state.buttonEl.style.background = theme.blue;
1326
+ state.buttonEl.style.color = theme.background;
1327
+ if (!state.disposer) state.disposer = term.onData((data) => {
1328
+ if (state.active && data.length === 1) {
1329
+ const code = data.charCodeAt(0);
1330
+ deactivateCtrl(state, theme);
1331
+ if (code >= 65 && code <= 90 || code >= 97 && code <= 122) sendData(term, String.fromCharCode(code & 31));
1332
+ }
1333
+ });
1334
+ }
1335
+ /** Deactivate ctrl sticky modifier */
1336
+ function deactivateCtrl(state, theme) {
1337
+ if (!state.buttonEl) return;
1338
+ state.active = false;
1339
+ state.buttonEl.style.background = theme.black;
1340
+ state.buttonEl.style.color = theme.foreground;
1341
+ if (state.disposer) {
1342
+ state.disposer.dispose();
1343
+ state.disposer = null;
1344
+ }
1345
+ }
1346
+ /** Wire up a single button's click handler based on its action type */
1347
+ function wireButton(button, def, term, ctrlState, config, registry, hooks, openDrawer, openComboPicker) {
1348
+ button.addEventListener("click", (e) => {
1349
+ e.preventDefault();
1350
+ const kbWasOpen = isKeyboardOpen();
1351
+ haptic();
1352
+ async function sendWithCtrlAware(data) {
1353
+ const before = await hooks.runBeforeSendData({
1354
+ term,
1355
+ config,
1356
+ source: "toolbar",
1357
+ actionType: def.action.type,
1358
+ kbWasOpen,
1359
+ data
1360
+ });
1361
+ if (before.blocked) return;
1362
+ let nextData = before.data;
1363
+ if (ctrlState.active && ctrlState.buttonEl) {
1364
+ deactivateCtrl(ctrlState, config.theme);
1365
+ if (nextData.length === 1) {
1366
+ const code = nextData.charCodeAt(0);
1367
+ if (code >= 65 && code <= 90 || code >= 97 && code <= 122) nextData = String.fromCharCode(code & 31);
1368
+ }
1369
+ }
1370
+ sendData(term, nextData);
1371
+ await hooks.runAfterSendData({
1372
+ term,
1373
+ config,
1374
+ source: "toolbar",
1375
+ actionType: def.action.type,
1376
+ kbWasOpen,
1377
+ data: nextData
1378
+ });
1379
+ }
1380
+ async function sendRaw(data) {
1381
+ const before = await hooks.runBeforeSendData({
1382
+ term,
1383
+ config,
1384
+ source: "toolbar",
1385
+ actionType: def.action.type,
1386
+ kbWasOpen,
1387
+ data
1388
+ });
1389
+ if (before.blocked) return;
1390
+ sendData(term, before.data);
1391
+ await hooks.runAfterSendData({
1392
+ term,
1393
+ config,
1394
+ source: "toolbar",
1395
+ actionType: def.action.type,
1396
+ kbWasOpen,
1397
+ data: before.data
1398
+ });
1399
+ }
1400
+ registry.execute(def.action, {
1401
+ term,
1402
+ kbWasOpen,
1403
+ focusIfNeeded: () => conditionalFocus(term, kbWasOpen),
1404
+ sendText: sendWithCtrlAware,
1405
+ sendRawText: sendRaw,
1406
+ openDrawer,
1407
+ openComboPicker,
1408
+ toggleCtrlModifier: () => {
1409
+ if (ctrlState.active) deactivateCtrl(ctrlState, config.theme);
1410
+ else activateCtrl(ctrlState, term, config.theme);
1411
+ conditionalFocus(term, kbWasOpen);
1412
+ }
1413
+ }).catch((error) => {
1414
+ console.error("remobi: toolbar action execution failed", error);
1415
+ conditionalFocus(term, kbWasOpen);
1416
+ });
1417
+ });
1418
+ }
1419
+ /** Build a row of buttons */
1420
+ function buildRow(buttons, term, ctrlState, config, registry, hooks, openDrawer, openComboPicker) {
1421
+ const row = el("div", { class: "wt-row" });
1422
+ for (const def of buttons) {
1423
+ const button = el("button");
1424
+ button.textContent = def.label;
1425
+ if (def.action.type === "ctrl-modifier") ctrlState.buttonEl = button;
1426
+ wireButton(button, def, term, ctrlState, config, registry, hooks, openDrawer, openComboPicker);
1427
+ row.appendChild(button);
1428
+ }
1429
+ return row;
1430
+ }
1431
+ /** Create the two-row toolbar */
1432
+ function createToolbar(term, config, openDrawer, hooks, actions = createDefaultActionRegistry(), openComboPicker) {
1433
+ const toolbar = el("div", { id: "wt-toolbar" });
1434
+ const ctrlState = createCtrlState();
1435
+ const row1 = buildRow(config.toolbar.row1, term, ctrlState, config, actions, hooks, openDrawer, openComboPicker);
1436
+ const row2 = buildRow(config.toolbar.row2, term, ctrlState, config, actions, hooks, openDrawer, openComboPicker);
1437
+ toolbar.appendChild(row1);
1438
+ toolbar.appendChild(row2);
1439
+ return {
1440
+ element: toolbar,
1441
+ ctrlState
1442
+ };
1443
+ }
1444
+
1445
+ //#endregion
1446
+ //#region src/viewport/landscape.ts
1447
+ /**
1448
+ * Detect landscape orientation + keyboard open state.
1449
+ * In landscape with keyboard, hides row 2 and shrinks buttons via CSS class.
1450
+ */
1451
+ function checkLandscapeKeyboard(toolbar) {
1452
+ const vp = window.visualViewport;
1453
+ if (!vp) return;
1454
+ const kbOpen = window.innerHeight - vp.height > KB_THRESHOLD;
1455
+ const landscape = window.innerWidth > window.innerHeight;
1456
+ if (kbOpen && landscape) toolbar.classList.add("wt-kb-open");
1457
+ else toolbar.classList.remove("wt-kb-open");
1458
+ }
1459
+
1460
+ //#endregion
1461
+ //#region src/viewport/height.ts
1462
+ function viewportHeight(vp, fallbackHeight, includeOffsetTop) {
1463
+ if (!vp) return fallbackHeight;
1464
+ return includeOffsetTop ? vp.height + vp.offsetTop : vp.height;
1465
+ }
1466
+ function lockDocumentHeight(height) {
1467
+ document.documentElement.style.setProperty("height", height, "important");
1468
+ document.documentElement.style.setProperty("max-height", height, "important");
1469
+ document.documentElement.style.setProperty("overflow", "hidden", "important");
1470
+ document.documentElement.style.setProperty("overscroll-behavior", "none", "important");
1471
+ document.body.style.setProperty("min-height", "0", "important");
1472
+ document.body.style.setProperty("height", height, "important");
1473
+ document.body.style.setProperty("max-height", height, "important");
1474
+ document.body.style.setProperty("overflow", "hidden", "important");
1475
+ document.body.style.setProperty("overscroll-behavior", "none", "important");
1476
+ }
1477
+ /**
1478
+ * Manage terminal height to account for the toolbar and virtual keyboard.
1479
+ * Uses visualViewport API when available for accurate keyboard detection.
1480
+ */
1481
+ function initHeightManager(toolbar) {
1482
+ let pendingResize = 0;
1483
+ function updateHeight() {
1484
+ pendingResize = 0;
1485
+ checkLandscapeKeyboard(toolbar);
1486
+ const vp = window.visualViewport;
1487
+ const kbOpen = isKeyboardOpen();
1488
+ lockDocumentHeight(`${viewportHeight(vp, window.innerHeight, kbOpen) - (kbOpen ? 0 : toolbar.offsetHeight || 90)}px`);
1489
+ resizeTerm();
1490
+ }
1491
+ function scheduleResize() {
1492
+ if (!pendingResize) pendingResize = requestAnimationFrame(updateHeight);
1493
+ }
1494
+ if (window.visualViewport) {
1495
+ window.visualViewport.addEventListener("resize", scheduleResize);
1496
+ window.visualViewport.addEventListener("scroll", scheduleResize);
1497
+ }
1498
+ window.addEventListener("resize", scheduleResize);
1499
+ window.addEventListener("orientationchange", () => {
1500
+ setTimeout(scheduleResize, 200);
1501
+ });
1502
+ scheduleResize();
1503
+ }
1504
+
1505
+ //#endregion
1506
+ //#region src/index.ts
1507
+ /** Detect touch device */
1508
+ function isMobile() {
1509
+ return "ontouchstart" in window || navigator.maxTouchPoints > 0;
1510
+ }
1511
+ /**
1512
+ * Initialise the remobi overlay.
1513
+ * Called automatically when loaded in a browser (via the IIFE in build output).
1514
+ * Config is embedded at build time.
1515
+ */
1516
+ function init(config = defaultConfig, hooks = createHookRegistry()) {
1517
+ waitForTerm().then(async (term) => {
1518
+ const disposeReconnect = setupReconnect(term, config.reconnect);
1519
+ const mobile = isMobile();
1520
+ const actions = createDefaultActionRegistry();
1521
+ let disposed = false;
1522
+ function dispose() {
1523
+ if (disposed) return;
1524
+ disposed = true;
1525
+ disposeReconnect();
1526
+ window.removeEventListener("pagehide", onPageHide);
1527
+ }
1528
+ function onPageHide(event) {
1529
+ if (event.persisted) return;
1530
+ dispose();
1531
+ }
1532
+ window.addEventListener("beforeunload", dispose, { once: true });
1533
+ window.addEventListener("pagehide", onPageHide);
1534
+ try {
1535
+ await hooks.runOverlayInitStart({
1536
+ term,
1537
+ config,
1538
+ mobile
1539
+ });
1540
+ document.fonts.ready.then(() => resizeTerm()).catch(() => {});
1541
+ document.title = `${config.name} · ${location.hostname.replace(/\..*/, "")}`;
1542
+ if (!mobile) {
1543
+ await hooks.runOverlayReady({
1544
+ term,
1545
+ config,
1546
+ mobile
1547
+ });
1548
+ return;
1549
+ }
1550
+ applyTheme(term, config.theme);
1551
+ term.options.fontSize = config.font.mobileSizeDefault;
1552
+ term.options.fontFamily = config.font.family;
1553
+ resizeTerm();
1554
+ const comboPicker = createComboPicker();
1555
+ document.body.appendChild(comboPicker.element);
1556
+ const drawer = createDrawer(term, config.drawer.buttons, {
1557
+ hooks,
1558
+ appConfig: config,
1559
+ actions,
1560
+ openComboPicker: comboPicker.open
1561
+ });
1562
+ document.body.appendChild(drawer.backdrop);
1563
+ document.body.appendChild(drawer.drawer);
1564
+ await hooks.runDrawerCreated({
1565
+ term,
1566
+ config,
1567
+ drawer: drawer.drawer,
1568
+ backdrop: drawer.backdrop
1569
+ });
1570
+ const { element: toolbar } = createToolbar(term, config, drawer.open, hooks, actions, comboPicker.open);
1571
+ document.body.appendChild(toolbar);
1572
+ await hooks.runToolbarCreated({
1573
+ term,
1574
+ config,
1575
+ toolbar
1576
+ });
1577
+ const { element: fontControls, helpButton } = createFontControls(term, config.font);
1578
+ document.body.appendChild(fontControls);
1579
+ if (config.floatingButtons.length > 0) {
1580
+ const { elements: floatingEls } = createFloatingButtons(term, config.floatingButtons, config, hooks, actions, drawer.open, comboPicker.open);
1581
+ for (const floatingEl of floatingEls) document.body.appendChild(floatingEl);
1582
+ }
1583
+ const { element: scrollButtons } = createScrollButtons(term, config.gestures.scroll);
1584
+ document.body.appendChild(scrollButtons);
1585
+ const gestureLock = createGestureLock();
1586
+ if (config.gestures.swipe.enabled) {
1587
+ const indicator = attachSwipeGestures(term, config.gestures.swipe, drawer.isOpen);
1588
+ document.body.appendChild(indicator);
1589
+ }
1590
+ if (config.gestures.pinch.enabled) attachPinchGestures(term, config.font, gestureLock);
1591
+ if (config.gestures.scroll.enabled) attachScrollGesture(term, config.gestures.scroll, gestureLock, drawer.isOpen);
1592
+ initHeightManager(toolbar);
1593
+ if (config.mobile.initData !== null && window.innerWidth < config.mobile.widthThreshold) {
1594
+ const data = config.mobile.initData;
1595
+ const before = await hooks.runBeforeSendData({
1596
+ term,
1597
+ config,
1598
+ source: "mobile-init",
1599
+ actionType: "send",
1600
+ kbWasOpen: false,
1601
+ data
1602
+ });
1603
+ if (!before.blocked) {
1604
+ sendData(term, before.data);
1605
+ await hooks.runAfterSendData({
1606
+ term,
1607
+ config,
1608
+ source: "mobile-init",
1609
+ actionType: "send",
1610
+ kbWasOpen: false,
1611
+ data: before.data
1612
+ });
1613
+ }
1614
+ }
1615
+ try {
1616
+ const { element: helpOverlay } = createHelpOverlay(term, helpButton, config);
1617
+ document.body.appendChild(helpOverlay);
1618
+ } catch (error) {
1619
+ console.error("remobi: failed to initialise help overlay", error);
1620
+ }
1621
+ await hooks.runOverlayReady({
1622
+ term,
1623
+ config,
1624
+ mobile
1625
+ });
1626
+ } catch (error) {
1627
+ dispose();
1628
+ throw error;
1629
+ }
1630
+ }).catch((error) => {
1631
+ console.error("remobi: failed to initialise overlay", error);
1632
+ });
1633
+ }
1634
+
1635
+ //#endregion
1636
+ export { createHookRegistry, defineConfig, init };
1637
+ //# sourceMappingURL=index.mjs.map