@cplieger/web-terminal-ui 3.0.2 → 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 +26 -18
- package/package.json +1 -1
- package/src/features/tabs.ts +90 -12
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);
|
|
@@ -238,28 +239,31 @@
|
|
|
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
269
|
/* Push the terminal surface below the fixed tab strip so it does not overlap
|
|
@@ -357,8 +361,12 @@
|
|
|
357
361
|
display var(--dur-standard) allow-discrete;
|
|
358
362
|
}
|
|
359
363
|
|
|
360
|
-
.wt-animate .wt-tab
|
|
361
|
-
|
|
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);
|
|
362
370
|
}
|
|
363
371
|
|
|
364
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
|
@@ -50,6 +50,11 @@ interface Tab {
|
|
|
50
50
|
aria: TabHandle;
|
|
51
51
|
scrollTop: number;
|
|
52
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;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
export interface TabsApi {
|
|
@@ -218,20 +223,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
218
223
|
let hintShown = false;
|
|
219
224
|
let tabSeq = 0; // monotonic source for the "Tab N" fallback number
|
|
220
225
|
|
|
221
|
-
// labelFor is a tab's base label before de-duplication
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
// a
|
|
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.
|
|
226
232
|
function labelFor(tab: Tab): string {
|
|
227
|
-
|
|
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)}`;
|
|
228
236
|
}
|
|
229
237
|
|
|
230
238
|
// relabelAll recomputes every tab's display label with de-duplication:
|
|
231
|
-
// when two tabs resolve to the same base label (
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
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.
|
|
235
243
|
function relabelAll(): void {
|
|
236
244
|
const counts = new Map<string, number>();
|
|
237
245
|
for (const t of tabList) {
|
|
@@ -255,6 +263,71 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
255
263
|
}
|
|
256
264
|
}
|
|
257
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
|
+
}
|
|
330
|
+
|
|
258
331
|
function focusInput(): void {
|
|
259
332
|
ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
|
|
260
333
|
}
|
|
@@ -424,6 +497,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
424
497
|
// rebuild viewport-first, so the last-known screen paints with no
|
|
425
498
|
// round-trip. Then let the kernel reconnect the WS to it (resume delta).
|
|
426
499
|
activeId = next.id;
|
|
500
|
+
resetInputLine(); // a partial line typed in the old tab does not carry over
|
|
427
501
|
ctx.render.bind(next.store);
|
|
428
502
|
ctx.notifySwitch({ id: next.id });
|
|
429
503
|
armCatchup();
|
|
@@ -438,7 +512,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
438
512
|
surface.scrollTop = savedTop;
|
|
439
513
|
}
|
|
440
514
|
});
|
|
441
|
-
ctx.announce(`Switched to ${next.
|
|
515
|
+
ctx.announce(`Switched to ${next.display}`);
|
|
442
516
|
focusInput();
|
|
443
517
|
}
|
|
444
518
|
|
|
@@ -616,6 +690,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
616
690
|
}
|
|
617
691
|
|
|
618
692
|
// --- Event wiring ---
|
|
693
|
+
// Derive each tab's title from the first line the user submits into it.
|
|
694
|
+
const offInput = ctx.registerInputObserver(deriveTitleFromInput);
|
|
619
695
|
newBtn.addEventListener("click", () => {
|
|
620
696
|
void create();
|
|
621
697
|
});
|
|
@@ -722,10 +798,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
722
798
|
create,
|
|
723
799
|
close: close_,
|
|
724
800
|
switchTo,
|
|
725
|
-
list: () =>
|
|
801
|
+
list: () =>
|
|
802
|
+
tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
|
|
726
803
|
},
|
|
727
804
|
teardown() {
|
|
728
805
|
offStatus?.();
|
|
806
|
+
offInput();
|
|
729
807
|
if (pollTimer !== null) {
|
|
730
808
|
clearInterval(pollTimer);
|
|
731
809
|
}
|