@cplieger/web-terminal-ui 3.4.5 → 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 CHANGED
@@ -3,6 +3,7 @@
3
3
  [![npm](https://img.shields.io/npm/v/@cplieger/web-terminal-ui)](https://www.npmjs.com/package/@cplieger/web-terminal-ui)
4
4
  [![JSR](https://jsr.io/badges/@cplieger/web-terminal-ui)](https://jsr.io/@cplieger/web-terminal-ui)
5
5
  [![Test coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/web-terminal-ui/badges/coverage.json)](https://github.com/cplieger/web-terminal-ui/actions/workflows/coverage.yml)
6
+ [![Mutation (TS)](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/web-terminal-ui/badges/mutation-ts.json)](https://github.com/cplieger/web-terminal-ui/issues?q=label%3Astryker-tracker)
6
7
  [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13431/badge)](https://www.bestpractices.dev/projects/13431)
7
8
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cplieger/web-terminal-ui/badge)](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.4.5",
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.2.0"
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 {
@@ -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
- if (sessions.length === 0) {
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 retry.
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
- let startTab = tabList[0];
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;
@@ -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), except "offline" once the initial-failure limit
58
- // is reached. connectionBanner renders "open" as hidden.
59
- if (!loaded && !(state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT)) {
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
  },
@@ -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();
@@ -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 = "open" | "connecting" | "reconnecting" | "offline" | "restarted";
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
  //