@cplieger/web-terminal-ui 4.6.0 → 4.7.1

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.
@@ -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
- // input-derived-title state machine. No DOM, no chrome, no kernel context —
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 raw server title (the OSC 0/2 window title the process set), possibly
41
- * empty before the process sets one. The displayed label is derived from it
42
- * with a numbered fallback and de-duplication (see relabelAll in index.ts). */
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
- /** A fallback title derived from the LAST non-empty line the user submitted
60
- * into this tab (updated on every Enter). Used only when the process sets no
61
- * window title of its own: the OSC 0/2 title (`title` above) takes precedence
62
- * when present and keeps updating, and this is what the tab reads as when it
63
- * does not — a plain shell with no PROMPT_COMMAND title, or kiro-cli with its
64
- * cwd-only title disabled. Undefined until the first non-empty submission; see
65
- * baseLabel. */
66
- derived?: string;
67
- /** The persisted client title reported by the server (its `clientTitle` wire
68
- * field): the last derived title pushed via PUT .../title, surviving reloads.
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. Preference order: the
76
- * process window title (OSC 0/2, e.g. a shell's PROMPT_COMMAND title), which
77
- * takes precedence whenever the program sets one and keeps updating as it
78
- * changes (the status SSE re-pushes it); then a fallback derived from the last
79
- * line the user submitted, for a program that sets no title (a bare shell, or
80
- * kiro-cli with its cwd-only title disabled); then a plain "New tab".
81
- * fallback=true marks that last case so relabelAll leaves untitled tabs as
82
- * "New tab" with no numeric suffix.
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
- * preferInputTitle (agent shell with an unreliable OSC title): show the live
85
- * submitted line, then the persisted client title on reload — the process OSC
86
- * `title` is ignored. Default: OSC title first, then the live derived line,
87
- * then the persisted client title (the latter two are the "latest user
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
- /** Best-effort and fire-and-forget: a failure never disrupts the terminal —
110
- * the locally-derived title still displays, it just is not persisted. */
111
- setTitle(id: string, title: string): Promise<void>;
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 new Error(`web-terminal-ui: session list failed (${String(r.status)})`);
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 new Error(`web-terminal-ui: session create failed (${String(r.status)})`);
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 new Error(`web-terminal-ui: session close failed (${String(r.status)})`);
348
+ throw await sessionError("close", r);
154
349
  }
155
350
  },
156
- // Persist the input-derived tab title server-side so it survives a page
157
- // reload and shows on other devices. The engine stores it as the session's
158
- // fallback title and uses it only when the program emits no OSC title.
159
- async setTitle(id: string, title: string): Promise<void> {
160
- try {
161
- await fetch(`${apiBase}/${encodeURIComponent(id)}/title`, {
162
- method: "PUT",
163
- headers: { "Content-Type": "application/json" },
164
- body: JSON.stringify({ title }),
165
- signal: AbortSignal.timeout(SESSION_API_TIMEOUT_MS),
166
- });
167
- } catch {
168
- /* best-effort persistence; ignore network/timeout errors */
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
- // A generous storage bound for the fallback (input-derived) title. The tab
217
- // label truncates it visually with max-width + ellipsis in EVERY view — the
218
- // desktop strip, the mobile active bar, and the mobile list — so this is only a
219
- // guard against storing a huge paste, not a display cut.
220
- const MAX_DERIVED = 512;
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
- /** The input-derived-title state machine: a tiny line editor over the kernel's
223
- * accepted outbound bytes. It tracks the current input line (handling
224
- * backspace and skipping the escape sequences arrow keys and bracketed paste
225
- * emit) and reports every non-empty submitted line through `submit`. The
226
- * caller owns what a submission means (update the active tab's derived title,
227
- * persist it, refresh chrome). Best-effort: an odd editing sequence just
228
- * yields no derived title. */
229
- export interface InputTitleDeriver {
230
- /** Feed one accepted outbound byte chunk (a ctx.registerInputObserver hook). */
231
- observe(bytes: Uint8Array): void;
232
- /** Drop the partial line (a tab switch: a line typed in the old tab must not
233
- * carry over). */
234
- reset(): void;
235
- }
236
-
237
- export function createInputTitleDeriver(submit: (line: string) => void): InputTitleDeriver {
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
  }
@@ -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");