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