@cplieger/web-terminal-ui 3.5.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +60 -27
  2. package/css/00-tokens.css +18 -2
  3. package/css/01-scope.css +76 -0
  4. package/css/{02-app.css → 02-terminal.css} +113 -239
  5. package/css/10-primitives.css +98 -0
  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} +127 -66
  11. package/css/{06-mobile.css → 31-switcher.css} +85 -80
  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 +19 -6
  19. package/scaffold/index.html +5 -0
  20. package/src/features/activity-monitor.ts +32 -2
  21. package/src/features/connection-banner.ts +1 -0
  22. package/src/features/context-menu.ts +6 -33
  23. package/src/features/menu-position.ts +50 -0
  24. package/src/features/mobile-toolbar.ts +5 -4
  25. package/src/features/{tabs.ts → tabs/index.ts} +310 -721
  26. package/src/features/tabs/model.ts +342 -0
  27. package/src/features/tabs/strip.ts +101 -0
  28. package/src/features/tabs/switcher.ts +129 -0
  29. package/src/index.ts +1 -0
  30. package/src/kernel/a11y.ts +30 -5
  31. package/src/kernel/conn-state.ts +16 -6
  32. package/src/kernel/kernel.ts +170 -59
  33. package/src/kernel/types.ts +68 -15
  34. package/src/presets/agent-tabbed.ts +15 -0
  35. package/src/presets/single.ts +27 -0
  36. package/src/presets/tabbed.ts +42 -0
  37. package/src/presets/touch.ts +13 -0
  38. package/src/presets.ts +12 -76
  39. package/src/viewport.ts +28 -15
  40. package/css/03-primitives.css +0 -83
  41. package/css/04-feedback.css +0 -180
@@ -9,115 +9,59 @@
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
- // 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)";
51
+ // used ONLY on a narrow coarse-pointer device (a phone — in EITHER
52
+ // orientation: a landscape phone is wide but short, and the kernel's narrow
53
+ // fact covers both). A big touchscreen (an iPad) and every fine-pointer device
54
+ // (a desktop, or an iPad with a trackpad / Magic Keyboard) get the multi-tab
55
+ // top strip instead — the switcher's single-giant-tab layout wastes a big
56
+ // screen (an iPad was getting the phone UI). The narrow half of that fact is
57
+ // the kernel's .wt-narrow root class / ctx.layout().narrow (kernel-owned
58
+ // breakpoint constants, root-size driven); CSS pairs it with
59
+ // (pointer: coarse) where touch matters.
55
60
  // Default cadence for the no-activityMonitor polling fallback.
56
61
  const DEFAULT_POLL_MS = 4000;
57
- // The server-side status of a session whose process has exited (mirrors the
58
- // engine's terminal.StatusExited). Such a session is viewable history — its
59
- // final screen replays and the kernel shows "Session ended" — but it can never
60
- // produce output again, so session selection prefers live sessions everywhere.
61
- const STATUS_EXITED = "exited";
62
- // Tab context-menu viewport clamp (mirrors context-menu.ts): keep this margin
63
- // from every viewport edge, and this gap above the pointer when the menu has to
64
- // flip up (a right-click near the bottom edge) so it is neither clipped nor
65
- // hidden under the cursor.
66
- const TAB_MENU_EDGE = 8;
67
- const TAB_MENU_GAP = 16;
68
-
69
- /** One session's client-side wire shape (matches terminal.SessionInfo). */
70
- interface SessionInfo {
71
- id: string;
72
- title: string;
73
- /** The raw client-set title (the value pushed via PUT .../title), before the
74
- * server's OSC fallback baked into `title`. A consumer that treats the
75
- * program's OSC window title as unreliable (an agent shell) reads this. */
76
- clientTitle?: string | undefined;
77
- createdAt: string;
78
- status: string;
79
- /** Sticky: true once the session has emitted a genuine activity signal
80
- * (OSC 9;4 progress, or a classified OSC 9 notification). Gates whether the
81
- * per-tab activity dot is shown; see paintStatusDot / baseLabel. */
82
- reportsActivity?: boolean;
83
- }
84
-
85
- interface Tab {
86
- id: string;
87
- /** The raw server title (the OSC 0/2 window title the process set), possibly
88
- * empty before the process sets one. The displayed label is derived from it
89
- * with a numbered fallback and de-duplication (see relabelAll). */
90
- title: string;
91
- /** The computed, de-duplicated label actually shown in the chrome. */
92
- display: string;
93
- createdAt: string;
94
- store: LineStore;
95
- el: HTMLElement;
96
- label: HTMLElement;
97
- dot: HTMLElement;
98
- aria: TabHandle;
99
- scrollTop: number;
100
- following: boolean;
101
- /** Sticky: true once this session emitted a genuine activity signal (OSC 9;4).
102
- * Its activity dot is shown only while true; a session that never reports
103
- * activity (a plain shell) keeps a clean, dot-less tab. Fed from the server's
104
- * reportsActivity via applyStatus. */
105
- reports: boolean;
106
- /** A fallback title derived from the LAST non-empty line the user submitted
107
- * into this tab (updated on every Enter). Used only when the process sets no
108
- * window title of its own: the OSC 0/2 title (`title` above) takes precedence
109
- * when present and keeps updating, and this is what the tab reads as when it
110
- * does not — a plain shell with no PROMPT_COMMAND title, or kiro-cli with its
111
- * cwd-only title disabled. Undefined until the first non-empty submission; see
112
- * baseLabel. */
113
- derived?: string;
114
- /** The persisted client title reported by the server (its `clientTitle` wire
115
- * field): the last derived title pushed via PUT .../title, surviving reloads.
116
- * In `preferInputTitle` mode this is the reload-recovery source (the live
117
- * `derived` is lost on reload, and `title` is the unreliable OSC value there).
118
- * In the default mode it is the lowest-priority fallback after title/derived. */
119
- clientTitle?: string | undefined;
120
- }
62
+ // Tab context-menu viewport clamping + the flip-above-the-pointer gap live in
63
+ // the shared point-anchored positioner (menu-position.ts), shared with the
64
+ // terminal context menu (formerly two hand-synced copies of the same math).
121
65
 
122
66
  export interface TabsApi {
123
67
  /** Spawn a fresh session and switch to it. */
@@ -158,130 +102,6 @@ export interface TabsOptions {
158
102
  preferInputTitle?: boolean;
159
103
  }
160
104
 
161
- // The +/x/keyboard glyphs are inline SVG (not font glyphs) so they center
162
- // exactly in their flex-centered buttons and stay symmetric regardless of the UI
163
- // font's metrics. Each is defined ONCE here and shared by every chip site and
164
- // control below (rather than duplicated across the desktop and mobile markup).
165
- const CLOSE_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg>`;
166
- const NEW_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`;
167
- 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>`;
168
- // Two-overlapping-windows glyph for the mobile switcher's dedicated open/close
169
- // button: the browser-style "tab switcher" icon, more recognisable than the
170
- // prior swap-arrows (which read like a keyboard Tab key). A latest-wins
171
- // background-tab notification dot rides on it (see switchButtonHTML). Same
172
- // viewBox + stroke=currentcolor treatment as the others.
173
- 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>`;
174
-
175
- // chipContent is the ONE builder for a tab chip's content — a status dot, a
176
- // label, and a close (x) — shared by all three chip sites: the desktop strip
177
- // (.wt-tab), the mobile active row (.wt-switcher-current), and each expanded
178
- // mobile list row (.wt-switcher-row). Each site passes its OWN class set (never
179
- // renamed) so every existing selector — and thus all CSS and all tests — still
180
- // matches. The one structural difference is WHERE the close sits: the desktop
181
- // chip nests it flat inside .wt-tab, while the two mobile chips place it as a
182
- // sibling of the select/swipe button (a button can't nest in a button). So the
183
- // builder returns two fragments — the dot+label pair and the close — that each
184
- // site drops into its own structure.
185
- function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
186
- dotLabel: string;
187
- close: string;
188
- } {
189
- return {
190
- dotLabel:
191
- `<span class="${v.dot} wt-status-dot" aria-hidden="true"></span>` +
192
- `<span class="${v.label}"></span>`,
193
- close: `<button type="button" class="${v.close}" aria-label="Close terminal"${v.closeAttr ?? ""}>${CLOSE_SVG}</button>`,
194
- };
195
- }
196
-
197
- // The ONE "+" (new-terminal) and keyboard button factories, shared by the
198
- // desktop strip and the mobile switcher — only the class set differs. These
199
- // build the markup; element construction + event wiring live in makeNewButton /
200
- // makeKbButton (in setup), which close over create() and the key-grid toggle.
201
- function newButtonHTML(cls: string): string {
202
- return `<button type="button" class="${cls}" aria-label="New terminal">${NEW_SVG}</button>`;
203
- }
204
- function kbButtonHTML(cls: string): string {
205
- return `<button type="button" class="${cls}" aria-label="Keyboard keys" aria-expanded="false" hidden>${KB_SVG}</button>`;
206
- }
207
- // The mobile switcher's dedicated open/close button. It toggles the tab list and
208
- // carries a latest-wins notification dot (a child span) — amber when a background
209
- // terminal needs input, green when a background turn finished — cleared when the
210
- // list opens. Only the mobile switcher builds it (element construction + event
211
- // wiring live in makeSwitchButton in setup); the desktop strip already shows
212
- // every tab's own status dot, so it needs no aggregate cue.
213
- function switchButtonHTML(cls: string): string {
214
- return (
215
- `<button type="button" class="${cls}" aria-label="Open tab switcher">${SWITCH_SVG}` +
216
- `<span class="wt-status-dot wt-switcher-switch-dot" aria-hidden="true"></span></button>`
217
- );
218
- }
219
-
220
- // Desktop strip chip: dot + label + close all flat inside .wt-tab (the whole
221
- // chip is the click/switch target; the close is a nested button). tabindex="-1"
222
- // keeps the close out of the tab order.
223
- const TAB_CHIP = chipContent({
224
- dot: "wt-tab-dot",
225
- label: "wt-tab-label",
226
- close: "wt-tab-close",
227
- closeAttr: ' tabindex="-1"',
228
- });
229
- const TAB_HTML = `
230
- <div class="wt-tab">
231
- ${TAB_CHIP.dotLabel}
232
- ${TAB_CHIP.close}
233
- </div>`;
234
-
235
- // Mobile bottom bar. One element, two parts stacked in a column: the always-
236
- // visible bar row (active tab as a tap/swipe surface + keyboard + "+") on top,
237
- // and a list of the OTHER tabs BELOW it. On swipe-up / tap the whole bar slides
238
- // up and the list fills in beneath it (down to the safe area); swipe-down /
239
- // tap collapses it back to the bottom. Selecting a listed tab swaps it into the
240
- // active row. This replaces the old separate modal overview sheet ("one element"
241
- // per the user): the bar itself lifts rather than opening a distinct surface.
242
- // The bar is the FIRST child and the list the SECOND: the switcher is bottom-
243
- // anchored, so a column with the list last grows the container upward, lifting
244
- // the bar and revealing the list below it (DOM order = visual order top-to-bottom).
245
- // - .wt-switcher-current-wrap: the active-tab row — a select/swipe surface
246
- // (.wt-switcher-current with dot + label) plus a close (x) overlaid at the
247
- // right, mirroring the listed rows. No "n / m" counter: the list below is a
248
- // rotating circular queue, so an absolute position number is meaningless.
249
- // - The keyboard button (.wt-switcher-kb, opens the key grid above the bar,
250
- // only wired + shown when a keyboardToggle feature is provided) and the
251
- // accent "+" (.wt-switcher-new, spawns a terminal) are appended to the bar
252
- // row from the shared factories in setup (see makeKbButton / makeNewButton).
253
- const CURRENT_CHIP = chipContent({
254
- dot: "wt-switcher-dot",
255
- label: "wt-switcher-label",
256
- close: "wt-switcher-current-close wt-btn",
257
- });
258
- const SWITCHER_HTML = `
259
- <div class="wt-switcher" role="group" aria-label="Terminal tabs">
260
- <div class="wt-switcher-bar">
261
- <div class="wt-switcher-current-wrap">
262
- <button type="button" class="wt-switcher-current" aria-haspopup="true" aria-expanded="false">
263
- <span class="wt-switcher-current-inner">${CURRENT_CHIP.dotLabel}</span>
264
- </button>
265
- ${CURRENT_CHIP.close}
266
- </div>
267
- </div>
268
- <ul class="wt-switcher-list" role="list"></ul>
269
- </div>`;
270
-
271
- // One row per OTHER tab in the expanded list: a stretched select target (dot +
272
- // label) with the close (x) laid inside it at the right (two buttons can't nest,
273
- // so the x is a sibling overlapping the select's reserved right padding).
274
- const ROW_CHIP = chipContent({
275
- dot: "wt-switcher-row-dot",
276
- label: "wt-switcher-row-label",
277
- close: "wt-switcher-row-close wt-btn",
278
- });
279
- const SWITCHER_ROW_HTML = `
280
- <li class="wt-switcher-row">
281
- <button type="button" class="wt-switcher-row-select">${ROW_CHIP.dotLabel}</button>
282
- ${ROW_CHIP.close}
283
- </li>`;
284
-
285
105
  // looksLikeHardwareKey reports whether a keydown could only have come from a
286
106
  // PHYSICAL keyboard on a touch device. There is no web API that directly says "a
287
107
  // hardware keyboard is attached" (navigator.keyboard is layout/lock only and
@@ -315,80 +135,26 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
315
135
  export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
316
136
  const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
317
137
  const preferInputTitle = opts.preferInputTitle ?? false;
318
- // fetch has no default timeout; a stalled-but-open server would otherwise leave a
319
- // boot apiList/apiCreate await pending forever, so setup() never resolves and the
320
- // kernel's post-setup overlay-dismiss never fires (permanent loading overlay). Bound
321
- // every session-REST call; on expiry the existing catch paths recover.
322
- const SESSION_API_TIMEOUT_MS = 15000;
323
-
324
- async function apiList(): Promise<SessionInfo[]> {
325
- const r = await fetch(apiBase, {
326
- headers: { Accept: "application/json" },
327
- signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
328
- });
329
- if (!r.ok) {
330
- throw new Error(`web-terminal-ui: session list failed (${String(r.status)})`);
331
- }
332
- const data: unknown = await r.json();
333
- // A 200 with a non-array body -- a proxy error object, or a Go server
334
- // marshaling a nil session slice as JSON `null` -- must not reach the
335
- // bootstrap's `sessions.length` / `for...of` (or the poll's `list.map`)
336
- // uncaught: it would throw out of setup(), trip the kernel's 'terminal
337
- // has no chrome' teardown, and (managesSessions skipped the startup
338
- // connect) leave connectionInitiated false -> a permanently dead
339
- // terminal. Reject a non-array so the callers' existing catch paths
340
- // recover (bootstrap -> [], poll -> skip the tick).
341
- if (!Array.isArray(data)) {
342
- throw new Error("web-terminal-ui: session list returned a non-array body");
343
- }
344
- return data as SessionInfo[];
345
- }
346
- async function apiCreate(): Promise<SessionInfo> {
347
- const r = await fetch(apiBase, {
348
- method: "POST",
349
- signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
350
- });
351
- if (!r.ok) {
352
- throw new Error(`web-terminal-ui: session create failed (${String(r.status)})`);
353
- }
354
- return (await r.json()) as SessionInfo;
355
- }
356
- async function apiClose(id: string): Promise<void> {
357
- const r = await fetch(`${apiBase}/${encodeURIComponent(id)}`, {
358
- method: "DELETE",
359
- signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
360
- });
361
- if (!r.ok) {
362
- throw new Error(`web-terminal-ui: session close failed (${String(r.status)})`);
363
- }
364
- }
365
- // Persist the input-derived tab title server-side so it survives a page reload
366
- // and shows on other devices. The engine stores it as the session's fallback
367
- // title and uses it only when the program emits no OSC title (the web-terminal-kiro case:
368
- // kiro-cli emits no usable OSC title, so the latest user message is the label).
369
- // Best-effort and fire-and-forget: a failure never disrupts the terminal — the
370
- // locally-derived title still displays, it just is not persisted this time.
371
- async function apiSetTitle(id: string, title: string): Promise<void> {
372
- try {
373
- await fetch(`${apiBase}/${encodeURIComponent(id)}/title`, {
374
- method: "PUT",
375
- headers: { "Content-Type": "application/json" },
376
- body: JSON.stringify({ title }),
377
- signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
378
- });
379
- } catch {
380
- /* best-effort persistence; ignore network/timeout errors */
381
- }
382
- }
383
-
138
+ // The session REST client (model.ts): every call timeout-bounded, list
139
+ // shape-guarded, title persistence fire-and-forget.
140
+ const api = createSessionAPI(apiBase);
141
+
142
+ // tabs owns session selection. The static sessionOwner registration tells
143
+ // the kernel not to open a bare /ws at startup (which a SessionManager would
144
+ // 404 for lack of ?session=); the kernel instead awaits
145
+ // resolveInitialSession() once setup completes and performs the first switch
146
+ // itself. The registration must exist on the feature VALUE (read before
147
+ // setup), while the bootstrap needs setup-scoped state — so it delegates to
148
+ // a closure setup() wires. resolveImpl is nulled on teardown.
149
+ let resolveImpl: (() => Promise<SessionRef | null>) | null = null;
384
150
  return {
385
151
  name: "tabs",
386
- // tabs owns session selection: it resolves the first session id from
387
- // GET/POST /api/sessions during setup and drives the first connect via
388
- // ctx.notifySwitch. This tells the kernel not to open a bare /ws at startup
389
- // (which a SessionManager would 404 for lack of ?session=).
390
- managesSessions: true,
391
- async setup(ctx: TerminalContext) {
152
+ sessionOwner: {
153
+ resolveInitialSession: () => (resolveImpl ? resolveImpl() : Promise.resolve(null)),
154
+ },
155
+ // Synchronous setup: the chrome mounts immediately; the async session
156
+ // bootstrap that used to live here is the kernel-driven resolver above.
157
+ setup(ctx: TerminalContext) {
392
158
  const tablist = ctx.tablist();
393
159
  const monitor = opts.activityMonitor ? ctx.use(opts.activityMonitor) : undefined;
394
160
 
@@ -458,42 +224,48 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
458
224
  }
459
225
 
460
226
  // --- Desktop tab strip (top-bar region) ---
227
+ // Two layers: the bar itself never scrolls; an inner scroller
228
+ // (.wt-tab-scroll, the tablist) holds the tabs plus the "+", which floats
229
+ // right of the tab list and scrolls WITH it when many tabs overflow. The
230
+ // keyboard button sits OUTSIDE the scroller as the bar's last flex item,
231
+ // pinned at the bar's right edge in the scroll-to-bottom button's column
232
+ // (an overflowing tab list can never push or scroll it away). On a fine
233
+ // pointer it is display:none (CSS), and the scroller stretches to the
234
+ // full bar width so the tabs reclaim that space. Both controls are built
235
+ // + wired by the same shared factories as the mobile switcher's; the kb
236
+ // button is CSS-gated to a wide touchscreen and un-hidden below only when
237
+ // a keyboardToggle is wired. addTabChrome inserts each tab before newBtn,
238
+ // keeping the scroller [tabs… +].
461
239
  const slot = ctx.region("top-bar", "tabs");
462
240
  const bar = document.createElement("div");
463
241
  bar.className = "wt-tab-bar";
464
- bar.setAttribute("role", "tablist");
465
242
  slot.appendChild(bar);
466
- // Strip-end controls, both flex items INSIDE the strip so they flow after
467
- // the tabs (appending them to the slot flowed them out of the strip):
468
- // the keyboard button first, then the "+" — mirroring the mobile
469
- // switcher's kb-before-new order, so rotating a phone to landscape (which
470
- // swaps in this strip) keeps the keyboard button LEFT of the "+" rather
471
- // than jumping to its right. Built + wired by the same shared factories
472
- // as the mobile switcher's; the kb button is CSS-gated to a wide
473
- // touchscreen and un-hidden below only when a keyboardToggle is wired.
474
- // addTabChrome inserts each tab before deskKb, keeping [tabs… kb +].
243
+ const scroller = document.createElement("div");
244
+ scroller.className = "wt-tab-scroll";
245
+ scroller.setAttribute("role", "tablist");
246
+ bar.appendChild(scroller);
247
+ const newBtn = makeNewButton("wt-tab-new");
248
+ scroller.appendChild(newBtn);
475
249
  const deskKb = makeKbButton("wt-tab-kb wt-btn");
476
250
  bar.appendChild(deskKb);
477
- const newBtn = makeNewButton("wt-tab-new");
478
- bar.appendChild(newBtn);
479
251
 
480
- // Pull the terminal surface up off the fixed BOTTOM strip on desktop so
481
- // the bar does not overlap the last rows. The surface is position:fixed
482
- // inset:0, so a bottom offset (gated to a fine pointer in CSS, since the
483
- // strip is hidden on a coarse pointer where the mobile switcher applies its
484
- // own inset) clears it. A ResizeObserver keeps the offset in step with the
485
- // real strip height rather than a hard-coded guess. The measured height is
486
- // published on the document root (not the surface): the scroll-to-bottom
487
- // button sits in a sibling region, not inside .term, so a property set on
488
- // .term would not inherit to it and it would fall back to the 44px guess
489
- // and overlap the strip. Both .term and the button inherit it from :root.
252
+ // Pull the terminal surface up off the docked BOTTOM strip on desktop so
253
+ // the bar does not overlap the last rows. The surface is absolute
254
+ // inset:0, so a bottom offset (gated to a fine pointer / non-narrow root
255
+ // in CSS, since the strip is hidden on the narrow-coarse phone where the
256
+ // mobile switcher applies its own inset) clears it. A ResizeObserver
257
+ // keeps the offset in step with the real strip height rather than a
258
+ // hard-coded guess. The measured height is published on the terminal
259
+ // ROOT (not the surface): the scroll-to-bottom button sits in a sibling
260
+ // region, not inside .term, so a property set on .term would not inherit
261
+ // to it and it would fall back to the 44px guess and overlap the strip.
262
+ // Both .term and the button inherit it from .wt-root — and the host page
263
+ // never sees it.
490
264
  const surface = ctx.surface();
491
265
  surface.classList.add("wt-with-tabbar");
266
+ const varRoot = surface.parentElement ?? surface;
492
267
  const barResize = new ResizeObserver(() => {
493
- document.documentElement.style.setProperty(
494
- "--wt-tabbar-h",
495
- `${String(bar.offsetHeight)}px`,
496
- );
268
+ varRoot.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
497
269
  });
498
270
  barResize.observe(bar);
499
271
 
@@ -592,7 +364,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
592
364
  root?.classList.add("wt-tabbed");
593
365
  // Reserve the collapsed bar row's height so terminal content stops above it
594
366
  // (mobile item 2): viewport.ts adds --wt-reserve-bottom to the surface's
595
- // bottom inset. Measure the bar row (not the expandable list, which just
367
+ // bottom inset (it reads the var off the surface, which inherits it from
368
+ // the root). Measure the bar row (not the expandable list, which just
596
369
  // overlays content). innerHeight - rect.top captures the row plus the
597
370
  // safe-area beneath it; the RO fires with the keyboard closed, so the value
598
371
  // excludes the keyboard lift (viewport.ts adds that separately). The
@@ -600,7 +373,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
600
373
  const swReserve = new ResizeObserver(() => {
601
374
  const rect = swBar.getBoundingClientRect();
602
375
  const px = rect.height > 0 ? Math.max(0, Math.round(window.innerHeight - rect.top)) : 0;
603
- document.documentElement.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
376
+ varRoot.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
604
377
  window.visualViewport?.dispatchEvent(new Event("resize"));
605
378
  });
606
379
  swReserve.observe(swBar);
@@ -635,24 +408,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
635
408
  ctx.region("overlay", "tab-menu").appendChild(tabMenu);
636
409
 
637
410
  const tabList: Tab[] = [];
638
- // Ids the user closed within CLOSE_TOMBSTONE_MS, so a stale server listing
639
- // (the SSE re-open snapshot, or the poll's GET /api/sessions) that predates
640
- // the server reaping the session does not re-adopt (flash back) the closed
641
- // tab. TTL-cleared in adoptSession.
642
- const recentlyClosed = new Map<string, number>();
643
- const CLOSE_TOMBSTONE_MS = 15000;
644
- function tombstone(id: string): void {
645
- const now = Date.now();
646
- // Sweep entries whose window already elapsed (adoptSession treats them as
647
- // untombstoned anyway) so the map cannot grow without bound over a long
648
- // session of opens/closes; then record this close.
649
- for (const [k, t] of recentlyClosed) {
650
- if (now - t >= CLOSE_TOMBSTONE_MS) {
651
- recentlyClosed.delete(k);
652
- }
653
- }
654
- recentlyClosed.set(id, now);
655
- }
411
+ // Monotonic local-mutation counter stamped onto each adopted tab (Tab.born)
412
+ // and snapshotted by reconcileOnce before its GET /api/sessions, so a
413
+ // stale listing can never drop a tab adopted while it was in flight (the
414
+ // boot race: the bootstrap's create vs the SSE stream-open reconcile).
415
+ let tabEpoch = 0;
416
+ // Close tombstones (model.ts): ids the user closed recently, so a stale
417
+ // server listing (the SSE re-open snapshot, or the poll's GET
418
+ // /api/sessions) that predates the server reaping the session does not
419
+ // re-adopt (flash back) a closed tab.
420
+ const tombstones = createTombstones();
656
421
  // The expanded mobile list's row elements, keyed by tab id. Rows are
657
422
  // reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
658
423
  // same elements from their old slots to their new ones (the rotation).
@@ -694,28 +459,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
694
459
  const prefersReduce = (): boolean =>
695
460
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
696
461
 
697
- // baseLabel is a tab's label before de-duplication. Preference order: the
698
- // process window title (OSC 0/2, e.g. a shell's PROMPT_COMMAND title),
699
- // which takes precedence whenever the program sets one and keeps updating
700
- // as it changes (the status SSE re-pushes it); then a fallback derived from
701
- // the last line the user submitted, for a program that sets no title (a
702
- // bare shell, or kiro-cli with its cwd-only title disabled); then a plain
703
- // "New tab". fallback=true marks that last case so relabelAll leaves
704
- // untitled tabs as "New tab" with no numeric suffix.
705
- function baseLabel(tab: Tab): { text: string; fallback: boolean } {
706
- // preferInputTitle (agent shell with an unreliable OSC title): show the
707
- // live submitted line, then the persisted client title on reload — the
708
- // process OSC `title` is ignored. Default: OSC title first, then the live
709
- // derived line, then the persisted client title (the latter two are the
710
- // "latest user message" fallback when the program sets no OSC title).
711
- const derived = tab.derived?.trim() ?? "";
712
- const persisted = tab.clientTitle?.trim() ?? "";
713
- const real = preferInputTitle
714
- ? derived || persisted
715
- : tab.title.trim() || derived || persisted;
716
- return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
717
- }
718
-
719
462
  // relabelAll recomputes every tab's display label with de-duplication:
720
463
  // when two tabs resolve to the same base label (e.g. two shells with the
721
464
  // same window title, or two tabs whose last submitted line was identical),
@@ -726,14 +469,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
726
469
  // read "New tab" without a numeric suffix.
727
470
  const counts = new Map<string, number>();
728
471
  for (const t of tabList) {
729
- const { text, fallback } = baseLabel(t);
472
+ const { text, fallback } = baseLabel(t, preferInputTitle);
730
473
  if (!fallback) {
731
474
  counts.set(text, (counts.get(text) ?? 0) + 1);
732
475
  }
733
476
  }
734
477
  const seen = new Map<string, number>();
735
478
  for (const t of tabList) {
736
- const { text, fallback } = baseLabel(t);
479
+ const { text, fallback } = baseLabel(t, preferInputTitle);
737
480
  let display = text;
738
481
  if (!fallback && (counts.get(text) ?? 0) > 1) {
739
482
  const k = (seen.get(text) ?? 0) + 1;
@@ -749,133 +492,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
749
492
  }
750
493
 
751
494
  // --- Fallback title derivation from the user's submitted lines ---
752
- // Used only when the process sets no OSC window title of its own (baseLabel
753
- // prefers tab.title). A shell without a PROMPT_COMMAND title, or kiro-cli
754
- // with its cwd-only title disabled, sets none — so the useful fallback is
755
- // the last line the user submitted. The kernel routes input to the active
756
- // session, so an input observer's bytes belong to activeId. A tiny line
757
- // editor tracks the current input line (handling backspace and skipping
758
- // the escape sequences arrow keys and bracketed paste emit) and updates the
759
- // tab's derived title on every non-empty submission. Best-effort: an odd
760
- // editing sequence just yields no derived title (the tab then reads "New
761
- // tab" until the next submission).
762
- // A generous storage bound for the fallback (input-derived) title. The tab
763
- // label truncates it visually with max-width + ellipsis in EVERY view — the
764
- // desktop strip, the mobile active bar, and the mobile list — so this is
765
- // only a guard against storing a huge paste, not a display cut. (It was a
766
- // hard 60 that cut the label mid-word regardless of how much fit.)
767
- const MAX_DERIVED = 512;
768
- let lineBytes: number[] = [];
769
- let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
770
- let csiParams = ""; // accumulated CSI parameter bytes, to spot paste guards
771
- let inPaste = false; // inside a bracketed paste (ESC[200~ … ESC[201~)
772
- function resetInputLine(): void {
773
- lineBytes = [];
774
- escState = 0;
775
- csiParams = "";
776
- inPaste = false;
777
- }
778
- function deriveTitleFromInput(bytes: Uint8Array): void {
495
+ // The line-editor state machine lives in model.ts
496
+ // (createInputTitleDeriver); this wires its submissions to the active
497
+ // tab. Used only when the process sets no OSC window title of its own
498
+ // (baseLabel prefers tab.title). The kernel routes input to the active
499
+ // session, so an input observer's bytes belong to activeId. Every
500
+ // non-empty submitted line updates the fallback title (the last one
501
+ // wins) and is persisted server-side (engine PUT .../title) so a reload
502
+ // and other devices recover this "latest message" label; the engine uses
503
+ // it only when the program emits no OSC title.
504
+ const titleDeriver = createInputTitleDeriver((line) => {
779
505
  const t = tabList.find((x) => x.id === activeId);
780
506
  if (!t) {
781
507
  return; // no active tab
782
508
  }
783
- for (let i = 0; i < bytes.length; i++) {
784
- const b = bytes[i];
785
- if (b === undefined) {
786
- continue;
787
- }
788
- if (escState === 1) {
789
- escState = b === 0x5b ? 2 : b === 0x4f ? 3 : 0; // ESC [ = CSI, ESC O = SS3
790
- continue;
791
- }
792
- if (escState === 2) {
793
- if (b >= 0x40 && b <= 0x7e) {
794
- // CSI final byte. Recognize the bracketed-paste guards ESC[200~
795
- // (start) and ESC[201~ (end) so embedded newlines in a pasted,
796
- // often multi-line, message are not read as line submissions.
797
- if (b === 0x7e) {
798
- if (csiParams === "200") {
799
- inPaste = true;
800
- } else if (csiParams === "201") {
801
- inPaste = false;
802
- }
803
- }
804
- csiParams = "";
805
- escState = 0; // CSI final byte
806
- } else if (b >= 0x30 && b <= 0x3f) {
807
- csiParams += String.fromCharCode(b); // parameter byte (digits, ; ? etc.)
808
- }
809
- continue;
810
- }
811
- if (escState === 3) {
812
- escState = 0; // SS3 final byte
813
- continue;
814
- }
815
- if (b === 0x1b) {
816
- escState = 1; // ESC: start of an escape sequence
817
- } else if (b === 0x0d || b === 0x0a) {
818
- // A newline is a line SUBMIT only when it terminates the input. It is
819
- // folded to a single space (keeping the current line going) when it is
820
- // instead a paste-internal break, so a multi-line message becomes one
821
- // logical line whose title reflects its START rather than only its
822
- // LAST line. Two cases fold:
823
- // - inside a bracketed paste (ESC[200~ … ESC[201~), and
824
- // - a newline FOLLOWED by more printable input in this SAME chunk:
825
- // a human pressing Enter sends the newline as the end of its own
826
- // input event, whereas a paste (even one sent WITHOUT bracketed-
827
- // paste guards — e.g. an agent shell like kiro-cli that keeps a
828
- // pasted multi-line message as one prompt) delivers
829
- // text + newline + text together, so trailing content marks the
830
- // newline as a soft break, not a submit. Without this, such a
831
- // paste left only its last line as the title (the reported
832
- // "the title cut off the start of my message").
833
- let softBreak = inPaste;
834
- if (!softBreak) {
835
- for (let j = i + 1; j < bytes.length; j++) {
836
- const nb = bytes[j];
837
- if (nb !== undefined && nb >= 0x20) {
838
- softBreak = true;
839
- break;
840
- }
841
- }
842
- }
843
- if (softBreak) {
844
- if (lineBytes.length > 0 && lineBytes[lineBytes.length - 1] !== 0x20) {
845
- lineBytes.push(0x20);
846
- }
847
- continue;
848
- }
849
- const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
850
- lineBytes = [];
851
- // Every non-empty submitted line updates the fallback title (the
852
- // last one wins); a leading "/" counts, since it is a valid shell
853
- // command path.
854
- if (line) {
855
- t.derived = line.slice(0, MAX_DERIVED);
856
- // Persist it server-side (engine PUT .../title) so a reload and
857
- // other devices recover this "latest message" label; the engine
858
- // uses it only when the program emits no OSC title.
859
- void apiSetTitle(t.id, t.derived);
860
- syncChrome();
861
- }
862
- } else if (b === 0x7f || b === 0x08) {
863
- // Backspace: drop one codepoint (pop UTF-8 continuation bytes + lead).
864
- while (lineBytes.length > 0) {
865
- const last = lineBytes[lineBytes.length - 1];
866
- if (last === undefined || (last & 0xc0) !== 0x80) {
867
- break;
868
- }
869
- lineBytes.pop();
870
- }
871
- lineBytes.pop();
872
- } else if (b === 0x03) {
873
- lineBytes = []; // Ctrl-C: cancel the current line
874
- } else if (b >= 0x20) {
875
- lineBytes.push(b); // printable ASCII or a UTF-8 byte
876
- }
877
- }
878
- }
509
+ t.derived = line;
510
+ void api.setTitle(t.id, t.derived);
511
+ syncChrome();
512
+ });
879
513
 
880
514
  function focusInput(): void {
881
515
  ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
@@ -928,51 +562,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
928
562
  );
929
563
  pick(row, ".wt-switcher-row-label").textContent = t.display;
930
564
  }
931
- // animateRowIn / animateRowOut give a listed tab an enter / leave motion:
932
- // the row's own max-height grows from 0 (fading in) on add, and collapses
933
- // to 0 (fading out, then removed) on close. The flex list's height follows
934
- // the row, so adding/closing a tab animates the tray height rather than
935
- // snapping. Inline-driven (cleared when done); the caller gates motion.
936
- const ROW_ANIM_MS = 220;
937
- const ROW_ANIM_EASE = "cubic-bezier(0.2, 0, 0, 1)";
938
- function animateRowIn(row: HTMLElement): void {
939
- const h = row.getBoundingClientRect().height;
940
- if (h <= 0) {
941
- return;
942
- }
943
- row.style.overflow = "hidden";
944
- row.style.transition = "none";
945
- row.style.maxHeight = "0";
946
- row.style.opacity = "0";
947
- requestAnimationFrame(() => {
948
- row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
949
- row.style.maxHeight = `${String(Math.ceil(h))}px`;
950
- row.style.opacity = "1";
951
- });
952
- window.setTimeout(() => {
953
- row.style.transition = "";
954
- row.style.maxHeight = "";
955
- row.style.opacity = "";
956
- row.style.overflow = "";
957
- }, ROW_ANIM_MS + 60);
958
- }
959
- function animateRowOut(row: HTMLElement): void {
960
- const h = row.getBoundingClientRect().height;
961
- row.style.overflow = "hidden";
962
- row.style.pointerEvents = "none";
963
- row.style.transition = "none";
964
- row.style.maxHeight = `${String(Math.ceil(h))}px`;
965
- row.style.opacity = "1";
966
- requestAnimationFrame(() => {
967
- row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
968
- row.style.maxHeight = "0";
969
- row.style.opacity = "0";
970
- });
971
- window.setTimeout(() => {
972
- row.remove();
973
- }, ROW_ANIM_MS + 60);
974
- }
975
-
976
565
  // renderSwitcherList reconciles the expanded list to a row per OTHER tab
977
566
  // (the active tab lives in the bar row), REUSING existing row elements
978
567
  // rather than rebuilding, so element identity is stable and the swipe
@@ -1279,9 +868,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1279
868
  }
1280
869
  paintStatusDot(dot, info.status, info.reportsActivity ?? false);
1281
870
  const aria = tablist.registerTab(el);
1282
- // Insert before the strip-end controls (keyboard button, then "+") so
1283
- // they stay the last items in the strip.
1284
- bar.insertBefore(el, deskKb);
871
+ // Insert before the "+" so it stays the last item of the scrolling tab
872
+ // list (the keyboard button lives outside the scroller, at the bar end).
873
+ scroller.insertBefore(el, newBtn);
1285
874
  // Runtime-added tabs animate in; initial tabs do not (see `started`).
1286
875
  // The timer (not animationend) also clears the class on the hidden mobile
1287
876
  // strip, where the animation never fires.
@@ -1294,6 +883,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1294
883
 
1295
884
  const tab: Tab = {
1296
885
  id: info.id,
886
+ born: ++tabEpoch,
1297
887
  title: info.title,
1298
888
  clientTitle: info.clientTitle,
1299
889
  display: "",
@@ -1309,7 +899,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1309
899
  };
1310
900
  // Set an initial label immediately (relabelAll refines it with de-dup
1311
901
  // once the tab is in tabList and syncChrome runs).
1312
- tab.display = baseLabel(tab).text;
902
+ tab.display = baseLabel(tab, preferInputTitle).text;
1313
903
  label.textContent = tab.display;
1314
904
  aria.setLabel(tab.display);
1315
905
  el.addEventListener("click", (e) => {
@@ -1383,11 +973,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1383
973
  slide = to > from ? "next" : "prev";
1384
974
  }
1385
975
  }
1386
- const surface = ctx.surface();
1387
976
  // Detach the current tab: save its scroll memory (keep its cache).
977
+ // Read through the engine's scroll seam, never surface.scrollTop —
978
+ // the controller owns the container's scroll geometry.
1388
979
  const cur = tabList.find((t) => t.id === activeId);
1389
980
  if (cur) {
1390
- cur.scrollTop = surface.scrollTop;
981
+ cur.scrollTop = ctx.scroll.currentScrollTop();
1391
982
  cur.following = !ctx.scroll.isUserScrolledUp();
1392
983
  }
1393
984
  // Decide whether to animate the expanded list as a rotation: only a
@@ -1422,7 +1013,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1422
1013
  } catch {
1423
1014
  /* storage unavailable (private mode / disabled) — non-fatal */
1424
1015
  }
1425
- resetInputLine(); // a partial line typed in the old tab does not carry over
1016
+ titleDeriver.reset(); // a partial line typed in the old tab does not carry over
1426
1017
  ctx.render.bind(next.store);
1427
1018
  ctx.notifySwitch({ id: next.id });
1428
1019
  armCatchup();
@@ -1449,7 +1040,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1449
1040
  if (following) {
1450
1041
  ctx.scroll.scrollToBottom();
1451
1042
  } else {
1452
- surface.scrollTop = savedTop;
1043
+ // The write half of scroll memory goes through the same seam; the
1044
+ // controller re-derives hold from the resulting scroll event.
1045
+ ctx.scroll.restoreScrollTop(savedTop);
1453
1046
  }
1454
1047
  });
1455
1048
  ctx.announce(`Switched to ${next.display}`);
@@ -1549,11 +1142,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1549
1142
 
1550
1143
  // dragTargetBefore returns the first tab whose horizontal midpoint is past
1551
1144
  // x (the element the dragged tab should sit before), or null to drop at the
1552
- // end (before the strip-end controls). syncOrderFromDom rebuilds tabList to
1145
+ // end (before the "+"). syncOrderFromDom rebuilds tabList to
1553
1146
  // match the strip's DOM order after a drag, so position indicators, the
1554
1147
  // switcher, and close-to-the-right/left all follow the visible order.
1555
1148
  function dragTargetBefore(x: number): HTMLElement | null {
1556
- for (const el of bar.querySelectorAll<HTMLElement>(".wt-tab:not(.wt-tab-dragging)")) {
1149
+ for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab:not(.wt-tab-dragging)")) {
1557
1150
  const rect = el.getBoundingClientRect();
1558
1151
  if (x < rect.left + rect.width / 2) {
1559
1152
  return el;
@@ -1563,7 +1156,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1563
1156
  }
1564
1157
  function syncOrderFromDom(): void {
1565
1158
  const order: Tab[] = [];
1566
- for (const el of bar.querySelectorAll<HTMLElement>(".wt-tab")) {
1159
+ for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
1567
1160
  const t = tabList.find((x) => x.el === el);
1568
1161
  if (t) {
1569
1162
  order.push(t);
@@ -1585,12 +1178,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1585
1178
  if (tabList.find((t) => t.id === info.id)) {
1586
1179
  return;
1587
1180
  }
1588
- const closedAt = recentlyClosed.get(info.id);
1589
- if (closedAt !== undefined) {
1590
- if (Date.now() - closedAt < CLOSE_TOMBSTONE_MS) {
1591
- return; // just closed here; ignore a stale listing until the server reaps it
1592
- }
1593
- recentlyClosed.delete(info.id);
1181
+ if (tombstones.active(info.id)) {
1182
+ return; // just closed here; ignore a stale listing until the server reaps it
1594
1183
  }
1595
1184
  tabList.push(addTabChrome(info));
1596
1185
  }
@@ -1619,7 +1208,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1619
1208
  async function create(): Promise<void> {
1620
1209
  let info: SessionInfo;
1621
1210
  try {
1622
- info = await apiCreate();
1211
+ info = await api.create();
1623
1212
  } catch {
1624
1213
  // A create can still fail transiently (network, server error); tell
1625
1214
  // the user rather than throwing.
@@ -1676,7 +1265,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1676
1265
  }
1677
1266
  // Tombstone this id briefly so a stale status snapshot/poll that predates
1678
1267
  // the server reaping it cannot re-adopt the just-closed tab.
1679
- tombstone(id);
1268
+ tombstones.add(id);
1680
1269
  // A pending switch-button cue whose subject just closed is moot: clear
1681
1270
  // it rather than leaving a dot no tab visit can ever resolve.
1682
1271
  acknowledgeSwitchNotify(id);
@@ -1699,7 +1288,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1699
1288
  syncChrome(); // reflect the drop immediately (count, position)
1700
1289
  if (remote) {
1701
1290
  try {
1702
- await apiClose(id);
1291
+ await api.close(id);
1703
1292
  } catch {
1704
1293
  ctx.toast("Couldn't close the terminal on the server");
1705
1294
  }
@@ -1743,7 +1332,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1743
1332
  // Tombstone briefly so a stale status snapshot/poll that predates the
1744
1333
  // server reaping these sessions cannot re-adopt (flash back) a just-
1745
1334
  // closed tab -- mirrors the single-close path in dropTab (h-f2).
1746
- tombstone(t.id);
1335
+ tombstones.add(t.id);
1747
1336
  acknowledgeSwitchNotify(t.id); // a cue for a closed tab is moot
1748
1337
  t.aria.remove();
1749
1338
  t.el.remove();
@@ -1764,7 +1353,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1764
1353
  }
1765
1354
  for (const t of victims) {
1766
1355
  try {
1767
- await apiClose(t.id);
1356
+ await api.close(t.id);
1768
1357
  } catch {
1769
1358
  ctx.toast("Couldn't close a terminal on the server");
1770
1359
  }
@@ -1836,31 +1425,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1836
1425
  tabMenuItem("Close all", false, () => {
1837
1426
  void closeAll();
1838
1427
  });
1839
- // Make visible (so it has measurable size), then clamp within the visible
1840
- // viewport; position:fixed means x/y are viewport coordinates, and the
1841
- // visual viewport (when present) excludes the on-screen keyboard.
1428
+ // Make visible (so it has measurable size), then place it within the
1429
+ // visible viewport via the shared point-anchored positioner (clamp to
1430
+ // the visual viewport; flip above the pointer near the bottom edge).
1842
1431
  tabMenu.classList.add("visible");
1843
- const vv = window.visualViewport;
1844
- const viewLeft = vv ? vv.offsetLeft : 0;
1845
- const viewTop = vv ? vv.offsetTop : 0;
1846
- const viewWidth = vv ? vv.width : window.innerWidth;
1847
- const viewHeight = vv ? vv.height : window.innerHeight;
1848
- const menuW = tabMenu.offsetWidth;
1849
- const menuH = tabMenu.offsetHeight;
1850
- const left = Math.max(
1851
- viewLeft + TAB_MENU_EDGE,
1852
- Math.min(x, viewLeft + viewWidth - menuW - TAB_MENU_EDGE),
1853
- );
1854
- let top = y;
1855
- if (y + menuH + TAB_MENU_EDGE > viewTop + viewHeight) {
1856
- top = y - menuH - TAB_MENU_GAP;
1857
- }
1858
- top = Math.max(
1859
- viewTop + TAB_MENU_EDGE,
1860
- Math.min(top, viewTop + viewHeight - menuH - TAB_MENU_EDGE),
1861
- );
1862
- tabMenu.style.left = `${String(left)}px`;
1863
- tabMenu.style.top = `${String(top)}px`;
1432
+ placeMenuAt(tabMenu, x, y);
1864
1433
  }
1865
1434
 
1866
1435
  // One-time "swipe to switch" hint on first multi-tab state, mobile only.
@@ -1869,7 +1438,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1869
1438
  return;
1870
1439
  }
1871
1440
  hintShown = true;
1872
- if (!window.matchMedia(SWITCHER_MEDIA).matches) {
1441
+ const l = ctx.layout();
1442
+ if (!(l.narrow && l.coarse)) {
1873
1443
  return; // only the mobile switcher layout has a swipe-to-switch bar
1874
1444
  }
1875
1445
  let seen = false;
@@ -1941,7 +1511,66 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1941
1511
  // drop; the poll additionally learns of a background exit the SSE would
1942
1512
  // have pushed (section 22.5).
1943
1513
  let offStatus: (() => void) | undefined;
1514
+ let offStreamOpen: (() => void) | undefined;
1944
1515
  let pollTimer: ReturnType<typeof setInterval> | null = null;
1516
+ // One-shot list reconcile shared by both status sources: adopt every
1517
+ // session the server lists, then drop tabs it no longer lists. The poll
1518
+ // fallback runs it on a timer; the SSE path runs it on every stream
1519
+ // (re)open — the reopen against a RESTARTED manager is the moment
1520
+ // zombie tabs must drop, because the fresh server's snapshot carries no
1521
+ // tombstones for sessions it never knew, so those tabs would otherwise
1522
+ // spin "Reconnecting…" forever (judgement finding sf-2).
1523
+ // Guarded against overlapping runs: a server slower than the trigger
1524
+ // cadence (but within the 15s API timeout) would otherwise race tabList
1525
+ // mutation. Skip the extra run instead.
1526
+ let reconciling = false;
1527
+ const reconcileOnce = async (): Promise<void> => {
1528
+ if (reconciling) {
1529
+ return;
1530
+ }
1531
+ reconciling = true;
1532
+ try {
1533
+ // Snapshot the mutation epoch BEFORE the list round-trip: the listing
1534
+ // is authoritative only for tabs that already existed when it was
1535
+ // requested. A tab adopted while the GET was in flight (the boot
1536
+ // race: the bootstrap's create vs this stream-open reconcile) is
1537
+ // invisible to the returned snapshot, and dropping it here cascaded
1538
+ // into a duplicate replacement session (dropTab's last-tab intercept
1539
+ // spawns one) — the double-create boot bug.
1540
+ const epochAtList = tabEpoch;
1541
+ let list: SessionInfo[];
1542
+ try {
1543
+ list = await api.list();
1544
+ } catch {
1545
+ return; // transient; try again on the next trigger
1546
+ }
1547
+ const seen = new Set(list.map((s) => s.id));
1548
+ for (const info of list) {
1549
+ adoptSession(info); // add sessions created elsewhere (no local tab)
1550
+ applyStatus(
1551
+ info.id,
1552
+ info.status,
1553
+ info.title,
1554
+ info.clientTitle,
1555
+ info.reportsActivity ?? false,
1556
+ );
1557
+ }
1558
+ // A tab the server no longer lists was reaped/closed elsewhere (or
1559
+ // died with a restarted manager): drop it locally (no DELETE — it
1560
+ // is already gone). Tabs born after the list snapshot are spared this
1561
+ // round; the next reconcile sees the server truth for them.
1562
+ const gone = tabList
1563
+ .filter((t) => !seen.has(t.id) && t.born <= epochAtList)
1564
+ .map((t) => t.id);
1565
+ for (const id of gone) {
1566
+ await dropTab(id, false);
1567
+ }
1568
+ ensureActive();
1569
+ syncChrome();
1570
+ } finally {
1571
+ reconciling = false;
1572
+ }
1573
+ };
1945
1574
  if (monitor) {
1946
1575
  offStatus = monitor.onStatus((s) => {
1947
1576
  if (s.removed) {
@@ -1949,73 +1578,33 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1949
1578
  return;
1950
1579
  }
1951
1580
  // Adopt a session created in another browser so all clients converge.
1952
- // Carry the persisted clientTitle through: the SSE snapshot usually
1953
- // beats the initial GET /api/sessions (whose adoptSession then dedups
1954
- // and never re-applies its fields), so dropping it here left every
1581
+ // The status record IS the session's wire shape (SessionStatus
1582
+ // extends SessionInfo), so it flows through whole — which also
1583
+ // carries the persisted clientTitle: the SSE snapshot usually beats
1584
+ // the initial GET /api/sessions (whose adoptSession then dedups and
1585
+ // never re-applies its fields), so dropping it here left every
1955
1586
  // SSE-adopted tab without its persisted title — in preferInputTitle
1956
1587
  // mode the label then fell back to "New tab" on reload.
1957
- adoptSession({
1958
- id: s.id,
1959
- title: s.title,
1960
- clientTitle: s.clientTitle,
1961
- createdAt: s.createdAt,
1962
- status: s.status,
1963
- reportsActivity: s.reportsActivity ?? false,
1964
- });
1588
+ adoptSession(s);
1965
1589
  applyStatus(s.id, s.status, s.title, s.clientTitle, s.reportsActivity ?? false);
1966
1590
  ensureActive();
1967
1591
  syncChrome();
1968
1592
  });
1593
+ offStreamOpen = monitor.onStreamOpen?.(() => {
1594
+ void reconcileOnce();
1595
+ });
1969
1596
  } else {
1970
1597
  const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
1971
- // Guard against overlapping runs: a server slower than pollMs (but
1972
- // within the 15s API timeout) would otherwise let setInterval start
1973
- // a second pollOnce while the first is still awaiting, racing tabList
1974
- // mutation. Skip the tick instead.
1975
- let polling = false;
1976
- const pollOnce = async (): Promise<void> => {
1977
- if (polling) {
1978
- return;
1979
- }
1980
- polling = true;
1981
- try {
1982
- let list: SessionInfo[];
1983
- try {
1984
- list = await apiList();
1985
- } catch {
1986
- return; // transient; try again next tick
1987
- }
1988
- const seen = new Set(list.map((s) => s.id));
1989
- for (const info of list) {
1990
- adoptSession(info); // add sessions created elsewhere (no local tab)
1991
- applyStatus(
1992
- info.id,
1993
- info.status,
1994
- info.title,
1995
- info.clientTitle,
1996
- info.reportsActivity ?? false,
1997
- );
1998
- }
1999
- // A tab the server no longer lists was reaped/closed elsewhere: drop it
2000
- // locally (no DELETE — it is already gone).
2001
- const gone = tabList.filter((t) => !seen.has(t.id)).map((t) => t.id);
2002
- for (const id of gone) {
2003
- await dropTab(id, false);
2004
- }
2005
- ensureActive();
2006
- syncChrome();
2007
- } finally {
2008
- polling = false;
2009
- }
2010
- };
2011
1598
  pollTimer = setInterval(() => {
2012
- void pollOnce();
1599
+ void reconcileOnce();
2013
1600
  }, pollMs);
2014
1601
  }
2015
1602
 
2016
1603
  // --- Event wiring ---
2017
- // Derive each tab's title from the first line the user submits into it.
2018
- const offInput = ctx.registerInputObserver(deriveTitleFromInput);
1604
+ // Derive each tab's title from the lines the user submits into it.
1605
+ const offInput = ctx.registerInputObserver((bytes) => {
1606
+ titleDeriver.observe(bytes);
1607
+ });
2019
1608
  // Observe (never consume) keydowns to detect a physical keyboard: a
2020
1609
  // hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
2021
1610
  // for a keyboard folio with no trackpad (no fine pointer to key off).
@@ -2034,7 +1623,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2034
1623
  if (e.dataTransfer) {
2035
1624
  e.dataTransfer.dropEffect = "move";
2036
1625
  }
2037
- bar.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? deskKb);
1626
+ scroller.insertBefore(draggingEl, dragTargetBefore(e.clientX) ?? newBtn);
2038
1627
  });
2039
1628
  // Active-row close (x): closes the current tab (mirrors a listed row's x).
2040
1629
  // stopPropagation so it is not read as a tap/swipe on the row surface.
@@ -2472,68 +2061,86 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2472
2061
  toggleSwitcher();
2473
2062
  });
2474
2063
 
2475
- // Initial population: list existing sessions, or create the first one.
2476
- let sessions: SessionInfo[];
2477
- try {
2478
- sessions = await apiList();
2479
- } catch {
2480
- sessions = [];
2481
- }
2482
- // Spawn a fresh session unless a LIVE one is listed. An exited session is
2483
- // viewable history, not a working terminal — booting a page whose every
2484
- // session has died (the agent exited: a sign-in dead end, a crash) onto a
2485
- // corpse was the stuck-loading wedge. The exited ones are still adopted
2486
- // below (switch to them to read their final screen; close them by hand).
2487
- if (!sessions.some((s) => s.status !== STATUS_EXITED)) {
2064
+ // The kernel-driven bootstrap (sessionOwner.resolveInitialSession): list
2065
+ // existing sessions or create the first one, adopt them, pick the start
2066
+ // tab, bind the renderer to its store — and RETURN its ref rather than
2067
+ // switching; the kernel performs the switch through the same path a tab
2068
+ // switch uses. A null return (nothing could be listed or spawned) keeps
2069
+ // the chrome up with the "+" retry alive, and the kernel — which now sees
2070
+ // the failure directly — dismisses the loading overlay over it.
2071
+ resolveImpl = async (): Promise<SessionRef | null> => {
2072
+ // Initial population: list existing sessions, or create the first one.
2073
+ let sessions: SessionInfo[];
2488
2074
  try {
2489
- sessions = [...sessions, await apiCreate()];
2075
+ sessions = await api.list();
2490
2076
  } catch {
2491
- // Do not let the initial spawn propagate out of setup(): the kernel
2492
- // would tear down every feature ("terminal has no chrome") and, since
2493
- // managesSessions skipped the startup connect, connectionInitiated
2494
- // stays false so the wake handlers (visibilitychange/pageshow/online)
2495
- // all no-op -- a permanently dead terminal with no recovery. Match the
2496
- // runtime create() path: toast and leave the chrome up so "+" can
2497
- // retry. Any exited sessions stay adopted (frozen screen + "Session
2498
- // ended" is still better than a blank page).
2499
- ctx.toast("Couldn't open a terminal");
2077
+ sessions = [];
2078
+ }
2079
+ // Spawn a fresh session unless a LIVE one is listed. An exited session
2080
+ // is viewable history, not a working terminal — booting a page whose
2081
+ // every session has died (the agent exited: a sign-in dead end, a
2082
+ // crash) onto a corpse was the stuck-loading wedge. The exited ones are
2083
+ // still adopted below (switch to them to read their final screen; close
2084
+ // them by hand).
2085
+ if (!sessions.some((s) => s.status !== STATUS_EXITED)) {
2086
+ try {
2087
+ sessions = [...sessions, await api.create()];
2088
+ } catch {
2089
+ // Match the runtime create() path: toast and leave the chrome up so
2090
+ // "+" can retry. Any exited sessions stay adopted (frozen screen +
2091
+ // "Session ended" is still better than a blank page). A throw here
2092
+ // would also be survivable (the kernel treats a rejected resolver
2093
+ // as null), but the toast is the better UX.
2094
+ ctx.toast("Couldn't open a terminal");
2095
+ }
2500
2096
  }
2501
- }
2502
- // Adopt (dedup) rather than blindly push. The status SSE pushes a snapshot
2503
- // of the existing sessions on open, and tabs subscribes (monitor.onStatus)
2504
- // BEFORE this list resolves, so a session may already have a tab by the
2505
- // time the list lands. A straight push doubled every already-adopted
2506
- // session (6 tabs from 3 across a fresh load), and paintActive then lit
2507
- // both copies of the active id ("2 active tabs" that move together).
2508
- for (const info of sessions) {
2509
- adoptSession(info);
2510
- }
2511
- // From here on, tabs added at runtime (create / adopt) animate in.
2512
- started = true;
2513
- // Activate the previously-active session if it still exists, else the
2514
- // first (oldest). Session ids are stable server-side, so a page reload
2515
- // reconnects to the tab the user left on instead of always the oldest.
2516
- // Live sessions outrank exited ones: the saved id is honored only while
2517
- // its session is still live (a reload used to restore straight onto the
2518
- // corpse of a died-while-away session and wedge there), and the default
2519
- // is the oldest LIVE tab. Only when nothing is live (the fresh-spawn
2520
- // above failed too) does an exited tab start — a frozen final screen
2521
- // with the "Session ended" banner beats a blank page.
2522
- const liveIds = new Set(sessions.filter((s) => s.status !== STATUS_EXITED).map((s) => s.id));
2523
- const oldestLive = tabList.find((t) => liveIds.has(t.id));
2524
- let startTab = oldestLive ?? tabList[0];
2525
- try {
2526
- const savedId = localStorage.getItem(ACTIVE_TAB_KEY);
2527
- if (savedId !== null && savedId !== "") {
2528
- const saved = tabList.find((x) => x.id === savedId);
2529
- if (saved && (liveIds.has(saved.id) || oldestLive === undefined)) {
2530
- startTab = saved;
2097
+ // Adopt (dedup) rather than blindly push. The status SSE pushes a
2098
+ // snapshot of the existing sessions on open, and tabs subscribes
2099
+ // (monitor.onStatus) BEFORE this list resolves, so a session may
2100
+ // already have a tab by the time the list lands. A straight push
2101
+ // doubled every already-adopted session (6 tabs from 3 across a fresh
2102
+ // load), and paintActive then lit both copies of the active id
2103
+ // ("2 active tabs" that move together).
2104
+ for (const info of sessions) {
2105
+ adoptSession(info);
2106
+ }
2107
+ // From here on, tabs added at runtime (create / adopt) animate in.
2108
+ started = true;
2109
+ // The SSE snapshot may have raced this bootstrap and already activated
2110
+ // a tab (ensureActive during the await above). The switch is then
2111
+ // already in flight — return null; the kernel sees connectionInitiated
2112
+ // and leaves the loading overlay to the normal ready path.
2113
+ if (activeId !== null) {
2114
+ return null;
2115
+ }
2116
+ // Activate the previously-active session if it still exists, else the
2117
+ // first (oldest). Session ids are stable server-side, so a page reload
2118
+ // reconnects to the tab the user left on instead of always the oldest.
2119
+ // Live sessions outrank exited ones: the saved id is honored only while
2120
+ // its session is still live (a reload used to restore straight onto the
2121
+ // corpse of a died-while-away session and wedge there), and the default
2122
+ // is the oldest LIVE tab. Only when nothing is live (the fresh-spawn
2123
+ // above failed too) does an exited tab start — a frozen final screen
2124
+ // with the "Session ended" banner beats a blank page.
2125
+ const liveIds = new Set(
2126
+ sessions.filter((s) => s.status !== STATUS_EXITED).map((s) => s.id),
2127
+ );
2128
+ const oldestLive = tabList.find((t) => liveIds.has(t.id));
2129
+ let startTab = oldestLive ?? tabList[0];
2130
+ try {
2131
+ const savedId = localStorage.getItem(ACTIVE_TAB_KEY);
2132
+ if (savedId !== null && savedId !== "") {
2133
+ const saved = tabList.find((x) => x.id === savedId);
2134
+ if (saved && (liveIds.has(saved.id) || oldestLive === undefined)) {
2135
+ startTab = saved;
2136
+ }
2531
2137
  }
2138
+ } catch {
2139
+ /* storage unavailable — fall back to the oldest live tab */
2140
+ }
2141
+ if (!startTab) {
2142
+ return null;
2532
2143
  }
2533
- } catch {
2534
- /* storage unavailable — fall back to the oldest live tab */
2535
- }
2536
- if (startTab) {
2537
2144
  activeId = startTab.id;
2538
2145
  try {
2539
2146
  localStorage.setItem(ACTIVE_TAB_KEY, startTab.id);
@@ -2541,10 +2148,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2541
2148
  /* storage unavailable — non-fatal */
2542
2149
  }
2543
2150
  ctx.render.bind(startTab.store);
2544
- ctx.notifySwitch({ id: startTab.id });
2545
2151
  syncChrome();
2546
2152
  focusInput();
2547
- }
2153
+ return { id: startTab.id };
2154
+ };
2548
2155
 
2549
2156
  return {
2550
2157
  api: {
@@ -2555,7 +2162,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2555
2162
  tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
2556
2163
  },
2557
2164
  teardown() {
2165
+ resolveImpl = null;
2558
2166
  offStatus?.();
2167
+ offStreamOpen?.();
2559
2168
  offInput();
2560
2169
  offHwKey();
2561
2170
  offArmed?.();
@@ -2570,9 +2179,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2570
2179
  barResize.disconnect();
2571
2180
  swReserve.disconnect();
2572
2181
  surface.classList.remove("wt-with-tabbar");
2573
- document.documentElement.style.removeProperty("--wt-tabbar-h");
2182
+ varRoot.style.removeProperty("--wt-tabbar-h");
2574
2183
  root?.classList.remove("wt-tabbed");
2575
- document.documentElement.style.removeProperty("--wt-reserve-bottom");
2184
+ varRoot.style.removeProperty("--wt-reserve-bottom");
2576
2185
  if (switchAnimTimer !== null) {
2577
2186
  clearTimeout(switchAnimTimer);
2578
2187
  }
@@ -2600,23 +2209,3 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2600
2209
  },
2601
2210
  };
2602
2211
  }
2603
-
2604
- // paintStatusDot applies a status dot's two orthogonal bits: data-status drives
2605
- // its appearance (idle / working / done / input / exited via CSS), and the
2606
- // .wt-reports class controls its visibility — the dot is hidden by default and
2607
- // shown only once the session has reported activity (OSC 9;4 progress or a
2608
- // classified OSC 9 notification), so a plain shell's tabs stay clean and
2609
- // label-only while an agent's light up.
2610
- function paintStatusDot(el: HTMLElement, status: string, reports: boolean): void {
2611
- el.dataset["status"] = status || "idle";
2612
- el.classList.toggle("wt-reports", reports);
2613
- }
2614
-
2615
- // pick returns a required descendant element or throws (static chrome only).
2616
- function pick(root: ParentNode, selector: string): HTMLElement {
2617
- const el = root.querySelector<HTMLElement>(selector);
2618
- if (!el) {
2619
- throw new Error(`web-terminal-ui: tabs chrome missing ${selector}`);
2620
- }
2621
- return el;
2622
- }