@cplieger/web-terminal-ui 3.0.1 → 3.0.3
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/css/03-v3.css +72 -33
- package/package.json +1 -1
- package/src/features/tabs.ts +165 -13
package/css/03-v3.css
CHANGED
|
@@ -204,6 +204,7 @@
|
|
|
204
204
|
left: 0;
|
|
205
205
|
right: 0;
|
|
206
206
|
display: flex;
|
|
207
|
+
align-items: center;
|
|
207
208
|
gap: 2px;
|
|
208
209
|
padding: var(--sp-1);
|
|
209
210
|
background: var(--surface);
|
|
@@ -233,40 +234,51 @@
|
|
|
233
234
|
}
|
|
234
235
|
|
|
235
236
|
.wt-tab-label {
|
|
236
|
-
max-width:
|
|
237
|
+
max-width: 20ch;
|
|
237
238
|
overflow: hidden;
|
|
238
239
|
text-overflow: ellipsis;
|
|
239
240
|
}
|
|
240
241
|
|
|
241
|
-
|
|
242
|
+
/* The tab close (×) and the add-tab (+) button share one look: a centered,
|
|
243
|
+
muted square glyph button that shows a box on hover like an active tab. Same
|
|
244
|
+
size and color so they read as a matched pair (and the + centers vertically
|
|
245
|
+
in the strip via the shared flex box + the bar's align-items: center). */
|
|
246
|
+
.wt-tab-close,
|
|
247
|
+
.wt-tab-new {
|
|
248
|
+
display: inline-flex;
|
|
249
|
+
align-items: center;
|
|
250
|
+
justify-content: center;
|
|
251
|
+
width: 1.375rem;
|
|
252
|
+
height: 1.375rem;
|
|
253
|
+
padding: 0;
|
|
242
254
|
background: transparent;
|
|
243
255
|
border: none;
|
|
244
|
-
color: inherit;
|
|
245
|
-
cursor: pointer;
|
|
246
|
-
padding: 0 var(--sp-1);
|
|
247
256
|
border-radius: var(--radius-inner);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
opacity: 1;
|
|
257
|
+
color: color-mix(in oklch, var(--text) 60%, transparent);
|
|
258
|
+
font-size: 1rem;
|
|
259
|
+
line-height: 1;
|
|
260
|
+
cursor: pointer;
|
|
253
261
|
}
|
|
254
262
|
|
|
255
|
-
.wt-tab-
|
|
256
|
-
|
|
257
|
-
|
|
263
|
+
.wt-tab-close:hover,
|
|
264
|
+
.wt-tab-new:hover {
|
|
265
|
+
background: var(--text-active);
|
|
258
266
|
color: var(--text);
|
|
259
|
-
cursor: pointer;
|
|
260
|
-
font-size: 1.1rem;
|
|
261
|
-
padding: 0 var(--sp-2);
|
|
262
|
-
border-radius: var(--radius-inner);
|
|
263
267
|
}
|
|
264
268
|
|
|
265
|
-
/*
|
|
266
|
-
rows. The
|
|
267
|
-
|
|
268
|
-
:
|
|
269
|
-
|
|
269
|
+
/* Push the terminal surface below the fixed tab strip so it does not overlap
|
|
270
|
+
the first rows. The tabs feature marks the surface (.wt-with-topbar) and keeps
|
|
271
|
+
the measured strip height in --wt-tabbar-h. The surface is position:fixed
|
|
272
|
+
inset:0, so a top offset is what clears it. Desktop only: the strip is hidden
|
|
273
|
+
on a coarse pointer, where the switcher docks to the bottom instead, so the
|
|
274
|
+
surface stays full-height there. (The prior `.wt-tab-bar ~ .term` /
|
|
275
|
+
`:has(> .wt-tab-bar) .term` rule never matched: the bar lives inside a
|
|
276
|
+
display:contents region, so it is neither a DOM sibling nor an ancestor of
|
|
277
|
+
.term.) */
|
|
278
|
+
@media (pointer: fine) {
|
|
279
|
+
.term.wt-with-topbar {
|
|
280
|
+
top: var(--wt-tabbar-h, 40px);
|
|
281
|
+
}
|
|
270
282
|
}
|
|
271
283
|
|
|
272
284
|
/* ===== Activity dots (activityMonitor via tabs) =====
|
|
@@ -278,36 +290,59 @@
|
|
|
278
290
|
height: 9px;
|
|
279
291
|
border-radius: 50%;
|
|
280
292
|
flex: none;
|
|
281
|
-
|
|
293
|
+
/* Default / idle (a new session with no turn yet): a hollow grey dot, so
|
|
294
|
+
"nothing has happened" reads as an outline rather than a filled state.
|
|
295
|
+
The fill (solid vs hollow) is the non-hue cue that separates the idle state
|
|
296
|
+
from the filled done/working/input states (WCAG 1.4.1). */
|
|
297
|
+
background: transparent;
|
|
298
|
+
border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
|
|
282
299
|
}
|
|
283
300
|
|
|
301
|
+
.wt-status-dot[data-status="idle"] {
|
|
302
|
+
background: transparent;
|
|
303
|
+
border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* Agent working: solid pulsing purple, matching Kiro IDE's "thinking" color
|
|
307
|
+
(#a78bfa). Motion is the primary non-hue cue. Server-side this is driven by
|
|
308
|
+
the OSC 9;4 progress signal, so it tracks agent activity, not the user typing
|
|
309
|
+
at the prompt. */
|
|
284
310
|
.wt-status-dot[data-status="working"] {
|
|
285
|
-
background:
|
|
311
|
+
background: #a78bfa;
|
|
312
|
+
border-color: #a78bfa;
|
|
286
313
|
animation: wt-pulse 1.2s ease-in-out infinite;
|
|
287
314
|
}
|
|
288
315
|
|
|
289
|
-
|
|
290
|
-
|
|
316
|
+
/* Agent done / ready: solid green, matching Kiro IDE's "Ready" (#22c55e), no
|
|
317
|
+
motion. The filled dot distinguishes it from the hollow idle without hue. */
|
|
318
|
+
.wt-status-dot[data-status="done"] {
|
|
319
|
+
background: #22c55e;
|
|
320
|
+
border-color: #22c55e;
|
|
291
321
|
}
|
|
292
322
|
|
|
323
|
+
/* User action required (permission / approval): solid amber matching Kiro IDE's
|
|
324
|
+
"waiting-approval" (#fbbf24), plus a ring so it reads as "needs you" via shape
|
|
325
|
+
as well as hue. It persists until you act on or switch into the tab. */
|
|
293
326
|
.wt-status-dot[data-status="input"] {
|
|
294
|
-
background:
|
|
295
|
-
|
|
296
|
-
box-shadow: 0 0 0 2px oklch(
|
|
327
|
+
background: #fbbf24;
|
|
328
|
+
border-color: #fbbf24;
|
|
329
|
+
box-shadow: 0 0 0 2px oklch(85% 0.16 85deg / 40%);
|
|
297
330
|
}
|
|
298
331
|
|
|
332
|
+
/* Exited: a dim hollow dot, static; distinct from idle by its lower opacity. */
|
|
299
333
|
.wt-status-dot[data-status="exited"] {
|
|
300
334
|
background: transparent;
|
|
301
|
-
border:
|
|
335
|
+
border: 1.5px solid color-mix(in oklch, var(--text) 30%, transparent);
|
|
336
|
+
opacity: 0.5;
|
|
302
337
|
}
|
|
303
338
|
|
|
304
|
-
/* Reduced-motion (or animations feature off): drop the pulse, keep the color. */
|
|
305
339
|
@keyframes wt-pulse {
|
|
306
340
|
50% {
|
|
307
341
|
opacity: 0.4;
|
|
308
342
|
}
|
|
309
343
|
}
|
|
310
344
|
|
|
345
|
+
/* Reduced-motion (or the animations feature off): drop the pulse, keep color. */
|
|
311
346
|
@media (prefers-reduced-motion: reduce) {
|
|
312
347
|
.wt-status-dot[data-status="working"] {
|
|
313
348
|
animation: none;
|
|
@@ -326,8 +361,12 @@
|
|
|
326
361
|
display var(--dur-standard) allow-discrete;
|
|
327
362
|
}
|
|
328
363
|
|
|
329
|
-
.wt-animate .wt-tab
|
|
330
|
-
|
|
364
|
+
.wt-animate .wt-tab,
|
|
365
|
+
.wt-animate .wt-tab-close,
|
|
366
|
+
.wt-animate .wt-tab-new {
|
|
367
|
+
transition:
|
|
368
|
+
background-color var(--dur-micro) var(--ease-standard),
|
|
369
|
+
color var(--dur-micro) var(--ease-standard);
|
|
331
370
|
}
|
|
332
371
|
|
|
333
372
|
/* ===== Mobile bottom-switcher (tabs feature, section 12) =====
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
package/src/features/tabs.ts
CHANGED
|
@@ -32,7 +32,16 @@ interface SessionInfo {
|
|
|
32
32
|
|
|
33
33
|
interface Tab {
|
|
34
34
|
id: string;
|
|
35
|
+
/** The raw server title (the OSC 0/2 window title the process set), possibly
|
|
36
|
+
* empty before the process sets one. The displayed label is derived from it
|
|
37
|
+
* with a numbered fallback and de-duplication (see relabelAll). */
|
|
35
38
|
title: string;
|
|
39
|
+
/** A stable per-session number for the "Tab N" fallback when the process has
|
|
40
|
+
* set no title. Monotonic across the page so a tab's number never changes
|
|
41
|
+
* under it when a sibling closes. */
|
|
42
|
+
num: number;
|
|
43
|
+
/** The computed, de-duplicated label actually shown in the chrome. */
|
|
44
|
+
display: string;
|
|
36
45
|
createdAt: string;
|
|
37
46
|
store: LineStore;
|
|
38
47
|
el: HTMLElement;
|
|
@@ -41,6 +50,11 @@ interface Tab {
|
|
|
41
50
|
aria: TabHandle;
|
|
42
51
|
scrollTop: number;
|
|
43
52
|
following: boolean;
|
|
53
|
+
/** A title derived from the first line the user submitted into this tab, used
|
|
54
|
+
* in preference to the process window title (kiro-cli's OSC 0 title is only
|
|
55
|
+
* the cwd for a live session, so it never reflects the conversation).
|
|
56
|
+
* Undefined until the first non-empty, non-slash submission. */
|
|
57
|
+
derived?: string;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
export interface TabsApi {
|
|
@@ -152,8 +166,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
152
166
|
bar.className = "wt-tab-bar";
|
|
153
167
|
bar.setAttribute("role", "tablist");
|
|
154
168
|
slot.appendChild(bar);
|
|
169
|
+
// The "+" button is the last flex item INSIDE the strip. Appending it to
|
|
170
|
+
// the slot (a sibling of the fixed .wt-tab-bar) flowed it out of the strip
|
|
171
|
+
// to the root, so it never appeared; inside the flex bar it renders at the
|
|
172
|
+
// end of the tabs (addTabChrome inserts each tab before it).
|
|
155
173
|
const newBtn = fromHTML(NEW_HTML);
|
|
156
|
-
|
|
174
|
+
bar.appendChild(newBtn);
|
|
175
|
+
|
|
176
|
+
// Push the terminal surface below the fixed strip on desktop so the bar
|
|
177
|
+
// does not overlap the first rows. The surface is position:fixed inset:0,
|
|
178
|
+
// so a top offset (gated to a fine pointer in CSS, since the strip is
|
|
179
|
+
// hidden on a coarse pointer where the switcher docks to the bottom
|
|
180
|
+
// instead) is what clears it. A ResizeObserver keeps the offset in step
|
|
181
|
+
// with the real strip height rather than a hard-coded guess.
|
|
182
|
+
const surface = ctx.surface();
|
|
183
|
+
surface.classList.add("wt-with-topbar");
|
|
184
|
+
const barResize = new ResizeObserver(() => {
|
|
185
|
+
surface.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
|
|
186
|
+
});
|
|
187
|
+
barResize.observe(bar);
|
|
157
188
|
|
|
158
189
|
// --- Mobile bottom-switcher (bottom-switcher region) ---
|
|
159
190
|
const switcher = fromHTML(SWITCHER_HTML);
|
|
@@ -190,6 +221,112 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
190
221
|
let overviewOpen = false;
|
|
191
222
|
let lastFocus: HTMLElement | null = null;
|
|
192
223
|
let hintShown = false;
|
|
224
|
+
let tabSeq = 0; // monotonic source for the "Tab N" fallback number
|
|
225
|
+
|
|
226
|
+
// labelFor is a tab's base label before de-duplication. Preference order:
|
|
227
|
+
// the title derived from the user's first submitted line (best for a chat
|
|
228
|
+
// like kiro-cli, whose OSC 0 title only ever reflects the cwd for a live
|
|
229
|
+
// session), then the process window title (OSC 0/2, e.g. a shell's), then
|
|
230
|
+
// a stable "Tab N". This fixes both the generic "terminal" label and the
|
|
231
|
+
// static "kiro: /workspace" one: a tab now names itself from what you type.
|
|
232
|
+
function labelFor(tab: Tab): string {
|
|
233
|
+
// derived is only ever set to a non-empty value, so ?? and || agree on
|
|
234
|
+
// it; the || on the (possibly empty) window title falls through to "Tab N".
|
|
235
|
+
return (tab.derived?.trim() ?? tab.title.trim()) || `Tab ${String(tab.num)}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// relabelAll recomputes every tab's display label with de-duplication:
|
|
239
|
+
// when two tabs resolve to the same base label (e.g. two tabs whose first
|
|
240
|
+
// submitted line was identical, or two shells with the same window title),
|
|
241
|
+
// the second and later get a " (k)" suffix in creation order, so the strip
|
|
242
|
+
// never shows two identical labels.
|
|
243
|
+
function relabelAll(): void {
|
|
244
|
+
const counts = new Map<string, number>();
|
|
245
|
+
for (const t of tabList) {
|
|
246
|
+
const base = labelFor(t);
|
|
247
|
+
counts.set(base, (counts.get(base) ?? 0) + 1);
|
|
248
|
+
}
|
|
249
|
+
const seen = new Map<string, number>();
|
|
250
|
+
for (const t of tabList) {
|
|
251
|
+
const base = labelFor(t);
|
|
252
|
+
let display = base;
|
|
253
|
+
if ((counts.get(base) ?? 0) > 1) {
|
|
254
|
+
const k = (seen.get(base) ?? 0) + 1;
|
|
255
|
+
seen.set(base, k);
|
|
256
|
+
if (k > 1) {
|
|
257
|
+
display = `${base} (${String(k)})`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
t.display = display;
|
|
261
|
+
t.label.textContent = display;
|
|
262
|
+
t.aria.setLabel(display);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- Title derivation from the user's first submitted line ---
|
|
267
|
+
// kiro-cli's OSC 0 title is only the cwd for a live session (it reloads its
|
|
268
|
+
// session title just when the session id changes, not per turn), so the
|
|
269
|
+
// useful, updating title for a chat is the user's first message. The kernel
|
|
270
|
+
// routes input to the active session, so an input observer's bytes belong
|
|
271
|
+
// to activeId. A tiny line editor tracks the current input line (handling
|
|
272
|
+
// backspace and skipping the escape sequences arrow keys and bracketed
|
|
273
|
+
// paste emit) and locks the tab's title on the first non-empty, non-slash
|
|
274
|
+
// submission. Best-effort: an odd editing sequence just yields no derived
|
|
275
|
+
// title, falling back to the window title or "Tab N".
|
|
276
|
+
const MAX_DERIVED = 60;
|
|
277
|
+
let lineBytes: number[] = [];
|
|
278
|
+
let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
|
|
279
|
+
function resetInputLine(): void {
|
|
280
|
+
lineBytes = [];
|
|
281
|
+
escState = 0;
|
|
282
|
+
}
|
|
283
|
+
function deriveTitleFromInput(bytes: Uint8Array): void {
|
|
284
|
+
const t = tabList.find((x) => x.id === activeId);
|
|
285
|
+
if (!t || t.derived !== undefined) {
|
|
286
|
+
return; // no active tab, or its first submission is already captured
|
|
287
|
+
}
|
|
288
|
+
for (const b of bytes) {
|
|
289
|
+
if (escState === 1) {
|
|
290
|
+
escState = b === 0x5b ? 2 : b === 0x4f ? 3 : 0; // ESC [ = CSI, ESC O = SS3
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (escState === 2) {
|
|
294
|
+
if (b >= 0x40 && b <= 0x7e) {
|
|
295
|
+
escState = 0; // CSI final byte
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (escState === 3) {
|
|
300
|
+
escState = 0; // SS3 final byte
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (b === 0x1b) {
|
|
304
|
+
escState = 1; // ESC: start of an escape sequence
|
|
305
|
+
} else if (b === 0x0d || b === 0x0a) {
|
|
306
|
+
const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
|
|
307
|
+
lineBytes = [];
|
|
308
|
+
if (line && !line.startsWith("/")) {
|
|
309
|
+
t.derived = line.slice(0, MAX_DERIVED);
|
|
310
|
+
syncChrome();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
} else if (b === 0x7f || b === 0x08) {
|
|
314
|
+
// Backspace: drop one codepoint (pop UTF-8 continuation bytes + lead).
|
|
315
|
+
while (lineBytes.length > 0) {
|
|
316
|
+
const last = lineBytes[lineBytes.length - 1];
|
|
317
|
+
if (last === undefined || (last & 0xc0) !== 0x80) {
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
lineBytes.pop();
|
|
321
|
+
}
|
|
322
|
+
lineBytes.pop();
|
|
323
|
+
} else if (b === 0x03) {
|
|
324
|
+
lineBytes = []; // Ctrl-C: cancel the current line
|
|
325
|
+
} else if (b >= 0x20) {
|
|
326
|
+
lineBytes.push(b); // printable ASCII or a UTF-8 byte
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
193
330
|
|
|
194
331
|
function focusInput(): void {
|
|
195
332
|
ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
|
|
@@ -210,7 +347,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
210
347
|
function syncMobile(): void {
|
|
211
348
|
const idx = tabList.findIndex((t) => t.id === activeId);
|
|
212
349
|
const active = idx >= 0 ? tabList[idx] : undefined;
|
|
213
|
-
swLabel.textContent = active ? active.
|
|
350
|
+
swLabel.textContent = active ? active.display : "";
|
|
214
351
|
swDot.dataset["status"] = active?.dot.dataset["status"] ?? "idle";
|
|
215
352
|
swPos.textContent =
|
|
216
353
|
tabList.length > 1 ? `${String(idx + 1)} / ${String(tabList.length)}` : "";
|
|
@@ -240,7 +377,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
240
377
|
const rselect = pick(row, ".wt-sheet-select");
|
|
241
378
|
const rclose = pick(row, ".wt-sheet-close");
|
|
242
379
|
rdot.dataset["status"] = t.dot.dataset["status"] ?? "idle";
|
|
243
|
-
rlabel.textContent = t.
|
|
380
|
+
rlabel.textContent = t.display;
|
|
244
381
|
if (t.id === activeId) {
|
|
245
382
|
row.classList.add("wt-sheet-row-active");
|
|
246
383
|
rselect.setAttribute("aria-current", "true");
|
|
@@ -261,6 +398,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
261
398
|
|
|
262
399
|
// syncChrome refreshes every surface after any state change. Idempotent.
|
|
263
400
|
function syncChrome(): void {
|
|
401
|
+
relabelAll();
|
|
264
402
|
paintActive();
|
|
265
403
|
syncMobile();
|
|
266
404
|
if (overviewOpen) {
|
|
@@ -303,16 +441,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
303
441
|
if (!label || !dot || !close) {
|
|
304
442
|
throw new Error("web-terminal-ui: tab chrome missing parts");
|
|
305
443
|
}
|
|
306
|
-
const title = info.title || "terminal";
|
|
307
|
-
label.textContent = title;
|
|
308
444
|
dot.dataset["status"] = info.status || "idle";
|
|
309
445
|
const aria = tablist.registerTab(el);
|
|
310
|
-
|
|
311
|
-
bar.
|
|
446
|
+
// Insert before the "+" button so it stays the last item in the strip.
|
|
447
|
+
bar.insertBefore(el, newBtn);
|
|
312
448
|
|
|
313
449
|
const tab: Tab = {
|
|
314
450
|
id: info.id,
|
|
315
|
-
title,
|
|
451
|
+
title: info.title,
|
|
452
|
+
num: ++tabSeq,
|
|
453
|
+
display: "",
|
|
316
454
|
createdAt: info.createdAt,
|
|
317
455
|
store: new LineStore(),
|
|
318
456
|
el,
|
|
@@ -322,6 +460,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
322
460
|
scrollTop: 0,
|
|
323
461
|
following: true,
|
|
324
462
|
};
|
|
463
|
+
// Set an initial label immediately (relabelAll refines it with de-dup
|
|
464
|
+
// once the tab is in tabList and syncChrome runs).
|
|
465
|
+
tab.display = labelFor(tab);
|
|
466
|
+
label.textContent = tab.display;
|
|
467
|
+
aria.setLabel(tab.display);
|
|
325
468
|
el.addEventListener("click", (e) => {
|
|
326
469
|
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
327
470
|
return; // handled by the close button
|
|
@@ -354,6 +497,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
354
497
|
// rebuild viewport-first, so the last-known screen paints with no
|
|
355
498
|
// round-trip. Then let the kernel reconnect the WS to it (resume delta).
|
|
356
499
|
activeId = next.id;
|
|
500
|
+
resetInputLine(); // a partial line typed in the old tab does not carry over
|
|
357
501
|
ctx.render.bind(next.store);
|
|
358
502
|
ctx.notifySwitch({ id: next.id });
|
|
359
503
|
armCatchup();
|
|
@@ -368,7 +512,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
368
512
|
surface.scrollTop = savedTop;
|
|
369
513
|
}
|
|
370
514
|
});
|
|
371
|
-
ctx.announce(`Switched to ${next.
|
|
515
|
+
ctx.announce(`Switched to ${next.display}`);
|
|
372
516
|
focusInput();
|
|
373
517
|
}
|
|
374
518
|
|
|
@@ -496,10 +640,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
496
640
|
return;
|
|
497
641
|
}
|
|
498
642
|
t.dot.dataset["status"] = status || "idle";
|
|
499
|
-
|
|
643
|
+
// Record the raw server title; the displayed label (fallback + de-dup)
|
|
644
|
+
// is recomputed by relabelAll via syncChrome, which the callers run
|
|
645
|
+
// right after applyStatus.
|
|
646
|
+
if (title !== undefined) {
|
|
500
647
|
t.title = title;
|
|
501
|
-
t.label.textContent = title;
|
|
502
|
-
t.aria.setLabel(title);
|
|
503
648
|
}
|
|
504
649
|
}
|
|
505
650
|
|
|
@@ -545,6 +690,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
545
690
|
}
|
|
546
691
|
|
|
547
692
|
// --- Event wiring ---
|
|
693
|
+
// Derive each tab's title from the first line the user submits into it.
|
|
694
|
+
const offInput = ctx.registerInputObserver(deriveTitleFromInput);
|
|
548
695
|
newBtn.addEventListener("click", () => {
|
|
549
696
|
void create();
|
|
550
697
|
});
|
|
@@ -651,13 +798,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
651
798
|
create,
|
|
652
799
|
close: close_,
|
|
653
800
|
switchTo,
|
|
654
|
-
list: () =>
|
|
801
|
+
list: () =>
|
|
802
|
+
tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
|
|
655
803
|
},
|
|
656
804
|
teardown() {
|
|
657
805
|
offStatus?.();
|
|
806
|
+
offInput();
|
|
658
807
|
if (pollTimer !== null) {
|
|
659
808
|
clearInterval(pollTimer);
|
|
660
809
|
}
|
|
810
|
+
barResize.disconnect();
|
|
811
|
+
surface.classList.remove("wt-with-topbar");
|
|
812
|
+
surface.style.removeProperty("--wt-tabbar-h");
|
|
661
813
|
clearCatchup();
|
|
662
814
|
closeOverview();
|
|
663
815
|
for (const t of tabList) {
|