@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.
- package/README.md +24 -11
- package/css/00-tokens.css +13 -1
- package/css/02-app.css +24 -120
- package/css/03-primitives.css +82 -0
- package/css/04-feedback.css +166 -0
- package/css/05-tabs.css +312 -0
- package/css/06-mobile.css +335 -0
- package/css/07-animations.css +102 -0
- package/css/MANIFEST +7 -1
- package/package.json +3 -3
- package/src/features/clipboard.ts +10 -0
- package/src/features/connection-banner.ts +1 -1
- package/src/features/context-menu.ts +174 -42
- package/src/features/mobile-toolbar.ts +59 -1
- package/src/features/scroll-to-bottom.ts +22 -6
- package/src/features/tabs.ts +1360 -254
- package/src/kernel/kernel.ts +65 -4
- package/src/kernel/types.ts +8 -0
- package/src/presets.ts +43 -6
- package/src/viewport.ts +16 -1
- package/css/03-v3.css +0 -705
package/src/kernel/kernel.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/kernel/types.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 [
|
|
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 =
|
|
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
|