@cplieger/web-terminal-ui 3.0.4 → 3.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.
@@ -44,6 +44,11 @@ const DEFAULT_WS_PATH = "/ws";
44
44
  const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
45
45
  const TOAST_MS = 3000;
46
46
  const TAP_MOVEMENT_PX = 10;
47
+ // A touch that focuses the input (opens the soft keyboard) must be a genuine
48
+ // tap: short and low-movement. A longer hold is a long-press, which belongs to
49
+ // native text selection / the context menu — so tap-to-focus bows out above
50
+ // this duration and never steals a long-press or a selection.
51
+ const TAP_MAX_MS = 500;
47
52
 
48
53
  // Kernel-owned core subtree: the display-only output, the hidden textarea (the
49
54
  // single keyboard target), and the IME composition view. No chrome; features
@@ -74,6 +79,18 @@ export function createTerminal(
74
79
  const kernelAbort = new AbortController();
75
80
  const { signal } = kernelAbort;
76
81
 
82
+ // Apply consumer theme overrides as CSS custom properties on the root, so the
83
+ // whole terminal subtree inherits them. The library ships the token defaults
84
+ // (css/00-tokens.css); these override them for this instance. Only custom
85
+ // properties (leading "--") are set.
86
+ if (opts.theme) {
87
+ for (const [key, value] of Object.entries(opts.theme)) {
88
+ if (key.startsWith("--")) {
89
+ root.style.setProperty(key, value);
90
+ }
91
+ }
92
+ }
93
+
77
94
  // --- Build the core subtree ---
78
95
  const tpl = document.createElement("template");
79
96
  tpl.innerHTML = CORE_TEMPLATE;
@@ -157,6 +174,16 @@ export function createTerminal(
157
174
 
158
175
  // --- Active session (SessionView + onSwitch fan-out) ---
159
176
  let activeSession: SessionRef | null = null;
177
+ // Whether the first connection has been kicked off. The wake-reconnect
178
+ // handlers (visibilitychange/pageshow/online) must not open a socket before
179
+ // it has: a session-managing feature (tabs) drives the first connect via
180
+ // notifySwitch once it resolves a session id, and connecting to the bare
181
+ // wsPath before then hits a session-gated endpoint (the SessionManager 404s a
182
+ // /ws with no ?session=). pageshow fires on the initial load, so without this
183
+ // gate a slow session list lets pageshow's reconnectNow open a bare /ws that
184
+ // 404s (seen in Firefox, where the list loses the race). Flips true on the
185
+ // startup connect (unmanaged) or the first setSession (managed).
186
+ let connectionInitiated = false;
160
187
 
161
188
  // --- Loading lifecycle ---
162
189
  let ready = false;
@@ -368,9 +395,16 @@ export function createTerminal(
368
395
  );
369
396
 
370
397
  // --- Focus strategy (the touch focus dance; see the input-model contract) ---
398
+ // On touch, the terminal output is the native text-selection surface, so this
399
+ // handler deliberately does the MINIMUM: it opens the keyboard on a clean tap
400
+ // and otherwise gets out of the browser's way. It never preventDefaults a
401
+ // touch and never clears a selection — a long-press to select a word, the OS
402
+ // copy/paste callout, and drag-to-scroll are all left to the platform (the
403
+ // research consensus: over text, allow the default and emit nothing).
371
404
  let lastPointerType = "mouse";
372
405
  let pointerDownX = 0;
373
406
  let pointerDownY = 0;
407
+ let pointerDownTime = 0;
374
408
  function focusTerminal(): void {
375
409
  input.focus({ preventScroll: true });
376
410
  }
@@ -380,6 +414,7 @@ export function createTerminal(
380
414
  lastPointerType = e.pointerType;
381
415
  pointerDownX = e.clientX;
382
416
  pointerDownY = e.clientY;
417
+ pointerDownTime = e.timeStamp;
383
418
  },
384
419
  { passive: true, signal },
385
420
  );
@@ -394,13 +429,29 @@ export function createTerminal(
394
429
  }
395
430
  const dx = Math.abs(e.clientX - pointerDownX);
396
431
  const dy = Math.abs(e.clientY - pointerDownY);
432
+ // A drag (scroll / selection-extend) or a long-press (native word-select /
433
+ // context menu) is not a tap-to-focus: bow out and let the browser own it.
397
434
  if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
398
435
  return;
399
436
  }
437
+ if (e.timeStamp - pointerDownTime > TAP_MAX_MS) {
438
+ return;
439
+ }
440
+ // A clean tap while text is selected means "done selecting": clear the
441
+ // selection (and dismiss the OS callout with it). This is our deselect —
442
+ // iOS otherwise leaves the selection stuck, because the synthetic mousedown
443
+ // we preventDefault to preserve the keyboard also suppresses the platform's
444
+ // own tap-to-deselect. Do NOT also focus: a deselect tap should not pop the
445
+ // keyboard; the next clean tap (nothing selected) opens it. The long-press
446
+ // that MADE the selection is filtered out above by the duration/movement
447
+ // guards, so only a deliberate later tap reaches here.
400
448
  const sel = window.getSelection();
401
- if (sel && sel.toString().length > 0) {
449
+ if (sel && !sel.isCollapsed) {
450
+ sel.removeAllRanges();
402
451
  return;
403
452
  }
453
+ // A clean tap with nothing selected focuses the input, synchronously (an
454
+ // async focus would not raise the iOS soft keyboard).
404
455
  focusTerminal();
405
456
  },
406
457
  { passive: true, signal },
@@ -477,7 +528,9 @@ export function createTerminal(
477
528
  "visibilitychange",
478
529
  () => {
479
530
  if (document.visibilityState === "visible") {
480
- connection.reconnectNow();
531
+ if (connectionInitiated) {
532
+ connection.reconnectNow();
533
+ }
481
534
  focusTerminal();
482
535
  }
483
536
  },
@@ -486,7 +539,9 @@ export function createTerminal(
486
539
  window.addEventListener(
487
540
  "pageshow",
488
541
  () => {
489
- connection.reconnectNow();
542
+ if (connectionInitiated) {
543
+ connection.reconnectNow();
544
+ }
490
545
  focusTerminal();
491
546
  },
492
547
  { signal },
@@ -494,7 +549,9 @@ export function createTerminal(
494
549
  window.addEventListener(
495
550
  "online",
496
551
  () => {
497
- connection.reconnectNow();
552
+ if (connectionInitiated) {
553
+ connection.reconnectNow();
554
+ }
498
555
  },
499
556
  { signal },
500
557
  );
@@ -584,6 +641,9 @@ export function createTerminal(
584
641
  // Reconnect the terminal WS to this session using its per-tab resume
585
642
  // state; the renderer was already pointed at its store by tabs.
586
643
  connection.setSession(session.id);
644
+ // The managed first connect has happened (session id is now on the WS
645
+ // URL); wake-reconnect handlers may fire from here on.
646
+ connectionInitiated = true;
587
647
  for (const { instance } of instances) {
588
648
  if (instance.onSwitch) {
589
649
  instance.onSwitch(session);
@@ -658,6 +718,7 @@ export function createTerminal(
658
718
  const sessionManaged = featureList.some((f) => f.managesSessions === true);
659
719
  if (!sessionManaged) {
660
720
  connection.connect();
721
+ connectionInitiated = true;
661
722
  }
662
723
  focusTerminal();
663
724
 
@@ -272,6 +272,14 @@ export interface CreateTerminalOptions {
272
272
  fontReady?: string;
273
273
  /** Optional pre-JS loading overlay the kernel fades out on first paint. */
274
274
  loading?: HTMLElement;
275
+ /** Theme overrides: CSS custom properties set on the terminal root so a
276
+ * consumer recolors the UI (accent, tab hover/active) without shipping CSS.
277
+ * Keys must be CSS custom-property names (start with "--"); values are any
278
+ * CSS value. The library ships the defaults (css/00-tokens.css) — the
279
+ * "template"; these are the consumer's "settings" and override them for this
280
+ * instance. Known tokens: --accent, --tab-hover-bg, --tab-active-bg,
281
+ * --tab-active-fg. */
282
+ theme?: Readonly<Record<string, string>>;
275
283
  }
276
284
 
277
285
  /** The handle createTerminal returns. Feature APIs are not materialized here
package/src/presets.ts CHANGED
@@ -4,7 +4,11 @@
4
4
  // lists (convenient, full UI); a consumer wanting a minimal footprint imports
5
5
  // individual features instead.
6
6
  //
7
- // presetTabbed (tabs + activityMonitor) is added once those features land.
7
+ // The tabbed presets split by consumer: presetTabbed is the generic label-only
8
+ // tabbed UI (a plain bash/sh terminal, the first-class experience); the derived
9
+ // presetAgentTabbed layers the activity monitor on top, so an agent shell like
10
+ // vibecli gets per-tab status dots. The split keeps agent semantics out of the
11
+ // generic terminal (design "generic first, vibecli is a derivation").
8
12
 
9
13
  import type { TerminalFeature } from "./kernel/types.js";
10
14
  import { clipboard } from "./features/clipboard.js";
@@ -15,6 +19,7 @@ import { connectionBanner } from "./features/connection-banner.js";
15
19
  import { mobileToolbar } from "./features/mobile-toolbar.js";
16
20
  import { tabs } from "./features/tabs.js";
17
21
  import { activityMonitor } from "./features/activity-monitor.js";
22
+ import type { ActivityMonitorApi } from "./features/activity-monitor.js";
18
23
  import { animations } from "./features/animations.js";
19
24
 
20
25
  /** Single-pane desktop UI: context menu, clipboard, scroll-to-bottom, predictive
@@ -37,10 +42,35 @@ export function presetTouch(): TerminalFeature<unknown>[] {
37
42
  return [...presetSingle(), mobileToolbar()];
38
43
  }
39
44
 
40
- /** Full reference UI: presetTouch plus tabs, the activity monitor, and
41
- * animations. activityMonitor is ordered before tabs because tabs reads its
42
- * API via ctx.use during setup (to wire status dots). */
45
+ // buildTabbed composes the tabbed UI shared by both tabbed presets: the
46
+ // single-pane touch features, an externally-driven mobile toolbar (its grid is
47
+ // opened from a keyboard button in the tab bar, not its own toggle), tabs wired
48
+ // to that toolbar, and animations. An optional activity monitor adds per-tab
49
+ // status dots (the agent variant). The toolbar and monitor are ordered before
50
+ // tabs because tabs reads their APIs via ctx.use.
51
+ function buildTabbed(monitor?: TerminalFeature<ActivityMonitorApi>): TerminalFeature<unknown>[] {
52
+ const kb = mobileToolbar({ externalToggle: true });
53
+ return [
54
+ ...presetSingle(),
55
+ kb,
56
+ ...(monitor ? [monitor] : []),
57
+ tabs({ keyboardToggle: kb, ...(monitor ? { activityMonitor: monitor } : {}) }),
58
+ animations(),
59
+ ];
60
+ }
61
+
62
+ /** Generic tabbed UI: touch features, label-only tabs, the mobile keyboard bar,
63
+ * and animations. No activity monitor, so tabs carry no status dots. This is
64
+ * the first-class experience for a plain terminal (bash/sh) where "agent
65
+ * activity" has no meaning. An agent shell wants presetAgentTabbed instead. */
43
66
  export function presetTabbed(): TerminalFeature<unknown>[] {
44
- const monitor = activityMonitor();
45
- return [...presetTouch(), monitor, tabs({ activityMonitor: monitor }), animations()];
67
+ return buildTabbed();
68
+ }
69
+
70
+ /** Agent-shell tabbed UI: presetTabbed plus the activity monitor, so each tab
71
+ * shows a live status dot (idle / working / done / needs-input) from the
72
+ * server's status SSE. vibecli composes this; a generic terminal composes
73
+ * presetTabbed. */
74
+ export function presetAgentTabbed(): TerminalFeature<unknown>[] {
75
+ return buildTabbed(activityMonitor());
46
76
  }
package/src/viewport.ts CHANGED
@@ -74,8 +74,23 @@ export function init(opts: {
74
74
  // left behind the keyboard for iOS to scroll to.
75
75
  const offsetTop = Math.max(0, Math.round(vv.offsetTop));
76
76
  const bottomInset = Math.max(0, Math.round(window.innerHeight - vv.offsetTop - vv.height));
77
+ // Reserved bottom chrome (a bottom tab bar) the content must clear, on top
78
+ // of the keyboard inset. A feature sets --wt-reserve-bottom (px) on the
79
+ // root, 0 when none; viewport owns the terminal's fixed-box geometry, so it
80
+ // folds the reserve into the bottom offset here. The reserve excludes the
81
+ // keyboard (it is measured with the keyboard closed), so adding it to
82
+ // bottomInset does not double-count.
83
+ const reserve = Math.max(
84
+ 0,
85
+ Math.round(
86
+ parseFloat(
87
+ getComputedStyle(document.documentElement).getPropertyValue("--wt-reserve-bottom"),
88
+ ) || 0,
89
+ ),
90
+ );
91
+ const bottom = bottomInset + reserve;
77
92
  termWrap.style.top = offsetTop > 0 ? `${offsetTop}px` : "";
78
- termWrap.style.bottom = bottomInset > 0 ? `${bottomInset}px` : "";
93
+ termWrap.style.bottom = bottom > 0 ? `${bottom}px` : "";
79
94
  // Expose the same geometry as CSS vars for the sibling fixed chrome:
80
95
  // --kb-inset lifts the bottom banner above the keyboard; --vv-top lets the
81
96
  // top key toolbar follow the visual viewport's offset (otherwise it