@cplieger/web-terminal-ui 3.4.5 → 4.0.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 +53 -23
- package/css/00-tokens.css +8 -2
- package/css/01-scope.css +56 -0
- package/css/{02-app.css → 02-terminal.css} +64 -239
- package/css/{03-primitives.css → 10-primitives.css} +21 -19
- 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} +96 -57
- package/css/{06-mobile.css → 31-switcher.css} +66 -81
- 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 +21 -5
- package/scaffold/index.html +5 -0
- package/src/features/connection-banner.ts +3 -0
- package/src/features/context-menu.ts +6 -33
- package/src/features/menu-position.ts +50 -0
- package/src/features/{tabs.ts → tabs/index.ts} +207 -636
- package/src/features/tabs/model.ts +334 -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 +20 -3
- package/src/kernel/kernel.ts +162 -59
- package/src/kernel/types.ts +65 -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/04-feedback.css +0 -180
package/src/kernel/kernel.ts
CHANGED
|
@@ -49,6 +49,14 @@ 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.
|
|
59
|
+
const NARROW_MAX_PX = 600;
|
|
52
60
|
|
|
53
61
|
// Kernel-owned core subtree: the display-only output, the hidden textarea (the
|
|
54
62
|
// single keyboard target), and the IME composition view. No chrome; features
|
|
@@ -75,6 +83,16 @@ export function createTerminal(
|
|
|
75
83
|
const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
|
|
76
84
|
const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
|
|
77
85
|
const featureList = opts.features ?? [];
|
|
86
|
+
// At most one feature owns session selection (fail fast, before any DOM
|
|
87
|
+
// work): the kernel drives the first connect through its registration.
|
|
88
|
+
const owners = featureList.filter((f) => f.sessionOwner !== undefined);
|
|
89
|
+
if (owners.length > 1) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`web-terminal-ui: multiple session-owning features: ${owners.map((f) => f.name).join(", ")}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const sessionOwner = owners[0]?.sessionOwner;
|
|
95
|
+
const sessionOwnerName = owners[0]?.name ?? "";
|
|
78
96
|
const encoder = new TextEncoder();
|
|
79
97
|
const kernelAbort = new AbortController();
|
|
80
98
|
const { signal } = kernelAbort;
|
|
@@ -91,6 +109,15 @@ export function createTerminal(
|
|
|
91
109
|
}
|
|
92
110
|
}
|
|
93
111
|
|
|
112
|
+
// Stamp the boundary classes: .wt-root scopes every library token and style
|
|
113
|
+
// rule to this subtree, and the layout-mode class decides how the root claims
|
|
114
|
+
// space — wt-viewport pins the root itself to the full viewport (the
|
|
115
|
+
// full-page product), wt-container fills the parent element (the embedded
|
|
116
|
+
// case). All chrome positions against the root, never the page. destroy()
|
|
117
|
+
// removes them.
|
|
118
|
+
const layoutMode = opts.layout ?? "viewport";
|
|
119
|
+
root.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
|
|
120
|
+
|
|
94
121
|
// --- Build the core subtree ---
|
|
95
122
|
const tpl = document.createElement("template");
|
|
96
123
|
tpl.innerHTML = CORE_TEMPLATE;
|
|
@@ -106,6 +133,18 @@ export function createTerminal(
|
|
|
106
133
|
const announcer = createAnnouncer(root);
|
|
107
134
|
const tablistController = createTablist(outputEl);
|
|
108
135
|
|
|
136
|
+
// --- Narrow-layout driver (.wt-narrow mirrors root width for CSS) ---
|
|
137
|
+
const isNarrow = (): boolean => root.clientWidth <= NARROW_MAX_PX;
|
|
138
|
+
function updateNarrow(): void {
|
|
139
|
+
root.classList.toggle("wt-narrow", isNarrow());
|
|
140
|
+
}
|
|
141
|
+
updateNarrow();
|
|
142
|
+
let narrowObserver: ResizeObserver | null = null;
|
|
143
|
+
if (typeof ResizeObserver === "function") {
|
|
144
|
+
narrowObserver = new ResizeObserver(updateNarrow);
|
|
145
|
+
narrowObserver.observe(root);
|
|
146
|
+
}
|
|
147
|
+
|
|
109
148
|
// Toast surface (kernel-owned shared primitive, section 22.3).
|
|
110
149
|
const toastEl = document.createElement("div");
|
|
111
150
|
toastEl.className = "wt-toast";
|
|
@@ -187,13 +226,13 @@ export function createTerminal(
|
|
|
187
226
|
let activeSession: SessionRef | null = null;
|
|
188
227
|
// Whether the first connection has been kicked off. The wake-reconnect
|
|
189
228
|
// 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 (
|
|
229
|
+
// it has: under a session owner the kernel drives the first connect only
|
|
230
|
+
// once resolveInitialSession() returns a session id, and connecting to the
|
|
231
|
+
// bare wsPath before then hits a session-gated endpoint (the SessionManager
|
|
232
|
+
// 404s a /ws with no ?session=). pageshow fires on the initial load, so
|
|
233
|
+
// without this gate a slow session list lets pageshow's reconnectNow open a
|
|
234
|
+
// bare /ws that 404s (seen in Firefox, where the list loses the race). Flips
|
|
235
|
+
// true on the startup connect (no owner) or the first setSession (owned).
|
|
197
236
|
let connectionInitiated = false;
|
|
198
237
|
|
|
199
238
|
// --- Loading lifecycle ---
|
|
@@ -313,6 +352,19 @@ export function createTerminal(
|
|
|
313
352
|
onOutboxFull() {
|
|
314
353
|
connState.closed();
|
|
315
354
|
},
|
|
355
|
+
onProcessExit() {
|
|
356
|
+
// The engine's definitive 4001 close: the session's process has exited
|
|
357
|
+
// and the engine will not reconnect it. Two jobs here. markReady()
|
|
358
|
+
// guarantees the page is usable even when the exit lands before any
|
|
359
|
+
// screen frame (attaching to an already-dead session on a server that
|
|
360
|
+
// races the replay) — without it the loading overlay would sit on top of
|
|
361
|
+
// the tabs chrome forever, which is exactly the reported stuck-loading
|
|
362
|
+
// wedge. Then surface the end state: "Session ended", not a flapping
|
|
363
|
+
// "Reconnecting…", since no reconnect is coming. The final screen (when
|
|
364
|
+
// the server delivered it) stays rendered behind the banner.
|
|
365
|
+
markReady();
|
|
366
|
+
connState.ended();
|
|
367
|
+
},
|
|
316
368
|
onServerRestart() {
|
|
317
369
|
render.resetScrollback();
|
|
318
370
|
render.resetScreen();
|
|
@@ -531,6 +583,7 @@ export function createTerminal(
|
|
|
531
583
|
// --- Viewport ---
|
|
532
584
|
viewport.init({
|
|
533
585
|
termWrap,
|
|
586
|
+
root,
|
|
534
587
|
suppressKeyboardInset: hasFinePointer,
|
|
535
588
|
onSettled() {
|
|
536
589
|
render.updateFontMetrics();
|
|
@@ -602,6 +655,42 @@ export function createTerminal(
|
|
|
602
655
|
const apiMap = new Map<TerminalFeature<unknown>, unknown>();
|
|
603
656
|
const instances: { feature: TerminalFeature<unknown>; instance: FeatureInstance<unknown> }[] = [];
|
|
604
657
|
|
|
658
|
+
// The one switch path: ctx.notifySwitch (tab switches) and the kernel's own
|
|
659
|
+
// owner-resolved first connect both land here.
|
|
660
|
+
function performSwitch(session: SessionRef): void {
|
|
661
|
+
// A feature's un-cancelled async (tabs create()/pollOnce()) can resolve
|
|
662
|
+
// after destroy() and request a switch; ignore it so a torn-down terminal
|
|
663
|
+
// never re-points or reopens the socket (connection.setSession below).
|
|
664
|
+
if (isDestroyed()) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
// Detach (design 5.1): make input safe before the socket is re-pointed.
|
|
668
|
+
// End any in-flight IME composition and clear the textarea so
|
|
669
|
+
// half-composed text is not delivered to either session, and let every
|
|
670
|
+
// feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
|
|
671
|
+
// cannot fire against the incoming session. This all runs before
|
|
672
|
+
// setSession, and the switch is synchronous, so input is inert between
|
|
673
|
+
// detach here and the onSwitch attach below.
|
|
674
|
+
composition.cancelComposition();
|
|
675
|
+
resetToPlaceholder(input);
|
|
676
|
+
for (const { instance } of instances) {
|
|
677
|
+
instance.onDetach?.();
|
|
678
|
+
}
|
|
679
|
+
activeSession = session;
|
|
680
|
+
// Reconnect the terminal WS to this session using its per-tab resume
|
|
681
|
+
// state; the renderer was already pointed at its store by the owner.
|
|
682
|
+
connection.setSession(session.id);
|
|
683
|
+
// The owned first connect has happened (session id is now on the WS URL);
|
|
684
|
+
// wake-reconnect handlers may fire from here on.
|
|
685
|
+
connectionInitiated = true;
|
|
686
|
+
for (const { instance } of instances) {
|
|
687
|
+
if (instance.onSwitch) {
|
|
688
|
+
instance.onSwitch(session);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
bus.emit("session:switch", session);
|
|
692
|
+
}
|
|
693
|
+
|
|
605
694
|
function makeContext(featureName: string): TerminalContext {
|
|
606
695
|
return {
|
|
607
696
|
region: (name, slot) => regions.region(name, slot),
|
|
@@ -665,38 +754,13 @@ export function createTerminal(
|
|
|
665
754
|
announcer.announce(message, politeness);
|
|
666
755
|
},
|
|
667
756
|
tablist: () => tablistController,
|
|
757
|
+
layout: () => ({
|
|
758
|
+
narrow: isNarrow(),
|
|
759
|
+
coarse:
|
|
760
|
+
typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches,
|
|
761
|
+
}),
|
|
668
762
|
notifySwitch(session) {
|
|
669
|
-
|
|
670
|
-
// resolve after destroy() and call this; ignore it so a torn-down terminal
|
|
671
|
-
// never re-points or reopens the socket (connection.setSession below).
|
|
672
|
-
if (isDestroyed()) {
|
|
673
|
-
return;
|
|
674
|
-
}
|
|
675
|
-
// Detach (design 5.1): make input safe before the socket is re-pointed.
|
|
676
|
-
// End any in-flight IME composition and clear the textarea so
|
|
677
|
-
// half-composed text is not delivered to either session, and let every
|
|
678
|
-
// feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
|
|
679
|
-
// cannot fire against the incoming session. This all runs before
|
|
680
|
-
// setSession, and the switch is synchronous, so input is inert between
|
|
681
|
-
// detach here and the onSwitch attach below.
|
|
682
|
-
composition.cancelComposition();
|
|
683
|
-
resetToPlaceholder(input);
|
|
684
|
-
for (const { instance } of instances) {
|
|
685
|
-
instance.onDetach?.();
|
|
686
|
-
}
|
|
687
|
-
activeSession = session;
|
|
688
|
-
// Reconnect the terminal WS to this session using its per-tab resume
|
|
689
|
-
// state; the renderer was already pointed at its store by tabs.
|
|
690
|
-
connection.setSession(session.id);
|
|
691
|
-
// The managed first connect has happened (session id is now on the WS
|
|
692
|
-
// URL); wake-reconnect handlers may fire from here on.
|
|
693
|
-
connectionInitiated = true;
|
|
694
|
-
for (const { instance } of instances) {
|
|
695
|
-
if (instance.onSwitch) {
|
|
696
|
-
instance.onSwitch(session);
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
bus.emit("session:switch", session);
|
|
763
|
+
performSwitch(session);
|
|
700
764
|
},
|
|
701
765
|
dropSession(id) {
|
|
702
766
|
connection.forgetSession(id);
|
|
@@ -731,10 +795,14 @@ export function createTerminal(
|
|
|
731
795
|
instances.length = 0;
|
|
732
796
|
}
|
|
733
797
|
|
|
734
|
-
|
|
798
|
+
// Resolves true when every feature set up; false when setup was aborted (a
|
|
799
|
+
// feature threw and everything rolled back, or destroy() ran mid-setup). The
|
|
800
|
+
// session-owner first connect below runs only on true — a resolver must never
|
|
801
|
+
// run against torn-down chrome.
|
|
802
|
+
async function setupFeatures(): Promise<boolean> {
|
|
735
803
|
for (const feature of featureList) {
|
|
736
804
|
if (destroyed) {
|
|
737
|
-
return;
|
|
805
|
+
return false;
|
|
738
806
|
}
|
|
739
807
|
try {
|
|
740
808
|
const instance = await feature.setup(makeContext(feature.name));
|
|
@@ -749,7 +817,7 @@ export function createTerminal(
|
|
|
749
817
|
} catch (err) {
|
|
750
818
|
reportError(feature.name, err);
|
|
751
819
|
}
|
|
752
|
-
return;
|
|
820
|
+
return false;
|
|
753
821
|
}
|
|
754
822
|
instances.push({ feature, instance });
|
|
755
823
|
// Populate the feature value's readonly api (consumer pattern:
|
|
@@ -765,27 +833,47 @@ export function createTerminal(
|
|
|
765
833
|
`web-terminal-ui: feature "${feature.name}" setup failed; terminal has no chrome`,
|
|
766
834
|
err,
|
|
767
835
|
);
|
|
768
|
-
return;
|
|
836
|
+
return false;
|
|
769
837
|
}
|
|
770
838
|
}
|
|
839
|
+
return true;
|
|
771
840
|
}
|
|
772
|
-
// A session-managing feature (tabs) drives the first connect itself once it
|
|
773
|
-
// has resolved a session id (ctx.notifySwitch -> connection.setSession, which
|
|
774
|
-
// adds ?session=<id>). Connecting here first would open a bare /ws that a
|
|
775
|
-
// SessionManager 404s (no ?session=), flashing a disconnect banner and
|
|
776
|
-
// churning the reconnect backoff until the feature sets a session. So skip the
|
|
777
|
-
// startup connect when managed; the single-terminal presets leave it to us.
|
|
778
|
-
const sessionManaged = featureList.some((f) => f.managesSessions === true);
|
|
779
|
-
|
|
780
841
|
// Features set up in the background; first paint is never gated on them.
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
842
|
+
// Under a session owner the kernel then drives the first connect itself:
|
|
843
|
+
// await the owner's resolveInitialSession() and switch to the session it
|
|
844
|
+
// returns through the same path a tab switch uses. Connecting to the bare
|
|
845
|
+
// wsPath first would open a /ws that a SessionManager 404s (no ?session=),
|
|
846
|
+
// flashing a disconnect banner and churning the reconnect backoff — hence no
|
|
847
|
+
// startup connect below when an owner is registered. A null (or thrown)
|
|
848
|
+
// resolution means the bootstrap failed: the owner keeps its retry chrome
|
|
849
|
+
// alive, and the kernel — which now SEES the failure instead of inferring it
|
|
850
|
+
// from a missing side effect — dismisses the loading overlay so that chrome
|
|
851
|
+
// is visible instead of an eternal spinner.
|
|
852
|
+
void setupFeatures().then(async (ok) => {
|
|
853
|
+
if (!ok) {
|
|
854
|
+
// Setup aborted (rolled back or destroyed). Under a session owner no
|
|
855
|
+
// connect will ever be attempted, so nothing else can lower the loading
|
|
856
|
+
// overlay — dismiss it so the page shows its real state.
|
|
857
|
+
if (!connectionInitiated && !destroyed) {
|
|
858
|
+
dismissLoadingOverlay();
|
|
859
|
+
}
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (!sessionOwner || isDestroyed()) {
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
let resolved: SessionRef | null = null;
|
|
866
|
+
try {
|
|
867
|
+
resolved = await sessionOwner.resolveInitialSession();
|
|
868
|
+
} catch (err) {
|
|
869
|
+
reportError(sessionOwnerName, err);
|
|
870
|
+
}
|
|
871
|
+
if (isDestroyed()) {
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (resolved) {
|
|
875
|
+
performSwitch(resolved);
|
|
876
|
+
} else if (!connectionInitiated) {
|
|
789
877
|
dismissLoadingOverlay();
|
|
790
878
|
}
|
|
791
879
|
});
|
|
@@ -793,7 +881,7 @@ export function createTerminal(
|
|
|
793
881
|
// --- Connect + focus ---
|
|
794
882
|
render.updateFontMetrics();
|
|
795
883
|
composition.positionCompositionView();
|
|
796
|
-
if (!
|
|
884
|
+
if (!sessionOwner) {
|
|
797
885
|
connection.connect();
|
|
798
886
|
connectionInitiated = true;
|
|
799
887
|
}
|
|
@@ -801,6 +889,19 @@ export function createTerminal(
|
|
|
801
889
|
|
|
802
890
|
return {
|
|
803
891
|
focus: focusTerminal,
|
|
892
|
+
send(bytes) {
|
|
893
|
+
if (destroyed) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
sendBytes(bytes);
|
|
897
|
+
},
|
|
898
|
+
reset() {
|
|
899
|
+
if (destroyed) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
render.resetScrollback();
|
|
903
|
+
render.resetScreen();
|
|
904
|
+
},
|
|
804
905
|
destroy() {
|
|
805
906
|
if (destroyed) {
|
|
806
907
|
return;
|
|
@@ -808,6 +909,7 @@ export function createTerminal(
|
|
|
808
909
|
destroyed = true;
|
|
809
910
|
teardownFeatures();
|
|
810
911
|
kernelAbort.abort();
|
|
912
|
+
narrowObserver?.disconnect();
|
|
811
913
|
viewport.teardown();
|
|
812
914
|
// Reset the composition singleton (kernel-driven, no feature teardown): a
|
|
813
915
|
// destroy() mid-IME-composition otherwise leaves module-level `composing`
|
|
@@ -826,6 +928,7 @@ export function createTerminal(
|
|
|
826
928
|
bus.clear();
|
|
827
929
|
announcer.destroy();
|
|
828
930
|
regions.destroy();
|
|
931
|
+
root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
|
|
829
932
|
root.replaceChildren();
|
|
830
933
|
},
|
|
831
934
|
};
|
package/src/kernel/types.ts
CHANGED
|
@@ -51,7 +51,16 @@ export interface SessionView {
|
|
|
51
51
|
// --- Connection state (mirrors the kernel's connection-state machine) ---
|
|
52
52
|
|
|
53
53
|
/** Connection state the kernel owns and broadcasts on `connection:state`. */
|
|
54
|
-
export type ConnState =
|
|
54
|
+
export type ConnState =
|
|
55
|
+
| "open"
|
|
56
|
+
| "connecting"
|
|
57
|
+
| "reconnecting"
|
|
58
|
+
| "offline"
|
|
59
|
+
| "restarted"
|
|
60
|
+
// The active session's process has EXITED (the engine's definitive 4001
|
|
61
|
+
// close): not a connectivity problem, so no reconnect is coming. Cleared
|
|
62
|
+
// only by connecting somewhere live (a tab switch / a new session).
|
|
63
|
+
| "ended";
|
|
55
64
|
|
|
56
65
|
// --- Engine drive handles ---
|
|
57
66
|
//
|
|
@@ -101,9 +110,6 @@ export interface TerminalEvents {
|
|
|
101
110
|
"wire:modes": ModesMessage;
|
|
102
111
|
/** A screen frame (first-paint / activity hooks). */
|
|
103
112
|
"wire:screen": ScreenMessage;
|
|
104
|
-
/** The layout breakpoint changed (desktop <-> mobile), so a feature re-homes
|
|
105
|
-
* its chrome across regions (section 22.13). */
|
|
106
|
-
"layout:change": { mobile: boolean };
|
|
107
113
|
/** The user scrolled away from / back to the bottom (drives the
|
|
108
114
|
* scroll-to-bottom affordance). */
|
|
109
115
|
"scroll:state": { scrolledUp: boolean };
|
|
@@ -195,6 +201,14 @@ export interface TerminalContext {
|
|
|
195
201
|
/** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
|
|
196
202
|
tablist(): TablistController;
|
|
197
203
|
|
|
204
|
+
/** 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. */
|
|
210
|
+
layout(): { narrow: boolean; coarse: boolean };
|
|
211
|
+
|
|
198
212
|
/** Switch the live terminal to a session. tabs binds the renderer to the
|
|
199
213
|
* session's cached store (ctx.render.bind) first, then calls this; the kernel
|
|
200
214
|
* reconnects the terminal WS to that session (connection.setSession, using
|
|
@@ -227,15 +241,32 @@ export interface TerminalFeature<Api = void> {
|
|
|
227
241
|
* Readonly so a feature is covariant in Api (a TerminalFeature<X> is a
|
|
228
242
|
* TerminalFeature<unknown>); the kernel sets it through a narrow cast. */
|
|
229
243
|
readonly api?: Api;
|
|
230
|
-
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
|
|
238
|
-
|
|
244
|
+
/** Present on the ONE feature that owns session selection (at most one per
|
|
245
|
+
* terminal; createTerminal throws when two features register). Its presence
|
|
246
|
+
* makes the kernel SKIP the startup connect to the bare wsPath (which a
|
|
247
|
+
* session-gated server 404s, churning the reconnect backoff); the kernel
|
|
248
|
+
* instead drives the first connect through resolveInitialSession() once
|
|
249
|
+
* feature setup completes. Single-terminal presets leave it unset so the
|
|
250
|
+
* kernel connects to the bare wsPath at startup. */
|
|
251
|
+
readonly sessionOwner?: SessionOwnerRegistration;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The session-owner registration: how the one session-owning feature and the
|
|
255
|
+
* kernel split the first connect. The feature owns session selection (list /
|
|
256
|
+
* spawn / pick) and its own bootstrap state; the kernel owns the connect
|
|
257
|
+
* itself, so a failed bootstrap is SEEN by the kernel (which dismisses the
|
|
258
|
+
* loading overlay) instead of inferred from a missing side effect. */
|
|
259
|
+
export interface SessionOwnerRegistration {
|
|
260
|
+
/** Resolve the initial session: list (or spawn) one, build per-session
|
|
261
|
+
* state, and bind the renderer to the session's store (ctx.render.bind) —
|
|
262
|
+
* but do NOT call ctx.notifySwitch for it. The kernel performs the switch
|
|
263
|
+
* with the returned ref through the same path notifySwitch uses. Return
|
|
264
|
+
* null when no session could be resolved (the bootstrap failed): the
|
|
265
|
+
* feature keeps its retry chrome alive, and the kernel dismisses the
|
|
266
|
+
* loading overlay so that chrome is visible. A throw is treated as null
|
|
267
|
+
* (and reported through the feature-error channel). Called exactly once,
|
|
268
|
+
* after every feature's setup has resolved. */
|
|
269
|
+
resolveInitialSession(): Promise<SessionRef | null>;
|
|
239
270
|
}
|
|
240
271
|
|
|
241
272
|
/** What a feature's setup returns: its optional typed API, a teardown, and an
|
|
@@ -266,6 +297,14 @@ export interface CreateTerminalOptions {
|
|
|
266
297
|
* Heterogeneous feature APIs are held as unknown here; a consumer reads a
|
|
267
298
|
* specific feature's api off the feature value it holds. */
|
|
268
299
|
features?: readonly TerminalFeature<unknown>[];
|
|
300
|
+
/** How the terminal claims space (default "viewport").
|
|
301
|
+
* "viewport": the root becomes a fixed full-viewport box — the full-page
|
|
302
|
+
* product (web-terminal-server, web-terminal-kiro, the scaffold page).
|
|
303
|
+
* "container": the root fills its parent element, which becomes the
|
|
304
|
+
* boundary — the embedded case (vibekit's panel). Either way the kernel
|
|
305
|
+
* stamps the matching class (wt-viewport / wt-container) on the root and
|
|
306
|
+
* every piece of chrome positions against the root, never the page. */
|
|
307
|
+
layout?: "viewport" | "container";
|
|
269
308
|
/** WebSocket endpoint path (default "/ws"). */
|
|
270
309
|
wsPath?: string;
|
|
271
310
|
/** CSS font shorthand awaited before the first resize. */
|
|
@@ -277,8 +316,8 @@ export interface CreateTerminalOptions {
|
|
|
277
316
|
* Keys must be CSS custom-property names (start with "--"); values are any
|
|
278
317
|
* CSS value. The library ships the defaults (css/00-tokens.css) — the
|
|
279
318
|
* "template"; these are the consumer's "settings" and override them for this
|
|
280
|
-
* instance. Known tokens: --accent, --tab-
|
|
281
|
-
* --tab-active-fg. */
|
|
319
|
+
* instance. Known tokens: --accent, --tab-bg, --tab-hover-bg,
|
|
320
|
+
* --tab-active-bg, --tab-active-fg, --tab-active-border. */
|
|
282
321
|
theme?: Readonly<Record<string, string>>;
|
|
283
322
|
}
|
|
284
323
|
|
|
@@ -288,6 +327,17 @@ export interface CreateTerminalOptions {
|
|
|
288
327
|
export interface TerminalHandle {
|
|
289
328
|
/** Focus the terminal input (opens the soft keyboard on touch). */
|
|
290
329
|
focus(): void;
|
|
330
|
+
/** Send bytes to the active session through the kernel's sanitizing,
|
|
331
|
+
* session-routed input funnel — the same path features use: input
|
|
332
|
+
* transforms apply, and the view snaps to the bottom exactly like typed
|
|
333
|
+
* input. The supported host path for "type this command" affordances
|
|
334
|
+
* (a run-in-shell button). No-op after destroy(). */
|
|
335
|
+
send(bytes: Uint8Array): void;
|
|
336
|
+
/** Reset the LOCAL display: drop the client-side scrollback and screen (the
|
|
337
|
+
* same reset the engine performs on a server restart). Deliberately injects
|
|
338
|
+
* no keystroke — a host that wants a freshly drawn prompt sends one itself
|
|
339
|
+
* (e.g. Ctrl+L via send()). No-op after destroy(). */
|
|
340
|
+
reset(): void;
|
|
291
341
|
/** Tear down every feature in reverse order, dispose all subscriptions, and
|
|
292
342
|
* release the kernel's DOM and engine wiring. */
|
|
293
343
|
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
|
+
}
|