@cplieger/web-terminal-ui 3.4.2 → 3.4.4

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
@@ -102,7 +102,7 @@ individual features instead:
102
102
  - `presetAgentTabbed()` — the same feature set as `presetTabbed()` (activity
103
103
  monitor included), but with `preferInputTitle`: each tab's label follows the
104
104
  latest submitted line (persisted server-side and recovered on reload) and the
105
- program's OSC 0/2 title is ignored — for an agent shell such as `vibecli`
105
+ program's OSC 0/2 title is ignored — for an agent shell such as `web-terminal-kiro`
106
106
  whose program emits a non-empty but useless OSC title. Its status dots
107
107
  (idle / working / done / needs-input) come from the same activity monitor,
108
108
  driven by the server's status SSE and its OSC-9 classifier.
@@ -143,7 +143,7 @@ The web-terminal family:
143
143
  Consumers that ship this UI:
144
144
 
145
145
  - [`vibekit`](https://github.com/cplieger/vibekit)
146
- - [`vibecli`](https://github.com/cplieger/vibecli)
146
+ - [`web-terminal-kiro`](https://github.com/cplieger/web-terminal-kiro)
147
147
 
148
148
  ## Disclaimer
149
149
 
package/css/00-tokens.css CHANGED
@@ -10,7 +10,7 @@
10
10
  /* ===== Themeable tokens (the consumer-configurable "template") =====
11
11
  These default to the neutral built-in look; a consumer recolors the UI by
12
12
  overriding them via createTerminal's `theme` option (which sets them on the
13
- terminal root) or its own CSS. vibecli sets a purple palette. Everything
13
+ terminal root) or its own CSS. web-terminal-kiro sets a purple palette. Everything
14
14
  above is the fixed design palette; everything here is the public theme API. */
15
15
  --accent: oklch(72% 0.15 250deg); /* light blue; themed per consumer */
16
16
  --tab-bg: oklch(30% 0 0deg / 90%); /* inactive-tab chip (translucent) */
package/css/01-reset.css CHANGED
@@ -14,6 +14,12 @@ body {
14
14
  background: var(--bg);
15
15
  color-scheme: dark;
16
16
  -webkit-font-smoothing: antialiased;
17
+ /* Disable the browser's landscape text autosizing ("text inflation"): without
18
+ this, rotating a phone to landscape zooms the text. iOS Safari implements
19
+ ONLY the -webkit- prefixed form (the unprefixed property is ignored there),
20
+ so both are required — unprefixed alone left iPhones inflating on rotate. */
21
+ /* stylelint-disable-next-line property-no-vendor-prefix -- iOS Safari only implements the prefixed form */
22
+ -webkit-text-size-adjust: 100%;
17
23
  text-size-adjust: 100%;
18
24
  overscroll-behavior: none;
19
25
  /* Disable pinch-to-zoom and double-tap-to-zoom. The viewport meta tag
package/css/05-tabs.css CHANGED
@@ -72,7 +72,7 @@
72
72
 
73
73
  /* The active tab reads clearly above the inactive chips: a stronger fill plus an
74
74
  accent-coloured edge (inactive chips carry a faint white --border; the active
75
- one gets the accent equivalent). Themeable via --accent (vibecli -> purple). */
75
+ one gets the accent equivalent). Themeable via --accent (web-terminal-kiro -> purple). */
76
76
  .wt-tab-active {
77
77
  background: var(--tab-active-bg);
78
78
  border-color: var(--tab-active-border);
@@ -195,24 +195,21 @@
195
195
  }
196
196
 
197
197
  /* Desktop-strip keyboard button (tabs feature): opens the on-screen key grid on
198
- a WIDE touchscreen (an iPad using the multi-tab strip), where there is no
199
- mobile switcher to carry one. Built + wired by the SAME factory as the mobile
200
- switcher's keyboard button (see makeKbButton in tabs.ts). Uses the shared
201
- .wt-btn primitive for its 44px box + centered icon. Hidden on a fine-pointer
202
- desktop (a real keyboard is present) and until a keyboardToggle feature is
203
- wired ([hidden]); shown only on a coarse pointer at strip widths (the media
204
- block below, kept in lockstep with SWITCHER_MEDIA in src/features/tabs.ts).
205
- Fixed at the strip's bottom-right — above the fixed bottom strip, with a
206
- matching-surface fill — so it caps the strip's right edge and never scrolls
207
- with the tabs. */
198
+ a WIDE touchscreen (an iPad using the multi-tab strip, or a phone rotated to
199
+ landscape), where there is no mobile switcher to carry one. Built + wired by
200
+ the SAME factory as the mobile switcher's keyboard button (see makeKbButton
201
+ in tabs.ts). Uses the shared .wt-btn primitive for its 44px box + centered
202
+ icon, borderless like the switcher's — the old fixed-overlay styling gave it
203
+ a border and pinned it right of the "+", which read as a different control
204
+ the moment a phone rotated to landscape. Now an ordinary flex item inside the
205
+ strip, before the "+" (kb-then-new, mirroring the mobile switcher's order).
206
+ Hidden on a fine-pointer desktop (a real keyboard is present) and until a
207
+ keyboardToggle feature is wired ([hidden]); shown only on a coarse pointer at
208
+ strip widths (the media block below, kept in lockstep with SWITCHER_MEDIA in
209
+ src/features/tabs.ts). */
208
210
  .wt-tab-kb {
209
211
  display: none;
210
- position: fixed;
211
- right: var(--sp-2);
212
- bottom: calc(var(--safe-bottom) + var(--sp-2));
213
- z-index: 16;
214
- background: var(--surface);
215
- border: 1px solid var(--border);
212
+ flex: none;
216
213
  }
217
214
 
218
215
  /* Grid open: filled so the toggled state reads at a glance (mirrors the
@@ -229,17 +226,13 @@
229
226
  color: var(--bg);
230
227
  }
231
228
 
232
- /* Show + reserve room only on a WIDE touchscreen (where the desktop strip is
233
- used AND a keyboardToggle is wired, hence :not([hidden])): the strip gets
234
- matching right padding so the tabs + "+" never scroll under the fixed button. */
229
+ /* Show only on a WIDE touchscreen (where the desktop strip is used AND a
230
+ keyboardToggle is wired, hence :not([hidden])). In-flow now, so the strip
231
+ needs no reserved right padding for it. */
235
232
  @media (pointer: coarse) and (width >= 601px) {
236
233
  .wt-tab-kb:not([hidden]) {
237
234
  display: inline-flex;
238
235
  }
239
-
240
- .wt-tab-bar {
241
- padding-right: calc(var(--touch-target) + 2 * var(--sp-2));
242
- }
243
236
  }
244
237
 
245
238
  /* The tab being dragged (reorder) dims while it moves. */
package/css/06-mobile.css CHANGED
@@ -111,7 +111,7 @@
111
111
  /* Right padding reserves room for the close (x) overlaid inside the row. */
112
112
  padding: 0 var(--touch-target) 0 var(--sp-2);
113
113
  /* The bar row always represents the active tab, so it wears the active-tab
114
- fill/foreground the desktop strip uses (themeable; purple in vibecli). */
114
+ fill/foreground the desktop strip uses (themeable; purple in web-terminal-kiro). */
115
115
  background: var(--tab-active-bg);
116
116
  color: var(--tab-active-fg);
117
117
  border: 1px solid var(--tab-active-border);
@@ -254,6 +254,18 @@
254
254
  margin-inline-start: 0;
255
255
  }
256
256
 
257
+ /* When the tab list expands the switch button collapses too (its job is done:
258
+ the list is open), mirroring the keyboard button's collapse in the same
259
+ animation (07-animations.css animates width + margin + opacity). Placed after
260
+ the .wt-switcher-multi reveal so it wins when both apply (expanded, two or
261
+ more tabs). */
262
+ .wt-switcher-expanded .wt-switcher-switch {
263
+ width: 0;
264
+ opacity: 0;
265
+ margin-inline-start: calc(-1 * var(--sp-1));
266
+ pointer-events: none;
267
+ }
268
+
257
269
  @media (hover: hover) {
258
270
  .wt-switcher-switch:hover {
259
271
  background: var(--tab-hover-bg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "3.4.2",
3
+ "version": "3.4.4",
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",
@@ -13,8 +13,8 @@
13
13
  ],
14
14
  "types": "./src/index.ts",
15
15
  "scripts": {
16
- "typecheck": "tsgo -p tsconfig.json",
17
- "typecheck:tests": "tsgo -p tsconfig.test.json",
16
+ "typecheck": "tsc -p tsconfig.json",
17
+ "typecheck:tests": "tsc -p tsconfig.test.json",
18
18
  "test": "vitest --run",
19
19
  "test:watch": "vitest",
20
20
  "test:coverage": "vitest --run --coverage",
@@ -29,19 +29,20 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@eslint/js": "10.0.1",
32
- "@types/node": "25.9.4",
33
- "@vitest/coverage-v8": "4.1.9",
34
- "eslint": "10.6.0",
35
- "fast-check": "4.8.0",
32
+ "@types/node": "25.9.5",
33
+ "@typescript/native": "npm:typescript@7.0.2",
34
+ "@vitest/coverage-v8": "4.1.10",
35
+ "eslint": "10.7.0",
36
+ "fast-check": "4.9.0",
36
37
  "happy-dom": "20.10.6",
37
38
  "html-validate": "11.5.5",
38
- "knip": "6.24.0",
39
- "prettier": "3.9.4",
39
+ "knip": "6.26.0",
40
+ "prettier": "3.9.5",
40
41
  "stylelint": "17.14.0",
41
42
  "stylelint-config-standard": "40.0.0",
42
- "typescript": "6.0.3",
43
- "typescript-eslint": "8.62.1",
44
- "vitest": "4.1.9"
43
+ "typescript": "npm:@typescript/typescript6@6.0.2",
44
+ "typescript-eslint": "8.63.0",
45
+ "vitest": "4.1.10"
45
46
  },
46
47
  "publishConfig": {
47
48
  "access": "public"
@@ -9,10 +9,12 @@
9
9
  <link rel="stylesheet" href="/style.css">
10
10
  <!-- [html-validate-disable-next no-style-tag -- critical CSS for instant loading indicator] -->
11
11
  <style>
12
- #loading{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;z-index:200;background:#000;font-family:system-ui,-apple-system,sans-serif;color:#b48eff;font-size:18px;transition:opacity .3s ease}
12
+ #loading{position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;background:#000;color:var(--accent,#6ea8ff);transition:opacity .3s ease}
13
13
  #loading.fade{opacity:0}
14
- #loading .spin{width:32px;height:32px;border:3px solid #b48eff;border-right-color:transparent;border-radius:50%;animation:sp .6s linear infinite}
15
- @keyframes sp{to{transform:rotate(360deg)}}
14
+ #loading .bar{position:relative;inline-size:min(220px,62vw);block-size:5px;overflow:hidden;border-radius:999px;background:color-mix(in oklch,currentColor 16%,transparent)}
15
+ #loading .bar::before{content:"";position:absolute;inset-block:0;inline-size:42%;border-radius:inherit;background:linear-gradient(90deg,transparent,currentColor 28%,color-mix(in oklch,currentColor,#fff 45%) 50%,currentColor 72%,transparent);filter:drop-shadow(0 0 4px color-mix(in oklch,currentColor 55%,transparent));animation:wt-loading-slide 1.5s cubic-bezier(.65,0,.35,1) infinite}
16
+ @keyframes wt-loading-slide{0%{transform:translateX(-110%)}100%{transform:translateX(250%)}}
17
+ @media (prefers-reduced-motion:reduce){#loading .bar::before{animation-duration:3s}}
16
18
  </style>
17
19
  </head>
18
20
  <body>
@@ -20,7 +22,7 @@
20
22
  element. The loading overlay is a sibling (not a child of the root) so it is
21
23
  never cleared; it is faded out once the first frame renders. -->
22
24
  <div id="terminal"></div>
23
- <div id="loading"><div class="spin"></div>Loading…</div>
25
+ <div id="loading" role="status" aria-label="Loading"><div class="bar" aria-hidden="true"></div></div>
24
26
  <!-- Deployment-specific: the importmap resolves the bare package specifiers to
25
27
  the compiled JS your build emits. Adjust the paths to your build layout. -->
26
28
  <script type="importmap">
@@ -146,7 +146,7 @@ export interface TabsOptions {
146
146
  /** Prefer the input-derived title over the process OSC 0/2 title. Default
147
147
  * false (OSC-first: a program that sets its own window title wins, else the
148
148
  * last submitted line). Set true for an agent shell whose program emits a
149
- * non-empty but useless OSC title (kiro-cli under vibecli): the label then
149
+ * non-empty but useless OSC title (kiro-cli under web-terminal-kiro): the label then
150
150
  * follows the latest submitted line (live) or the persisted client title (on
151
151
  * reload), and the unreliable OSC title is ignored entirely. presetAgentTabbed
152
152
  * enables this. */
@@ -160,10 +160,12 @@ export interface TabsOptions {
160
160
  const CLOSE_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg>`;
161
161
  const NEW_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`;
162
162
  const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3.5"/><path d="M15 15 9.5 9.5M9.5 13V9.5H13"/></svg>`;
163
- // Two-arrows "switch" glyph for the mobile switcher's dedicated open/close
164
- // button (a latest-wins background-tab notification dot rides on it — see
165
- // switchButtonHTML). Same viewBox + stroke=currentcolor treatment as the others.
166
- const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9h13M14 6l3 3-3 3M20 15H7M10 12l-3 3 3 3"/></svg>`;
163
+ // Two-overlapping-windows glyph for the mobile switcher's dedicated open/close
164
+ // button: the browser-style "tab switcher" icon, more recognisable than the
165
+ // prior swap-arrows (which read like a keyboard Tab key). A latest-wins
166
+ // background-tab notification dot rides on it (see switchButtonHTML). Same
167
+ // viewBox + stroke=currentcolor treatment as the others.
168
+ const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3"/></svg>`;
167
169
 
168
170
  // chipContent is the ONE builder for a tab chip's content — a status dot, a
169
171
  // label, and a close (x) — shared by all three chip sites: the desktop strip
@@ -357,7 +359,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
357
359
  }
358
360
  // Persist the input-derived tab title server-side so it survives a page reload
359
361
  // and shows on other devices. The engine stores it as the session's fallback
360
- // title and uses it only when the program emits no OSC title (the vibecli case:
362
+ // title and uses it only when the program emits no OSC title (the web-terminal-kiro case:
361
363
  // kiro-cli emits no usable OSC title, so the latest user message is the label).
362
364
  // Best-effort and fire-and-forget: a failure never disrupts the terminal — the
363
365
  // locally-derived title still displays, it just is not persisted this time.
@@ -456,20 +458,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
456
458
  bar.className = "wt-tab-bar";
457
459
  bar.setAttribute("role", "tablist");
458
460
  slot.appendChild(bar);
459
- // The "+" button is the last flex item INSIDE the strip. Appending it to
460
- // the slot (a sibling of the fixed .wt-tab-bar) flowed it out of the strip
461
- // to the root, so it never appeared; inside the flex bar it renders at the
462
- // end of the tabs (addTabChrome inserts each tab before it).
461
+ // Strip-end controls, both flex items INSIDE the strip so they flow after
462
+ // the tabs (appending them to the slot flowed them out of the strip):
463
+ // the keyboard button first, then the "+" — mirroring the mobile
464
+ // switcher's kb-before-new order, so rotating a phone to landscape (which
465
+ // swaps in this strip) keeps the keyboard button LEFT of the "+" rather
466
+ // than jumping to its right. Built + wired by the same shared factories
467
+ // as the mobile switcher's; the kb button is CSS-gated to a wide
468
+ // touchscreen and un-hidden below only when a keyboardToggle is wired.
469
+ // addTabChrome inserts each tab before deskKb, keeping [tabs… kb +].
470
+ const deskKb = makeKbButton("wt-tab-kb wt-btn");
471
+ bar.appendChild(deskKb);
463
472
  const newBtn = makeNewButton("wt-tab-new");
464
473
  bar.appendChild(newBtn);
465
- // Desktop-strip keyboard button, built + wired by the SAME factory as the
466
- // mobile switcher's. A SIBLING of the strip (appended to the region slot,
467
- // not into .wt-tab-bar) so it never affects the strip's child order; CSS
468
- // shows it only on a wide touchscreen (where the strip, not the switcher,
469
- // is used) and hides it on a fine-pointer desktop. Un-hidden below only
470
- // when a keyboardToggle feature is wired.
471
- const deskKb = makeKbButton("wt-tab-kb wt-btn");
472
- slot.appendChild(deskKb);
473
474
 
474
475
  // Pull the terminal surface up off the fixed BOTTOM strip on desktop so
475
476
  // the bar does not overlap the last rows. The surface is position:fixed
@@ -520,8 +521,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
520
521
  swBar.append(swKb, swSwitch, swNew);
521
522
  // Latest-wins notification state for the switch button's dot: overwritten
522
523
  // by each qualifying background-tab event (applyStatus) and cleared when
523
- // the list opens (expandSwitcher). "" = no pending cue; the dot is hidden.
524
+ // the list opens (expandSwitcher) or the raising tab is visited or closed
525
+ // (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden.
524
526
  let switchNotify: "" | "input" | "done" = "";
527
+ // The session that raised the pending cue, so arriving on that tab (a
528
+ // swipe or any switch) acknowledges it without opening the list.
529
+ let switchNotifyId: string | null = null;
525
530
  function paintSwitchDot(): void {
526
531
  // Reuse the per-tab status-dot colours (single source, css/05-tabs.css
527
532
  // .wt-status-dot[data-status="input"|"done"]) instead of re-declaring
@@ -533,6 +538,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
533
538
  swSwitchDot.dataset["status"] = switchNotify;
534
539
  }
535
540
  }
541
+ function clearSwitchNotify(): void {
542
+ switchNotify = "";
543
+ switchNotifyId = null;
544
+ paintSwitchDot();
545
+ }
546
+ // acknowledgeSwitchNotify clears the pending cue when its subject session
547
+ // is reached (switchTo — including a swipe arriving on it) or ceases to
548
+ // exist (a close/reap): the notification is resolved or moot then, and
549
+ // only opening the list used to clear it (the reported "swiping to the
550
+ // concerned tab leaves the dot lit").
551
+ function acknowledgeSwitchNotify(id: string): void {
552
+ if (switchNotifyId === id) {
553
+ clearSwitchNotify();
554
+ }
555
+ }
536
556
  ctx.region("bottom-switcher", "switcher").appendChild(switcher);
537
557
  // The keyboard buttons open the key grid; show them only when a toolbar is
538
558
  // wired to drive. Read the toolbar's API lazily at tap time (ctx.use), so
@@ -1099,33 +1119,43 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1099
1119
  from = first - el.getBoundingClientRect().top; // survivor: old spot -> new
1100
1120
  } else {
1101
1121
  from = dir === "next" ? pitch : -pitch; // newcomer: in from the trailing edge
1102
- el.style.opacity = "0";
1122
+ el.style.opacity = "0"; // fades in as it rotates to its slot (below)
1103
1123
  }
1104
1124
  el.style.transition = "none";
1105
1125
  el.style.transform = `translateY(${String(Math.round(from))}px)`;
1106
1126
  }
1107
1127
  const exit = dir === "next" ? -pitch : pitch; // leaving row off the leading edge
1108
- requestAnimationFrame(() => {
1109
- // Couple opacity to the SAME easing + duration as the transform so a
1110
- // row's fade tracks its DISTANCE from its target slot (every reel row
1111
- // travels one pitch): opacity is a linear function of remaining
1112
- // distance, so a row is transparent a pitch away (at the clipped
1113
- // edge) and only fully opaque once it settles. Entering rows fade IN
1114
- // as they rotate in and the leaving row fades OUT as it exits — no
1115
- // hard black cutoff at the list edges, and no permanent edge fade.
1116
- // (Was opacity 0.2s ease-out, decoupled from the motion, so a row hit
1117
- // full opacity while still at the edge — the reported hard cut.)
1118
- const trans =
1119
- "transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.25s cubic-bezier(0.2, 0, 0, 1)";
1120
- for (const el of rowEls.values()) {
1121
- el.style.transition = trans;
1122
- el.style.transform = "translateY(0)";
1123
- el.style.opacity = "";
1124
- }
1125
- ghost.style.transition = trans;
1126
- ghost.style.transform = `translateY(${String(Math.round(exit))}px)`;
1127
- ghost.style.opacity = "0";
1128
- });
1128
+ // The leaving row starts at its captured spot, fully opaque.
1129
+ ghost.style.transition = "none";
1130
+ ghost.style.transform = "translateY(0)";
1131
+ ghost.style.opacity = "1";
1132
+ // Commit the from-state (transforms + opacities) with a forced reflow
1133
+ // BEFORE the to-state, so BOTH the transform and the opacity transitions
1134
+ // fire from it. The prior code used a bare rAF (letting the browser
1135
+ // collapse from->to into one recalc) and reverted the entering row's
1136
+ // opacity to "" (no explicit end value), so the fade never animated
1137
+ // (the reported "no fade in / fade out"). The modern display/visibility
1138
+ // transition (transition-behavior: allow-discrete + @starting-style,
1139
+ // Baseline 2024) does NOT apply here: these rows are reused and moved by
1140
+ // a JS transform FLIP, not toggled via display:none, so the reliable
1141
+ // path is a real reflow plus an explicit opacity transition.
1142
+ swList.getBoundingClientRect(); // read forces the reflow (commit the from-state)
1143
+ // Couple opacity to the SAME easing + duration as the transform so a
1144
+ // row's fade tracks its DISTANCE from its target slot (each reel row
1145
+ // travels one pitch): a row is transparent a pitch away (at the clipped
1146
+ // edge) and only fully opaque once it settles. Entering rows fade IN as
1147
+ // they rotate in, the leaving row fades OUT as it exits: no hard cutoff
1148
+ // at the list edges and no permanent edge mask.
1149
+ const trans =
1150
+ "transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.25s cubic-bezier(0.2, 0, 0, 1)";
1151
+ for (const el of rowEls.values()) {
1152
+ el.style.transition = trans;
1153
+ el.style.transform = "translateY(0)";
1154
+ el.style.opacity = "1";
1155
+ }
1156
+ ghost.style.transition = trans;
1157
+ ghost.style.transform = `translateY(${String(Math.round(exit))}px)`;
1158
+ ghost.style.opacity = "0";
1129
1159
  reelTimer = setTimeout(endReelNow, REEL_MS);
1130
1160
  };
1131
1161
  }
@@ -1211,8 +1241,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1211
1241
  }
1212
1242
  // Opening the list acknowledges any pending background-tab notification:
1213
1243
  // the user is now looking at the tabs, so clear the switch button's dot.
1214
- switchNotify = "";
1215
- paintSwitchDot();
1244
+ clearSwitchNotify();
1216
1245
  renderSwitcherList();
1217
1246
  setExpandedState(true);
1218
1247
  ctx.announce("Terminal list expanded");
@@ -1245,8 +1274,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1245
1274
  }
1246
1275
  paintStatusDot(dot, info.status, info.reportsActivity ?? false);
1247
1276
  const aria = tablist.registerTab(el);
1248
- // Insert before the "+" button so it stays the last item in the strip.
1249
- bar.insertBefore(el, newBtn);
1277
+ // Insert before the strip-end controls (keyboard button, then "+") so
1278
+ // they stay the last items in the strip.
1279
+ bar.insertBefore(el, deskKb);
1250
1280
  // Runtime-added tabs animate in; initial tabs do not (see `started`).
1251
1281
  // The timer (not animationend) also clears the class on the hidden mobile
1252
1282
  // strip, where the animation never fires.
@@ -1378,6 +1408,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1378
1408
  // rebuild viewport-first, so the last-known screen paints with no
1379
1409
  // round-trip. Then let the kernel reconnect the WS to it (resume delta).
1380
1410
  activeId = next.id;
1411
+ // Arriving on the tab that raised the switch-button cue resolves it
1412
+ // (a swipe through the tabs must dismiss the dot, not only opening the
1413
+ // list).
1414
+ acknowledgeSwitchNotify(next.id);
1381
1415
  try {
1382
1416
  localStorage.setItem(ACTIVE_TAB_KEY, next.id);
1383
1417
  } catch {
@@ -1510,9 +1544,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1510
1544
 
1511
1545
  // dragTargetBefore returns the first tab whose horizontal midpoint is past
1512
1546
  // x (the element the dragged tab should sit before), or null to drop at the
1513
- // end (before the "+"). syncOrderFromDom rebuilds tabList to match the
1514
- // strip's DOM order after a drag, so position indicators, the switcher, and
1515
- // close-to-the-right/left all follow the visible order.
1547
+ // end (before the strip-end controls). syncOrderFromDom rebuilds tabList to
1548
+ // match the strip's DOM order after a drag, so position indicators, the
1549
+ // switcher, and close-to-the-right/left all follow the visible order.
1516
1550
  function dragTargetBefore(x: number): HTMLElement | null {
1517
1551
  for (const el of bar.querySelectorAll<HTMLElement>(".wt-tab:not(.wt-tab-dragging)")) {
1518
1552
  const rect = el.getBoundingClientRect();
@@ -1635,6 +1669,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1635
1669
  // Tombstone this id briefly so a stale status snapshot/poll that predates
1636
1670
  // the server reaping it cannot re-adopt the just-closed tab.
1637
1671
  tombstone(id);
1672
+ // A pending switch-button cue whose subject just closed is moot: clear
1673
+ // it rather than leaving a dot no tab visit can ever resolve.
1674
+ acknowledgeSwitchNotify(id);
1638
1675
  tab.aria.remove();
1639
1676
  // Remove immediately (no exit animation): a lingering element made the
1640
1677
  // "+" teleport after a delay, and made a last-tab replacement appear in
@@ -1699,6 +1736,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1699
1736
  // server reaping these sessions cannot re-adopt (flash back) a just-
1700
1737
  // closed tab -- mirrors the single-close path in dropTab (h-f2).
1701
1738
  tombstone(t.id);
1739
+ acknowledgeSwitchNotify(t.id); // a cue for a closed tab is moot
1702
1740
  t.aria.remove();
1703
1741
  t.el.remove();
1704
1742
  ctx.dropSession(t.id);
@@ -1868,6 +1906,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1868
1906
  // dedicated button.
1869
1907
  if (id !== activeId && (status === "input" || status === "done")) {
1870
1908
  switchNotify = status;
1909
+ switchNotifyId = id;
1871
1910
  paintSwitchDot();
1872
1911
  }
1873
1912
  // Record the raw server title; the displayed label (fallback + de-dup)
@@ -1902,15 +1941,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1902
1941
  return;
1903
1942
  }
1904
1943
  // Adopt a session created in another browser so all clients converge.
1944
+ // Carry the persisted clientTitle through: the SSE snapshot usually
1945
+ // beats the initial GET /api/sessions (whose adoptSession then dedups
1946
+ // and never re-applies its fields), so dropping it here left every
1947
+ // SSE-adopted tab without its persisted title — in preferInputTitle
1948
+ // mode the label then fell back to "New tab" on reload.
1905
1949
  adoptSession({
1906
1950
  id: s.id,
1907
1951
  title: s.title,
1908
- clientTitle: undefined,
1952
+ clientTitle: s.clientTitle,
1909
1953
  createdAt: s.createdAt,
1910
1954
  status: s.status,
1911
1955
  reportsActivity: s.reportsActivity ?? false,
1912
1956
  });
1913
- applyStatus(s.id, s.status, s.title, undefined, s.reportsActivity ?? false);
1957
+ applyStatus(s.id, s.status, s.title, s.clientTitle, s.reportsActivity ?? false);
1914
1958
  ensureActive();
1915
1959
  syncChrome();
1916
1960
  });
@@ -1982,7 +2026,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1982
2026
  if (e.dataTransfer) {
1983
2027
  e.dataTransfer.dropEffect = "move";
1984
2028
  }
1985
- bar.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? newBtn);
2029
+ bar.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? deskKb);
1986
2030
  });
1987
2031
  // Active-row close (x): closes the current tab (mirrors a listed row's x).
1988
2032
  // stopPropagation so it is not read as a tap/swipe on the row surface.
@@ -2042,9 +2086,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2042
2086
  return;
2043
2087
  }
2044
2088
  const target = e.target as HTMLElement | null;
2089
+ // The tab strip counts as chrome like the switcher: without it, the
2090
+ // desktop-strip keyboard button's own pointerup landed here, closed the
2091
+ // grid, and the button's click then re-opened it — so tapping the button
2092
+ // to CLOSE the grid never worked (a wide-touchscreen / landscape-phone
2093
+ // bug; the switcher's kb button was already exempt via .wt-switcher).
2045
2094
  const inChrome =
2046
2095
  target !== null &&
2047
- (target.closest(".wt-switcher") !== null || target.closest(".key-toolbar") !== null);
2096
+ (target.closest(".wt-switcher") !== null ||
2097
+ target.closest(".wt-tab-bar") !== null ||
2098
+ target.closest(".key-toolbar") !== null);
2048
2099
  if (inChrome) {
2049
2100
  return;
2050
2101
  }
package/src/presets.ts CHANGED
@@ -66,7 +66,7 @@ function buildTabbed(preferInputTitle: boolean): TerminalFeature<unknown>[] {
66
66
  /** Tabbed UI: the touch features, tabs, the mobile keyboard bar, the activity
67
67
  * monitor, and animations. Requires a server that speaks the session API
68
68
  * (`/api/sessions`, `/ws?session=`, and the status SSE `/api/sessions/events`),
69
- * such as `web-terminal-server` or `vibecli`. Each tab's title follows the
69
+ * such as `web-terminal-server` or `web-terminal-kiro`. Each tab's title follows the
70
70
  * process OSC 0/2 window title when the program sets one, else the last
71
71
  * submitted line; each tab's activity dot stays hidden until its session
72
72
  * reports activity via OSC 9;4 progress (kiro-cli, Claude Code, …), so a plain
@@ -75,7 +75,7 @@ export function presetTabbed(): TerminalFeature<unknown>[] {
75
75
  return buildTabbed(false);
76
76
  }
77
77
 
78
- /** Tabbed UI for an agent shell (vibecli). Same features as presetTabbed, but
78
+ /** Tabbed UI for an agent shell (web-terminal-kiro). Same features as presetTabbed, but
79
79
  * with `preferInputTitle`: the agent's program (kiro-cli) emits a non-empty but
80
80
  * useless OSC 0/2 title, so each tab's label follows the latest submitted line
81
81
  * (persisted server-side and recovered on reload) and the OSC title is ignored.