@cplieger/web-terminal-ui 3.5.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -27
- package/css/00-tokens.css +18 -2
- package/css/01-scope.css +76 -0
- package/css/{02-app.css → 02-terminal.css} +113 -239
- package/css/10-primitives.css +98 -0
- package/css/20-connection-banner.css +42 -0
- package/css/21-scroll-to-bottom.css +65 -0
- package/css/22-context-menu.css +45 -0
- package/css/23-toolbar.css +181 -0
- package/css/{05-tabs.css → 30-tabs.css} +127 -66
- package/css/{06-mobile.css → 31-switcher.css} +85 -80
- package/css/{07-animations.css → 40-animations.css} +15 -15
- package/css/MANIFEST +19 -11
- package/css/MANIFEST.single +12 -0
- package/css/MANIFEST.tabbed +16 -0
- package/css/MANIFEST.touch +12 -0
- package/css/{01-reset.css → page.css} +18 -19
- package/package.json +19 -6
- package/scaffold/index.html +5 -0
- package/src/features/activity-monitor.ts +32 -2
- package/src/features/connection-banner.ts +1 -0
- package/src/features/context-menu.ts +6 -33
- package/src/features/menu-position.ts +50 -0
- package/src/features/mobile-toolbar.ts +5 -4
- package/src/features/{tabs.ts → tabs/index.ts} +310 -721
- package/src/features/tabs/model.ts +342 -0
- package/src/features/tabs/strip.ts +101 -0
- package/src/features/tabs/switcher.ts +129 -0
- package/src/index.ts +1 -0
- package/src/kernel/a11y.ts +30 -5
- package/src/kernel/conn-state.ts +16 -6
- package/src/kernel/kernel.ts +170 -59
- package/src/kernel/types.ts +68 -15
- package/src/presets/agent-tabbed.ts +15 -0
- package/src/presets/single.ts +27 -0
- package/src/presets/tabbed.ts +42 -0
- package/src/presets/touch.ts +13 -0
- package/src/presets.ts +12 -76
- package/src/viewport.ts +28 -15
- package/css/03-primitives.css +0 -83
- package/css/04-feedback.css +0 -180
package/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,6 +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 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.
|
|
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;
|
|
52
70
|
|
|
53
71
|
// Kernel-owned core subtree: the display-only output, the hidden textarea (the
|
|
54
72
|
// single keyboard target), and the IME composition view. No chrome; features
|
|
@@ -75,6 +93,16 @@ export function createTerminal(
|
|
|
75
93
|
const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
|
|
76
94
|
const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
|
|
77
95
|
const featureList = opts.features ?? [];
|
|
96
|
+
// At most one feature owns session selection (fail fast, before any DOM
|
|
97
|
+
// work): the kernel drives the first connect through its registration.
|
|
98
|
+
const owners = featureList.filter((f) => f.sessionOwner !== undefined);
|
|
99
|
+
if (owners.length > 1) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`web-terminal-ui: multiple session-owning features: ${owners.map((f) => f.name).join(", ")}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const sessionOwner = owners[0]?.sessionOwner;
|
|
105
|
+
const sessionOwnerName = owners[0]?.name ?? "";
|
|
78
106
|
const encoder = new TextEncoder();
|
|
79
107
|
const kernelAbort = new AbortController();
|
|
80
108
|
const { signal } = kernelAbort;
|
|
@@ -91,6 +119,15 @@ export function createTerminal(
|
|
|
91
119
|
}
|
|
92
120
|
}
|
|
93
121
|
|
|
122
|
+
// Stamp the boundary classes: .wt-root scopes every library token and style
|
|
123
|
+
// rule to this subtree, and the layout-mode class decides how the root claims
|
|
124
|
+
// space — wt-viewport pins the root itself to the full viewport (the
|
|
125
|
+
// full-page product), wt-container fills the parent element (the embedded
|
|
126
|
+
// case). All chrome positions against the root, never the page. destroy()
|
|
127
|
+
// removes them.
|
|
128
|
+
const layoutMode = opts.layout ?? "viewport";
|
|
129
|
+
root.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
|
|
130
|
+
|
|
94
131
|
// --- Build the core subtree ---
|
|
95
132
|
const tpl = document.createElement("template");
|
|
96
133
|
tpl.innerHTML = CORE_TEMPLATE;
|
|
@@ -106,6 +143,22 @@ export function createTerminal(
|
|
|
106
143
|
const announcer = createAnnouncer(root);
|
|
107
144
|
const tablistController = createTablist(outputEl);
|
|
108
145
|
|
|
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;
|
|
152
|
+
function updateNarrow(): void {
|
|
153
|
+
root.classList.toggle("wt-narrow", isNarrow());
|
|
154
|
+
}
|
|
155
|
+
updateNarrow();
|
|
156
|
+
let narrowObserver: ResizeObserver | null = null;
|
|
157
|
+
if (typeof ResizeObserver === "function") {
|
|
158
|
+
narrowObserver = new ResizeObserver(updateNarrow);
|
|
159
|
+
narrowObserver.observe(root);
|
|
160
|
+
}
|
|
161
|
+
|
|
109
162
|
// Toast surface (kernel-owned shared primitive, section 22.3).
|
|
110
163
|
const toastEl = document.createElement("div");
|
|
111
164
|
toastEl.className = "wt-toast";
|
|
@@ -187,13 +240,13 @@ export function createTerminal(
|
|
|
187
240
|
let activeSession: SessionRef | null = null;
|
|
188
241
|
// Whether the first connection has been kicked off. The wake-reconnect
|
|
189
242
|
// handlers (visibilitychange/pageshow/online) must not open a socket before
|
|
190
|
-
// it has: a session
|
|
191
|
-
//
|
|
192
|
-
// wsPath before then hits a session-gated endpoint (the SessionManager
|
|
193
|
-
// /ws with no ?session=). pageshow fires on the initial load, so
|
|
194
|
-
// gate a slow session list lets pageshow's reconnectNow open a
|
|
195
|
-
// 404s (seen in Firefox, where the list loses the race). Flips
|
|
196
|
-
// startup connect (
|
|
243
|
+
// it has: under a session owner the kernel drives the first connect only
|
|
244
|
+
// once resolveInitialSession() returns a session id, and connecting to the
|
|
245
|
+
// bare wsPath before then hits a session-gated endpoint (the SessionManager
|
|
246
|
+
// 404s a /ws with no ?session=). pageshow fires on the initial load, so
|
|
247
|
+
// without this gate a slow session list lets pageshow's reconnectNow open a
|
|
248
|
+
// bare /ws that 404s (seen in Firefox, where the list loses the race). Flips
|
|
249
|
+
// true on the startup connect (no owner) or the first setSession (owned).
|
|
197
250
|
let connectionInitiated = false;
|
|
198
251
|
|
|
199
252
|
// --- Loading lifecycle ---
|
|
@@ -326,6 +379,13 @@ export function createTerminal(
|
|
|
326
379
|
markReady();
|
|
327
380
|
connState.ended();
|
|
328
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
|
+
},
|
|
329
389
|
onServerRestart() {
|
|
330
390
|
render.resetScrollback();
|
|
331
391
|
render.resetScreen();
|
|
@@ -544,6 +604,7 @@ export function createTerminal(
|
|
|
544
604
|
// --- Viewport ---
|
|
545
605
|
viewport.init({
|
|
546
606
|
termWrap,
|
|
607
|
+
root,
|
|
547
608
|
suppressKeyboardInset: hasFinePointer,
|
|
548
609
|
onSettled() {
|
|
549
610
|
render.updateFontMetrics();
|
|
@@ -615,6 +676,42 @@ export function createTerminal(
|
|
|
615
676
|
const apiMap = new Map<TerminalFeature<unknown>, unknown>();
|
|
616
677
|
const instances: { feature: TerminalFeature<unknown>; instance: FeatureInstance<unknown> }[] = [];
|
|
617
678
|
|
|
679
|
+
// The one switch path: ctx.notifySwitch (tab switches) and the kernel's own
|
|
680
|
+
// owner-resolved first connect both land here.
|
|
681
|
+
function performSwitch(session: SessionRef): void {
|
|
682
|
+
// A feature's un-cancelled async (tabs create()/pollOnce()) can resolve
|
|
683
|
+
// after destroy() and request a switch; ignore it so a torn-down terminal
|
|
684
|
+
// never re-points or reopens the socket (connection.setSession below).
|
|
685
|
+
if (isDestroyed()) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// Detach (design 5.1): make input safe before the socket is re-pointed.
|
|
689
|
+
// End any in-flight IME composition and clear the textarea so
|
|
690
|
+
// half-composed text is not delivered to either session, and let every
|
|
691
|
+
// feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
|
|
692
|
+
// cannot fire against the incoming session. This all runs before
|
|
693
|
+
// setSession, and the switch is synchronous, so input is inert between
|
|
694
|
+
// detach here and the onSwitch attach below.
|
|
695
|
+
composition.cancelComposition();
|
|
696
|
+
resetToPlaceholder(input);
|
|
697
|
+
for (const { instance } of instances) {
|
|
698
|
+
instance.onDetach?.();
|
|
699
|
+
}
|
|
700
|
+
activeSession = session;
|
|
701
|
+
// Reconnect the terminal WS to this session using its per-tab resume
|
|
702
|
+
// state; the renderer was already pointed at its store by the owner.
|
|
703
|
+
connection.setSession(session.id);
|
|
704
|
+
// The owned first connect has happened (session id is now on the WS URL);
|
|
705
|
+
// wake-reconnect handlers may fire from here on.
|
|
706
|
+
connectionInitiated = true;
|
|
707
|
+
for (const { instance } of instances) {
|
|
708
|
+
if (instance.onSwitch) {
|
|
709
|
+
instance.onSwitch(session);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
bus.emit("session:switch", session);
|
|
713
|
+
}
|
|
714
|
+
|
|
618
715
|
function makeContext(featureName: string): TerminalContext {
|
|
619
716
|
return {
|
|
620
717
|
region: (name, slot) => regions.region(name, slot),
|
|
@@ -678,38 +775,13 @@ export function createTerminal(
|
|
|
678
775
|
announcer.announce(message, politeness);
|
|
679
776
|
},
|
|
680
777
|
tablist: () => tablistController,
|
|
778
|
+
layout: () => ({
|
|
779
|
+
narrow: isNarrow(),
|
|
780
|
+
coarse:
|
|
781
|
+
typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches,
|
|
782
|
+
}),
|
|
681
783
|
notifySwitch(session) {
|
|
682
|
-
|
|
683
|
-
// resolve after destroy() and call this; ignore it so a torn-down terminal
|
|
684
|
-
// never re-points or reopens the socket (connection.setSession below).
|
|
685
|
-
if (isDestroyed()) {
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
// Detach (design 5.1): make input safe before the socket is re-pointed.
|
|
689
|
-
// End any in-flight IME composition and clear the textarea so
|
|
690
|
-
// half-composed text is not delivered to either session, and let every
|
|
691
|
-
// feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
|
|
692
|
-
// cannot fire against the incoming session. This all runs before
|
|
693
|
-
// setSession, and the switch is synchronous, so input is inert between
|
|
694
|
-
// detach here and the onSwitch attach below.
|
|
695
|
-
composition.cancelComposition();
|
|
696
|
-
resetToPlaceholder(input);
|
|
697
|
-
for (const { instance } of instances) {
|
|
698
|
-
instance.onDetach?.();
|
|
699
|
-
}
|
|
700
|
-
activeSession = session;
|
|
701
|
-
// Reconnect the terminal WS to this session using its per-tab resume
|
|
702
|
-
// state; the renderer was already pointed at its store by tabs.
|
|
703
|
-
connection.setSession(session.id);
|
|
704
|
-
// The managed first connect has happened (session id is now on the WS
|
|
705
|
-
// URL); wake-reconnect handlers may fire from here on.
|
|
706
|
-
connectionInitiated = true;
|
|
707
|
-
for (const { instance } of instances) {
|
|
708
|
-
if (instance.onSwitch) {
|
|
709
|
-
instance.onSwitch(session);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
bus.emit("session:switch", session);
|
|
784
|
+
performSwitch(session);
|
|
713
785
|
},
|
|
714
786
|
dropSession(id) {
|
|
715
787
|
connection.forgetSession(id);
|
|
@@ -744,10 +816,14 @@ export function createTerminal(
|
|
|
744
816
|
instances.length = 0;
|
|
745
817
|
}
|
|
746
818
|
|
|
747
|
-
|
|
819
|
+
// Resolves true when every feature set up; false when setup was aborted (a
|
|
820
|
+
// feature threw and everything rolled back, or destroy() ran mid-setup). The
|
|
821
|
+
// session-owner first connect below runs only on true — a resolver must never
|
|
822
|
+
// run against torn-down chrome.
|
|
823
|
+
async function setupFeatures(): Promise<boolean> {
|
|
748
824
|
for (const feature of featureList) {
|
|
749
825
|
if (destroyed) {
|
|
750
|
-
return;
|
|
826
|
+
return false;
|
|
751
827
|
}
|
|
752
828
|
try {
|
|
753
829
|
const instance = await feature.setup(makeContext(feature.name));
|
|
@@ -762,7 +838,7 @@ export function createTerminal(
|
|
|
762
838
|
} catch (err) {
|
|
763
839
|
reportError(feature.name, err);
|
|
764
840
|
}
|
|
765
|
-
return;
|
|
841
|
+
return false;
|
|
766
842
|
}
|
|
767
843
|
instances.push({ feature, instance });
|
|
768
844
|
// Populate the feature value's readonly api (consumer pattern:
|
|
@@ -778,27 +854,47 @@ export function createTerminal(
|
|
|
778
854
|
`web-terminal-ui: feature "${feature.name}" setup failed; terminal has no chrome`,
|
|
779
855
|
err,
|
|
780
856
|
);
|
|
781
|
-
return;
|
|
857
|
+
return false;
|
|
782
858
|
}
|
|
783
859
|
}
|
|
860
|
+
return true;
|
|
784
861
|
}
|
|
785
|
-
// A session-managing feature (tabs) drives the first connect itself once it
|
|
786
|
-
// has resolved a session id (ctx.notifySwitch -> connection.setSession, which
|
|
787
|
-
// adds ?session=<id>). Connecting here first would open a bare /ws that a
|
|
788
|
-
// SessionManager 404s (no ?session=), flashing a disconnect banner and
|
|
789
|
-
// churning the reconnect backoff until the feature sets a session. So skip the
|
|
790
|
-
// startup connect when managed; the single-terminal presets leave it to us.
|
|
791
|
-
const sessionManaged = featureList.some((f) => f.managesSessions === true);
|
|
792
|
-
|
|
793
862
|
// Features set up in the background; first paint is never gated on them.
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
863
|
+
// Under a session owner the kernel then drives the first connect itself:
|
|
864
|
+
// await the owner's resolveInitialSession() and switch to the session it
|
|
865
|
+
// returns through the same path a tab switch uses. Connecting to the bare
|
|
866
|
+
// wsPath first would open a /ws that a SessionManager 404s (no ?session=),
|
|
867
|
+
// flashing a disconnect banner and churning the reconnect backoff — hence no
|
|
868
|
+
// startup connect below when an owner is registered. A null (or thrown)
|
|
869
|
+
// resolution means the bootstrap failed: the owner keeps its retry chrome
|
|
870
|
+
// alive, and the kernel — which now SEES the failure instead of inferring it
|
|
871
|
+
// from a missing side effect — dismisses the loading overlay so that chrome
|
|
872
|
+
// is visible instead of an eternal spinner.
|
|
873
|
+
void setupFeatures().then(async (ok) => {
|
|
874
|
+
if (!ok) {
|
|
875
|
+
// Setup aborted (rolled back or destroyed). Under a session owner no
|
|
876
|
+
// connect will ever be attempted, so nothing else can lower the loading
|
|
877
|
+
// overlay — dismiss it so the page shows its real state.
|
|
878
|
+
if (!connectionInitiated && !destroyed) {
|
|
879
|
+
dismissLoadingOverlay();
|
|
880
|
+
}
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (!sessionOwner || isDestroyed()) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
let resolved: SessionRef | null = null;
|
|
887
|
+
try {
|
|
888
|
+
resolved = await sessionOwner.resolveInitialSession();
|
|
889
|
+
} catch (err) {
|
|
890
|
+
reportError(sessionOwnerName, err);
|
|
891
|
+
}
|
|
892
|
+
if (isDestroyed()) {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (resolved) {
|
|
896
|
+
performSwitch(resolved);
|
|
897
|
+
} else if (!connectionInitiated) {
|
|
802
898
|
dismissLoadingOverlay();
|
|
803
899
|
}
|
|
804
900
|
});
|
|
@@ -806,7 +902,7 @@ export function createTerminal(
|
|
|
806
902
|
// --- Connect + focus ---
|
|
807
903
|
render.updateFontMetrics();
|
|
808
904
|
composition.positionCompositionView();
|
|
809
|
-
if (!
|
|
905
|
+
if (!sessionOwner) {
|
|
810
906
|
connection.connect();
|
|
811
907
|
connectionInitiated = true;
|
|
812
908
|
}
|
|
@@ -814,6 +910,19 @@ export function createTerminal(
|
|
|
814
910
|
|
|
815
911
|
return {
|
|
816
912
|
focus: focusTerminal,
|
|
913
|
+
send(bytes) {
|
|
914
|
+
if (destroyed) {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
sendBytes(bytes);
|
|
918
|
+
},
|
|
919
|
+
reset() {
|
|
920
|
+
if (destroyed) {
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
render.resetScrollback();
|
|
924
|
+
render.resetScreen();
|
|
925
|
+
},
|
|
817
926
|
destroy() {
|
|
818
927
|
if (destroyed) {
|
|
819
928
|
return;
|
|
@@ -821,6 +930,7 @@ export function createTerminal(
|
|
|
821
930
|
destroyed = true;
|
|
822
931
|
teardownFeatures();
|
|
823
932
|
kernelAbort.abort();
|
|
933
|
+
narrowObserver?.disconnect();
|
|
824
934
|
viewport.teardown();
|
|
825
935
|
// Reset the composition singleton (kernel-driven, no feature teardown): a
|
|
826
936
|
// destroy() mid-IME-composition otherwise leaves module-level `composing`
|
|
@@ -839,6 +949,7 @@ export function createTerminal(
|
|
|
839
949
|
bus.clear();
|
|
840
950
|
announcer.destroy();
|
|
841
951
|
regions.destroy();
|
|
952
|
+
root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
|
|
842
953
|
root.replaceChildren();
|
|
843
954
|
},
|
|
844
955
|
};
|
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) ---
|
|
@@ -110,9 +120,6 @@ export interface TerminalEvents {
|
|
|
110
120
|
"wire:modes": ModesMessage;
|
|
111
121
|
/** A screen frame (first-paint / activity hooks). */
|
|
112
122
|
"wire:screen": ScreenMessage;
|
|
113
|
-
/** The layout breakpoint changed (desktop <-> mobile), so a feature re-homes
|
|
114
|
-
* its chrome across regions (section 22.13). */
|
|
115
|
-
"layout:change": { mobile: boolean };
|
|
116
123
|
/** The user scrolled away from / back to the bottom (drives the
|
|
117
124
|
* scroll-to-bottom affordance). */
|
|
118
125
|
"scroll:state": { scrolledUp: boolean };
|
|
@@ -204,6 +211,16 @@ export interface TerminalContext {
|
|
|
204
211
|
/** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
|
|
205
212
|
tablist(): TablistController;
|
|
206
213
|
|
|
214
|
+
/** The current layout facts a feature keys touch-vs-desktop behavior on.
|
|
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. */
|
|
222
|
+
layout(): { narrow: boolean; coarse: boolean };
|
|
223
|
+
|
|
207
224
|
/** Switch the live terminal to a session. tabs binds the renderer to the
|
|
208
225
|
* session's cached store (ctx.render.bind) first, then calls this; the kernel
|
|
209
226
|
* reconnects the terminal WS to that session (connection.setSession, using
|
|
@@ -236,15 +253,32 @@ export interface TerminalFeature<Api = void> {
|
|
|
236
253
|
* Readonly so a feature is covariant in Api (a TerminalFeature<X> is a
|
|
237
254
|
* TerminalFeature<unknown>); the kernel sets it through a narrow cast. */
|
|
238
255
|
readonly api?: Api;
|
|
239
|
-
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
|
|
247
|
-
|
|
256
|
+
/** Present on the ONE feature that owns session selection (at most one per
|
|
257
|
+
* terminal; createTerminal throws when two features register). Its presence
|
|
258
|
+
* makes the kernel SKIP the startup connect to the bare wsPath (which a
|
|
259
|
+
* session-gated server 404s, churning the reconnect backoff); the kernel
|
|
260
|
+
* instead drives the first connect through resolveInitialSession() once
|
|
261
|
+
* feature setup completes. Single-terminal presets leave it unset so the
|
|
262
|
+
* kernel connects to the bare wsPath at startup. */
|
|
263
|
+
readonly sessionOwner?: SessionOwnerRegistration;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The session-owner registration: how the one session-owning feature and the
|
|
267
|
+
* kernel split the first connect. The feature owns session selection (list /
|
|
268
|
+
* spawn / pick) and its own bootstrap state; the kernel owns the connect
|
|
269
|
+
* itself, so a failed bootstrap is SEEN by the kernel (which dismisses the
|
|
270
|
+
* loading overlay) instead of inferred from a missing side effect. */
|
|
271
|
+
export interface SessionOwnerRegistration {
|
|
272
|
+
/** Resolve the initial session: list (or spawn) one, build per-session
|
|
273
|
+
* state, and bind the renderer to the session's store (ctx.render.bind) —
|
|
274
|
+
* but do NOT call ctx.notifySwitch for it. The kernel performs the switch
|
|
275
|
+
* with the returned ref through the same path notifySwitch uses. Return
|
|
276
|
+
* null when no session could be resolved (the bootstrap failed): the
|
|
277
|
+
* feature keeps its retry chrome alive, and the kernel dismisses the
|
|
278
|
+
* loading overlay so that chrome is visible. A throw is treated as null
|
|
279
|
+
* (and reported through the feature-error channel). Called exactly once,
|
|
280
|
+
* after every feature's setup has resolved. */
|
|
281
|
+
resolveInitialSession(): Promise<SessionRef | null>;
|
|
248
282
|
}
|
|
249
283
|
|
|
250
284
|
/** What a feature's setup returns: its optional typed API, a teardown, and an
|
|
@@ -275,6 +309,14 @@ export interface CreateTerminalOptions {
|
|
|
275
309
|
* Heterogeneous feature APIs are held as unknown here; a consumer reads a
|
|
276
310
|
* specific feature's api off the feature value it holds. */
|
|
277
311
|
features?: readonly TerminalFeature<unknown>[];
|
|
312
|
+
/** How the terminal claims space (default "viewport").
|
|
313
|
+
* "viewport": the root becomes a fixed full-viewport box — the full-page
|
|
314
|
+
* product (web-terminal-server, web-terminal-kiro, the scaffold page).
|
|
315
|
+
* "container": the root fills its parent element, which becomes the
|
|
316
|
+
* boundary — the embedded case (vibekit's panel). Either way the kernel
|
|
317
|
+
* stamps the matching class (wt-viewport / wt-container) on the root and
|
|
318
|
+
* every piece of chrome positions against the root, never the page. */
|
|
319
|
+
layout?: "viewport" | "container";
|
|
278
320
|
/** WebSocket endpoint path (default "/ws"). */
|
|
279
321
|
wsPath?: string;
|
|
280
322
|
/** CSS font shorthand awaited before the first resize. */
|
|
@@ -286,8 +328,8 @@ export interface CreateTerminalOptions {
|
|
|
286
328
|
* Keys must be CSS custom-property names (start with "--"); values are any
|
|
287
329
|
* CSS value. The library ships the defaults (css/00-tokens.css) — the
|
|
288
330
|
* "template"; these are the consumer's "settings" and override them for this
|
|
289
|
-
* instance. Known tokens: --accent, --tab-
|
|
290
|
-
* --tab-active-fg. */
|
|
331
|
+
* instance. Known tokens: --accent, --tab-bg, --tab-hover-bg,
|
|
332
|
+
* --tab-active-bg, --tab-active-fg, --tab-active-border. */
|
|
291
333
|
theme?: Readonly<Record<string, string>>;
|
|
292
334
|
}
|
|
293
335
|
|
|
@@ -297,6 +339,17 @@ export interface CreateTerminalOptions {
|
|
|
297
339
|
export interface TerminalHandle {
|
|
298
340
|
/** Focus the terminal input (opens the soft keyboard on touch). */
|
|
299
341
|
focus(): void;
|
|
342
|
+
/** Send bytes to the active session through the kernel's sanitizing,
|
|
343
|
+
* session-routed input funnel — the same path features use: input
|
|
344
|
+
* transforms apply, and the view snaps to the bottom exactly like typed
|
|
345
|
+
* input. The supported host path for "type this command" affordances
|
|
346
|
+
* (a run-in-shell button). No-op after destroy(). */
|
|
347
|
+
send(bytes: Uint8Array): void;
|
|
348
|
+
/** Reset the LOCAL display: drop the client-side scrollback and screen (the
|
|
349
|
+
* same reset the engine performs on a server restart). Deliberately injects
|
|
350
|
+
* no keystroke — a host that wants a freshly drawn prompt sends one itself
|
|
351
|
+
* (e.g. Ctrl+L via send()). No-op after destroy(). */
|
|
352
|
+
reset(): void;
|
|
300
353
|
/** Tear down every feature in reverse order, dispose all subscriptions, and
|
|
301
354
|
* release the kernel's DOM and engine wiring. */
|
|
302
355
|
destroy(): void;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// presetAgentTabbed: the tabbed composition tuned for an agent shell.
|
|
2
|
+
|
|
3
|
+
import type { TerminalFeature } from "../kernel/types.js";
|
|
4
|
+
import { buildTabbed } from "./tabbed.js";
|
|
5
|
+
|
|
6
|
+
/** Tabbed UI for an agent shell (web-terminal-kiro). Same features as
|
|
7
|
+
* presetTabbed, but with `preferInputTitle`: the agent's program (kiro-cli)
|
|
8
|
+
* emits a non-empty but useless OSC 0/2 title, so each tab's label follows the
|
|
9
|
+
* latest submitted line (persisted server-side and recovered on reload) and
|
|
10
|
+
* the OSC title is ignored. The agent-vs-generic status distinction remains
|
|
11
|
+
* server-side (a status classifier mapping OSC 9 notifications to
|
|
12
|
+
* done/needs-input). */
|
|
13
|
+
export function presetAgentTabbed(): TerminalFeature<unknown>[] {
|
|
14
|
+
return buildTabbed(true);
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// presetSingle: the single-pane desktop composition. Its own entry module so a
|
|
2
|
+
// consumer importing "@cplieger/web-terminal-ui/presets/single" pulls in ONLY
|
|
3
|
+
// this feature graph — none of the toolbar/tabs modules (the ./presets barrel
|
|
4
|
+
// statically reaches everything; these per-preset entries are the
|
|
5
|
+
// graph-minimal imports).
|
|
6
|
+
|
|
7
|
+
import type { TerminalFeature } from "../kernel/types.js";
|
|
8
|
+
import { clipboard } from "../features/clipboard.js";
|
|
9
|
+
import { contextMenu } from "../features/context-menu.js";
|
|
10
|
+
import { scrollToBottom } from "../features/scroll-to-bottom.js";
|
|
11
|
+
import { predictiveEcho } from "../features/predictive-echo.js";
|
|
12
|
+
import { connectionBanner } from "../features/connection-banner.js";
|
|
13
|
+
|
|
14
|
+
/** Single-pane desktop UI: context menu, clipboard, scroll-to-bottom, predictive
|
|
15
|
+
* echo, and the connection banner. No mobile toolbar, no tabs. */
|
|
16
|
+
export function presetSingle(): TerminalFeature<unknown>[] {
|
|
17
|
+
// contextMenu offers Copy/Paste through the clipboard feature (ctx.use), so it
|
|
18
|
+
// holds the same clipboard value the array includes.
|
|
19
|
+
const clip = clipboard();
|
|
20
|
+
return [
|
|
21
|
+
contextMenu({ clipboard: clip }),
|
|
22
|
+
clip,
|
|
23
|
+
scrollToBottom(),
|
|
24
|
+
predictiveEcho(),
|
|
25
|
+
connectionBanner(),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// presetTabbed (and the shared tabbed composition builder): the full
|
|
2
|
+
// multi-session UI. Requires a server that speaks the session API
|
|
3
|
+
// (`/api/sessions`, `/ws?session=`, and the status SSE `/api/sessions/events`),
|
|
4
|
+
// such as `web-terminal-server` or `web-terminal-kiro`.
|
|
5
|
+
|
|
6
|
+
import type { TerminalFeature } from "../kernel/types.js";
|
|
7
|
+
import { mobileToolbar } from "../features/mobile-toolbar.js";
|
|
8
|
+
import { tabs } from "../features/tabs/index.js";
|
|
9
|
+
import { activityMonitor } from "../features/activity-monitor.js";
|
|
10
|
+
import { animations } from "../features/animations.js";
|
|
11
|
+
import { presetSingle } from "./single.js";
|
|
12
|
+
|
|
13
|
+
/** buildTabbed composes the tabbed UI: the single-pane touch features, an
|
|
14
|
+
* externally-driven mobile toolbar (its grid is opened from a keyboard button
|
|
15
|
+
* in the tab bar, not its own toggle), the activity monitor (the status-SSE
|
|
16
|
+
* data source), tabs wired to both, and animations. The activity dot reveals
|
|
17
|
+
* itself per tab only when a session reports activity (OSC 9;4), so the
|
|
18
|
+
* monitor is always included — a plain shell just never shows a dot. The
|
|
19
|
+
* toolbar and monitor are ordered before tabs because tabs reads their APIs
|
|
20
|
+
* via ctx.use. Shared by presetTabbed and presetAgentTabbed (agent-tabbed.ts),
|
|
21
|
+
* which differ only in the title source. */
|
|
22
|
+
export function buildTabbed(preferInputTitle: boolean): TerminalFeature<unknown>[] {
|
|
23
|
+
const kb = mobileToolbar({ externalToggle: true });
|
|
24
|
+
const monitor = activityMonitor();
|
|
25
|
+
return [
|
|
26
|
+
...presetSingle(),
|
|
27
|
+
kb,
|
|
28
|
+
monitor,
|
|
29
|
+
tabs({ keyboardToggle: kb, activityMonitor: monitor, preferInputTitle }),
|
|
30
|
+
animations(),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Tabbed UI: the touch features, tabs, the mobile keyboard bar, the activity
|
|
35
|
+
* monitor, and animations. Each tab's title follows the process OSC 0/2 window
|
|
36
|
+
* title when the program sets one, else the last submitted line; each tab's
|
|
37
|
+
* activity dot stays hidden until its session reports activity via OSC 9;4
|
|
38
|
+
* progress (kiro-cli, Claude Code, …), so a plain bash/sh keeps clean,
|
|
39
|
+
* label-only tabs. */
|
|
40
|
+
export function presetTabbed(): TerminalFeature<unknown>[] {
|
|
41
|
+
return buildTabbed(false);
|
|
42
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// presetTouch: the touch-first composition (single + the on-screen key
|
|
2
|
+
// toolbar). Its own entry module so a touch consumer's delivered import graph
|
|
3
|
+
// excludes the tabs/activity/animations modules entirely (vibekit's embedded
|
|
4
|
+
// panel imports exactly this).
|
|
5
|
+
|
|
6
|
+
import type { TerminalFeature } from "../kernel/types.js";
|
|
7
|
+
import { mobileToolbar } from "../features/mobile-toolbar.js";
|
|
8
|
+
import { presetSingle } from "./single.js";
|
|
9
|
+
|
|
10
|
+
/** Touch-first UI: presetSingle plus the on-screen key toolbar. */
|
|
11
|
+
export function presetTouch(): TerminalFeature<unknown>[] {
|
|
12
|
+
return [...presetSingle(), mobileToolbar()];
|
|
13
|
+
}
|