@cplieger/web-terminal-ui 3.5.0 → 4.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.
- package/README.md +60 -27
- package/css/00-tokens.css +18 -2
- package/css/01-scope.css +76 -0
- package/css/{02-app.css → 02-terminal.css} +113 -239
- package/css/10-primitives.css +98 -0
- package/css/20-connection-banner.css +42 -0
- package/css/21-scroll-to-bottom.css +65 -0
- package/css/22-context-menu.css +45 -0
- package/css/23-toolbar.css +181 -0
- package/css/{05-tabs.css → 30-tabs.css} +127 -66
- package/css/{06-mobile.css → 31-switcher.css} +85 -80
- package/css/{07-animations.css → 40-animations.css} +15 -15
- package/css/MANIFEST +19 -11
- package/css/MANIFEST.single +12 -0
- package/css/MANIFEST.tabbed +16 -0
- package/css/MANIFEST.touch +12 -0
- package/css/{01-reset.css → page.css} +18 -19
- package/package.json +19 -6
- package/scaffold/index.html +5 -0
- package/src/features/activity-monitor.ts +32 -2
- package/src/features/connection-banner.ts +1 -0
- package/src/features/context-menu.ts +6 -33
- package/src/features/menu-position.ts +50 -0
- package/src/features/mobile-toolbar.ts +5 -4
- package/src/features/{tabs.ts → tabs/index.ts} +310 -721
- package/src/features/tabs/model.ts +342 -0
- package/src/features/tabs/strip.ts +101 -0
- package/src/features/tabs/switcher.ts +129 -0
- package/src/index.ts +1 -0
- package/src/kernel/a11y.ts +30 -5
- package/src/kernel/conn-state.ts +16 -6
- package/src/kernel/kernel.ts +170 -59
- package/src/kernel/types.ts +68 -15
- package/src/presets/agent-tabbed.ts +15 -0
- package/src/presets/single.ts +27 -0
- package/src/presets/tabbed.ts +42 -0
- package/src/presets/touch.ts +13 -0
- package/src/presets.ts +12 -76
- package/src/viewport.ts +28 -15
- package/css/03-primitives.css +0 -83
- package/css/04-feedback.css +0 -180
package/package.json
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
|
-
"./presets": "./src/presets.ts"
|
|
9
|
+
"./presets": "./src/presets.ts",
|
|
10
|
+
"./presets/single": "./src/presets/single.ts",
|
|
11
|
+
"./presets/touch": "./src/presets/touch.ts",
|
|
12
|
+
"./presets/tabbed": "./src/presets/tabbed.ts",
|
|
13
|
+
"./presets/agent-tabbed": "./src/presets/agent-tabbed.ts",
|
|
14
|
+
"./features/clipboard": "./src/features/clipboard.ts",
|
|
15
|
+
"./features/context-menu": "./src/features/context-menu.ts",
|
|
16
|
+
"./features/scroll-to-bottom": "./src/features/scroll-to-bottom.ts",
|
|
17
|
+
"./features/predictive-echo": "./src/features/predictive-echo.ts",
|
|
18
|
+
"./features/connection-banner": "./src/features/connection-banner.ts",
|
|
19
|
+
"./features/mobile-toolbar": "./src/features/mobile-toolbar.ts",
|
|
20
|
+
"./features/tabs": "./src/features/tabs/index.ts",
|
|
21
|
+
"./features/activity-monitor": "./src/features/activity-monitor.ts",
|
|
22
|
+
"./features/animations": "./src/features/animations.ts"
|
|
10
23
|
},
|
|
11
24
|
"sideEffects": [
|
|
12
25
|
"**/*.css"
|
|
@@ -25,7 +38,7 @@
|
|
|
25
38
|
"verify": "bash scripts/verify.sh"
|
|
26
39
|
},
|
|
27
40
|
"peerDependencies": {
|
|
28
|
-
"@cplieger/web-terminal-engine": "
|
|
41
|
+
"@cplieger/web-terminal-engine": "^3.0.0"
|
|
29
42
|
},
|
|
30
43
|
"devDependencies": {
|
|
31
44
|
"@eslint/js": "10.0.1",
|
|
@@ -38,13 +51,13 @@
|
|
|
38
51
|
"eslint": "10.7.0",
|
|
39
52
|
"fast-check": "4.9.0",
|
|
40
53
|
"happy-dom": "20.10.6",
|
|
41
|
-
"html-validate": "11.5.
|
|
42
|
-
"knip": "6.
|
|
54
|
+
"html-validate": "11.5.6",
|
|
55
|
+
"knip": "6.27.0",
|
|
43
56
|
"prettier": "3.9.5",
|
|
44
57
|
"stylelint": "17.14.0",
|
|
45
58
|
"stylelint-config-standard": "40.0.0",
|
|
46
59
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
47
|
-
"typescript-eslint": "8.
|
|
60
|
+
"typescript-eslint": "8.64.0",
|
|
48
61
|
"vitest": "4.1.10"
|
|
49
62
|
},
|
|
50
63
|
"publishConfig": {
|
package/scaffold/index.html
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
<meta name="color-scheme" content="dark">
|
|
7
7
|
<title>Web Terminal</title>
|
|
8
8
|
<!-- App icons and web-app manifest are deployment-specific; add your own. -->
|
|
9
|
+
<!-- /style.css = the package's css/MANIFEST concatenated: the reference
|
|
10
|
+
FULL-PAGE bundle (css/page.css — html/body reset + @font-face expecting
|
|
11
|
+
the font files at /vendor/fonts/ — plus the complete tabbed component
|
|
12
|
+
set). An embedder serves a per-preset manifest (css/MANIFEST.single/
|
|
13
|
+
.touch/.tabbed) instead and passes layout: "container". -->
|
|
9
14
|
<link rel="stylesheet" href="/style.css">
|
|
10
15
|
<!-- [html-validate-disable-next no-style-tag -- critical CSS for instant loading indicator] -->
|
|
11
16
|
<style>
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
// session's live status through its API (design sections 7, 22.4, 22.5). It is a
|
|
4
4
|
// pure data source with no chrome of its own; the tabs feature consumes it via
|
|
5
5
|
// ctx.use to render per-tab activity dots and drop exited/removed tabs. The
|
|
6
|
-
// server pushes an initial snapshot on every (re)open
|
|
7
|
-
//
|
|
6
|
+
// server pushes an initial snapshot on every (re)open — but the snapshot
|
|
7
|
+
// carries no tombstones for sessions a REPLACEMENT server never knew (manager
|
|
8
|
+
// restart), so the stream-open signal is surfaced via onStreamOpen and the
|
|
9
|
+
// consumer (tabs) runs a one-shot GET /api/sessions reconcile there to drop
|
|
10
|
+
// zombie tabs.
|
|
8
11
|
|
|
9
12
|
import { connectStatusStream } from "@cplieger/web-terminal-engine";
|
|
10
13
|
import type { SessionStatus } from "@cplieger/web-terminal-engine";
|
|
@@ -18,6 +21,13 @@ export interface ActivityMonitorApi {
|
|
|
18
21
|
onStatus(cb: (s: SessionStatus) => void): Unsubscribe;
|
|
19
22
|
/** The last known status for a session, or undefined. */
|
|
20
23
|
current(id: string): SessionStatus | undefined;
|
|
24
|
+
/** Subscribe to stream (re)open events — fired on the initial SSE connect
|
|
25
|
+
* and every reconnect (including the reopen against a restarted server,
|
|
26
|
+
* whose fresh snapshot cannot tombstone sessions it never knew). A
|
|
27
|
+
* subscriber attaching after an open has already happened is called
|
|
28
|
+
* immediately (catch-up), so a late consumer never misses the initial
|
|
29
|
+
* open. Optional so alternative monitor implementations remain valid. */
|
|
30
|
+
onStreamOpen?(cb: () => void): Unsubscribe;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
export function activityMonitor(
|
|
@@ -29,8 +39,20 @@ export function activityMonitor(
|
|
|
29
39
|
const path = opts.eventsPath ?? DEFAULT_EVENTS_PATH;
|
|
30
40
|
const statuses = new Map<string, SessionStatus>();
|
|
31
41
|
const subs = new Set<(s: SessionStatus) => void>();
|
|
42
|
+
const openSubs = new Set<() => void>();
|
|
43
|
+
let opens = 0;
|
|
32
44
|
|
|
33
45
|
const stream = connectStatusStream(path, {
|
|
46
|
+
onOpen() {
|
|
47
|
+
opens++;
|
|
48
|
+
for (const cb of [...openSubs]) {
|
|
49
|
+
try {
|
|
50
|
+
cb();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error("web-terminal-ui: activityMonitor open subscriber threw", err);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
34
56
|
onStatus(s) {
|
|
35
57
|
if (s.removed) {
|
|
36
58
|
statuses.delete(s.id);
|
|
@@ -59,10 +81,18 @@ export function activityMonitor(
|
|
|
59
81
|
current(id) {
|
|
60
82
|
return statuses.get(id);
|
|
61
83
|
},
|
|
84
|
+
onStreamOpen(cb) {
|
|
85
|
+
openSubs.add(cb);
|
|
86
|
+
if (opens > 0) {
|
|
87
|
+
cb(); // catch-up: the stream opened before this subscriber attached
|
|
88
|
+
}
|
|
89
|
+
return () => openSubs.delete(cb);
|
|
90
|
+
},
|
|
62
91
|
},
|
|
63
92
|
teardown() {
|
|
64
93
|
stream.close();
|
|
65
94
|
subs.clear();
|
|
95
|
+
openSubs.clear();
|
|
66
96
|
statuses.clear();
|
|
67
97
|
},
|
|
68
98
|
};
|
|
@@ -14,6 +14,7 @@ const TEXT: Partial<Record<ConnState, string>> = {
|
|
|
14
14
|
// The session's process exited (definitive; nothing is retrying). The final
|
|
15
15
|
// screen stays rendered behind the banner; open a new tab to keep working.
|
|
16
16
|
ended: "Session ended",
|
|
17
|
+
incompatible: "Terminal protocol mismatch; update the server or reload this page",
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export function connectionBanner(): TerminalFeature {
|
|
@@ -38,17 +38,15 @@
|
|
|
38
38
|
|
|
39
39
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
40
40
|
import type { ClipboardApi } from "./clipboard.js";
|
|
41
|
+
import { placeMenuAt } from "./menu-position.js";
|
|
41
42
|
|
|
42
43
|
// Slightly longer than the browser's ~500ms native long-press so a native word
|
|
43
44
|
// selection (which we defer to) has registered — and fired selectionchange —
|
|
44
45
|
// before this fires.
|
|
45
46
|
const LONG_PRESS_MS = 550;
|
|
46
47
|
const LONG_PRESS_MOVE_PX = 10;
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
// above the keyboard), it opens above the point. This gap lifts it clear of the
|
|
50
|
-
// fingertip so the touch does not cover the menu items.
|
|
51
|
-
const MENU_GAP = 16;
|
|
48
|
+
// Viewport clamping + the flip-above-the-fingertip gap live in the shared
|
|
49
|
+
// point-anchored positioner (menu-position.ts), shared with the tab menu.
|
|
52
50
|
// After a touch long-press opens the menu, swallow the trailing click/tap that
|
|
53
51
|
// the same gesture emits on finger-release for this window, so the menu does not
|
|
54
52
|
// immediately dismiss itself (the classic contextmenu-then-touchend race).
|
|
@@ -209,35 +207,10 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
209
207
|
return; // nothing to offer
|
|
210
208
|
}
|
|
211
209
|
// Make visible (so it has measurable size), then place it within the
|
|
212
|
-
//
|
|
213
|
-
// visual viewport
|
|
214
|
-
// keyboard, so clamping to it keeps the menu off the keyboard too.
|
|
210
|
+
// visible viewport via the shared point-anchored positioner (clamp to
|
|
211
|
+
// the visual viewport; flip above the finger near the bottom edge).
|
|
215
212
|
menu.classList.add("visible");
|
|
216
|
-
|
|
217
|
-
const viewLeft = vv ? vv.offsetLeft : 0;
|
|
218
|
-
const viewTop = vv ? vv.offsetTop : 0;
|
|
219
|
-
const viewWidth = vv ? vv.width : window.innerWidth;
|
|
220
|
-
const viewHeight = vv ? vv.height : window.innerHeight;
|
|
221
|
-
const menuW = menu.offsetWidth;
|
|
222
|
-
const menuH = menu.offsetHeight;
|
|
223
|
-
const left = Math.max(
|
|
224
|
-
viewLeft + EDGE_MARGIN,
|
|
225
|
-
Math.min(x, viewLeft + viewWidth - menuW - EDGE_MARGIN),
|
|
226
|
-
);
|
|
227
|
-
// Open just below the point; but if that would overflow the visible
|
|
228
|
-
// bottom (a long-press near the bottom edge, where the user naturally
|
|
229
|
-
// taps), flip above the finger so the menu is neither clipped off-screen
|
|
230
|
-
// nor hidden under the fingertip.
|
|
231
|
-
let top = y;
|
|
232
|
-
if (y + menuH + EDGE_MARGIN > viewTop + viewHeight) {
|
|
233
|
-
top = y - menuH - MENU_GAP;
|
|
234
|
-
}
|
|
235
|
-
top = Math.max(
|
|
236
|
-
viewTop + EDGE_MARGIN,
|
|
237
|
-
Math.min(top, viewTop + viewHeight - menuH - EDGE_MARGIN),
|
|
238
|
-
);
|
|
239
|
-
menu.style.left = `${String(left)}px`;
|
|
240
|
-
menu.style.top = `${String(top)}px`;
|
|
213
|
+
placeMenuAt(menu, x, y);
|
|
241
214
|
// A touch long-press is immediately followed by a synthetic click on
|
|
242
215
|
// release; swallow it so the menu stays open (see SWALLOW_MS).
|
|
243
216
|
if (touch) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// menu-position.ts — shared point-anchored placement for the fixed-position
|
|
2
|
+
// menus (the terminal context menu and the tab-strip menu), which previously
|
|
3
|
+
// carried two hand-synced copies of this exact clamp/flip math.
|
|
4
|
+
//
|
|
5
|
+
// position:fixed means x/y are viewport coordinates; the VISUAL viewport
|
|
6
|
+
// (when present) is the area above the on-screen keyboard, so clamping to it
|
|
7
|
+
// keeps a menu off the keyboard too.
|
|
8
|
+
|
|
9
|
+
/** Clamp margin from the visible viewport edges (px). */
|
|
10
|
+
const EDGE_MARGIN = 8;
|
|
11
|
+
|
|
12
|
+
/** Gap between the pointer and a flipped-above menu (px), so the menu is not
|
|
13
|
+
* hidden under the fingertip that long-pressed near the bottom edge. */
|
|
14
|
+
const FLIP_GAP = 16;
|
|
15
|
+
|
|
16
|
+
/** Place an already-visible `menu` at viewport point (x, y): open just below
|
|
17
|
+
* the point, flip above it when that would overflow the visible bottom, and
|
|
18
|
+
* clamp into the visual viewport on both axes. The menu is absolute-positioned
|
|
19
|
+
* against the terminal root (.wt-root), so the viewport-space result is
|
|
20
|
+
* rebased onto its offsetParent's box — placement is identical whether the
|
|
21
|
+
* root fills the viewport (wt-viewport) or an embedder's panel
|
|
22
|
+
* (wt-container). The menu must be measurable (rendered, not display:none)
|
|
23
|
+
* when called. */
|
|
24
|
+
export function placeMenuAt(menu: HTMLElement, x: number, y: number): void {
|
|
25
|
+
const vv = window.visualViewport;
|
|
26
|
+
const viewLeft = vv ? vv.offsetLeft : 0;
|
|
27
|
+
const viewTop = vv ? vv.offsetTop : 0;
|
|
28
|
+
const viewWidth = vv ? vv.width : window.innerWidth;
|
|
29
|
+
const viewHeight = vv ? vv.height : window.innerHeight;
|
|
30
|
+
const menuW = menu.offsetWidth;
|
|
31
|
+
const menuH = menu.offsetHeight;
|
|
32
|
+
const left = Math.max(
|
|
33
|
+
viewLeft + EDGE_MARGIN,
|
|
34
|
+
Math.min(x, viewLeft + viewWidth - menuW - EDGE_MARGIN),
|
|
35
|
+
);
|
|
36
|
+
let top = y;
|
|
37
|
+
if (y + menuH + EDGE_MARGIN > viewTop + viewHeight) {
|
|
38
|
+
top = y - menuH - FLIP_GAP;
|
|
39
|
+
}
|
|
40
|
+
top = Math.max(viewTop + EDGE_MARGIN, Math.min(top, viewTop + viewHeight - menuH - EDGE_MARGIN));
|
|
41
|
+
// Rebase from viewport coordinates onto the positioned ancestor's box: left/
|
|
42
|
+
// top on an absolute element are offsets from the offsetParent's padding box,
|
|
43
|
+
// which for all terminal chrome is .wt-root (getBoundingClientRect is its
|
|
44
|
+
// viewport-space position; the root has no border, so border/padding offsets
|
|
45
|
+
// do not enter). A missing offsetParent (display:none ancestor — the menu is
|
|
46
|
+
// visible when called, so not in practice) falls back to (0,0) viewport base.
|
|
47
|
+
const base = menu.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
|
48
|
+
menu.style.left = `${String(Math.round(left - base.left))}px`;
|
|
49
|
+
menu.style.top = `${String(Math.round(top - base.top))}px`;
|
|
50
|
+
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// mobileToolbar feature: the on-screen key toolbar (Tab/Esc/arrows/Enter + a
|
|
2
2
|
// collapse toggle and sticky-Ctrl) in the thumb-zone region (design section
|
|
3
|
-
// 22.4). The engine's
|
|
4
|
-
// arrows, sticky-Ctrl state
|
|
3
|
+
// 22.4). The engine's toolbar.bindMobileToolbar wires the buttons (DECCKM-aware
|
|
4
|
+
// arrows, sticky-Ctrl state; engine >= v3, where the widget moved out of the
|
|
5
|
+
// keyboard module); this feature builds the toolbar chrome, routes its
|
|
5
6
|
// output through the kernel funnel, and registers sticky-Ctrl as an input
|
|
6
7
|
// transform so a typed character is rewritten to its Ctrl byte when armed.
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
+
import { toolbar } from "@cplieger/web-terminal-engine";
|
|
9
10
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
10
11
|
import { fromHTML } from "./dom.js";
|
|
11
12
|
|
|
12
|
-
const { bindMobileToolbar } =
|
|
13
|
+
const { bindMobileToolbar } = toolbar;
|
|
13
14
|
|
|
14
15
|
/** mobileToolbar's public API, so a peer feature (tabs) can drive the key grid
|
|
15
16
|
* from a button it owns instead of the toolbar's own toggle. */
|