@cplieger/web-terminal-ui 3.4.5 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -23
- package/css/00-tokens.css +8 -2
- package/css/01-scope.css +56 -0
- package/css/{02-app.css → 02-terminal.css} +64 -239
- package/css/{03-primitives.css → 10-primitives.css} +21 -19
- package/css/20-connection-banner.css +42 -0
- package/css/21-scroll-to-bottom.css +65 -0
- package/css/22-context-menu.css +45 -0
- package/css/23-toolbar.css +181 -0
- package/css/{05-tabs.css → 30-tabs.css} +96 -57
- package/css/{06-mobile.css → 31-switcher.css} +66 -81
- package/css/{07-animations.css → 40-animations.css} +15 -15
- package/css/MANIFEST +19 -11
- package/css/MANIFEST.single +12 -0
- package/css/MANIFEST.tabbed +16 -0
- package/css/MANIFEST.touch +12 -0
- package/css/{01-reset.css → page.css} +18 -19
- package/package.json +21 -5
- package/scaffold/index.html +5 -0
- package/src/features/connection-banner.ts +3 -0
- package/src/features/context-menu.ts +6 -33
- package/src/features/menu-position.ts +50 -0
- package/src/features/{tabs.ts → tabs/index.ts} +207 -636
- package/src/features/tabs/model.ts +334 -0
- package/src/features/tabs/strip.ts +101 -0
- package/src/features/tabs/switcher.ts +129 -0
- package/src/index.ts +1 -0
- package/src/kernel/a11y.ts +30 -5
- package/src/kernel/conn-state.ts +20 -3
- package/src/kernel/kernel.ts +162 -59
- package/src/kernel/types.ts +65 -15
- package/src/presets/agent-tabbed.ts +15 -0
- package/src/presets/single.ts +27 -0
- package/src/presets/tabbed.ts +42 -0
- package/src/presets/touch.ts +13 -0
- package/src/presets.ts +12 -76
- package/src/viewport.ts +28 -15
- package/css/04-feedback.css +0 -180
|
@@ -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
package/src/kernel/a11y.ts
CHANGED
|
@@ -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
|
|
43
|
-
// re-announced
|
|
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
|
-
|
|
46
|
-
|
|
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
|
}
|
package/src/kernel/conn-state.ts
CHANGED
|
@@ -33,6 +33,11 @@ export interface ConnStateMachine {
|
|
|
33
33
|
closed(): void;
|
|
34
34
|
/** Server restarted (epoch mismatch); recent input may be lost. */
|
|
35
35
|
restarted(): void;
|
|
36
|
+
/** The active session's process exited (the engine's definitive 4001
|
|
37
|
+
* close). Unlike closed(), this is not a failure to escalate or retry:
|
|
38
|
+
* the state shows immediately and persists until open() (a switch to a
|
|
39
|
+
* live session) replaces it. */
|
|
40
|
+
ended(): void;
|
|
36
41
|
/** First screen frame rendered: the loading overlay is done, so the banner
|
|
37
42
|
* may show reconnect state from here on. */
|
|
38
43
|
setLoaded(): void;
|
|
@@ -54,9 +59,14 @@ export function createConnState(opts: {
|
|
|
54
59
|
|
|
55
60
|
function emit(): void {
|
|
56
61
|
// Suppress transient states until the initial load is over (the loading
|
|
57
|
-
// overlay owns the screen),
|
|
58
|
-
// is reached
|
|
59
|
-
|
|
62
|
+
// overlay owns the screen), with two exceptions that must show through:
|
|
63
|
+
// "offline" once the initial-failure limit is reached, and "ended" (a
|
|
64
|
+
// definitive process exit — nothing further will ever fire, so suppressing
|
|
65
|
+
// it would hide the only explanation the page will get). connectionBanner
|
|
66
|
+
// renders "open" as hidden.
|
|
67
|
+
const passesLoadingGate =
|
|
68
|
+
state === "ended" || (state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT);
|
|
69
|
+
if (!loaded && !passesLoadingGate) {
|
|
60
70
|
opts.onState("open"); // nothing to show yet
|
|
61
71
|
return;
|
|
62
72
|
}
|
|
@@ -113,6 +123,13 @@ export function createConnState(opts: {
|
|
|
113
123
|
}
|
|
114
124
|
}, RESTARTED_CLEAR_MS);
|
|
115
125
|
},
|
|
126
|
+
ended(): void {
|
|
127
|
+
// A definitive end, not a failure: reset the failure streak so a later
|
|
128
|
+
// reconnect to a LIVE session starts its escalation ladder from zero,
|
|
129
|
+
// and show the state immediately (no grace delay — nothing is retrying).
|
|
130
|
+
consecutiveFailures = 0;
|
|
131
|
+
setState("ended", 0);
|
|
132
|
+
},
|
|
116
133
|
setLoaded(): void {
|
|
117
134
|
loaded = true;
|
|
118
135
|
},
|