@cplieger/web-terminal-ui 3.4.5 → 4.0.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.
Files changed (38) hide show
  1. package/README.md +53 -23
  2. package/css/00-tokens.css +8 -2
  3. package/css/01-scope.css +56 -0
  4. package/css/{02-app.css → 02-terminal.css} +64 -239
  5. package/css/{03-primitives.css → 10-primitives.css} +21 -19
  6. package/css/20-connection-banner.css +42 -0
  7. package/css/21-scroll-to-bottom.css +65 -0
  8. package/css/22-context-menu.css +45 -0
  9. package/css/23-toolbar.css +181 -0
  10. package/css/{05-tabs.css → 30-tabs.css} +96 -57
  11. package/css/{06-mobile.css → 31-switcher.css} +66 -81
  12. package/css/{07-animations.css → 40-animations.css} +15 -15
  13. package/css/MANIFEST +19 -11
  14. package/css/MANIFEST.single +12 -0
  15. package/css/MANIFEST.tabbed +16 -0
  16. package/css/MANIFEST.touch +12 -0
  17. package/css/{01-reset.css → page.css} +18 -19
  18. package/package.json +21 -5
  19. package/scaffold/index.html +5 -0
  20. package/src/features/connection-banner.ts +3 -0
  21. package/src/features/context-menu.ts +6 -33
  22. package/src/features/menu-position.ts +50 -0
  23. package/src/features/{tabs.ts → tabs/index.ts} +207 -636
  24. package/src/features/tabs/model.ts +334 -0
  25. package/src/features/tabs/strip.ts +101 -0
  26. package/src/features/tabs/switcher.ts +129 -0
  27. package/src/index.ts +1 -0
  28. package/src/kernel/a11y.ts +30 -5
  29. package/src/kernel/conn-state.ts +20 -3
  30. package/src/kernel/kernel.ts +162 -59
  31. package/src/kernel/types.ts +65 -15
  32. package/src/presets/agent-tabbed.ts +15 -0
  33. package/src/presets/single.ts +27 -0
  34. package/src/presets/tabbed.ts +42 -0
  35. package/src/presets/touch.ts +13 -0
  36. package/src/presets.ts +12 -76
  37. package/src/viewport.ts +28 -15
  38. package/css/04-feedback.css +0 -180
@@ -9,110 +9,57 @@
9
9
  // background delta arrives after.
10
10
 
11
11
  import { LineStore, modes } from "@cplieger/web-terminal-engine";
12
- import type { TerminalContext, TerminalFeature, TabHandle } from "../kernel/types.js";
13
- import type { ActivityMonitorApi } from "./activity-monitor.js";
14
- import type { MobileToolbarApi } from "./mobile-toolbar.js";
15
- import { fromHTML } from "./dom.js";
12
+ import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/types.js";
13
+ import type { ActivityMonitorApi } from "../activity-monitor.js";
14
+ import type { MobileToolbarApi } from "../mobile-toolbar.js";
15
+ import { fromHTML } from "../dom.js";
16
+ import { placeMenuAt } from "../menu-position.js";
17
+ import type { SessionInfo, Tab } from "./model.js";
18
+ import {
19
+ ACTIVE_TAB_KEY,
20
+ STATUS_EXITED,
21
+ SWIPE_HINT_KEY,
22
+ baseLabel,
23
+ createInputTitleDeriver,
24
+ createSessionAPI,
25
+ createTombstones,
26
+ } from "./model.js";
27
+ import {
28
+ TAB_HTML,
29
+ kbButtonHTML,
30
+ newButtonHTML,
31
+ paintStatusDot,
32
+ pick,
33
+ switchButtonHTML,
34
+ } from "./strip.js";
35
+ import {
36
+ AXIS_LOCK_PX,
37
+ PREVIEW_DRAG_RATIO,
38
+ PREVIEW_PEEK_MAX,
39
+ SWIPE_DURATION,
40
+ SWIPE_MIN_PX,
41
+ SWIPE_VELOCITY,
42
+ SWITCHER_HTML,
43
+ SWITCHER_ROW_HTML,
44
+ VELOCITY_STALE_MS,
45
+ animateRowIn,
46
+ animateRowOut,
47
+ } from "./switcher.js";
16
48
 
17
49
  const DEFAULT_API_BASE = "/api/sessions";
18
- // Swipe recognition on the mobile switcher bar: a mostly-horizontal drag past
19
- // this distance switches; a near-stationary release is a tap (opens overview).
20
- const SWIPE_MIN_PX = 40;
21
- // Movement (px) before an in-progress bar drag commits to a horizontal
22
- // (tab-switch preview) or vertical (expand/collapse) axis.
23
- const AXIS_LOCK_PX = 8;
24
- // Flick-to-commit thresholds (from @use-gesture's drag defaults, MIT): a release
25
- // counts as a flick when the gesture was quick, fast, and travelled enough, so a
26
- // short fast swipe commits the switch/expand even below the halfway distance.
27
- const SWIPE_VELOCITY = 0.5; // px/ms at release
28
- const SWIPE_DURATION = 250; // ms, whole-gesture cap for a flick
29
- // If the finger paused longer than this before lifting, the release velocity is
30
- // stale (pointerup usually repeats the last pointermove position -> reads ~0),
31
- // so ignore it and commit by distance instead (@use-gesture issue #332).
32
- const VELOCITY_STALE_MS = 32;
33
- // Live drag preview: while an open list is being swiped, it peeks in the swipe
34
- // direction by this fraction of the finger's horizontal travel, capped at this
35
- // many pixels — a hint of the coming rotation, not the full shift (the incoming
36
- // row only appears on release, so a large move looked wrong). The release reel
37
- // continues from wherever the peek left the rows.
38
- const PREVIEW_DRAG_RATIO = 0.1;
39
- const PREVIEW_PEEK_MAX = 10;
40
- // One-time "swipe to switch" hint, remembered across loads.
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
50
  // The mobile bottom-switcher (a single full-width active-tab chip + swipe) is
46
51
  // used ONLY on a narrow coarse-pointer device (a phone). A wide touchscreen (an
47
52
  // iPad) and every fine-pointer device (a desktop, or an iPad with a trackpad /
48
53
  // Magic Keyboard) get the multi-tab top strip instead — the switcher's
49
54
  // 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)";
55
+ // UI). The narrow half of that fact is the kernel's .wt-narrow root class /
56
+ // ctx.layout().narrow (ONE breakpoint constant, kernel-owned, root-width
57
+ // driven); CSS pairs it with (pointer: coarse) where touch matters.
55
58
  // Default cadence for the no-activityMonitor polling fallback.
56
59
  const DEFAULT_POLL_MS = 4000;
57
- // Tab context-menu viewport clamp (mirrors context-menu.ts): keep this margin
58
- // from every viewport edge, and this gap above the pointer when the menu has to
59
- // flip up (a right-click near the bottom edge) so it is neither clipped nor
60
- // hidden under the cursor.
61
- const TAB_MENU_EDGE = 8;
62
- const TAB_MENU_GAP = 16;
63
-
64
- /** One session's client-side wire shape (matches terminal.SessionInfo). */
65
- interface SessionInfo {
66
- id: string;
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;
72
- createdAt: string;
73
- status: string;
74
- /** Sticky: true once the session has emitted a genuine activity signal
75
- * (OSC 9;4 progress, or a classified OSC 9 notification). Gates whether the
76
- * per-tab activity dot is shown; see paintStatusDot / baseLabel. */
77
- reportsActivity?: boolean;
78
- }
79
-
80
- interface Tab {
81
- id: string;
82
- /** The raw server title (the OSC 0/2 window title the process set), possibly
83
- * empty before the process sets one. The displayed label is derived from it
84
- * with a numbered fallback and de-duplication (see relabelAll). */
85
- title: string;
86
- /** The computed, de-duplicated label actually shown in the chrome. */
87
- display: string;
88
- createdAt: string;
89
- store: LineStore;
90
- el: HTMLElement;
91
- label: HTMLElement;
92
- dot: HTMLElement;
93
- aria: TabHandle;
94
- scrollTop: number;
95
- following: boolean;
96
- /** Sticky: true once this session emitted a genuine activity signal (OSC 9;4).
97
- * Its activity dot is shown only while true; a session that never reports
98
- * activity (a plain shell) keeps a clean, dot-less tab. Fed from the server's
99
- * reportsActivity via applyStatus. */
100
- reports: boolean;
101
- /** A fallback title derived from the LAST non-empty line the user submitted
102
- * into this tab (updated on every Enter). Used only when the process sets no
103
- * window title of its own: the OSC 0/2 title (`title` above) takes precedence
104
- * when present and keeps updating, and this is what the tab reads as when it
105
- * does not — a plain shell with no PROMPT_COMMAND title, or kiro-cli with its
106
- * cwd-only title disabled. Undefined until the first non-empty submission; see
107
- * baseLabel. */
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;
115
- }
60
+ // Tab context-menu viewport clamping + the flip-above-the-pointer gap live in
61
+ // the shared point-anchored positioner (menu-position.ts), shared with the
62
+ // terminal context menu (formerly two hand-synced copies of the same math).
116
63
 
117
64
  export interface TabsApi {
118
65
  /** Spawn a fresh session and switch to it. */
@@ -153,130 +100,6 @@ export interface TabsOptions {
153
100
  preferInputTitle?: boolean;
154
101
  }
155
102
 
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-overlapping-windows glyph for the mobile switcher's dedicated open/close
164
- // button: the browser-style "tab switcher" icon, more recognisable than the
165
- // prior swap-arrows (which read like a keyboard Tab key). A latest-wins
166
- // background-tab notification dot rides on it (see switchButtonHTML). Same
167
- // viewBox + stroke=currentcolor treatment as the others.
168
- const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3"/></svg>`;
169
-
170
- // chipContent is the ONE builder for a tab chip's content — a status dot, a
171
- // label, and a close (x) — shared by all three chip sites: the desktop strip
172
- // (.wt-tab), the mobile active row (.wt-switcher-current), and each expanded
173
- // mobile list row (.wt-switcher-row). Each site passes its OWN class set (never
174
- // renamed) so every existing selector — and thus all CSS and all tests — still
175
- // matches. The one structural difference is WHERE the close sits: the desktop
176
- // chip nests it flat inside .wt-tab, while the two mobile chips place it as a
177
- // sibling of the select/swipe button (a button can't nest in a button). So the
178
- // builder returns two fragments — the dot+label pair and the close — that each
179
- // site drops into its own structure.
180
- function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
181
- dotLabel: string;
182
- close: string;
183
- } {
184
- return {
185
- dotLabel:
186
- `<span class="${v.dot} wt-status-dot" aria-hidden="true"></span>` +
187
- `<span class="${v.label}"></span>`,
188
- close: `<button type="button" class="${v.close}" aria-label="Close terminal"${v.closeAttr ?? ""}>${CLOSE_SVG}</button>`,
189
- };
190
- }
191
-
192
- // The ONE "+" (new-terminal) and keyboard button factories, shared by the
193
- // desktop strip and the mobile switcher — only the class set differs. These
194
- // build the markup; element construction + event wiring live in makeNewButton /
195
- // makeKbButton (in setup), which close over create() and the key-grid toggle.
196
- function newButtonHTML(cls: string): string {
197
- return `<button type="button" class="${cls}" aria-label="New terminal">${NEW_SVG}</button>`;
198
- }
199
- function kbButtonHTML(cls: string): string {
200
- return `<button type="button" class="${cls}" aria-label="Keyboard keys" aria-expanded="false" hidden>${KB_SVG}</button>`;
201
- }
202
- // The mobile switcher's dedicated open/close button. It toggles the tab list and
203
- // carries a latest-wins notification dot (a child span) — amber when a background
204
- // terminal needs input, green when a background turn finished — cleared when the
205
- // list opens. Only the mobile switcher builds it (element construction + event
206
- // wiring live in makeSwitchButton in setup); the desktop strip already shows
207
- // every tab's own status dot, so it needs no aggregate cue.
208
- function switchButtonHTML(cls: string): string {
209
- return (
210
- `<button type="button" class="${cls}" aria-label="Open tab switcher">${SWITCH_SVG}` +
211
- `<span class="wt-status-dot wt-switcher-switch-dot" aria-hidden="true"></span></button>`
212
- );
213
- }
214
-
215
- // Desktop strip chip: dot + label + close all flat inside .wt-tab (the whole
216
- // chip is the click/switch target; the close is a nested button). tabindex="-1"
217
- // keeps the close out of the tab order.
218
- const TAB_CHIP = chipContent({
219
- dot: "wt-tab-dot",
220
- label: "wt-tab-label",
221
- close: "wt-tab-close",
222
- closeAttr: ' tabindex="-1"',
223
- });
224
- const TAB_HTML = `
225
- <div class="wt-tab">
226
- ${TAB_CHIP.dotLabel}
227
- ${TAB_CHIP.close}
228
- </div>`;
229
-
230
- // Mobile bottom bar. One element, two parts stacked in a column: the always-
231
- // visible bar row (active tab as a tap/swipe surface + keyboard + "+") on top,
232
- // and a list of the OTHER tabs BELOW it. On swipe-up / tap the whole bar slides
233
- // up and the list fills in beneath it (down to the safe area); swipe-down /
234
- // tap collapses it back to the bottom. Selecting a listed tab swaps it into the
235
- // active row. This replaces the old separate modal overview sheet ("one element"
236
- // per the user): the bar itself lifts rather than opening a distinct surface.
237
- // The bar is the FIRST child and the list the SECOND: the switcher is bottom-
238
- // anchored, so a column with the list last grows the container upward, lifting
239
- // the bar and revealing the list below it (DOM order = visual order top-to-bottom).
240
- // - .wt-switcher-current-wrap: the active-tab row — a select/swipe surface
241
- // (.wt-switcher-current with dot + label) plus a close (x) overlaid at the
242
- // right, mirroring the listed rows. No "n / m" counter: the list below is a
243
- // rotating circular queue, so an absolute position number is meaningless.
244
- // - The keyboard button (.wt-switcher-kb, opens the key grid above the bar,
245
- // only wired + shown when a keyboardToggle feature is provided) and the
246
- // accent "+" (.wt-switcher-new, spawns a terminal) are appended to the bar
247
- // row from the shared factories in setup (see makeKbButton / makeNewButton).
248
- const CURRENT_CHIP = chipContent({
249
- dot: "wt-switcher-dot",
250
- label: "wt-switcher-label",
251
- close: "wt-switcher-current-close wt-btn",
252
- });
253
- const SWITCHER_HTML = `
254
- <div class="wt-switcher" role="group" aria-label="Terminal tabs">
255
- <div class="wt-switcher-bar">
256
- <div class="wt-switcher-current-wrap">
257
- <button type="button" class="wt-switcher-current" aria-haspopup="true" aria-expanded="false">
258
- <span class="wt-switcher-current-inner">${CURRENT_CHIP.dotLabel}</span>
259
- </button>
260
- ${CURRENT_CHIP.close}
261
- </div>
262
- </div>
263
- <ul class="wt-switcher-list" role="list"></ul>
264
- </div>`;
265
-
266
- // One row per OTHER tab in the expanded list: a stretched select target (dot +
267
- // label) with the close (x) laid inside it at the right (two buttons can't nest,
268
- // so the x is a sibling overlapping the select's reserved right padding).
269
- const ROW_CHIP = chipContent({
270
- dot: "wt-switcher-row-dot",
271
- label: "wt-switcher-row-label",
272
- close: "wt-switcher-row-close wt-btn",
273
- });
274
- const SWITCHER_ROW_HTML = `
275
- <li class="wt-switcher-row">
276
- <button type="button" class="wt-switcher-row-select">${ROW_CHIP.dotLabel}</button>
277
- ${ROW_CHIP.close}
278
- </li>`;
279
-
280
103
  // looksLikeHardwareKey reports whether a keydown could only have come from a
281
104
  // PHYSICAL keyboard on a touch device. There is no web API that directly says "a
282
105
  // hardware keyboard is attached" (navigator.keyboard is layout/lock only and
@@ -310,80 +133,26 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
310
133
  export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
311
134
  const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
312
135
  const preferInputTitle = opts.preferInputTitle ?? false;
313
- // fetch has no default timeout; a stalled-but-open server would otherwise leave a
314
- // boot apiList/apiCreate await pending forever, so setup() never resolves and the
315
- // kernel's post-setup overlay-dismiss never fires (permanent loading overlay). Bound
316
- // every session-REST call; on expiry the existing catch paths recover.
317
- const SESSION_API_TIMEOUT_MS = 15000;
318
-
319
- async function apiList(): Promise<SessionInfo[]> {
320
- const r = await fetch(apiBase, {
321
- headers: { Accept: "application/json" },
322
- signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
323
- });
324
- if (!r.ok) {
325
- throw new Error(`web-terminal-ui: session list failed (${String(r.status)})`);
326
- }
327
- const data: unknown = await r.json();
328
- // A 200 with a non-array body -- a proxy error object, or a Go server
329
- // marshaling a nil session slice as JSON `null` -- must not reach the
330
- // bootstrap's `sessions.length` / `for...of` (or the poll's `list.map`)
331
- // uncaught: it would throw out of setup(), trip the kernel's 'terminal
332
- // has no chrome' teardown, and (managesSessions skipped the startup
333
- // connect) leave connectionInitiated false -> a permanently dead
334
- // terminal. Reject a non-array so the callers' existing catch paths
335
- // recover (bootstrap -> [], poll -> skip the tick).
336
- if (!Array.isArray(data)) {
337
- throw new Error("web-terminal-ui: session list returned a non-array body");
338
- }
339
- return data as SessionInfo[];
340
- }
341
- async function apiCreate(): Promise<SessionInfo> {
342
- const r = await fetch(apiBase, {
343
- method: "POST",
344
- signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
345
- });
346
- if (!r.ok) {
347
- throw new Error(`web-terminal-ui: session create failed (${String(r.status)})`);
348
- }
349
- return (await r.json()) as SessionInfo;
350
- }
351
- async function apiClose(id: string): Promise<void> {
352
- const r = await fetch(`${apiBase}/${encodeURIComponent(id)}`, {
353
- method: "DELETE",
354
- signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
355
- });
356
- if (!r.ok) {
357
- throw new Error(`web-terminal-ui: session close failed (${String(r.status)})`);
358
- }
359
- }
360
- // Persist the input-derived tab title server-side so it survives a page reload
361
- // and shows on other devices. The engine stores it as the session's fallback
362
- // title and uses it only when the program emits no OSC title (the web-terminal-kiro case:
363
- // kiro-cli emits no usable OSC title, so the latest user message is the label).
364
- // Best-effort and fire-and-forget: a failure never disrupts the terminal — the
365
- // locally-derived title still displays, it just is not persisted this time.
366
- async function apiSetTitle(id: string, title: string): Promise<void> {
367
- try {
368
- await fetch(`${apiBase}/${encodeURIComponent(id)}/title`, {
369
- method: "PUT",
370
- headers: { "Content-Type": "application/json" },
371
- body: JSON.stringify({ title }),
372
- signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
373
- });
374
- } catch {
375
- /* best-effort persistence; ignore network/timeout errors */
376
- }
377
- }
378
-
136
+ // The session REST client (model.ts): every call timeout-bounded, list
137
+ // shape-guarded, title persistence fire-and-forget.
138
+ const api = createSessionAPI(apiBase);
139
+
140
+ // tabs owns session selection. The static sessionOwner registration tells
141
+ // the kernel not to open a bare /ws at startup (which a SessionManager would
142
+ // 404 for lack of ?session=); the kernel instead awaits
143
+ // resolveInitialSession() once setup completes and performs the first switch
144
+ // itself. The registration must exist on the feature VALUE (read before
145
+ // setup), while the bootstrap needs setup-scoped state — so it delegates to
146
+ // a closure setup() wires. resolveImpl is nulled on teardown.
147
+ let resolveImpl: (() => Promise<SessionRef | null>) | null = null;
379
148
  return {
380
149
  name: "tabs",
381
- // tabs owns session selection: it resolves the first session id from
382
- // GET/POST /api/sessions during setup and drives the first connect via
383
- // ctx.notifySwitch. This tells the kernel not to open a bare /ws at startup
384
- // (which a SessionManager would 404 for lack of ?session=).
385
- managesSessions: true,
386
- async setup(ctx: TerminalContext) {
150
+ sessionOwner: {
151
+ resolveInitialSession: () => (resolveImpl ? resolveImpl() : Promise.resolve(null)),
152
+ },
153
+ // Synchronous setup: the chrome mounts immediately; the async session
154
+ // bootstrap that used to live here is the kernel-driven resolver above.
155
+ setup(ctx: TerminalContext) {
387
156
  const tablist = ctx.tablist();
388
157
  const monitor = opts.activityMonitor ? ctx.use(opts.activityMonitor) : undefined;
389
158
 
@@ -472,23 +241,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
472
241
  const newBtn = makeNewButton("wt-tab-new");
473
242
  bar.appendChild(newBtn);
474
243
 
475
- // Pull the terminal surface up off the fixed BOTTOM strip on desktop so
476
- // the bar does not overlap the last rows. The surface is position:fixed
477
- // inset:0, so a bottom offset (gated to a fine pointer in CSS, since the
478
- // strip is hidden on a coarse pointer where the mobile switcher applies its
479
- // own inset) clears it. A ResizeObserver keeps the offset in step with the
480
- // real strip height rather than a hard-coded guess. The measured height is
481
- // published on the document root (not the surface): the scroll-to-bottom
482
- // button sits in a sibling region, not inside .term, so a property set on
483
- // .term would not inherit to it and it would fall back to the 44px guess
484
- // and overlap the strip. Both .term and the button inherit it from :root.
244
+ // Pull the terminal surface up off the docked BOTTOM strip on desktop so
245
+ // the bar does not overlap the last rows. The surface is absolute
246
+ // inset:0, so a bottom offset (gated to a fine pointer / non-narrow root
247
+ // in CSS, since the strip is hidden on the narrow-coarse phone where the
248
+ // mobile switcher applies its own inset) clears it. A ResizeObserver
249
+ // keeps the offset in step with the real strip height rather than a
250
+ // hard-coded guess. The measured height is published on the terminal
251
+ // ROOT (not the surface): the scroll-to-bottom button sits in a sibling
252
+ // region, not inside .term, so a property set on .term would not inherit
253
+ // to it and it would fall back to the 44px guess and overlap the strip.
254
+ // Both .term and the button inherit it from .wt-root — and the host page
255
+ // never sees it.
485
256
  const surface = ctx.surface();
486
257
  surface.classList.add("wt-with-tabbar");
258
+ const varRoot = surface.parentElement ?? surface;
487
259
  const barResize = new ResizeObserver(() => {
488
- document.documentElement.style.setProperty(
489
- "--wt-tabbar-h",
490
- `${String(bar.offsetHeight)}px`,
491
- );
260
+ varRoot.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
492
261
  });
493
262
  barResize.observe(bar);
494
263
 
@@ -587,7 +356,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
587
356
  root?.classList.add("wt-tabbed");
588
357
  // Reserve the collapsed bar row's height so terminal content stops above it
589
358
  // (mobile item 2): viewport.ts adds --wt-reserve-bottom to the surface's
590
- // bottom inset. Measure the bar row (not the expandable list, which just
359
+ // bottom inset (it reads the var off the surface, which inherits it from
360
+ // the root). Measure the bar row (not the expandable list, which just
591
361
  // overlays content). innerHeight - rect.top captures the row plus the
592
362
  // safe-area beneath it; the RO fires with the keyboard closed, so the value
593
363
  // excludes the keyboard lift (viewport.ts adds that separately). The
@@ -595,7 +365,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
595
365
  const swReserve = new ResizeObserver(() => {
596
366
  const rect = swBar.getBoundingClientRect();
597
367
  const px = rect.height > 0 ? Math.max(0, Math.round(window.innerHeight - rect.top)) : 0;
598
- document.documentElement.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
368
+ varRoot.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
599
369
  window.visualViewport?.dispatchEvent(new Event("resize"));
600
370
  });
601
371
  swReserve.observe(swBar);
@@ -630,24 +400,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
630
400
  ctx.region("overlay", "tab-menu").appendChild(tabMenu);
631
401
 
632
402
  const tabList: Tab[] = [];
633
- // Ids the user closed within CLOSE_TOMBSTONE_MS, so a stale server listing
634
- // (the SSE re-open snapshot, or the poll's GET /api/sessions) that predates
635
- // the server reaping the session does not re-adopt (flash back) the closed
636
- // tab. TTL-cleared in adoptSession.
637
- const recentlyClosed = new Map<string, number>();
638
- const CLOSE_TOMBSTONE_MS = 15000;
639
- function tombstone(id: string): void {
640
- const now = Date.now();
641
- // Sweep entries whose window already elapsed (adoptSession treats them as
642
- // untombstoned anyway) so the map cannot grow without bound over a long
643
- // session of opens/closes; then record this close.
644
- for (const [k, t] of recentlyClosed) {
645
- if (now - t >= CLOSE_TOMBSTONE_MS) {
646
- recentlyClosed.delete(k);
647
- }
648
- }
649
- recentlyClosed.set(id, now);
650
- }
403
+ // Close tombstones (model.ts): ids the user closed recently, so a stale
404
+ // server listing (the SSE re-open snapshot, or the poll's GET
405
+ // /api/sessions) that predates the server reaping the session does not
406
+ // re-adopt (flash back) a closed tab.
407
+ const tombstones = createTombstones();
651
408
  // The expanded mobile list's row elements, keyed by tab id. Rows are
652
409
  // reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
653
410
  // same elements from their old slots to their new ones (the rotation).
@@ -689,28 +446,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
689
446
  const prefersReduce = (): boolean =>
690
447
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
691
448
 
692
- // baseLabel is a tab's label before de-duplication. Preference order: the
693
- // process window title (OSC 0/2, e.g. a shell's PROMPT_COMMAND title),
694
- // which takes precedence whenever the program sets one and keeps updating
695
- // as it changes (the status SSE re-pushes it); then a fallback derived from
696
- // the last line the user submitted, for a program that sets no title (a
697
- // bare shell, or kiro-cli with its cwd-only title disabled); then a plain
698
- // "New tab". fallback=true marks that last case so relabelAll leaves
699
- // untitled tabs as "New tab" with no numeric suffix.
700
- function baseLabel(tab: Tab): { text: string; fallback: boolean } {
701
- // preferInputTitle (agent shell with an unreliable OSC title): show the
702
- // live submitted line, then the persisted client title on reload — the
703
- // process OSC `title` is ignored. Default: OSC title first, then the live
704
- // derived line, then the persisted client title (the latter two are the
705
- // "latest user message" fallback when the program sets no OSC title).
706
- const derived = tab.derived?.trim() ?? "";
707
- const persisted = tab.clientTitle?.trim() ?? "";
708
- const real = preferInputTitle
709
- ? derived || persisted
710
- : tab.title.trim() || derived || persisted;
711
- return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
712
- }
713
-
714
449
  // relabelAll recomputes every tab's display label with de-duplication:
715
450
  // when two tabs resolve to the same base label (e.g. two shells with the
716
451
  // same window title, or two tabs whose last submitted line was identical),
@@ -721,14 +456,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
721
456
  // read "New tab" without a numeric suffix.
722
457
  const counts = new Map<string, number>();
723
458
  for (const t of tabList) {
724
- const { text, fallback } = baseLabel(t);
459
+ const { text, fallback } = baseLabel(t, preferInputTitle);
725
460
  if (!fallback) {
726
461
  counts.set(text, (counts.get(text) ?? 0) + 1);
727
462
  }
728
463
  }
729
464
  const seen = new Map<string, number>();
730
465
  for (const t of tabList) {
731
- const { text, fallback } = baseLabel(t);
466
+ const { text, fallback } = baseLabel(t, preferInputTitle);
732
467
  let display = text;
733
468
  if (!fallback && (counts.get(text) ?? 0) > 1) {
734
469
  const k = (seen.get(text) ?? 0) + 1;
@@ -744,133 +479,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
744
479
  }
745
480
 
746
481
  // --- Fallback title derivation from the user's submitted lines ---
747
- // Used only when the process sets no OSC window title of its own (baseLabel
748
- // prefers tab.title). A shell without a PROMPT_COMMAND title, or kiro-cli
749
- // with its cwd-only title disabled, sets none — so the useful fallback is
750
- // the last line the user submitted. The kernel routes input to the active
751
- // session, so an input observer's bytes belong to activeId. A tiny line
752
- // editor tracks the current input line (handling backspace and skipping
753
- // the escape sequences arrow keys and bracketed paste emit) and updates the
754
- // tab's derived title on every non-empty submission. Best-effort: an odd
755
- // editing sequence just yields no derived title (the tab then reads "New
756
- // tab" until the next submission).
757
- // A generous storage bound for the fallback (input-derived) title. The tab
758
- // label truncates it visually with max-width + ellipsis in EVERY view — the
759
- // desktop strip, the mobile active bar, and the mobile list — so this is
760
- // only a guard against storing a huge paste, not a display cut. (It was a
761
- // hard 60 that cut the label mid-word regardless of how much fit.)
762
- const MAX_DERIVED = 512;
763
- let lineBytes: number[] = [];
764
- let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
765
- let csiParams = ""; // accumulated CSI parameter bytes, to spot paste guards
766
- let inPaste = false; // inside a bracketed paste (ESC[200~ … ESC[201~)
767
- function resetInputLine(): void {
768
- lineBytes = [];
769
- escState = 0;
770
- csiParams = "";
771
- inPaste = false;
772
- }
773
- function deriveTitleFromInput(bytes: Uint8Array): void {
482
+ // The line-editor state machine lives in model.ts
483
+ // (createInputTitleDeriver); this wires its submissions to the active
484
+ // tab. Used only when the process sets no OSC window title of its own
485
+ // (baseLabel prefers tab.title). The kernel routes input to the active
486
+ // session, so an input observer's bytes belong to activeId. Every
487
+ // non-empty submitted line updates the fallback title (the last one
488
+ // wins) and is persisted server-side (engine PUT .../title) so a reload
489
+ // and other devices recover this "latest message" label; the engine uses
490
+ // it only when the program emits no OSC title.
491
+ const titleDeriver = createInputTitleDeriver((line) => {
774
492
  const t = tabList.find((x) => x.id === activeId);
775
493
  if (!t) {
776
494
  return; // no active tab
777
495
  }
778
- for (let i = 0; i < bytes.length; i++) {
779
- const b = bytes[i];
780
- if (b === undefined) {
781
- continue;
782
- }
783
- if (escState === 1) {
784
- escState = b === 0x5b ? 2 : b === 0x4f ? 3 : 0; // ESC [ = CSI, ESC O = SS3
785
- continue;
786
- }
787
- if (escState === 2) {
788
- if (b >= 0x40 && b <= 0x7e) {
789
- // CSI final byte. Recognize the bracketed-paste guards ESC[200~
790
- // (start) and ESC[201~ (end) so embedded newlines in a pasted,
791
- // often multi-line, message are not read as line submissions.
792
- if (b === 0x7e) {
793
- if (csiParams === "200") {
794
- inPaste = true;
795
- } else if (csiParams === "201") {
796
- inPaste = false;
797
- }
798
- }
799
- csiParams = "";
800
- escState = 0; // CSI final byte
801
- } else if (b >= 0x30 && b <= 0x3f) {
802
- csiParams += String.fromCharCode(b); // parameter byte (digits, ; ? etc.)
803
- }
804
- continue;
805
- }
806
- if (escState === 3) {
807
- escState = 0; // SS3 final byte
808
- continue;
809
- }
810
- if (b === 0x1b) {
811
- escState = 1; // ESC: start of an escape sequence
812
- } else if (b === 0x0d || b === 0x0a) {
813
- // A newline is a line SUBMIT only when it terminates the input. It is
814
- // folded to a single space (keeping the current line going) when it is
815
- // instead a paste-internal break, so a multi-line message becomes one
816
- // logical line whose title reflects its START rather than only its
817
- // LAST line. Two cases fold:
818
- // - inside a bracketed paste (ESC[200~ … ESC[201~), and
819
- // - a newline FOLLOWED by more printable input in this SAME chunk:
820
- // a human pressing Enter sends the newline as the end of its own
821
- // input event, whereas a paste (even one sent WITHOUT bracketed-
822
- // paste guards — e.g. an agent shell like kiro-cli that keeps a
823
- // pasted multi-line message as one prompt) delivers
824
- // text + newline + text together, so trailing content marks the
825
- // newline as a soft break, not a submit. Without this, such a
826
- // paste left only its last line as the title (the reported
827
- // "the title cut off the start of my message").
828
- let softBreak = inPaste;
829
- if (!softBreak) {
830
- for (let j = i + 1; j < bytes.length; j++) {
831
- const nb = bytes[j];
832
- if (nb !== undefined && nb >= 0x20) {
833
- softBreak = true;
834
- break;
835
- }
836
- }
837
- }
838
- if (softBreak) {
839
- if (lineBytes.length > 0 && lineBytes[lineBytes.length - 1] !== 0x20) {
840
- lineBytes.push(0x20);
841
- }
842
- continue;
843
- }
844
- const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
845
- lineBytes = [];
846
- // Every non-empty submitted line updates the fallback title (the
847
- // last one wins); a leading "/" counts, since it is a valid shell
848
- // command path.
849
- if (line) {
850
- t.derived = line.slice(0, MAX_DERIVED);
851
- // Persist it server-side (engine PUT .../title) so a reload and
852
- // other devices recover this "latest message" label; the engine
853
- // uses it only when the program emits no OSC title.
854
- void apiSetTitle(t.id, t.derived);
855
- syncChrome();
856
- }
857
- } else if (b === 0x7f || b === 0x08) {
858
- // Backspace: drop one codepoint (pop UTF-8 continuation bytes + lead).
859
- while (lineBytes.length > 0) {
860
- const last = lineBytes[lineBytes.length - 1];
861
- if (last === undefined || (last & 0xc0) !== 0x80) {
862
- break;
863
- }
864
- lineBytes.pop();
865
- }
866
- lineBytes.pop();
867
- } else if (b === 0x03) {
868
- lineBytes = []; // Ctrl-C: cancel the current line
869
- } else if (b >= 0x20) {
870
- lineBytes.push(b); // printable ASCII or a UTF-8 byte
871
- }
872
- }
873
- }
496
+ t.derived = line;
497
+ void api.setTitle(t.id, t.derived);
498
+ syncChrome();
499
+ });
874
500
 
875
501
  function focusInput(): void {
876
502
  ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
@@ -923,51 +549,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
923
549
  );
924
550
  pick(row, ".wt-switcher-row-label").textContent = t.display;
925
551
  }
926
- // animateRowIn / animateRowOut give a listed tab an enter / leave motion:
927
- // the row's own max-height grows from 0 (fading in) on add, and collapses
928
- // to 0 (fading out, then removed) on close. The flex list's height follows
929
- // the row, so adding/closing a tab animates the tray height rather than
930
- // snapping. Inline-driven (cleared when done); the caller gates motion.
931
- const ROW_ANIM_MS = 220;
932
- const ROW_ANIM_EASE = "cubic-bezier(0.2, 0, 0, 1)";
933
- function animateRowIn(row: HTMLElement): void {
934
- const h = row.getBoundingClientRect().height;
935
- if (h <= 0) {
936
- return;
937
- }
938
- row.style.overflow = "hidden";
939
- row.style.transition = "none";
940
- row.style.maxHeight = "0";
941
- row.style.opacity = "0";
942
- requestAnimationFrame(() => {
943
- row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
944
- row.style.maxHeight = `${String(Math.ceil(h))}px`;
945
- row.style.opacity = "1";
946
- });
947
- window.setTimeout(() => {
948
- row.style.transition = "";
949
- row.style.maxHeight = "";
950
- row.style.opacity = "";
951
- row.style.overflow = "";
952
- }, ROW_ANIM_MS + 60);
953
- }
954
- function animateRowOut(row: HTMLElement): void {
955
- const h = row.getBoundingClientRect().height;
956
- row.style.overflow = "hidden";
957
- row.style.pointerEvents = "none";
958
- row.style.transition = "none";
959
- row.style.maxHeight = `${String(Math.ceil(h))}px`;
960
- row.style.opacity = "1";
961
- requestAnimationFrame(() => {
962
- row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
963
- row.style.maxHeight = "0";
964
- row.style.opacity = "0";
965
- });
966
- window.setTimeout(() => {
967
- row.remove();
968
- }, ROW_ANIM_MS + 60);
969
- }
970
-
971
552
  // renderSwitcherList reconciles the expanded list to a row per OTHER tab
972
553
  // (the active tab lives in the bar row), REUSING existing row elements
973
554
  // rather than rebuilding, so element identity is stable and the swipe
@@ -1304,7 +885,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1304
885
  };
1305
886
  // Set an initial label immediately (relabelAll refines it with de-dup
1306
887
  // once the tab is in tabList and syncChrome runs).
1307
- tab.display = baseLabel(tab).text;
888
+ tab.display = baseLabel(tab, preferInputTitle).text;
1308
889
  label.textContent = tab.display;
1309
890
  aria.setLabel(tab.display);
1310
891
  el.addEventListener("click", (e) => {
@@ -1417,7 +998,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1417
998
  } catch {
1418
999
  /* storage unavailable (private mode / disabled) — non-fatal */
1419
1000
  }
1420
- resetInputLine(); // a partial line typed in the old tab does not carry over
1001
+ titleDeriver.reset(); // a partial line typed in the old tab does not carry over
1421
1002
  ctx.render.bind(next.store);
1422
1003
  ctx.notifySwitch({ id: next.id });
1423
1004
  armCatchup();
@@ -1580,12 +1161,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1580
1161
  if (tabList.find((t) => t.id === info.id)) {
1581
1162
  return;
1582
1163
  }
1583
- const closedAt = recentlyClosed.get(info.id);
1584
- if (closedAt !== undefined) {
1585
- if (Date.now() - closedAt < CLOSE_TOMBSTONE_MS) {
1586
- return; // just closed here; ignore a stale listing until the server reaps it
1587
- }
1588
- recentlyClosed.delete(info.id);
1164
+ if (tombstones.active(info.id)) {
1165
+ return; // just closed here; ignore a stale listing until the server reaps it
1589
1166
  }
1590
1167
  tabList.push(addTabChrome(info));
1591
1168
  }
@@ -1598,11 +1175,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1598
1175
  // stream / poll adopt the existing sessions below; without this they would
1599
1176
  // render inert (blank, never connecting) until the user taps a tab. This
1600
1177
  // is the sibling of the '+'-retry recovery the bootstrap already handles.
1178
+ // A live tab outranks an exited one (its dot status is fed by the same
1179
+ // SSE/poll that adopted it); a corpse is only auto-activated when nothing
1180
+ // else exists.
1601
1181
  function ensureActive(): void {
1602
1182
  if (activeId !== null) {
1603
1183
  return;
1604
1184
  }
1605
- const first = tabList[0];
1185
+ const first = tabList.find((t) => t.dot.dataset["status"] !== STATUS_EXITED) ?? tabList[0];
1606
1186
  if (first) {
1607
1187
  switchTo(first.id);
1608
1188
  }
@@ -1611,7 +1191,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1611
1191
  async function create(): Promise<void> {
1612
1192
  let info: SessionInfo;
1613
1193
  try {
1614
- info = await apiCreate();
1194
+ info = await api.create();
1615
1195
  } catch {
1616
1196
  // A create can still fail transiently (network, server error); tell
1617
1197
  // the user rather than throwing.
@@ -1668,7 +1248,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1668
1248
  }
1669
1249
  // Tombstone this id briefly so a stale status snapshot/poll that predates
1670
1250
  // the server reaping it cannot re-adopt the just-closed tab.
1671
- tombstone(id);
1251
+ tombstones.add(id);
1672
1252
  // A pending switch-button cue whose subject just closed is moot: clear
1673
1253
  // it rather than leaving a dot no tab visit can ever resolve.
1674
1254
  acknowledgeSwitchNotify(id);
@@ -1691,7 +1271,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1691
1271
  syncChrome(); // reflect the drop immediately (count, position)
1692
1272
  if (remote) {
1693
1273
  try {
1694
- await apiClose(id);
1274
+ await api.close(id);
1695
1275
  } catch {
1696
1276
  ctx.toast("Couldn't close the terminal on the server");
1697
1277
  }
@@ -1735,7 +1315,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1735
1315
  // Tombstone briefly so a stale status snapshot/poll that predates the
1736
1316
  // server reaping these sessions cannot re-adopt (flash back) a just-
1737
1317
  // closed tab -- mirrors the single-close path in dropTab (h-f2).
1738
- tombstone(t.id);
1318
+ tombstones.add(t.id);
1739
1319
  acknowledgeSwitchNotify(t.id); // a cue for a closed tab is moot
1740
1320
  t.aria.remove();
1741
1321
  t.el.remove();
@@ -1756,7 +1336,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1756
1336
  }
1757
1337
  for (const t of victims) {
1758
1338
  try {
1759
- await apiClose(t.id);
1339
+ await api.close(t.id);
1760
1340
  } catch {
1761
1341
  ctx.toast("Couldn't close a terminal on the server");
1762
1342
  }
@@ -1828,31 +1408,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1828
1408
  tabMenuItem("Close all", false, () => {
1829
1409
  void closeAll();
1830
1410
  });
1831
- // Make visible (so it has measurable size), then clamp within the visible
1832
- // viewport; position:fixed means x/y are viewport coordinates, and the
1833
- // visual viewport (when present) excludes the on-screen keyboard.
1411
+ // Make visible (so it has measurable size), then place it within the
1412
+ // visible viewport via the shared point-anchored positioner (clamp to
1413
+ // the visual viewport; flip above the pointer near the bottom edge).
1834
1414
  tabMenu.classList.add("visible");
1835
- const vv = window.visualViewport;
1836
- const viewLeft = vv ? vv.offsetLeft : 0;
1837
- const viewTop = vv ? vv.offsetTop : 0;
1838
- const viewWidth = vv ? vv.width : window.innerWidth;
1839
- const viewHeight = vv ? vv.height : window.innerHeight;
1840
- const menuW = tabMenu.offsetWidth;
1841
- const menuH = tabMenu.offsetHeight;
1842
- const left = Math.max(
1843
- viewLeft + TAB_MENU_EDGE,
1844
- Math.min(x, viewLeft + viewWidth - menuW - TAB_MENU_EDGE),
1845
- );
1846
- let top = y;
1847
- if (y + menuH + TAB_MENU_EDGE > viewTop + viewHeight) {
1848
- top = y - menuH - TAB_MENU_GAP;
1849
- }
1850
- top = Math.max(
1851
- viewTop + TAB_MENU_EDGE,
1852
- Math.min(top, viewTop + viewHeight - menuH - TAB_MENU_EDGE),
1853
- );
1854
- tabMenu.style.left = `${String(left)}px`;
1855
- tabMenu.style.top = `${String(top)}px`;
1415
+ placeMenuAt(tabMenu, x, y);
1856
1416
  }
1857
1417
 
1858
1418
  // One-time "swipe to switch" hint on first multi-tab state, mobile only.
@@ -1861,7 +1421,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1861
1421
  return;
1862
1422
  }
1863
1423
  hintShown = true;
1864
- if (!window.matchMedia(SWITCHER_MEDIA).matches) {
1424
+ const l = ctx.layout();
1425
+ if (!(l.narrow && l.coarse)) {
1865
1426
  return; // only the mobile switcher layout has a swipe-to-switch bar
1866
1427
  }
1867
1428
  let seen = false;
@@ -1941,19 +1502,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1941
1502
  return;
1942
1503
  }
1943
1504
  // Adopt a session created in another browser so all clients converge.
1944
- // Carry the persisted clientTitle through: the SSE snapshot usually
1945
- // beats the initial GET /api/sessions (whose adoptSession then dedups
1946
- // and never re-applies its fields), so dropping it here left every
1505
+ // The status record IS the session's wire shape (SessionStatus
1506
+ // extends SessionInfo), so it flows through whole — which also
1507
+ // carries the persisted clientTitle: the SSE snapshot usually beats
1508
+ // the initial GET /api/sessions (whose adoptSession then dedups and
1509
+ // never re-applies its fields), so dropping it here left every
1947
1510
  // SSE-adopted tab without its persisted title — in preferInputTitle
1948
1511
  // mode the label then fell back to "New tab" on reload.
1949
- adoptSession({
1950
- id: s.id,
1951
- title: s.title,
1952
- clientTitle: s.clientTitle,
1953
- createdAt: s.createdAt,
1954
- status: s.status,
1955
- reportsActivity: s.reportsActivity ?? false,
1956
- });
1512
+ adoptSession(s);
1957
1513
  applyStatus(s.id, s.status, s.title, s.clientTitle, s.reportsActivity ?? false);
1958
1514
  ensureActive();
1959
1515
  syncChrome();
@@ -1973,7 +1529,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1973
1529
  try {
1974
1530
  let list: SessionInfo[];
1975
1531
  try {
1976
- list = await apiList();
1532
+ list = await api.list();
1977
1533
  } catch {
1978
1534
  return; // transient; try again next tick
1979
1535
  }
@@ -2006,8 +1562,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2006
1562
  }
2007
1563
 
2008
1564
  // --- Event wiring ---
2009
- // Derive each tab's title from the first line the user submits into it.
2010
- const offInput = ctx.registerInputObserver(deriveTitleFromInput);
1565
+ // Derive each tab's title from the lines the user submits into it.
1566
+ const offInput = ctx.registerInputObserver((bytes) => {
1567
+ titleDeriver.observe(bytes);
1568
+ });
2011
1569
  // Observe (never consume) keydowns to detect a physical keyboard: a
2012
1570
  // hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
2013
1571
  // for a keyboard folio with no trackpad (no fine pointer to key off).
@@ -2464,54 +2022,86 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2464
2022
  toggleSwitcher();
2465
2023
  });
2466
2024
 
2467
- // Initial population: list existing sessions, or create the first one.
2468
- let sessions: SessionInfo[];
2469
- try {
2470
- sessions = await apiList();
2471
- } catch {
2472
- sessions = [];
2473
- }
2474
- if (sessions.length === 0) {
2025
+ // The kernel-driven bootstrap (sessionOwner.resolveInitialSession): list
2026
+ // existing sessions or create the first one, adopt them, pick the start
2027
+ // tab, bind the renderer to its store — and RETURN its ref rather than
2028
+ // switching; the kernel performs the switch through the same path a tab
2029
+ // switch uses. A null return (nothing could be listed or spawned) keeps
2030
+ // the chrome up with the "+" retry alive, and the kernel — which now sees
2031
+ // the failure directly — dismisses the loading overlay over it.
2032
+ resolveImpl = async (): Promise<SessionRef | null> => {
2033
+ // Initial population: list existing sessions, or create the first one.
2034
+ let sessions: SessionInfo[];
2475
2035
  try {
2476
- sessions = [await apiCreate()];
2036
+ sessions = await api.list();
2477
2037
  } catch {
2478
- // Do not let the initial spawn propagate out of setup(): the kernel
2479
- // would tear down every feature ("terminal has no chrome") and, since
2480
- // managesSessions skipped the startup connect, connectionInitiated
2481
- // stays false so the wake handlers (visibilitychange/pageshow/online)
2482
- // all no-op -- a permanently dead terminal with no recovery. Match the
2483
- // runtime create() path: toast and leave the chrome up so "+" can retry.
2484
- ctx.toast("Couldn't open a terminal");
2485
2038
  sessions = [];
2486
2039
  }
2487
- }
2488
- // Adopt (dedup) rather than blindly push. The status SSE pushes a snapshot
2489
- // of the existing sessions on open, and tabs subscribes (monitor.onStatus)
2490
- // BEFORE this list resolves, so a session may already have a tab by the
2491
- // time the list lands. A straight push doubled every already-adopted
2492
- // session (6 tabs from 3 across a fresh load), and paintActive then lit
2493
- // both copies of the active id ("2 active tabs" that move together).
2494
- for (const info of sessions) {
2495
- adoptSession(info);
2496
- }
2497
- // From here on, tabs added at runtime (create / adopt) animate in.
2498
- started = true;
2499
- // Activate the previously-active session if it still exists, else the
2500
- // first (oldest). Session ids are stable server-side, so a page reload
2501
- // reconnects to the tab the user left on instead of always the oldest.
2502
- let startTab = tabList[0];
2503
- try {
2504
- const savedId = localStorage.getItem(ACTIVE_TAB_KEY);
2505
- if (savedId !== null && savedId !== "") {
2506
- const saved = tabList.find((x) => x.id === savedId);
2507
- if (saved) {
2508
- startTab = saved;
2040
+ // Spawn a fresh session unless a LIVE one is listed. An exited session
2041
+ // is viewable history, not a working terminal — booting a page whose
2042
+ // every session has died (the agent exited: a sign-in dead end, a
2043
+ // crash) onto a corpse was the stuck-loading wedge. The exited ones are
2044
+ // still adopted below (switch to them to read their final screen; close
2045
+ // them by hand).
2046
+ if (!sessions.some((s) => s.status !== STATUS_EXITED)) {
2047
+ try {
2048
+ sessions = [...sessions, await api.create()];
2049
+ } catch {
2050
+ // Match the runtime create() path: toast and leave the chrome up so
2051
+ // "+" can retry. Any exited sessions stay adopted (frozen screen +
2052
+ // "Session ended" is still better than a blank page). A throw here
2053
+ // would also be survivable (the kernel treats a rejected resolver
2054
+ // as null), but the toast is the better UX.
2055
+ ctx.toast("Couldn't open a terminal");
2509
2056
  }
2510
2057
  }
2511
- } catch {
2512
- /* storage unavailable — fall back to the oldest tab */
2513
- }
2514
- if (startTab) {
2058
+ // Adopt (dedup) rather than blindly push. The status SSE pushes a
2059
+ // snapshot of the existing sessions on open, and tabs subscribes
2060
+ // (monitor.onStatus) BEFORE this list resolves, so a session may
2061
+ // already have a tab by the time the list lands. A straight push
2062
+ // doubled every already-adopted session (6 tabs from 3 across a fresh
2063
+ // load), and paintActive then lit both copies of the active id
2064
+ // ("2 active tabs" that move together).
2065
+ for (const info of sessions) {
2066
+ adoptSession(info);
2067
+ }
2068
+ // From here on, tabs added at runtime (create / adopt) animate in.
2069
+ started = true;
2070
+ // The SSE snapshot may have raced this bootstrap and already activated
2071
+ // a tab (ensureActive during the await above). The switch is then
2072
+ // already in flight — return null; the kernel sees connectionInitiated
2073
+ // and leaves the loading overlay to the normal ready path.
2074
+ if (activeId !== null) {
2075
+ return null;
2076
+ }
2077
+ // Activate the previously-active session if it still exists, else the
2078
+ // first (oldest). Session ids are stable server-side, so a page reload
2079
+ // reconnects to the tab the user left on instead of always the oldest.
2080
+ // Live sessions outrank exited ones: the saved id is honored only while
2081
+ // its session is still live (a reload used to restore straight onto the
2082
+ // corpse of a died-while-away session and wedge there), and the default
2083
+ // is the oldest LIVE tab. Only when nothing is live (the fresh-spawn
2084
+ // above failed too) does an exited tab start — a frozen final screen
2085
+ // with the "Session ended" banner beats a blank page.
2086
+ const liveIds = new Set(
2087
+ sessions.filter((s) => s.status !== STATUS_EXITED).map((s) => s.id),
2088
+ );
2089
+ const oldestLive = tabList.find((t) => liveIds.has(t.id));
2090
+ let startTab = oldestLive ?? tabList[0];
2091
+ try {
2092
+ const savedId = localStorage.getItem(ACTIVE_TAB_KEY);
2093
+ if (savedId !== null && savedId !== "") {
2094
+ const saved = tabList.find((x) => x.id === savedId);
2095
+ if (saved && (liveIds.has(saved.id) || oldestLive === undefined)) {
2096
+ startTab = saved;
2097
+ }
2098
+ }
2099
+ } catch {
2100
+ /* storage unavailable — fall back to the oldest live tab */
2101
+ }
2102
+ if (!startTab) {
2103
+ return null;
2104
+ }
2515
2105
  activeId = startTab.id;
2516
2106
  try {
2517
2107
  localStorage.setItem(ACTIVE_TAB_KEY, startTab.id);
@@ -2519,10 +2109,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2519
2109
  /* storage unavailable — non-fatal */
2520
2110
  }
2521
2111
  ctx.render.bind(startTab.store);
2522
- ctx.notifySwitch({ id: startTab.id });
2523
2112
  syncChrome();
2524
2113
  focusInput();
2525
- }
2114
+ return { id: startTab.id };
2115
+ };
2526
2116
 
2527
2117
  return {
2528
2118
  api: {
@@ -2533,6 +2123,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2533
2123
  tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
2534
2124
  },
2535
2125
  teardown() {
2126
+ resolveImpl = null;
2536
2127
  offStatus?.();
2537
2128
  offInput();
2538
2129
  offHwKey();
@@ -2548,9 +2139,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2548
2139
  barResize.disconnect();
2549
2140
  swReserve.disconnect();
2550
2141
  surface.classList.remove("wt-with-tabbar");
2551
- document.documentElement.style.removeProperty("--wt-tabbar-h");
2142
+ varRoot.style.removeProperty("--wt-tabbar-h");
2552
2143
  root?.classList.remove("wt-tabbed");
2553
- document.documentElement.style.removeProperty("--wt-reserve-bottom");
2144
+ varRoot.style.removeProperty("--wt-reserve-bottom");
2554
2145
  if (switchAnimTimer !== null) {
2555
2146
  clearTimeout(switchAnimTimer);
2556
2147
  }
@@ -2578,23 +2169,3 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2578
2169
  },
2579
2170
  };
2580
2171
  }
2581
-
2582
- // paintStatusDot applies a status dot's two orthogonal bits: data-status drives
2583
- // its appearance (idle / working / done / input / exited via CSS), and the
2584
- // .wt-reports class controls its visibility — the dot is hidden by default and
2585
- // shown only once the session has reported activity (OSC 9;4 progress or a
2586
- // classified OSC 9 notification), so a plain shell's tabs stay clean and
2587
- // label-only while an agent's light up.
2588
- function paintStatusDot(el: HTMLElement, status: string, reports: boolean): void {
2589
- el.dataset["status"] = status || "idle";
2590
- el.classList.toggle("wt-reports", reports);
2591
- }
2592
-
2593
- // pick returns a required descendant element or throws (static chrome only).
2594
- function pick(root: ParentNode, selector: string): HTMLElement {
2595
- const el = root.querySelector<HTMLElement>(selector);
2596
- if (!el) {
2597
- throw new Error(`web-terminal-ui: tabs chrome missing ${selector}`);
2598
- }
2599
- return el;
2600
- }