@cplieger/web-terminal-ui 4.3.0 → 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
@@ -13,10 +13,10 @@
13
13
 
14
14
  :where(.wt-root) .wt-tab-bar {
15
15
  /* Absolute against .wt-root, like all chrome (identical geometry in
16
- wt-viewport mode; see 01-scope.css). The bar itself never scrolls: the
17
- tabs + "+" scroll inside .wt-tab-scroll (below), so the keyboard button —
18
- the bar's last flex item, outside the scroller — stays pinned at the right
19
- edge however many tabs are open. */
16
+ wt-viewport mode; see 01-scope.css). The bar itself never scrolls: only
17
+ the tabs scroll, inside .wt-tab-scroll (below); the "+" and the keyboard
18
+ button are fixed bar items after it, so both stay visible at the bar's
19
+ right edge however many tabs are open. */
20
20
  position: absolute;
21
21
  bottom: var(--safe-bottom);
22
22
  left: 0;
@@ -32,17 +32,22 @@
32
32
  z-index: 15;
33
33
  }
34
34
 
35
- /* The scrolling tab list inside the bar: the tabs plus the "+", which floats
36
- right of the last tab and scrolls with the list. Horizontal scrolling lives
37
- HERE, not on the bar, so the strip-end keyboard button never scrolls away.
38
- flex: 1 + min-width: 0 make it shrink to leave room for the keyboard button
39
- when that is shown (a wide touchscreen) and stretch to the full bar width
40
- when it is hidden (a fine-pointer desktop), so the tabs reclaim the space. */
35
+ /* The scrolling tab list inside the bar: the tabs and nothing else — the "+"
36
+ and the keyboard button are fixed bar siblings after it. Horizontal
37
+ scrolling lives HERE, not on the bar, so neither control ever scrolls away.
38
+ flex: 0 1 auto + min-width: 0 shrink-WRAP the list: while the tabs fit, the
39
+ scroller is exactly as wide as its content and the "+" trails the last tab
40
+ across the bar gap (visually identical to living in the list); once the
41
+ tabs overflow, the scroller caps at the space the fixed controls leave and
42
+ the "+" reads as pinned at the bar's right edge (with the keyboard button,
43
+ when shown on a wide touchscreen, anchored to its right). The tabs feature
44
+ also maps a vertical wheel over the bar to scrollLeft here — the strip has
45
+ no vertical scroll to spend it on. */
41
46
  :where(.wt-root) .wt-tab-scroll {
42
47
  display: flex;
43
48
  align-items: center;
44
49
  gap: var(--sp-1);
45
- flex: 1 1 auto;
50
+ flex: 0 1 auto;
46
51
  min-width: 0;
47
52
  overflow-x: auto;
48
53
  /* A wheel/trackpad/touch scroll that runs out of tabs must not chain into
@@ -164,9 +169,12 @@
164
169
  }
165
170
  }
166
171
 
167
- /* The add-tab (+) button: its hover box matches an active tab's fill and
168
- stretches to the strip height; the 2px strip gap (not a custom margin) spaces
169
- it from the last tab. Its glyph is an inline SVG. */
172
+ /* The add-tab (+) button: a fixed bar item OUTSIDE the tab scroller (it never
173
+ scrolls away with an overflowing list; the shrink-wrapped scroller keeps it
174
+ visually trailing the last tab while the tabs fit — see .wt-tab-scroll). Its
175
+ hover box matches an active tab's fill and stretches to the strip height;
176
+ the bar gap (not a custom margin) spaces it from the list. Its glyph is an
177
+ inline SVG. */
170
178
  :where(.wt-root) .wt-tab-new {
171
179
  display: inline-flex;
172
180
  align-items: center;
@@ -226,8 +234,10 @@
226
234
  tab scroller: pinned at the bar's right edge in the same column as the
227
235
  scroll-to-bottom button floating above it (both 44px boxes whose right edge
228
236
  sits --sp-2 from the root's right edge), where an overflowing tab list can
229
- never push or scroll it away; the "+" stays with the tabs inside the
230
- scroller. Hidden on a fine-pointer desktop (a real keyboard is present; the
237
+ never push or scroll it away — margin-left: auto (below) keeps it there
238
+ whatever the tab count. The "+" is likewise a fixed bar item outside the
239
+ scroller, but it TRAILS the tab list (the auto margin sits between them)
240
+ and packs up against the kb only when the strip overflows. Hidden on a fine-pointer desktop (a real keyboard is present; the
231
241
  scroller then stretches to the full bar width) and until a keyboardToggle
232
242
  feature is wired ([hidden]); shown only on a coarse pointer at strip widths
233
243
  (the .wt-narrow complement below; the breakpoint itself is the kernel's,
@@ -235,6 +245,11 @@
235
245
  :where(.wt-root) .wt-tab-kb {
236
246
  display: none;
237
247
  flex: none;
248
+ /* Pin at the bar's FAR right edge always: the auto margin absorbs whatever
249
+ width the shrink-wrapped scroller + "+" leave free, so the kb button's
250
+ position is independent of the tab count (the "+" trails the tab list and
251
+ only packs up against the kb once the strip overflows). */
252
+ margin-left: auto;
238
253
  }
239
254
 
240
255
  /* Grid open: filled so the toggled state reads at a glance (mirrors the
@@ -342,8 +357,11 @@
342
357
  tabs feature adds .wt-reports to that dot (paintStatusDot). A program that
343
358
  emits no OSC 9 signal (a plain bash/sh) has no activity concept, so its tabs
344
359
  stay clean, label-only; a progress-reporting agent (kiro-cli, Claude Code)
345
- lights up. The element stays in the DOM either way (JS sets data-status
346
- 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. */
347
365
  :where(.wt-root) .wt-status-dot {
348
366
  display: none;
349
367
  width: 9px;
@@ -376,19 +394,43 @@
376
394
  border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
377
395
  }
378
396
 
379
- /* Agent working: solid pulsing dot (--status-working; pale lime by default —
380
- its high lightness keeps it clear on every tab state) wearing the same 2px
381
- ring as input, so the two agent-turn states share one silhouette: a live
382
- turn pulses (dot + ring together), a turn blocked on the user is that same
383
- silhouette frozen and recolored (input, below). Motion separates working
384
- from input; the ring separates both from done's bare disc. Server-side this
385
- is driven by the OSC 9;4 progress signal, so it tracks agent activity, not
386
- 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. */
387
412
  :where(.wt-root) .wt-status-dot[data-status="working"] {
413
+ position: relative;
388
414
  background: var(--status-working);
389
415
  border-color: var(--status-working);
390
416
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
391
- 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;
392
434
  }
393
435
 
394
436
  /* Agent done / ready: solid disc (--status-done), no motion. The default green
@@ -421,39 +463,58 @@
421
463
  opacity: 0.5;
422
464
  }
423
465
 
424
- @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 {
425
473
  0%,
474
+ 70%,
426
475
  100% {
427
- /* Pin the animated layer's paint as well as its opacity. When working begins
428
- in the same frame that input/done ends, an opacity-only animation can
429
- pulse the previously rasterized input/done paint (the input ring
430
- included, hence the box-shadow pin). */
431
476
  background-color: var(--status-working);
432
477
  border-color: var(--status-working);
433
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
434
- opacity: 1;
435
478
  }
436
479
 
437
- 50% {
438
- background-color: var(--status-working);
439
- border-color: var(--status-working);
440
- 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);
441
492
  opacity: 0.4;
442
493
  }
494
+
495
+ 100% {
496
+ transform: scale(2.1);
497
+ opacity: 0;
498
+ }
443
499
  }
444
500
 
445
- /* Reduced-motion (or the animations feature off): drop the pulse, keep color. */
501
+ /* Reduced-motion: drop the working motion (glow + ripple), keep color. */
446
502
  @media (prefers-reduced-motion: reduce) {
447
503
  :where(.wt-root) .wt-status-dot[data-status="working"] {
448
504
  animation: none;
449
- /* The pulse is what separates working from input's frozen silhouette; with
450
- motion removed, punch an inner hole (dot -> donut) so working differs
451
- from input (solid ringed disc) and done (bare disc) by shape, not hue
452
- 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. */
453
509
  box-shadow:
454
510
  inset 0 0 0 2px var(--bg),
455
511
  0 0 0 2px color-mix(in srgb, var(--status-working) 40%, transparent);
456
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
+ }
457
518
  }
458
519
 
459
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.0",
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);
@@ -225,17 +238,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
225
238
 
226
239
  // --- Desktop tab strip (top-bar region) ---
227
240
  // Two layers: the bar itself never scrolls; an inner scroller
228
- // (.wt-tab-scroll, the tablist) holds the tabs plus the "+", which floats
229
- // right of the tab list and scrolls WITH it when many tabs overflow. The
230
- // keyboard button sits OUTSIDE the scroller as the bar's last flex item,
231
- // pinned at the bar's right edge in the scroll-to-bottom button's column
232
- // (an overflowing tab list can never push or scroll it away). On a fine
233
- // pointer it is display:none (CSS), and the scroller stretches to the
234
- // full bar width so the tabs reclaim that space. Both controls are built
235
- // + wired by the same shared factories as the mobile switcher's; the kb
236
- // button is CSS-gated to a wide touchscreen and un-hidden below only when
237
- // a keyboardToggle is wired. addTabChrome inserts each tab before newBtn,
238
- // keeping the scroller [tabs… +].
241
+ // (.wt-tab-scroll, the tablist) holds ONLY the tabs. The "+" and the
242
+ // keyboard button sit OUTSIDE the scroller as fixed bar items in the
243
+ // order [scroller | + | kb], so an overflowing tab list can never push
244
+ // or scroll either control away. The scroller shrink-wraps its content
245
+ // (CSS flex: 0 1 auto), so while the tabs fit the "+" trails the last
246
+ // tab exactly as if it were in the list, while the kb button (a wide
247
+ // touchscreen; hidden on a fine pointer) stays pinned at the bar's FAR
248
+ // right edge via margin-left: auto whatever the tab count. Once the
249
+ // tabs overflow, the scroller caps at the remaining bar width and the
250
+ // "+" packs right, up against the kb. Both controls are built + wired
251
+ // by the same shared factories as the mobile switcher's; the kb button
252
+ // is CSS-gated to a wide touchscreen and un-hidden below only when a
253
+ // keyboardToggle is wired. addTabChrome appends each tab to the
254
+ // scroller's end.
239
255
  const slot = ctx.region("top-bar", "tabs");
240
256
  const bar = document.createElement("div");
241
257
  bar.className = "wt-tab-bar";
@@ -245,9 +261,38 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
245
261
  scroller.setAttribute("role", "tablist");
246
262
  bar.appendChild(scroller);
247
263
  const newBtn = makeNewButton("wt-tab-new");
248
- scroller.appendChild(newBtn);
264
+ bar.appendChild(newBtn);
249
265
  const deskKb = makeKbButton("wt-tab-kb wt-btn");
250
266
  bar.appendChild(deskKb);
267
+ // A vertical mouse wheel anywhere over the strip scrolls the tab list
268
+ // horizontally — the strip has no vertical dimension to spend the delta
269
+ // on, and this is the affordance browser tab bars train. Bound on the
270
+ // BAR so the empty strip area and the fixed controls translate too.
271
+ // Horizontal-dominant deltas (a trackpad pan) keep native handling, and
272
+ // a wheel over a non-overflowing strip falls through untouched. The
273
+ // listener is non-passive because a translated tick must preventDefault
274
+ // so an embedding page (wt-container mode) does not also scroll.
275
+ bar.addEventListener(
276
+ "wheel",
277
+ (e) => {
278
+ if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) {
279
+ return; // horizontal-dominant: native scroll already handles it
280
+ }
281
+ if (scroller.scrollWidth <= scroller.clientWidth) {
282
+ return; // nothing to scroll: let the page have the wheel
283
+ }
284
+ e.preventDefault();
285
+ // deltaMode: 0 = pixels, 1 = lines (Firefox wheel), 2 = pages.
286
+ const step =
287
+ e.deltaMode === 1
288
+ ? e.deltaY * 32
289
+ : e.deltaMode === 2
290
+ ? e.deltaY * scroller.clientWidth
291
+ : e.deltaY;
292
+ scroller.scrollLeft += step;
293
+ },
294
+ { passive: false },
295
+ );
251
296
 
252
297
  // Pull the terminal surface up off the docked BOTTOM strip on desktop so
253
298
  // the bar does not overlap the last rows. The surface is absolute
@@ -515,13 +560,27 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
515
560
  ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
516
561
  }
517
562
 
518
- // paintActive updates the desktop strip's active state.
563
+ // paintActive updates the desktop strip's active state. When the ACTIVE
564
+ // TAB CHANGES (tracked via lastRevealedActive — not on every chrome
565
+ // sync, so a user browsing a scrolled strip is never yanked back by an
566
+ // unrelated repaint), the newly active chip is brought into view in the
567
+ // overflowed scroller; inline: "nearest" is a no-op when it is already
568
+ // visible. The typeof guard covers happy-dom, which lacks
569
+ // scrollIntoView.
570
+ let lastRevealedActive = "";
519
571
  function paintActive(): void {
520
572
  for (const t of tabList) {
521
573
  const on = t.id === activeId;
522
574
  t.el.classList.toggle("wt-tab-active", on);
523
575
  t.aria.setSelected(on);
524
576
  }
577
+ if (activeId && activeId !== lastRevealedActive) {
578
+ lastRevealedActive = activeId;
579
+ const active = tabList.find((t) => t.id === activeId);
580
+ if (active && typeof active.el.scrollIntoView === "function") {
581
+ active.el.scrollIntoView({ block: "nearest", inline: "nearest" });
582
+ }
583
+ }
525
584
  }
526
585
 
527
586
  // syncMobile updates the bottom bar: active label + dot, and the aggregate
@@ -866,11 +925,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
866
925
  if (!label || !dot || !close) {
867
926
  throw new Error("web-terminal-ui: tab chrome missing parts");
868
927
  }
869
- paintStatusDot(dot, info.status, info.reportsActivity ?? false);
928
+ paintStatusDot(dot, info.status, reportsOf(info.reportsActivity));
870
929
  const aria = tablist.registerTab(el);
871
- // Insert before the "+" so it stays the last item of the scrolling tab
872
- // list (the keyboard button lives outside the scroller, at the bar end).
873
- scroller.insertBefore(el, newBtn);
930
+ // Append to the scroller's end: the tab list is ALL the scroller holds
931
+ // (the "+" and keyboard button are fixed bar items outside it).
932
+ scroller.appendChild(el);
874
933
  // Runtime-added tabs animate in; initial tabs do not (see `started`).
875
934
  // The timer (not animationend) also clears the class on the hidden mobile
876
935
  // strip, where the animation never fires.
@@ -895,7 +954,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
895
954
  aria,
896
955
  scrollTop: 0,
897
956
  following: true,
898
- reports: info.reportsActivity ?? false,
957
+ reports: reportsOf(info.reportsActivity),
899
958
  };
900
959
  // Set an initial label immediately (relabelAll refines it with de-dup
901
960
  // once the tab is in tabList and syncChrome runs).
@@ -1183,8 +1242,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1183
1242
  }
1184
1243
 
1185
1244
  // dragTargetBefore returns the first tab whose horizontal midpoint is past
1186
- // x (the element the dragged tab should sit before), or null to drop at the
1187
- // end (before the "+"). syncOrderFromDom rebuilds tabList to
1245
+ // x (the element the dragged tab should sit before), or null to drop at
1246
+ // the end of the tab list. syncOrderFromDom rebuilds tabList to
1188
1247
  // match the strip's DOM order after a drag, so position indicators, the
1189
1248
  // switcher, and close-to-the-right/left all follow the visible order.
1190
1249
  function dragTargetBefore(x: number): HTMLElement | null {
@@ -1211,8 +1270,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1211
1270
  }
1212
1271
  }
1213
1272
 
1214
- // moveTab shifts a tab one slot left or right in tabList and re-inserts
1215
- // every tab element in list order (before the "+") so the DOM matches.
1273
+ // moveTab shifts a tab one slot left or right in tabList and re-appends
1274
+ // every tab element in list order so the DOM matches.
1216
1275
  // It is the single-pointer / non-drag alternative to the drag-and-drop
1217
1276
  // reorder (WCAG 2.5.7), surfaced as Move left / Move right in the tab
1218
1277
  // context menu.
@@ -1228,7 +1287,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1228
1287
  }
1229
1288
  tabList.splice(to, 0, tab);
1230
1289
  for (const item of tabList) {
1231
- scroller.insertBefore(item.el, newBtn);
1290
+ scroller.appendChild(item.el);
1232
1291
  }
1233
1292
  syncChrome();
1234
1293
  ctx.announce(`Moved ${tab.display} to position ${String(to + 1)}`);
@@ -1623,7 +1682,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1623
1682
  info.status,
1624
1683
  info.title,
1625
1684
  info.clientTitle,
1626
- info.reportsActivity ?? false,
1685
+ reportsOf(info.reportsActivity),
1627
1686
  );
1628
1687
  }
1629
1688
  // A tab the server no longer lists was reaped/closed elsewhere (or
@@ -1657,7 +1716,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1657
1716
  // SSE-adopted tab without its persisted title — in preferInputTitle
1658
1717
  // mode the label then fell back to "New tab" on reload.
1659
1718
  adoptSession(s);
1660
- 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));
1661
1720
  ensureActive();
1662
1721
  syncChrome();
1663
1722
  });
@@ -1694,7 +1753,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1694
1753
  if (e.dataTransfer) {
1695
1754
  e.dataTransfer.dropEffect = "move";
1696
1755
  }
1697
- scroller.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? newBtn);
1756
+ scroller.insertBefore(draggingEl, dragTargetBefore(e.clientX));
1698
1757
  });
1699
1758
  // Active-row close (x): closes the current tab (mirrors a listed row's x).
1700
1759
  // stopPropagation so it is not read as a tap/swipe on the row surface.
@@ -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
  }