@cplieger/web-terminal-ui 7.3.0 → 7.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/css/00-tokens.css +11 -0
- package/css/10-primitives.css +1 -0
- package/css/23-toolbar.css +1 -0
- package/css/30-tabs.css +82 -16
- package/package.json +1 -1
- package/src/features/activity-monitor.ts +37 -10
- package/src/features/animations.ts +23 -10
- package/src/features/clipboard.ts +26 -4
- package/src/features/connection-banner.ts +18 -5
- package/src/features/context-menu.ts +68 -48
- package/src/features/mobile-toolbar.ts +24 -7
- package/src/features/predictive-echo.ts +25 -6
- package/src/features/scroll-to-bottom.ts +19 -5
- package/src/features/tabs/attention.ts +4 -4
- package/src/features/tabs/index.ts +55 -16
- package/src/features/tabs/model.ts +7 -6
- package/src/kernel/scrollback-storage.ts +12 -0
- package/src/kernel/startup-copy.ts +18 -16
- package/src/kernel/style-contract.ts +35 -33
- package/src/presets/agent-tabbed.ts +10 -7
- package/src/presets/single.ts +9 -5
- package/src/presets/tabbed.ts +12 -9
- package/src/presets/touch.ts +8 -4
- package/src/presets.ts +23 -17
package/README.md
CHANGED
|
@@ -139,16 +139,17 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
|
|
|
139
139
|
- `presetTabbed()`: the generic tabbed UI, `presetTouch()` plus tabs, the
|
|
140
140
|
activity monitor, and animations. Each tab's title is OSC-first: it follows
|
|
141
141
|
the process window title (OSC 0/2) when the program sets one and keeps it
|
|
142
|
-
updated, otherwise the
|
|
142
|
+
updated, otherwise a name the server infers from the foreground process or the
|
|
143
|
+
working directory. The per-tab activity dot
|
|
143
144
|
reveals itself only when a session reports OSC 9;4 progress, so a plain shell
|
|
144
145
|
keeps clean, label-only tabs. Requires a server that speaks the session API
|
|
145
|
-
(`/api/sessions
|
|
146
|
+
(`/api/sessions`, `/ws?session=`, and the status SSE `/api/sessions/events`),
|
|
147
|
+
such as `web-terminal-server`.
|
|
146
148
|
- `presetAgentTabbed()`: the same feature set as `presetTabbed()`, tuned for an
|
|
147
|
-
agent shell such as `web-terminal-kiro
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
session's first OSC 9;4 signal. Its status dots come from the same activity
|
|
149
|
+
agent shell such as `web-terminal-kiro`, and differing from `presetTabbed()`
|
|
150
|
+
only in presumed activity reporting. With `presumeReports`, the idle activity
|
|
151
|
+
dot shows from tab creation instead of waiting for the session's first
|
|
152
|
+
OSC 9;4 signal. Its status dots come from the same activity
|
|
152
153
|
monitor and cover eight states: the three animated OSC 9;4 progress states
|
|
153
154
|
(working, warning, error), the frozen ringed needs-input dot, two static discs
|
|
154
155
|
(a green finished turn, a red crashed process), and two hollow ones (idle and a
|
package/css/00-tokens.css
CHANGED
|
@@ -168,6 +168,17 @@
|
|
|
168
168
|
the best answer when the measurement cannot run. Do not tune it by eye. */
|
|
169
169
|
--label-ink-shift: 0em;
|
|
170
170
|
--dur-micro: 0.1s;
|
|
171
|
+
/* The activity vocabulary's one beat period, read by all three animated tab
|
|
172
|
+
cues — the working dot's glow, its travelling ripple, and the secondary
|
|
173
|
+
activity mark's breath (30-tabs.css). A token rather than three literals
|
|
174
|
+
because the three are phase-locked BY sharing it: the ripple launches off the
|
|
175
|
+
disc as the glow peaks, and the mark beside them reports on the same clock, so
|
|
176
|
+
retuning one alone desynchronises cues a reader sees side by side. It was a
|
|
177
|
+
comment saying "change both or neither" until 2026-09, which is the same rule
|
|
178
|
+
with nothing enforcing it.
|
|
179
|
+
Shared with vibekit's `--dot-beat-dur` (01-tokens.css) for the reason the
|
|
180
|
+
--status-* inks are: one signal across the two apps, beat included. */
|
|
181
|
+
--dot-beat-dur: 1.2s;
|
|
171
182
|
--dur-standard: 0.2s;
|
|
172
183
|
--dur-enter: 0.25s;
|
|
173
184
|
--dur-exit: 0.15s;
|
package/css/10-primitives.css
CHANGED
package/css/23-toolbar.css
CHANGED
package/css/30-tabs.css
CHANGED
|
@@ -815,7 +815,7 @@
|
|
|
815
815
|
transparent 92%
|
|
816
816
|
);
|
|
817
817
|
opacity: 0;
|
|
818
|
-
animation: wt-working-glow
|
|
818
|
+
animation: wt-working-glow var(--dot-beat-dur) ease-in-out infinite;
|
|
819
819
|
will-change: opacity;
|
|
820
820
|
}
|
|
821
821
|
|
|
@@ -850,7 +850,7 @@
|
|
|
850
850
|
var(--dot-color) 76%,
|
|
851
851
|
transparent 100%
|
|
852
852
|
);
|
|
853
|
-
animation: wt-working-ripple
|
|
853
|
+
animation: wt-working-ripple var(--dot-beat-dur) linear infinite;
|
|
854
854
|
}
|
|
855
855
|
|
|
856
856
|
/* Agent done / ready: solid disc (--status-done), no motion. The default green
|
|
@@ -983,27 +983,80 @@
|
|
|
983
983
|
this size reads as an off-centre pulse. */
|
|
984
984
|
:where(.wt-root) .wt-activity-mark[data-activity="working"] {
|
|
985
985
|
--wt-mark-ink: var(--status-working);
|
|
986
|
+
/* The band, named because the MASK below has to know it: the overlay's hole is
|
|
987
|
+
the mark's inradius less this. Paired with the declaration it describes so the
|
|
988
|
+
two cannot drift apart on width. */
|
|
989
|
+
--wt-mark-band: 2px;
|
|
986
990
|
|
|
987
|
-
border-width:
|
|
991
|
+
border-width: var(--wt-mark-band);
|
|
988
992
|
transform: translateZ(0);
|
|
989
993
|
}
|
|
990
994
|
|
|
991
995
|
/* The breath: a pre-painted radial animating OPACITY only, never colours, so the
|
|
992
996
|
beat costs compositing rather than a repaint per frame for as long as a
|
|
993
|
-
background task runs (the battery reason the dot's glow overlay records).
|
|
997
|
+
background task runs (the battery reason the dot's glow overlay records).
|
|
998
|
+
|
|
999
|
+
PLUS A MASK, which is the one thing a ring needs and a disc does not, and whose
|
|
1000
|
+
absence was a real defect rather than a missing nicety. Over a solid disc the
|
|
1001
|
+
overlay's bright core has almost no headroom above its own backdrop; over a
|
|
1002
|
+
ring's transparent HOLE it has all of it, so an unmasked core FILLED the hole at
|
|
1003
|
+
the peak and the mark rendered as a solid rounded square. That is the one thing
|
|
1004
|
+
this element may never be: the mark and the activity dot 8px away share their ink
|
|
1005
|
+
at zero hue distance, so the ring-against-disc silhouette is the entire WCAG
|
|
1006
|
+
1.4.1 channel between them, and a fill collapses it for the brightest third of
|
|
1007
|
+
every cycle. Masked, the beat lights the BAND and the hole's four corners, so
|
|
1008
|
+
what a reader sees is the hole morphing between a square and the circle inscribed
|
|
1009
|
+
in it — the ring survives the peak.
|
|
1010
|
+
|
|
1011
|
+
EVERY NUMBER HERE IS A RELATION, and the port that copied vibekit's LITERALS is
|
|
1012
|
+
what left this rule running with nothing visible to show. Two of them were
|
|
1013
|
+
arithmetic for an 8px mark with a 2px band: `inset: -1px` put the overlay on that
|
|
1014
|
+
mark's own border box, and the core stop `70%` is where the core reaches the
|
|
1015
|
+
hole's CORNER there — sqrt(2) * (size / 2 - band) / (size / 2) = 70.71%. At 9px
|
|
1016
|
+
and 2px the same relation is 78.57%, so the copied 70% ended the solid core at
|
|
1017
|
+
2.45px while the mask opens only at 2.5px: core and visible mask did not
|
|
1018
|
+
intersect at all, the beat lit nothing the mask kept, and 4 pixels of a 9x9 mark
|
|
1019
|
+
moved between trough and peak against vibekit's 20 (measured at dpr 1, both
|
|
1020
|
+
marks, sRGB). Written as relations they survive any size: the inset IS the band,
|
|
1021
|
+
the core stop IS the hole's corner radius, the mask's hole IS its inradius. So
|
|
1022
|
+
port vibekit's `.tab-run-dot` (`12-tabs.css`, "THE GLOW BEAT") — the same mark
|
|
1023
|
+
solving the same problem, with the mask from the start — by its relations, never
|
|
1024
|
+
by its numbers. sqrt(2) is spelled to four decimals because the synced stylelint
|
|
1025
|
+
sets `number-max-precision: 4` outside colour functions.
|
|
1026
|
+
|
|
1027
|
+
NOTHING HERE EXTENDS PAST THE MARK. `box-sizing: border-box` (01-scope.css) puts
|
|
1028
|
+
the padding box — an absolutely positioned child's containing block — INSIDE the
|
|
1029
|
+
band, so `inset: calc(-1 * var(--wt-mark-band))` grows the overlay back out to
|
|
1030
|
+
exactly the mark's own border box and no further. Read it as "fill the mark", not
|
|
1031
|
+
as "overhang it": a larger inset would reach outside, where a feathered edge's
|
|
1032
|
+
visible radius moves with the beat and renders differently at every pixel
|
|
1033
|
+
density. `border-radius: 50%` is the same discipline on the last literal, and it
|
|
1034
|
+
is inert BY CONSTRUCTION rather than by measurement, which is why no assertion
|
|
1035
|
+
pins it: for side S and radius r the clip's 45° boundary is (S/2 - r) * sqrt(2) + r,
|
|
1036
|
+
monotonically decreasing in r, so the inscribed circle is the WORST case for
|
|
1037
|
+
reaching the hole's corners — and it still clears them for any band at or above
|
|
1038
|
+
S/2 * (1 - 1/sqrt(2)), 1.32px here against 2px. Do not assert it, and do not drop
|
|
1039
|
+
it either: it is what keeps a literal radius out of a rule whose whole defect was
|
|
1040
|
+
literals. */
|
|
994
1041
|
:where(.wt-root) .wt-activity-mark[data-activity="working"]::before {
|
|
995
1042
|
content: "";
|
|
996
1043
|
position: absolute;
|
|
997
|
-
inset: -
|
|
998
|
-
border-radius:
|
|
1044
|
+
inset: calc(-1 * var(--wt-mark-band));
|
|
1045
|
+
border-radius: 50%;
|
|
999
1046
|
background: radial-gradient(
|
|
1000
1047
|
closest-side,
|
|
1001
|
-
color-mix(in oklch, var(--wt-mark-ink) 78%, #fff) 0
|
|
1048
|
+
color-mix(in oklch, var(--wt-mark-ink) 78%, #fff) 0
|
|
1049
|
+
calc(1.4142 * (var(--wt-mark-size) / 2 - var(--wt-mark-band))),
|
|
1002
1050
|
transparent 92%
|
|
1003
1051
|
);
|
|
1004
1052
|
opacity: 0;
|
|
1005
|
-
animation: wt-activity-breath
|
|
1053
|
+
animation: wt-activity-breath var(--dot-beat-dur) ease-in-out infinite;
|
|
1006
1054
|
will-change: opacity;
|
|
1055
|
+
mask: radial-gradient(
|
|
1056
|
+
closest-side,
|
|
1057
|
+
transparent 0 calc(var(--wt-mark-size) / 2 - var(--wt-mark-band)),
|
|
1058
|
+
#000 calc(var(--wt-mark-size) / 2 - var(--wt-mark-band))
|
|
1059
|
+
);
|
|
1007
1060
|
}
|
|
1008
1061
|
|
|
1009
1062
|
/* Waiting: the thin band plus a static halo. Stopped and resumable, nobody being
|
|
@@ -1054,8 +1107,10 @@
|
|
|
1054
1107
|
usable weight through the travel. The ramp-in is short enough to read as
|
|
1055
1108
|
instant while stopping the emission from popping in.
|
|
1056
1109
|
|
|
1057
|
-
The disc's own glow shares this
|
|
1058
|
-
|
|
1110
|
+
The disc's own glow shares this period so the two cues stay phase-locked, and
|
|
1111
|
+
since 2026-09 that is STRUCTURAL rather than a request: all three animated cues
|
|
1112
|
+
read `--dot-beat-dur` (00-tokens.css), so there is no longer a pair of literals
|
|
1113
|
+
to change both of. */
|
|
1059
1114
|
@keyframes wt-working-ripple {
|
|
1060
1115
|
0% {
|
|
1061
1116
|
transform: scale(1);
|
|
@@ -1076,9 +1131,16 @@
|
|
|
1076
1131
|
}
|
|
1077
1132
|
}
|
|
1078
1133
|
|
|
1079
|
-
/* The activity mark's breath,
|
|
1080
|
-
|
|
1081
|
-
|
|
1134
|
+
/* The activity mark's breath: rest, one swell, rest. Opacity only — a colour
|
|
1135
|
+
keyframe here would reintroduce the per-frame repaint this shape exists to avoid.
|
|
1136
|
+
|
|
1137
|
+
The dot's glow ENVELOPE, at a lower CEILING, and the ceiling is where the two
|
|
1138
|
+
deliberately part. 0.55 rather than 1 is vibekit's number for the same mark
|
|
1139
|
+
(`12-tabs.css`), and it is what keeps a strip of busy tabs reading as one pulse
|
|
1140
|
+
instead of a row of flashes — the dot can afford 1 because its overlay lifts a
|
|
1141
|
+
solid disc of its own hue and has almost no headroom above its own backdrop,
|
|
1142
|
+
while this one is masked onto a 2px band over the page and has all of it. Used by
|
|
1143
|
+
the mark alone, so the value moves nothing else. */
|
|
1082
1144
|
@keyframes wt-activity-breath {
|
|
1083
1145
|
0%,
|
|
1084
1146
|
70%,
|
|
@@ -1087,7 +1149,7 @@
|
|
|
1087
1149
|
}
|
|
1088
1150
|
|
|
1089
1151
|
35% {
|
|
1090
|
-
opacity:
|
|
1152
|
+
opacity: 0.55;
|
|
1091
1153
|
}
|
|
1092
1154
|
}
|
|
1093
1155
|
|
|
@@ -1131,9 +1193,13 @@
|
|
|
1131
1193
|
|
|
1132
1194
|
/* Working loses its breath, so it takes the HEAVIEST band instead: motion is
|
|
1133
1195
|
REPLACED by a channel rather than removed, and the three states then separate
|
|
1134
|
-
by band alone (3px bare / 1px halo / 2px halo).
|
|
1196
|
+
by band alone (3px bare / 1px halo / 2px halo).
|
|
1197
|
+
|
|
1198
|
+
Re-pointing --wt-mark-band rather than declaring `border-width` keeps the number
|
|
1199
|
+
in the one place the state's own rule reads it from, the mask's divisor included
|
|
1200
|
+
— which is moot here only because the overlay is gone one rule below. */
|
|
1135
1201
|
:where(.wt-root) .wt-activity-mark[data-activity="working"] {
|
|
1136
|
-
|
|
1202
|
+
--wt-mark-band: 3px;
|
|
1137
1203
|
}
|
|
1138
1204
|
|
|
1139
1205
|
:where(.wt-root) .wt-activity-mark[data-activity="working"]::before {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "7.3.
|
|
3
|
+
"version": "7.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
/**
|
|
2
|
+
* activityMonitor feature: subscribes to the server's status SSE
|
|
3
|
+
* (/api/sessions/events via the engine's connectStatusStream) and exposes each
|
|
4
|
+
* session's live status through its API. It is a
|
|
5
|
+
* pure data source with no chrome of its own; the tabs feature consumes it via
|
|
6
|
+
* ctx.use to render per-tab activity dots and drop exited/removed tabs. The
|
|
7
|
+
* server pushes an initial snapshot on every (re)open — but the snapshot
|
|
8
|
+
* carries no tombstones for sessions a REPLACEMENT server never knew (manager
|
|
9
|
+
* restart), so the stream-open signal is surfaced via onStreamOpen and the
|
|
10
|
+
* consumer (tabs) runs a one-shot GET /api/sessions reconcile there to drop
|
|
11
|
+
* zombie tabs.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
11
15
|
|
|
12
16
|
import { connectStatusStream } from "@cplieger/web-terminal-engine";
|
|
13
17
|
import type { SessionStatus } from "@cplieger/web-terminal-engine";
|
|
@@ -15,6 +19,14 @@ import type { TerminalFeature, Unsubscribe } from "../kernel/types.js";
|
|
|
15
19
|
|
|
16
20
|
const DEFAULT_EVENTS_PATH = "/api/sessions/events";
|
|
17
21
|
|
|
22
|
+
/** The value a peer feature reads through `ctx.use(activityMonitor(...))`: the
|
|
23
|
+
* live status of every session the server knows about.
|
|
24
|
+
*
|
|
25
|
+
* Read-only — nothing here starts, stops or mutates a session. `current` answers
|
|
26
|
+
* from the last event seen rather than from the network, so it is undefined for
|
|
27
|
+
* a session no event has covered yet (the initial snapshot arrives
|
|
28
|
+
* asynchronously after setup) and for one the stream has since reported
|
|
29
|
+
* `removed`. A caller that needs "eventually" rather than "now" subscribes. */
|
|
18
30
|
export interface ActivityMonitorApi {
|
|
19
31
|
/** Subscribe to every status update (idle/working/warning/failed/input/done/
|
|
20
32
|
* exited/crashed, plus removed), including the OSC 9;4 percentage and any OSC
|
|
@@ -32,6 +44,21 @@ export interface ActivityMonitorApi {
|
|
|
32
44
|
onStreamOpen?(cb: () => void): Unsubscribe;
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
/** Build the activityMonitor feature.
|
|
48
|
+
*
|
|
49
|
+
* Requires a server that serves the status SSE endpoint; on a server without one
|
|
50
|
+
* the stream never opens, every `current` stays undefined and no subscriber ever
|
|
51
|
+
* fires, which is why `tabs` treats the monitor as optional and polls
|
|
52
|
+
* `GET /api/sessions` instead when it is absent.
|
|
53
|
+
*
|
|
54
|
+
* `eventsPath` overrides the endpoint (default `/api/sessions/events`). Order it
|
|
55
|
+
* BEFORE any feature that reads its API through `ctx.use`, since a feature's
|
|
56
|
+
* setup can only see peers already set up. Teardown closes the stream and drops
|
|
57
|
+
* every subscriber and cached status.
|
|
58
|
+
*
|
|
59
|
+
* A throwing subscriber is caught and logged rather than propagated: it must not
|
|
60
|
+
* skip the remaining subscribers, and it must not reach the engine's SSE reader
|
|
61
|
+
* and kill the stream for everyone. */
|
|
35
62
|
export function activityMonitor(
|
|
36
63
|
opts: { eventsPath?: string } = {},
|
|
37
64
|
): TerminalFeature<ActivityMonitorApi> {
|
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
/**
|
|
2
|
+
* animations feature: opt-in motion for the chrome surfaces.
|
|
3
|
+
* Without it the chrome surfaces are instant (no transitions); with
|
|
4
|
+
* it, and when the user has not asked for reduced motion, it enables the shared
|
|
5
|
+
* animation vocabulary by marking the terminal root, which the feature
|
|
6
|
+
* stylesheets key their transitions off. A few pieces of motion animate
|
|
7
|
+
* unconditionally regardless of this feature (terminal-core cursor/SGR blink and
|
|
8
|
+
* the mobile key toolbar open/close in 02-app.css, plus the working-status-dot
|
|
9
|
+
* pulse in 05-tabs.css, its WCAG non-hue cue), though prefers-reduced-motion
|
|
10
|
+
* still defeats all of it. It tracks prefers-reduced-motion live so toggling the
|
|
11
|
+
* OS setting takes effect.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
11
15
|
|
|
12
16
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
13
17
|
|
|
18
|
+
/** Build the animations feature. Exposes no API — its whole effect is the
|
|
19
|
+
* `wt-animate` class on the terminal root, which the shipped stylesheets gate
|
|
20
|
+
* their transitions on, so a composition either includes it or gets instant
|
|
21
|
+
* chrome.
|
|
22
|
+
*
|
|
23
|
+
* Position in the feature list does not matter (nothing reads it through
|
|
24
|
+
* `ctx.use`). The class is applied during setup, re-evaluated whenever the
|
|
25
|
+
* `prefers-reduced-motion` media query changes, and removed on teardown along
|
|
26
|
+
* with the listener. */
|
|
14
27
|
export function animations(): TerminalFeature {
|
|
15
28
|
return {
|
|
16
29
|
name: "animations",
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* clipboard feature: copy/paste helpers, the desktop Ctrl+Shift+C/V shortcuts,
|
|
3
|
+
* and OSC 52 mirroring. Exposes a typed API so contextMenu
|
|
4
|
+
* can offer Copy/Paste through it (ctx.use). With this feature absent, inbound
|
|
5
|
+
* OSC 52 has no subscriber and is intentionally a no-op.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
5
9
|
|
|
6
10
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
7
11
|
|
|
12
|
+
/** The value a peer feature reads through `ctx.use(clipboard())` — contextMenu's
|
|
13
|
+
* Copy and Paste items are this API behind a button.
|
|
14
|
+
*
|
|
15
|
+
* Both members are fire-and-forget: the underlying Clipboard API calls are async
|
|
16
|
+
* and their outcome is reported to the user as a toast rather than returned, so a
|
|
17
|
+
* caller cannot await or branch on success. Both require a secure context
|
|
18
|
+
* (`navigator.clipboard` is absent on a plain-HTTP non-loopback host, a supported
|
|
19
|
+
* web-terminal-server deployment), where they toast "Clipboard unavailable" and
|
|
20
|
+
* do nothing. */
|
|
8
21
|
export interface ClipboardApi {
|
|
9
22
|
/** Write text to the system clipboard (surfaces a toast on success/failure). */
|
|
10
23
|
copy(text: string): void;
|
|
@@ -12,6 +25,15 @@ export interface ClipboardApi {
|
|
|
12
25
|
paste(): void;
|
|
13
26
|
}
|
|
14
27
|
|
|
28
|
+
/** Build the clipboard feature.
|
|
29
|
+
*
|
|
30
|
+
* It claims three things for the lifetime of the terminal: a kernel keydown
|
|
31
|
+
* intercept (Ctrl+Shift+C copies the browser selection, Ctrl+Shift+V pastes, and
|
|
32
|
+
* a plain Ctrl+V is consumed WITHOUT preventDefault so the browser's own paste
|
|
33
|
+
* event reaches the hidden textarea instead of being mapped to `\x16`), the
|
|
34
|
+
* `wire:clipboard` event that mirrors an application's OSC 52 copy to the system
|
|
35
|
+
* clipboard, and a document `copy` listener that toasts "Copied" for a selection
|
|
36
|
+
* inside the terminal surface only. Teardown releases all three. */
|
|
15
37
|
export function clipboard(): TerminalFeature<ClipboardApi> {
|
|
16
38
|
return {
|
|
17
39
|
name: "clipboard",
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* connectionBanner feature: renders the kernel's connection state as a visible
|
|
3
|
+
* banner. The state machine, the loading
|
|
4
|
+
* lifecycle, and the give-up hook are kernel-owned; this feature is a pure
|
|
5
|
+
* renderer subscribing to connection:state. It uses the kernel's single
|
|
6
|
+
* announcer (ctx.announce) rather than spawning its own aria-live region.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
6
10
|
|
|
7
11
|
import type { ConnState, TerminalFeature } from "../kernel/types.js";
|
|
8
12
|
|
|
@@ -17,6 +21,15 @@ const TEXT: Partial<Record<ConnState, string>> = {
|
|
|
17
21
|
incompatible: "Terminal protocol mismatch; update the server or reload this page",
|
|
18
22
|
};
|
|
19
23
|
|
|
24
|
+
/** Build the connectionBanner feature. Exposes no API — it owns one element in
|
|
25
|
+
* the kernel's "banner" region and nothing else reads from it.
|
|
26
|
+
*
|
|
27
|
+
* Only the states with copy above are shown; `connected` and any other state
|
|
28
|
+
* hides the banner, so it is a transient surface rather than a permanent status
|
|
29
|
+
* line. `restarted`, `ended` and `incompatible` are the terminal ones a user
|
|
30
|
+
* cannot wait out. Each change is announced once through `ctx.announce`, so a
|
|
31
|
+
* flaky link re-entering the same state does not repeat itself to a screen
|
|
32
|
+
* reader. Teardown unsubscribes and removes the element. */
|
|
20
33
|
export function connectionBanner(): TerminalFeature {
|
|
21
34
|
return {
|
|
22
35
|
name: "connectionBanner",
|
|
@@ -1,51 +1,55 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
1
|
+
/**
|
|
2
|
+
* contextMenu feature: the Copy / Select All / Paste menu for the terminal
|
|
3
|
+
* surface, rendered into the overlay region. Copy and Paste
|
|
4
|
+
* need the clipboard feature (ctx.use), and Paste routes through the kernel's
|
|
5
|
+
* sanitizing funnel. Escape-to-close goes through the kernel keydown intercept;
|
|
6
|
+
* outside-click is a document gesture.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS MENU EXISTS
|
|
9
|
+
* The terminal output is real DOM text, so the browser already does the hard
|
|
10
|
+
* part: drag-select with a mouse, long-press word-select plus the OS copy callout
|
|
11
|
+
* on touch. What no platform can offer is PASTE — the keyboard target is a 1x1
|
|
12
|
+
* pointer-events:none textarea, so there is no editable surface under the pointer
|
|
13
|
+
* for a native paste item to attach to. This menu is the paste path. It carries
|
|
14
|
+
* Copy and Select All because a menu appearing where the platform's would have
|
|
15
|
+
* appeared should not be missing them.
|
|
16
|
+
*
|
|
17
|
+
* THE MODEL: one owner per gesture, and nothing decided mid-gesture.
|
|
18
|
+
*
|
|
19
|
+
* Mouse / pen — `contextmenu` is an already-classified request for a menu:
|
|
20
|
+
* preventDefault and open at the pointer. No timers, no heuristics, no
|
|
21
|
+
* platform branches.
|
|
22
|
+
*
|
|
23
|
+
* Touch — the platform owns the press while the finger is down. We run no hold
|
|
24
|
+
* timer against it, open nothing before it finishes, and never cancel its
|
|
25
|
+
* gesture on WebKit. We classify ONCE, on `touchend`, when every fact is
|
|
26
|
+
* settled: a single-finger, stationary press held past the tap ceiling
|
|
27
|
+
* (kernel/gesture.ts, shared with the kernel's tap-to-focus so the two cannot
|
|
28
|
+
* both claim one press) that selected nothing and did not start on a link is a
|
|
29
|
+
* press the platform declined — so it is ours, and it is the paste path. A
|
|
30
|
+
* press that produced a selection belongs to the OS callout; a press on a link
|
|
31
|
+
* belongs to the platform's link preview; a shorter press is the kernel's tap.
|
|
32
|
+
*
|
|
33
|
+
* WHY AT RELEASE RATHER THAN DURING THE HOLD
|
|
34
|
+
* Opening from a ~550ms hold timer put this feature in a race it cannot win: the
|
|
35
|
+
* timer has to beat the platform's own long-press threshold and then GUESS
|
|
36
|
+
* whether a selection is still coming (iOS 26 registers a word selection well
|
|
37
|
+
* after 550ms). Four mechanisms existed only to referee that guess — a hit test
|
|
38
|
+
* for glyphs under the finger, a selectionchange watch to retract a menu that
|
|
39
|
+
* opened too early, a device sniff, and per-platform contextmenu branching,
|
|
40
|
+
* because WebKit reads preventDefault on a touch contextmenu as "cancel every
|
|
41
|
+
* remaining default of this gesture", the not-yet-registered selection included.
|
|
42
|
+
* Deciding at release deletes all four: the outcome is observed, not predicted.
|
|
43
|
+
*
|
|
44
|
+
* It also fixes the symptom that prompted the rewrite. A touch long-press emits a
|
|
45
|
+
* trailing click on release, and the swallow window that covers that click was
|
|
46
|
+
* armed when the menu OPENED — ~550ms into the press, expiring 350ms later — so
|
|
47
|
+
* holding a beat longer meant the release click landed as an outside click and
|
|
48
|
+
* dismissed the menu the instant the finger lifted. Opened BY the release, the
|
|
49
|
+
* window can only ever start at the release edge.
|
|
50
|
+
*
|
|
51
|
+
* @module
|
|
52
|
+
*/
|
|
49
53
|
|
|
50
54
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
51
55
|
import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "../kernel/gesture.js";
|
|
@@ -56,6 +60,12 @@ import { createClickSwallow, placeMenuAt } from "./menu-position.js";
|
|
|
56
60
|
// swallow all live in the shared point-anchored menu module (menu-position.ts),
|
|
57
61
|
// shared with the tab menu.
|
|
58
62
|
|
|
63
|
+
/** Options for the contextMenu feature.
|
|
64
|
+
*
|
|
65
|
+
* Pass the SAME clipboard feature value the composition includes, not a second
|
|
66
|
+
* `clipboard()` call: `ctx.use` resolves a value to the instance the kernel set
|
|
67
|
+
* up, so a value that is not in the feature list resolves to nothing and the
|
|
68
|
+
* menu silently loses Copy and Paste. `presetSingle` shows the shape. */
|
|
59
69
|
export interface ContextMenuOptions {
|
|
60
70
|
/** The clipboard feature value, so the menu can offer Copy/Paste through its
|
|
61
71
|
* API (ctx.use). Omitted: the menu shows only Select All. */
|
|
@@ -109,6 +119,16 @@ function selectionText(): string {
|
|
|
109
119
|
* so the selection test alone would not keep us out of its way. */
|
|
110
120
|
const onLink = isLinkTarget;
|
|
111
121
|
|
|
122
|
+
/** Build the contextMenu feature. Exposes no API — the menu is opened by the
|
|
123
|
+
* gestures described above, never programmatically by a peer.
|
|
124
|
+
*
|
|
125
|
+
* Hand it the clipboard value the composition also includes (see
|
|
126
|
+
* ContextMenuOptions) or it degrades to a Select All-only menu; its own position in
|
|
127
|
+
* the feature list is otherwise free, since nothing reads it through `ctx.use`. Its
|
|
128
|
+
* items act on the browser selection inside the terminal surface and on the
|
|
129
|
+
* kernel's paste funnel, so it holds no session state and survives a tab switch
|
|
130
|
+
* unchanged. Teardown removes the menu element and releases the keydown intercept
|
|
131
|
+
* along with every surface and document listener. */
|
|
112
132
|
export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
113
133
|
return {
|
|
114
134
|
name: "contextMenu",
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* mobileToolbar feature: the on-screen key toolbar (Tab/Esc/arrows/Enter + a
|
|
3
|
+
* collapse toggle and sticky-Ctrl) in the thumb-zone region.
|
|
4
|
+
* The engine's toolbar.bindMobileToolbar wires the buttons (DECCKM-aware
|
|
5
|
+
* arrows, sticky-Ctrl state; engine >= v3, where the widget moved out of the
|
|
6
|
+
* keyboard module); this feature builds the toolbar chrome, routes its
|
|
7
|
+
* output through the kernel funnel, and registers sticky-Ctrl as an input
|
|
8
|
+
* transform so a typed character is rewritten to its Ctrl byte when armed.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
8
12
|
|
|
9
13
|
import { toolbar } from "@cplieger/web-terminal-engine";
|
|
10
14
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
@@ -52,6 +56,19 @@ const TOOLBAR_HTML = `
|
|
|
52
56
|
<button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
|
|
53
57
|
</div>`;
|
|
54
58
|
|
|
59
|
+
/** Build the mobileToolbar feature.
|
|
60
|
+
*
|
|
61
|
+
* The chrome is always built; whether it is VISIBLE is a CSS decision — the keys
|
|
62
|
+
* and the toggle are `display: none` and only a coarse pointer restores them
|
|
63
|
+
* (`css/23-toolbar.css`) — so a desktop consumer including this feature pays the
|
|
64
|
+
* DOM and sees nothing. Order it before `tabs` when `externalToggle` is set, since
|
|
65
|
+
* tabs reads the returned API through `ctx.use` to drive the grid from the tab
|
|
66
|
+
* bar's keyboard button.
|
|
67
|
+
*
|
|
68
|
+
* Sticky-Ctrl is registered as a kernel INPUT TRANSFORM rather than handled in
|
|
69
|
+
* the toolbar's own click path, so it rewrites a character typed on the soft
|
|
70
|
+
* keyboard as well as one produced by these buttons. Teardown removes the
|
|
71
|
+
* toolbar, drops the transform, and clears every subscriber. */
|
|
55
72
|
export function mobileToolbar(opts: MobileToolbarOptions = {}): TerminalFeature<MobileToolbarApi> {
|
|
56
73
|
return {
|
|
57
74
|
name: "mobileToolbar",
|
|
@@ -1,13 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* predictiveEcho feature: Mosh-style local echo. Wires the
|
|
3
|
+
* predict.ts mini-VT to the kernel input funnel: it advances a predicted cursor
|
|
4
|
+
* on accepted input (an input observer) and owns the col-0 backspace brake (an
|
|
5
|
+
* input transform that drops a lone DEL at the true origin, since the predicted
|
|
6
|
+
* cursor is the authoritative position for that decision). It re-pushes the
|
|
7
|
+
* predicted-cursor overlay on prediction changes and after each render flush.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
7
11
|
|
|
8
12
|
import * as predict from "../predict.js";
|
|
9
13
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
10
14
|
|
|
15
|
+
/** Build the predictiveEcho feature. Exposes no API — its output is the engine
|
|
16
|
+
* renderer's predicted-cursor overlay.
|
|
17
|
+
*
|
|
18
|
+
* The prediction state is MODULE-scoped (`../predict.js`), not per instance, so two
|
|
19
|
+
* terminals in one page that both include this feature would drive one predicted
|
|
20
|
+
* cursor between them. That is within the kernel's existing "call createTerminal
|
|
21
|
+
* exactly once" contract, but it is the reason this feature cannot be made
|
|
22
|
+
* per-instance by composing it twice.
|
|
23
|
+
*
|
|
24
|
+
* Predictions are advisory: `predict` suspends itself on any byte it cannot
|
|
25
|
+
* model, because a wrong prediction is worse than a missing one. The feature
|
|
26
|
+
* resets it on a server restart and on a tab switch (`onDetach`), so the outgoing
|
|
27
|
+
* session's ghost cursor never paints over the incoming session's screen; the
|
|
28
|
+
* next server frame re-arms it. Teardown drops every listener, hides the overlay,
|
|
29
|
+
* and clears the prediction. */
|
|
11
30
|
export function predictiveEcho(): TerminalFeature {
|
|
12
31
|
return {
|
|
13
32
|
name: "predictiveEcho",
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* scrollToBottom feature: a scroll-to-bottom control in the thumb-zone region,
|
|
3
|
+
* shown only while the user has scrolled up. The
|
|
4
|
+
* scroll-state signal comes from the kernel's scroll:state event (the kernel
|
|
5
|
+
* owns scroll.init); this feature just renders the affordance and drives
|
|
6
|
+
* scroll.scrollToBottom.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
6
10
|
|
|
7
11
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
8
12
|
import { fromHTML, holdFocusOnPress } from "./dom.js";
|
|
@@ -12,6 +16,16 @@ const BUTTON_HTML = `
|
|
|
12
16
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg>
|
|
13
17
|
</button>`;
|
|
14
18
|
|
|
19
|
+
/** Build the scrollToBottom feature. Exposes no API — the button is the whole
|
|
20
|
+
* surface, and a peer that wants the same effect calls `ctx.scroll.scrollToBottom`
|
|
21
|
+
* directly.
|
|
22
|
+
*
|
|
23
|
+
* Its job is resuming FOLLOW, not just moving the viewport, which is why it also
|
|
24
|
+
* acts when the reader is already at the bottom but holding (an ED3 clamp leaves
|
|
25
|
+
* exactly that state). The jump is smooth unless the user asked for reduced
|
|
26
|
+
* motion, and it is idempotent, so binding both `pointerdown` (touch presses feel
|
|
27
|
+
* immediate) and `click` (keyboard activation) is safe. Teardown unsubscribes and
|
|
28
|
+
* removes the button. */
|
|
15
29
|
export function scrollToBottom(): TerminalFeature {
|
|
16
30
|
return {
|
|
17
31
|
name: "scrollToBottom",
|
|
@@ -151,9 +151,9 @@ export function createAttention(env: AttentionEnv): AttentionSurfaces {
|
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
/** iconVariantHref rewrites an icon URL to its variant, by the convention
|
|
155
|
-
*
|
|
156
|
-
*
|
|
154
|
+
/** iconVariantHref rewrites an icon URL to its variant, by the naming convention
|
|
155
|
+
* the variant assets must follow (they are the consuming app's own files, served
|
|
156
|
+
* alongside its icons rather than shipped by this library): the `favicon` token
|
|
157
157
|
* of the filename gains `-<variant>`, so `/favicon.svg` becomes
|
|
158
158
|
* `/favicon-input.svg` and `/favicon-32x32.png` becomes
|
|
159
159
|
* `/favicon-input-32x32.png`. The extension is preserved, so each link keeps
|
|
@@ -161,7 +161,7 @@ export function createAttention(env: AttentionEnv): AttentionSurfaces {
|
|
|
161
161
|
*
|
|
162
162
|
* A URL whose filename does not start with `favicon` is returned unchanged,
|
|
163
163
|
* which leaves that link alone rather than pointing it at a 404. Exported for
|
|
164
|
-
* the test that pins this against
|
|
164
|
+
* the test that pins this against that naming convention. */
|
|
165
165
|
export function iconVariantHref(href: string, variant: string): string | null {
|
|
166
166
|
const match = /(^|\/)favicon(?=[-.])/.exec(href);
|
|
167
167
|
if (!match) {
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
/**
|
|
2
|
+
* tabs feature: multiple independent terminals over the one kernel.
|
|
3
|
+
* It owns the session set (GET/POST/DELETE
|
|
4
|
+
* /api/sessions), a per-tab LineStore switching cache, the reconnect-on-switch
|
|
5
|
+
* swap, and the tab chrome on both form factors: the desktop top-bar strip and
|
|
6
|
+
* the mobile bottom-switcher + modal overview sheet. The kernel drives one
|
|
7
|
+
* active session; switching re-points the renderer at the next tab's cached
|
|
8
|
+
* store (ctx.render.bind) and asks the kernel to reconnect the terminal WS to it
|
|
9
|
+
* (ctx.notifySwitch), so the last-known screen paints instantly and the
|
|
10
|
+
* background delta arrives after.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
10
14
|
|
|
11
15
|
import { modes } from "@cplieger/web-terminal-engine";
|
|
12
16
|
import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/types.js";
|
|
@@ -102,6 +106,20 @@ const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
|
|
|
102
106
|
// the shared point-anchored positioner (menu-position.ts), shared with the
|
|
103
107
|
// terminal context menu (formerly two hand-synced copies of the same math).
|
|
104
108
|
|
|
109
|
+
/** The value a peer feature or a host reads through `ctx.use(tabs(...))`: the
|
|
110
|
+
* session set as commands, plus a snapshot of the strip.
|
|
111
|
+
*
|
|
112
|
+
* `create` and `close` are server round trips, and NEITHER rejects: a refusal is
|
|
113
|
+
* reported to the user as a toast and the promise resolves anyway, so awaiting one
|
|
114
|
+
* tells you the attempt finished and not that it succeeded (read `list` for that).
|
|
115
|
+
* Both are safe to call more than once per gesture — `create` shares an in-flight
|
|
116
|
+
* create, so the duplicate activation an iPad trackpad delivers opens one terminal
|
|
117
|
+
* rather than two. `switchTo` is local and synchronous, and ignores an unknown id.
|
|
118
|
+
*
|
|
119
|
+
* `list` is a snapshot, not a live view — it does not update, so re-read it rather
|
|
120
|
+
* than holding onto the array, and treat it as display data: the tab a user is
|
|
121
|
+
* looking at is this feature's own state and mutating it goes through the members
|
|
122
|
+
* above. */
|
|
105
123
|
export interface TabsApi {
|
|
106
124
|
/** Spawn a fresh session and switch to it. Calls made while a create is in
|
|
107
125
|
* flight share that create, so one gesture opens exactly one terminal. */
|
|
@@ -114,6 +132,14 @@ export interface TabsApi {
|
|
|
114
132
|
list(): readonly { id: string; title: string; active: boolean }[];
|
|
115
133
|
}
|
|
116
134
|
|
|
135
|
+
/** Options for the tabs feature.
|
|
136
|
+
*
|
|
137
|
+
* The two feature-valued members (`activityMonitor`, `keyboardToggle`) must be the
|
|
138
|
+
* SAME values the composition includes and must be ordered before tabs in the
|
|
139
|
+
* feature list, since `ctx.use` only resolves a peer the kernel has already set
|
|
140
|
+
* up. Both are optional and their absence degrades rather than fails: no monitor
|
|
141
|
+
* means polling instead of the status SSE, no toolbar means no keyboard button in
|
|
142
|
+
* the mobile bar. `buildTabbed` in presets/tabbed.ts is the worked example. */
|
|
117
143
|
export interface TabsOptions {
|
|
118
144
|
/** REST base for the session API (default "/api/sessions"). */
|
|
119
145
|
apiBase?: string;
|
|
@@ -149,10 +175,10 @@ export interface TabsOptions {
|
|
|
149
175
|
* `link[rel=icon]` whose filename starts with `favicon`, three variants must
|
|
150
176
|
* be served alongside it with `-input`, `-done` and `-alert` inserted after
|
|
151
177
|
* that token (`/favicon.svg` needs `/favicon-input.svg`; `/favicon-32x32.png`
|
|
152
|
-
* needs `/favicon-input-32x32.png`). Generate them with
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
178
|
+
* needs `/favicon-input-32x32.png`). Generate them with whatever tooling you
|
|
179
|
+
* like as long as it writes exactly those names, and assert their presence in
|
|
180
|
+
* the APP's own tests — the library cannot check a file it does not ship, and
|
|
181
|
+
* a missing variant is a blank tab icon.
|
|
156
182
|
*
|
|
157
183
|
* Not every platform honours it: Safari caches the first icon it fetched and
|
|
158
184
|
* ignores later changes, and an installed app has no tab icon at all. The
|
|
@@ -321,6 +347,20 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
|
321
347
|
}
|
|
322
348
|
}
|
|
323
349
|
|
|
350
|
+
/** Build the tabs feature.
|
|
351
|
+
*
|
|
352
|
+
* Requires a server that speaks the session API — `GET`/`POST`/`DELETE` on
|
|
353
|
+
* `/api/sessions`, `?session=<id>` on the WebSocket, and ideally the status SSE.
|
|
354
|
+
* It registers itself as the kernel's `sessionOwner`, so it and not the kernel
|
|
355
|
+
* resolves the session the first connect attaches to. At most one feature may claim
|
|
356
|
+
* that role, and `createTerminal` throws before any DOM work when two do.
|
|
357
|
+
*
|
|
358
|
+
* The arrangement of the strip is SERVER state (the `order` field), while the
|
|
359
|
+
* active tab and the dismissed notification cues are per-viewer and live in
|
|
360
|
+
* `localStorage` — so a phone reordering tabs moves them on the desktop, and
|
|
361
|
+
* switching tabs on one does not move the other. Teardown removes all chrome,
|
|
362
|
+
* releases the per-tab caches, and leaves the sessions themselves running on the
|
|
363
|
+
* server; closing a tab is what kills a process. */
|
|
324
364
|
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
325
365
|
const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
|
|
326
366
|
const presumeReports = opts.presumeReports ?? false;
|
|
@@ -3544,9 +3584,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3544
3584
|
}
|
|
3545
3585
|
|
|
3546
3586
|
// --- Event wiring ---
|
|
3547
|
-
// No input observer. The ENGINE
|
|
3548
|
-
//
|
|
3549
|
-
// does per-keystroke title work in the browser, and the name is identical for
|
|
3587
|
+
// No input observer. The ENGINE owns a session's name, so no preset does
|
|
3588
|
+
// per-keystroke title work in the browser, and the name is identical for
|
|
3550
3589
|
// every client attached to the session — including one that attaches later.
|
|
3551
3590
|
// Observe (never consume) keydowns to detect a physical keyboard: a
|
|
3552
3591
|
// hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
|
|
@@ -309,7 +309,8 @@ export function worseCue(a: CueStatus | "", b: CueStatus | ""): CueStatus | "" {
|
|
|
309
309
|
|
|
310
310
|
/** The icon variant a cue paints, which is NOT one per status: `crashed` and
|
|
311
311
|
* `failed` both render --status-failed, so they share one asset. An app ships
|
|
312
|
-
* one icon per name returned here (see .
|
|
312
|
+
* one icon per name returned here (see TabsOptions.attentionIcons for the
|
|
313
|
+
* naming contract those files must satisfy). */
|
|
313
314
|
export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
|
|
314
315
|
if (status === "input" || status === "done") {
|
|
315
316
|
return status;
|
|
@@ -532,8 +533,8 @@ export interface Tab {
|
|
|
532
533
|
* session (the boot double-create bug). */
|
|
533
534
|
born: number;
|
|
534
535
|
/** The title the SERVER resolved for this session: its pinned name, else the
|
|
535
|
-
*
|
|
536
|
-
*
|
|
536
|
+
* program's OSC 0/2 window title, else a title the host pushed through
|
|
537
|
+
* SetSessionTitle, else the engine's own foreground-process/cwd inference.
|
|
537
538
|
* Possibly empty only against an engine that has none of those. The displayed
|
|
538
539
|
* label adds a numbered fallback and de-duplication (see relabelAll in
|
|
539
540
|
* index.ts). */
|
|
@@ -603,9 +604,9 @@ export interface Tab {
|
|
|
603
604
|
* there is one, otherwise the title the SERVER resolved.
|
|
604
605
|
*
|
|
605
606
|
* Only two rungs, because the engine now owns every automatic source and folds
|
|
606
|
-
* them into `title` in precedence order (pinned,
|
|
607
|
-
*
|
|
608
|
-
* inference). A client that re-implemented that ladder could only disagree with
|
|
607
|
+
* them into `title` in precedence order (pinned, the program's OSC window title,
|
|
608
|
+
* a title the host pushed through SetSessionTitle, then its own
|
|
609
|
+
* foreground-process/cwd inference). A client that re-implemented that ladder could only disagree with
|
|
609
610
|
* the server and with every other client.
|
|
610
611
|
*
|
|
611
612
|
* The pin is still checked here, even though the server also folds it into
|
|
@@ -54,6 +54,18 @@ const DEFAULT_PREFIX = "wt.scrollback.";
|
|
|
54
54
|
*/
|
|
55
55
|
const DEFAULT_MAX_BYTES = 512 * 1024;
|
|
56
56
|
|
|
57
|
+
/** Options for `localScrollbackStorage`.
|
|
58
|
+
*
|
|
59
|
+
* Four of the five are the kernel's policy rather than this store's: `maxAgeMs`,
|
|
60
|
+
* `lines` and `saveIntervalMs` are passed straight through on the returned
|
|
61
|
+
* `ScrollbackPersistence`, so the store's own sweep and the kernel's load-time age
|
|
62
|
+
* check cannot disagree about a number a caller set once. `maxBytes` is this
|
|
63
|
+
* store's alone, since only it can enumerate what it owns.
|
|
64
|
+
*
|
|
65
|
+
* Every value is validated as a positive integer and an invalid one falls back to
|
|
66
|
+
* the default rather than being clamped. Sizing `maxBytes` is a decision ABOUT THE
|
|
67
|
+
* HOST APPLICATION, not about the terminal: this competes for the origin's
|
|
68
|
+
* `localStorage` quota with everything else the page stores. */
|
|
57
69
|
export interface LocalScrollbackStorageOptions {
|
|
58
70
|
/** Key prefix (default `"wt.scrollback."`). */
|
|
59
71
|
prefix?: string;
|
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The copy of the "terminal did not start" recovery surface, as DATA.
|
|
3
|
+
*
|
|
4
|
+
* Why this is a module of its own rather than string literals inside the
|
|
5
|
+
* renderer: a full-page consumer needs the same words in a place no module can
|
|
6
|
+
* reach. Its HTML carries an inline bootstrap watchdog whose entire job is to
|
|
7
|
+
* report "the JS bundle never ran" -- a rung below `import`, by definition, or
|
|
8
|
+
* it could not detect its own failure case. That watchdog cannot import these
|
|
9
|
+
* strings at runtime, so a consumer's BUILD substitutes them into its HTML
|
|
10
|
+
* instead, and the words survive as one declaration rather than three
|
|
11
|
+
* hand-agreed copies.
|
|
12
|
+
*
|
|
13
|
+
* This file therefore imports nothing and touches no DOM, so a Node build script
|
|
14
|
+
* can read it without loading the kernel. It is exported at the package root and
|
|
15
|
+
* at the "./startup-copy" subpath for exactly that use.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
17
19
|
|
|
18
20
|
/** The words the recovery surface shows, whichever startup rung failed.
|
|
19
21
|
*
|
|
@@ -1,36 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The names that cross this package's boundary as STRINGS, published as data.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces of this library are addressed by name rather than by type, so no
|
|
5
|
+
* compiler on either side of the boundary checks them:
|
|
6
|
+
*
|
|
7
|
+
* - `theme` is `Readonly<Record<string, string>>`. The kernel copies every key
|
|
8
|
+
* onto the terminal root verbatim, so a consumer that names a token this
|
|
9
|
+
* library renamed or retired gets a live declaration nothing reads: no
|
|
10
|
+
* error, no warning, just the library's own defaults where the consumer's
|
|
11
|
+
* brand was meant to be.
|
|
12
|
+
* - The loading overlay is the CONSUMER's element (it must paint before this
|
|
13
|
+
* module loads), styled by `css/page.css` purely because the markup opts in
|
|
14
|
+
* by class. Those classes are usually hardcoded in a static HTML file no
|
|
15
|
+
* compiler reads at all.
|
|
16
|
+
*
|
|
17
|
+
* Both are published as data, so they can be ASSERTED against: a consumer can
|
|
18
|
+
* check its own theme keys and its own overlay markup against these exports in
|
|
19
|
+
* its own test, the same way it can check its bootstrap watchdog's wording
|
|
20
|
+
* against `STARTUP_FAILURE_COPY` on the `./startup-copy` entrypoint. This file
|
|
21
|
+
* imports nothing and touches no DOM, so a test or a Node build script can read
|
|
22
|
+
* it without loading the kernel.
|
|
23
|
+
*
|
|
24
|
+
* These lists are HAND-WRITTEN on purpose. They are the public contract — a
|
|
25
|
+
* decision about what this library promises to support — not a fact derived
|
|
26
|
+
* from the stylesheets. What IS derived from the stylesheets is the
|
|
27
|
+
* verification that the promise holds: an internal test generates the
|
|
28
|
+
* declared/referenced token inventory from the CSS itself and fails if any
|
|
29
|
+
* token named here is not both DECLARED and READ by a rule. A token that is
|
|
30
|
+
* declared but never read is the silent-no-op case above, reached from the
|
|
31
|
+
* library's side instead of the consumer's, and no hand-maintained list can
|
|
32
|
+
* catch it.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
34
36
|
|
|
35
37
|
/** CSS custom properties a consumer may set through `CreateTerminalOptions.theme`.
|
|
36
38
|
*
|
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* presetAgentTabbed: the tabbed composition tuned for an agent shell.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
2
6
|
|
|
3
7
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
4
8
|
import { type TabbedPresetOptions, buildTabbed } from "./tabbed.js";
|
|
5
9
|
|
|
6
|
-
/** Tabbed UI for an agent shell
|
|
7
|
-
*
|
|
10
|
+
/** Tabbed UI for an agent shell. Same features as presetTabbed, tuned for
|
|
11
|
+
* sessions that ARE agents:
|
|
8
12
|
* - `presumeReports`: every session's program is an agent that WILL report
|
|
9
13
|
* OSC 9;4 progress, so the idle activity dot shows from tab creation
|
|
10
14
|
* instead of popping in seconds later when the agent has booted far enough
|
|
11
15
|
* to first report; the server's sticky reportsActivity flag then merely
|
|
12
16
|
* confirms. (presetTabbed keeps the evidence-driven reveal instead: a
|
|
13
17
|
* plain shell never grows a meaningless dot.)
|
|
14
|
-
*
|
|
15
|
-
* status classifier that maps OSC 9 notifications to done/needs-input
|
|
16
|
-
*
|
|
17
|
-
* enables because its program's own window title is not worth showing. */
|
|
18
|
+
* One agent-vs-generic distinction lives SERVER-side rather than here: the
|
|
19
|
+
* status classifier that maps OSC 9 notifications to done/needs-input. Tab
|
|
20
|
+
* names are server-side too but are not agent-specific — see buildTabbed. */
|
|
18
21
|
export function presetAgentTabbed(opts: TabbedPresetOptions = {}): TerminalFeature<unknown>[] {
|
|
19
22
|
return buildTabbed(true, opts);
|
|
20
23
|
}
|
package/src/presets/single.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* presetSingle: the single-pane desktop composition. Its own entry module so a
|
|
3
|
+
* consumer importing "@cplieger/web-terminal-ui/presets/single" pulls in ONLY
|
|
4
|
+
* this feature graph — none of the toolbar/tabs modules (the ./presets barrel
|
|
5
|
+
* statically reaches everything; these per-preset entries are the
|
|
6
|
+
* graph-minimal imports).
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
6
10
|
|
|
7
11
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
8
12
|
import { clipboard } from "../features/clipboard.js";
|
package/src/presets/tabbed.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* presetTabbed (and the shared tabbed composition builder): the full
|
|
3
|
+
* multi-session UI. Requires a server that speaks the session API
|
|
4
|
+
* (`/api/sessions`, `/ws?session=`, and the status SSE `/api/sessions/events`),
|
|
5
|
+
* such as `web-terminal-server` or `web-terminal-kiro`.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
5
9
|
|
|
6
10
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
7
11
|
import { mobileToolbar } from "../features/mobile-toolbar.js";
|
|
@@ -18,7 +22,7 @@ export interface TabbedPresetOptions {
|
|
|
18
22
|
/** Swap the page's icon links to a status variant while a background session
|
|
19
23
|
* wants the user. Off by default, and enabling it is a promise that the
|
|
20
24
|
* variant assets are served — see TabsOptions.attentionIcons for the naming
|
|
21
|
-
* contract
|
|
25
|
+
* contract those files must satisfy. */
|
|
22
26
|
attentionIcons?: boolean;
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -33,10 +37,9 @@ export interface TabbedPresetOptions {
|
|
|
33
37
|
* via ctx.use. Shared by presetTabbed and presetAgentTabbed (agent-tabbed.ts),
|
|
34
38
|
* which now differ only in presumed activity reporting (presumeReports).
|
|
35
39
|
*
|
|
36
|
-
* Titles are
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* foreground-process/cwd inference — and both presets render what it reports. A
|
|
40
|
+
* Titles are not a preset concern. The ENGINE resolves a session's name — its
|
|
41
|
+
* pinned name, a title its host pushed, the program's OSC window title, or its
|
|
42
|
+
* own foreground-process/cwd inference — and both presets render what it reports. A
|
|
40
43
|
* browser that re-derived any of that could only disagree with the server and
|
|
41
44
|
* with every other client attached to the same session. */
|
|
42
45
|
export function buildTabbed(
|
package/src/presets/touch.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* presetTouch: the touch-first composition (single + the on-screen key
|
|
3
|
+
* toolbar). Its own entry module so a touch consumer's delivered import graph
|
|
4
|
+
* excludes the tabs/activity/animations modules entirely (an embedded panel
|
|
5
|
+
* imports exactly this).
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
5
9
|
|
|
6
10
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
7
11
|
import { mobileToolbar } from "../features/mobile-toolbar.js";
|
package/src/presets.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Feature presets: the barrel. Presets are plain feature-array factories, so
|
|
3
|
+
* they are spreadable and editable: a consumer can drop or add a feature with a
|
|
4
|
+
* filter/spread. This barrel statically imports EVERY preset (and therefore
|
|
5
|
+
* every feature); a consumer that wants the minimal delivered import graph for
|
|
6
|
+
* its composition imports the per-preset entry module instead —
|
|
7
|
+
* "@cplieger/web-terminal-ui/presets/single" | "/presets/touch" |
|
|
8
|
+
* "/presets/tabbed" | "/presets/agent-tabbed" — or hand-picks individual
|
|
9
|
+
* features from "@cplieger/web-terminal-ui/features/<name>".
|
|
10
|
+
*
|
|
11
|
+
* The tabbed presets (presetTabbed / presetAgentTabbed) share the same feature
|
|
12
|
+
* set (both include the activity monitor, which drives the per-tab activity
|
|
13
|
+
* dot). They differ in one flag, `presumeReports`: presetAgentTabbed presumes
|
|
14
|
+
* every session's program is an agent that WILL report OSC 9;4 progress, so the
|
|
15
|
+
* idle dot shows from tab creation instead of appearing seconds later on the
|
|
16
|
+
* first report, while presetTabbed keeps the evidence-driven reveal so a plain
|
|
17
|
+
* shell never grows a meaningless dot. Session NAMES are not a preset concern:
|
|
18
|
+
* the engine resolves them, including a title its host pushes.
|
|
19
|
+
* The generic-vs-agent STATUS distinction stays server-side — an agent server
|
|
20
|
+
* sets a classifier mapping OSC 9 notifications to done/needs-input.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
18
24
|
|
|
19
25
|
export { presetSingle } from "./presets/single.js";
|
|
20
26
|
export { presetTouch } from "./presets/touch.js";
|