@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 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 last command submitted. The per-tab activity dot
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` and `/ws?session=`), such as `web-terminal-server`.
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`. With `preferInputTitle`, each tab's
148
- label follows the latest submitted line (persisted server-side and recovered
149
- on reload) and the program's OSC 0/2 title is ignored. With `presumeReports`,
150
- the idle activity dot shows from tab creation instead of waiting for the
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;
@@ -35,6 +35,7 @@
35
35
  font: inherit;
36
36
  cursor: pointer;
37
37
  touch-action: manipulation;
38
+ -webkit-user-select: none;
38
39
  user-select: none;
39
40
  }
40
41
 
@@ -111,6 +111,7 @@
111
111
  font-weight: 500;
112
112
  cursor: pointer;
113
113
  touch-action: manipulation;
114
+ -webkit-user-select: none;
114
115
  user-select: none;
115
116
  display: flex;
116
117
  align-items: center;
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 1.2s ease-in-out infinite;
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 1.2s linear infinite;
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: 2px;
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: -1px;
998
- border-radius: 4px;
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 70%,
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 1.2s ease-in-out infinite;
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 1.2s period so the two cues stay phase-locked;
1058
- change both or neither. */
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, same envelope as the dot's glow: rest, one bright
1080
- swell, rest. Opacity only — a colour keyframe here would reintroduce the
1081
- per-frame repaint this shape exists to avoid. */
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: 1;
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
- border-width: 3px;
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.0",
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
- // activityMonitor feature: subscribes to the server's status SSE
2
- // (/api/sessions/events via the engine's connectStatusStream) and exposes each
3
- // session's live status through its API (design sections 7, 22.4, 22.5). It is a
4
- // pure data source with no chrome of its own; the tabs feature consumes it via
5
- // ctx.use to render per-tab activity dots and drop exited/removed tabs. The
6
- // server pushes an initial snapshot on every (re)open — but the snapshot
7
- // carries no tombstones for sessions a REPLACEMENT server never knew (manager
8
- // restart), so the stream-open signal is surfaced via onStreamOpen and the
9
- // consumer (tabs) runs a one-shot GET /api/sessions reconcile there to drop
10
- // zombie tabs.
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
- // animations feature: opt-in motion for the chrome surfaces (design sections
2
- // 13, 22.4). Without it the chrome surfaces are instant (no transitions); with
3
- // it, and when the user has not asked for reduced motion, it enables the shared
4
- // animation vocabulary by marking the terminal root, which the feature
5
- // stylesheets key their transitions off. A few pieces of motion animate
6
- // unconditionally regardless of this feature (terminal-core cursor/SGR blink and
7
- // the mobile key toolbar open/close in 02-app.css, plus the working-status-dot
8
- // pulse in 05-tabs.css, its WCAG non-hue cue), though prefers-reduced-motion
9
- // still defeats all of it. It tracks prefers-reduced-motion live so toggling the
10
- // OS setting takes effect.
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
- // clipboard feature: copy/paste helpers, the desktop Ctrl+Shift+C/V shortcuts,
2
- // and OSC 52 mirroring (design section 22.4). Exposes a typed API so contextMenu
3
- // can offer Copy/Paste through it (ctx.use). With this feature absent, inbound
4
- // OSC 52 has no subscriber and is intentionally a no-op.
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
- // connectionBanner feature: renders the kernel's connection state as a visible
2
- // banner (design sections 22.3, 22.4). The state machine, the loading
3
- // lifecycle, and the give-up hook are kernel-owned; this feature is a pure
4
- // renderer subscribing to connection:state. It uses the kernel's single
5
- // announcer (ctx.announce) rather than spawning its own aria-live region.
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
- // contextMenu feature: the Copy / Select All / Paste menu for the terminal
2
- // surface (design section 22.4), rendered into the overlay region. Copy and Paste
3
- // need the clipboard feature (ctx.use), and Paste routes through the kernel's
4
- // sanitizing funnel. Escape-to-close goes through the kernel keydown intercept;
5
- // outside-click is a document gesture.
6
- //
7
- // WHY THIS MENU EXISTS
8
- // The terminal output is real DOM text, so the browser already does the hard
9
- // part: drag-select with a mouse, long-press word-select plus the OS copy callout
10
- // on touch. What no platform can offer is PASTE — the keyboard target is a 1x1
11
- // pointer-events:none textarea, so there is no editable surface under the pointer
12
- // for a native paste item to attach to. This menu is the paste path. It carries
13
- // Copy and Select All because a menu appearing where the platform's would have
14
- // appeared should not be missing them.
15
- //
16
- // THE MODEL: one owner per gesture, and nothing decided mid-gesture.
17
- //
18
- // Mouse / pen — `contextmenu` is an already-classified request for a menu:
19
- // preventDefault and open at the pointer. No timers, no heuristics, no
20
- // platform branches.
21
- //
22
- // Touch — the platform owns the press while the finger is down. We run no hold
23
- // timer against it, open nothing before it finishes, and never cancel its
24
- // gesture on WebKit. We classify ONCE, on `touchend`, when every fact is
25
- // settled: a single-finger, stationary press held past the tap ceiling
26
- // (kernel/gesture.ts, shared with the kernel's tap-to-focus so the two cannot
27
- // both claim one press) that selected nothing and did not start on a link is a
28
- // press the platform declined — so it is ours, and it is the paste path. A
29
- // press that produced a selection belongs to the OS callout; a press on a link
30
- // belongs to the platform's link preview; a shorter press is the kernel's tap.
31
- //
32
- // WHY AT RELEASE RATHER THAN DURING THE HOLD
33
- // Opening from a ~550ms hold timer put this feature in a race it cannot win: the
34
- // timer has to beat the platform's own long-press threshold and then GUESS
35
- // whether a selection is still coming (iOS 26 registers a word selection well
36
- // after 550ms). Four mechanisms existed only to referee that guess — a hit test
37
- // for glyphs under the finger, a selectionchange watch to retract a menu that
38
- // opened too early, a device sniff, and per-platform contextmenu branching,
39
- // because WebKit reads preventDefault on a touch contextmenu as "cancel every
40
- // remaining default of this gesture", the not-yet-registered selection included.
41
- // Deciding at release deletes all four: the outcome is observed, not predicted.
42
- //
43
- // It also fixes the symptom that prompted the rewrite. A touch long-press emits a
44
- // trailing click on release, and the swallow window that covers that click was
45
- // armed when the menu OPENED — ~550ms into the press, expiring 350ms later — so
46
- // holding a beat longer meant the release click landed as an outside click and
47
- // dismissed the menu the instant the finger lifted. Opened BY the release, the
48
- // window can only ever start at the release edge.
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
- // mobileToolbar feature: the on-screen key toolbar (Tab/Esc/arrows/Enter + a
2
- // collapse toggle and sticky-Ctrl) in the thumb-zone region (design section
3
- // 22.4). The engine's toolbar.bindMobileToolbar wires the buttons (DECCKM-aware
4
- // arrows, sticky-Ctrl state; engine >= v3, where the widget moved out of the
5
- // keyboard module); this feature builds the toolbar chrome, routes its
6
- // output through the kernel funnel, and registers sticky-Ctrl as an input
7
- // transform so a typed character is rewritten to its Ctrl byte when armed.
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
- // predictiveEcho feature: Mosh-style local echo (design section 22.4). Wires the
2
- // predict.ts mini-VT to the kernel input funnel: it advances a predicted cursor
3
- // on accepted input (an input observer) and owns the col-0 backspace brake (an
4
- // input transform that drops a lone DEL at the true origin, since the predicted
5
- // cursor is the authoritative position for that decision). It re-pushes the
6
- // predicted-cursor overlay on prediction changes and after each render flush.
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
- // scrollToBottom feature: a scroll-to-bottom control in the thumb-zone region,
2
- // shown only while the user has scrolled up (design section 22.4). The
3
- // scroll-state signal comes from the kernel's scroll:state event (the kernel
4
- // owns scroll.init); this feature just renders the affordance and drives
5
- // scroll.scrollToBottom.
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 the
155
- * asset generator writes (the fleet's .kiro/scripts/gen-attention-icons.py, which
156
- * serves every consuming app rather than living in this repo): the `favicon` token
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 the generator's own naming. */
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
- // tabs feature: multiple independent terminals over the one kernel (design
2
- // sections 5, 6, 12, 22.5). It owns the session set (GET/POST/DELETE
3
- // /api/sessions), a per-tab LineStore switching cache, the reconnect-on-switch
4
- // swap, and the tab chrome on both form factors: the desktop top-bar strip and
5
- // the mobile bottom-switcher + modal overview sheet. The kernel drives one
6
- // active session; switching re-points the renderer at the next tab's cached
7
- // store (ctx.render.bind) and asks the kernel to reconnect the terminal WS to it
8
- // (ctx.notifySwitch), so the last-known screen paints instantly and the
9
- // background delta arrives after.
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
- * `.kiro/scripts/gen-attention-icons.py`, which writes exactly those names, and
154
- * assert their presence in the APP's own tests — the library cannot check a
155
- * file it does not ship, and a missing variant is a blank tab icon.
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 derives a session's name from the input
3548
- // stream when its host asked for that (terminal.WithInputTitle), so no preset
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 .kiro/scripts/gen-attention-icons.py). */
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
- * input-derived name, else the program's OSC 0/2 window title, else a
536
- * client-pushed label, else the engine's own foreground-process/cwd inference.
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, input-derived, the program's OSC
607
- * window title, a client-pushed label, then its own foreground-process/cwd
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
- // The copy of the "terminal did not start" recovery surface, as DATA.
2
- //
3
- // Why this is a module of its own rather than string literals inside the
4
- // renderer: a full-page consumer needs the same words in a place no module can
5
- // reach. Its HTML carries an inline bootstrap watchdog whose entire job is to
6
- // report "the JS bundle never ran" -- a rung below `import`, by definition, or
7
- // it could not detect its own failure case. That watchdog cannot import these
8
- // strings at runtime, so a consumer's BUILD substitutes them into its HTML
9
- // instead, and the words survive as one declaration rather than three
10
- // hand-agreed copies (the pattern this replaced had web-terminal-kiro's app
11
- // code, its inline watchdog, and this library all restating them, kept in step
12
- // by comments asking the next reader to remember).
13
- //
14
- // This file therefore imports nothing and touches no DOM, so a Node build script
15
- // can read it without loading the kernel. It is exported at the package root and
16
- // at the "./startup-copy" subpath for exactly that use.
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
- // The names that cross this package's boundary as STRINGS, published as data.
2
- //
3
- // Two surfaces of this library are addressed by name rather than by type, so no
4
- // compiler on either side of the boundary checks them:
5
- //
6
- // - `theme` is `Readonly<Record<string, string>>`. The kernel copies every key
7
- // onto the terminal root verbatim, so a consumer that names a token this
8
- // library renamed or retired gets a live declaration nothing reads: no
9
- // error, no warning, just the library's own defaults where the consumer's
10
- // brand was meant to be.
11
- // - The loading overlay is the CONSUMER's element (it must paint before this
12
- // module loads), styled by `css/page.css` purely because the markup opts in
13
- // by class. Those classes are usually hardcoded in a static HTML file no
14
- // compiler reads at all.
15
- //
16
- // Both were previously prose — a doc comment on the option, a sentence in the
17
- // README — which a consumer could only agree with by remembering. Published as
18
- // data they can be ASSERTED against instead: web-terminal-kiro's app.test.ts
19
- // checks its theme keys and its overlay markup against these exports the same
20
- // way it already checks its bootstrap watchdog's wording against
21
- // STARTUP_FAILURE_COPY. Same reason, same shape: this file imports nothing and
22
- // touches no DOM, so a test or a Node build script can read it without loading
23
- // the kernel.
24
- //
25
- // These lists are HAND-WRITTEN on purpose. They are the public contract — a
26
- // decision about what this library promises to support — not a fact derived
27
- // from the stylesheets. What IS derived from the stylesheets is the
28
- // verification that the promise holds: `src/css-contract.node.test.ts` generates the
29
- // declared/referenced token inventory from the CSS itself and fails if any
30
- // token named here is not both DECLARED and READ by a rule. A token that is
31
- // declared but never read is the silent-no-op case above, reached from the
32
- // library's side instead of the consumer's, and no hand-maintained list can
33
- // catch it.
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
- // presetAgentTabbed: the tabbed composition tuned for an agent shell.
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 (web-terminal-kiro). Same features as
7
- * presetTabbed, tuned for sessions that ARE agents:
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
- * Two agent-vs-generic distinctions live SERVER-side rather than here: the
15
- * status classifier that maps OSC 9 notifications to done/needs-input, and the
16
- * input-derived session name (terminal.WithInputTitle), which the agent host
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
  }
@@ -1,8 +1,12 @@
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).
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";
@@ -1,7 +1,11 @@
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`.
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 and .kiro/scripts/gen-attention-icons.py, which writes them. */
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 no longer a preset concern. The ENGINE resolves a session's name —
37
- * its pinned name, an input-derived name when the host asked for one
38
- * (terminal.WithInputTitle), the program's OSC window title, or its own
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(
@@ -1,7 +1,11 @@
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).
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
- // 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>".
9
- //
10
- // The tabbed presets (presetTabbed / presetAgentTabbed) share the same feature
11
- // set (both include the activity monitor, whose per-tab dot reveals itself only
12
- // on OSC 9;4 progress, so a plain shell never shows one). They differ in the
13
- // title source: presetAgentTabbed sets preferInputTitle (the agent's program
14
- // emits a useless OSC title, so the label follows the latest submitted line),
15
- // while presetTabbed is OSC-first. The generic-vs-agent STATUS distinction stays
16
- // server-side — an agent server sets a classifier mapping OSC 9 notifications to
17
- // done/needs-input.
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";