@cplieger/web-terminal-ui 3.3.1 → 3.4.1

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.
@@ -39,6 +39,19 @@ const PREVIEW_DRAG_RATIO = 0.1;
39
39
  const PREVIEW_PEEK_MAX = 10;
40
40
  // One-time "swipe to switch" hint, remembered across loads.
41
41
  const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
42
+ // localStorage key for the last active session id, so a page reload reopens the
43
+ // tab the user left on rather than always defaulting to the oldest one.
44
+ const ACTIVE_TAB_KEY = "wt-active-session";
45
+ // The mobile bottom-switcher (a single full-width active-tab chip + swipe) is
46
+ // used ONLY on a narrow coarse-pointer device (a phone). A wide touchscreen (an
47
+ // iPad) and every fine-pointer device (a desktop, or an iPad with a trackpad /
48
+ // Magic Keyboard) get the multi-tab top strip instead — the switcher's
49
+ // single-giant-tab layout wastes a big screen (an iPad was getting the phone
50
+ // UI). MUST stay in lockstep with the identical media query in css/06-mobile.css
51
+ // (.wt-switcher shown / .wt-tab-bar hidden) and its complement in css/05-tabs.css
52
+ // (the desktop-strip terminal clearance). Width chosen to sit above every phone
53
+ // portrait width and below iPad portrait (768).
54
+ const SWITCHER_MEDIA = "(pointer: coarse) and (max-width: 600px)";
42
55
  // Default cadence for the no-activityMonitor polling fallback.
43
56
  const DEFAULT_POLL_MS = 4000;
44
57
  // Tab context-menu viewport clamp (mirrors context-menu.ts): keep this margin
@@ -52,6 +65,10 @@ const TAB_MENU_GAP = 16;
52
65
  interface SessionInfo {
53
66
  id: string;
54
67
  title: string;
68
+ /** The raw client-set title (the value pushed via PUT .../title), before the
69
+ * server's OSC fallback baked into `title`. A consumer that treats the
70
+ * program's OSC window title as unreliable (an agent shell) reads this. */
71
+ clientTitle?: string | undefined;
55
72
  createdAt: string;
56
73
  status: string;
57
74
  /** Sticky: true once the session has emitted a genuine activity signal
@@ -89,6 +106,12 @@ interface Tab {
89
106
  * cwd-only title disabled. Undefined until the first non-empty submission; see
90
107
  * baseLabel. */
91
108
  derived?: string;
109
+ /** The persisted client title reported by the server (its `clientTitle` wire
110
+ * field): the last derived title pushed via PUT .../title, surviving reloads.
111
+ * In `preferInputTitle` mode this is the reload-recovery source (the live
112
+ * `derived` is lost on reload, and `title` is the unreliable OSC value there).
113
+ * In the default mode it is the lowest-priority fallback after title/derived. */
114
+ clientTitle?: string | undefined;
92
115
  }
93
116
 
94
117
  export interface TabsApi {
@@ -120,19 +143,88 @@ export interface TabsOptions {
120
143
  * toolbar should be built with { externalToggle: true } so its own top-right
121
144
  * toggle is hidden and the grid opens above the bar. */
122
145
  keyboardToggle?: TerminalFeature<MobileToolbarApi>;
146
+ /** Prefer the input-derived title over the process OSC 0/2 title. Default
147
+ * false (OSC-first: a program that sets its own window title wins, else the
148
+ * last submitted line). Set true for an agent shell whose program emits a
149
+ * non-empty but useless OSC title (kiro-cli under vibecli): the label then
150
+ * follows the latest submitted line (live) or the persisted client title (on
151
+ * reload), and the unreliable OSC title is ignored entirely. presetAgentTabbed
152
+ * enables this. */
153
+ preferInputTitle?: boolean;
123
154
  }
124
155
 
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.
156
+ // The +/x/keyboard glyphs are inline SVG (not font glyphs) so they center
157
+ // exactly in their flex-centered buttons and stay symmetric regardless of the UI
158
+ // font's metrics. Each is defined ONCE here and shared by every chip site and
159
+ // control below (rather than duplicated across the desktop and mobile markup).
160
+ const CLOSE_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg>`;
161
+ const NEW_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`;
162
+ const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3.5"/><path d="M15 15 9.5 9.5M9.5 13V9.5H13"/></svg>`;
163
+ // Two-arrows "switch" glyph for the mobile switcher's dedicated open/close
164
+ // button (a latest-wins background-tab notification dot rides on it — see
165
+ // switchButtonHTML). Same viewBox + stroke=currentcolor treatment as the others.
166
+ const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9h13M14 6l3 3-3 3M20 15H7M10 12l-3 3 3 3"/></svg>`;
167
+
168
+ // chipContent is the ONE builder for a tab chip's content — a status dot, a
169
+ // label, and a close (x) — shared by all three chip sites: the desktop strip
170
+ // (.wt-tab), the mobile active row (.wt-switcher-current), and each expanded
171
+ // mobile list row (.wt-switcher-row). Each site passes its OWN class set (never
172
+ // renamed) so every existing selector — and thus all CSS and all tests — still
173
+ // matches. The one structural difference is WHERE the close sits: the desktop
174
+ // chip nests it flat inside .wt-tab, while the two mobile chips place it as a
175
+ // sibling of the select/swipe button (a button can't nest in a button). So the
176
+ // builder returns two fragments — the dot+label pair and the close — that each
177
+ // site drops into its own structure.
178
+ function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
179
+ dotLabel: string;
180
+ close: string;
181
+ } {
182
+ return {
183
+ dotLabel:
184
+ `<span class="${v.dot} wt-status-dot" aria-hidden="true"></span>` +
185
+ `<span class="${v.label}"></span>`,
186
+ close: `<button type="button" class="${v.close}" aria-label="Close terminal"${v.closeAttr ?? ""}>${CLOSE_SVG}</button>`,
187
+ };
188
+ }
189
+
190
+ // The ONE "+" (new-terminal) and keyboard button factories, shared by the
191
+ // desktop strip and the mobile switcher — only the class set differs. These
192
+ // build the markup; element construction + event wiring live in makeNewButton /
193
+ // makeKbButton (in setup), which close over create() and the key-grid toggle.
194
+ function newButtonHTML(cls: string): string {
195
+ return `<button type="button" class="${cls}" aria-label="New terminal">${NEW_SVG}</button>`;
196
+ }
197
+ function kbButtonHTML(cls: string): string {
198
+ return `<button type="button" class="${cls}" aria-label="Keyboard keys" aria-expanded="false" hidden>${KB_SVG}</button>`;
199
+ }
200
+ // The mobile switcher's dedicated open/close button. It toggles the tab list and
201
+ // carries a latest-wins notification dot (a child span) — amber when a background
202
+ // terminal needs input, green when a background turn finished — cleared when the
203
+ // list opens. Only the mobile switcher builds it (element construction + event
204
+ // wiring live in makeSwitchButton in setup); the desktop strip already shows
205
+ // every tab's own status dot, so it needs no aggregate cue.
206
+ function switchButtonHTML(cls: string): string {
207
+ return (
208
+ `<button type="button" class="${cls}" aria-label="Open tab switcher">${SWITCH_SVG}` +
209
+ `<span class="wt-status-dot wt-switcher-switch-dot" aria-hidden="true"></span></button>`
210
+ );
211
+ }
212
+
213
+ // Desktop strip chip: dot + label + close all flat inside .wt-tab (the whole
214
+ // chip is the click/switch target; the close is a nested button). tabindex="-1"
215
+ // keeps the close out of the tab order.
216
+ const TAB_CHIP = chipContent({
217
+ dot: "wt-tab-dot",
218
+ label: "wt-tab-label",
219
+ close: "wt-tab-close",
220
+ closeAttr: ' tabindex="-1"',
221
+ });
127
222
  const TAB_HTML = `
128
223
  <div class="wt-tab">
129
- <span class="wt-tab-dot wt-status-dot" aria-hidden="true"></span>
130
- <span class="wt-tab-label"></span>
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>
224
+ ${TAB_CHIP.dotLabel}
225
+ ${TAB_CHIP.close}
132
226
  </div>`;
133
227
 
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>`;
135
-
136
228
  // Mobile bottom bar. One element, two parts stacked in a column: the always-
137
229
  // visible bar row (active tab as a tap/swipe surface + keyboard + "+") on top,
138
230
  // and a list of the OTHER tabs BELOW it. On swipe-up / tap the whole bar slides
@@ -147,23 +239,24 @@ const NEW_HTML = `<button type="button" class="wt-tab-new" aria-label="New termi
147
239
  // (.wt-switcher-current with dot + label) plus a close (x) overlaid at the
148
240
  // right, mirroring the listed rows. No "n / m" counter: the list below is a
149
241
  // 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.
242
+ // - The keyboard button (.wt-switcher-kb, opens the key grid above the bar,
243
+ // only wired + shown when a keyboardToggle feature is provided) and the
244
+ // accent "+" (.wt-switcher-new, spawns a terminal) are appended to the bar
245
+ // row from the shared factories in setup (see makeKbButton / makeNewButton).
246
+ const CURRENT_CHIP = chipContent({
247
+ dot: "wt-switcher-dot",
248
+ label: "wt-switcher-label",
249
+ close: "wt-switcher-current-close wt-btn",
250
+ });
153
251
  const SWITCHER_HTML = `
154
252
  <div class="wt-switcher" role="group" aria-label="Terminal tabs">
155
253
  <div class="wt-switcher-bar">
156
254
  <div class="wt-switcher-current-wrap">
157
255
  <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>
256
+ <span class="wt-switcher-current-inner">${CURRENT_CHIP.dotLabel}</span>
162
257
  </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>
258
+ ${CURRENT_CHIP.close}
164
259
  </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>
167
260
  </div>
168
261
  <ul class="wt-switcher-list" role="list"></ul>
169
262
  </div>`;
@@ -171,34 +264,114 @@ const SWITCHER_HTML = `
171
264
  // One row per OTHER tab in the expanded list: a stretched select target (dot +
172
265
  // label) with the close (x) laid inside it at the right (two buttons can't nest,
173
266
  // so the x is a sibling overlapping the select's reserved right padding).
267
+ const ROW_CHIP = chipContent({
268
+ dot: "wt-switcher-row-dot",
269
+ label: "wt-switcher-row-label",
270
+ close: "wt-switcher-row-close wt-btn",
271
+ });
174
272
  const SWITCHER_ROW_HTML = `
175
273
  <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>
179
- </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>
274
+ <button type="button" class="wt-switcher-row-select">${ROW_CHIP.dotLabel}</button>
275
+ ${ROW_CHIP.close}
181
276
  </li>`;
182
277
 
278
+ // looksLikeHardwareKey reports whether a keydown could only have come from a
279
+ // PHYSICAL keyboard on a touch device. There is no web API that directly says "a
280
+ // hardware keyboard is attached" (navigator.keyboard is layout/lock only and
281
+ // unsupported on iOS Safari; navigator.virtualKeyboard is Chromium-only), so we
282
+ // infer it: the iOS on-screen keyboard has no modifier keys and no
283
+ // arrows/Escape/Tab/nav/function keys, so any of these means real hardware. Used
284
+ // to latch a "physical keyboard present" flag that also covers a keyboard-only
285
+ // Smart Keyboard Folio (which, unlike a Magic Keyboard, adds no trackpad and so
286
+ // does not match `any-pointer: fine`).
287
+ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
288
+ if (ev.ctrlKey || ev.metaKey || ev.altKey) {
289
+ return true;
290
+ }
291
+ switch (ev.key) {
292
+ case "ArrowUp":
293
+ case "ArrowDown":
294
+ case "ArrowLeft":
295
+ case "ArrowRight":
296
+ case "Escape":
297
+ case "Tab":
298
+ case "Home":
299
+ case "End":
300
+ case "PageUp":
301
+ case "PageDown":
302
+ return true;
303
+ default:
304
+ return /^F\d{1,2}$/.test(ev.key); // F1–F12
305
+ }
306
+ }
307
+
183
308
  export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
184
309
  const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
310
+ const preferInputTitle = opts.preferInputTitle ?? false;
311
+ // fetch has no default timeout; a stalled-but-open server would otherwise leave a
312
+ // boot apiList/apiCreate await pending forever, so setup() never resolves and the
313
+ // kernel's post-setup overlay-dismiss never fires (permanent loading overlay). Bound
314
+ // every session-REST call; on expiry the existing catch paths recover.
315
+ const SESSION_API_TIMEOUT_MS = 15000;
185
316
 
186
317
  async function apiList(): Promise<SessionInfo[]> {
187
- const r = await fetch(apiBase, { headers: { Accept: "application/json" } });
318
+ const r = await fetch(apiBase, {
319
+ headers: { Accept: "application/json" },
320
+ signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
321
+ });
188
322
  if (!r.ok) {
189
323
  throw new Error(`web-terminal-ui: session list failed (${String(r.status)})`);
190
324
  }
191
- return (await r.json()) as SessionInfo[];
325
+ const data: unknown = await r.json();
326
+ // A 200 with a non-array body -- a proxy error object, or a Go server
327
+ // marshaling a nil session slice as JSON `null` -- must not reach the
328
+ // bootstrap's `sessions.length` / `for...of` (or the poll's `list.map`)
329
+ // uncaught: it would throw out of setup(), trip the kernel's 'terminal
330
+ // has no chrome' teardown, and (managesSessions skipped the startup
331
+ // connect) leave connectionInitiated false -> a permanently dead
332
+ // terminal. Reject a non-array so the callers' existing catch paths
333
+ // recover (bootstrap -> [], poll -> skip the tick).
334
+ if (!Array.isArray(data)) {
335
+ throw new Error("web-terminal-ui: session list returned a non-array body");
336
+ }
337
+ return data as SessionInfo[];
192
338
  }
193
339
  async function apiCreate(): Promise<SessionInfo> {
194
- const r = await fetch(apiBase, { method: "POST" });
340
+ const r = await fetch(apiBase, {
341
+ method: "POST",
342
+ signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
343
+ });
195
344
  if (!r.ok) {
196
345
  throw new Error(`web-terminal-ui: session create failed (${String(r.status)})`);
197
346
  }
198
347
  return (await r.json()) as SessionInfo;
199
348
  }
200
349
  async function apiClose(id: string): Promise<void> {
201
- await fetch(`${apiBase}/${encodeURIComponent(id)}`, { method: "DELETE" });
350
+ const r = await fetch(`${apiBase}/${encodeURIComponent(id)}`, {
351
+ method: "DELETE",
352
+ signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
353
+ });
354
+ if (!r.ok) {
355
+ throw new Error(`web-terminal-ui: session close failed (${String(r.status)})`);
356
+ }
357
+ }
358
+ // Persist the input-derived tab title server-side so it survives a page reload
359
+ // and shows on other devices. The engine stores it as the session's fallback
360
+ // title and uses it only when the program emits no OSC title (the vibecli case:
361
+ // kiro-cli emits no usable OSC title, so the latest user message is the label).
362
+ // Best-effort and fire-and-forget: a failure never disrupts the terminal — the
363
+ // locally-derived title still displays, it just is not persisted this time.
364
+ async function apiSetTitle(id: string, title: string): Promise<void> {
365
+ try {
366
+ await fetch(`${apiBase}/${encodeURIComponent(id)}/title`, {
367
+ method: "PUT",
368
+ headers: { "Content-Type": "application/json" },
369
+ body: JSON.stringify({ title }),
370
+ signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
371
+ });
372
+ } catch {
373
+ /* best-effort persistence; ignore network/timeout errors */
374
+ }
202
375
  }
203
376
 
204
377
  return {
@@ -212,6 +385,71 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
212
385
  const tablist = ctx.tablist();
213
386
  const monitor = opts.activityMonitor ? ctx.use(opts.activityMonitor) : undefined;
214
387
 
388
+ // The keyboard buttons wired to the key grid — the mobile switcher's and
389
+ // the desktop strip's — built + wired by the ONE makeKbButton factory;
390
+ // closeKeyGrid and the sticky-Ctrl armed reflect update every one.
391
+ const kbButtons: HTMLElement[] = [];
392
+ // makeNewButton / makeKbButton are the shared control factories (goals 2 &
393
+ // 3): one "+" and one keyboard-button implementation, each built + wired
394
+ // once and reused for the desktop strip and the mobile switcher. The "+"
395
+ // spawns a terminal; the keyboard button toggles the key grid (via the
396
+ // keyboardToggle feature, read lazily so feature ordering does not matter)
397
+ // and reflects its open state on every keyboard button.
398
+ function makeNewButton(cls: string): HTMLElement {
399
+ const btn = fromHTML(newButtonHTML(cls));
400
+ // Keep the hidden terminal textarea focused: a bar button needs no focus
401
+ // of its own, and letting the tap shift focus makes iOS/iPadOS consume
402
+ // the FIRST tap to blur the input (so "+" only fired create on the second
403
+ // tap — the reported double-tap). preventDefault on pointerdown fires the
404
+ // action on the first tap; switchTo then re-focuses the input for the new
405
+ // tab on a physical-keyboard device.
406
+ btn.addEventListener("pointerdown", (e) => {
407
+ e.preventDefault();
408
+ });
409
+ btn.addEventListener("click", () => {
410
+ void create();
411
+ });
412
+ return btn;
413
+ }
414
+ function makeKbButton(cls: string): HTMLElement {
415
+ const btn = fromHTML(kbButtonHTML(cls));
416
+ // First-tap focus retention (see makeNewButton): keep the terminal
417
+ // textarea focused so the toggle fires on the first tap on iOS/iPadOS.
418
+ btn.addEventListener("pointerdown", (e) => {
419
+ e.preventDefault();
420
+ });
421
+ btn.addEventListener("click", () => {
422
+ const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
423
+ if (!kb) {
424
+ return;
425
+ }
426
+ kb.toggle();
427
+ const open = kb.isOpen();
428
+ for (const b of kbButtons) {
429
+ b.setAttribute("aria-expanded", String(open));
430
+ b.classList.toggle("wt-active", open);
431
+ }
432
+ });
433
+ kbButtons.push(btn);
434
+ return btn;
435
+ }
436
+ // makeSwitchButton builds the mobile switcher's dedicated open/close
437
+ // button (its notification dot is painted by paintSwitchDot). Like the
438
+ // other bar buttons it preventDefaults pointerdown for first-tap focus
439
+ // retention (keeps the terminal textarea focused so it fires on the first
440
+ // tap on iOS/iPadOS); its click toggles the list (toggleSwitcher opens when
441
+ // collapsed, closes when expanded).
442
+ function makeSwitchButton(): HTMLElement {
443
+ const btn = fromHTML(switchButtonHTML("wt-switcher-switch wt-btn"));
444
+ btn.addEventListener("pointerdown", (e) => {
445
+ e.preventDefault();
446
+ });
447
+ btn.addEventListener("click", () => {
448
+ toggleSwitcher();
449
+ });
450
+ return btn;
451
+ }
452
+
215
453
  // --- Desktop tab strip (top-bar region) ---
216
454
  const slot = ctx.region("top-bar", "tabs");
217
455
  const bar = document.createElement("div");
@@ -222,8 +460,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
222
460
  // the slot (a sibling of the fixed .wt-tab-bar) flowed it out of the strip
223
461
  // to the root, so it never appeared; inside the flex bar it renders at the
224
462
  // end of the tabs (addTabChrome inserts each tab before it).
225
- const newBtn = fromHTML(NEW_HTML);
463
+ const newBtn = makeNewButton("wt-tab-new");
226
464
  bar.appendChild(newBtn);
465
+ // Desktop-strip keyboard button, built + wired by the SAME factory as the
466
+ // mobile switcher's. A SIBLING of the strip (appended to the region slot,
467
+ // not into .wt-tab-bar) so it never affects the strip's child order; CSS
468
+ // shows it only on a wide touchscreen (where the strip, not the switcher,
469
+ // is used) and hides it on a fine-pointer desktop. Un-hidden below only
470
+ // when a keyboardToggle feature is wired.
471
+ const deskKb = makeKbButton("wt-tab-kb wt-btn");
472
+ slot.appendChild(deskKb);
227
473
 
228
474
  // Pull the terminal surface up off the fixed BOTTOM strip on desktop so
229
475
  // the bar does not overlap the last rows. The surface is position:fixed
@@ -261,24 +507,55 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
261
507
  // swipe: the content (dot + label) and the close (x). Moving both keeps the
262
508
  // whole active-tab chip sliding as one, rather than the close staying put.
263
509
  const swipeEls = [swInner, swClose];
264
- const swKb = pick(switcher, ".wt-switcher-kb");
265
- const swNew = pick(switcher, ".wt-switcher-new");
510
+ // The mobile "+", keyboard, and switcher buttons: built + wired by the
511
+ // SAME shared factories as the desktop strip's, then appended to the bar
512
+ // row so the order stays current-wrap | keyboard | switch | "+". Unifying
513
+ // the controls means one implementation placed per layout rather than
514
+ // duplicated markup. The switch button sits BETWEEN the keyboard and "+":
515
+ // it toggles the list and carries the moved background-tab attention cue.
516
+ const swKb = makeKbButton("wt-switcher-kb wt-btn");
517
+ const swSwitch = makeSwitchButton();
518
+ const swSwitchDot = pick(swSwitch, ".wt-switcher-switch-dot");
519
+ const swNew = makeNewButton("wt-switcher-new wt-btn wt-switcher-new-btn");
520
+ swBar.append(swKb, swSwitch, swNew);
521
+ // Latest-wins notification state for the switch button's dot: overwritten
522
+ // by each qualifying background-tab event (applyStatus) and cleared when
523
+ // the list opens (expandSwitcher). "" = no pending cue; the dot is hidden.
524
+ let switchNotify: "" | "input" | "done" = "";
525
+ function paintSwitchDot(): void {
526
+ // Reuse the per-tab status-dot colours (single source, css/05-tabs.css
527
+ // .wt-status-dot[data-status="input"|"done"]) instead of re-declaring
528
+ // them: the dot has the .wt-status-dot class, so data-status colours it
529
+ // exactly like the tabs' own dots.
530
+ if (switchNotify === "") {
531
+ delete swSwitchDot.dataset["status"];
532
+ } else {
533
+ swSwitchDot.dataset["status"] = switchNotify;
534
+ }
535
+ }
266
536
  ctx.region("bottom-switcher", "switcher").appendChild(switcher);
267
- // The keyboard button opens the key grid; show it only when a toolbar is
537
+ // The keyboard buttons open the key grid; show them only when a toolbar is
268
538
  // wired to drive. Read the toolbar's API lazily at tap time (ctx.use), so
269
539
  // feature ordering does not matter.
270
540
  let offArmed: (() => void) | undefined;
271
541
  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).
542
+ // Un-hide every keyboard button; the mobile one then shows in the
543
+ // switcher bar, the desktop one is CSS-gated to a wide touchscreen.
544
+ for (const b of kbButtons) {
545
+ b.hidden = false;
546
+ }
547
+ // Mirror sticky-Ctrl on every keyboard button: when a Ctrl press is
548
+ // armed, invert the button (like the armed Ctrl key) so the pending
549
+ // modifier is visible with the grid closed — the toolbar sets up before
550
+ // tabs, so its API is available now (see the preset ordering note). Also
551
+ // clears on the auto-disarm after a Ctrl byte and on a tab switch
552
+ // (onDetach disarms).
278
553
  const kbApi = ctx.use(opts.keyboardToggle);
279
554
  if (kbApi) {
280
555
  const reflectArmed = (armed: boolean): void => {
281
- swKb.classList.toggle("wt-armed", armed);
556
+ for (const b of kbButtons) {
557
+ b.classList.toggle("wt-armed", armed);
558
+ }
282
559
  };
283
560
  reflectArmed(kbApi.isCtrlArmed());
284
561
  offArmed = kbApi.onCtrlArmedChange(reflectArmed);
@@ -333,6 +610,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
333
610
  ctx.region("overlay", "tab-menu").appendChild(tabMenu);
334
611
 
335
612
  const tabList: Tab[] = [];
613
+ // Ids the user closed within CLOSE_TOMBSTONE_MS, so a stale server listing
614
+ // (the SSE re-open snapshot, or the poll's GET /api/sessions) that predates
615
+ // the server reaping the session does not re-adopt (flash back) the closed
616
+ // tab. TTL-cleared in adoptSession.
617
+ const recentlyClosed = new Map<string, number>();
618
+ const CLOSE_TOMBSTONE_MS = 15000;
619
+ function tombstone(id: string): void {
620
+ const now = Date.now();
621
+ // Sweep entries whose window already elapsed (adoptSession treats them as
622
+ // untombstoned anyway) so the map cannot grow without bound over a long
623
+ // session of opens/closes; then record this close.
624
+ for (const [k, t] of recentlyClosed) {
625
+ if (now - t >= CLOSE_TOMBSTONE_MS) {
626
+ recentlyClosed.delete(k);
627
+ }
628
+ }
629
+ recentlyClosed.set(id, now);
630
+ }
336
631
  // The expanded mobile list's row elements, keyed by tab id. Rows are
337
632
  // reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
338
633
  // same elements from their old slots to their new ones (the rotation).
@@ -355,11 +650,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
355
650
  let creatingTab = false;
356
651
  let collapseClearTimer: ReturnType<typeof setTimeout> | null = null;
357
652
  let hintShown = false;
358
- // On a touch device, focusing the hidden input opens the virtual keyboard.
359
- // A switch triggered by tapping/swiping the tab UI must not do that (it is
360
- // disruptive), so the input is focused on a switch only with a fine
361
- // pointer; on touch the user taps the terminal itself to open the keyboard.
362
- const coarse = window.matchMedia("(pointer: coarse)").matches;
653
+ // Whether to focus the input on a tab switch. On a device with a physical
654
+ // keyboard this is what you want (switch, then type immediately); on a
655
+ // keyboard-less touchscreen it must NOT happen, or every switch pops the
656
+ // virtual keyboard. No web API reports a hardware keyboard directly, so we
657
+ // combine two proxies: (1) a fine pointer (a Magic Keyboard carries a
658
+ // trackpad, so an iPad with one matches, as does every desktop; a bare
659
+ // phone / keyboard-less tablet does not) — read live, since a keyboard can
660
+ // be attached/detached; and (2) sawHardwareKey, latched once we observe a
661
+ // keydown only a hardware keyboard emits (covers a trackpad-less keyboard
662
+ // folio). See looksLikeHardwareKey and the keydown observer below.
663
+ let sawHardwareKey = false;
664
+ const hasFinePointer = (): boolean =>
665
+ typeof window.matchMedia === "function" && window.matchMedia("(any-pointer: fine)").matches;
666
+ const physicalKeyboardLikely = (): boolean => sawHardwareKey || hasFinePointer();
363
667
  // Motion opt-out (checked live: the OS setting can change). Gates the
364
668
  // interactive swipe/rotation animations, mirroring the CSS .wt-animate gate.
365
669
  const prefersReduce = (): boolean =>
@@ -374,7 +678,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
374
678
  // "New tab". fallback=true marks that last case so relabelAll leaves
375
679
  // untitled tabs as "New tab" with no numeric suffix.
376
680
  function baseLabel(tab: Tab): { text: string; fallback: boolean } {
377
- const real = tab.title.trim() || (tab.derived?.trim() ?? "");
681
+ // preferInputTitle (agent shell with an unreliable OSC title): show the
682
+ // live submitted line, then the persisted client title on reload — the
683
+ // process OSC `title` is ignored. Default: OSC title first, then the live
684
+ // derived line, then the persisted client title (the latter two are the
685
+ // "latest user message" fallback when the program sets no OSC title).
686
+ const derived = tab.derived?.trim() ?? "";
687
+ const persisted = tab.clientTitle?.trim() ?? "";
688
+ const real = preferInputTitle
689
+ ? derived || persisted
690
+ : tab.title.trim() || derived || persisted;
378
691
  return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
379
692
  }
380
693
 
@@ -421,26 +734,52 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
421
734
  // tab's derived title on every non-empty submission. Best-effort: an odd
422
735
  // editing sequence just yields no derived title (the tab then reads "New
423
736
  // tab" until the next submission).
424
- const MAX_DERIVED = 60;
737
+ // A generous storage bound for the fallback (input-derived) title. The tab
738
+ // label truncates it visually with max-width + ellipsis in EVERY view — the
739
+ // desktop strip, the mobile active bar, and the mobile list — so this is
740
+ // only a guard against storing a huge paste, not a display cut. (It was a
741
+ // hard 60 that cut the label mid-word regardless of how much fit.)
742
+ const MAX_DERIVED = 512;
425
743
  let lineBytes: number[] = [];
426
744
  let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
745
+ let csiParams = ""; // accumulated CSI parameter bytes, to spot paste guards
746
+ let inPaste = false; // inside a bracketed paste (ESC[200~ … ESC[201~)
427
747
  function resetInputLine(): void {
428
748
  lineBytes = [];
429
749
  escState = 0;
750
+ csiParams = "";
751
+ inPaste = false;
430
752
  }
431
753
  function deriveTitleFromInput(bytes: Uint8Array): void {
432
754
  const t = tabList.find((x) => x.id === activeId);
433
755
  if (!t) {
434
756
  return; // no active tab
435
757
  }
436
- for (const b of bytes) {
758
+ for (let i = 0; i < bytes.length; i++) {
759
+ const b = bytes[i];
760
+ if (b === undefined) {
761
+ continue;
762
+ }
437
763
  if (escState === 1) {
438
764
  escState = b === 0x5b ? 2 : b === 0x4f ? 3 : 0; // ESC [ = CSI, ESC O = SS3
439
765
  continue;
440
766
  }
441
767
  if (escState === 2) {
442
768
  if (b >= 0x40 && b <= 0x7e) {
769
+ // CSI final byte. Recognize the bracketed-paste guards ESC[200~
770
+ // (start) and ESC[201~ (end) so embedded newlines in a pasted,
771
+ // often multi-line, message are not read as line submissions.
772
+ if (b === 0x7e) {
773
+ if (csiParams === "200") {
774
+ inPaste = true;
775
+ } else if (csiParams === "201") {
776
+ inPaste = false;
777
+ }
778
+ }
779
+ csiParams = "";
443
780
  escState = 0; // CSI final byte
781
+ } else if (b >= 0x30 && b <= 0x3f) {
782
+ csiParams += String.fromCharCode(b); // parameter byte (digits, ; ? etc.)
444
783
  }
445
784
  continue;
446
785
  }
@@ -451,6 +790,37 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
451
790
  if (b === 0x1b) {
452
791
  escState = 1; // ESC: start of an escape sequence
453
792
  } else if (b === 0x0d || b === 0x0a) {
793
+ // A newline is a line SUBMIT only when it terminates the input. It is
794
+ // folded to a single space (keeping the current line going) when it is
795
+ // instead a paste-internal break, so a multi-line message becomes one
796
+ // logical line whose title reflects its START rather than only its
797
+ // LAST line. Two cases fold:
798
+ // - inside a bracketed paste (ESC[200~ … ESC[201~), and
799
+ // - a newline FOLLOWED by more printable input in this SAME chunk:
800
+ // a human pressing Enter sends the newline as the end of its own
801
+ // input event, whereas a paste (even one sent WITHOUT bracketed-
802
+ // paste guards — e.g. an agent shell like kiro-cli that keeps a
803
+ // pasted multi-line message as one prompt) delivers
804
+ // text + newline + text together, so trailing content marks the
805
+ // newline as a soft break, not a submit. Without this, such a
806
+ // paste left only its last line as the title (the reported
807
+ // "the title cut off the start of my message").
808
+ let softBreak = inPaste;
809
+ if (!softBreak) {
810
+ for (let j = i + 1; j < bytes.length; j++) {
811
+ const nb = bytes[j];
812
+ if (nb !== undefined && nb >= 0x20) {
813
+ softBreak = true;
814
+ break;
815
+ }
816
+ }
817
+ }
818
+ if (softBreak) {
819
+ if (lineBytes.length > 0 && lineBytes[lineBytes.length - 1] !== 0x20) {
820
+ lineBytes.push(0x20);
821
+ }
822
+ continue;
823
+ }
454
824
  const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
455
825
  lineBytes = [];
456
826
  // Every non-empty submitted line updates the fallback title (the
@@ -458,6 +828,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
458
828
  // command path.
459
829
  if (line) {
460
830
  t.derived = line.slice(0, MAX_DERIVED);
831
+ // Persist it server-side (engine PUT .../title) so a reload and
832
+ // other devices recover this "latest message" label; the engine
833
+ // uses it only when the program emits no OSC title.
834
+ void apiSetTitle(t.id, t.derived);
461
835
  syncChrome();
462
836
  }
463
837
  } else if (b === 0x7f || b === 0x08) {
@@ -500,16 +874,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
500
874
  const active = idx >= 0 ? tabList[idx] : undefined;
501
875
  swLabel.textContent = active ? active.display : "";
502
876
  paintStatusDot(swDot, active?.dot.dataset["status"] ?? "idle", active?.reports ?? false);
503
- const bgInput = tabList.some(
504
- (t) => t.id !== activeId && t.dot.dataset["status"] === "input",
505
- );
506
- if (bgInput) {
507
- swCurrent.dataset["attention"] = "input";
508
- swCurrent.setAttribute("aria-label", "Terminals; a background terminal needs input");
509
- } else {
510
- delete swCurrent.dataset["attention"];
511
- swCurrent.removeAttribute("aria-label");
512
- }
877
+ // The aggregate background-notification cue rides the dedicated switch
878
+ // button's dot (paintSwitchDot), not the active surface (it did not fit
879
+ // there).
513
880
  }
514
881
 
515
882
  // buildRow creates one expanded-list row for a tab and wires its handlers
@@ -630,6 +997,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
630
997
  animateRowIn(row); // grow + fade in (the tray height follows)
631
998
  }
632
999
  }
1000
+ // Publish the measured content height so the expanded list animates its
1001
+ // max-height between 0 and the REAL content height (--wt-list-h in
1002
+ // 06-mobile.css), not a fixed 50dvh far larger than the content — which
1003
+ // made the open finish early and the close start late (box height =
1004
+ // min(content, max-height), so the transition's stretch past the content
1005
+ // moved nothing: the asymmetric, choppy toggle). scrollHeight is the full
1006
+ // content height regardless of the collapsed max-height:0 clip, so this
1007
+ // is valid whether measured while collapsed (on open, before the expanded
1008
+ // class) or already open (a tab added/closed). Capped at 50dvh (then
1009
+ // overflow-y:auto scrolls). This works without interpolate-size (iOS
1010
+ // Safari lacks it), unlike a height:auto transition.
1011
+ const visH = window.visualViewport?.height ?? window.innerHeight;
1012
+ switcher.style.setProperty(
1013
+ "--wt-list-h",
1014
+ `${String(Math.min(swList.scrollHeight, Math.round(visH * 0.5)))}px`,
1015
+ );
633
1016
  }
634
1017
 
635
1018
  // clearRows empties the list and drops the reused-row cache (after a
@@ -723,7 +1106,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
723
1106
  }
724
1107
  const exit = dir === "next" ? -pitch : pitch; // leaving row off the leading edge
725
1108
  requestAnimationFrame(() => {
726
- const trans = "transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.2s ease-out";
1109
+ // Couple opacity to the SAME easing + duration as the transform so a
1110
+ // row's fade tracks its DISTANCE from its target slot (every reel row
1111
+ // travels one pitch): opacity is a linear function of remaining
1112
+ // distance, so a row is transparent a pitch away (at the clipped
1113
+ // edge) and only fully opaque once it settles. Entering rows fade IN
1114
+ // as they rotate in and the leaving row fades OUT as it exits — no
1115
+ // hard black cutoff at the list edges, and no permanent edge fade.
1116
+ // (Was opacity 0.2s ease-out, decoupled from the motion, so a row hit
1117
+ // full opacity while still at the edge — the reported hard cut.)
1118
+ const trans =
1119
+ "transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.25s cubic-bezier(0.2, 0, 0, 1)";
727
1120
  for (const el of rowEls.values()) {
728
1121
  el.style.transition = trans;
729
1122
  el.style.transform = "translateY(0)";
@@ -742,6 +1135,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
742
1135
  relabelAll();
743
1136
  paintActive();
744
1137
  syncMobile();
1138
+ // The dedicated switch button only earns its place once there are ≥2
1139
+ // tabs (a single tab has nothing to switch to; expandSwitcher no-ops
1140
+ // there). .wt-switcher-multi drives its collapse-when-single / animate-in
1141
+ // -when-a-second-opens motion in CSS (the active chip shrinks to make
1142
+ // room in lockstep, via the flex layout); aria-hidden + tabindex keep the
1143
+ // collapsed button out of the a11y tree and tab order.
1144
+ const multiTab = tabList.length >= 2;
1145
+ switcher.classList.toggle("wt-switcher-multi", multiTab);
1146
+ swSwitch.setAttribute("aria-hidden", multiTab ? "false" : "true");
1147
+ swSwitch.tabIndex = multiTab ? 0 : -1;
745
1148
  if (expanded) {
746
1149
  renderSwitcherList();
747
1150
  }
@@ -757,8 +1160,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
757
1160
  if (kb?.isOpen()) {
758
1161
  kb.toggle();
759
1162
  }
760
- swKb.setAttribute("aria-expanded", "false");
761
- swKb.classList.remove("wt-active");
1163
+ for (const b of kbButtons) {
1164
+ b.setAttribute("aria-expanded", "false");
1165
+ b.classList.remove("wt-active");
1166
+ }
762
1167
  }
763
1168
 
764
1169
  // setExpandedState applies the resting expanded/collapsed state: the class
@@ -804,6 +1209,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
804
1209
  if (expanded || tabList.length < 2) {
805
1210
  return;
806
1211
  }
1212
+ // Opening the list acknowledges any pending background-tab notification:
1213
+ // the user is now looking at the tabs, so clear the switch button's dot.
1214
+ switchNotify = "";
1215
+ paintSwitchDot();
807
1216
  renderSwitcherList();
808
1217
  setExpandedState(true);
809
1218
  ctx.announce("Terminal list expanded");
@@ -851,6 +1260,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
851
1260
  const tab: Tab = {
852
1261
  id: info.id,
853
1262
  title: info.title,
1263
+ clientTitle: info.clientTitle,
854
1264
  display: "",
855
1265
  createdAt: info.createdAt,
856
1266
  store: new LineStore(),
@@ -968,6 +1378,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
968
1378
  // rebuild viewport-first, so the last-known screen paints with no
969
1379
  // round-trip. Then let the kernel reconnect the WS to it (resume delta).
970
1380
  activeId = next.id;
1381
+ try {
1382
+ localStorage.setItem(ACTIVE_TAB_KEY, next.id);
1383
+ } catch {
1384
+ /* storage unavailable (private mode / disabled) — non-fatal */
1385
+ }
971
1386
  resetInputLine(); // a partial line typed in the old tab does not carry over
972
1387
  ctx.render.bind(next.store);
973
1388
  ctx.notifySwitch({ id: next.id });
@@ -979,18 +1394,32 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
979
1394
  syncChrome(); // reconciles the expanded list into the new order
980
1395
  reelReconcile = false;
981
1396
  playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
982
- // Restore scroll memory best-effort after the async rebuild; a
983
- // following tab sticks to the bottom on its own.
1397
+ // Restore scroll memory best-effort after the async rebuild. The engine
1398
+ // scroll controller's follow flag is GLOBAL (one per kernel) and still
1399
+ // reflects the tab we just left, so binding a following tab right after
1400
+ // being scrolled up in another tab left the controller in "holding":
1401
+ // the renderer's post-flush stickToBottom() no-op'd and the cached
1402
+ // screen rendered above the viewport (a black gap until a touch scrolled
1403
+ // it and re-engaged follow — the "content pops down when I wiggle it"
1404
+ // symptom). Re-assert here: scrollToBottom() snaps down AND re-engages
1405
+ // follow (so the resume delta then pins correctly); a scrolled-up tab
1406
+ // restores its saved read position instead.
984
1407
  const savedTop = next.scrollTop;
985
1408
  const following = next.following;
986
1409
  requestAnimationFrame(() => {
987
- if (!following) {
1410
+ if (following) {
1411
+ ctx.scroll.scrollToBottom();
1412
+ } else {
988
1413
  surface.scrollTop = savedTop;
989
1414
  }
990
1415
  });
991
1416
  ctx.announce(`Switched to ${next.display}`);
992
- if (!coarse) {
993
- focusInput(); // desktop only; on touch this would pop the keyboard (#7)
1417
+ if (physicalKeyboardLikely()) {
1418
+ // A physical keyboard is (likely) present, so focus the input on
1419
+ // switch — switch and type immediately (the iPad + Magic Keyboard
1420
+ // ask). On a keyboard-less touchscreen this is skipped, or every
1421
+ // switch would pop the virtual keyboard (#7).
1422
+ focusInput();
994
1423
  }
995
1424
  }
996
1425
 
@@ -1049,7 +1478,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1049
1478
  // which have no live socket, still refresh their title from that sweep).
1050
1479
  ctx.on("wire:title", ({ session, title }) => {
1051
1480
  const t = tabList.find((x) => x.id === session);
1052
- if (t && title !== t.title) {
1481
+ // Ignore a blank title (an OSC 0/2 clear the process may emit when it
1482
+ // redraws its prompt after idling): keep the last good label until a real
1483
+ // replacement arrives, rather than reverting to "New tab". A non-blank
1484
+ // change updates the label at once.
1485
+ if (t && title.trim() !== "" && title !== t.title) {
1053
1486
  t.title = title;
1054
1487
  syncChrome();
1055
1488
  }
@@ -1113,9 +1546,34 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1113
1546
  if (tabList.find((t) => t.id === info.id)) {
1114
1547
  return;
1115
1548
  }
1549
+ const closedAt = recentlyClosed.get(info.id);
1550
+ if (closedAt !== undefined) {
1551
+ if (Date.now() - closedAt < CLOSE_TOMBSTONE_MS) {
1552
+ return; // just closed here; ignore a stale listing until the server reaps it
1553
+ }
1554
+ recentlyClosed.delete(info.id);
1555
+ }
1116
1556
  tabList.push(addTabChrome(info));
1117
1557
  }
1118
1558
 
1559
+ // Activate the first tab when nothing is active. The bootstrap normally
1560
+ // activates a tab, but if the initial apiList AND apiCreate both fail at
1561
+ // load its `if (startTab)` activation is skipped, leaving activeId null
1562
+ // and connectionInitiated false -- so the kernel never opens the terminal
1563
+ // WS and its wake handlers no-op. When the server recovers, the status
1564
+ // stream / poll adopt the existing sessions below; without this they would
1565
+ // render inert (blank, never connecting) until the user taps a tab. This
1566
+ // is the sibling of the '+'-retry recovery the bootstrap already handles.
1567
+ function ensureActive(): void {
1568
+ if (activeId !== null) {
1569
+ return;
1570
+ }
1571
+ const first = tabList[0];
1572
+ if (first) {
1573
+ switchTo(first.id);
1574
+ }
1575
+ }
1576
+
1119
1577
  async function create(): Promise<void> {
1120
1578
  let info: SessionInfo;
1121
1579
  try {
@@ -1174,6 +1632,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1174
1632
  if (!tab) {
1175
1633
  return;
1176
1634
  }
1635
+ // Tombstone this id briefly so a stale status snapshot/poll that predates
1636
+ // the server reaping it cannot re-adopt the just-closed tab.
1637
+ tombstone(id);
1177
1638
  tab.aria.remove();
1178
1639
  // Remove immediately (no exit animation): a lingering element made the
1179
1640
  // "+" teleport after a delay, and made a last-tab replacement appear in
@@ -1192,7 +1653,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1192
1653
  }
1193
1654
  syncChrome(); // reflect the drop immediately (count, position)
1194
1655
  if (remote) {
1195
- await apiClose(id);
1656
+ try {
1657
+ await apiClose(id);
1658
+ } catch {
1659
+ ctx.toast("Couldn't close the terminal on the server");
1660
+ }
1196
1661
  }
1197
1662
  if (activeId === id) {
1198
1663
  // Switch to a neighbor, or spawn a fresh session if this was the last.
@@ -1230,6 +1695,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1230
1695
  if (idx >= 0) {
1231
1696
  tabList.splice(idx, 1);
1232
1697
  }
1698
+ // Tombstone briefly so a stale status snapshot/poll that predates the
1699
+ // server reaping these sessions cannot re-adopt (flash back) a just-
1700
+ // closed tab -- mirrors the single-close path in dropTab (h-f2).
1701
+ tombstone(t.id);
1233
1702
  t.aria.remove();
1234
1703
  t.el.remove();
1235
1704
  ctx.dropSession(t.id);
@@ -1248,7 +1717,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1248
1717
  syncChrome();
1249
1718
  }
1250
1719
  for (const t of victims) {
1251
- await apiClose(t.id);
1720
+ try {
1721
+ await apiClose(t.id);
1722
+ } catch {
1723
+ ctx.toast("Couldn't close a terminal on the server");
1724
+ }
1252
1725
  }
1253
1726
  if (tabList.length === 0) {
1254
1727
  await create(); // there is always at least one terminal open
@@ -1350,8 +1823,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1350
1823
  return;
1351
1824
  }
1352
1825
  hintShown = true;
1353
- if (!window.matchMedia("(pointer: coarse)").matches) {
1354
- return; // desktop: swipe is irrelevant
1826
+ if (!window.matchMedia(SWITCHER_MEDIA).matches) {
1827
+ return; // only the mobile switcher layout has a swipe-to-switch bar
1355
1828
  }
1356
1829
  let seen = false;
1357
1830
  try {
@@ -1377,6 +1850,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1377
1850
  id: string,
1378
1851
  status: string,
1379
1852
  title: string | undefined,
1853
+ clientTitle: string | undefined,
1380
1854
  reports: boolean,
1381
1855
  ): void {
1382
1856
  const t = tabList.find((tab) => tab.id === id);
@@ -1385,12 +1859,34 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1385
1859
  }
1386
1860
  t.reports = reports;
1387
1861
  paintStatusDot(t.dot, status, reports);
1862
+ // Latest-wins background-tab notification for the switch button's dot: a
1863
+ // background tab (not the active one) reaching "input" (needs you) or
1864
+ // "done" (turn finished) raises the cue in that colour; each qualifying
1865
+ // event overwrites the prior one, and expandSwitcher clears it when the
1866
+ // list opens. The active surface keeps its own needs-input cue (see
1867
+ // syncMobile); this is the moved + upgraded, glanceable version on the
1868
+ // dedicated button.
1869
+ if (id !== activeId && (status === "input" || status === "done")) {
1870
+ switchNotify = status;
1871
+ paintSwitchDot();
1872
+ }
1388
1873
  // Record the raw server title; the displayed label (fallback + de-dup)
1389
1874
  // is recomputed by relabelAll via syncChrome, which the callers run
1390
- // right after applyStatus.
1391
- if (title !== undefined) {
1875
+ // right after applyStatus. Ignore a BLANK title: a status sweep (or the
1876
+ // process clearing its OSC 0/2 window title) reports an empty string,
1877
+ // and overwriting a good label with it dropped an idle tab back to "New
1878
+ // tab". Hold the last known title until a genuine (non-blank) one
1879
+ // arrives; the derived-from-input fallback is likewise sticky.
1880
+ if (title !== undefined && title.trim() !== "") {
1392
1881
  t.title = title;
1393
1882
  }
1883
+ // The persisted client title is authoritative from the server (set via
1884
+ // PUT .../title); apply it as-is, including "" for a fresh session, so the
1885
+ // preferInputTitle label recovers it on reload. No blank-guard: unlike the
1886
+ // OSC title it does not flicker (it changes only on an explicit push).
1887
+ if (clientTitle !== undefined) {
1888
+ t.clientTitle = clientTitle;
1889
+ }
1394
1890
  }
1395
1891
 
1396
1892
  // Live status: the activity monitor (SSE push) when present, else a poll of
@@ -1409,34 +1905,56 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1409
1905
  adoptSession({
1410
1906
  id: s.id,
1411
1907
  title: s.title,
1908
+ clientTitle: undefined,
1412
1909
  createdAt: s.createdAt,
1413
1910
  status: s.status,
1414
1911
  reportsActivity: s.reportsActivity ?? false,
1415
1912
  });
1416
- applyStatus(s.id, s.status, s.title, s.reportsActivity ?? false);
1913
+ applyStatus(s.id, s.status, s.title, undefined, s.reportsActivity ?? false);
1914
+ ensureActive();
1417
1915
  syncChrome();
1418
1916
  });
1419
1917
  } else {
1420
1918
  const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
1919
+ // Guard against overlapping runs: a server slower than pollMs (but
1920
+ // within the 15s API timeout) would otherwise let setInterval start
1921
+ // a second pollOnce while the first is still awaiting, racing tabList
1922
+ // mutation. Skip the tick instead.
1923
+ let polling = false;
1421
1924
  const pollOnce = async (): Promise<void> => {
1422
- let list: SessionInfo[];
1423
- try {
1424
- list = await apiList();
1425
- } catch {
1426
- return; // transient; try again next tick
1427
- }
1428
- const seen = new Set(list.map((s) => s.id));
1429
- for (const info of list) {
1430
- adoptSession(info); // add sessions created elsewhere (no local tab)
1431
- applyStatus(info.id, info.status, info.title, info.reportsActivity ?? false);
1925
+ if (polling) {
1926
+ return;
1432
1927
  }
1433
- // A tab the server no longer lists was reaped/closed elsewhere: drop it
1434
- // locally (no DELETE — it is already gone).
1435
- const gone = tabList.filter((t) => !seen.has(t.id)).map((t) => t.id);
1436
- for (const id of gone) {
1437
- await dropTab(id, false);
1928
+ polling = true;
1929
+ try {
1930
+ let list: SessionInfo[];
1931
+ try {
1932
+ list = await apiList();
1933
+ } catch {
1934
+ return; // transient; try again next tick
1935
+ }
1936
+ const seen = new Set(list.map((s) => s.id));
1937
+ for (const info of list) {
1938
+ adoptSession(info); // add sessions created elsewhere (no local tab)
1939
+ applyStatus(
1940
+ info.id,
1941
+ info.status,
1942
+ info.title,
1943
+ info.clientTitle,
1944
+ info.reportsActivity ?? false,
1945
+ );
1946
+ }
1947
+ // A tab the server no longer lists was reaped/closed elsewhere: drop it
1948
+ // locally (no DELETE — it is already gone).
1949
+ const gone = tabList.filter((t) => !seen.has(t.id)).map((t) => t.id);
1950
+ for (const id of gone) {
1951
+ await dropTab(id, false);
1952
+ }
1953
+ ensureActive();
1954
+ syncChrome();
1955
+ } finally {
1956
+ polling = false;
1438
1957
  }
1439
- syncChrome();
1440
1958
  };
1441
1959
  pollTimer = setInterval(() => {
1442
1960
  void pollOnce();
@@ -1446,8 +1964,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1446
1964
  // --- Event wiring ---
1447
1965
  // Derive each tab's title from the first line the user submits into it.
1448
1966
  const offInput = ctx.registerInputObserver(deriveTitleFromInput);
1449
- newBtn.addEventListener("click", () => {
1450
- void create();
1967
+ // Observe (never consume) keydowns to detect a physical keyboard: a
1968
+ // hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
1969
+ // for a keyboard folio with no trackpad (no fine pointer to key off).
1970
+ const offHwKey = ctx.registerKeydown((ev) => {
1971
+ if (!sawHardwareKey && looksLikeHardwareKey(ev)) {
1972
+ sawHardwareKey = true;
1973
+ }
1974
+ return false;
1451
1975
  });
1452
1976
  // Live reorder while dragging a tab over the strip (dragend commits it).
1453
1977
  bar.addEventListener("dragover", (e) => {
@@ -1460,23 +1984,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1460
1984
  }
1461
1985
  bar.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? newBtn);
1462
1986
  });
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);
1475
- });
1476
- // Mobile "+": spawn a terminal (mirrors the desktop strip's newBtn).
1477
- swNew.addEventListener("click", () => {
1478
- void create();
1479
- });
1480
1987
  // Active-row close (x): closes the current tab (mirrors a listed row's x).
1481
1988
  // stopPropagation so it is not read as a tap/swipe on the row surface.
1482
1989
  swClose.addEventListener("click", (e) => {
@@ -1520,6 +2027,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1520
2027
  // the dismissing tap. Taps inside the switcher or the key grid are left to
1521
2028
  // their own controls; when nothing is open the tap falls through untouched.
1522
2029
  const onDocTapDismiss = (e: PointerEvent): void => {
2030
+ // A swipe gesture on the switcher owns the pointer; its own end logic
2031
+ // (endHorizontal/endVertical, via the window-level endOnUp) resolves the
2032
+ // outcome. Stand down here so this capture-phase handler's stopPropagation
2033
+ // cannot swallow that window-bubble pointerup -- which, when setPointerCapture
2034
+ // failed and the finger released outside the switcher, would strand gActive=true
2035
+ // and brick all future swipes until reload.
2036
+ if (gActive) {
2037
+ return;
2038
+ }
1523
2039
  const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
1524
2040
  const gridOpen = kb?.isOpen() ?? false;
1525
2041
  if (!expanded && !gridOpen) {
@@ -1704,7 +2220,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1704
2220
  switcher.classList.add("wt-switcher-expanded");
1705
2221
  swList.style.transition = "none";
1706
2222
  swList.style.overflowY = "hidden";
1707
- gTargetMax = Math.min(swList.scrollHeight, Math.round(window.innerHeight * 0.5));
2223
+ // Bound the interactive drag against the VISUAL viewport (the region above the soft
2224
+ // keyboard), matching the switcher's kb-inset bottom anchor, so the list can't grow
2225
+ // past the visible area with the keyboard open.
2226
+ const visH = window.visualViewport?.height ?? window.innerHeight;
2227
+ gTargetMax = Math.min(swList.scrollHeight, Math.round(visH * 0.5));
1708
2228
  gStartMax = expanded ? gTargetMax : 0;
1709
2229
  swList.style.maxHeight = `${String(gStartMax)}px`;
1710
2230
  }
@@ -1901,7 +2421,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1901
2421
  sessions = [];
1902
2422
  }
1903
2423
  if (sessions.length === 0) {
1904
- sessions = [await apiCreate()];
2424
+ try {
2425
+ sessions = [await apiCreate()];
2426
+ } catch {
2427
+ // Do not let the initial spawn propagate out of setup(): the kernel
2428
+ // would tear down every feature ("terminal has no chrome") and, since
2429
+ // managesSessions skipped the startup connect, connectionInitiated
2430
+ // stays false so the wake handlers (visibilitychange/pageshow/online)
2431
+ // all no-op -- a permanently dead terminal with no recovery. Match the
2432
+ // runtime create() path: toast and leave the chrome up so "+" can retry.
2433
+ ctx.toast("Couldn't open a terminal");
2434
+ sessions = [];
2435
+ }
1905
2436
  }
1906
2437
  // Adopt (dedup) rather than blindly push. The status SSE pushes a snapshot
1907
2438
  // of the existing sessions on open, and tabs subscribes (monitor.onStatus)
@@ -1914,12 +2445,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1914
2445
  }
1915
2446
  // From here on, tabs added at runtime (create / adopt) animate in.
1916
2447
  started = true;
1917
- // Activate the first (oldest) session.
1918
- const first = tabList[0];
1919
- if (first) {
1920
- activeId = first.id;
1921
- ctx.render.bind(first.store);
1922
- ctx.notifySwitch({ id: first.id });
2448
+ // Activate the previously-active session if it still exists, else the
2449
+ // first (oldest). Session ids are stable server-side, so a page reload
2450
+ // reconnects to the tab the user left on instead of always the oldest.
2451
+ let startTab = tabList[0];
2452
+ try {
2453
+ const savedId = localStorage.getItem(ACTIVE_TAB_KEY);
2454
+ if (savedId !== null && savedId !== "") {
2455
+ const saved = tabList.find((x) => x.id === savedId);
2456
+ if (saved) {
2457
+ startTab = saved;
2458
+ }
2459
+ }
2460
+ } catch {
2461
+ /* storage unavailable — fall back to the oldest tab */
2462
+ }
2463
+ if (startTab) {
2464
+ activeId = startTab.id;
2465
+ try {
2466
+ localStorage.setItem(ACTIVE_TAB_KEY, startTab.id);
2467
+ } catch {
2468
+ /* storage unavailable — non-fatal */
2469
+ }
2470
+ ctx.render.bind(startTab.store);
2471
+ ctx.notifySwitch({ id: startTab.id });
1923
2472
  syncChrome();
1924
2473
  focusInput();
1925
2474
  }
@@ -1935,6 +2484,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1935
2484
  teardown() {
1936
2485
  offStatus?.();
1937
2486
  offInput();
2487
+ offHwKey();
1938
2488
  offArmed?.();
1939
2489
  offMenuKey();
1940
2490
  document.removeEventListener("click", onDocClickMenu);
@@ -1968,6 +2518,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1968
2518
  rowEls.clear();
1969
2519
  bar.remove();
1970
2520
  newBtn.remove();
2521
+ deskKb.remove();
1971
2522
  tabMenu.remove();
1972
2523
  switcher.remove();
1973
2524
  catchup.remove();