@cplieger/web-terminal-ui 3.0.4 → 3.2.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.
@@ -11,16 +11,42 @@
11
11
  import { LineStore, modes } from "@cplieger/web-terminal-engine";
12
12
  import type { TerminalContext, TerminalFeature, TabHandle } from "../kernel/types.js";
13
13
  import type { ActivityMonitorApi } from "./activity-monitor.js";
14
+ import type { MobileToolbarApi } from "./mobile-toolbar.js";
14
15
  import { fromHTML } from "./dom.js";
15
16
 
16
17
  const DEFAULT_API_BASE = "/api/sessions";
17
18
  // Swipe recognition on the mobile switcher bar: a mostly-horizontal drag past
18
19
  // this distance switches; a near-stationary release is a tap (opens overview).
19
20
  const SWIPE_MIN_PX = 40;
21
+ // Movement (px) before an in-progress bar drag commits to a horizontal
22
+ // (tab-switch preview) or vertical (expand/collapse) axis.
23
+ const AXIS_LOCK_PX = 8;
24
+ // Flick-to-commit thresholds (from @use-gesture's drag defaults, MIT): a release
25
+ // counts as a flick when the gesture was quick, fast, and travelled enough, so a
26
+ // short fast swipe commits the switch/expand even below the halfway distance.
27
+ const SWIPE_VELOCITY = 0.5; // px/ms at release
28
+ const SWIPE_DURATION = 250; // ms, whole-gesture cap for a flick
29
+ // If the finger paused longer than this before lifting, the release velocity is
30
+ // stale (pointerup usually repeats the last pointermove position -> reads ~0),
31
+ // so ignore it and commit by distance instead (@use-gesture issue #332).
32
+ const VELOCITY_STALE_MS = 32;
33
+ // Live drag preview: while an open list is being swiped, it peeks in the swipe
34
+ // direction by this fraction of the finger's horizontal travel, capped at this
35
+ // many pixels — a hint of the coming rotation, not the full shift (the incoming
36
+ // row only appears on release, so a large move looked wrong). The release reel
37
+ // continues from wherever the peek left the rows.
38
+ const PREVIEW_DRAG_RATIO = 0.1;
39
+ const PREVIEW_PEEK_MAX = 10;
20
40
  // One-time "swipe to switch" hint, remembered across loads.
21
41
  const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
22
42
  // Default cadence for the no-activityMonitor polling fallback.
23
43
  const DEFAULT_POLL_MS = 4000;
44
+ // Tab context-menu viewport clamp (mirrors context-menu.ts): keep this margin
45
+ // from every viewport edge, and this gap above the pointer when the menu has to
46
+ // flip up (a right-click near the bottom edge) so it is neither clipped nor
47
+ // hidden under the cursor.
48
+ const TAB_MENU_EDGE = 8;
49
+ const TAB_MENU_GAP = 16;
24
50
 
25
51
  /** One session's client-side wire shape (matches terminal.SessionInfo). */
26
52
  interface SessionInfo {
@@ -28,6 +54,10 @@ interface SessionInfo {
28
54
  title: string;
29
55
  createdAt: string;
30
56
  status: string;
57
+ /** Sticky: true once the session has emitted a genuine activity signal
58
+ * (OSC 9;4 progress, or a classified OSC 9 notification). Gates whether the
59
+ * per-tab activity dot is shown; see paintStatusDot / baseLabel. */
60
+ reportsActivity?: boolean;
31
61
  }
32
62
 
33
63
  interface Tab {
@@ -36,10 +66,6 @@ interface Tab {
36
66
  * empty before the process sets one. The displayed label is derived from it
37
67
  * with a numbered fallback and de-duplication (see relabelAll). */
38
68
  title: string;
39
- /** A stable per-session number for the "Tab N" fallback when the process has
40
- * set no title. Monotonic across the page so a tab's number never changes
41
- * under it when a sibling closes. */
42
- num: number;
43
69
  /** The computed, de-duplicated label actually shown in the chrome. */
44
70
  display: string;
45
71
  createdAt: string;
@@ -50,10 +76,18 @@ interface Tab {
50
76
  aria: TabHandle;
51
77
  scrollTop: number;
52
78
  following: boolean;
53
- /** A title derived from the first line the user submitted into this tab, used
54
- * in preference to the process window title (kiro-cli's OSC 0 title is only
55
- * the cwd for a live session, so it never reflects the conversation).
56
- * Undefined until the first non-empty, non-slash submission. */
79
+ /** Sticky: true once this session emitted a genuine activity signal (OSC 9;4).
80
+ * Its activity dot is shown only while true; a session that never reports
81
+ * activity (a plain shell) keeps a clean, dot-less tab. Fed from the server's
82
+ * reportsActivity via applyStatus. */
83
+ reports: boolean;
84
+ /** A fallback title derived from the LAST non-empty line the user submitted
85
+ * into this tab (updated on every Enter). Used only when the process sets no
86
+ * window title of its own: the OSC 0/2 title (`title` above) takes precedence
87
+ * when present and keeps updating, and this is what the tab reads as when it
88
+ * does not — a plain shell with no PROMPT_COMMAND title, or kiro-cli with its
89
+ * cwd-only title disabled. Undefined until the first non-empty submission; see
90
+ * baseLabel. */
57
91
  derived?: string;
58
92
  }
59
93
 
@@ -80,51 +114,70 @@ export interface TabsOptions {
80
114
  * titles and drop reaped tabs (section 22.5). Ignored when activityMonitor is
81
115
  * present. Default 4000. */
82
116
  pollMs?: number;
117
+ /** The mobileToolbar feature value, so the mobile switcher bar renders a
118
+ * keyboard button that opens the key grid (ctx.use at tap time). Without it
119
+ * the bar shows no keyboard button (e.g. a desktop-only consumer). The
120
+ * toolbar should be built with { externalToggle: true } so its own top-right
121
+ * toggle is hidden and the grid opens above the bar. */
122
+ keyboardToggle?: TerminalFeature<MobileToolbarApi>;
83
123
  }
84
124
 
125
+ // The +/x glyphs are inline SVG (not font glyphs) so they center exactly in
126
+ // their hover box and stay symmetric regardless of the UI font's metrics.
85
127
  const TAB_HTML = `
86
128
  <div class="wt-tab">
87
129
  <span class="wt-tab-dot wt-status-dot" aria-hidden="true"></span>
88
130
  <span class="wt-tab-label"></span>
89
- <button type="button" class="wt-tab-close" aria-label="Close terminal" tabindex="-1">\u00d7</button>
131
+ <button type="button" class="wt-tab-close" aria-label="Close terminal" tabindex="-1"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg></button>
90
132
  </div>`;
91
133
 
92
- const NEW_HTML = `<button type="button" class="wt-tab-new" aria-label="New terminal">+</button>`;
134
+ const NEW_HTML = `<button type="button" class="wt-tab-new" aria-label="New terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>`;
93
135
 
94
- const CLOSEALL_HTML = `<button type="button" class="wt-tab-closeall" aria-label="Close all terminals">Close all</button>`;
95
-
96
- // Mobile bottom-switcher: the active tab (dot + label + position) as a tap/swipe
97
- // surface, plus a >=44px overview control that carries the aggregate badge.
136
+ // Mobile bottom bar. One element, two parts stacked in a column: the always-
137
+ // visible bar row (active tab as a tap/swipe surface + keyboard + "+") on top,
138
+ // and a list of the OTHER tabs BELOW it. On swipe-up / tap the whole bar slides
139
+ // up and the list fills in beneath it (down to the safe area); swipe-down /
140
+ // tap collapses it back to the bottom. Selecting a listed tab swaps it into the
141
+ // active row. This replaces the old separate modal overview sheet ("one element"
142
+ // per the user): the bar itself lifts rather than opening a distinct surface.
143
+ // The bar is the FIRST child and the list the SECOND: the switcher is bottom-
144
+ // anchored, so a column with the list last grows the container upward, lifting
145
+ // the bar and revealing the list below it (DOM order = visual order top-to-bottom).
146
+ // - .wt-switcher-current-wrap: the active-tab row — a select/swipe surface
147
+ // (.wt-switcher-current with dot + label) plus a close (x) overlaid at the
148
+ // right, mirroring the listed rows. No "n / m" counter: the list below is a
149
+ // rotating circular queue, so an absolute position number is meaningless.
150
+ // - .wt-switcher-kb: opens the key grid above the bar (only wired + shown when
151
+ // a keyboardToggle feature is provided).
152
+ // - .wt-switcher-new: the accent "+" that spawns a terminal.
98
153
  const SWITCHER_HTML = `
99
154
  <div class="wt-switcher" role="group" aria-label="Terminal tabs">
100
- <button type="button" class="wt-switcher-current" aria-haspopup="dialog">
101
- <span class="wt-switcher-dot wt-status-dot" aria-hidden="true"></span>
102
- <span class="wt-switcher-label"></span>
103
- <span class="wt-switcher-pos" aria-hidden="true"></span>
104
- </button>
105
- <button type="button" class="wt-switcher-overview wt-btn" aria-haspopup="dialog" aria-label="All terminals">
106
- <span class="wt-switcher-count" aria-hidden="true"></span>
107
- </button>
108
- </div>`;
109
-
110
- // Modal overview sheet (kernel `sheet` region): every tab with title + dot +
111
- // close, plus create. Starts hidden; opened from the switcher.
112
- const SHEET_HTML = `
113
- <div class="wt-sheet" role="dialog" aria-modal="true" aria-label="Terminals" hidden>
114
- <div class="wt-sheet-header">
115
- <span class="wt-sheet-title">Terminals</span>
116
- <button type="button" class="wt-sheet-new wt-btn">+ New terminal</button>
155
+ <div class="wt-switcher-bar">
156
+ <div class="wt-switcher-current-wrap">
157
+ <button type="button" class="wt-switcher-current" aria-haspopup="true" aria-expanded="false">
158
+ <span class="wt-switcher-current-inner">
159
+ <span class="wt-switcher-dot wt-status-dot" aria-hidden="true"></span>
160
+ <span class="wt-switcher-label"></span>
161
+ </span>
162
+ </button>
163
+ <button type="button" class="wt-switcher-current-close wt-btn" aria-label="Close terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg></button>
164
+ </div>
165
+ <button type="button" class="wt-switcher-kb wt-btn" aria-label="Keyboard keys" aria-expanded="false" hidden><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></button>
166
+ <button type="button" class="wt-switcher-new wt-btn wt-switcher-new-btn" aria-label="New terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
117
167
  </div>
118
- <ul class="wt-sheet-list" role="list"></ul>
168
+ <ul class="wt-switcher-list" role="list"></ul>
119
169
  </div>`;
120
170
 
121
- const SHEET_ROW_HTML = `
122
- <li class="wt-sheet-row">
123
- <button type="button" class="wt-sheet-select">
124
- <span class="wt-sheet-row-dot wt-status-dot" aria-hidden="true"></span>
125
- <span class="wt-sheet-row-label"></span>
171
+ // One row per OTHER tab in the expanded list: a stretched select target (dot +
172
+ // label) with the close (x) laid inside it at the right (two buttons can't nest,
173
+ // so the x is a sibling overlapping the select's reserved right padding).
174
+ const SWITCHER_ROW_HTML = `
175
+ <li class="wt-switcher-row">
176
+ <button type="button" class="wt-switcher-row-select">
177
+ <span class="wt-switcher-row-dot wt-status-dot" aria-hidden="true"></span>
178
+ <span class="wt-switcher-row-label"></span>
126
179
  </button>
127
- <button type="button" class="wt-sheet-close wt-btn" aria-label="Close terminal">\u00d7</button>
180
+ <button type="button" class="wt-switcher-row-close wt-btn" aria-label="Close terminal"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg></button>
128
181
  </li>`;
129
182
 
130
183
  export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
@@ -139,9 +192,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
139
192
  }
140
193
  async function apiCreate(): Promise<SessionInfo> {
141
194
  const r = await fetch(apiBase, { method: "POST" });
142
- if (r.status === 429) {
143
- throw new Error("web-terminal-ui: too many sessions");
144
- }
145
195
  if (!r.ok) {
146
196
  throw new Error(`web-terminal-ui: session create failed (${String(r.status)})`);
147
197
  }
@@ -174,43 +224,93 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
174
224
  // end of the tabs (addTabChrome inserts each tab before it).
175
225
  const newBtn = fromHTML(NEW_HTML);
176
226
  bar.appendChild(newBtn);
177
- // A right-anchored "Close all" button (CSS margin-left: auto pushes it to
178
- // the far end of the strip). Desktop only, since it lives inside the bar,
179
- // which is hidden on a coarse pointer.
180
- const closeAllBtn = fromHTML(CLOSEALL_HTML);
181
- bar.appendChild(closeAllBtn);
182
-
183
- // Push the terminal surface below the fixed strip on desktop so the bar
184
- // does not overlap the first rows. The surface is position:fixed inset:0,
185
- // so a top offset (gated to a fine pointer in CSS, since the strip is
186
- // hidden on a coarse pointer where the switcher docks to the bottom
187
- // instead) is what clears it. A ResizeObserver keeps the offset in step
188
- // with the real strip height rather than a hard-coded guess.
227
+
228
+ // Pull the terminal surface up off the fixed BOTTOM strip on desktop so
229
+ // the bar does not overlap the last rows. The surface is position:fixed
230
+ // inset:0, so a bottom offset (gated to a fine pointer in CSS, since the
231
+ // strip is hidden on a coarse pointer where the mobile switcher applies its
232
+ // own inset) clears it. A ResizeObserver keeps the offset in step with the
233
+ // real strip height rather than a hard-coded guess. The measured height is
234
+ // published on the document root (not the surface): the scroll-to-bottom
235
+ // button sits in a sibling region, not inside .term, so a property set on
236
+ // .term would not inherit to it and it would fall back to the 44px guess
237
+ // and overlap the strip. Both .term and the button inherit it from :root.
189
238
  const surface = ctx.surface();
190
- surface.classList.add("wt-with-topbar");
239
+ surface.classList.add("wt-with-tabbar");
191
240
  const barResize = new ResizeObserver(() => {
192
- surface.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
241
+ document.documentElement.style.setProperty(
242
+ "--wt-tabbar-h",
243
+ `${String(bar.offsetHeight)}px`,
244
+ );
193
245
  });
194
246
  barResize.observe(bar);
195
247
 
196
- // --- Mobile bottom-switcher (bottom-switcher region) ---
248
+ // --- Mobile bottom bar (bottom-switcher region) ---
197
249
  const switcher = fromHTML(SWITCHER_HTML);
250
+ const swList = pick(switcher, ".wt-switcher-list");
251
+ const swBar = pick(switcher, ".wt-switcher-bar");
198
252
  const swCurrent = pick(switcher, ".wt-switcher-current");
253
+ // The row's content wrapper: it translates with the finger during a
254
+ // horizontal swipe (the active tab area physically swiping) and slides the
255
+ // incoming tab's label in on commit.
256
+ const swInner = pick(switcher, ".wt-switcher-current-inner");
199
257
  const swDot = pick(switcher, ".wt-switcher-dot");
200
258
  const swLabel = pick(switcher, ".wt-switcher-label");
201
- const swPos = pick(switcher, ".wt-switcher-pos");
202
- const overviewBtn = pick(switcher, ".wt-switcher-overview");
203
- const swCount = pick(switcher, ".wt-switcher-count");
259
+ const swClose = pick(switcher, ".wt-switcher-current-close");
260
+ // The active-tab elements that translate together during a horizontal
261
+ // swipe: the content (dot + label) and the close (x). Moving both keeps the
262
+ // whole active-tab chip sliding as one, rather than the close staying put.
263
+ const swipeEls = [swInner, swClose];
264
+ const swKb = pick(switcher, ".wt-switcher-kb");
265
+ const swNew = pick(switcher, ".wt-switcher-new");
204
266
  ctx.region("bottom-switcher", "switcher").appendChild(switcher);
267
+ // The keyboard button opens the key grid; show it only when a toolbar is
268
+ // wired to drive. Read the toolbar's API lazily at tap time (ctx.use), so
269
+ // feature ordering does not matter.
270
+ let offArmed: (() => void) | undefined;
271
+ if (opts.keyboardToggle) {
272
+ swKb.hidden = false;
273
+ // Mirror sticky-Ctrl on the keyboard button: when a Ctrl press is armed,
274
+ // invert the button (like the armed Ctrl key) so the pending modifier is
275
+ // visible with the grid closed — the toolbar sets up before tabs, so its
276
+ // API is available now (see the preset ordering note). Also clears on the
277
+ // auto-disarm after a Ctrl byte and on a tab switch (onDetach disarms).
278
+ const kbApi = ctx.use(opts.keyboardToggle);
279
+ if (kbApi) {
280
+ const reflectArmed = (armed: boolean): void => {
281
+ swKb.classList.toggle("wt-armed", armed);
282
+ };
283
+ reflectArmed(kbApi.isCtrlArmed());
284
+ offArmed = kbApi.onCtrlArmedChange(reflectArmed);
285
+ }
286
+ }
287
+ // Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
288
+ // scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
289
+ const root = ctx.surface().parentElement;
290
+ root?.classList.add("wt-tabbed");
291
+ // Reserve the collapsed bar row's height so terminal content stops above it
292
+ // (mobile item 2): viewport.ts adds --wt-reserve-bottom to the surface's
293
+ // bottom inset. Measure the bar row (not the expandable list, which just
294
+ // overlays content). innerHeight - rect.top captures the row plus the
295
+ // safe-area beneath it; the RO fires with the keyboard closed, so the value
296
+ // excludes the keyboard lift (viewport.ts adds that separately). The
297
+ // synthetic visualViewport resize makes viewport.ts recompute immediately.
298
+ const swReserve = new ResizeObserver(() => {
299
+ const rect = swBar.getBoundingClientRect();
300
+ const px = rect.height > 0 ? Math.max(0, Math.round(window.innerHeight - rect.top)) : 0;
301
+ document.documentElement.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
302
+ window.visualViewport?.dispatchEvent(new Event("resize"));
303
+ });
304
+ swReserve.observe(swBar);
205
305
 
206
- // --- Overview sheet + scrim (sheet region) ---
207
- const sheetSlot = ctx.region("sheet", "overview");
208
- const scrim = document.createElement("div");
209
- scrim.className = "wt-sheet-scrim";
210
- const sheet = fromHTML(SHEET_HTML);
211
- const sheetList = pick(sheet, ".wt-sheet-list");
212
- const sheetNewBtn = pick(sheet, ".wt-sheet-new");
213
- sheetSlot.append(scrim, sheet);
306
+ // Activity dots are revealed PER TAB, not chrome-wide: each dot stays
307
+ // hidden (CSS: .wt-status-dot { display: none }) until its session reports
308
+ // activity (OSC 9;4 progress or a classified OSC 9 notification), at which
309
+ // point paintStatusDot adds .wt-reports to reveal it (see applyStatus /
310
+ // syncMobile / updateRow). A program that emits no OSC 9 signal (a plain
311
+ // bash/sh) keeps clean, label-only tabs; an agent's tabs light up. The
312
+ // monitor (below) is the live source; without it the poll fallback feeds
313
+ // the same reportsActivity flag.
214
314
 
215
315
  // Catching-up cue: a switched-into tab's cached screen is stale until its
216
316
  // resume delta lands, so it must not read as live (sections 12/13). Shown
@@ -223,50 +323,85 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
223
323
  ctx.region("banner", "catchup").appendChild(catchup);
224
324
  let catchupTimer: ReturnType<typeof setTimeout> | null = null;
225
325
 
326
+ // --- Desktop right-click tab context menu (overlay region) ---
327
+ // Replaces the old bar "Close all" button with a richer per-tab menu. Built
328
+ // on demand (mirroring context-menu.ts) so each item targets the
329
+ // right-clicked tab and its disabled state reflects that tab's position.
330
+ const tabMenu = document.createElement("div");
331
+ tabMenu.className = "wt-tab-menu";
332
+ tabMenu.setAttribute("role", "menu");
333
+ ctx.region("overlay", "tab-menu").appendChild(tabMenu);
334
+
226
335
  const tabList: Tab[] = [];
336
+ // The expanded mobile list's row elements, keyed by tab id. Rows are
337
+ // reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
338
+ // same elements from their old slots to their new ones (the rotation).
339
+ const rowEls = new Map<string, HTMLElement>();
227
340
  let activeId: string | null = null;
228
- let overviewOpen = false;
229
- let lastFocus: HTMLElement | null = null;
341
+ let draggingEl: HTMLElement | null = null;
342
+ // Gate the new-tab enter animation: tabs present at initial population
343
+ // should not animate in (jarring on load); only tabs added at runtime do.
344
+ let started = false;
345
+ let expanded = false;
346
+ // Interactive horizontal-swipe preview: while a swipe drags with the list
347
+ // open, the rows peek a few pixels in the swipe direction (dragActive); the
348
+ // release reel then continues from wherever they are.
349
+ let dragActive = false;
350
+ // True while a reel's reconcile runs (a swipe switch): renderSwitcherList
351
+ // suppresses its add/remove row animation then, since the reel owns row
352
+ // motion. creatingTab suppresses the reel for a create, so the new listed
353
+ // row grows+fades in (animateRowIn) rather than rotating.
354
+ let reelReconcile = false;
355
+ let creatingTab = false;
356
+ let collapseClearTimer: ReturnType<typeof setTimeout> | null = null;
230
357
  let hintShown = false;
231
- let tabSeq = 0; // monotonic source for the "Tab N" fallback number
232
358
  // On a touch device, focusing the hidden input opens the virtual keyboard.
233
359
  // A switch triggered by tapping/swiping the tab UI must not do that (it is
234
360
  // disruptive), so the input is focused on a switch only with a fine
235
361
  // pointer; on touch the user taps the terminal itself to open the keyboard.
236
362
  const coarse = window.matchMedia("(pointer: coarse)").matches;
363
+ // Motion opt-out (checked live: the OS setting can change). Gates the
364
+ // interactive swipe/rotation animations, mirroring the CSS .wt-animate gate.
365
+ const prefersReduce = (): boolean =>
366
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
237
367
 
238
- // labelFor is a tab's base label before de-duplication. Preference order:
239
- // the title derived from the user's first submitted line (best for a chat
240
- // like kiro-cli, whose OSC 0 title only ever reflects the cwd for a live
241
- // session), then the process window title (OSC 0/2, e.g. a shell's), then
242
- // a stable "Tab N". This fixes both the generic "terminal" label and the
243
- // static "kiro: /workspace" one: a tab now names itself from what you type.
244
- function labelFor(tab: Tab): string {
245
- // derived is only ever set to a non-empty value, so ?? and || agree on
246
- // it; the || on the (possibly empty) window title falls through to "Tab N".
247
- return (tab.derived?.trim() ?? tab.title.trim()) || `Tab ${String(tab.num)}`;
368
+ // baseLabel is a tab's label before de-duplication. Preference order: the
369
+ // process window title (OSC 0/2, e.g. a shell's PROMPT_COMMAND title),
370
+ // which takes precedence whenever the program sets one and keeps updating
371
+ // as it changes (the status SSE re-pushes it); then a fallback derived from
372
+ // the last line the user submitted, for a program that sets no title (a
373
+ // bare shell, or kiro-cli with its cwd-only title disabled); then a plain
374
+ // "New tab". fallback=true marks that last case so relabelAll leaves
375
+ // untitled tabs as "New tab" with no numeric suffix.
376
+ function baseLabel(tab: Tab): { text: string; fallback: boolean } {
377
+ const real = tab.title.trim() || (tab.derived?.trim() ?? "");
378
+ return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
248
379
  }
249
380
 
250
381
  // relabelAll recomputes every tab's display label with de-duplication:
251
- // when two tabs resolve to the same base label (e.g. two tabs whose first
252
- // submitted line was identical, or two shells with the same window title),
382
+ // when two tabs resolve to the same base label (e.g. two shells with the
383
+ // same window title, or two tabs whose last submitted line was identical),
253
384
  // the second and later get a " (k)" suffix in creation order, so the strip
254
385
  // never shows two identical labels.
255
386
  function relabelAll(): void {
387
+ // Count only real (non-fallback) labels, so multiple untitled tabs all
388
+ // read "New tab" without a numeric suffix.
256
389
  const counts = new Map<string, number>();
257
390
  for (const t of tabList) {
258
- const base = labelFor(t);
259
- counts.set(base, (counts.get(base) ?? 0) + 1);
391
+ const { text, fallback } = baseLabel(t);
392
+ if (!fallback) {
393
+ counts.set(text, (counts.get(text) ?? 0) + 1);
394
+ }
260
395
  }
261
396
  const seen = new Map<string, number>();
262
397
  for (const t of tabList) {
263
- const base = labelFor(t);
264
- let display = base;
265
- if ((counts.get(base) ?? 0) > 1) {
266
- const k = (seen.get(base) ?? 0) + 1;
267
- seen.set(base, k);
398
+ const { text, fallback } = baseLabel(t);
399
+ let display = text;
400
+ if (!fallback && (counts.get(text) ?? 0) > 1) {
401
+ const k = (seen.get(text) ?? 0) + 1;
402
+ seen.set(text, k);
268
403
  if (k > 1) {
269
- display = `${base} (${String(k)})`;
404
+ display = `${text} (${String(k)})`;
270
405
  }
271
406
  }
272
407
  t.display = display;
@@ -275,16 +410,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
275
410
  }
276
411
  }
277
412
 
278
- // --- Title derivation from the user's first submitted line ---
279
- // kiro-cli's OSC 0 title is only the cwd for a live session (it reloads its
280
- // session title just when the session id changes, not per turn), so the
281
- // useful, updating title for a chat is the user's first message. The kernel
282
- // routes input to the active session, so an input observer's bytes belong
283
- // to activeId. A tiny line editor tracks the current input line (handling
284
- // backspace and skipping the escape sequences arrow keys and bracketed
285
- // paste emit) and locks the tab's title on the first non-empty, non-slash
286
- // submission. Best-effort: an odd editing sequence just yields no derived
287
- // title, falling back to the window title or "Tab N".
413
+ // --- Fallback title derivation from the user's submitted lines ---
414
+ // Used only when the process sets no OSC window title of its own (baseLabel
415
+ // prefers tab.title). A shell without a PROMPT_COMMAND title, or kiro-cli
416
+ // with its cwd-only title disabled, sets none — so the useful fallback is
417
+ // the last line the user submitted. The kernel routes input to the active
418
+ // session, so an input observer's bytes belong to activeId. A tiny line
419
+ // editor tracks the current input line (handling backspace and skipping
420
+ // the escape sequences arrow keys and bracketed paste emit) and updates the
421
+ // tab's derived title on every non-empty submission. Best-effort: an odd
422
+ // editing sequence just yields no derived title (the tab then reads "New
423
+ // tab" until the next submission).
288
424
  const MAX_DERIVED = 60;
289
425
  let lineBytes: number[] = [];
290
426
  let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
@@ -294,8 +430,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
294
430
  }
295
431
  function deriveTitleFromInput(bytes: Uint8Array): void {
296
432
  const t = tabList.find((x) => x.id === activeId);
297
- if (!t || t.derived !== undefined) {
298
- return; // no active tab, or its first submission is already captured
433
+ if (!t) {
434
+ return; // no active tab
299
435
  }
300
436
  for (const b of bytes) {
301
437
  if (escState === 1) {
@@ -317,10 +453,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
317
453
  } else if (b === 0x0d || b === 0x0a) {
318
454
  const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
319
455
  lineBytes = [];
320
- if (line && !line.startsWith("/")) {
456
+ // Every non-empty submitted line updates the fallback title (the
457
+ // last one wins); a leading "/" counts, since it is a valid shell
458
+ // command path.
459
+ if (line) {
321
460
  t.derived = line.slice(0, MAX_DERIVED);
322
461
  syncChrome();
323
- return;
324
462
  }
325
463
  } else if (b === 0x7f || b === 0x08) {
326
464
  // Backspace: drop one codepoint (pop UTF-8 continuation bytes + lead).
@@ -353,59 +491,250 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
353
491
  }
354
492
  }
355
493
 
356
- // syncMobile updates the bottom switcher: active label + position, the tab
357
- // count, and the aggregate needs-input badge (so a background tab blocked
358
- // on input is glanceable on mobile without opening the sheet, section 12).
494
+ // syncMobile updates the bottom bar: active label + dot, and the aggregate
495
+ // needs-input cue. The cue rides the active surface: a background tab
496
+ // blocked on input is glanceable, and tapping/swiping opens the list to
497
+ // resolve it (section 12).
359
498
  function syncMobile(): void {
360
499
  const idx = tabList.findIndex((t) => t.id === activeId);
361
500
  const active = idx >= 0 ? tabList[idx] : undefined;
362
501
  swLabel.textContent = active ? active.display : "";
363
- swDot.dataset["status"] = active?.dot.dataset["status"] ?? "idle";
364
- swPos.textContent =
365
- tabList.length > 1 ? `${String(idx + 1)} / ${String(tabList.length)}` : "";
366
- swCount.textContent = String(tabList.length);
502
+ paintStatusDot(swDot, active?.dot.dataset["status"] ?? "idle", active?.reports ?? false);
367
503
  const bgInput = tabList.some(
368
504
  (t) => t.id !== activeId && t.dot.dataset["status"] === "input",
369
505
  );
370
506
  if (bgInput) {
371
- overviewBtn.dataset["attention"] = "input";
372
- overviewBtn.setAttribute(
373
- "aria-label",
374
- "All terminals; a background terminal needs input",
375
- );
507
+ swCurrent.dataset["attention"] = "input";
508
+ swCurrent.setAttribute("aria-label", "Terminals; a background terminal needs input");
376
509
  } else {
377
- delete overviewBtn.dataset["attention"];
378
- overviewBtn.setAttribute("aria-label", "All terminals");
510
+ delete swCurrent.dataset["attention"];
511
+ swCurrent.removeAttribute("aria-label");
379
512
  }
380
513
  }
381
514
 
382
- // renderOverview rebuilds the sheet's tab rows from the current tabList.
383
- function renderOverview(): void {
384
- sheetList.replaceChildren();
385
- for (const t of tabList) {
386
- const row = fromHTML(SHEET_ROW_HTML);
387
- const rdot = pick(row, ".wt-sheet-row-dot");
388
- const rlabel = pick(row, ".wt-sheet-row-label");
389
- const rselect = pick(row, ".wt-sheet-select");
390
- const rclose = pick(row, ".wt-sheet-close");
391
- rdot.dataset["status"] = t.dot.dataset["status"] ?? "idle";
392
- rlabel.textContent = t.display;
393
- if (t.id === activeId) {
394
- row.classList.add("wt-sheet-row-active");
395
- rselect.setAttribute("aria-current", "true");
396
- }
397
- rselect.addEventListener("click", () => {
398
- // Close first so focus restores off the sheet, then switch (which
399
- // focuses the terminal) so focus lands in the terminal, not the bar.
400
- closeOverview();
401
- switchTo(t.id);
402
- });
403
- rclose.addEventListener("click", (e) => {
404
- e.stopPropagation();
405
- void close_(t.id);
406
- });
407
- sheetList.appendChild(row);
515
+ // buildRow creates one expanded-list row for a tab and wires its handlers
516
+ // (select = switch + collapse; x = close). Its dot/label are filled by
517
+ // updateRow. The element is cached in rowEls and reused across renders.
518
+ function buildRow(t: Tab): HTMLElement {
519
+ const row = fromHTML(SWITCHER_ROW_HTML);
520
+ pick(row, ".wt-switcher-row-select").addEventListener("click", () => {
521
+ collapseSwitcher();
522
+ switchTo(t.id);
523
+ });
524
+ pick(row, ".wt-switcher-row-close").addEventListener("click", (e) => {
525
+ e.stopPropagation();
526
+ void close_(t.id);
527
+ });
528
+ return row;
529
+ }
530
+ // updateRow refreshes a reused row's live bits (status dot + label).
531
+ function updateRow(row: HTMLElement, t: Tab): void {
532
+ paintStatusDot(
533
+ pick(row, ".wt-switcher-row-dot"),
534
+ t.dot.dataset["status"] ?? "idle",
535
+ t.reports,
536
+ );
537
+ pick(row, ".wt-switcher-row-label").textContent = t.display;
538
+ }
539
+ // animateRowIn / animateRowOut give a listed tab an enter / leave motion:
540
+ // the row's own max-height grows from 0 (fading in) on add, and collapses
541
+ // to 0 (fading out, then removed) on close. The flex list's height follows
542
+ // the row, so adding/closing a tab animates the tray height rather than
543
+ // snapping. Inline-driven (cleared when done); the caller gates motion.
544
+ const ROW_ANIM_MS = 220;
545
+ const ROW_ANIM_EASE = "cubic-bezier(0.2, 0, 0, 1)";
546
+ function animateRowIn(row: HTMLElement): void {
547
+ const h = row.getBoundingClientRect().height;
548
+ if (h <= 0) {
549
+ return;
408
550
  }
551
+ row.style.overflow = "hidden";
552
+ row.style.transition = "none";
553
+ row.style.maxHeight = "0";
554
+ row.style.opacity = "0";
555
+ requestAnimationFrame(() => {
556
+ row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
557
+ row.style.maxHeight = `${String(Math.ceil(h))}px`;
558
+ row.style.opacity = "1";
559
+ });
560
+ window.setTimeout(() => {
561
+ row.style.transition = "";
562
+ row.style.maxHeight = "";
563
+ row.style.opacity = "";
564
+ row.style.overflow = "";
565
+ }, ROW_ANIM_MS + 60);
566
+ }
567
+ function animateRowOut(row: HTMLElement): void {
568
+ const h = row.getBoundingClientRect().height;
569
+ row.style.overflow = "hidden";
570
+ row.style.pointerEvents = "none";
571
+ row.style.transition = "none";
572
+ row.style.maxHeight = `${String(Math.ceil(h))}px`;
573
+ row.style.opacity = "1";
574
+ requestAnimationFrame(() => {
575
+ row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
576
+ row.style.maxHeight = "0";
577
+ row.style.opacity = "0";
578
+ });
579
+ window.setTimeout(() => {
580
+ row.remove();
581
+ }, ROW_ANIM_MS + 60);
582
+ }
583
+
584
+ // renderSwitcherList reconciles the expanded list to a row per OTHER tab
585
+ // (the active tab lives in the bar row), REUSING existing row elements
586
+ // rather than rebuilding, so element identity is stable and the swipe
587
+ // rotation can FLIP the same rows between positions. Order is circular
588
+ // starting AFTER the active tab, so the list reads as the queue that
589
+ // follows the current one (active #k -> k+1, k+2, ... wrapping around); as
590
+ // the active tab changes on a swipe, this order rotates by one.
591
+ function renderSwitcherList(): void {
592
+ const n = tabList.length;
593
+ const activeIdx = tabList.findIndex((t) => t.id === activeId);
594
+ const start = activeIdx >= 0 ? activeIdx : 0;
595
+ const desired: Tab[] = [];
596
+ for (let step = 1; step < n; step++) {
597
+ const t = tabList[(start + step) % n];
598
+ if (t) {
599
+ desired.push(t);
600
+ }
601
+ }
602
+ // Animate an incremental add/close only on an already-open list: NOT
603
+ // during the initial expand (expanded is still false while expandSwitcher
604
+ // populates), NOT during a reel (it owns row motion), NOT under reduced
605
+ // motion — those paths reveal/move rows their own way.
606
+ const anim = expanded && !reelReconcile && !prefersReduce();
607
+ const keep = new Set(desired.map((t) => t.id));
608
+ for (const [id, el] of rowEls) {
609
+ if (!keep.has(id)) {
610
+ rowEls.delete(id);
611
+ if (anim) {
612
+ animateRowOut(el); // collapse + fade, then remove
613
+ } else {
614
+ el.remove();
615
+ }
616
+ }
617
+ }
618
+ // Append in desired order: appendChild moves an existing node, so this
619
+ // both inserts new rows and reorders reused ones into the new sequence.
620
+ for (const t of desired) {
621
+ let row = rowEls.get(t.id);
622
+ const isNew = row === undefined;
623
+ if (!row) {
624
+ row = buildRow(t);
625
+ rowEls.set(t.id, row);
626
+ }
627
+ updateRow(row, t);
628
+ swList.appendChild(row);
629
+ if (isNew && anim) {
630
+ animateRowIn(row); // grow + fade in (the tray height follows)
631
+ }
632
+ }
633
+ }
634
+
635
+ // clearRows empties the list and drops the reused-row cache (after a
636
+ // collapse), so the next expand rebuilds fresh rather than reusing rows
637
+ // that might carry a stale reel transform.
638
+ function clearRows(): void {
639
+ endReelNow();
640
+ swList.replaceChildren();
641
+ rowEls.clear();
642
+ }
643
+
644
+ // The circular-queue rotation, as a true reel: when a swipe switches the
645
+ // active tab while the list is expanded, every surviving row slides one
646
+ // slot (the rows visibly rotate past a fixed frame), the row that becomes
647
+ // active exits the leading edge, and the row that was active enters the
648
+ // trailing edge. It is a FLIP over reused row elements: prepareReel (run
649
+ // BEFORE syncChrome reconciles the list) snapshots the current row pixel
650
+ // positions and lifts the leaving row out of the flow as an absolute ghost
651
+ // so the reconcile can't reshuffle the survivors; the returned closure
652
+ // (run AFTER the reconcile) inverts every row to its old spot and releases
653
+ // it to the new one, and slides the ghost out. Pixel positions make it
654
+ // correct regardless of the row gap, list padding, or separator border.
655
+ const REEL_MS = 300;
656
+ let reelTimer: ReturnType<typeof setTimeout> | null = null;
657
+ let reelGhost: HTMLElement | null = null;
658
+ // endReelNow settles any in-flight reel immediately: drop the ghost, clear
659
+ // the row transforms, and hand overflow/position back to the stylesheet.
660
+ function endReelNow(): void {
661
+ if (reelTimer !== null) {
662
+ clearTimeout(reelTimer);
663
+ reelTimer = null;
664
+ }
665
+ if (reelGhost) {
666
+ reelGhost.remove();
667
+ reelGhost = null;
668
+ }
669
+ swList.style.overflow = "";
670
+ swList.style.position = "";
671
+ for (const el of rowEls.values()) {
672
+ el.style.transition = "";
673
+ el.style.transform = "";
674
+ el.style.opacity = "";
675
+ }
676
+ }
677
+ // leaving is the tab becoming active (its row exits the list); the entering
678
+ // row (the previously-active tab) is built by the reconcile that runs
679
+ // between prepareReel and the returned closure, so it needs no argument.
680
+ function prepareReel(dir: "next" | "prev", leaving: Tab): (() => void) | undefined {
681
+ const ghost = rowEls.get(leaving.id);
682
+ if (!ghost) {
683
+ return undefined;
684
+ }
685
+ // Capture positions BEFORE settling any in-flight transform, so a live
686
+ // drag preview flows into the reel: each row starts where the finger left
687
+ // it (First includes the preview offset). For a plain flick with no
688
+ // preview, these are simply the rows' rest positions.
689
+ const firstTops = new Map<string, number>();
690
+ for (const [id, el] of rowEls) {
691
+ firstTops.set(id, el.getBoundingClientRect().top);
692
+ }
693
+ const listRect = swList.getBoundingClientRect();
694
+ const st = getComputedStyle(swList);
695
+ const pitch = ghost.getBoundingClientRect().height + (parseFloat(st.rowGap) || 0);
696
+ const top = (firstTops.get(leaving.id) ?? listRect.top) - listRect.top + swList.scrollTop;
697
+ endReelNow(); // settle a prior reel / the drag preview before this one
698
+ // Freeze the frame: clip overflow so rows leaving/entering are masked at
699
+ // the edges, and anchor the leaving row absolutely at its captured spot so
700
+ // the reconcile that follows leaves the survivors where they are.
701
+ swList.style.overflow = "hidden";
702
+ swList.style.position = "relative";
703
+ rowEls.delete(leaving.id); // the reconcile must not touch the ghost
704
+ ghost.style.position = "absolute";
705
+ ghost.style.left = st.paddingLeft;
706
+ ghost.style.right = st.paddingRight;
707
+ ghost.style.top = `${String(Math.round(top))}px`;
708
+ ghost.style.pointerEvents = "none";
709
+ reelGhost = ghost;
710
+
711
+ return () => {
712
+ for (const [id, el] of rowEls) {
713
+ const first = firstTops.get(id);
714
+ let from: number;
715
+ if (first !== undefined) {
716
+ from = first - el.getBoundingClientRect().top; // survivor: old spot -> new
717
+ } else {
718
+ from = dir === "next" ? pitch : -pitch; // newcomer: in from the trailing edge
719
+ el.style.opacity = "0";
720
+ }
721
+ el.style.transition = "none";
722
+ el.style.transform = `translateY(${String(Math.round(from))}px)`;
723
+ }
724
+ const exit = dir === "next" ? -pitch : pitch; // leaving row off the leading edge
725
+ requestAnimationFrame(() => {
726
+ const trans = "transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.2s ease-out";
727
+ for (const el of rowEls.values()) {
728
+ el.style.transition = trans;
729
+ el.style.transform = "translateY(0)";
730
+ el.style.opacity = "";
731
+ }
732
+ ghost.style.transition = trans;
733
+ ghost.style.transform = `translateY(${String(Math.round(exit))}px)`;
734
+ ghost.style.opacity = "0";
735
+ });
736
+ reelTimer = setTimeout(endReelNow, REEL_MS);
737
+ };
409
738
  }
410
739
 
411
740
  // syncChrome refreshes every surface after any state change. Idempotent.
@@ -413,36 +742,88 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
413
742
  relabelAll();
414
743
  paintActive();
415
744
  syncMobile();
416
- if (overviewOpen) {
417
- renderOverview();
745
+ if (expanded) {
746
+ renderSwitcherList();
418
747
  }
419
748
  maybeSwipeHint();
420
749
  }
421
750
 
422
- function openOverview(): void {
423
- if (overviewOpen) {
751
+ // closeKeyGrid closes the mobile key grid (if a keyboardToggle is wired and
752
+ // open) and resets the switcher's keyboard button state. Used when the tab
753
+ // list expands (the button is hidden then, so the grid must not linger
754
+ // behind it) and when a tap on the terminal dismisses an open grid.
755
+ function closeKeyGrid(): void {
756
+ const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
757
+ if (kb?.isOpen()) {
758
+ kb.toggle();
759
+ }
760
+ swKb.setAttribute("aria-expanded", "false");
761
+ swKb.classList.remove("wt-active");
762
+ }
763
+
764
+ // setExpandedState applies the resting expanded/collapsed state: the class
765
+ // that drives the list's max-height (and its padding/border), the aria
766
+ // flag, and (on collapse) the deferred row clear so the rows leave the a11y
767
+ // tree only after the collapse animation. It does not render rows or touch
768
+ // the inline styles the interactive drag uses; expandSwitcher and the drag
769
+ // release own those.
770
+ function setExpandedState(on: boolean): void {
771
+ if (on) {
772
+ if (collapseClearTimer !== null) {
773
+ clearTimeout(collapseClearTimer);
774
+ collapseClearTimer = null;
775
+ }
776
+ expanded = true;
777
+ switcher.classList.add("wt-switcher-expanded");
778
+ swCurrent.setAttribute("aria-expanded", "true");
779
+ // Simplify the crowded bar: close the key grid and hide the keyboard
780
+ // button while the list is open (CSS collapses it and the active row
781
+ // grows to fill). Avoids the grid opening behind the expanded list.
782
+ closeKeyGrid();
783
+ } else {
784
+ expanded = false;
785
+ switcher.classList.remove("wt-switcher-expanded");
786
+ swCurrent.setAttribute("aria-expanded", "false");
787
+ if (collapseClearTimer !== null) {
788
+ clearTimeout(collapseClearTimer);
789
+ }
790
+ collapseClearTimer = setTimeout(() => {
791
+ collapseClearTimer = null;
792
+ if (!expanded) {
793
+ clearRows();
794
+ }
795
+ }, 260);
796
+ }
797
+ }
798
+
799
+ // expandSwitcher grows the bar to list the other tabs below the active row
800
+ // (swipe-up / tap). No-op with a single tab (nothing to list). Not modal:
801
+ // it never steals focus (focusing the hidden input on touch would pop the
802
+ // keyboard).
803
+ function expandSwitcher(): void {
804
+ if (expanded || tabList.length < 2) {
424
805
  return;
425
806
  }
426
- overviewOpen = true;
427
- lastFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
428
- renderOverview();
429
- scrim.classList.add("visible");
430
- sheet.hidden = false;
431
- sheet.classList.add("visible");
432
- sheet.querySelector<HTMLElement>("button")?.focus();
433
- ctx.announce("Terminals list opened");
807
+ renderSwitcherList();
808
+ setExpandedState(true);
809
+ ctx.announce("Terminal list expanded");
434
810
  }
435
811
 
436
- function closeOverview(): void {
437
- if (!overviewOpen) {
812
+ // collapseSwitcher shrinks the bar back to just the active row (swipe-down /
813
+ // tap / select).
814
+ function collapseSwitcher(): void {
815
+ if (!expanded) {
438
816
  return;
439
817
  }
440
- overviewOpen = false;
441
- scrim.classList.remove("visible");
442
- sheet.classList.remove("visible");
443
- sheet.hidden = true;
444
- lastFocus?.focus();
445
- lastFocus = null;
818
+ setExpandedState(false);
819
+ }
820
+
821
+ function toggleSwitcher(): void {
822
+ if (expanded) {
823
+ collapseSwitcher();
824
+ } else {
825
+ expandSwitcher();
826
+ }
446
827
  }
447
828
 
448
829
  function addTabChrome(info: SessionInfo): Tab {
@@ -453,15 +834,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
453
834
  if (!label || !dot || !close) {
454
835
  throw new Error("web-terminal-ui: tab chrome missing parts");
455
836
  }
456
- dot.dataset["status"] = info.status || "idle";
837
+ paintStatusDot(dot, info.status, info.reportsActivity ?? false);
457
838
  const aria = tablist.registerTab(el);
458
839
  // Insert before the "+" button so it stays the last item in the strip.
459
840
  bar.insertBefore(el, newBtn);
841
+ // Runtime-added tabs animate in; initial tabs do not (see `started`).
842
+ // The timer (not animationend) also clears the class on the hidden mobile
843
+ // strip, where the animation never fires.
844
+ if (started) {
845
+ el.classList.add("wt-tab-enter");
846
+ setTimeout(() => {
847
+ el.classList.remove("wt-tab-enter");
848
+ }, 300);
849
+ }
460
850
 
461
851
  const tab: Tab = {
462
852
  id: info.id,
463
853
  title: info.title,
464
- num: ++tabSeq,
465
854
  display: "",
466
855
  createdAt: info.createdAt,
467
856
  store: new LineStore(),
@@ -471,10 +860,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
471
860
  aria,
472
861
  scrollTop: 0,
473
862
  following: true,
863
+ reports: info.reportsActivity ?? false,
474
864
  };
475
865
  // Set an initial label immediately (relabelAll refines it with de-dup
476
866
  // once the tab is in tabList and syncChrome runs).
477
- tab.display = labelFor(tab);
867
+ tab.display = baseLabel(tab).text;
478
868
  label.textContent = tab.display;
479
869
  aria.setLabel(tab.display);
480
870
  el.addEventListener("click", (e) => {
@@ -500,10 +890,35 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
500
890
  void close_(tab.id);
501
891
  }
502
892
  });
893
+ // Right-click opens the tab context menu (desktop). preventDefault stops
894
+ // the browser's own menu; the strip is hidden on a coarse pointer, so
895
+ // this is desktop-only in practice.
896
+ el.addEventListener("contextmenu", (e) => {
897
+ e.preventDefault();
898
+ showTabMenu(e.clientX, e.clientY, tab.id);
899
+ });
900
+ // Drag-and-drop reorder on the desktop strip. The bar's dragover moves
901
+ // this element live; dragend commits the new order into tabList.
902
+ el.draggable = true;
903
+ el.addEventListener("dragstart", (e) => {
904
+ draggingEl = el;
905
+ el.classList.add("wt-tab-dragging");
906
+ if (e.dataTransfer) {
907
+ e.dataTransfer.effectAllowed = "move";
908
+ // Firefox requires drag data to be set for the drag to start.
909
+ e.dataTransfer.setData("text/plain", tab.id);
910
+ }
911
+ hideTabMenu();
912
+ });
913
+ el.addEventListener("dragend", () => {
914
+ el.classList.remove("wt-tab-dragging");
915
+ draggingEl = null;
916
+ syncOrderFromDom();
917
+ });
503
918
  return tab;
504
919
  }
505
920
 
506
- function switchTo(id: string): void {
921
+ function switchTo(id: string, dir?: "next" | "prev"): void {
507
922
  if (id === activeId) {
508
923
  return;
509
924
  }
@@ -511,6 +926,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
511
926
  if (!next) {
512
927
  return;
513
928
  }
929
+ // Derive a slide direction from the index delta when the caller did not
930
+ // give one (a desktop tab click, a sheet select): moving to a later tab
931
+ // slides the incoming content in from the right, an earlier tab from the
932
+ // left, so desktop switches feel like the mobile swipe.
933
+ let slide = dir;
934
+ if (slide === undefined && activeId !== null) {
935
+ const from = tabList.findIndex((t) => t.id === activeId);
936
+ const to = tabList.findIndex((t) => t.id === id);
937
+ if (from >= 0 && to >= 0) {
938
+ slide = to > from ? "next" : "prev";
939
+ }
940
+ }
514
941
  const surface = ctx.surface();
515
942
  // Detach the current tab: save its scroll memory (keep its cache).
516
943
  const cur = tabList.find((t) => t.id === activeId);
@@ -518,6 +945,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
518
945
  cur.scrollTop = surface.scrollTop;
519
946
  cur.following = !ctx.scroll.isUserScrolledUp();
520
947
  }
948
+ // Decide whether to animate the expanded list as a rotation: only a
949
+ // swipe to an adjacent tab while the list is open. prepareReel snapshots
950
+ // the rows BEFORE the reconcile below; the returned closure FLIPs them
951
+ // into their new slots after it.
952
+ const fromIdx = tabList.findIndex((t) => t.id === activeId);
953
+ const toIdx = tabList.findIndex((t) => t.id === next.id);
954
+ // A one-step move: adjacent, OR a wrap between the first and last tab
955
+ // (index gap n-1), since the list rotates infinitely.
956
+ const stepGap = Math.abs(toIdx - fromIdx);
957
+ let playReel: (() => void) | undefined;
958
+ if (
959
+ expanded &&
960
+ !creatingTab &&
961
+ (slide === "next" || slide === "prev") &&
962
+ (stepGap === 1 || stepGap === tabList.length - 1) &&
963
+ !prefersReduce()
964
+ ) {
965
+ playReel = prepareReel(slide, next);
966
+ }
521
967
  // Attach the next tab: point the renderer at its cached store and
522
968
  // rebuild viewport-first, so the last-known screen paints with no
523
969
  // round-trip. Then let the kernel reconnect the WS to it (resume delta).
@@ -526,8 +972,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
526
972
  ctx.render.bind(next.store);
527
973
  ctx.notifySwitch({ id: next.id });
528
974
  armCatchup();
529
- flashSwitch();
530
- syncChrome();
975
+ flashSwitch(slide);
976
+ // Mark the reconcile as reel-driven so renderSwitcherList suppresses its
977
+ // add/remove row animation (the reel owns row motion here).
978
+ reelReconcile = playReel !== undefined;
979
+ syncChrome(); // reconciles the expanded list into the new order
980
+ reelReconcile = false;
981
+ playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
531
982
  // Restore scroll memory best-effort after the async rebuild; a
532
983
  // following tab sticks to the bottom on its own.
533
984
  const savedTop = next.scrollTop;
@@ -563,14 +1014,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
563
1014
  }
564
1015
  catchup.classList.remove("visible");
565
1016
  }
566
- // flashSwitch adds a class the animations feature keys a brief content fade
567
- // off (a no-op when animations are absent or reduced-motion is set).
568
- function flashSwitch(): void {
1017
+ // flashSwitch plays the switch animation the animations feature keys off
1018
+ // (a no-op when animations are absent or reduced-motion is set). With a
1019
+ // direction the incoming content slides in from that side (the swipe
1020
+ // feel); without one it is a plain cross-fade. The rAF re-adds the class a
1021
+ // frame after clearing it so a rapid re-switch restarts the animation.
1022
+ let switchAnimTimer: ReturnType<typeof setTimeout> | null = null;
1023
+ function flashSwitch(dir?: "next" | "prev"): void {
569
1024
  const surface = ctx.surface();
570
- surface.classList.add("wt-switching");
571
- setTimeout(() => {
572
- surface.classList.remove("wt-switching");
573
- }, 300);
1025
+ const cls = dir ? `wt-switching-${dir}` : "wt-switching";
1026
+ surface.classList.remove("wt-switching", "wt-switching-next", "wt-switching-prev");
1027
+ if (switchAnimTimer !== null) {
1028
+ clearTimeout(switchAnimTimer);
1029
+ }
1030
+ requestAnimationFrame(() => {
1031
+ surface.classList.add(cls);
1032
+ });
1033
+ switchAnimTimer = setTimeout(() => {
1034
+ switchAnimTimer = null;
1035
+ surface.classList.remove("wt-switching", "wt-switching-next", "wt-switching-prev");
1036
+ }, 360);
574
1037
  }
575
1038
 
576
1039
  // The first screen frame after a switch is the resume delta landing.
@@ -578,23 +1041,104 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
578
1041
  clearCatchup();
579
1042
  });
580
1043
 
1044
+ // Live window-title updates for the active session: the engine sends a
1045
+ // TITLE frame on the live socket whenever the process changes its OSC 0/2
1046
+ // title, which the kernel republishes as wire:title. baseLabel prefers the
1047
+ // OSC title, so applying it here updates the active tab's label at once
1048
+ // rather than waiting for the next status-SSE/poll sweep (background tabs,
1049
+ // which have no live socket, still refresh their title from that sweep).
1050
+ ctx.on("wire:title", ({ session, title }) => {
1051
+ const t = tabList.find((x) => x.id === session);
1052
+ if (t && title !== t.title) {
1053
+ t.title = title;
1054
+ syncChrome();
1055
+ }
1056
+ });
1057
+
581
1058
  // switchRelative moves delta tabs from the active one (swipe left = next).
1059
+ // The direction feeds the slide animation (next slides in from the right,
1060
+ // prev from the left).
582
1061
  function switchRelative(delta: number): void {
1062
+ const n = tabList.length;
1063
+ if (n < 2) {
1064
+ return;
1065
+ }
583
1066
  const idx = tabList.findIndex((t) => t.id === activeId);
584
1067
  if (idx < 0) {
585
1068
  return;
586
1069
  }
587
- const next = tabList[idx + delta];
1070
+ // Wrap around the ends so the list rotates infinitely: swiping past the
1071
+ // last tab lands on the first, and past the first on the last.
1072
+ const next = tabList[(((idx + delta) % n) + n) % n];
588
1073
  if (next) {
589
- switchTo(next.id);
1074
+ switchTo(next.id, delta > 0 ? "next" : "prev");
590
1075
  }
591
1076
  }
592
1077
 
1078
+ // dragTargetBefore returns the first tab whose horizontal midpoint is past
1079
+ // x (the element the dragged tab should sit before), or null to drop at the
1080
+ // end (before the "+"). syncOrderFromDom rebuilds tabList to match the
1081
+ // strip's DOM order after a drag, so position indicators, the switcher, and
1082
+ // close-to-the-right/left all follow the visible order.
1083
+ function dragTargetBefore(x: number): HTMLElement | null {
1084
+ for (const el of bar.querySelectorAll<HTMLElement>(".wt-tab:not(.wt-tab-dragging)")) {
1085
+ const rect = el.getBoundingClientRect();
1086
+ if (x < rect.left + rect.width / 2) {
1087
+ return el;
1088
+ }
1089
+ }
1090
+ return null;
1091
+ }
1092
+ function syncOrderFromDom(): void {
1093
+ const order: Tab[] = [];
1094
+ for (const el of bar.querySelectorAll<HTMLElement>(".wt-tab")) {
1095
+ const t = tabList.find((x) => x.el === el);
1096
+ if (t) {
1097
+ order.push(t);
1098
+ }
1099
+ }
1100
+ if (order.length === tabList.length) {
1101
+ tabList.length = 0;
1102
+ tabList.push(...order);
1103
+ syncChrome();
1104
+ }
1105
+ }
1106
+
1107
+ // adoptSession adds a tab for a session that exists server-side but has no
1108
+ // local tab yet, e.g. one created in another browser (the server pushes it
1109
+ // over the status SSE, and the poll fallback lists it). This keeps every
1110
+ // client's tab set converged on the server, so mobile and desktop never
1111
+ // desync. The caller runs syncChrome.
1112
+ function adoptSession(info: SessionInfo): void {
1113
+ if (tabList.find((t) => t.id === info.id)) {
1114
+ return;
1115
+ }
1116
+ tabList.push(addTabChrome(info));
1117
+ }
1118
+
593
1119
  async function create(): Promise<void> {
594
- const info = await apiCreate();
595
- const tab = addTabChrome(info);
596
- tabList.push(tab);
1120
+ let info: SessionInfo;
1121
+ try {
1122
+ info = await apiCreate();
1123
+ } catch {
1124
+ // A create can still fail transiently (network, server error); tell
1125
+ // the user rather than throwing.
1126
+ ctx.toast("Couldn't open a terminal");
1127
+ return;
1128
+ }
1129
+ // The status SSE may have adopted this session during the POST round-trip
1130
+ // (server broadcasts the new session to all clients); reuse that tab.
1131
+ let tab = tabList.find((t) => t.id === info.id);
1132
+ if (!tab) {
1133
+ tab = addTabChrome(info);
1134
+ tabList.push(tab);
1135
+ }
1136
+ // Suppress the swipe reel for a create: the list should grow and fade the
1137
+ // new row in (animateRowIn) rather than rotate. switchTo still slides the
1138
+ // terminal content and updates the active chip.
1139
+ creatingTab = true;
597
1140
  switchTo(tab.id);
1141
+ creatingTab = false;
598
1142
  }
599
1143
 
600
1144
  // dropTab removes a tab's chrome + cache and re-homes the active session.
@@ -606,14 +1150,47 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
606
1150
  if (idx < 0) {
607
1151
  return;
608
1152
  }
1153
+ // Closing the only remaining tab: spawn its replacement BEFORE removing
1154
+ // this one, so the strip never empties and the "+" never teleports to the
1155
+ // far left (then jumps back) while the create POST is in flight. create()
1156
+ // adds the new tab before the "+" and switches to it; dropping the old
1157
+ // one is then an ordinary non-last close (length > 1, so this intercept
1158
+ // does not re-fire -> no unbounded recursion). remote-only (a user
1159
+ // close). If create() fails it adds nothing (and toasts), so keep the
1160
+ // existing tab rather than stranding the user on an empty strip.
1161
+ if (remote && tabList.length === 1 && tabList[0]?.id === id) {
1162
+ await create();
1163
+ // Drop the old tab only if a replacement actually landed (create()
1164
+ // adds nothing and toasts on failure); otherwise keep the existing tab
1165
+ // rather than stranding the user on an empty strip. The replacement is
1166
+ // a different session, so dropping the old one is a non-last close and
1167
+ // this intercept does not re-fire.
1168
+ if (tabList.some((t) => t.id !== id)) {
1169
+ await dropTab(id, true);
1170
+ }
1171
+ return;
1172
+ }
609
1173
  const [tab] = tabList.splice(idx, 1);
610
1174
  if (!tab) {
611
1175
  return;
612
1176
  }
613
1177
  tab.aria.remove();
1178
+ // Remove immediately (no exit animation): a lingering element made the
1179
+ // "+" teleport after a delay, and made a last-tab replacement appear in
1180
+ // the second slot before shifting left. The strip reflows in one frame.
614
1181
  tab.el.remove();
615
1182
  ctx.dropSession(id);
616
- syncChrome(); // reflect the drop immediately (count, position, sheet)
1183
+ // If this close empties the expanded list (only one tab remains),
1184
+ // collapse the switcher FIRST so the whole tray animates shut with the
1185
+ // last row still in it — otherwise it sits open-but-empty with the
1186
+ // separator shown and the keyboard button hidden. Collapsing flips
1187
+ // `expanded` off, so the syncChrome (and any re-home switchTo) below skip
1188
+ // the list reconcile and leave the row for the collapse to sweep away
1189
+ // (clearRows runs after the collapse transition).
1190
+ if (expanded && tabList.length < 2) {
1191
+ collapseSwitcher();
1192
+ }
1193
+ syncChrome(); // reflect the drop immediately (count, position)
617
1194
  if (remote) {
618
1195
  await apiClose(id);
619
1196
  }
@@ -633,26 +1210,138 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
633
1210
  await dropTab(id, true);
634
1211
  }
635
1212
 
636
- // closeAll closes every tab (killing each process) and leaves one fresh
637
- // terminal. Destructive - each tab is a running agent - so it confirms.
638
- async function closeAll(): Promise<void> {
639
- if (tabList.length === 0) {
1213
+ // closeMany closes a set of tabs at once. Each tab is a running agent, so
1214
+ // it confirms when closing two or more. It removes their chrome + cache,
1215
+ // re-homes the active tab to a survivor if it was among them, DELETEs each
1216
+ // server session, and guarantees at least one terminal stays open (a fresh
1217
+ // one is spawned only when every tab was closed). closeAll / closeOthers /
1218
+ // closeToRight / closeToLeft are thin wrappers that pick the id set.
1219
+ async function closeMany(ids: readonly string[]): Promise<void> {
1220
+ const victims = tabList.filter((t) => ids.includes(t.id));
1221
+ if (victims.length === 0) {
640
1222
  return;
641
1223
  }
642
- if (!window.confirm(`Close all ${String(tabList.length)} terminals?`)) {
1224
+ if (victims.length >= 2 && !window.confirm(`Close ${String(victims.length)} terminals?`)) {
643
1225
  return;
644
1226
  }
645
- const victims = [...tabList];
646
- tabList.length = 0;
647
- activeId = null;
1227
+ const closingActive = activeId !== null && ids.includes(activeId);
648
1228
  for (const t of victims) {
1229
+ const idx = tabList.indexOf(t);
1230
+ if (idx >= 0) {
1231
+ tabList.splice(idx, 1);
1232
+ }
649
1233
  t.aria.remove();
650
1234
  t.el.remove();
651
1235
  ctx.dropSession(t.id);
1236
+ }
1237
+ // Re-home the live view before the DELETEs: if the active tab was closed,
1238
+ // attach a survivor; otherwise just refresh the chrome.
1239
+ if (closingActive) {
1240
+ activeId = null;
1241
+ const survivor = tabList[0];
1242
+ if (survivor) {
1243
+ switchTo(survivor.id);
1244
+ } else {
1245
+ syncChrome();
1246
+ }
1247
+ } else {
1248
+ syncChrome();
1249
+ }
1250
+ for (const t of victims) {
652
1251
  await apiClose(t.id);
653
1252
  }
654
- syncChrome();
655
- await create(); // there is always at least one terminal open
1253
+ if (tabList.length === 0) {
1254
+ await create(); // there is always at least one terminal open
1255
+ }
1256
+ }
1257
+
1258
+ function closeOthers(id: string): Promise<void> {
1259
+ return closeMany(tabList.filter((t) => t.id !== id).map((t) => t.id));
1260
+ }
1261
+ function closeToRight(id: string): Promise<void> {
1262
+ const idx = tabList.findIndex((t) => t.id === id);
1263
+ return idx < 0 ? Promise.resolve() : closeMany(tabList.slice(idx + 1).map((t) => t.id));
1264
+ }
1265
+ function closeToLeft(id: string): Promise<void> {
1266
+ const idx = tabList.findIndex((t) => t.id === id);
1267
+ return idx < 0 ? Promise.resolve() : closeMany(tabList.slice(0, idx).map((t) => t.id));
1268
+ }
1269
+ // closeAll closes every tab and leaves one fresh terminal (closeMany
1270
+ // confirms since it is destructive).
1271
+ function closeAll(): Promise<void> {
1272
+ return closeMany(tabList.map((t) => t.id));
1273
+ }
1274
+
1275
+ // hideTabMenu / showTabMenu drive the right-click menu. showTabMenu rebuilds
1276
+ // the items for the target tab (disabled states reflect its position) and
1277
+ // clamps into the visible viewport, flipping above the pointer near the
1278
+ // bottom edge (mirrors context-menu.ts).
1279
+ function hideTabMenu(): void {
1280
+ tabMenu.classList.remove("visible");
1281
+ tabMenu.replaceChildren();
1282
+ }
1283
+ function tabMenuItem(label: string, disabled: boolean, run: () => void): void {
1284
+ const b = document.createElement("button");
1285
+ b.type = "button";
1286
+ b.setAttribute("role", "menuitem");
1287
+ b.textContent = label;
1288
+ if (disabled) {
1289
+ b.disabled = true;
1290
+ } else {
1291
+ b.addEventListener("click", () => {
1292
+ hideTabMenu();
1293
+ run();
1294
+ });
1295
+ }
1296
+ tabMenu.appendChild(b);
1297
+ }
1298
+ function showTabMenu(x: number, y: number, id: string): void {
1299
+ hideTabMenu();
1300
+ const idx = tabList.findIndex((t) => t.id === id);
1301
+ if (idx < 0) {
1302
+ return;
1303
+ }
1304
+ const n = tabList.length;
1305
+ tabMenuItem("Close", false, () => {
1306
+ void close_(id);
1307
+ });
1308
+ tabMenuItem("Close others", n <= 1, () => {
1309
+ void closeOthers(id);
1310
+ });
1311
+ tabMenuItem("Close to the right", idx >= n - 1, () => {
1312
+ void closeToRight(id);
1313
+ });
1314
+ tabMenuItem("Close to the left", idx <= 0, () => {
1315
+ void closeToLeft(id);
1316
+ });
1317
+ tabMenuItem("Close all", false, () => {
1318
+ void closeAll();
1319
+ });
1320
+ // Make visible (so it has measurable size), then clamp within the visible
1321
+ // viewport; position:fixed means x/y are viewport coordinates, and the
1322
+ // visual viewport (when present) excludes the on-screen keyboard.
1323
+ tabMenu.classList.add("visible");
1324
+ const vv = window.visualViewport;
1325
+ const viewLeft = vv ? vv.offsetLeft : 0;
1326
+ const viewTop = vv ? vv.offsetTop : 0;
1327
+ const viewWidth = vv ? vv.width : window.innerWidth;
1328
+ const viewHeight = vv ? vv.height : window.innerHeight;
1329
+ const menuW = tabMenu.offsetWidth;
1330
+ const menuH = tabMenu.offsetHeight;
1331
+ const left = Math.max(
1332
+ viewLeft + TAB_MENU_EDGE,
1333
+ Math.min(x, viewLeft + viewWidth - menuW - TAB_MENU_EDGE),
1334
+ );
1335
+ let top = y;
1336
+ if (y + menuH + TAB_MENU_EDGE > viewTop + viewHeight) {
1337
+ top = y - menuH - TAB_MENU_GAP;
1338
+ }
1339
+ top = Math.max(
1340
+ viewTop + TAB_MENU_EDGE,
1341
+ Math.min(top, viewTop + viewHeight - menuH - TAB_MENU_EDGE),
1342
+ );
1343
+ tabMenu.style.left = `${String(left)}px`;
1344
+ tabMenu.style.top = `${String(top)}px`;
656
1345
  }
657
1346
 
658
1347
  // One-time "swipe to switch" hint on first multi-tab state, mobile only.
@@ -678,17 +1367,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
678
1367
  } catch {
679
1368
  /* ignore */
680
1369
  }
681
- ctx.toast("Swipe the bar to switch terminals");
1370
+ ctx.toast("Swipe to switch terminals");
682
1371
  }
683
1372
 
684
- // applyStatus updates one tab's dot + title from a status record (shared by
685
- // the SSE monitor and the polling fallback).
686
- function applyStatus(id: string, status: string, title: string | undefined): void {
1373
+ // applyStatus updates one tab's dot (status + reveal) + title from a status
1374
+ // record (shared by the SSE monitor and the polling fallback). reports is
1375
+ // the server's sticky reportsActivity flag; it gates the dot's visibility.
1376
+ function applyStatus(
1377
+ id: string,
1378
+ status: string,
1379
+ title: string | undefined,
1380
+ reports: boolean,
1381
+ ): void {
687
1382
  const t = tabList.find((tab) => tab.id === id);
688
1383
  if (!t) {
689
1384
  return;
690
1385
  }
691
- t.dot.dataset["status"] = status || "idle";
1386
+ t.reports = reports;
1387
+ paintStatusDot(t.dot, status, reports);
692
1388
  // Record the raw server title; the displayed label (fallback + de-dup)
693
1389
  // is recomputed by relabelAll via syncChrome, which the callers run
694
1390
  // right after applyStatus.
@@ -709,7 +1405,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
709
1405
  void dropTab(s.id, false); // already gone server-side; no DELETE
710
1406
  return;
711
1407
  }
712
- applyStatus(s.id, s.status, s.title);
1408
+ // Adopt a session created in another browser so all clients converge.
1409
+ adoptSession({
1410
+ id: s.id,
1411
+ title: s.title,
1412
+ createdAt: s.createdAt,
1413
+ status: s.status,
1414
+ reportsActivity: s.reportsActivity ?? false,
1415
+ });
1416
+ applyStatus(s.id, s.status, s.title, s.reportsActivity ?? false);
713
1417
  syncChrome();
714
1418
  });
715
1419
  } else {
@@ -723,7 +1427,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
723
1427
  }
724
1428
  const seen = new Set(list.map((s) => s.id));
725
1429
  for (const info of list) {
726
- applyStatus(info.id, info.status, info.title);
1430
+ adoptSession(info); // add sessions created elsewhere (no local tab)
1431
+ applyStatus(info.id, info.status, info.title, info.reportsActivity ?? false);
727
1432
  }
728
1433
  // A tab the server no longer lists was reaped/closed elsewhere: drop it
729
1434
  // locally (no DELETE — it is already gone).
@@ -744,82 +1449,448 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
744
1449
  newBtn.addEventListener("click", () => {
745
1450
  void create();
746
1451
  });
747
- closeAllBtn.addEventListener("click", () => {
748
- void closeAll();
1452
+ // Live reorder while dragging a tab over the strip (dragend commits it).
1453
+ bar.addEventListener("dragover", (e) => {
1454
+ if (!draggingEl) {
1455
+ return;
1456
+ }
1457
+ e.preventDefault();
1458
+ if (e.dataTransfer) {
1459
+ e.dataTransfer.dropEffect = "move";
1460
+ }
1461
+ bar.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? newBtn);
749
1462
  });
750
- overviewBtn.addEventListener("click", () => {
751
- openOverview();
1463
+ // Keyboard button: open/close the key grid above the bar. The toolbar API
1464
+ // is read lazily (ctx.use) so feature ordering does not matter; the button
1465
+ // is hidden unless a keyboardToggle was provided (set above).
1466
+ swKb.addEventListener("click", () => {
1467
+ const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
1468
+ if (!kb) {
1469
+ return;
1470
+ }
1471
+ kb.toggle();
1472
+ const open = kb.isOpen();
1473
+ swKb.setAttribute("aria-expanded", String(open));
1474
+ swKb.classList.toggle("wt-active", open);
752
1475
  });
753
- sheetNewBtn.addEventListener("click", () => {
754
- closeOverview();
1476
+ // Mobile "+": spawn a terminal (mirrors the desktop strip's newBtn).
1477
+ swNew.addEventListener("click", () => {
755
1478
  void create();
756
1479
  });
757
- scrim.addEventListener("click", () => {
758
- closeOverview();
1480
+ // Active-row close (x): closes the current tab (mirrors a listed row's x).
1481
+ // stopPropagation so it is not read as a tap/swipe on the row surface.
1482
+ swClose.addEventListener("click", (e) => {
1483
+ e.stopPropagation();
1484
+ if (activeId !== null) {
1485
+ void close_(activeId);
1486
+ }
759
1487
  });
760
- sheet.addEventListener("keydown", (e) => {
761
- if (e.key === "Escape") {
762
- e.preventDefault();
763
- closeOverview();
1488
+ // Dismiss the tab context menu on an outside click, on Escape, and on
1489
+ // scroll (it is anchored to a viewport point, so a scroll would detach it).
1490
+ const onDocClickMenu = (): void => {
1491
+ hideTabMenu();
1492
+ };
1493
+ document.addEventListener("click", onDocClickMenu);
1494
+ // A right-click anywhere other than a tab (the terminal content, elsewhere,
1495
+ // or a native browser menu) dismisses the tab menu. A right-click ON a tab
1496
+ // is handled by that tab's own contextmenu handler (which reopens it), and
1497
+ // fires before this one, so the menu is not immediately re-hidden.
1498
+ const onDocContextMenu = (e: MouseEvent): void => {
1499
+ if (!(e.target as HTMLElement).closest(".wt-tab")) {
1500
+ hideTabMenu();
1501
+ }
1502
+ };
1503
+ document.addEventListener("contextmenu", onDocContextMenu);
1504
+ const onScrollMenu = (): void => {
1505
+ hideTabMenu();
1506
+ };
1507
+ window.addEventListener("scroll", onScrollMenu, true);
1508
+ const offMenuKey = ctx.registerKeydown((ev) => {
1509
+ if (ev.key === "Escape" && tabMenu.classList.contains("visible")) {
1510
+ ev.preventDefault();
1511
+ hideTabMenu();
1512
+ return true;
1513
+ }
1514
+ return false;
1515
+ });
1516
+ // A tap on the terminal/background dismisses an open mobile overlay — the
1517
+ // expanded tab list or the key grid — rather than opening the keyboard, so
1518
+ // tap 1 closes and tap 2 opens the keyboard. Runs in the capture phase and
1519
+ // stops propagation, so the kernel's surface tap-to-focus never fires for
1520
+ // the dismissing tap. Taps inside the switcher or the key grid are left to
1521
+ // their own controls; when nothing is open the tap falls through untouched.
1522
+ const onDocTapDismiss = (e: PointerEvent): void => {
1523
+ const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
1524
+ const gridOpen = kb?.isOpen() ?? false;
1525
+ if (!expanded && !gridOpen) {
764
1526
  return;
765
1527
  }
766
- if (e.key !== "Tab") {
1528
+ const target = e.target as HTMLElement | null;
1529
+ const inChrome =
1530
+ target !== null &&
1531
+ (target.closest(".wt-switcher") !== null || target.closest(".key-toolbar") !== null);
1532
+ if (inChrome) {
767
1533
  return;
768
1534
  }
769
- const focusables = Array.from(sheet.querySelectorAll<HTMLElement>("button")).filter(
770
- (el) => !el.hasAttribute("disabled"),
771
- );
772
- const firstEl = focusables[0];
773
- const lastEl = focusables[focusables.length - 1];
774
- if (!firstEl || !lastEl) {
1535
+ if (expanded) {
1536
+ collapseSwitcher();
1537
+ } else {
1538
+ closeKeyGrid();
1539
+ }
1540
+ e.stopPropagation();
1541
+ };
1542
+ document.addEventListener("pointerup", onDocTapDismiss, true);
1543
+
1544
+ // Interactive drag on the bar (mobile). The gesture follows the finger
1545
+ // live rather than only acting on release: after a small axis-lock move it
1546
+ // commits to horizontal (slide the terminal content to preview a tab
1547
+ // switch) or vertical (grow/shrink the tab list under the bar). On release
1548
+ // it snaps: a horizontal drag past a quarter-width commits the switch (else
1549
+ // springs back); a vertical drag past the halfway point snaps the list open
1550
+ // (else closed). A near-stationary release is a tap that toggles the list
1551
+ // (the click listener). swCurrent has touch-action:none so these drags
1552
+ // never scroll the page, and the pointer is captured so a drag that leaves
1553
+ // the bar still delivers its move/up here.
1554
+ let gDownX = 0;
1555
+ let gDownY = 0;
1556
+ let gAxis: "h" | "v" | null = null;
1557
+ let gActive = false;
1558
+ let gStartMax = 0; // list max-height (px) when a vertical drag began
1559
+ let gTargetMax = 0; // fully-open list height (px) for the current drag
1560
+ let swiped = false; // a drag was handled; the trailing click must not also toggle
1561
+ let gPointerId = -1; // the pointer that owns the drag; stray fingers are ignored
1562
+ let gDownT = 0; // pointerdown timestamp, for the flick duration test
1563
+ let gVX = 0; // last-sample velocity (px/ms) horizontal
1564
+ let gVY = 0; // last-sample velocity (px/ms) vertical
1565
+ let gLastX = 0;
1566
+ let gLastY = 0;
1567
+ let gLastT = 0; // time of the last pointermove sample (for the stale-velocity guard)
1568
+ // Owns the window-level move/end listeners for the current drag. They catch
1569
+ // the gesture wherever the finger goes and however it ends, so the state
1570
+ // machine can never be stranded (gActive stuck true) by a pointerup that
1571
+ // missed swCurrent — the case that bricked swiping until a reload.
1572
+ let gestureAbort: AbortController | null = null;
1573
+
1574
+ // The row content follows the finger during a horizontal drag (the active
1575
+ // tab area physically swiping). The terminal content is NOT dragged: the
1576
+ // old finger-following content translate read as a few-pixel snap, so the
1577
+ // commit instead lets switchTo slide the incoming terminal in from the side.
1578
+ function beginHorizontal(): void {
1579
+ for (const el of swipeEls) {
1580
+ el.style.transition = "none"; // track the finger 1:1
1581
+ }
1582
+ // Preview the list rotation live during the drag when the list is open,
1583
+ // so it nudges with the finger instead of only moving on release (see
1584
+ // moveHorizontal). The rows translate as a group (clipped by the list,
1585
+ // whose box/border stay put); the release reel continues from there.
1586
+ dragActive = expanded && tabList.length >= 2 && !prefersReduce();
1587
+ if (!dragActive) {
775
1588
  return;
776
1589
  }
777
- if (e.shiftKey && document.activeElement === firstEl) {
778
- e.preventDefault();
779
- lastEl.focus();
780
- } else if (!e.shiftKey && document.activeElement === lastEl) {
781
- e.preventDefault();
782
- firstEl.focus();
1590
+ endReelNow(); // cancel any settling reel/spring before previewing
1591
+ swList.style.overflow = "hidden";
1592
+ swList.style.position = "relative";
1593
+ }
1594
+ function moveHorizontal(dx: number): void {
1595
+ // The active-tab chip (dot + label + close) follows the finger 1:1. No
1596
+ // rubber-band: the list is circular, so there is no end to resist.
1597
+ const tx = `translateX(${String(Math.round(dx))}px)`;
1598
+ for (const el of swipeEls) {
1599
+ el.style.transform = tx;
783
1600
  }
784
- });
1601
+ if (!dragActive) {
1602
+ return;
1603
+ }
1604
+ // Peek the list a few pixels in the swipe direction — a hint of the
1605
+ // coming rotation, not the full shift (the incoming row only appears on
1606
+ // release, so a large move read wrong). Drag left (dx < 0, next) nudges
1607
+ // rows up; drag right (prev) nudges them down. The release reel continues
1608
+ // from this offset.
1609
+ const p = Math.max(-PREVIEW_PEEK_MAX, Math.min(PREVIEW_PEEK_MAX, dx * PREVIEW_DRAG_RATIO));
1610
+ for (const el of rowEls.values()) {
1611
+ el.style.transition = "none";
1612
+ el.style.transform = `translateY(${String(Math.round(p))}px)`;
1613
+ }
1614
+ }
1615
+ // springRowsBack eases the previewed rows back to rest when a drag is
1616
+ // released without committing, then hands overflow/position back to CSS.
1617
+ function springRowsBack(): void {
1618
+ const rows = [...rowEls.values()];
1619
+ requestAnimationFrame(() => {
1620
+ for (const el of rows) {
1621
+ el.style.transition = "transform 0.2s ease-out";
1622
+ el.style.transform = "translateY(0)";
1623
+ }
1624
+ });
1625
+ if (reelTimer !== null) {
1626
+ clearTimeout(reelTimer);
1627
+ }
1628
+ reelTimer = setTimeout(endReelNow, 220);
1629
+ }
1630
+ function endHorizontal(dx: number, releaseT: number, canceled: boolean): void {
1631
+ const width = ctx.surface().clientWidth || window.innerWidth;
1632
+ const dir = dx < 0 ? 1 : -1;
1633
+ // Commit on a flick (quick + fast + far enough) or once dragged past a
1634
+ // quarter width; a cancel/capture-loss never commits (springs back). The
1635
+ // list is circular, so any switch has a target: ≥2 tabs is the only gate.
1636
+ const paused = releaseT - gLastT > VELOCITY_STALE_MS;
1637
+ const vx = paused ? 0 : Math.abs(gVX);
1638
+ const flick =
1639
+ releaseT - gDownT < SWIPE_DURATION && vx > SWIPE_VELOCITY && Math.abs(dx) > SWIPE_MIN_PX;
1640
+ const commit = !canceled && (flick || Math.abs(dx) >= width * 0.25) && tabList.length >= 2;
1641
+ const wasDrag = dragActive;
1642
+ dragActive = false;
1643
+ if (!commit) {
1644
+ if (wasDrag) {
1645
+ springRowsBack(); // ease the previewed rows back to rest
1646
+ }
1647
+ const spring = prefersReduce() ? "" : "transform 0.2s ease-out";
1648
+ for (const el of swipeEls) {
1649
+ el.style.transition = spring;
1650
+ el.style.transform = "";
1651
+ }
1652
+ window.setTimeout(() => {
1653
+ for (const el of swipeEls) {
1654
+ el.style.transition = "";
1655
+ }
1656
+ }, 220);
1657
+ return;
1658
+ }
1659
+ // Commit: switchTo slides the incoming terminal in from the side and (when
1660
+ // the list is open) runs the reel, which continues from wherever the drag
1661
+ // preview left the rows. switchRelative wraps around the ends.
1662
+ switchRelative(dir);
1663
+ if (prefersReduce()) {
1664
+ for (const el of swipeEls) {
1665
+ el.style.transition = "";
1666
+ el.style.transform = "";
1667
+ }
1668
+ return;
1669
+ }
1670
+ // Slide the whole active-tab chip in from the swipe side (next from the
1671
+ // right, prev from the left), every part by the SAME pixel distance so
1672
+ // the close stays locked to the dot + label (a per-element % would move
1673
+ // the narrow close less than the wide label).
1674
+ const slide = (swInner.getBoundingClientRect().width || width) * dir;
1675
+ for (const el of swipeEls) {
1676
+ el.style.transition = "none";
1677
+ el.style.transform = `translateX(${String(Math.round(slide))}px)`;
1678
+ }
1679
+ requestAnimationFrame(() => {
1680
+ for (const el of swipeEls) {
1681
+ el.style.transition = "transform 0.25s cubic-bezier(0.2, 0, 0, 1)";
1682
+ el.style.transform = "translateX(0)";
1683
+ }
1684
+ });
1685
+ window.setTimeout(() => {
1686
+ for (const el of swipeEls) {
1687
+ el.style.transition = "";
1688
+ el.style.transform = "";
1689
+ }
1690
+ }, 320);
1691
+ }
1692
+
1693
+ function beginVertical(): void {
1694
+ if (tabList.length < 2 && !expanded) {
1695
+ gTargetMax = 0; // nothing to reveal; the vertical drag is inert
1696
+ return;
1697
+ }
1698
+ if (!expanded) {
1699
+ renderSwitcherList();
1700
+ }
1701
+ // Apply the expanded styling (padding/border) so the reveal matches the
1702
+ // settled look, but drive the height with inline max-height (transition
1703
+ // off, clipping instead of auto-scroll) so it tracks the finger 1:1.
1704
+ switcher.classList.add("wt-switcher-expanded");
1705
+ swList.style.transition = "none";
1706
+ swList.style.overflowY = "hidden";
1707
+ gTargetMax = Math.min(swList.scrollHeight, Math.round(window.innerHeight * 0.5));
1708
+ gStartMax = expanded ? gTargetMax : 0;
1709
+ swList.style.maxHeight = `${String(gStartMax)}px`;
1710
+ }
1711
+ function moveVertical(dy: number): void {
1712
+ if (gTargetMax <= 0) {
1713
+ return;
1714
+ }
1715
+ // Drag up (dy < 0) grows the list; drag down shrinks it.
1716
+ const next = Math.max(0, Math.min(gTargetMax, gStartMax - dy));
1717
+ swList.style.maxHeight = `${String(next)}px`;
1718
+ }
1719
+ function endVertical(dy: number, releaseT: number, canceled: boolean): void {
1720
+ if (gTargetMax <= 0) {
1721
+ return;
1722
+ }
1723
+ const current = Math.max(0, Math.min(gTargetMax, gStartMax - dy));
1724
+ // A quick flick opens (drag up) or closes (drag down) regardless of how
1725
+ // far it got; otherwise snap to whichever state is nearer. A cancel snaps
1726
+ // to the nearer state (no flick).
1727
+ const paused = releaseT - gLastT > VELOCITY_STALE_MS;
1728
+ const vy = paused ? 0 : Math.abs(gVY);
1729
+ const flick =
1730
+ !canceled &&
1731
+ releaseT - gDownT < SWIPE_DURATION &&
1732
+ vy > SWIPE_VELOCITY &&
1733
+ Math.abs(dy) > SWIPE_MIN_PX;
1734
+ const open = flick ? dy < 0 : current >= gTargetMax / 2;
1735
+ // Hand height back to the class (transition restored) so it snaps to the
1736
+ // settled state from wherever the finger left it.
1737
+ swList.style.transition = "";
1738
+ swList.style.overflowY = "";
1739
+ swList.style.maxHeight = "";
1740
+ if (open) {
1741
+ setExpandedState(true);
1742
+ ctx.announce("Terminal list expanded");
1743
+ } else {
1744
+ setExpandedState(false);
1745
+ }
1746
+ }
1747
+
1748
+ const endGesture = (e: PointerEvent, canceled: boolean): void => {
1749
+ if (!gActive || e.pointerId !== gPointerId) {
1750
+ return;
1751
+ }
1752
+ gActive = false;
1753
+ const dx = e.clientX - gDownX;
1754
+ const dy = e.clientY - gDownY;
1755
+ if (gAxis === "h") {
1756
+ endHorizontal(dx, e.timeStamp, canceled);
1757
+ } else if (gAxis === "v") {
1758
+ endVertical(dy, e.timeStamp, canceled);
1759
+ } else if (
1760
+ !canceled &&
1761
+ Math.abs(dx) >= SWIPE_MIN_PX &&
1762
+ Math.abs(dx) > Math.abs(dy) * 1.5 &&
1763
+ modes.getMouseMode() === 0
1764
+ ) {
1765
+ // No pointermove locked an axis (a flick with no intermediate move, or
1766
+ // a synthetic down/up): fall back to a discrete switch from the net
1767
+ // delta so the gesture still resolves.
1768
+ swiped = true;
1769
+ switchRelative(dx < 0 ? 1 : -1);
1770
+ } else if (!canceled && Math.abs(dy) >= SWIPE_MIN_PX && Math.abs(dy) > Math.abs(dx) * 1.5) {
1771
+ swiped = true;
1772
+ if (dy < 0) {
1773
+ expandSwitcher();
1774
+ } else {
1775
+ collapseSwitcher();
1776
+ }
1777
+ }
1778
+ gAxis = null;
1779
+ // The gesture is over: drop the window move/end listeners for it.
1780
+ gestureAbort?.abort();
1781
+ gestureAbort = null;
1782
+ };
1783
+
1784
+ // The move handler runs on WINDOW (attached for the gesture's life on
1785
+ // pointerdown), so the drag is tracked wherever the finger goes — even if
1786
+ // setPointerCapture failed or the finger left the thin bar. With capture,
1787
+ // the events also bubble here; without it, this is the only path.
1788
+ function onGestureMove(e: PointerEvent): void {
1789
+ if (!gActive || e.pointerId !== gPointerId) {
1790
+ return;
1791
+ }
1792
+ // Track last-sample velocity for the flick test on release; guard
1793
+ // dt === 0 (high-refresh / coalesced events) against divide-by-zero.
1794
+ const dt = e.timeStamp - gLastT;
1795
+ if (dt > 0) {
1796
+ gVX = (e.clientX - gLastX) / dt;
1797
+ gVY = (e.clientY - gLastY) / dt;
1798
+ gLastX = e.clientX;
1799
+ gLastY = e.clientY;
1800
+ gLastT = e.timeStamp;
1801
+ }
1802
+ const dx = e.clientX - gDownX;
1803
+ const dy = e.clientY - gDownY;
1804
+ if (gAxis === null) {
1805
+ if (Math.max(Math.abs(dx), Math.abs(dy)) < AXIS_LOCK_PX) {
1806
+ return;
1807
+ }
1808
+ if (Math.abs(dx) > Math.abs(dy)) {
1809
+ // Horizontal switch preview, unless a mouse-mode app is capturing
1810
+ // drags (leave the bar swipe inert then, matching the old gate).
1811
+ if (modes.getMouseMode() !== 0) {
1812
+ gActive = false;
1813
+ swiped = true;
1814
+ gestureAbort?.abort(); // gesture abandoned; drop the window listeners
1815
+ gestureAbort = null;
1816
+ return;
1817
+ }
1818
+ gAxis = "h";
1819
+ swiped = true;
1820
+ beginHorizontal();
1821
+ } else {
1822
+ gAxis = "v";
1823
+ swiped = true;
1824
+ beginVertical();
1825
+ }
1826
+ }
1827
+ if (gAxis === "h") {
1828
+ moveHorizontal(dx);
1829
+ } else {
1830
+ moveVertical(dy);
1831
+ }
1832
+ }
1833
+ const endOnUp = (e: PointerEvent): void => {
1834
+ endGesture(e, false);
1835
+ };
1836
+ const endOnCancel = (e: PointerEvent): void => {
1837
+ endGesture(e, true);
1838
+ };
785
1839
 
786
- // Swipe/tap on the switcher bar: a horizontal swipe switches (gated off
787
- // under mouse tracking, since mouse-mode apps capture drags); a tap opens
788
- // the overview. click also covers keyboard/mouse activation.
789
- let downX = 0;
790
- let downY = 0;
791
- let swiped = false;
792
1840
  swCurrent.addEventListener(
793
1841
  "pointerdown",
794
1842
  (e) => {
795
- downX = e.clientX;
796
- downY = e.clientY;
1843
+ if (gActive) {
1844
+ return; // a drag already owns a pointer; ignore a second finger
1845
+ }
1846
+ gDownX = e.clientX;
1847
+ gDownY = e.clientY;
1848
+ gDownT = e.timeStamp;
1849
+ gLastX = e.clientX;
1850
+ gLastY = e.clientY;
1851
+ gLastT = e.timeStamp;
1852
+ gVX = 0;
1853
+ gVY = 0;
1854
+ gPointerId = e.pointerId;
1855
+ gAxis = null;
1856
+ gActive = true;
797
1857
  swiped = false;
1858
+ try {
1859
+ swCurrent.setPointerCapture(e.pointerId);
1860
+ } catch {
1861
+ /* capture unavailable; the window listeners below track it anyway */
1862
+ }
1863
+ // Own the move/end on WINDOW for this gesture, so it always resolves —
1864
+ // wherever the finger goes and however it lifts. Relying only on
1865
+ // swCurrent (via pointer capture) stranded gActive=true forever when
1866
+ // capture failed and the release landed on another element, bricking
1867
+ // all future swipes until reload. Torn down in endGesture.
1868
+ gestureAbort?.abort();
1869
+ gestureAbort = new AbortController();
1870
+ const opts = { passive: true, signal: gestureAbort.signal };
1871
+ window.addEventListener("pointermove", onGestureMove, opts);
1872
+ window.addEventListener("pointerup", endOnUp, opts);
1873
+ window.addEventListener("pointercancel", endOnCancel, opts);
798
1874
  },
799
1875
  { passive: true },
800
1876
  );
1877
+ // If the browser revokes pointer capture mid-drag (device change, system
1878
+ // interruption), end the gesture cleanly as an abort rather than leaving it
1879
+ // stuck — the safety net a hand-rolled drag usually misses. After a normal
1880
+ // pointerup this fires too, but gActive is already false so it no-ops.
801
1881
  swCurrent.addEventListener(
802
- "pointerup",
1882
+ "lostpointercapture",
803
1883
  (e) => {
804
- const dx = e.clientX - downX;
805
- const dy = e.clientY - downY;
806
- if (
807
- Math.abs(dx) >= SWIPE_MIN_PX &&
808
- Math.abs(dx) > Math.abs(dy) * 1.5 &&
809
- modes.getMouseMode() === 0
810
- ) {
811
- swiped = true;
812
- switchRelative(dx < 0 ? 1 : -1);
813
- }
1884
+ endGesture(e, true);
814
1885
  },
815
1886
  { passive: true },
816
1887
  );
817
1888
  swCurrent.addEventListener("click", () => {
818
1889
  if (swiped) {
819
- swiped = false; // consumed by the swipe; do not also open the overview
1890
+ swiped = false; // consumed by a drag; do not also toggle
820
1891
  return;
821
1892
  }
822
- openOverview();
1893
+ toggleSwitcher();
823
1894
  });
824
1895
 
825
1896
  // Initial population: list existing sessions, or create the first one.
@@ -832,9 +1903,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
832
1903
  if (sessions.length === 0) {
833
1904
  sessions = [await apiCreate()];
834
1905
  }
1906
+ // Adopt (dedup) rather than blindly push. The status SSE pushes a snapshot
1907
+ // of the existing sessions on open, and tabs subscribes (monitor.onStatus)
1908
+ // BEFORE this list resolves, so a session may already have a tab by the
1909
+ // time the list lands. A straight push doubled every already-adopted
1910
+ // session (6 tabs from 3 across a fresh load), and paintActive then lit
1911
+ // both copies of the active id ("2 active tabs" that move together).
835
1912
  for (const info of sessions) {
836
- tabList.push(addTabChrome(info));
1913
+ adoptSession(info);
837
1914
  }
1915
+ // From here on, tabs added at runtime (create / adopt) animate in.
1916
+ started = true;
838
1917
  // Activate the first (oldest) session.
839
1918
  const first = tabList[0];
840
1919
  if (first) {
@@ -856,25 +1935,41 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
856
1935
  teardown() {
857
1936
  offStatus?.();
858
1937
  offInput();
1938
+ offArmed?.();
1939
+ offMenuKey();
1940
+ document.removeEventListener("click", onDocClickMenu);
1941
+ document.removeEventListener("contextmenu", onDocContextMenu);
1942
+ document.removeEventListener("pointerup", onDocTapDismiss, true);
1943
+ window.removeEventListener("scroll", onScrollMenu, true);
859
1944
  if (pollTimer !== null) {
860
1945
  clearInterval(pollTimer);
861
1946
  }
862
1947
  barResize.disconnect();
863
- surface.classList.remove("wt-with-topbar");
864
- surface.style.removeProperty("--wt-tabbar-h");
1948
+ swReserve.disconnect();
1949
+ surface.classList.remove("wt-with-tabbar");
1950
+ document.documentElement.style.removeProperty("--wt-tabbar-h");
1951
+ root?.classList.remove("wt-tabbed");
1952
+ document.documentElement.style.removeProperty("--wt-reserve-bottom");
1953
+ if (switchAnimTimer !== null) {
1954
+ clearTimeout(switchAnimTimer);
1955
+ }
1956
+ if (collapseClearTimer !== null) {
1957
+ clearTimeout(collapseClearTimer);
1958
+ }
1959
+ endReelNow();
1960
+ gestureAbort?.abort(); // drop any in-flight gesture's window listeners
865
1961
  clearCatchup();
866
- closeOverview();
1962
+ hideTabMenu();
867
1963
  for (const t of tabList) {
868
1964
  t.aria.remove();
869
1965
  t.el.remove();
870
1966
  }
871
1967
  tabList.length = 0;
1968
+ rowEls.clear();
872
1969
  bar.remove();
873
1970
  newBtn.remove();
874
- closeAllBtn.remove();
1971
+ tabMenu.remove();
875
1972
  switcher.remove();
876
- sheet.remove();
877
- scrim.remove();
878
1973
  catchup.remove();
879
1974
  },
880
1975
  };
@@ -882,6 +1977,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
882
1977
  };
883
1978
  }
884
1979
 
1980
+ // paintStatusDot applies a status dot's two orthogonal bits: data-status drives
1981
+ // its appearance (idle / working / done / input / exited via CSS), and the
1982
+ // .wt-reports class controls its visibility — the dot is hidden by default and
1983
+ // shown only once the session has reported activity (OSC 9;4 progress or a
1984
+ // classified OSC 9 notification), so a plain shell's tabs stay clean and
1985
+ // label-only while an agent's light up.
1986
+ function paintStatusDot(el: HTMLElement, status: string, reports: boolean): void {
1987
+ el.dataset["status"] = status || "idle";
1988
+ el.classList.toggle("wt-reports", reports);
1989
+ }
1990
+
885
1991
  // pick returns a required descendant element or throws (static chrome only).
886
1992
  function pick(root: ParentNode, selector: string): HTMLElement {
887
1993
  const el = root.querySelector<HTMLElement>(selector);