@cplieger/web-terminal-ui 4.0.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 CHANGED
@@ -31,7 +31,8 @@ with opt-in feature modules that own everything above the raw terminal:
31
31
  - **predictive local echo**, **IME / composition** (CJK, dictation,
32
32
  autocorrect), and **viewport + keyboard-inset** handling for the iOS soft
33
33
  keyboard, rotation, and font-load reflows
34
- - a connection-status banner and a copy toast
34
+ - a connection-status banner, including a persistent protocol-incompatibility
35
+ state, and a copy toast
35
36
 
36
37
  It is published as TypeScript source (no build step) to npm and JSR, alongside
37
38
  the CSS bundle and a reference HTML page. Consumers who want a different UI should
@@ -43,9 +44,12 @@ depend on the engine directly and skip this package.
43
44
  npm install @cplieger/web-terminal-ui @cplieger/web-terminal-engine
44
45
  ```
45
46
 
46
- `@cplieger/web-terminal-engine` is a peer dependency — the UI is built on the engine,
47
- and both halves of the wire protocol must stay in lockstep, so the consumer
48
- pins the engine version explicitly.
47
+ `@cplieger/web-terminal-engine` is a peer dependency — the UI is built on the
48
+ engine, so the consumer pins the engine version explicitly. Pairing compatibility
49
+ is governed by the engine's
50
+ [directional wire contract](https://github.com/cplieger/web-terminal-engine#wire-protocol),
51
+ not strict package-version equality; the UI surfaces a terminal incompatibility
52
+ through its connection banner.
49
53
 
50
54
  ## Usage
51
55
 
package/css/00-tokens.css CHANGED
@@ -12,6 +12,16 @@
12
12
  --surface: oklch(16% 0.005 280deg / 70%);
13
13
  --border: oklch(100% 0 0deg / 15%);
14
14
  --text: oklch(90% 0.005 280deg);
15
+
16
+ /* Captured copies of the default color pair, resolved HERE (custom
17
+ properties substitute var() at computed-value time, so descendants
18
+ inherit the resolved colors, not the references). The DECSCNM
19
+ reverse-video rule in 02-terminal.css swaps --text/--bg on .term by
20
+ reading these — a direct self-referential swap (--text: var(--bg))
21
+ would be a cycle and invalidate both. Theme overrides (inline custom
22
+ properties on this element, or a consumer rule) are captured too. */
23
+ --text-default: var(--text);
24
+ --bg-default: var(--bg);
15
25
  --text-active: color-mix(in oklch, var(--text) 15%, transparent);
16
26
  /* ===== Themeable tokens (the consumer-configurable "template") =====
17
27
  These default to the neutral built-in look; a consumer recolors the UI by
package/css/01-scope.css CHANGED
@@ -33,6 +33,26 @@
33
33
 
34
34
  .wt-root {
35
35
  -webkit-font-smoothing: antialiased;
36
+ /* The terminal subtree is dark regardless of the host page's scheme, and
37
+ UA-rendered bits inside it (classic scrollbars, selection tints) must
38
+ match — page.css declares this on html for full-page hosts, but an
39
+ embedded root (wt-container) inherits the HOST's scheme without this. */
40
+ color-scheme: dark;
41
+ /* Design-system scrollbar COLOR on every scroller in the subtree (the
42
+ terminal, the tab scroller, the expanded switcher list): border-grey
43
+ thumb, no track — the vibekit/subflux treatment. scrollbar-color
44
+ INHERITS, so the boundary is the one place to set it. Where it is not
45
+ supported the color-scheme above still renders the UA fallback dark.
46
+ Overlay-scrollbar platforms are unaffected. */
47
+ scrollbar-color: var(--border) transparent;
48
+ }
49
+
50
+ /* scrollbar-width does NOT inherit (unlike scrollbar-color), so the thin
51
+ variant is applied to every element in the boundary: a no-op for anything
52
+ that does not scroll, and any scroller a future feature adds is covered
53
+ automatically instead of joining an explicit list nobody remembers. */
54
+ .wt-root * {
55
+ scrollbar-width: thin;
36
56
  }
37
57
 
38
58
  /* stylelint-disable declaration-no-important, time-min-milliseconds --
@@ -8,6 +8,20 @@
8
8
  position: absolute;
9
9
  inset: 0;
10
10
  overflow: hidden auto;
11
+ /* Scroll stays in the terminal: reaching the top of scrollback (or the
12
+ bottom) must not chain the gesture into an embedding page (wt-container
13
+ mode), and a touch pull inside the terminal must not trigger the host's
14
+ pull-to-refresh. Full-page hosts already get this via page.css's
15
+ html/body overscroll-behavior: none; this covers the embedded case. */
16
+ overscroll-behavior: contain;
17
+ /* Reserve the scrollbar gutter from the start on classic-scrollbar
18
+ platforms. Without it the scrollbar pops in when scrollback first
19
+ exceeds a screen, narrowing the content box -> ResizeObserver -> column
20
+ recount -> PTY resize -> the running program reflows: a one-time layout
21
+ jump every fresh session. Real terminals reserve this space permanently;
22
+ with the boundary's thin dark scrollbars (01-scope.css) the idle gutter
23
+ is a subtle strip. No-op on overlay-scrollbar platforms. */
24
+ scrollbar-gutter: stable;
11
25
  /* The scroll controller (vterm scroll.ts) is the single owner of
12
26
  scrollTop: it pins to the bottom only while the user is following,
13
27
  and does nothing while they are scrolled up reading. New output
@@ -236,6 +250,41 @@
236
250
  color: var(--text);
237
251
  }
238
252
 
253
+ /* Caret overlay (engine >= v3): the renderer paints the REAL cursor as one
254
+ absolutely-positioned element in the scroll container (a sibling of
255
+ .pred-cursor, below it in the stack), not as a restyled span inside the
256
+ cursor row — rows stay pure content, so a native text selection survives
257
+ cursor motion and typing. The engine sets left/top/width/height/line-height
258
+ inline each flush; the .term-cursor / -underline / -bar variant classes
259
+ above keep supplying the colors and borders, and the JS-driven
260
+ .cursor-blink-off toggle (now on the scroll container) keeps driving the
261
+ block blink. */
262
+ :where(.wt-root) .term-cursor-overlay {
263
+ position: absolute;
264
+ pointer-events: none;
265
+ white-space: pre;
266
+ overflow: hidden;
267
+ z-index: 3;
268
+ box-sizing: border-box;
269
+ }
270
+
271
+ :where(.wt-root) .term-cursor-overlay:not(.visible) {
272
+ display: none;
273
+ }
274
+
275
+ /* DECSCNM reverse video (private mode 5): the engine toggles
276
+ .term-reverse-video on the scroll container (render.updateReverseVideo);
277
+ this rule swaps the DEFAULT color pair for the terminal subtree via the
278
+ captured copies from 00-tokens.css. Everything painted with var(--text)/
279
+ var(--bg) — default-colored cells, the screen background, the block cursor's
280
+ inverted cell — follows automatically; explicitly colored cells keep their
281
+ colors, matching xterm's DECSCNM semantics (the default pair swaps, not a
282
+ per-pixel inversion). */
283
+ :where(.wt-root) .term.term-reverse-video {
284
+ --text: var(--bg-default);
285
+ --bg: var(--text-default);
286
+ }
287
+
239
288
  :where(.wt-root) .term-blink {
240
289
  animation: wt-blink-anim 1s step-end infinite;
241
290
  }
@@ -41,6 +41,19 @@
41
41
  background: var(--text-active);
42
42
  }
43
43
 
44
+ /* Keyboard-focus ring for every chrome control (all interactive chrome is a
45
+ real <button>: tabs, +, keyboard, switcher rows, menu items, the FAB).
46
+ :focus-visible only — a tap/click never shows the ring (the UA heuristic),
47
+ so the touch-first surfaces stay clean while Tab-key navigation gets the
48
+ family's accent ring (the vibekit/subflux convention: 2px accent, 2px out).
49
+ Follows each control's border-radius; themeable via --accent like the "+"
50
+ glyphs. The terminal's own hidden textarea is not a button and keeps its
51
+ outline: none (02-terminal.css). */
52
+ :where(.wt-root) button:focus-visible {
53
+ outline: 2px solid var(--accent);
54
+ outline-offset: 2px;
55
+ }
56
+
44
57
  :where(.wt-root) .wt-btn svg {
45
58
  width: var(--icon);
46
59
  height: var(--icon);
package/css/30-tabs.css CHANGED
@@ -13,7 +13,10 @@
13
13
 
14
14
  :where(.wt-root) .wt-tab-bar {
15
15
  /* Absolute against .wt-root, like all chrome (identical geometry in
16
- wt-viewport mode; see 01-scope.css). */
16
+ wt-viewport mode; see 01-scope.css). The bar itself never scrolls: the
17
+ tabs + "+" scroll inside .wt-tab-scroll (below), so the keyboard button —
18
+ the bar's last flex item, outside the scroller — stays pinned at the right
19
+ edge however many tabs are open. */
17
20
  position: absolute;
18
21
  bottom: var(--safe-bottom);
19
22
  left: 0;
@@ -26,17 +29,34 @@
26
29
  background: var(--surface);
27
30
  backdrop-filter: var(--blur);
28
31
  border-top: 1px solid var(--border);
29
- overflow-x: auto;
30
32
  z-index: 15;
31
33
  }
32
34
 
35
+ /* The scrolling tab list inside the bar: the tabs plus the "+", which floats
36
+ right of the last tab and scrolls with the list. Horizontal scrolling lives
37
+ HERE, not on the bar, so the strip-end keyboard button never scrolls away.
38
+ flex: 1 + min-width: 0 make it shrink to leave room for the keyboard button
39
+ when that is shown (a wide touchscreen) and stretch to the full bar width
40
+ when it is hidden (a fine-pointer desktop), so the tabs reclaim the space. */
41
+ :where(.wt-root) .wt-tab-scroll {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: var(--sp-1);
45
+ flex: 1 1 auto;
46
+ min-width: 0;
47
+ overflow-x: auto;
48
+ /* A wheel/trackpad/touch scroll that runs out of tabs must not chain into
49
+ an embedding page (wt-container mode). */
50
+ overscroll-behavior-x: contain;
51
+ }
52
+
33
53
  :where(.wt-root) .wt-tab {
34
54
  display: inline-flex;
35
55
  align-items: center;
36
56
  gap: var(--sp-1);
37
57
  /* Browser-style tabs: up to 300px per tab when there is room, shrinking toward
38
- a 100px floor as the strip fills; past the floor the bar scrolls horizontally
39
- (.wt-tab-bar has overflow-x: auto). flex-grow is 0, so the 300px basis is a
58
+ a 100px floor as the strip fills; past the floor the list scrolls horizontally
59
+ (.wt-tab-scroll has overflow-x: auto). flex-grow is 0, so the 300px basis is a
40
60
  hard cap — every tab is the same size and never stretches past it, whatever
41
61
  its title; the label truncates instead. box-sizing keeps the basis a true
42
62
  total. */
@@ -202,14 +222,16 @@
202
222
  landscape), where there is no mobile switcher to carry one. Built + wired by
203
223
  the SAME factory as the mobile switcher's keyboard button (see makeKbButton
204
224
  in tabs.ts). Uses the shared .wt-btn primitive for its 44px box + centered
205
- icon, borderless like the switcher's — the old fixed-overlay styling gave it
206
- a border and pinned it right of the "+", which read as a different control
207
- the moment a phone rotated to landscape. Now an ordinary flex item inside the
208
- strip, before the "+" (kb-then-new, mirroring the mobile switcher's order).
209
- Hidden on a fine-pointer desktop (a real keyboard is present) and until a
210
- keyboardToggle feature is wired ([hidden]); shown only on a coarse pointer at
211
- strip widths (the .wt-narrow complement below; the breakpoint itself is the
212
- kernel's, mirrored as the root class). */
225
+ icon, borderless like the switcher's. The bar's LAST flex item, OUTSIDE the
226
+ tab scroller: pinned at the bar's right edge in the same column as the
227
+ scroll-to-bottom button floating above it (both 44px boxes whose right edge
228
+ sits --sp-2 from the root's right edge), where an overflowing tab list can
229
+ never push or scroll it away; the "+" stays with the tabs inside the
230
+ scroller. Hidden on a fine-pointer desktop (a real keyboard is present; the
231
+ scroller then stretches to the full bar width) and until a keyboardToggle
232
+ feature is wired ([hidden]); shown only on a coarse pointer at strip widths
233
+ (the .wt-narrow complement below; the breakpoint itself is the kernel's,
234
+ mirrored as the root class). */
213
235
  :where(.wt-root) .wt-tab-kb {
214
236
  display: none;
215
237
  flex: none;
@@ -82,6 +82,10 @@
82
82
  clear of the soft keyboard. */
83
83
  max-height: var(--wt-list-h, 50dvh);
84
84
  overflow-y: auto;
85
+ /* Reaching the list's end must not hand the touch scroll to an embedding
86
+ page (wt-container mode) — the tray sits over the terminal, and a chained
87
+ scroll behind an open tray is disorienting. */
88
+ overscroll-behavior-y: contain;
85
89
  /* No top padding: the bar's --sp-2 bottom padding already sits between the
86
90
  active tab and the first row, so the seam equals the --sp-2 inter-row gap. */
87
91
  padding-top: 0;
@@ -318,12 +322,19 @@
318
322
  In the tabbed presets the mobile toolbar hides its own toggle (a tab-chrome
319
323
  keyboard button drives it — the switcher's on a narrow phone, the strip's
320
324
  .wt-tab-kb on a wide touchscreen) and the grid docks bottom-right above the
321
- tab chrome, to the left of where scroll-to-bottom sits. It collapses to
322
- fully hidden, since there is no in-grid toggle to keep visible. The BASE
323
- rules here are the wide-touchscreen (strip) placement — above --wt-tabbar-h;
324
- the .wt-narrow override below re-anchors above the switcher bar instead
325
- (--wt-switcher-h). Everything else (toggle hidden, right offset, grid
326
- columns, collapsed fully hidden) is identical in both modes. */
325
+ tab chrome, its RIGHT edge aligned with the keyboard button that opened it,
326
+ so the popup visually hangs off its toggle in every mode (it may overlap the
327
+ scroll-to-bottom button — deliberate: the grid is transient and on top). It
328
+ collapses to fully hidden, since there is no in-grid toggle to keep visible.
329
+ The BASE rules here are the wide-touchscreen (strip) placement — above
330
+ --wt-tabbar-h, right-aligned to the strip's .wt-tab-kb (the bar's last flex
331
+ item, inset --sp-2 by the bar padding); the .wt-narrow overrides below
332
+ re-anchor above the switcher bar (--wt-switcher-h) and shift left past the
333
+ bar controls sitting right of the switcher's kb button: the "+" (always)
334
+ and, once a second tab reveals it, the switch button (.wt-switcher-multi
335
+ from the tabs feature, read here via :has() since the grid is a sibling
336
+ region of the switcher). Everything else (toggle hidden, grid columns,
337
+ collapsed fully hidden) is identical in all modes. */
327
338
  @media (pointer: coarse) {
328
339
  :where(.wt-root) .wt-toolbar-external .kb-toggle {
329
340
  display: none;
@@ -331,7 +342,7 @@
331
342
 
332
343
  :where(.wt-root) .key-toolbar.wt-toolbar-external {
333
344
  top: auto;
334
- right: calc(var(--sp-3) + var(--touch-target) + var(--sp-2));
345
+ right: var(--sp-2);
335
346
  bottom: calc(
336
347
  var(--wt-tabbar-h, 44px) + var(--safe-bottom, 0px) + var(--kb-inset, 0px) + var(--sp-2)
337
348
  );
@@ -342,12 +353,21 @@
342
353
  display: none;
343
354
  }
344
355
 
345
- /* Narrow phone (mobile switcher mode): dock above the switcher bar. */
356
+ /* Narrow phone (mobile switcher mode): dock above the switcher bar,
357
+ right-aligned to the switcher's kb button — one 44px control (+ gap)
358
+ in from the edge past the "+"... */
346
359
  :where(.wt-root.wt-narrow) .key-toolbar.wt-toolbar-external {
360
+ right: calc(var(--sp-2) + var(--touch-target) + var(--sp-1));
347
361
  bottom: calc(
348
362
  var(--wt-switcher-h) + var(--safe-bottom, 0px) + var(--kb-inset, 0px) + var(--sp-2)
349
363
  );
350
364
  }
365
+
366
+ /* ...and a second control in once the switch button is revealed (>= 2 tabs).
367
+ Ordered after the single-tab rule so it wins when both match. */
368
+ :where(.wt-root.wt-narrow:has(.wt-switcher-multi)) .key-toolbar.wt-toolbar-external {
369
+ right: calc(var(--sp-2) + 2 * (var(--touch-target) + var(--sp-1)));
370
+ }
351
371
  }
352
372
 
353
373
  /* ===== Expanded tab-list rows (tabs feature) =====
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "4.0.0",
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",
@@ -38,7 +38,7 @@
38
38
  "verify": "bash scripts/verify.sh"
39
39
  },
40
40
  "peerDependencies": {
41
- "@cplieger/web-terminal-engine": "^2.8.0"
41
+ "@cplieger/web-terminal-engine": "^3.0.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@eslint/js": "10.0.1",
@@ -52,7 +52,7 @@
52
52
  "fast-check": "4.9.0",
53
53
  "happy-dom": "20.10.6",
54
54
  "html-validate": "11.5.6",
55
- "knip": "6.26.0",
55
+ "knip": "6.27.0",
56
56
  "prettier": "3.9.5",
57
57
  "stylelint": "17.14.0",
58
58
  "stylelint-config-standard": "40.0.0",
@@ -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, so no explicit resync is
7
- // needed here.
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 {
@@ -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 keyboard.bindMobileToolbar wires the buttons (DECCKM-aware
4
- // arrows, sticky-Ctrl state); this feature builds the toolbar chrome, routes its
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 { keyboard } from "@cplieger/web-terminal-engine";
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 } = keyboard;
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. */
@@ -48,13 +48,15 @@ import {
48
48
 
49
49
  const DEFAULT_API_BASE = "/api/sessions";
50
50
  // The mobile bottom-switcher (a single full-width active-tab chip + swipe) is
51
- // used ONLY on a narrow coarse-pointer device (a phone). A wide touchscreen (an
52
- // iPad) and every fine-pointer device (a desktop, or an iPad with a trackpad /
53
- // Magic Keyboard) get the multi-tab top strip instead — the switcher's
54
- // single-giant-tab layout wastes a big screen (an iPad was getting the phone
55
- // UI). The narrow half of that fact is the kernel's .wt-narrow root class /
56
- // ctx.layout().narrow (ONE breakpoint constant, kernel-owned, root-width
57
- // driven); CSS pairs it with (pointer: coarse) where touch matters.
51
+ // used ONLY on a narrow coarse-pointer device (a phone — in EITHER
52
+ // orientation: a landscape phone is wide but short, and the kernel's narrow
53
+ // fact covers both). A big touchscreen (an iPad) and every fine-pointer device
54
+ // (a desktop, or an iPad with a trackpad / Magic Keyboard) get the multi-tab
55
+ // top strip instead — the switcher's single-giant-tab layout wastes a big
56
+ // screen (an iPad was getting the phone UI). The narrow half of that fact is
57
+ // the kernel's .wt-narrow root class / ctx.layout().narrow (kernel-owned
58
+ // breakpoint constants, root-size driven); CSS pairs it with
59
+ // (pointer: coarse) where touch matters.
58
60
  // Default cadence for the no-activityMonitor polling fallback.
59
61
  const DEFAULT_POLL_MS = 4000;
60
62
  // Tab context-menu viewport clamping + the flip-above-the-pointer gap live in
@@ -222,24 +224,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
222
224
  }
223
225
 
224
226
  // --- Desktop tab strip (top-bar region) ---
227
+ // Two layers: the bar itself never scrolls; an inner scroller
228
+ // (.wt-tab-scroll, the tablist) holds the tabs plus the "+", which floats
229
+ // right of the tab list and scrolls WITH it when many tabs overflow. The
230
+ // keyboard button sits OUTSIDE the scroller as the bar's last flex item,
231
+ // pinned at the bar's right edge in the scroll-to-bottom button's column
232
+ // (an overflowing tab list can never push or scroll it away). On a fine
233
+ // pointer it is display:none (CSS), and the scroller stretches to the
234
+ // full bar width so the tabs reclaim that space. Both controls are built
235
+ // + wired by the same shared factories as the mobile switcher's; the kb
236
+ // button is CSS-gated to a wide touchscreen and un-hidden below only when
237
+ // a keyboardToggle is wired. addTabChrome inserts each tab before newBtn,
238
+ // keeping the scroller [tabs… +].
225
239
  const slot = ctx.region("top-bar", "tabs");
226
240
  const bar = document.createElement("div");
227
241
  bar.className = "wt-tab-bar";
228
- bar.setAttribute("role", "tablist");
229
242
  slot.appendChild(bar);
230
- // Strip-end controls, both flex items INSIDE the strip so they flow after
231
- // the tabs (appending them to the slot flowed them out of the strip):
232
- // the keyboard button first, then the "+" — mirroring the mobile
233
- // switcher's kb-before-new order, so rotating a phone to landscape (which
234
- // swaps in this strip) keeps the keyboard button LEFT of the "+" rather
235
- // than jumping to its right. Built + wired by the same shared factories
236
- // as the mobile switcher's; the kb button is CSS-gated to a wide
237
- // touchscreen and un-hidden below only when a keyboardToggle is wired.
238
- // addTabChrome inserts each tab before deskKb, keeping [tabs… kb +].
243
+ const scroller = document.createElement("div");
244
+ scroller.className = "wt-tab-scroll";
245
+ scroller.setAttribute("role", "tablist");
246
+ bar.appendChild(scroller);
247
+ const newBtn = makeNewButton("wt-tab-new");
248
+ scroller.appendChild(newBtn);
239
249
  const deskKb = makeKbButton("wt-tab-kb wt-btn");
240
250
  bar.appendChild(deskKb);
241
- const newBtn = makeNewButton("wt-tab-new");
242
- bar.appendChild(newBtn);
243
251
 
244
252
  // Pull the terminal surface up off the docked BOTTOM strip on desktop so
245
253
  // the bar does not overlap the last rows. The surface is absolute
@@ -400,6 +408,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
400
408
  ctx.region("overlay", "tab-menu").appendChild(tabMenu);
401
409
 
402
410
  const tabList: Tab[] = [];
411
+ // Monotonic local-mutation counter stamped onto each adopted tab (Tab.born)
412
+ // and snapshotted by reconcileOnce before its GET /api/sessions, so a
413
+ // stale listing can never drop a tab adopted while it was in flight (the
414
+ // boot race: the bootstrap's create vs the SSE stream-open reconcile).
415
+ let tabEpoch = 0;
403
416
  // Close tombstones (model.ts): ids the user closed recently, so a stale
404
417
  // server listing (the SSE re-open snapshot, or the poll's GET
405
418
  // /api/sessions) that predates the server reaping the session does not
@@ -855,9 +868,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
855
868
  }
856
869
  paintStatusDot(dot, info.status, info.reportsActivity ?? false);
857
870
  const aria = tablist.registerTab(el);
858
- // Insert before the strip-end controls (keyboard button, then "+") so
859
- // they stay the last items in the strip.
860
- bar.insertBefore(el, deskKb);
871
+ // Insert before the "+" so it stays the last item of the scrolling tab
872
+ // list (the keyboard button lives outside the scroller, at the bar end).
873
+ scroller.insertBefore(el, newBtn);
861
874
  // Runtime-added tabs animate in; initial tabs do not (see `started`).
862
875
  // The timer (not animationend) also clears the class on the hidden mobile
863
876
  // strip, where the animation never fires.
@@ -870,6 +883,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
870
883
 
871
884
  const tab: Tab = {
872
885
  id: info.id,
886
+ born: ++tabEpoch,
873
887
  title: info.title,
874
888
  clientTitle: info.clientTitle,
875
889
  display: "",
@@ -959,11 +973,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
959
973
  slide = to > from ? "next" : "prev";
960
974
  }
961
975
  }
962
- const surface = ctx.surface();
963
976
  // Detach the current tab: save its scroll memory (keep its cache).
977
+ // Read through the engine's scroll seam, never surface.scrollTop —
978
+ // the controller owns the container's scroll geometry.
964
979
  const cur = tabList.find((t) => t.id === activeId);
965
980
  if (cur) {
966
- cur.scrollTop = surface.scrollTop;
981
+ cur.scrollTop = ctx.scroll.currentScrollTop();
967
982
  cur.following = !ctx.scroll.isUserScrolledUp();
968
983
  }
969
984
  // Decide whether to animate the expanded list as a rotation: only a
@@ -1025,7 +1040,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1025
1040
  if (following) {
1026
1041
  ctx.scroll.scrollToBottom();
1027
1042
  } else {
1028
- surface.scrollTop = savedTop;
1043
+ // The write half of scroll memory goes through the same seam; the
1044
+ // controller re-derives hold from the resulting scroll event.
1045
+ ctx.scroll.restoreScrollTop(savedTop);
1029
1046
  }
1030
1047
  });
1031
1048
  ctx.announce(`Switched to ${next.display}`);
@@ -1125,11 +1142,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1125
1142
 
1126
1143
  // dragTargetBefore returns the first tab whose horizontal midpoint is past
1127
1144
  // x (the element the dragged tab should sit before), or null to drop at the
1128
- // end (before the strip-end controls). syncOrderFromDom rebuilds tabList to
1145
+ // end (before the "+"). syncOrderFromDom rebuilds tabList to
1129
1146
  // match the strip's DOM order after a drag, so position indicators, the
1130
1147
  // switcher, and close-to-the-right/left all follow the visible order.
1131
1148
  function dragTargetBefore(x: number): HTMLElement | null {
1132
- for (const el of bar.querySelectorAll<HTMLElement>(".wt-tab:not(.wt-tab-dragging)")) {
1149
+ for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab:not(.wt-tab-dragging)")) {
1133
1150
  const rect = el.getBoundingClientRect();
1134
1151
  if (x < rect.left + rect.width / 2) {
1135
1152
  return el;
@@ -1139,7 +1156,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1139
1156
  }
1140
1157
  function syncOrderFromDom(): void {
1141
1158
  const order: Tab[] = [];
1142
- for (const el of bar.querySelectorAll<HTMLElement>(".wt-tab")) {
1159
+ for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
1143
1160
  const t = tabList.find((x) => x.el === el);
1144
1161
  if (t) {
1145
1162
  order.push(t);
@@ -1494,7 +1511,66 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1494
1511
  // drop; the poll additionally learns of a background exit the SSE would
1495
1512
  // have pushed (section 22.5).
1496
1513
  let offStatus: (() => void) | undefined;
1514
+ let offStreamOpen: (() => void) | undefined;
1497
1515
  let pollTimer: ReturnType<typeof setInterval> | null = null;
1516
+ // One-shot list reconcile shared by both status sources: adopt every
1517
+ // session the server lists, then drop tabs it no longer lists. The poll
1518
+ // fallback runs it on a timer; the SSE path runs it on every stream
1519
+ // (re)open — the reopen against a RESTARTED manager is the moment
1520
+ // zombie tabs must drop, because the fresh server's snapshot carries no
1521
+ // tombstones for sessions it never knew, so those tabs would otherwise
1522
+ // spin "Reconnecting…" forever (judgement finding sf-2).
1523
+ // Guarded against overlapping runs: a server slower than the trigger
1524
+ // cadence (but within the 15s API timeout) would otherwise race tabList
1525
+ // mutation. Skip the extra run instead.
1526
+ let reconciling = false;
1527
+ const reconcileOnce = async (): Promise<void> => {
1528
+ if (reconciling) {
1529
+ return;
1530
+ }
1531
+ reconciling = true;
1532
+ try {
1533
+ // Snapshot the mutation epoch BEFORE the list round-trip: the listing
1534
+ // is authoritative only for tabs that already existed when it was
1535
+ // requested. A tab adopted while the GET was in flight (the boot
1536
+ // race: the bootstrap's create vs this stream-open reconcile) is
1537
+ // invisible to the returned snapshot, and dropping it here cascaded
1538
+ // into a duplicate replacement session (dropTab's last-tab intercept
1539
+ // spawns one) — the double-create boot bug.
1540
+ const epochAtList = tabEpoch;
1541
+ let list: SessionInfo[];
1542
+ try {
1543
+ list = await api.list();
1544
+ } catch {
1545
+ return; // transient; try again on the next trigger
1546
+ }
1547
+ const seen = new Set(list.map((s) => s.id));
1548
+ for (const info of list) {
1549
+ adoptSession(info); // add sessions created elsewhere (no local tab)
1550
+ applyStatus(
1551
+ info.id,
1552
+ info.status,
1553
+ info.title,
1554
+ info.clientTitle,
1555
+ info.reportsActivity ?? false,
1556
+ );
1557
+ }
1558
+ // A tab the server no longer lists was reaped/closed elsewhere (or
1559
+ // died with a restarted manager): drop it locally (no DELETE — it
1560
+ // is already gone). Tabs born after the list snapshot are spared this
1561
+ // round; the next reconcile sees the server truth for them.
1562
+ const gone = tabList
1563
+ .filter((t) => !seen.has(t.id) && t.born <= epochAtList)
1564
+ .map((t) => t.id);
1565
+ for (const id of gone) {
1566
+ await dropTab(id, false);
1567
+ }
1568
+ ensureActive();
1569
+ syncChrome();
1570
+ } finally {
1571
+ reconciling = false;
1572
+ }
1573
+ };
1498
1574
  if (monitor) {
1499
1575
  offStatus = monitor.onStatus((s) => {
1500
1576
  if (s.removed) {
@@ -1514,50 +1590,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1514
1590
  ensureActive();
1515
1591
  syncChrome();
1516
1592
  });
1593
+ offStreamOpen = monitor.onStreamOpen?.(() => {
1594
+ void reconcileOnce();
1595
+ });
1517
1596
  } else {
1518
1597
  const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
1519
- // Guard against overlapping runs: a server slower than pollMs (but
1520
- // within the 15s API timeout) would otherwise let setInterval start
1521
- // a second pollOnce while the first is still awaiting, racing tabList
1522
- // mutation. Skip the tick instead.
1523
- let polling = false;
1524
- const pollOnce = async (): Promise<void> => {
1525
- if (polling) {
1526
- return;
1527
- }
1528
- polling = true;
1529
- try {
1530
- let list: SessionInfo[];
1531
- try {
1532
- list = await api.list();
1533
- } catch {
1534
- return; // transient; try again next tick
1535
- }
1536
- const seen = new Set(list.map((s) => s.id));
1537
- for (const info of list) {
1538
- adoptSession(info); // add sessions created elsewhere (no local tab)
1539
- applyStatus(
1540
- info.id,
1541
- info.status,
1542
- info.title,
1543
- info.clientTitle,
1544
- info.reportsActivity ?? false,
1545
- );
1546
- }
1547
- // A tab the server no longer lists was reaped/closed elsewhere: drop it
1548
- // locally (no DELETE — it is already gone).
1549
- const gone = tabList.filter((t) => !seen.has(t.id)).map((t) => t.id);
1550
- for (const id of gone) {
1551
- await dropTab(id, false);
1552
- }
1553
- ensureActive();
1554
- syncChrome();
1555
- } finally {
1556
- polling = false;
1557
- }
1558
- };
1559
1598
  pollTimer = setInterval(() => {
1560
- void pollOnce();
1599
+ void reconcileOnce();
1561
1600
  }, pollMs);
1562
1601
  }
1563
1602
 
@@ -1584,7 +1623,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1584
1623
  if (e.dataTransfer) {
1585
1624
  e.dataTransfer.dropEffect = "move";
1586
1625
  }
1587
- bar.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? deskKb);
1626
+ scroller.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? newBtn);
1588
1627
  });
1589
1628
  // Active-row close (x): closes the current tab (mirrors a listed row's x).
1590
1629
  // stopPropagation so it is not read as a tap/swipe on the row surface.
@@ -2125,6 +2164,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2125
2164
  teardown() {
2126
2165
  resolveImpl = null;
2127
2166
  offStatus?.();
2167
+ offStreamOpen?.();
2128
2168
  offInput();
2129
2169
  offHwKey();
2130
2170
  offArmed?.();
@@ -29,6 +29,14 @@ export const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
29
29
 
30
30
  export interface Tab {
31
31
  id: string;
32
+ /** Local mutation epoch at which this tab was adopted (a monotonic counter,
33
+ * not a timestamp). The list reconcile snapshots the counter BEFORE its GET
34
+ * /api/sessions and drops a server-unlisted tab only when the tab predates
35
+ * that snapshot — a tab born while the list was in flight (the bootstrap's
36
+ * create racing the SSE stream-open reconcile) is invisible to that stale
37
+ * listing, and dropping it would cascade into a duplicate replacement
38
+ * session (the boot double-create bug). */
39
+ born: number;
32
40
  /** The raw server title (the OSC 0/2 window title the process set), possibly
33
41
  * empty before the process sets one. The displayed label is derived from it
34
42
  * with a numbered fallback and de-duplication (see relabelAll in index.ts). */
@@ -38,6 +38,9 @@ export interface ConnStateMachine {
38
38
  * the state shows immediately and persists until open() (a switch to a
39
39
  * live session) replaces it. */
40
40
  ended(): void;
41
+ /** The engine refused an explicitly incompatible wire revision. This
42
+ * terminal state bypasses the loading gate and persists until open(). */
43
+ incompatible(): void;
41
44
  /** First screen frame rendered: the loading overlay is done, so the banner
42
45
  * may show reconnect state from here on. */
43
46
  setLoaded(): void;
@@ -59,13 +62,14 @@ export function createConnState(opts: {
59
62
 
60
63
  function emit(): void {
61
64
  // Suppress transient states until the initial load is over (the loading
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.
65
+ // overlay owns the screen). Terminal states ("ended" and "incompatible")
66
+ // must pass through because no later frame or reconnect will explain the
67
+ // failure; "offline" also passes once the initial-failure limit is reached.
68
+ // connectionBanner renders "open" as hidden.
67
69
  const passesLoadingGate =
68
- state === "ended" || (state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT);
70
+ state === "ended" ||
71
+ state === "incompatible" ||
72
+ (state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT);
69
73
  if (!loaded && !passesLoadingGate) {
70
74
  opts.onState("open"); // nothing to show yet
71
75
  return;
@@ -130,6 +134,12 @@ export function createConnState(opts: {
130
134
  consecutiveFailures = 0;
131
135
  setState("ended", 0);
132
136
  },
137
+ incompatible(): void {
138
+ // Wire refusal is terminal for this page instance. Like ended, it is not
139
+ // part of the failure ladder and must remain visible without a timer.
140
+ consecutiveFailures = 0;
141
+ setState("incompatible", 0);
142
+ },
133
143
  setLoaded(): void {
134
144
  loaded = true;
135
145
  },
@@ -49,14 +49,24 @@ const TAP_MOVEMENT_PX = 10;
49
49
  // native text selection / the context menu — so tap-to-focus bows out above
50
50
  // this duration and never steals a long-press or a selection.
51
51
  const TAP_MAX_MS = 500;
52
- // The narrow-layout breakpoint, in ROOT-width pixels. The single source of the
53
- // number: the kernel mirrors it into the .wt-narrow root class for CSS (paired
54
- // there with pointer-coarseness media queries where touch matters) and features
55
- // read the same fact via ctx.layout(). Root width — not viewport width — so an
56
- // embedded terminal in a narrow panel counts as narrow. For every full-page
57
- // consumer root width equals viewport width, so the behavior matches the old
58
- // (max-width: 600px) media queries exactly.
52
+ // The narrow-layout breakpoints, in ROOT pixels. The single source of the
53
+ // numbers: the kernel mirrors the resulting fact into the .wt-narrow root
54
+ // class for CSS (paired there with pointer-coarseness media queries where
55
+ // touch matters) and features read the same fact via ctx.layout(). Root
56
+ // dimensions — not viewport dimensions — so an embedded terminal in a narrow
57
+ // panel counts as narrow. For every full-page consumer root size equals
58
+ // viewport size, so the width half matches the old (max-width: 600px) media
59
+ // queries exactly.
59
60
  const NARROW_MAX_PX = 600;
61
+ // A root can be compact by HEIGHT as well as width: a large phone rotated to
62
+ // landscape (e.g. an iPhone 14 Pro Max at 932x430) is wider than
63
+ // NARROW_MAX_PX, but its short viewport is still a phone in the hand — the
64
+ // thumb-reach switcher bar is the right UX and the desktop strip wastes
65
+ // scarce rows. Tablets are never this short (the smallest iPad is 744 CSS px
66
+ // tall in landscape), so height <= 500 cleanly separates "landscape phone"
67
+ // from "tablet/desktop" with margin on both sides (the tallest current
68
+ // phones are ~440 in landscape, less with browser chrome).
69
+ const SHORT_MAX_PX = 500;
60
70
 
61
71
  // Kernel-owned core subtree: the display-only output, the hidden textarea (the
62
72
  // single keyboard target), and the IME composition view. No chrome; features
@@ -133,8 +143,12 @@ export function createTerminal(
133
143
  const announcer = createAnnouncer(root);
134
144
  const tablistController = createTablist(outputEl);
135
145
 
136
- // --- Narrow-layout driver (.wt-narrow mirrors root width for CSS) ---
137
- const isNarrow = (): boolean => root.clientWidth <= NARROW_MAX_PX;
146
+ // --- Narrow-layout driver (.wt-narrow mirrors root size for CSS) ---
147
+ // Narrow = compact in EITHER dimension: skinny (a phone in portrait, a
148
+ // narrow embedded panel) or short (a phone in landscape). The one
149
+ // ResizeObserver below fires on both width and height changes.
150
+ const isNarrow = (): boolean =>
151
+ root.clientWidth <= NARROW_MAX_PX || root.clientHeight <= SHORT_MAX_PX;
138
152
  function updateNarrow(): void {
139
153
  root.classList.toggle("wt-narrow", isNarrow());
140
154
  }
@@ -365,6 +379,13 @@ export function createTerminal(
365
379
  markReady();
366
380
  connState.ended();
367
381
  },
382
+ onWireIncompatible() {
383
+ // Compatibility refusal is terminal for this page instance. Lower the
384
+ // pre-JS overlay even if no screen frame arrived, then leave the
385
+ // actionable banner visible while the engine suppresses reconnects.
386
+ markReady();
387
+ connState.incompatible();
388
+ },
368
389
  onServerRestart() {
369
390
  render.resetScrollback();
370
391
  render.resetScreen();
@@ -60,7 +60,10 @@ export type ConnState =
60
60
  // The active session's process has EXITED (the engine's definitive 4001
61
61
  // close): not a connectivity problem, so no reconnect is coming. Cleared
62
62
  // only by connecting somewhere live (a tab switch / a new session).
63
- | "ended";
63
+ | "ended"
64
+ // The engine refused an explicitly incompatible wire revision. This is a
65
+ // terminal state, not a transient disconnect; page reload/update is required.
66
+ | "incompatible";
64
67
 
65
68
  // --- Engine drive handles ---
66
69
  //
@@ -89,6 +92,13 @@ export interface RenderHandle {
89
92
  export interface ScrollHandle {
90
93
  scrollToBottom(): void;
91
94
  isUserScrolledUp(): boolean;
95
+ /** The viewport's current scroll offset — the read half of per-tab scroll
96
+ * memory. The engine's scroll controller owns the container's scrollTop;
97
+ * features go through this seam, never the DOM element (engine >= v3). */
98
+ currentScrollTop(): number;
99
+ /** Restore a saved offset; follow/hold re-derives from the resulting scroll
100
+ * event exactly as for a user scroll — the write half of scroll memory. */
101
+ restoreScrollTop(top: number): void;
92
102
  }
93
103
 
94
104
  // --- Typed event bus payloads (section 22.4) ---
@@ -202,11 +212,13 @@ export interface TerminalContext {
202
212
  tablist(): TablistController;
203
213
 
204
214
  /** The current layout facts a feature keys touch-vs-desktop behavior on.
205
- * `narrow` is ROOT width at or below the kernel's single breakpoint (the
206
- * same fact the .wt-narrow root class exposes to CSS — root width, not
207
- * viewport width, so an embedded terminal in a narrow panel counts as
208
- * narrow). `coarse` is the primary pointer's coarseness (a live media-query
209
- * read). Read lazily at interaction time. */
215
+ * `narrow` is a ROOT compact in EITHER dimension against the kernel's
216
+ * breakpoints: skinny (width, a portrait phone or a narrow embedded panel)
217
+ * or short (height, a landscape phone) — the same fact the .wt-narrow root
218
+ * class exposes to CSS; root size, not viewport size, so an embedded
219
+ * terminal in a narrow panel counts as narrow. `coarse` is the primary
220
+ * pointer's coarseness (a live media-query read). Read lazily at
221
+ * interaction time. */
210
222
  layout(): { narrow: boolean; coarse: boolean };
211
223
 
212
224
  /** Switch the live terminal to a session. tabs binds the renderer to the