@cplieger/web-terminal-ui 4.3.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,12 +128,15 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
128
128
  keeps clean, label-only tabs. Requires a server that speaks the session API
129
129
  (`/api/sessions` and `/ws?session=`), such as `web-terminal-server`.
130
130
  - `presetAgentTabbed()` — the same feature set as `presetTabbed()` (activity
131
- monitor included), but with `preferInputTitle`: each tab's label follows the
132
- latest submitted line (persisted server-side and recovered on reload) and the
133
- program's OSC 0/2 title is ignored — for an agent shell such as `web-terminal-kiro`
134
- whose program emits a non-empty but useless OSC title. Its status dots
135
- (idle / working / done / needs-input) come from the same activity monitor,
136
- driven by the server's status SSE and its OSC-9 classifier.
131
+ monitor included), tuned for an agent shell such as `web-terminal-kiro`: with
132
+ `preferInputTitle`, each tab's label follows the latest submitted line
133
+ (persisted server-side and recovered on reload) and the program's non-empty
134
+ but useless OSC 0/2 title is ignored; and with `presumeReports`, the idle
135
+ activity dot shows from tab creation instead of waiting for the session's
136
+ first OSC 9;4 signal — every session is an agent, so there is nothing to
137
+ prove. Its status dots (idle / working / done / needs-input) come from the
138
+ same activity monitor, driven by the server's status SSE and its OSC-9
139
+ classifier.
137
140
 
138
141
  ### Options
139
142
 
package/css/30-tabs.css CHANGED
@@ -357,8 +357,11 @@
357
357
  tabs feature adds .wt-reports to that dot (paintStatusDot). A program that
358
358
  emits no OSC 9 signal (a plain bash/sh) has no activity concept, so its tabs
359
359
  stay clean, label-only; a progress-reporting agent (kiro-cli, Claude Code)
360
- lights up. The element stays in the DOM either way (JS sets data-status
361
- regardless), so revealing it is a pure CSS switch. */
360
+ lights up. An agent-shell consumer can PRESUME the report instead (tabs'
361
+ presumeReports option, on in presetAgentTabbed): .wt-reports is then set
362
+ from tab creation, so the idle dot shows immediately rather than popping in
363
+ after the agent's first signal. The element stays in the DOM either way (JS
364
+ sets data-status regardless), so revealing it is a pure CSS switch. */
362
365
  :where(.wt-root) .wt-status-dot {
363
366
  display: none;
364
367
  width: 9px;
@@ -391,19 +394,43 @@
391
394
  border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
392
395
  }
393
396
 
394
- /* Agent working: solid pulsing dot (--status-working; pale lime by default —
395
- its high lightness keeps it clear on every tab state) wearing the same 2px
396
- ring as input, so the two agent-turn states share one silhouette: a live
397
- turn pulses (dot + ring together), a turn blocked on the user is that same
398
- silhouette frozen and recolored (input, below). Motion separates working
399
- from input; the ring separates both from done's bare disc. Server-side this
400
- is driven by the OSC 9;4 progress signal, so it tracks agent activity, not
401
- the user typing at the prompt. */
397
+ /* Agent working: two INDEPENDENTLY animated layers on one element make the
398
+ "alive" read (the status-badge "processing" idiom — Ant Design's processing
399
+ badge, the classic CSS live-indicator ripple): the solid disc keeps full
400
+ opacity and gently GLOWS (its colors brighten mid-beat), while a ::after
401
+ ring — born exactly on the static 2px ring — RIPPLES outward and dissolves,
402
+ re-emitted each cycle. The static ring itself never moves, so working keeps
403
+ input's exact rest silhouette (a live turn ripples; a turn blocked on the
404
+ user is that same silhouette frozen — see input below). Motion separates
405
+ working from input; the ring separates both from done's bare disc. The old
406
+ single-layer pulse faded disc + ring together (opacity on the element),
407
+ which read as a blink and coupled the layers — an element's opacity always
408
+ multiplies onto its pseudos, so true independence requires animating disc
409
+ COLORS, not element opacity. Server-side this is driven by the OSC 9;4
410
+ progress signal, so it tracks agent activity, not the user typing at the
411
+ prompt. */
402
412
  :where(.wt-root) .wt-status-dot[data-status="working"] {
413
+ position: relative;
403
414
  background: var(--status-working);
404
415
  border-color: var(--status-working);
405
416
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
406
- animation: wt-pulse 1.2s ease-in-out infinite;
417
+ animation: wt-working-glow 1.2s ease-in-out infinite;
418
+ }
419
+
420
+ /* The ripple layer: at scale(1) its 2px border overlays the static ring's
421
+ 9->13px band exactly (inset -2px), then the wave detaches, expanding and
422
+ fading. transform + opacity only, so it composites without repaints. It
423
+ exists only in the working state — no stale layer to pin when a dot changes
424
+ status. (The switcher notification dot never shows working — input/done
425
+ only — so its position: absolute placement never meets this rule's
426
+ position: relative.) */
427
+ :where(.wt-root) .wt-status-dot[data-status="working"]::after {
428
+ content: "";
429
+ position: absolute;
430
+ inset: -2px;
431
+ border: 2px solid var(--status-working);
432
+ border-radius: 50%;
433
+ animation: wt-working-ripple 1.2s ease-in-out infinite;
407
434
  }
408
435
 
409
436
  /* Agent done / ready: solid disc (--status-done), no motion. The default green
@@ -436,39 +463,58 @@
436
463
  opacity: 0.5;
437
464
  }
438
465
 
439
- @keyframes wt-pulse {
466
+ /* The disc's glow beat: base -> brighter -> base, then a rest until the next
467
+ cycle. Colors are set at EVERY frame, which both drives the glow and pins
468
+ the paint — a dot entering working in the same frame input/done ends can
469
+ never re-pulse the previously rasterized paint (the hazard the old
470
+ opacity-only pulse had to pin box-shadow against). Element opacity stays 1
471
+ throughout; see the layer-independence note on the working rule. */
472
+ @keyframes wt-working-glow {
440
473
  0%,
474
+ 70%,
441
475
  100% {
442
- /* Pin the animated layer's paint as well as its opacity. When working begins
443
- in the same frame that input/done ends, an opacity-only animation can
444
- pulse the previously rasterized input/done paint (the input ring
445
- included, hence the box-shadow pin). */
446
476
  background-color: var(--status-working);
447
477
  border-color: var(--status-working);
448
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
449
- opacity: 1;
450
478
  }
451
479
 
452
- 50% {
453
- background-color: var(--status-working);
454
- border-color: var(--status-working);
455
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
480
+ 35% {
481
+ background-color: color-mix(in oklch, var(--status-working) 78%, #fff);
482
+ border-color: color-mix(in oklch, var(--status-working) 78%, #fff);
483
+ }
484
+ }
485
+
486
+ /* The ripple wave: one emission per cycle, launching off the static ring and
487
+ dissolving by ~2.1x. Opacity starts at the static ring's own 40% weight so
488
+ the wave reads as the ring letting go of a copy, not a new brighter object. */
489
+ @keyframes wt-working-ripple {
490
+ 0% {
491
+ transform: scale(1);
456
492
  opacity: 0.4;
457
493
  }
494
+
495
+ 100% {
496
+ transform: scale(2.1);
497
+ opacity: 0;
498
+ }
458
499
  }
459
500
 
460
- /* Reduced-motion (or the animations feature off): drop the pulse, keep color. */
501
+ /* Reduced-motion: drop the working motion (glow + ripple), keep color. */
461
502
  @media (prefers-reduced-motion: reduce) {
462
503
  :where(.wt-root) .wt-status-dot[data-status="working"] {
463
504
  animation: none;
464
- /* The pulse is what separates working from input's frozen silhouette; with
465
- motion removed, punch an inner hole (dot -> donut) so working differs
466
- from input (solid ringed disc) and done (bare disc) by shape, not hue
467
- alone (WCAG 1.4.1). The outer ring stays. */
505
+ /* Motion is what separates working from input's frozen silhouette; with
506
+ it removed, punch an inner hole (dot -> donut) so working differs from
507
+ input (solid ringed disc) and done (bare disc) by shape, not hue alone
508
+ (WCAG 1.4.1). The outer ring stays. */
468
509
  box-shadow:
469
510
  inset 0 0 0 2px var(--bg),
470
511
  0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
471
512
  }
513
+
514
+ /* The ripple layer disappears entirely (not just paused mid-wave). */
515
+ :where(.wt-root) .wt-status-dot[data-status="working"]::after {
516
+ content: none;
517
+ }
472
518
  }
473
519
 
474
520
  /* ===== Catching-up cue (tabs feature, sections 12/13) =====
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "4.3.1",
3
+ "version": "4.4.0",
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": "GPL-3.0-or-later",
@@ -100,6 +100,13 @@ export interface TabsOptions {
100
100
  * reload), and the unreliable OSC title is ignored entirely. presetAgentTabbed
101
101
  * enables this. */
102
102
  preferInputTitle?: boolean;
103
+ /** Presume every session reports activity (an agent shell, where the program
104
+ * always emits OSC 9;4 progress): each tab's dot is visible as idle from
105
+ * creation instead of popping in seconds later when the agent has booted
106
+ * far enough to first report — the server's sticky reportsActivity flag
107
+ * then merely confirms. Default false (evidence-driven reveal: a plain
108
+ * shell keeps clean, label-only tabs). presetAgentTabbed enables this. */
109
+ presumeReports?: boolean;
103
110
  }
104
111
 
105
112
  // looksLikeHardwareKey reports whether a keydown could only have come from a
@@ -135,6 +142,12 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
135
142
  export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
136
143
  const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
137
144
  const preferInputTitle = opts.preferInputTitle ?? false;
145
+ const presumeReports = opts.presumeReports ?? false;
146
+ // reportsOf floors the server's per-session reportsActivity flag with the
147
+ // consumer's presumption (see TabsOptions.presumeReports): an agent shell
148
+ // shows the idle dot from tab creation instead of waiting out the agent's
149
+ // boot-to-first-OSC-9;4 window.
150
+ const reportsOf = (reports?: boolean): boolean => presumeReports || (reports ?? false);
138
151
  // The session REST client (model.ts): every call timeout-bounded, list
139
152
  // shape-guarded, title persistence fire-and-forget.
140
153
  const api = createSessionAPI(apiBase);
@@ -912,7 +925,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
912
925
  if (!label || !dot || !close) {
913
926
  throw new Error("web-terminal-ui: tab chrome missing parts");
914
927
  }
915
- paintStatusDot(dot, info.status, info.reportsActivity ?? false);
928
+ paintStatusDot(dot, info.status, reportsOf(info.reportsActivity));
916
929
  const aria = tablist.registerTab(el);
917
930
  // Append to the scroller's end: the tab list is ALL the scroller holds
918
931
  // (the "+" and keyboard button are fixed bar items outside it).
@@ -941,7 +954,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
941
954
  aria,
942
955
  scrollTop: 0,
943
956
  following: true,
944
- reports: info.reportsActivity ?? false,
957
+ reports: reportsOf(info.reportsActivity),
945
958
  };
946
959
  // Set an initial label immediately (relabelAll refines it with de-dup
947
960
  // once the tab is in tabList and syncChrome runs).
@@ -1669,7 +1682,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1669
1682
  info.status,
1670
1683
  info.title,
1671
1684
  info.clientTitle,
1672
- info.reportsActivity ?? false,
1685
+ reportsOf(info.reportsActivity),
1673
1686
  );
1674
1687
  }
1675
1688
  // A tab the server no longer lists was reaped/closed elsewhere (or
@@ -1703,7 +1716,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1703
1716
  // SSE-adopted tab without its persisted title — in preferInputTitle
1704
1717
  // mode the label then fell back to "New tab" on reload.
1705
1718
  adoptSession(s);
1706
- applyStatus(s.id, s.status, s.title, s.clientTitle, s.reportsActivity ?? false);
1719
+ applyStatus(s.id, s.status, s.title, s.clientTitle, reportsOf(s.reportsActivity));
1707
1720
  ensureActive();
1708
1721
  syncChrome();
1709
1722
  });
@@ -4,12 +4,19 @@ import type { TerminalFeature } from "../kernel/types.js";
4
4
  import { buildTabbed } from "./tabbed.js";
5
5
 
6
6
  /** Tabbed UI for an agent shell (web-terminal-kiro). Same features as
7
- * presetTabbed, but with `preferInputTitle`: the agent's program (kiro-cli)
8
- * emits a non-empty but useless OSC 0/2 title, so each tab's label follows the
9
- * latest submitted line (persisted server-side and recovered on reload) and
10
- * the OSC title is ignored. The agent-vs-generic status distinction remains
11
- * server-side (a status classifier mapping OSC 9 notifications to
12
- * done/needs-input). */
7
+ * presetTabbed, tuned for sessions that ARE agents:
8
+ * - `preferInputTitle`: the agent's program (kiro-cli) emits a non-empty but
9
+ * useless OSC 0/2 title, so each tab's label follows the latest submitted
10
+ * line (persisted server-side and recovered on reload) and the OSC title
11
+ * is ignored.
12
+ * - `presumeReports`: every session's program is an agent that WILL report
13
+ * OSC 9;4 progress, so the idle activity dot shows from tab creation
14
+ * instead of popping in seconds later when the agent has booted far enough
15
+ * to first report; the server's sticky reportsActivity flag then merely
16
+ * confirms. (presetTabbed keeps the evidence-driven reveal instead: a
17
+ * plain shell never grows a meaningless dot.)
18
+ * The agent-vs-generic status distinction remains server-side (a status
19
+ * classifier mapping OSC 9 notifications to done/needs-input). */
13
20
  export function presetAgentTabbed(): TerminalFeature<unknown>[] {
14
21
  return buildTabbed(true);
15
22
  }
@@ -14,19 +14,26 @@ import { presetSingle } from "./single.js";
14
14
  * externally-driven mobile toolbar (its grid is opened from a keyboard button
15
15
  * in the tab bar, not its own toggle), the activity monitor (the status-SSE
16
16
  * data source), tabs wired to both, and animations. The activity dot reveals
17
- * itself per tab only when a session reports activity (OSC 9;4), so the
18
- * monitor is always included — a plain shell just never shows a dot. The
17
+ * itself per tab only when a session reports activity (OSC 9;4) — or from tab
18
+ * creation when the agent composition presumes it — so the monitor is always
19
+ * included; a plain shell under presetTabbed just never shows a dot. The
19
20
  * toolbar and monitor are ordered before tabs because tabs reads their APIs
20
21
  * via ctx.use. Shared by presetTabbed and presetAgentTabbed (agent-tabbed.ts),
21
- * which differ only in the title source. */
22
- export function buildTabbed(preferInputTitle: boolean): TerminalFeature<unknown>[] {
22
+ * which differ only in the agent-shell tuning: input-derived titles
23
+ * (preferInputTitle) + presumed activity reporting (presumeReports). */
24
+ export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
23
25
  const kb = mobileToolbar({ externalToggle: true });
24
26
  const monitor = activityMonitor();
25
27
  return [
26
28
  ...presetSingle(),
27
29
  kb,
28
30
  monitor,
29
- tabs({ keyboardToggle: kb, activityMonitor: monitor, preferInputTitle }),
31
+ tabs({
32
+ keyboardToggle: kb,
33
+ activityMonitor: monitor,
34
+ preferInputTitle: agentShell,
35
+ presumeReports: agentShell,
36
+ }),
30
37
  animations(),
31
38
  ];
32
39
  }