@cplieger/web-terminal-ui 3.5.0 → 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 +52 -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 +18 -5
- package/scaffold/index.html +5 -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} +204 -655
- 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/kernel.ts +149 -59
- package/src/kernel/types.ts +55 -14
- 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 ---
|
|
@@ -544,6 +583,7 @@ export function createTerminal(
|
|
|
544
583
|
// --- Viewport ---
|
|
545
584
|
viewport.init({
|
|
546
585
|
termWrap,
|
|
586
|
+
root,
|
|
547
587
|
suppressKeyboardInset: hasFinePointer,
|
|
548
588
|
onSettled() {
|
|
549
589
|
render.updateFontMetrics();
|
|
@@ -615,6 +655,42 @@ export function createTerminal(
|
|
|
615
655
|
const apiMap = new Map<TerminalFeature<unknown>, unknown>();
|
|
616
656
|
const instances: { feature: TerminalFeature<unknown>; instance: FeatureInstance<unknown> }[] = [];
|
|
617
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
|
+
|
|
618
694
|
function makeContext(featureName: string): TerminalContext {
|
|
619
695
|
return {
|
|
620
696
|
region: (name, slot) => regions.region(name, slot),
|
|
@@ -678,38 +754,13 @@ export function createTerminal(
|
|
|
678
754
|
announcer.announce(message, politeness);
|
|
679
755
|
},
|
|
680
756
|
tablist: () => tablistController,
|
|
757
|
+
layout: () => ({
|
|
758
|
+
narrow: isNarrow(),
|
|
759
|
+
coarse:
|
|
760
|
+
typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches,
|
|
761
|
+
}),
|
|
681
762
|
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);
|
|
763
|
+
performSwitch(session);
|
|
713
764
|
},
|
|
714
765
|
dropSession(id) {
|
|
715
766
|
connection.forgetSession(id);
|
|
@@ -744,10 +795,14 @@ export function createTerminal(
|
|
|
744
795
|
instances.length = 0;
|
|
745
796
|
}
|
|
746
797
|
|
|
747
|
-
|
|
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> {
|
|
748
803
|
for (const feature of featureList) {
|
|
749
804
|
if (destroyed) {
|
|
750
|
-
return;
|
|
805
|
+
return false;
|
|
751
806
|
}
|
|
752
807
|
try {
|
|
753
808
|
const instance = await feature.setup(makeContext(feature.name));
|
|
@@ -762,7 +817,7 @@ export function createTerminal(
|
|
|
762
817
|
} catch (err) {
|
|
763
818
|
reportError(feature.name, err);
|
|
764
819
|
}
|
|
765
|
-
return;
|
|
820
|
+
return false;
|
|
766
821
|
}
|
|
767
822
|
instances.push({ feature, instance });
|
|
768
823
|
// Populate the feature value's readonly api (consumer pattern:
|
|
@@ -778,27 +833,47 @@ export function createTerminal(
|
|
|
778
833
|
`web-terminal-ui: feature "${feature.name}" setup failed; terminal has no chrome`,
|
|
779
834
|
err,
|
|
780
835
|
);
|
|
781
|
-
return;
|
|
836
|
+
return false;
|
|
782
837
|
}
|
|
783
838
|
}
|
|
839
|
+
return true;
|
|
784
840
|
}
|
|
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
841
|
// Features set up in the background; first paint is never gated on them.
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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) {
|
|
802
877
|
dismissLoadingOverlay();
|
|
803
878
|
}
|
|
804
879
|
});
|
|
@@ -806,7 +881,7 @@ export function createTerminal(
|
|
|
806
881
|
// --- Connect + focus ---
|
|
807
882
|
render.updateFontMetrics();
|
|
808
883
|
composition.positionCompositionView();
|
|
809
|
-
if (!
|
|
884
|
+
if (!sessionOwner) {
|
|
810
885
|
connection.connect();
|
|
811
886
|
connectionInitiated = true;
|
|
812
887
|
}
|
|
@@ -814,6 +889,19 @@ export function createTerminal(
|
|
|
814
889
|
|
|
815
890
|
return {
|
|
816
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
|
+
},
|
|
817
905
|
destroy() {
|
|
818
906
|
if (destroyed) {
|
|
819
907
|
return;
|
|
@@ -821,6 +909,7 @@ export function createTerminal(
|
|
|
821
909
|
destroyed = true;
|
|
822
910
|
teardownFeatures();
|
|
823
911
|
kernelAbort.abort();
|
|
912
|
+
narrowObserver?.disconnect();
|
|
824
913
|
viewport.teardown();
|
|
825
914
|
// Reset the composition singleton (kernel-driven, no feature teardown): a
|
|
826
915
|
// destroy() mid-IME-composition otherwise leaves module-level `composing`
|
|
@@ -839,6 +928,7 @@ export function createTerminal(
|
|
|
839
928
|
bus.clear();
|
|
840
929
|
announcer.destroy();
|
|
841
930
|
regions.destroy();
|
|
931
|
+
root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
|
|
842
932
|
root.replaceChildren();
|
|
843
933
|
},
|
|
844
934
|
};
|
package/src/kernel/types.ts
CHANGED
|
@@ -110,9 +110,6 @@ export interface TerminalEvents {
|
|
|
110
110
|
"wire:modes": ModesMessage;
|
|
111
111
|
/** A screen frame (first-paint / activity hooks). */
|
|
112
112
|
"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
113
|
/** The user scrolled away from / back to the bottom (drives the
|
|
117
114
|
* scroll-to-bottom affordance). */
|
|
118
115
|
"scroll:state": { scrolledUp: boolean };
|
|
@@ -204,6 +201,14 @@ export interface TerminalContext {
|
|
|
204
201
|
/** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
|
|
205
202
|
tablist(): TablistController;
|
|
206
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
|
+
|
|
207
212
|
/** Switch the live terminal to a session. tabs binds the renderer to the
|
|
208
213
|
* session's cached store (ctx.render.bind) first, then calls this; the kernel
|
|
209
214
|
* reconnects the terminal WS to that session (connection.setSession, using
|
|
@@ -236,15 +241,32 @@ export interface TerminalFeature<Api = void> {
|
|
|
236
241
|
* Readonly so a feature is covariant in Api (a TerminalFeature<X> is a
|
|
237
242
|
* TerminalFeature<unknown>); the kernel sets it through a narrow cast. */
|
|
238
243
|
readonly api?: Api;
|
|
239
|
-
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
|
|
247
|
-
|
|
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>;
|
|
248
270
|
}
|
|
249
271
|
|
|
250
272
|
/** What a feature's setup returns: its optional typed API, a teardown, and an
|
|
@@ -275,6 +297,14 @@ export interface CreateTerminalOptions {
|
|
|
275
297
|
* Heterogeneous feature APIs are held as unknown here; a consumer reads a
|
|
276
298
|
* specific feature's api off the feature value it holds. */
|
|
277
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";
|
|
278
308
|
/** WebSocket endpoint path (default "/ws"). */
|
|
279
309
|
wsPath?: string;
|
|
280
310
|
/** CSS font shorthand awaited before the first resize. */
|
|
@@ -286,8 +316,8 @@ export interface CreateTerminalOptions {
|
|
|
286
316
|
* Keys must be CSS custom-property names (start with "--"); values are any
|
|
287
317
|
* CSS value. The library ships the defaults (css/00-tokens.css) — the
|
|
288
318
|
* "template"; these are the consumer's "settings" and override them for this
|
|
289
|
-
* instance. Known tokens: --accent, --tab-
|
|
290
|
-
* --tab-active-fg. */
|
|
319
|
+
* instance. Known tokens: --accent, --tab-bg, --tab-hover-bg,
|
|
320
|
+
* --tab-active-bg, --tab-active-fg, --tab-active-border. */
|
|
291
321
|
theme?: Readonly<Record<string, string>>;
|
|
292
322
|
}
|
|
293
323
|
|
|
@@ -297,6 +327,17 @@ export interface CreateTerminalOptions {
|
|
|
297
327
|
export interface TerminalHandle {
|
|
298
328
|
/** Focus the terminal input (opens the soft keyboard on touch). */
|
|
299
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;
|
|
300
341
|
/** Tear down every feature in reverse order, dispose all subscriptions, and
|
|
301
342
|
* release the kernel's DOM and engine wiring. */
|
|
302
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
|
+
}
|
package/src/presets.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
// Feature presets
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Feature presets: the barrel. Presets are plain feature-array factories, so
|
|
2
|
+
// they are spreadable and editable: a consumer can drop or add a feature with a
|
|
3
|
+
// filter/spread. This barrel statically imports EVERY preset (and therefore
|
|
4
|
+
// every feature); a consumer that wants the minimal delivered import graph for
|
|
5
|
+
// its composition imports the per-preset entry module instead —
|
|
6
|
+
// "@cplieger/web-terminal-ui/presets/single" | "/presets/touch" |
|
|
7
|
+
// "/presets/tabbed" | "/presets/agent-tabbed" — or hand-picks individual
|
|
8
|
+
// features from "@cplieger/web-terminal-ui/features/<name>".
|
|
6
9
|
//
|
|
7
10
|
// The tabbed presets (presetTabbed / presetAgentTabbed) share the same feature
|
|
8
11
|
// set (both include the activity monitor, whose per-tab dot reveals itself only
|
|
@@ -13,74 +16,7 @@
|
|
|
13
16
|
// server-side — an agent server sets a classifier mapping OSC 9 notifications to
|
|
14
17
|
// done/needs-input.
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
import { predictiveEcho } from "./features/predictive-echo.js";
|
|
21
|
-
import { connectionBanner } from "./features/connection-banner.js";
|
|
22
|
-
import { mobileToolbar } from "./features/mobile-toolbar.js";
|
|
23
|
-
import { tabs } from "./features/tabs.js";
|
|
24
|
-
import { activityMonitor } from "./features/activity-monitor.js";
|
|
25
|
-
import { animations } from "./features/animations.js";
|
|
26
|
-
|
|
27
|
-
/** Single-pane desktop UI: context menu, clipboard, scroll-to-bottom, predictive
|
|
28
|
-
* echo, and the connection banner. No mobile toolbar, no tabs. */
|
|
29
|
-
export function presetSingle(): TerminalFeature<unknown>[] {
|
|
30
|
-
// contextMenu offers Copy/Paste through the clipboard feature (ctx.use), so it
|
|
31
|
-
// holds the same clipboard value the array includes.
|
|
32
|
-
const clip = clipboard();
|
|
33
|
-
return [
|
|
34
|
-
contextMenu({ clipboard: clip }),
|
|
35
|
-
clip,
|
|
36
|
-
scrollToBottom(),
|
|
37
|
-
predictiveEcho(),
|
|
38
|
-
connectionBanner(),
|
|
39
|
-
];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Touch-first UI: presetSingle plus the on-screen key toolbar. */
|
|
43
|
-
export function presetTouch(): TerminalFeature<unknown>[] {
|
|
44
|
-
return [...presetSingle(), mobileToolbar()];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// buildTabbed composes the tabbed UI: the single-pane touch features, an
|
|
48
|
-
// externally-driven mobile toolbar (its grid is opened from a keyboard button in
|
|
49
|
-
// the tab bar, not its own toggle), the activity monitor (the status-SSE data
|
|
50
|
-
// source), tabs wired to both, and animations. The activity dot reveals itself
|
|
51
|
-
// per tab only when a session reports activity (OSC 9;4), so the monitor is
|
|
52
|
-
// always included — a plain shell just never reveals a dot. The toolbar and
|
|
53
|
-
// monitor are ordered before tabs because tabs reads their APIs via ctx.use.
|
|
54
|
-
function buildTabbed(preferInputTitle: boolean): TerminalFeature<unknown>[] {
|
|
55
|
-
const kb = mobileToolbar({ externalToggle: true });
|
|
56
|
-
const monitor = activityMonitor();
|
|
57
|
-
return [
|
|
58
|
-
...presetSingle(),
|
|
59
|
-
kb,
|
|
60
|
-
monitor,
|
|
61
|
-
tabs({ keyboardToggle: kb, activityMonitor: monitor, preferInputTitle }),
|
|
62
|
-
animations(),
|
|
63
|
-
];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Tabbed UI: the touch features, tabs, the mobile keyboard bar, the activity
|
|
67
|
-
* monitor, and animations. Requires a server that speaks the session API
|
|
68
|
-
* (`/api/sessions`, `/ws?session=`, and the status SSE `/api/sessions/events`),
|
|
69
|
-
* such as `web-terminal-server` or `web-terminal-kiro`. Each tab's title follows the
|
|
70
|
-
* process OSC 0/2 window title when the program sets one, else the last
|
|
71
|
-
* submitted line; each tab's activity dot stays hidden until its session
|
|
72
|
-
* reports activity via OSC 9;4 progress (kiro-cli, Claude Code, …), so a plain
|
|
73
|
-
* bash/sh keeps clean, label-only tabs. */
|
|
74
|
-
export function presetTabbed(): TerminalFeature<unknown>[] {
|
|
75
|
-
return buildTabbed(false);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Tabbed UI for an agent shell (web-terminal-kiro). Same features as presetTabbed, but
|
|
79
|
-
* with `preferInputTitle`: the agent's program (kiro-cli) emits a non-empty but
|
|
80
|
-
* useless OSC 0/2 title, so each tab's label follows the latest submitted line
|
|
81
|
-
* (persisted server-side and recovered on reload) and the OSC title is ignored.
|
|
82
|
-
* The agent-vs-generic status distinction remains server-side (a status
|
|
83
|
-
* classifier mapping OSC 9 notifications to done/needs-input). */
|
|
84
|
-
export function presetAgentTabbed(): TerminalFeature<unknown>[] {
|
|
85
|
-
return buildTabbed(true);
|
|
86
|
-
}
|
|
19
|
+
export { presetSingle } from "./presets/single.js";
|
|
20
|
+
export { presetTouch } from "./presets/touch.js";
|
|
21
|
+
export { presetTabbed } from "./presets/tabbed.js";
|
|
22
|
+
export { presetAgentTabbed } from "./presets/agent-tabbed.js";
|