@cplieger/web-terminal-ui 3.0.4 → 3.2.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,13 @@
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 (presetTabbed / presetAgentTabbed) are now identical: both
8
+ // include the activity monitor, because the per-tab activity dot reveals itself
9
+ // only when a session reports activity (OSC 9;4 progress), so a plain shell
10
+ // simply never shows one. The generic-vs-agent distinction is now purely
11
+ // server-side — an agent server sets a status classifier that maps OSC 9
12
+ // notifications to done/needs-input. presetAgentTabbed is kept as an alias for
13
+ // its consumers (vibecli).
8
14
 
9
15
  import type { TerminalFeature } from "./kernel/types.js";
10
16
  import { clipboard } from "./features/clipboard.js";
@@ -37,10 +43,41 @@ export function presetTouch(): TerminalFeature<unknown>[] {
37
43
  return [...presetSingle(), mobileToolbar()];
38
44
  }
39
45
 
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). */
43
- export function presetTabbed(): TerminalFeature<unknown>[] {
46
+ // buildTabbed composes the tabbed UI: the single-pane touch features, an
47
+ // externally-driven mobile toolbar (its grid is opened from a keyboard button in
48
+ // the tab bar, not its own toggle), the activity monitor (the status-SSE data
49
+ // source), tabs wired to both, and animations. The activity dot reveals itself
50
+ // per tab only when a session reports activity (OSC 9;4), so the monitor is
51
+ // always included — a plain shell just never reveals a dot. The toolbar and
52
+ // monitor are ordered before tabs because tabs reads their APIs via ctx.use.
53
+ function buildTabbed(): TerminalFeature<unknown>[] {
54
+ const kb = mobileToolbar({ externalToggle: true });
44
55
  const monitor = activityMonitor();
45
- return [...presetTouch(), monitor, tabs({ activityMonitor: monitor }), animations()];
56
+ return [
57
+ ...presetSingle(),
58
+ kb,
59
+ monitor,
60
+ tabs({ keyboardToggle: kb, activityMonitor: monitor }),
61
+ animations(),
62
+ ];
63
+ }
64
+
65
+ /** Tabbed UI: the touch features, tabs, the mobile keyboard bar, the activity
66
+ * monitor, and animations. Requires a server that speaks the session API
67
+ * (`/api/sessions`, `/ws?session=`, and the status SSE `/api/sessions/events`),
68
+ * such as `web-terminal-server` or `vibecli`. Each tab's title follows the
69
+ * process OSC 0/2 window title when the program sets one, else the last
70
+ * submitted line; each tab's activity dot stays hidden until its session
71
+ * reports activity via OSC 9;4 progress (kiro-cli, Claude Code, …), so a plain
72
+ * bash/sh keeps clean, label-only tabs. */
73
+ export function presetTabbed(): TerminalFeature<unknown>[] {
74
+ return buildTabbed();
75
+ }
76
+
77
+ /** Alias of presetTabbed, kept for the agent-shell consumer (vibecli). The UI is
78
+ * identical — the generic-vs-agent distinction is now purely server-side: an
79
+ * agent server sets a status classifier (mapping OSC 9 notifications to
80
+ * done/needs-input), and each tab's dot reveals itself from OSC 9;4 either way. */
81
+ export function presetAgentTabbed(): TerminalFeature<unknown>[] {
82
+ return buildTabbed();
46
83
  }
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