@cplieger/web-terminal-ui 3.5.0 → 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.
@@ -0,0 +1,334 @@
1
+ // tabs/model.ts — the session MODEL half of the tabs feature: the wire type,
2
+ // the per-tab record, the session REST client, the close-tombstone set, and the
3
+ // input-derived-title state machine. No DOM, no chrome, no kernel context —
4
+ // everything here is factory/pure and unit-testable in isolation. The chrome
5
+ // halves are strip.ts (desktop) and switcher.ts (mobile); index.ts wires all
6
+ // three over the kernel context.
7
+
8
+ import type { LineStore } from "@cplieger/web-terminal-engine";
9
+ import type { SessionInfo } from "@cplieger/web-terminal-engine";
10
+ import type { TabHandle } from "../../kernel/types.js";
11
+
12
+ // One session's wire shape (SessionInfo) is the ENGINE's exported type — the
13
+ // same repo as the Go terminal.SessionInfo it mirrors, so the cross-language
14
+ // contract has one home. Type-only import: erases at compile, no runtime dep.
15
+ export type { SessionInfo };
16
+
17
+ // The server-side status of a session whose process has exited (mirrors the
18
+ // engine's terminal.StatusExited). Such a session is viewable history — its
19
+ // final screen replays and the kernel shows "Session ended" — but it can never
20
+ // produce output again, so session selection prefers live sessions everywhere.
21
+ export const STATUS_EXITED = "exited";
22
+
23
+ // localStorage key for the last active session id, so a page reload reopens the
24
+ // tab the user left on rather than always defaulting to the oldest one.
25
+ export const ACTIVE_TAB_KEY = "wt-active-session";
26
+
27
+ // One-time "swipe to switch" hint, remembered across loads.
28
+ export const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
29
+
30
+ export interface Tab {
31
+ id: string;
32
+ /** The raw server title (the OSC 0/2 window title the process set), possibly
33
+ * empty before the process sets one. The displayed label is derived from it
34
+ * with a numbered fallback and de-duplication (see relabelAll in index.ts). */
35
+ title: string;
36
+ /** The computed, de-duplicated label actually shown in the chrome. */
37
+ display: string;
38
+ createdAt: string;
39
+ store: LineStore;
40
+ el: HTMLElement;
41
+ label: HTMLElement;
42
+ dot: HTMLElement;
43
+ aria: TabHandle;
44
+ scrollTop: number;
45
+ following: boolean;
46
+ /** Sticky: true once this session emitted a genuine activity signal (OSC 9;4).
47
+ * Its activity dot is shown only while true; a session that never reports
48
+ * activity (a plain shell) keeps a clean, dot-less tab. Fed from the server's
49
+ * reportsActivity via applyStatus. */
50
+ reports: boolean;
51
+ /** A fallback title derived from the LAST non-empty line the user submitted
52
+ * into this tab (updated on every Enter). Used only when the process sets no
53
+ * window title of its own: the OSC 0/2 title (`title` above) takes precedence
54
+ * when present and keeps updating, and this is what the tab reads as when it
55
+ * does not — a plain shell with no PROMPT_COMMAND title, or kiro-cli with its
56
+ * cwd-only title disabled. Undefined until the first non-empty submission; see
57
+ * baseLabel. */
58
+ derived?: string;
59
+ /** The persisted client title reported by the server (its `clientTitle` wire
60
+ * field): the last derived title pushed via PUT .../title, surviving reloads.
61
+ * In `preferInputTitle` mode this is the reload-recovery source (the live
62
+ * `derived` is lost on reload, and `title` is the unreliable OSC value there).
63
+ * In the default mode it is the lowest-priority fallback after title/derived. */
64
+ clientTitle?: string | undefined;
65
+ }
66
+
67
+ /** baseLabel is a tab's label before de-duplication. Preference order: the
68
+ * process window title (OSC 0/2, e.g. a shell's PROMPT_COMMAND title), which
69
+ * takes precedence whenever the program sets one and keeps updating as it
70
+ * changes (the status SSE re-pushes it); then a fallback derived from the last
71
+ * line the user submitted, for a program that sets no title (a bare shell, or
72
+ * kiro-cli with its cwd-only title disabled); then a plain "New tab".
73
+ * fallback=true marks that last case so relabelAll leaves untitled tabs as
74
+ * "New tab" with no numeric suffix.
75
+ *
76
+ * preferInputTitle (agent shell with an unreliable OSC title): show the live
77
+ * submitted line, then the persisted client title on reload — the process OSC
78
+ * `title` is ignored. Default: OSC title first, then the live derived line,
79
+ * then the persisted client title (the latter two are the "latest user
80
+ * message" fallback when the program sets no OSC title). */
81
+ export function baseLabel(
82
+ tab: Tab,
83
+ preferInputTitle: boolean,
84
+ ): { text: string; fallback: boolean } {
85
+ const derived = tab.derived?.trim() ?? "";
86
+ const persisted = tab.clientTitle?.trim() ?? "";
87
+ const real = preferInputTitle ? derived || persisted : tab.title.trim() || derived || persisted;
88
+ return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
89
+ }
90
+
91
+ /** The session REST client (GET/POST/DELETE /api/sessions + PUT .../title),
92
+ * bound to an apiBase. Every call is timeout-bounded: fetch has no default
93
+ * timeout, and a stalled-but-open server would otherwise leave a bootstrap
94
+ * list/create await pending forever (the old permanent-loading-overlay wedge;
95
+ * under the v4 session-owner contract the kernel would eventually see nothing,
96
+ * but a bounded call recovers into the retry chrome MUCH sooner). */
97
+ export interface SessionAPI {
98
+ list(): Promise<SessionInfo[]>;
99
+ create(): Promise<SessionInfo>;
100
+ close(id: string): Promise<void>;
101
+ /** Best-effort and fire-and-forget: a failure never disrupts the terminal —
102
+ * the locally-derived title still displays, it just is not persisted. */
103
+ setTitle(id: string, title: string): Promise<void>;
104
+ }
105
+
106
+ const SESSION_API_TIMEOUT_MS = 15000;
107
+
108
+ export function createSessionAPI(apiBase: string): SessionAPI {
109
+ return {
110
+ async list(): Promise<SessionInfo[]> {
111
+ const r = await fetch(apiBase, {
112
+ headers: { Accept: "application/json" },
113
+ signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
114
+ });
115
+ if (!r.ok) {
116
+ throw new Error(`web-terminal-ui: session list failed (${String(r.status)})`);
117
+ }
118
+ const data: unknown = await r.json();
119
+ // A 200 with a non-array body -- a proxy error object, or a Go server
120
+ // marshaling a nil session slice as JSON `null` -- must not reach the
121
+ // bootstrap's `sessions.length` / `for...of` (or the poll's `list.map`)
122
+ // uncaught. Reject a non-array so the callers' existing catch paths
123
+ // recover (bootstrap -> [], poll -> skip the tick).
124
+ if (!Array.isArray(data)) {
125
+ throw new Error("web-terminal-ui: session list returned a non-array body");
126
+ }
127
+ return data as SessionInfo[];
128
+ },
129
+ async create(): Promise<SessionInfo> {
130
+ const r = await fetch(apiBase, {
131
+ method: "POST",
132
+ signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
133
+ });
134
+ if (!r.ok) {
135
+ throw new Error(`web-terminal-ui: session create failed (${String(r.status)})`);
136
+ }
137
+ return (await r.json()) as SessionInfo;
138
+ },
139
+ async close(id: string): Promise<void> {
140
+ const r = await fetch(`${apiBase}/${encodeURIComponent(id)}`, {
141
+ method: "DELETE",
142
+ signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
143
+ });
144
+ if (!r.ok) {
145
+ throw new Error(`web-terminal-ui: session close failed (${String(r.status)})`);
146
+ }
147
+ },
148
+ // Persist the input-derived tab title server-side so it survives a page
149
+ // reload and shows on other devices. The engine stores it as the session's
150
+ // fallback title and uses it only when the program emits no OSC title.
151
+ async setTitle(id: string, title: string): Promise<void> {
152
+ try {
153
+ await fetch(`${apiBase}/${encodeURIComponent(id)}/title`, {
154
+ method: "PUT",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({ title }),
157
+ signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
158
+ });
159
+ } catch {
160
+ /* best-effort persistence; ignore network/timeout errors */
161
+ }
162
+ },
163
+ };
164
+ }
165
+
166
+ /** Close tombstones: ids the user closed within the TTL, so a stale server
167
+ * listing (the SSE re-open snapshot, or the poll's GET /api/sessions) that
168
+ * predates the server reaping the session does not re-adopt (flash back) the
169
+ * closed tab. */
170
+ export interface Tombstones {
171
+ add(id: string): void;
172
+ /** True while `id` is tombstoned (within the TTL). A hit past the TTL clears
173
+ * the entry and reports false (the adopt may proceed). */
174
+ active(id: string): boolean;
175
+ }
176
+
177
+ const CLOSE_TOMBSTONE_MS = 15000;
178
+
179
+ export function createTombstones(ttlMs: number = CLOSE_TOMBSTONE_MS): Tombstones {
180
+ const recentlyClosed = new Map<string, number>();
181
+ return {
182
+ add(id: string): void {
183
+ const now = Date.now();
184
+ // Sweep entries whose window already elapsed (active() treats them as
185
+ // untombstoned anyway) so the map cannot grow without bound over a long
186
+ // session of opens/closes; then record this close.
187
+ for (const [k, t] of recentlyClosed) {
188
+ if (now - t >= ttlMs) {
189
+ recentlyClosed.delete(k);
190
+ }
191
+ }
192
+ recentlyClosed.set(id, now);
193
+ },
194
+ active(id: string): boolean {
195
+ const closedAt = recentlyClosed.get(id);
196
+ if (closedAt === undefined) {
197
+ return false;
198
+ }
199
+ if (Date.now() - closedAt < ttlMs) {
200
+ return true;
201
+ }
202
+ recentlyClosed.delete(id);
203
+ return false;
204
+ },
205
+ };
206
+ }
207
+
208
+ // A generous storage bound for the fallback (input-derived) title. The tab
209
+ // label truncates it visually with max-width + ellipsis in EVERY view — the
210
+ // desktop strip, the mobile active bar, and the mobile list — so this is only a
211
+ // guard against storing a huge paste, not a display cut.
212
+ const MAX_DERIVED = 512;
213
+
214
+ /** The input-derived-title state machine: a tiny line editor over the kernel's
215
+ * accepted outbound bytes. It tracks the current input line (handling
216
+ * backspace and skipping the escape sequences arrow keys and bracketed paste
217
+ * emit) and reports every non-empty submitted line through `submit`. The
218
+ * caller owns what a submission means (update the active tab's derived title,
219
+ * persist it, refresh chrome). Best-effort: an odd editing sequence just
220
+ * yields no derived title. */
221
+ export interface InputTitleDeriver {
222
+ /** Feed one accepted outbound byte chunk (a ctx.registerInputObserver hook). */
223
+ observe(bytes: Uint8Array): void;
224
+ /** Drop the partial line (a tab switch: a line typed in the old tab must not
225
+ * carry over). */
226
+ reset(): void;
227
+ }
228
+
229
+ export function createInputTitleDeriver(submit: (line: string) => void): InputTitleDeriver {
230
+ let lineBytes: number[] = [];
231
+ let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
232
+ let csiParams = ""; // accumulated CSI parameter bytes, to spot paste guards
233
+ let inPaste = false; // inside a bracketed paste (ESC[200~ … ESC[201~)
234
+ return {
235
+ reset(): void {
236
+ lineBytes = [];
237
+ escState = 0;
238
+ csiParams = "";
239
+ inPaste = false;
240
+ },
241
+ observe(bytes: Uint8Array): void {
242
+ for (let i = 0; i < bytes.length; i++) {
243
+ const b = bytes[i];
244
+ if (b === undefined) {
245
+ continue;
246
+ }
247
+ if (escState === 1) {
248
+ escState = b === 0x5b ? 2 : b === 0x4f ? 3 : 0; // ESC [ = CSI, ESC O = SS3
249
+ continue;
250
+ }
251
+ if (escState === 2) {
252
+ if (b >= 0x40 && b <= 0x7e) {
253
+ // CSI final byte. Recognize the bracketed-paste guards ESC[200~
254
+ // (start) and ESC[201~ (end) so embedded newlines in a pasted,
255
+ // often multi-line, message are not read as line submissions.
256
+ if (b === 0x7e) {
257
+ if (csiParams === "200") {
258
+ inPaste = true;
259
+ } else if (csiParams === "201") {
260
+ inPaste = false;
261
+ }
262
+ }
263
+ csiParams = "";
264
+ escState = 0; // CSI final byte
265
+ } else if (b >= 0x30 && b <= 0x3f) {
266
+ csiParams += String.fromCharCode(b); // parameter byte (digits, ; ? etc.)
267
+ }
268
+ continue;
269
+ }
270
+ if (escState === 3) {
271
+ escState = 0; // SS3 final byte
272
+ continue;
273
+ }
274
+ if (b === 0x1b) {
275
+ escState = 1; // ESC: start of an escape sequence
276
+ } else if (b === 0x0d || b === 0x0a) {
277
+ // A newline is a line SUBMIT only when it terminates the input. It is
278
+ // folded to a single space (keeping the current line going) when it is
279
+ // instead a paste-internal break, so a multi-line message becomes one
280
+ // logical line whose title reflects its START rather than only its
281
+ // LAST line. Two cases fold:
282
+ // - inside a bracketed paste (ESC[200~ … ESC[201~), and
283
+ // - a newline FOLLOWED by more printable input in this SAME chunk:
284
+ // a human pressing Enter sends the newline as the end of its own
285
+ // input event, whereas a paste (even one sent WITHOUT bracketed-
286
+ // paste guards — e.g. an agent shell like kiro-cli that keeps a
287
+ // pasted multi-line message as one prompt) delivers
288
+ // text + newline + text together, so trailing content marks the
289
+ // newline as a soft break, not a submit. Without this, such a
290
+ // paste left only its last line as the title (the reported
291
+ // "the title cut off the start of my message").
292
+ let softBreak = inPaste;
293
+ if (!softBreak) {
294
+ for (let j = i + 1; j < bytes.length; j++) {
295
+ const nb = bytes[j];
296
+ if (nb !== undefined && nb >= 0x20) {
297
+ softBreak = true;
298
+ break;
299
+ }
300
+ }
301
+ }
302
+ if (softBreak) {
303
+ if (lineBytes.length > 0 && lineBytes[lineBytes.length - 1] !== 0x20) {
304
+ lineBytes.push(0x20);
305
+ }
306
+ continue;
307
+ }
308
+ const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
309
+ lineBytes = [];
310
+ // Every non-empty submitted line updates the fallback title (the
311
+ // last one wins); a leading "/" counts, since it is a valid shell
312
+ // command path.
313
+ if (line) {
314
+ submit(line.slice(0, MAX_DERIVED));
315
+ }
316
+ } else if (b === 0x7f || b === 0x08) {
317
+ // Backspace: drop one codepoint (pop UTF-8 continuation bytes + lead).
318
+ while (lineBytes.length > 0) {
319
+ const last = lineBytes[lineBytes.length - 1];
320
+ if (last === undefined || (last & 0xc0) !== 0x80) {
321
+ break;
322
+ }
323
+ lineBytes.pop();
324
+ }
325
+ lineBytes.pop();
326
+ } else if (b === 0x03) {
327
+ lineBytes = []; // Ctrl-C: cancel the current line
328
+ } else if (b >= 0x20) {
329
+ lineBytes.push(b); // printable ASCII or a UTF-8 byte
330
+ }
331
+ }
332
+ },
333
+ };
334
+ }
@@ -0,0 +1,101 @@
1
+ // tabs/strip.ts — the desktop tab strip's chrome vocabulary: the shared SVG
2
+ // glyphs, the one chip-content builder every chip site reuses, the strip tab
3
+ // template, the shared control markup factories, the status-dot painter, and
4
+ // the required-descendant picker. Everything here is closure-free markup +
5
+ // tiny pure helpers; element construction with event wiring stays in index.ts
6
+ // (it closes over the feature state). The mobile chrome templates live in
7
+ // switcher.ts; the session model in model.ts.
8
+
9
+ // The +/x/keyboard glyphs are inline SVG (not font glyphs) so they center
10
+ // exactly in their flex-centered buttons and stay symmetric regardless of the UI
11
+ // font's metrics. Each is defined ONCE here and shared by every chip site and
12
+ // control (rather than duplicated across the desktop and mobile markup).
13
+ const CLOSE_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6 18 18M18 6 6 18"/></svg>`;
14
+ const NEW_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`;
15
+ 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>`;
16
+ // Two-overlapping-windows glyph for the mobile switcher's dedicated open/close
17
+ // button: the browser-style "tab switcher" icon, more recognisable than the
18
+ // prior swap-arrows (which read like a keyboard Tab key). A latest-wins
19
+ // background-tab notification dot rides on it (see switchButtonHTML). Same
20
+ // viewBox + stroke=currentcolor treatment as the others.
21
+ 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>`;
22
+
23
+ // chipContent is the ONE builder for a tab chip's content — a status dot, a
24
+ // label, and a close (x) — shared by all three chip sites: the desktop strip
25
+ // (.wt-tab), the mobile active row (.wt-switcher-current), and each expanded
26
+ // mobile list row (.wt-switcher-row). Each site passes its OWN class set (never
27
+ // renamed) so every existing selector — and thus all CSS and all tests — still
28
+ // matches. The one structural difference is WHERE the close sits: the desktop
29
+ // chip nests it flat inside .wt-tab, while the two mobile chips place it as a
30
+ // sibling of the select/swipe button (a button can't nest in a button). So the
31
+ // builder returns two fragments — the dot+label pair and the close — that each
32
+ // site drops into its own structure.
33
+ export function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
34
+ dotLabel: string;
35
+ close: string;
36
+ } {
37
+ return {
38
+ dotLabel:
39
+ `<span class="${v.dot} wt-status-dot" aria-hidden="true"></span>` +
40
+ `<span class="${v.label}"></span>`,
41
+ close: `<button type="button" class="${v.close}" aria-label="Close terminal"${v.closeAttr ?? ""}>${CLOSE_SVG}</button>`,
42
+ };
43
+ }
44
+
45
+ // The ONE "+" (new-terminal) and keyboard button markup factories, shared by
46
+ // the desktop strip and the mobile switcher — only the class set differs. These
47
+ // build the markup; element construction + event wiring live in makeNewButton /
48
+ // makeKbButton (index.ts), which close over create() and the key-grid toggle.
49
+ export function newButtonHTML(cls: string): string {
50
+ return `<button type="button" class="${cls}" aria-label="New terminal">${NEW_SVG}</button>`;
51
+ }
52
+ export function kbButtonHTML(cls: string): string {
53
+ return `<button type="button" class="${cls}" aria-label="Keyboard keys" aria-expanded="false" hidden>${KB_SVG}</button>`;
54
+ }
55
+ // The mobile switcher's dedicated open/close button. It toggles the tab list and
56
+ // carries a latest-wins notification dot (a child span) — amber when a background
57
+ // terminal needs input, green when a background turn finished — cleared when the
58
+ // list opens. Only the mobile switcher builds it (element construction + event
59
+ // wiring live in makeSwitchButton in index.ts); the desktop strip already shows
60
+ // every tab's own status dot, so it needs no aggregate cue.
61
+ export function switchButtonHTML(cls: string): string {
62
+ return (
63
+ `<button type="button" class="${cls}" aria-label="Open tab switcher">${SWITCH_SVG}` +
64
+ `<span class="wt-status-dot wt-switcher-switch-dot" aria-hidden="true"></span></button>`
65
+ );
66
+ }
67
+
68
+ // Desktop strip chip: dot + label + close all flat inside .wt-tab (the whole
69
+ // chip is the click/switch target; the close is a nested button). tabindex="-1"
70
+ // keeps the close out of the tab order.
71
+ const TAB_CHIP = chipContent({
72
+ dot: "wt-tab-dot",
73
+ label: "wt-tab-label",
74
+ close: "wt-tab-close",
75
+ closeAttr: ' tabindex="-1"',
76
+ });
77
+ export const TAB_HTML = `
78
+ <div class="wt-tab">
79
+ ${TAB_CHIP.dotLabel}
80
+ ${TAB_CHIP.close}
81
+ </div>`;
82
+
83
+ /** paintStatusDot applies a status dot's two orthogonal bits: data-status drives
84
+ * its appearance (idle / working / done / input / exited via CSS), and the
85
+ * .wt-reports class controls its visibility — the dot is hidden by default and
86
+ * shown only once the session has reported activity (OSC 9;4 progress or a
87
+ * classified OSC 9 notification), so a plain shell's tabs stay clean and
88
+ * label-only while an agent's light up. */
89
+ export function paintStatusDot(el: HTMLElement, status: string, reports: boolean): void {
90
+ el.dataset["status"] = status || "idle";
91
+ el.classList.toggle("wt-reports", reports);
92
+ }
93
+
94
+ /** pick returns a required descendant element or throws (static chrome only). */
95
+ export function pick(root: ParentNode, selector: string): HTMLElement {
96
+ const el = root.querySelector<HTMLElement>(selector);
97
+ if (!el) {
98
+ throw new Error(`web-terminal-ui: tabs chrome missing ${selector}`);
99
+ }
100
+ return el;
101
+ }
@@ -0,0 +1,129 @@
1
+ // tabs/switcher.ts — the mobile bottom-switcher's chrome vocabulary: its
2
+ // templates, the swipe/flick gesture constants, and the closure-free row
3
+ // enter/leave animations. The gesture STATE MACHINE (axis lock, live preview,
4
+ // the release reel) stays in index.ts: it mutates drag state shared with the
5
+ // switch/expand paths, so extracting it would spread one interaction across two
6
+ // files. The desktop chrome vocabulary lives in strip.ts; the session model in
7
+ // model.ts.
8
+
9
+ import { chipContent } from "./strip.js";
10
+
11
+ // Swipe recognition on the mobile switcher bar: a mostly-horizontal drag past
12
+ // this distance switches; a near-stationary release is a tap (opens overview).
13
+ export const SWIPE_MIN_PX = 40;
14
+ // Movement (px) before an in-progress bar drag commits to a horizontal
15
+ // (tab-switch preview) or vertical (expand/collapse) axis.
16
+ export const AXIS_LOCK_PX = 8;
17
+ // Flick-to-commit thresholds (from @use-gesture's drag defaults, MIT): a release
18
+ // counts as a flick when the gesture was quick, fast, and travelled enough, so a
19
+ // short fast swipe commits the switch/expand even below the halfway distance.
20
+ export const SWIPE_VELOCITY = 0.5; // px/ms at release
21
+ export const SWIPE_DURATION = 250; // ms, whole-gesture cap for a flick
22
+ // If the finger paused longer than this before lifting, the release velocity is
23
+ // stale (pointerup usually repeats the last pointermove position -> reads ~0),
24
+ // so ignore it and commit by distance instead (@use-gesture issue #332).
25
+ export const VELOCITY_STALE_MS = 32;
26
+ // Live drag preview: while an open list is being swiped, it peeks in the swipe
27
+ // direction by this fraction of the finger's horizontal travel, capped at this
28
+ // many pixels — a hint of the coming rotation, not the full shift (the incoming
29
+ // row only appears on release, so a large move looked wrong). The release reel
30
+ // continues from wherever the peek left the rows.
31
+ export const PREVIEW_DRAG_RATIO = 0.1;
32
+ export const PREVIEW_PEEK_MAX = 10;
33
+
34
+ // Mobile bottom bar. One element, two parts stacked in a column: the always-
35
+ // visible bar row (active tab as a tap/swipe surface + keyboard + "+") on top,
36
+ // and a list of the OTHER tabs BELOW it. On swipe-up / tap the whole bar slides
37
+ // up and the list fills in beneath it (down to the safe area); swipe-down /
38
+ // tap collapses it back to the bottom. Selecting a listed tab swaps it into the
39
+ // active row. This replaces the old separate modal overview sheet ("one element"
40
+ // per the user): the bar itself lifts rather than opening a distinct surface.
41
+ // The bar is the FIRST child and the list the SECOND: the switcher is bottom-
42
+ // anchored, so a column with the list last grows the container upward, lifting
43
+ // the bar and revealing the list below it (DOM order = visual order top-to-bottom).
44
+ // - .wt-switcher-current-wrap: the active-tab row — a select/swipe surface
45
+ // (.wt-switcher-current with dot + label) plus a close (x) overlaid at the
46
+ // right, mirroring the listed rows. No "n / m" counter: the list below is a
47
+ // rotating circular queue, so an absolute position number is meaningless.
48
+ // - The keyboard button (.wt-switcher-kb, opens the key grid above the bar,
49
+ // only wired + shown when a keyboardToggle feature is provided) and the
50
+ // accent "+" (.wt-switcher-new, spawns a terminal) are appended to the bar
51
+ // row from the shared factories in index.ts (see makeKbButton / makeNewButton).
52
+ const CURRENT_CHIP = chipContent({
53
+ dot: "wt-switcher-dot",
54
+ label: "wt-switcher-label",
55
+ close: "wt-switcher-current-close wt-btn",
56
+ });
57
+ export const SWITCHER_HTML = `
58
+ <div class="wt-switcher" role="group" aria-label="Terminal tabs">
59
+ <div class="wt-switcher-bar">
60
+ <div class="wt-switcher-current-wrap">
61
+ <button type="button" class="wt-switcher-current" aria-haspopup="true" aria-expanded="false">
62
+ <span class="wt-switcher-current-inner">${CURRENT_CHIP.dotLabel}</span>
63
+ </button>
64
+ ${CURRENT_CHIP.close}
65
+ </div>
66
+ </div>
67
+ <ul class="wt-switcher-list" role="list"></ul>
68
+ </div>`;
69
+
70
+ // One row per OTHER tab in the expanded list: a stretched select target (dot +
71
+ // label) with the close (x) laid inside it at the right (two buttons can't nest,
72
+ // so the x is a sibling overlapping the select's reserved right padding).
73
+ const ROW_CHIP = chipContent({
74
+ dot: "wt-switcher-row-dot",
75
+ label: "wt-switcher-row-label",
76
+ close: "wt-switcher-row-close wt-btn",
77
+ });
78
+ export const SWITCHER_ROW_HTML = `
79
+ <li class="wt-switcher-row">
80
+ <button type="button" class="wt-switcher-row-select">${ROW_CHIP.dotLabel}</button>
81
+ ${ROW_CHIP.close}
82
+ </li>`;
83
+
84
+ // animateRowIn / animateRowOut give a listed tab an enter / leave motion:
85
+ // the row's own max-height grows from 0 (fading in) on add, and collapses
86
+ // to 0 (fading out, then removed) on close. The flex list's height follows
87
+ // the row, so adding/closing a tab animates the tray height rather than
88
+ // snapping. Inline-driven (cleared when done); the caller gates motion.
89
+ const ROW_ANIM_MS = 220;
90
+ const ROW_ANIM_EASE = "cubic-bezier(0.2, 0, 0, 1)";
91
+
92
+ export function animateRowIn(row: HTMLElement): void {
93
+ const h = row.getBoundingClientRect().height;
94
+ if (h <= 0) {
95
+ return;
96
+ }
97
+ row.style.overflow = "hidden";
98
+ row.style.transition = "none";
99
+ row.style.maxHeight = "0";
100
+ row.style.opacity = "0";
101
+ requestAnimationFrame(() => {
102
+ row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
103
+ row.style.maxHeight = `${String(Math.ceil(h))}px`;
104
+ row.style.opacity = "1";
105
+ });
106
+ window.setTimeout(() => {
107
+ row.style.transition = "";
108
+ row.style.maxHeight = "";
109
+ row.style.opacity = "";
110
+ row.style.overflow = "";
111
+ }, ROW_ANIM_MS + 60);
112
+ }
113
+
114
+ export function animateRowOut(row: HTMLElement): void {
115
+ const h = row.getBoundingClientRect().height;
116
+ row.style.overflow = "hidden";
117
+ row.style.pointerEvents = "none";
118
+ row.style.transition = "none";
119
+ row.style.maxHeight = `${String(Math.ceil(h))}px`;
120
+ row.style.opacity = "1";
121
+ requestAnimationFrame(() => {
122
+ row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
123
+ row.style.maxHeight = "0";
124
+ row.style.opacity = "0";
125
+ });
126
+ window.setTimeout(() => {
127
+ row.remove();
128
+ }, ROW_ANIM_MS + 60);
129
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export type {
17
17
  TerminalHandle,
18
18
  TerminalFeature,
19
19
  FeatureInstance,
20
+ SessionOwnerRegistration,
20
21
  TerminalContext,
21
22
  TerminalEvents,
22
23
  RegionName,
@@ -25,6 +25,11 @@ export interface Announcer {
25
25
  destroy(): void;
26
26
  }
27
27
 
28
+ /** Delay before a cleared live region is re-set with the new message (ms).
29
+ * Long enough for assistive tech to register the clear and the set as two
30
+ * distinct mutations (a sub-frame gap is not). */
31
+ const REANNOUNCE_DELAY_MS = 100;
32
+
28
33
  /** Build the single pair of live regions inside root. */
29
34
  export function createAnnouncer(root: HTMLElement): Announcer {
30
35
  const polite = document.createElement("div");
@@ -37,17 +42,37 @@ export function createAnnouncer(root: HTMLElement): Announcer {
37
42
  hideVisually(assertive);
38
43
  root.append(polite, assertive);
39
44
 
45
+ // Pending re-set timers, one per region, so a rapid follow-up announcement
46
+ // replaces the pending one instead of interleaving with it.
47
+ const timers = new Map<HTMLElement, ReturnType<typeof setTimeout>>();
48
+
40
49
  function announce(message: string, politeness: "polite" | "assertive" = "polite"): void {
41
50
  const el = politeness === "assertive" ? assertive : polite;
42
- // Clear then set on the next frame so a repeat of the same message is
43
- // re-announced (screen readers ignore an unchanged live-region value).
51
+ // Clear then re-set after a short (~100ms) TIMER so a repeat of the same
52
+ // message is re-announced: screen readers ignore an unchanged live-region
53
+ // value, and a sub-frame gap (the previous requestAnimationFrame
54
+ // approach, ~16ms) is too fast for some assistive tech to register two
55
+ // distinct mutations — the same rationale ui-primitives' announce()
56
+ // documents for its shared region.
44
57
  el.textContent = "";
45
- requestAnimationFrame(() => {
46
- el.textContent = message;
47
- });
58
+ const pending = timers.get(el);
59
+ if (pending !== undefined) {
60
+ clearTimeout(pending);
61
+ }
62
+ timers.set(
63
+ el,
64
+ setTimeout(() => {
65
+ timers.delete(el);
66
+ el.textContent = message;
67
+ }, REANNOUNCE_DELAY_MS),
68
+ );
48
69
  }
49
70
 
50
71
  function destroy(): void {
72
+ for (const t of timers.values()) {
73
+ clearTimeout(t);
74
+ }
75
+ timers.clear();
51
76
  polite.remove();
52
77
  assertive.remove();
53
78
  }