@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 +9 -6
- package/css/30-tabs.css +104 -43
- package/package.json +1 -1
- package/src/features/tabs/index.ts +85 -26
- package/src/presets/agent-tabbed.ts +13 -6
- package/src/presets/tabbed.ts +12 -5
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),
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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:
|
|
17
|
-
tabs
|
|
18
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
HERE, not on the bar, so
|
|
38
|
-
flex: 1 + min-width: 0
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
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:
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
230
|
-
|
|
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.
|
|
346
|
-
|
|
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:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
background-color: var(--status-working);
|
|
439
|
-
border-color: var(--status-working);
|
|
440
|
-
|
|
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
|
|
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
|
-
/*
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
+
"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
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// (
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
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
|
-
|
|
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
|
|
928
|
+
paintStatusDot(dot, info.status, reportsOf(info.reportsActivity));
|
|
870
929
|
const aria = tablist.registerTab(el);
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
scroller.
|
|
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
|
|
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
|
|
1187
|
-
// end
|
|
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-
|
|
1215
|
-
// every tab element in list order
|
|
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.
|
|
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
|
|
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
|
|
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)
|
|
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,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
}
|
package/src/presets/tabbed.ts
CHANGED
|
@@ -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)
|
|
18
|
-
*
|
|
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
|
|
22
|
-
|
|
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({
|
|
31
|
+
tabs({
|
|
32
|
+
keyboardToggle: kb,
|
|
33
|
+
activityMonitor: monitor,
|
|
34
|
+
preferInputTitle: agentShell,
|
|
35
|
+
presumeReports: agentShell,
|
|
36
|
+
}),
|
|
30
37
|
animations(),
|
|
31
38
|
];
|
|
32
39
|
}
|