@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 +8 -4
- package/css/00-tokens.css +10 -0
- package/css/01-scope.css +20 -0
- package/css/02-terminal.css +49 -0
- package/css/10-primitives.css +13 -0
- package/css/30-tabs.css +34 -12
- package/css/31-switcher.css +28 -8
- package/package.json +3 -3
- package/src/features/activity-monitor.ts +32 -2
- package/src/features/connection-banner.ts +1 -0
- package/src/features/mobile-toolbar.ts +5 -4
- package/src/features/tabs/index.ts +110 -70
- package/src/features/tabs/model.ts +8 -0
- package/src/kernel/conn-state.ts +16 -6
- package/src/kernel/kernel.ts +30 -9
- package/src/kernel/types.ts +18 -6
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
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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 --
|
package/css/02-terminal.css
CHANGED
|
@@ -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
|
}
|
package/css/10-primitives.css
CHANGED
|
@@ -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
|
|
39
|
-
(.wt-tab-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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;
|
package/css/31-switcher.css
CHANGED
|
@@ -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,
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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:
|
|
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.
|
|
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": "^
|
|
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.
|
|
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
|
|
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 {
|
|
@@ -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. */
|
|
@@ -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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
859
|
-
//
|
|
860
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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). */
|
package/src/kernel/conn-state.ts
CHANGED
|
@@ -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)
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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" ||
|
|
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
|
},
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -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
|
|
53
|
-
//
|
|
54
|
-
// there with pointer-coarseness media queries where
|
|
55
|
-
// read the same fact via ctx.layout(). Root
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// (max-width: 600px) media
|
|
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
|
|
137
|
-
|
|
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();
|
package/src/kernel/types.ts
CHANGED
|
@@ -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
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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
|