@cplieger/web-terminal-ui 4.6.0 → 4.7.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 +14 -6
- package/css/02-terminal.css +9 -4
- package/css/30-tabs.css +90 -0
- package/css/31-switcher.css +9 -0
- package/package.json +7 -4
- package/src/features/tabs/index.ts +678 -98
- package/src/features/tabs/model.ts +284 -182
- package/src/kernel/a11y.ts +16 -0
- package/src/kernel/kernel.ts +119 -47
- package/src/kernel/types.ts +41 -11
- package/src/presets/agent-tabbed.ts +4 -6
- package/src/presets/tabbed.ts +8 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// tabs/model.ts — the session MODEL half of the tabs feature: the wire type,
|
|
2
2
|
// the per-tab record, the session REST client, the close-tombstone set, and the
|
|
3
|
-
//
|
|
3
|
+
// pinned-name helpers. No DOM, no chrome, no kernel context —
|
|
4
4
|
// everything here is factory/pure and unit-testable in isolation. The chrome
|
|
5
5
|
// halves are strip.ts (desktop) and switcher.ts (mobile); index.ts wires all
|
|
6
6
|
// three over the kernel context.
|
|
@@ -27,6 +27,97 @@ export const ACTIVE_TAB_KEY = "wt-active-session";
|
|
|
27
27
|
// One-time "swipe to switch" hint, remembered across loads.
|
|
28
28
|
export const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
|
|
29
29
|
|
|
30
|
+
/** localStorage key for the user's tab ARRANGEMENT: the strip's session ids in
|
|
31
|
+
* the order they are shown, so a drag-reorder (or a Move left/right) survives a
|
|
32
|
+
* reload instead of snapping back to the server's creation order.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately client-side, alongside ACTIVE_TAB_KEY: the arrangement is a
|
|
35
|
+
* per-viewer preference, not a property of the session. Keeping it here costs
|
|
36
|
+
* no engine API — the session REST surface is a fixed four routes, and adding a
|
|
37
|
+
* fifth for cosmetics would be a release-noted change to every consumer. The
|
|
38
|
+
* tradeoff is that two devices do not share an arrangement (each keeps its own),
|
|
39
|
+
* and that when several windows of the same browser are open the last order
|
|
40
|
+
* change written wins. */
|
|
41
|
+
export const TAB_ORDER_KEY = "wt-tab-order";
|
|
42
|
+
|
|
43
|
+
/** Bound on the persisted arrangement. Every id is a live PTY server-side, so a
|
|
44
|
+
* real strip is nowhere near this; the cap is there so a corrupted or hostile
|
|
45
|
+
* stored value cannot make the restore path do unbounded work. */
|
|
46
|
+
export const MAX_PERSISTED_TAB_ORDER = 200;
|
|
47
|
+
|
|
48
|
+
/** parseTabOrder reads a stored arrangement into a clean id list. Anything the
|
|
49
|
+
* restore path cannot trust yields [] (or is dropped), because falling back to
|
|
50
|
+
* the server's creation order is always a valid arrangement: a non-JSON or
|
|
51
|
+
* non-array value, a non-string or empty entry, and a duplicate id (which would
|
|
52
|
+
* make the insert position ambiguous) are all discarded rather than repaired.
|
|
53
|
+
* Pure, so it is testable without a storage backend; the caller owns the
|
|
54
|
+
* localStorage read and its try/catch. */
|
|
55
|
+
export function parseTabOrder(raw: string | null): string[] {
|
|
56
|
+
if (raw === null || raw === "") {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
let data: unknown;
|
|
60
|
+
try {
|
|
61
|
+
data = JSON.parse(raw);
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(data)) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const out: string[] = [];
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
for (const entry of data) {
|
|
71
|
+
if (typeof entry !== "string" || entry === "" || seen.has(entry)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
seen.add(entry);
|
|
75
|
+
out.push(entry);
|
|
76
|
+
if (out.length >= MAX_PERSISTED_TAB_ORDER) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** serializeTabOrder encodes an arrangement for storage, capped the same way the
|
|
84
|
+
* read is so a write can never produce a value its own parser would truncate
|
|
85
|
+
* differently. */
|
|
86
|
+
export function serializeTabOrder(ids: readonly string[]): string {
|
|
87
|
+
return JSON.stringify(ids.slice(0, MAX_PERSISTED_TAB_ORDER));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** orderedInsertIndex returns where `id` belongs in `current` (a list of tab ids
|
|
91
|
+
* in display order) to honour the stored arrangement `saved`.
|
|
92
|
+
*
|
|
93
|
+
* This is what makes the restore independent of the order tabs ARRIVE in, which
|
|
94
|
+
* matters because they arrive from two racing sources: the status stream pushes
|
|
95
|
+
* a snapshot of the existing sessions on open, and the bootstrap's
|
|
96
|
+
* GET /api/sessions lands separately — whichever wins, each session is placed
|
|
97
|
+
* by the saved arrangement rather than by arrival.
|
|
98
|
+
*
|
|
99
|
+
* An id absent from `saved` goes last: it is a session created after the
|
|
100
|
+
* arrangement was stored (here or in another browser), and a new tab belongs at
|
|
101
|
+
* the end. Ids in `current` that are absent from `saved` are transparent to the
|
|
102
|
+
* scan, so a saved tab can still slot ahead of them. */
|
|
103
|
+
export function orderedInsertIndex(
|
|
104
|
+
current: readonly string[],
|
|
105
|
+
saved: readonly string[],
|
|
106
|
+
id: string,
|
|
107
|
+
): number {
|
|
108
|
+
const rank = saved.indexOf(id);
|
|
109
|
+
if (rank < 0) {
|
|
110
|
+
return current.length;
|
|
111
|
+
}
|
|
112
|
+
for (let i = 0; i < current.length; i++) {
|
|
113
|
+
const other = saved.indexOf(current[i] ?? "");
|
|
114
|
+
if (other > rank) {
|
|
115
|
+
return i;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return current.length;
|
|
119
|
+
}
|
|
120
|
+
|
|
30
121
|
export interface Tab {
|
|
31
122
|
id: string;
|
|
32
123
|
/** Local mutation epoch at which this tab was adopted (a monotonic counter,
|
|
@@ -37,9 +128,12 @@ export interface Tab {
|
|
|
37
128
|
* listing, and dropping it would cascade into a duplicate replacement
|
|
38
129
|
* session (the boot double-create bug). */
|
|
39
130
|
born: number;
|
|
40
|
-
/** The
|
|
41
|
-
*
|
|
42
|
-
*
|
|
131
|
+
/** The title the SERVER resolved for this session: its pinned name, else the
|
|
132
|
+
* input-derived name, else the program's OSC 0/2 window title, else a
|
|
133
|
+
* client-pushed label, else the engine's own foreground-process/cwd inference.
|
|
134
|
+
* Possibly empty only against an engine that has none of those. The displayed
|
|
135
|
+
* label adds a numbered fallback and de-duplication (see relabelAll in
|
|
136
|
+
* index.ts). */
|
|
43
137
|
title: string;
|
|
44
138
|
/** The computed, de-duplicated label actually shown in the chrome. */
|
|
45
139
|
display: string;
|
|
@@ -56,46 +150,50 @@ export interface Tab {
|
|
|
56
150
|
* activity (a plain shell) keeps a clean, dot-less tab. Fed from the server's
|
|
57
151
|
* reportsActivity via applyStatus. */
|
|
58
152
|
reports: boolean;
|
|
59
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
*
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
* In `preferInputTitle` mode this is the reload-recovery source (the live
|
|
70
|
-
* `derived` is lost on reload, and `title` is the unreliable OSC value there).
|
|
71
|
-
* In the default mode it is the lowest-priority fallback after title/derived. */
|
|
72
|
-
clientTitle?: string | undefined;
|
|
153
|
+
/** The user's pinned name (the server's `pinnedTitle` wire field). Outranks
|
|
154
|
+
* every automatic source in `baseLabel`, and its presence is what enables the
|
|
155
|
+
* tab menu's "use the automatic name" action. Undefined or empty means the tab
|
|
156
|
+
* has no user-set name. */
|
|
157
|
+
pinnedTitle?: string | undefined;
|
|
158
|
+
/** Monotonic per-tab rename counter. A rename or clear increments it and
|
|
159
|
+
* captures the value; the response is applied only if it is still current when
|
|
160
|
+
* it lands, so a slow failure cannot roll back a newer rename, a later clear,
|
|
161
|
+
* or a status-stream update from another client. */
|
|
162
|
+
nameSeq: number;
|
|
73
163
|
}
|
|
74
164
|
|
|
75
|
-
/** baseLabel is a tab's label before de-duplication
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
165
|
+
/** baseLabel is a tab's label before de-duplication: the user's pinned name if
|
|
166
|
+
* there is one, otherwise the title the SERVER resolved.
|
|
167
|
+
*
|
|
168
|
+
* Only two rungs, because the engine now owns every automatic source and folds
|
|
169
|
+
* them into `title` in precedence order (pinned, input-derived, the program's OSC
|
|
170
|
+
* window title, a client-pushed label, then its own foreground-process/cwd
|
|
171
|
+
* inference). A client that re-implemented that ladder could only disagree with
|
|
172
|
+
* the server and with every other client.
|
|
173
|
+
*
|
|
174
|
+
* The pin is still checked here, even though the server also folds it into
|
|
175
|
+
* `title`, so a rename paints immediately instead of waiting for the round trip.
|
|
83
176
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
* message" fallback when the program sets no OSC title). */
|
|
89
|
-
export function baseLabel(
|
|
90
|
-
tab: Tab,
|
|
91
|
-
preferInputTitle: boolean,
|
|
92
|
-
): { text: string; fallback: boolean } {
|
|
93
|
-
const derived = tab.derived?.trim() ?? "";
|
|
94
|
-
const persisted = tab.clientTitle?.trim() ?? "";
|
|
95
|
-
const real = preferInputTitle ? derived || persisted : tab.title.trim() || derived || persisted;
|
|
177
|
+
* fallback=true marks the "New tab" case so relabelAll leaves untitled tabs
|
|
178
|
+
* unnumbered. */
|
|
179
|
+
export function baseLabel(tab: Tab): { text: string; fallback: boolean } {
|
|
180
|
+
const real = pinnedNameOf(tab) || tab.title.trim();
|
|
96
181
|
return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
|
|
97
182
|
}
|
|
98
183
|
|
|
184
|
+
/** pinnedNameOf normalizes a tab's pin: trimmed, with a whitespace-only value
|
|
185
|
+
* reading as absent. One definition, so baseLabel's precedence and the menu's
|
|
186
|
+
* enabled state can never disagree about whether a tab is pinned. */
|
|
187
|
+
function pinnedNameOf(tab: Tab): string {
|
|
188
|
+
return tab.pinnedTitle?.trim() ?? "";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** hasPinnedName reports whether a tab carries a user-set name, which is what
|
|
192
|
+
* gates the tab menu's "use the automatic name" item. */
|
|
193
|
+
export function hasPinnedName(tab: Tab): boolean {
|
|
194
|
+
return pinnedNameOf(tab) !== "";
|
|
195
|
+
}
|
|
196
|
+
|
|
99
197
|
/** The session REST client (GET/POST/DELETE /api/sessions + PUT .../title),
|
|
100
198
|
* bound to an apiBase. Every call is timeout-bounded: fetch has no default
|
|
101
199
|
* timeout, and a stalled-but-open server would otherwise leave a bootstrap
|
|
@@ -106,13 +204,110 @@ export interface SessionAPI {
|
|
|
106
204
|
list(): Promise<SessionInfo[]>;
|
|
107
205
|
create(): Promise<SessionInfo>;
|
|
108
206
|
close(id: string): Promise<void>;
|
|
109
|
-
/**
|
|
110
|
-
* the
|
|
111
|
-
|
|
207
|
+
/** Set the user's pinned name for a session. UNLIKE setTitle this THROWS on
|
|
208
|
+
* failure: a rename the user typed and that silently did not persist looks
|
|
209
|
+
* correct until the next reload, so the caller must be able to surface it. */
|
|
210
|
+
setPinnedTitle(id: string, title: string): Promise<void>;
|
|
211
|
+
/** Remove a session's pinned name so its label falls back to the automatic
|
|
212
|
+
* sources. Throws on failure, for the same reason as setPinnedTitle. */
|
|
213
|
+
clearPinnedTitle(id: string): Promise<void>;
|
|
112
214
|
}
|
|
113
215
|
|
|
114
216
|
const SESSION_API_TIMEOUT_MS = 15000;
|
|
115
217
|
|
|
218
|
+
/** A non-2xx response from the session API, carrying what the server actually
|
|
219
|
+
* said instead of flattening it into a message string.
|
|
220
|
+
*
|
|
221
|
+
* The motivating case is a host that legitimately and TEMPORARILY refuses
|
|
222
|
+
* session creation: web-terminal-kiro answers 503 with `Retry-After: 5` and a
|
|
223
|
+
* body message while its tool engine installs the manifest's tools on first
|
|
224
|
+
* boot. Callers that only saw an `Error` could not tell that apart from a 500,
|
|
225
|
+
* could not honour the retry hint, and could not repeat the server's
|
|
226
|
+
* explanation — so the page read as broken while the server was deliberately
|
|
227
|
+
* waiting. Everything needed to do better is on this error. */
|
|
228
|
+
export class SessionAPIError extends Error {
|
|
229
|
+
/** HTTP status, so a caller can branch on 503 (retry) vs 429 vs 5xx. */
|
|
230
|
+
readonly status: number;
|
|
231
|
+
/** Retry-After in milliseconds, or undefined when the server sent no usable
|
|
232
|
+
* hint. */
|
|
233
|
+
readonly retryAfterMs: number | undefined;
|
|
234
|
+
/** The human-readable message from the error envelope, when the server sent
|
|
235
|
+
* one (webhttp's `error` field, or `message` elsewhere), so the host's own
|
|
236
|
+
* explanation can reach the user verbatim. Length-capped because it is
|
|
237
|
+
* server-controlled text destined for UI chrome. */
|
|
238
|
+
readonly serverMessage: string | undefined;
|
|
239
|
+
|
|
240
|
+
constructor(operation: string, status: number, retryAfterMs?: number, serverMessage?: string) {
|
|
241
|
+
super(`web-terminal-ui: session ${operation} failed (${String(status)})`);
|
|
242
|
+
this.name = "SessionAPIError";
|
|
243
|
+
this.status = status;
|
|
244
|
+
this.retryAfterMs = retryAfterMs;
|
|
245
|
+
this.serverMessage = serverMessage;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const RETRY_AFTER_MAX_MS = 60000;
|
|
250
|
+
const SERVER_MESSAGE_MAX_CHARS = 120;
|
|
251
|
+
|
|
252
|
+
/** Parse Retry-After (RFC 9110): delta-seconds, or an HTTP-date. Undefined for a
|
|
253
|
+
* missing or unparseable value. Clamped so a buggy or hostile header cannot
|
|
254
|
+
* park the UI for hours, and floored at 0 so a date already in the past retries
|
|
255
|
+
* immediately rather than never. */
|
|
256
|
+
function parseRetryAfter(header: string | null): number | undefined {
|
|
257
|
+
if (header === null) {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
const raw = header.trim();
|
|
261
|
+
if (raw === "") {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
if (/^\d+$/.test(raw)) {
|
|
265
|
+
return Math.min(Number(raw) * 1000, RETRY_AFTER_MAX_MS);
|
|
266
|
+
}
|
|
267
|
+
const when = Date.parse(raw);
|
|
268
|
+
if (Number.isNaN(when)) {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
return Math.min(Math.max(when - Date.now(), 0), RETRY_AFTER_MAX_MS);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Pull the error envelope's human-readable message out of a failed response,
|
|
275
|
+
* without letting a non-JSON body, a hostile payload, or a slow read break the
|
|
276
|
+
* caller: the status and retry hint matter more than the prose, so every failure
|
|
277
|
+
* here is simply "no server message".
|
|
278
|
+
*
|
|
279
|
+
* Two field names are accepted. `error` is the field the first-party Go envelope
|
|
280
|
+
* writes (webhttp's ErrorResponse, `json:"error"`), which is what every server in
|
|
281
|
+
* this family returns; `message` is the common alternative elsewhere. Preferring
|
|
282
|
+
* `error` keeps the family's own hosts authoritative. */
|
|
283
|
+
async function readServerMessage(r: Response): Promise<string | undefined> {
|
|
284
|
+
try {
|
|
285
|
+
const body: unknown = await r.json();
|
|
286
|
+
if (typeof body !== "object" || body === null) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
const fields = body as { error?: unknown; message?: unknown };
|
|
290
|
+
for (const candidate of [fields.error, fields.message]) {
|
|
291
|
+
if (typeof candidate === "string" && candidate.trim() !== "") {
|
|
292
|
+
return candidate.trim().slice(0, SERVER_MESSAGE_MAX_CHARS);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return undefined;
|
|
296
|
+
} catch {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Build the error for a failed session-API response. */
|
|
302
|
+
async function sessionError(operation: string, r: Response): Promise<SessionAPIError> {
|
|
303
|
+
return new SessionAPIError(
|
|
304
|
+
operation,
|
|
305
|
+
r.status,
|
|
306
|
+
parseRetryAfter(r.headers.get("Retry-After")),
|
|
307
|
+
await readServerMessage(r),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
116
311
|
export function createSessionAPI(apiBase: string): SessionAPI {
|
|
117
312
|
return {
|
|
118
313
|
async list(): Promise<SessionInfo[]> {
|
|
@@ -121,7 +316,7 @@ export function createSessionAPI(apiBase: string): SessionAPI {
|
|
|
121
316
|
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
122
317
|
});
|
|
123
318
|
if (!r.ok) {
|
|
124
|
-
throw
|
|
319
|
+
throw await sessionError("list", r);
|
|
125
320
|
}
|
|
126
321
|
const data: unknown = await r.json();
|
|
127
322
|
// A 200 with a non-array body -- a proxy error object, or a Go server
|
|
@@ -140,7 +335,7 @@ export function createSessionAPI(apiBase: string): SessionAPI {
|
|
|
140
335
|
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
141
336
|
});
|
|
142
337
|
if (!r.ok) {
|
|
143
|
-
throw
|
|
338
|
+
throw await sessionError("create", r);
|
|
144
339
|
}
|
|
145
340
|
return (await r.json()) as SessionInfo;
|
|
146
341
|
},
|
|
@@ -150,22 +345,29 @@ export function createSessionAPI(apiBase: string): SessionAPI {
|
|
|
150
345
|
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
151
346
|
});
|
|
152
347
|
if (!r.ok) {
|
|
153
|
-
throw
|
|
348
|
+
throw await sessionError("close", r);
|
|
154
349
|
}
|
|
155
350
|
},
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
351
|
+
// The user's pinned name. Not best-effort: the caller shows a failure and
|
|
352
|
+
// rolls the optimistic label back, so both of these propagate.
|
|
353
|
+
async setPinnedTitle(id: string, title: string): Promise<void> {
|
|
354
|
+
const r = await fetch(`${apiBase}/${encodeURIComponent(id)}/pinned-title`, {
|
|
355
|
+
method: "PUT",
|
|
356
|
+
headers: { "Content-Type": "application/json" },
|
|
357
|
+
body: JSON.stringify({ title }),
|
|
358
|
+
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
359
|
+
});
|
|
360
|
+
if (!r.ok) {
|
|
361
|
+
throw await sessionError("set pinned title", r);
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
async clearPinnedTitle(id: string): Promise<void> {
|
|
365
|
+
const r = await fetch(`${apiBase}/${encodeURIComponent(id)}/pinned-title`, {
|
|
366
|
+
method: "DELETE",
|
|
367
|
+
signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
|
|
368
|
+
});
|
|
369
|
+
if (!r.ok) {
|
|
370
|
+
throw await sessionError("clear pinned title", r);
|
|
169
371
|
}
|
|
170
372
|
},
|
|
171
373
|
};
|
|
@@ -213,130 +415,30 @@ export function createTombstones(ttlMs: number = CLOSE_TOMBSTONE_MS): Tombstones
|
|
|
213
415
|
};
|
|
214
416
|
}
|
|
215
417
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
418
|
+
/** MAX_PINNED_NAME bounds a user-typed tab name, mirroring the engine's own
|
|
419
|
+
* pinned-title cap so the field cannot accept text the server would silently
|
|
420
|
+
* truncate. A hand-typed label past ~40 characters is never visible in a 300px
|
|
421
|
+
* chip; 128 is generous. Counted in CODE POINTS, matching the server's runes —
|
|
422
|
+
* a naive `slice` would count UTF-16 code units and could cut a surrogate pair in
|
|
423
|
+
* half, sending a lone surrogate to the server. Neither bound is a count of
|
|
424
|
+
* user-perceived characters (a grapheme cluster is neither, per UAX #29); both
|
|
425
|
+
* are defensive limits, not display promises. */
|
|
426
|
+
export const MAX_PINNED_NAME = 128;
|
|
221
427
|
|
|
222
|
-
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
let lineBytes: number[] = [];
|
|
239
|
-
let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
|
|
240
|
-
let csiParams = ""; // accumulated CSI parameter bytes, to spot paste guards
|
|
241
|
-
let inPaste = false; // inside a bracketed paste (ESC[200~ … ESC[201~)
|
|
242
|
-
return {
|
|
243
|
-
reset(): void {
|
|
244
|
-
lineBytes = [];
|
|
245
|
-
escState = 0;
|
|
246
|
-
csiParams = "";
|
|
247
|
-
inPaste = false;
|
|
248
|
-
},
|
|
249
|
-
observe(bytes: Uint8Array): void {
|
|
250
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
251
|
-
const b = bytes[i];
|
|
252
|
-
if (b === undefined) {
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
if (escState === 1) {
|
|
256
|
-
escState = b === 0x5b ? 2 : b === 0x4f ? 3 : 0; // ESC [ = CSI, ESC O = SS3
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
if (escState === 2) {
|
|
260
|
-
if (b >= 0x40 && b <= 0x7e) {
|
|
261
|
-
// CSI final byte. Recognize the bracketed-paste guards ESC[200~
|
|
262
|
-
// (start) and ESC[201~ (end) so embedded newlines in a pasted,
|
|
263
|
-
// often multi-line, message are not read as line submissions.
|
|
264
|
-
if (b === 0x7e) {
|
|
265
|
-
if (csiParams === "200") {
|
|
266
|
-
inPaste = true;
|
|
267
|
-
} else if (csiParams === "201") {
|
|
268
|
-
inPaste = false;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
csiParams = "";
|
|
272
|
-
escState = 0; // CSI final byte
|
|
273
|
-
} else if (b >= 0x30 && b <= 0x3f) {
|
|
274
|
-
csiParams += String.fromCharCode(b); // parameter byte (digits, ; ? etc.)
|
|
275
|
-
}
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
if (escState === 3) {
|
|
279
|
-
escState = 0; // SS3 final byte
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
if (b === 0x1b) {
|
|
283
|
-
escState = 1; // ESC: start of an escape sequence
|
|
284
|
-
} else if (b === 0x0d || b === 0x0a) {
|
|
285
|
-
// A newline is a line SUBMIT only when it terminates the input. It is
|
|
286
|
-
// folded to a single space (keeping the current line going) when it is
|
|
287
|
-
// instead a paste-internal break, so a multi-line message becomes one
|
|
288
|
-
// logical line whose title reflects its START rather than only its
|
|
289
|
-
// LAST line. Two cases fold:
|
|
290
|
-
// - inside a bracketed paste (ESC[200~ … ESC[201~), and
|
|
291
|
-
// - a newline FOLLOWED by more printable input in this SAME chunk:
|
|
292
|
-
// a human pressing Enter sends the newline as the end of its own
|
|
293
|
-
// input event, whereas a paste (even one sent WITHOUT bracketed-
|
|
294
|
-
// paste guards — e.g. an agent shell like kiro-cli that keeps a
|
|
295
|
-
// pasted multi-line message as one prompt) delivers
|
|
296
|
-
// text + newline + text together, so trailing content marks the
|
|
297
|
-
// newline as a soft break, not a submit. Without this, such a
|
|
298
|
-
// paste left only its last line as the title (the reported
|
|
299
|
-
// "the title cut off the start of my message").
|
|
300
|
-
let softBreak = inPaste;
|
|
301
|
-
if (!softBreak) {
|
|
302
|
-
for (let j = i + 1; j < bytes.length; j++) {
|
|
303
|
-
const nb = bytes[j];
|
|
304
|
-
if (nb !== undefined && nb >= 0x20) {
|
|
305
|
-
softBreak = true;
|
|
306
|
-
break;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
if (softBreak) {
|
|
311
|
-
if (lineBytes.length > 0 && lineBytes[lineBytes.length - 1] !== 0x20) {
|
|
312
|
-
lineBytes.push(0x20);
|
|
313
|
-
}
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
|
|
317
|
-
lineBytes = [];
|
|
318
|
-
// Every non-empty submitted line updates the fallback title (the
|
|
319
|
-
// last one wins); a leading "/" counts, since it is a valid shell
|
|
320
|
-
// command path.
|
|
321
|
-
if (line) {
|
|
322
|
-
submit(line.slice(0, MAX_DERIVED));
|
|
323
|
-
}
|
|
324
|
-
} else if (b === 0x7f || b === 0x08) {
|
|
325
|
-
// Backspace: drop one codepoint (pop UTF-8 continuation bytes + lead).
|
|
326
|
-
while (lineBytes.length > 0) {
|
|
327
|
-
const last = lineBytes[lineBytes.length - 1];
|
|
328
|
-
if (last === undefined || (last & 0xc0) !== 0x80) {
|
|
329
|
-
break;
|
|
330
|
-
}
|
|
331
|
-
lineBytes.pop();
|
|
332
|
-
}
|
|
333
|
-
lineBytes.pop();
|
|
334
|
-
} else if (b === 0x03) {
|
|
335
|
-
lineBytes = []; // Ctrl-C: cancel the current line
|
|
336
|
-
} else if (b >= 0x20) {
|
|
337
|
-
lineBytes.push(b); // printable ASCII or a UTF-8 byte
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
},
|
|
341
|
-
};
|
|
428
|
+
/** sanitizePinnedName cleans a user-typed tab name before it is displayed or
|
|
429
|
+
* sent: control characters and DEL out (they would inject newlines or escape
|
|
430
|
+
* sequences into the label and into logs, CWE-117), trimmed, bounded by code
|
|
431
|
+
* point. Applied client-side rather than trusting the server's round-trip, so
|
|
432
|
+
* the optimistic label matches what the server will store. */
|
|
433
|
+
export function sanitizePinnedName(s: string): string {
|
|
434
|
+
const kept: string[] = [];
|
|
435
|
+
for (const ch of s) {
|
|
436
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
437
|
+
if (code < 0x20 || code === 0x7f) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
kept.push(ch);
|
|
441
|
+
}
|
|
442
|
+
// Trim first, then bound: leading whitespace must not consume the budget.
|
|
443
|
+
return Array.from(kept.join("").trim()).slice(0, MAX_PINNED_NAME).join("");
|
|
342
444
|
}
|
package/src/kernel/a11y.ts
CHANGED
|
@@ -113,6 +113,22 @@ export function createTablist(panel: HTMLElement): TablistController {
|
|
|
113
113
|
setLabel(text: string): void {
|
|
114
114
|
tab.setAttribute("aria-label", text);
|
|
115
115
|
},
|
|
116
|
+
setEditing(editing: boolean, selected: boolean): void {
|
|
117
|
+
if (editing) {
|
|
118
|
+
// Drop the tab semantics while a textbox lives inside the chip, and
|
|
119
|
+
// take the chip out of the roving sequence so Tab moves past it to the
|
|
120
|
+
// field rather than onto a role-less container. aria-controls and the
|
|
121
|
+
// panel's aria-labelledby stay: the panel still describes this session,
|
|
122
|
+
// and clearing them would flap the panel's accessible name mid-edit.
|
|
123
|
+
tab.removeAttribute("role");
|
|
124
|
+
tab.removeAttribute("aria-selected");
|
|
125
|
+
tab.tabIndex = -1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
tab.setAttribute("role", "tab");
|
|
129
|
+
tab.setAttribute("aria-selected", selected ? "true" : "false");
|
|
130
|
+
tab.tabIndex = selected ? 0 : -1;
|
|
131
|
+
},
|
|
116
132
|
remove(): void {
|
|
117
133
|
tab.removeAttribute("role");
|
|
118
134
|
tab.removeAttribute("aria-controls");
|