@cplieger/web-terminal-ui 3.4.4 → 3.5.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 +1 -0
- package/package.json +5 -2
- package/src/features/connection-banner.ts +3 -0
- package/src/features/context-menu.ts +47 -14
- package/src/features/tabs.ts +30 -8
- package/src/kernel/conn-state.ts +20 -3
- package/src/kernel/kernel.ts +13 -0
- package/src/kernel/types.ts +10 -1
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@cplieger/web-terminal-ui)
|
|
4
4
|
[](https://jsr.io/@cplieger/web-terminal-ui)
|
|
5
5
|
[](https://github.com/cplieger/web-terminal-ui/actions/workflows/coverage.yml)
|
|
6
|
+
[](https://github.com/cplieger/web-terminal-ui/issues?q=label%3Astryker-tracker)
|
|
6
7
|
[](https://www.bestpractices.dev/projects/13431)
|
|
7
8
|
[](https://scorecard.dev/viewer/?uri=github.com/cplieger/web-terminal-ui)
|
|
8
9
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.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",
|
|
@@ -25,10 +25,13 @@
|
|
|
25
25
|
"verify": "bash scripts/verify.sh"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
-
"@cplieger/web-terminal-engine": ">=2.
|
|
28
|
+
"@cplieger/web-terminal-engine": ">=2.4.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@eslint/js": "10.0.1",
|
|
32
|
+
"@stryker-mutator/core": "9.6.1",
|
|
33
|
+
"@stryker-mutator/typescript-checker": "9.6.1",
|
|
34
|
+
"@stryker-mutator/vitest-runner": "9.6.1",
|
|
32
35
|
"@types/node": "25.9.5",
|
|
33
36
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
34
37
|
"@vitest/coverage-v8": "4.1.10",
|
|
@@ -11,6 +11,9 @@ const TEXT: Partial<Record<ConnState, string>> = {
|
|
|
11
11
|
reconnecting: "Reconnecting\u2026",
|
|
12
12
|
offline: "Offline",
|
|
13
13
|
restarted: "Server restarted; recent input may be lost",
|
|
14
|
+
// The session's process exited (definitive; nothing is retrying). The final
|
|
15
|
+
// screen stays rendered behind the banner; open a new tab to keep working.
|
|
16
|
+
ended: "Session ended",
|
|
14
17
|
};
|
|
15
18
|
|
|
16
19
|
export function connectionBanner(): TerminalFeature {
|
|
@@ -21,13 +21,20 @@
|
|
|
21
21
|
// getSelection() read at a fixed delay races the native selection and caused the
|
|
22
22
|
// reported flakiness (sometimes native, sometimes custom, sometimes both). So we
|
|
23
23
|
// TRACK whether a selection is made during THIS press via a selectionchange
|
|
24
|
-
// listener armed only while a touch is down
|
|
25
|
-
// the pending menu and, if it already opened, close it
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
24
|
+
// listener armed only while a touch is down (the moment one appears we cancel
|
|
25
|
+
// the pending menu and, if it already opened, close it), AND we gate the menu on
|
|
26
|
+
// PRESS POSITION: a long-press that landed on a glyph run belongs to the
|
|
27
|
+
// platform's word-select even when no selection has registered yet (iOS 26's
|
|
28
|
+
// native selection can land well past our 550ms timer, so timing alone misfires
|
|
29
|
+
// over text). The platforms deliver the long-press differently: iPhone fires NO
|
|
30
|
+
// contextmenu (a hold timer detects it); iPadOS DOES fire contextmenu mid-press
|
|
31
|
+
// and WebKit treats preventDefault on it as "cancel every remaining default of
|
|
32
|
+
// this gesture" — including a not-yet-registered selection — so on Apple touch
|
|
33
|
+
// devices the contextmenu handler never preventDefaults (that asymmetry is what
|
|
34
|
+
// once made iPad unable to select at all while iPhone worked); Android also
|
|
35
|
+
// fires contextmenu, and there we DO preventDefault when showing our own menu so
|
|
36
|
+
// Android's native menu doesn't also appear (the "both menus" case). All paths
|
|
37
|
+
// share the same selection + position gates, so the outcome is consistent.
|
|
31
38
|
|
|
32
39
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
33
40
|
import type { ClipboardApi } from "./clipboard.js";
|
|
@@ -116,6 +123,22 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
116
123
|
}
|
|
117
124
|
};
|
|
118
125
|
|
|
126
|
+
// True when the press landed on rendered text (a glyph run inside the
|
|
127
|
+
// output). The empty-space menu must not open there: a long-press on text
|
|
128
|
+
// belongs to the platform's word-selection, and opening ours first races
|
|
129
|
+
// it (on iOS 26 the native selection can register well after 550ms, so
|
|
130
|
+
// the old no-selection-yet check misfired over text). Hitting the row or
|
|
131
|
+
// container (the blank area beside/below content) still counts as empty
|
|
132
|
+
// space so Paste stays reachable.
|
|
133
|
+
const pressOverText = (x: number, y: number): boolean => {
|
|
134
|
+
const el = document.elementFromPoint(x, y);
|
|
135
|
+
if (!(el instanceof HTMLElement) || !el.closest(".term-output")) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const run = el.closest("span, a");
|
|
139
|
+
return run !== null && run.textContent.trim().length > 0;
|
|
140
|
+
};
|
|
141
|
+
|
|
119
142
|
// True when text is (or just got) selected, so the OS callout owns Copy and
|
|
120
143
|
// our menu stays out of the way. pressSelectedText catches a native
|
|
121
144
|
// selection the instant it happens (selectionchange), avoiding a race with
|
|
@@ -238,6 +261,20 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
238
261
|
show(e.clientX, e.clientY, false);
|
|
239
262
|
return;
|
|
240
263
|
}
|
|
264
|
+
if (appleTouch) {
|
|
265
|
+
// NEVER preventDefault a touch contextmenu on iOS/iPadOS: WebKit then
|
|
266
|
+
// cancels every remaining default action of the gesture, including a
|
|
267
|
+
// native word-selection that registers AFTER this event (iPadOS fires
|
|
268
|
+
// contextmenu mid-long-press; on iOS 26 the selection can land well
|
|
269
|
+
// after it). iPhone fires no contextmenu at all, so suppressing here
|
|
270
|
+
// is exactly what made iPad behave differently from iPhone. If our
|
|
271
|
+
// empty-space menu opened first (the hold timer beat a slow native
|
|
272
|
+
// selection), stand down so the platform's selection wins.
|
|
273
|
+
if (menu.classList.contains("visible") && !hasNativeSelection()) {
|
|
274
|
+
hide(false);
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
241
278
|
if (menu.classList.contains("visible")) {
|
|
242
279
|
e.preventDefault(); // the hold timer already opened it; just suppress native
|
|
243
280
|
return;
|
|
@@ -245,12 +282,8 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
245
282
|
if (hasNativeSelection()) {
|
|
246
283
|
return; // native selection toolbar owns Copy over text
|
|
247
284
|
}
|
|
248
|
-
if (
|
|
249
|
-
//
|
|
250
|
-
// registers, so hasNativeSelection() can still be false here. Defer
|
|
251
|
-
// anyway so the platform's own selection wins instead of our
|
|
252
|
-
// empty-space menu (which would preventDefault and suppress it).
|
|
253
|
-
return;
|
|
285
|
+
if (pressOverText(e.clientX, e.clientY)) {
|
|
286
|
+
return; // a long-press on text belongs to the platform's word-select
|
|
254
287
|
}
|
|
255
288
|
e.preventDefault();
|
|
256
289
|
clearLongPress();
|
|
@@ -322,7 +355,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
322
355
|
longPressOrigin = { x: t.clientX, y: t.clientY };
|
|
323
356
|
longPressTimer = window.setTimeout(() => {
|
|
324
357
|
longPressTimer = 0;
|
|
325
|
-
if (!hasNativeSelection()) {
|
|
358
|
+
if (!hasNativeSelection() && !pressOverText(longPressOrigin.x, longPressOrigin.y)) {
|
|
326
359
|
show(longPressOrigin.x, longPressOrigin.y, true);
|
|
327
360
|
}
|
|
328
361
|
}, LONG_PRESS_MS);
|
package/src/features/tabs.ts
CHANGED
|
@@ -54,6 +54,11 @@ const ACTIVE_TAB_KEY = "wt-active-session";
|
|
|
54
54
|
const SWITCHER_MEDIA = "(pointer: coarse) and (max-width: 600px)";
|
|
55
55
|
// Default cadence for the no-activityMonitor polling fallback.
|
|
56
56
|
const DEFAULT_POLL_MS = 4000;
|
|
57
|
+
// The server-side status of a session whose process has exited (mirrors the
|
|
58
|
+
// engine's terminal.StatusExited). Such a session is viewable history — its
|
|
59
|
+
// final screen replays and the kernel shows "Session ended" — but it can never
|
|
60
|
+
// produce output again, so session selection prefers live sessions everywhere.
|
|
61
|
+
const STATUS_EXITED = "exited";
|
|
57
62
|
// Tab context-menu viewport clamp (mirrors context-menu.ts): keep this margin
|
|
58
63
|
// from every viewport edge, and this gap above the pointer when the menu has to
|
|
59
64
|
// flip up (a right-click near the bottom edge) so it is neither clipped nor
|
|
@@ -1598,11 +1603,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1598
1603
|
// stream / poll adopt the existing sessions below; without this they would
|
|
1599
1604
|
// render inert (blank, never connecting) until the user taps a tab. This
|
|
1600
1605
|
// is the sibling of the '+'-retry recovery the bootstrap already handles.
|
|
1606
|
+
// A live tab outranks an exited one (its dot status is fed by the same
|
|
1607
|
+
// SSE/poll that adopted it); a corpse is only auto-activated when nothing
|
|
1608
|
+
// else exists.
|
|
1601
1609
|
function ensureActive(): void {
|
|
1602
1610
|
if (activeId !== null) {
|
|
1603
1611
|
return;
|
|
1604
1612
|
}
|
|
1605
|
-
const first = tabList[0];
|
|
1613
|
+
const first = tabList.find((t) => t.dot.dataset["status"] !== STATUS_EXITED) ?? tabList[0];
|
|
1606
1614
|
if (first) {
|
|
1607
1615
|
switchTo(first.id);
|
|
1608
1616
|
}
|
|
@@ -2471,18 +2479,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2471
2479
|
} catch {
|
|
2472
2480
|
sessions = [];
|
|
2473
2481
|
}
|
|
2474
|
-
|
|
2482
|
+
// Spawn a fresh session unless a LIVE one is listed. An exited session is
|
|
2483
|
+
// viewable history, not a working terminal — booting a page whose every
|
|
2484
|
+
// session has died (the agent exited: a sign-in dead end, a crash) onto a
|
|
2485
|
+
// corpse was the stuck-loading wedge. The exited ones are still adopted
|
|
2486
|
+
// below (switch to them to read their final screen; close them by hand).
|
|
2487
|
+
if (!sessions.some((s) => s.status !== STATUS_EXITED)) {
|
|
2475
2488
|
try {
|
|
2476
|
-
sessions = [await apiCreate()];
|
|
2489
|
+
sessions = [...sessions, await apiCreate()];
|
|
2477
2490
|
} catch {
|
|
2478
2491
|
// Do not let the initial spawn propagate out of setup(): the kernel
|
|
2479
2492
|
// would tear down every feature ("terminal has no chrome") and, since
|
|
2480
2493
|
// managesSessions skipped the startup connect, connectionInitiated
|
|
2481
2494
|
// stays false so the wake handlers (visibilitychange/pageshow/online)
|
|
2482
2495
|
// all no-op -- a permanently dead terminal with no recovery. Match the
|
|
2483
|
-
// runtime create() path: toast and leave the chrome up so "+" can
|
|
2496
|
+
// runtime create() path: toast and leave the chrome up so "+" can
|
|
2497
|
+
// retry. Any exited sessions stay adopted (frozen screen + "Session
|
|
2498
|
+
// ended" is still better than a blank page).
|
|
2484
2499
|
ctx.toast("Couldn't open a terminal");
|
|
2485
|
-
sessions = [];
|
|
2486
2500
|
}
|
|
2487
2501
|
}
|
|
2488
2502
|
// Adopt (dedup) rather than blindly push. The status SSE pushes a snapshot
|
|
@@ -2499,17 +2513,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2499
2513
|
// Activate the previously-active session if it still exists, else the
|
|
2500
2514
|
// first (oldest). Session ids are stable server-side, so a page reload
|
|
2501
2515
|
// reconnects to the tab the user left on instead of always the oldest.
|
|
2502
|
-
|
|
2516
|
+
// Live sessions outrank exited ones: the saved id is honored only while
|
|
2517
|
+
// its session is still live (a reload used to restore straight onto the
|
|
2518
|
+
// corpse of a died-while-away session and wedge there), and the default
|
|
2519
|
+
// is the oldest LIVE tab. Only when nothing is live (the fresh-spawn
|
|
2520
|
+
// above failed too) does an exited tab start — a frozen final screen
|
|
2521
|
+
// with the "Session ended" banner beats a blank page.
|
|
2522
|
+
const liveIds = new Set(sessions.filter((s) => s.status !== STATUS_EXITED).map((s) => s.id));
|
|
2523
|
+
const oldestLive = tabList.find((t) => liveIds.has(t.id));
|
|
2524
|
+
let startTab = oldestLive ?? tabList[0];
|
|
2503
2525
|
try {
|
|
2504
2526
|
const savedId = localStorage.getItem(ACTIVE_TAB_KEY);
|
|
2505
2527
|
if (savedId !== null && savedId !== "") {
|
|
2506
2528
|
const saved = tabList.find((x) => x.id === savedId);
|
|
2507
|
-
if (saved) {
|
|
2529
|
+
if (saved && (liveIds.has(saved.id) || oldestLive === undefined)) {
|
|
2508
2530
|
startTab = saved;
|
|
2509
2531
|
}
|
|
2510
2532
|
}
|
|
2511
2533
|
} catch {
|
|
2512
|
-
/* storage unavailable — fall back to the oldest tab */
|
|
2534
|
+
/* storage unavailable — fall back to the oldest live tab */
|
|
2513
2535
|
}
|
|
2514
2536
|
if (startTab) {
|
|
2515
2537
|
activeId = startTab.id;
|
package/src/kernel/conn-state.ts
CHANGED
|
@@ -33,6 +33,11 @@ export interface ConnStateMachine {
|
|
|
33
33
|
closed(): void;
|
|
34
34
|
/** Server restarted (epoch mismatch); recent input may be lost. */
|
|
35
35
|
restarted(): void;
|
|
36
|
+
/** The active session's process exited (the engine's definitive 4001
|
|
37
|
+
* close). Unlike closed(), this is not a failure to escalate or retry:
|
|
38
|
+
* the state shows immediately and persists until open() (a switch to a
|
|
39
|
+
* live session) replaces it. */
|
|
40
|
+
ended(): void;
|
|
36
41
|
/** First screen frame rendered: the loading overlay is done, so the banner
|
|
37
42
|
* may show reconnect state from here on. */
|
|
38
43
|
setLoaded(): void;
|
|
@@ -54,9 +59,14 @@ export function createConnState(opts: {
|
|
|
54
59
|
|
|
55
60
|
function emit(): void {
|
|
56
61
|
// Suppress transient states until the initial load is over (the loading
|
|
57
|
-
// overlay owns the screen),
|
|
58
|
-
// is reached
|
|
59
|
-
|
|
62
|
+
// overlay owns the screen), with two exceptions that must show through:
|
|
63
|
+
// "offline" once the initial-failure limit is reached, and "ended" (a
|
|
64
|
+
// definitive process exit — nothing further will ever fire, so suppressing
|
|
65
|
+
// it would hide the only explanation the page will get). connectionBanner
|
|
66
|
+
// renders "open" as hidden.
|
|
67
|
+
const passesLoadingGate =
|
|
68
|
+
state === "ended" || (state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT);
|
|
69
|
+
if (!loaded && !passesLoadingGate) {
|
|
60
70
|
opts.onState("open"); // nothing to show yet
|
|
61
71
|
return;
|
|
62
72
|
}
|
|
@@ -113,6 +123,13 @@ export function createConnState(opts: {
|
|
|
113
123
|
}
|
|
114
124
|
}, RESTARTED_CLEAR_MS);
|
|
115
125
|
},
|
|
126
|
+
ended(): void {
|
|
127
|
+
// A definitive end, not a failure: reset the failure streak so a later
|
|
128
|
+
// reconnect to a LIVE session starts its escalation ladder from zero,
|
|
129
|
+
// and show the state immediately (no grace delay — nothing is retrying).
|
|
130
|
+
consecutiveFailures = 0;
|
|
131
|
+
setState("ended", 0);
|
|
132
|
+
},
|
|
116
133
|
setLoaded(): void {
|
|
117
134
|
loaded = true;
|
|
118
135
|
},
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -313,6 +313,19 @@ export function createTerminal(
|
|
|
313
313
|
onOutboxFull() {
|
|
314
314
|
connState.closed();
|
|
315
315
|
},
|
|
316
|
+
onProcessExit() {
|
|
317
|
+
// The engine's definitive 4001 close: the session's process has exited
|
|
318
|
+
// and the engine will not reconnect it. Two jobs here. markReady()
|
|
319
|
+
// guarantees the page is usable even when the exit lands before any
|
|
320
|
+
// screen frame (attaching to an already-dead session on a server that
|
|
321
|
+
// races the replay) — without it the loading overlay would sit on top of
|
|
322
|
+
// the tabs chrome forever, which is exactly the reported stuck-loading
|
|
323
|
+
// wedge. Then surface the end state: "Session ended", not a flapping
|
|
324
|
+
// "Reconnecting…", since no reconnect is coming. The final screen (when
|
|
325
|
+
// the server delivered it) stays rendered behind the banner.
|
|
326
|
+
markReady();
|
|
327
|
+
connState.ended();
|
|
328
|
+
},
|
|
316
329
|
onServerRestart() {
|
|
317
330
|
render.resetScrollback();
|
|
318
331
|
render.resetScreen();
|
package/src/kernel/types.ts
CHANGED
|
@@ -51,7 +51,16 @@ export interface SessionView {
|
|
|
51
51
|
// --- Connection state (mirrors the kernel's connection-state machine) ---
|
|
52
52
|
|
|
53
53
|
/** Connection state the kernel owns and broadcasts on `connection:state`. */
|
|
54
|
-
export type ConnState =
|
|
54
|
+
export type ConnState =
|
|
55
|
+
| "open"
|
|
56
|
+
| "connecting"
|
|
57
|
+
| "reconnecting"
|
|
58
|
+
| "offline"
|
|
59
|
+
| "restarted"
|
|
60
|
+
// The active session's process has EXITED (the engine's definitive 4001
|
|
61
|
+
// close): not a connectivity problem, so no reconnect is coming. Cleared
|
|
62
|
+
// only by connecting somewhere live (a tab switch / a new session).
|
|
63
|
+
| "ended";
|
|
55
64
|
|
|
56
65
|
// --- Engine drive handles ---
|
|
57
66
|
//
|