@cplieger/web-terminal-ui 3.3.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,12 +734,21 @@ 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);
@@ -440,7 +762,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
440
762
  }
441
763
  if (escState === 2) {
442
764
  if (b >= 0x40 && b <= 0x7e) {
765
+ // CSI final byte. Recognize the bracketed-paste guards ESC[200~
766
+ // (start) and ESC[201~ (end) so embedded newlines in a pasted,
767
+ // often multi-line, message are not read as line submissions.
768
+ if (b === 0x7e) {
769
+ if (csiParams === "200") {
770
+ inPaste = true;
771
+ } else if (csiParams === "201") {
772
+ inPaste = false;
773
+ }
774
+ }
775
+ csiParams = "";
443
776
  escState = 0; // CSI final byte
777
+ } else if (b >= 0x30 && b <= 0x3f) {
778
+ csiParams += String.fromCharCode(b); // parameter byte (digits, ; ? etc.)
444
779
  }
445
780
  continue;
446
781
  }
@@ -451,6 +786,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
451
786
  if (b === 0x1b) {
452
787
  escState = 1; // ESC: start of an escape sequence
453
788
  } else if (b === 0x0d || b === 0x0a) {
789
+ if (inPaste) {
790
+ // A newline INSIDE a bracketed paste is pasted content, not a
791
+ // submit: fold it to a single space so the whole paste stays one
792
+ // logical line and the title reflects its START (a multi-line
793
+ // paste previously left only its LAST line as the title).
794
+ if (lineBytes.length > 0 && lineBytes[lineBytes.length - 1] !== 0x20) {
795
+ lineBytes.push(0x20);
796
+ }
797
+ continue;
798
+ }
454
799
  const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
455
800
  lineBytes = [];
456
801
  // Every non-empty submitted line updates the fallback title (the
@@ -458,6 +803,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
458
803
  // command path.
459
804
  if (line) {
460
805
  t.derived = line.slice(0, MAX_DERIVED);
806
+ // Persist it server-side (engine PUT .../title) so a reload and
807
+ // other devices recover this "latest message" label; the engine
808
+ // uses it only when the program emits no OSC title.
809
+ void apiSetTitle(t.id, t.derived);
461
810
  syncChrome();
462
811
  }
463
812
  } else if (b === 0x7f || b === 0x08) {
@@ -500,16 +849,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
500
849
  const active = idx >= 0 ? tabList[idx] : undefined;
501
850
  swLabel.textContent = active ? active.display : "";
502
851
  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
- }
852
+ // The aggregate background-notification cue rides the dedicated switch
853
+ // button's dot (paintSwitchDot), not the active surface (it did not fit
854
+ // there).
513
855
  }
514
856
 
515
857
  // buildRow creates one expanded-list row for a tab and wires its handlers
@@ -757,8 +1099,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
757
1099
  if (kb?.isOpen()) {
758
1100
  kb.toggle();
759
1101
  }
760
- swKb.setAttribute("aria-expanded", "false");
761
- swKb.classList.remove("wt-active");
1102
+ for (const b of kbButtons) {
1103
+ b.setAttribute("aria-expanded", "false");
1104
+ b.classList.remove("wt-active");
1105
+ }
762
1106
  }
763
1107
 
764
1108
  // setExpandedState applies the resting expanded/collapsed state: the class
@@ -804,6 +1148,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
804
1148
  if (expanded || tabList.length < 2) {
805
1149
  return;
806
1150
  }
1151
+ // Opening the list acknowledges any pending background-tab notification:
1152
+ // the user is now looking at the tabs, so clear the switch button's dot.
1153
+ switchNotify = "";
1154
+ paintSwitchDot();
807
1155
  renderSwitcherList();
808
1156
  setExpandedState(true);
809
1157
  ctx.announce("Terminal list expanded");
@@ -851,6 +1199,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
851
1199
  const tab: Tab = {
852
1200
  id: info.id,
853
1201
  title: info.title,
1202
+ clientTitle: info.clientTitle,
854
1203
  display: "",
855
1204
  createdAt: info.createdAt,
856
1205
  store: new LineStore(),
@@ -968,6 +1317,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
968
1317
  // rebuild viewport-first, so the last-known screen paints with no
969
1318
  // round-trip. Then let the kernel reconnect the WS to it (resume delta).
970
1319
  activeId = next.id;
1320
+ try {
1321
+ localStorage.setItem(ACTIVE_TAB_KEY, next.id);
1322
+ } catch {
1323
+ /* storage unavailable (private mode / disabled) — non-fatal */
1324
+ }
971
1325
  resetInputLine(); // a partial line typed in the old tab does not carry over
972
1326
  ctx.render.bind(next.store);
973
1327
  ctx.notifySwitch({ id: next.id });
@@ -979,18 +1333,32 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
979
1333
  syncChrome(); // reconciles the expanded list into the new order
980
1334
  reelReconcile = false;
981
1335
  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.
1336
+ // Restore scroll memory best-effort after the async rebuild. The engine
1337
+ // scroll controller's follow flag is GLOBAL (one per kernel) and still
1338
+ // reflects the tab we just left, so binding a following tab right after
1339
+ // being scrolled up in another tab left the controller in "holding":
1340
+ // the renderer's post-flush stickToBottom() no-op'd and the cached
1341
+ // screen rendered above the viewport (a black gap until a touch scrolled
1342
+ // it and re-engaged follow — the "content pops down when I wiggle it"
1343
+ // symptom). Re-assert here: scrollToBottom() snaps down AND re-engages
1344
+ // follow (so the resume delta then pins correctly); a scrolled-up tab
1345
+ // restores its saved read position instead.
984
1346
  const savedTop = next.scrollTop;
985
1347
  const following = next.following;
986
1348
  requestAnimationFrame(() => {
987
- if (!following) {
1349
+ if (following) {
1350
+ ctx.scroll.scrollToBottom();
1351
+ } else {
988
1352
  surface.scrollTop = savedTop;
989
1353
  }
990
1354
  });
991
1355
  ctx.announce(`Switched to ${next.display}`);
992
- if (!coarse) {
993
- focusInput(); // desktop only; on touch this would pop the keyboard (#7)
1356
+ if (physicalKeyboardLikely()) {
1357
+ // A physical keyboard is (likely) present, so focus the input on
1358
+ // switch — switch and type immediately (the iPad + Magic Keyboard
1359
+ // ask). On a keyboard-less touchscreen this is skipped, or every
1360
+ // switch would pop the virtual keyboard (#7).
1361
+ focusInput();
994
1362
  }
995
1363
  }
996
1364
 
@@ -1049,7 +1417,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1049
1417
  // which have no live socket, still refresh their title from that sweep).
1050
1418
  ctx.on("wire:title", ({ session, title }) => {
1051
1419
  const t = tabList.find((x) => x.id === session);
1052
- if (t && title !== t.title) {
1420
+ // Ignore a blank title (an OSC 0/2 clear the process may emit when it
1421
+ // redraws its prompt after idling): keep the last good label until a real
1422
+ // replacement arrives, rather than reverting to "New tab". A non-blank
1423
+ // change updates the label at once.
1424
+ if (t && title.trim() !== "" && title !== t.title) {
1053
1425
  t.title = title;
1054
1426
  syncChrome();
1055
1427
  }
@@ -1113,9 +1485,34 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1113
1485
  if (tabList.find((t) => t.id === info.id)) {
1114
1486
  return;
1115
1487
  }
1488
+ const closedAt = recentlyClosed.get(info.id);
1489
+ if (closedAt !== undefined) {
1490
+ if (Date.now() - closedAt < CLOSE_TOMBSTONE_MS) {
1491
+ return; // just closed here; ignore a stale listing until the server reaps it
1492
+ }
1493
+ recentlyClosed.delete(info.id);
1494
+ }
1116
1495
  tabList.push(addTabChrome(info));
1117
1496
  }
1118
1497
 
1498
+ // Activate the first tab when nothing is active. The bootstrap normally
1499
+ // activates a tab, but if the initial apiList AND apiCreate both fail at
1500
+ // load its `if (startTab)` activation is skipped, leaving activeId null
1501
+ // and connectionInitiated false -- so the kernel never opens the terminal
1502
+ // WS and its wake handlers no-op. When the server recovers, the status
1503
+ // stream / poll adopt the existing sessions below; without this they would
1504
+ // render inert (blank, never connecting) until the user taps a tab. This
1505
+ // is the sibling of the '+'-retry recovery the bootstrap already handles.
1506
+ function ensureActive(): void {
1507
+ if (activeId !== null) {
1508
+ return;
1509
+ }
1510
+ const first = tabList[0];
1511
+ if (first) {
1512
+ switchTo(first.id);
1513
+ }
1514
+ }
1515
+
1119
1516
  async function create(): Promise<void> {
1120
1517
  let info: SessionInfo;
1121
1518
  try {
@@ -1174,6 +1571,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1174
1571
  if (!tab) {
1175
1572
  return;
1176
1573
  }
1574
+ // Tombstone this id briefly so a stale status snapshot/poll that predates
1575
+ // the server reaping it cannot re-adopt the just-closed tab.
1576
+ tombstone(id);
1177
1577
  tab.aria.remove();
1178
1578
  // Remove immediately (no exit animation): a lingering element made the
1179
1579
  // "+" teleport after a delay, and made a last-tab replacement appear in
@@ -1192,7 +1592,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1192
1592
  }
1193
1593
  syncChrome(); // reflect the drop immediately (count, position)
1194
1594
  if (remote) {
1195
- await apiClose(id);
1595
+ try {
1596
+ await apiClose(id);
1597
+ } catch {
1598
+ ctx.toast("Couldn't close the terminal on the server");
1599
+ }
1196
1600
  }
1197
1601
  if (activeId === id) {
1198
1602
  // Switch to a neighbor, or spawn a fresh session if this was the last.
@@ -1230,6 +1634,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1230
1634
  if (idx >= 0) {
1231
1635
  tabList.splice(idx, 1);
1232
1636
  }
1637
+ // Tombstone briefly so a stale status snapshot/poll that predates the
1638
+ // server reaping these sessions cannot re-adopt (flash back) a just-
1639
+ // closed tab -- mirrors the single-close path in dropTab (h-f2).
1640
+ tombstone(t.id);
1233
1641
  t.aria.remove();
1234
1642
  t.el.remove();
1235
1643
  ctx.dropSession(t.id);
@@ -1248,7 +1656,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1248
1656
  syncChrome();
1249
1657
  }
1250
1658
  for (const t of victims) {
1251
- await apiClose(t.id);
1659
+ try {
1660
+ await apiClose(t.id);
1661
+ } catch {
1662
+ ctx.toast("Couldn't close a terminal on the server");
1663
+ }
1252
1664
  }
1253
1665
  if (tabList.length === 0) {
1254
1666
  await create(); // there is always at least one terminal open
@@ -1350,8 +1762,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1350
1762
  return;
1351
1763
  }
1352
1764
  hintShown = true;
1353
- if (!window.matchMedia("(pointer: coarse)").matches) {
1354
- return; // desktop: swipe is irrelevant
1765
+ if (!window.matchMedia(SWITCHER_MEDIA).matches) {
1766
+ return; // only the mobile switcher layout has a swipe-to-switch bar
1355
1767
  }
1356
1768
  let seen = false;
1357
1769
  try {
@@ -1377,6 +1789,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1377
1789
  id: string,
1378
1790
  status: string,
1379
1791
  title: string | undefined,
1792
+ clientTitle: string | undefined,
1380
1793
  reports: boolean,
1381
1794
  ): void {
1382
1795
  const t = tabList.find((tab) => tab.id === id);
@@ -1385,12 +1798,34 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1385
1798
  }
1386
1799
  t.reports = reports;
1387
1800
  paintStatusDot(t.dot, status, reports);
1801
+ // Latest-wins background-tab notification for the switch button's dot: a
1802
+ // background tab (not the active one) reaching "input" (needs you) or
1803
+ // "done" (turn finished) raises the cue in that colour; each qualifying
1804
+ // event overwrites the prior one, and expandSwitcher clears it when the
1805
+ // list opens. The active surface keeps its own needs-input cue (see
1806
+ // syncMobile); this is the moved + upgraded, glanceable version on the
1807
+ // dedicated button.
1808
+ if (id !== activeId && (status === "input" || status === "done")) {
1809
+ switchNotify = status;
1810
+ paintSwitchDot();
1811
+ }
1388
1812
  // Record the raw server title; the displayed label (fallback + de-dup)
1389
1813
  // is recomputed by relabelAll via syncChrome, which the callers run
1390
- // right after applyStatus.
1391
- if (title !== undefined) {
1814
+ // right after applyStatus. Ignore a BLANK title: a status sweep (or the
1815
+ // process clearing its OSC 0/2 window title) reports an empty string,
1816
+ // and overwriting a good label with it dropped an idle tab back to "New
1817
+ // tab". Hold the last known title until a genuine (non-blank) one
1818
+ // arrives; the derived-from-input fallback is likewise sticky.
1819
+ if (title !== undefined && title.trim() !== "") {
1392
1820
  t.title = title;
1393
1821
  }
1822
+ // The persisted client title is authoritative from the server (set via
1823
+ // PUT .../title); apply it as-is, including "" for a fresh session, so the
1824
+ // preferInputTitle label recovers it on reload. No blank-guard: unlike the
1825
+ // OSC title it does not flicker (it changes only on an explicit push).
1826
+ if (clientTitle !== undefined) {
1827
+ t.clientTitle = clientTitle;
1828
+ }
1394
1829
  }
1395
1830
 
1396
1831
  // Live status: the activity monitor (SSE push) when present, else a poll of
@@ -1409,34 +1844,56 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1409
1844
  adoptSession({
1410
1845
  id: s.id,
1411
1846
  title: s.title,
1847
+ clientTitle: undefined,
1412
1848
  createdAt: s.createdAt,
1413
1849
  status: s.status,
1414
1850
  reportsActivity: s.reportsActivity ?? false,
1415
1851
  });
1416
- applyStatus(s.id, s.status, s.title, s.reportsActivity ?? false);
1852
+ applyStatus(s.id, s.status, s.title, undefined, s.reportsActivity ?? false);
1853
+ ensureActive();
1417
1854
  syncChrome();
1418
1855
  });
1419
1856
  } else {
1420
1857
  const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
1858
+ // Guard against overlapping runs: a server slower than pollMs (but
1859
+ // within the 15s API timeout) would otherwise let setInterval start
1860
+ // a second pollOnce while the first is still awaiting, racing tabList
1861
+ // mutation. Skip the tick instead.
1862
+ let polling = false;
1421
1863
  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);
1864
+ if (polling) {
1865
+ return;
1432
1866
  }
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);
1867
+ polling = true;
1868
+ try {
1869
+ let list: SessionInfo[];
1870
+ try {
1871
+ list = await apiList();
1872
+ } catch {
1873
+ return; // transient; try again next tick
1874
+ }
1875
+ const seen = new Set(list.map((s) => s.id));
1876
+ for (const info of list) {
1877
+ adoptSession(info); // add sessions created elsewhere (no local tab)
1878
+ applyStatus(
1879
+ info.id,
1880
+ info.status,
1881
+ info.title,
1882
+ info.clientTitle,
1883
+ info.reportsActivity ?? false,
1884
+ );
1885
+ }
1886
+ // A tab the server no longer lists was reaped/closed elsewhere: drop it
1887
+ // locally (no DELETE — it is already gone).
1888
+ const gone = tabList.filter((t) => !seen.has(t.id)).map((t) => t.id);
1889
+ for (const id of gone) {
1890
+ await dropTab(id, false);
1891
+ }
1892
+ ensureActive();
1893
+ syncChrome();
1894
+ } finally {
1895
+ polling = false;
1438
1896
  }
1439
- syncChrome();
1440
1897
  };
1441
1898
  pollTimer = setInterval(() => {
1442
1899
  void pollOnce();
@@ -1446,8 +1903,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1446
1903
  // --- Event wiring ---
1447
1904
  // Derive each tab's title from the first line the user submits into it.
1448
1905
  const offInput = ctx.registerInputObserver(deriveTitleFromInput);
1449
- newBtn.addEventListener("click", () => {
1450
- void create();
1906
+ // Observe (never consume) keydowns to detect a physical keyboard: a
1907
+ // hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
1908
+ // for a keyboard folio with no trackpad (no fine pointer to key off).
1909
+ const offHwKey = ctx.registerKeydown((ev) => {
1910
+ if (!sawHardwareKey && looksLikeHardwareKey(ev)) {
1911
+ sawHardwareKey = true;
1912
+ }
1913
+ return false;
1451
1914
  });
1452
1915
  // Live reorder while dragging a tab over the strip (dragend commits it).
1453
1916
  bar.addEventListener("dragover", (e) => {
@@ -1460,23 +1923,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1460
1923
  }
1461
1924
  bar.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? newBtn);
1462
1925
  });
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
1926
  // Active-row close (x): closes the current tab (mirrors a listed row's x).
1481
1927
  // stopPropagation so it is not read as a tap/swipe on the row surface.
1482
1928
  swClose.addEventListener("click", (e) => {
@@ -1520,6 +1966,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1520
1966
  // the dismissing tap. Taps inside the switcher or the key grid are left to
1521
1967
  // their own controls; when nothing is open the tap falls through untouched.
1522
1968
  const onDocTapDismiss = (e: PointerEvent): void => {
1969
+ // A swipe gesture on the switcher owns the pointer; its own end logic
1970
+ // (endHorizontal/endVertical, via the window-level endOnUp) resolves the
1971
+ // outcome. Stand down here so this capture-phase handler's stopPropagation
1972
+ // cannot swallow that window-bubble pointerup -- which, when setPointerCapture
1973
+ // failed and the finger released outside the switcher, would strand gActive=true
1974
+ // and brick all future swipes until reload.
1975
+ if (gActive) {
1976
+ return;
1977
+ }
1523
1978
  const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
1524
1979
  const gridOpen = kb?.isOpen() ?? false;
1525
1980
  if (!expanded && !gridOpen) {
@@ -1704,7 +2159,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1704
2159
  switcher.classList.add("wt-switcher-expanded");
1705
2160
  swList.style.transition = "none";
1706
2161
  swList.style.overflowY = "hidden";
1707
- gTargetMax = Math.min(swList.scrollHeight, Math.round(window.innerHeight * 0.5));
2162
+ // Bound the interactive drag against the VISUAL viewport (the region above the soft
2163
+ // keyboard), matching the switcher's kb-inset bottom anchor, so the list can't grow
2164
+ // past the visible area with the keyboard open.
2165
+ const visH = window.visualViewport?.height ?? window.innerHeight;
2166
+ gTargetMax = Math.min(swList.scrollHeight, Math.round(visH * 0.5));
1708
2167
  gStartMax = expanded ? gTargetMax : 0;
1709
2168
  swList.style.maxHeight = `${String(gStartMax)}px`;
1710
2169
  }
@@ -1901,7 +2360,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1901
2360
  sessions = [];
1902
2361
  }
1903
2362
  if (sessions.length === 0) {
1904
- sessions = [await apiCreate()];
2363
+ try {
2364
+ sessions = [await apiCreate()];
2365
+ } catch {
2366
+ // Do not let the initial spawn propagate out of setup(): the kernel
2367
+ // would tear down every feature ("terminal has no chrome") and, since
2368
+ // managesSessions skipped the startup connect, connectionInitiated
2369
+ // stays false so the wake handlers (visibilitychange/pageshow/online)
2370
+ // all no-op -- a permanently dead terminal with no recovery. Match the
2371
+ // runtime create() path: toast and leave the chrome up so "+" can retry.
2372
+ ctx.toast("Couldn't open a terminal");
2373
+ sessions = [];
2374
+ }
1905
2375
  }
1906
2376
  // Adopt (dedup) rather than blindly push. The status SSE pushes a snapshot
1907
2377
  // of the existing sessions on open, and tabs subscribes (monitor.onStatus)
@@ -1914,12 +2384,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1914
2384
  }
1915
2385
  // From here on, tabs added at runtime (create / adopt) animate in.
1916
2386
  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 });
2387
+ // Activate the previously-active session if it still exists, else the
2388
+ // first (oldest). Session ids are stable server-side, so a page reload
2389
+ // reconnects to the tab the user left on instead of always the oldest.
2390
+ let startTab = tabList[0];
2391
+ try {
2392
+ const savedId = localStorage.getItem(ACTIVE_TAB_KEY);
2393
+ if (savedId !== null && savedId !== "") {
2394
+ const saved = tabList.find((x) => x.id === savedId);
2395
+ if (saved) {
2396
+ startTab = saved;
2397
+ }
2398
+ }
2399
+ } catch {
2400
+ /* storage unavailable — fall back to the oldest tab */
2401
+ }
2402
+ if (startTab) {
2403
+ activeId = startTab.id;
2404
+ try {
2405
+ localStorage.setItem(ACTIVE_TAB_KEY, startTab.id);
2406
+ } catch {
2407
+ /* storage unavailable — non-fatal */
2408
+ }
2409
+ ctx.render.bind(startTab.store);
2410
+ ctx.notifySwitch({ id: startTab.id });
1923
2411
  syncChrome();
1924
2412
  focusInput();
1925
2413
  }
@@ -1935,6 +2423,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1935
2423
  teardown() {
1936
2424
  offStatus?.();
1937
2425
  offInput();
2426
+ offHwKey();
1938
2427
  offArmed?.();
1939
2428
  offMenuKey();
1940
2429
  document.removeEventListener("click", onDocClickMenu);
@@ -1968,6 +2457,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1968
2457
  rowEls.clear();
1969
2458
  bar.remove();
1970
2459
  newBtn.remove();
2460
+ deskKb.remove();
1971
2461
  tabMenu.remove();
1972
2462
  switcher.remove();
1973
2463
  catchup.remove();