@cplieger/web-terminal-ui 3.0.2 → 3.0.4
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 +93 -9
- package/package.json +1 -1
- package/src/features/tabs.ts +145 -14
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);
|
|
@@ -218,6 +219,9 @@
|
|
|
218
219
|
align-items: center;
|
|
219
220
|
gap: var(--sp-1);
|
|
220
221
|
padding: var(--sp-1) var(--sp-2);
|
|
222
|
+
/* A transparent border is reserved on every tab so toggling the inactive
|
|
223
|
+
outline (below) never shifts the strip's layout. */
|
|
224
|
+
border: 1px solid transparent;
|
|
221
225
|
border-radius: var(--radius-inner);
|
|
222
226
|
color: color-mix(in oklch, var(--text) 70%, transparent);
|
|
223
227
|
font-family: var(--font-ui);
|
|
@@ -227,6 +231,17 @@
|
|
|
227
231
|
user-select: none;
|
|
228
232
|
}
|
|
229
233
|
|
|
234
|
+
/* Inactive tabs read as outlined chips (so adjacent tabs stay visually
|
|
235
|
+
separated) and take a subtle fill on hover. */
|
|
236
|
+
.wt-tab:not(.wt-tab-active) {
|
|
237
|
+
border-color: var(--border);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.wt-tab:not(.wt-tab-active):hover {
|
|
241
|
+
background: var(--text-active);
|
|
242
|
+
color: var(--text);
|
|
243
|
+
}
|
|
244
|
+
|
|
230
245
|
.wt-tab-active {
|
|
231
246
|
background: color-mix(in oklch, var(--text) 10%, var(--surface));
|
|
232
247
|
color: var(--text);
|
|
@@ -238,28 +253,92 @@
|
|
|
238
253
|
text-overflow: ellipsis;
|
|
239
254
|
}
|
|
240
255
|
|
|
256
|
+
/* A little breathing room after the activity dot, before the label. */
|
|
257
|
+
.wt-tab-dot {
|
|
258
|
+
margin-right: 2px;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* The tab close (×): a centered, muted square glyph button with a box on hover.
|
|
262
|
+
font: inherit so the glyph uses the UI font and centers cleanly in its box. */
|
|
241
263
|
.wt-tab-close {
|
|
264
|
+
display: inline-flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
justify-content: center;
|
|
267
|
+
width: 1.375rem;
|
|
268
|
+
height: 1.375rem;
|
|
269
|
+
padding: 0;
|
|
242
270
|
background: transparent;
|
|
243
271
|
border: none;
|
|
244
|
-
color: inherit;
|
|
245
|
-
cursor: pointer;
|
|
246
|
-
padding: 0 var(--sp-1);
|
|
247
272
|
border-radius: var(--radius-inner);
|
|
248
|
-
|
|
273
|
+
color: color-mix(in oklch, var(--text) 60%, transparent);
|
|
274
|
+
font: inherit;
|
|
275
|
+
font-size: 1rem;
|
|
276
|
+
line-height: 1;
|
|
277
|
+
cursor: pointer;
|
|
249
278
|
}
|
|
250
279
|
|
|
251
280
|
.wt-tab-close:hover {
|
|
252
|
-
|
|
281
|
+
background: var(--text-active);
|
|
282
|
+
color: var(--text);
|
|
253
283
|
}
|
|
254
284
|
|
|
285
|
+
/* On an inactive tab the close appears only on hover, kept in the layout via
|
|
286
|
+
visibility so the tab width never jumps; on the active tab it is always shown. */
|
|
287
|
+
.wt-tab:not(.wt-tab-active) .wt-tab-close {
|
|
288
|
+
visibility: hidden;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.wt-tab:not(.wt-tab-active):hover .wt-tab-close {
|
|
292
|
+
visibility: visible;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* The add-tab (+) button: its hover box matches an active tab's box (it
|
|
296
|
+
stretches to the strip height and uses the same active-tab fill), it centers
|
|
297
|
+
its glyph, and a left margin keeps its hover box clear of the last tab's. */
|
|
255
298
|
.wt-tab-new {
|
|
299
|
+
display: inline-flex;
|
|
300
|
+
align-items: center;
|
|
301
|
+
justify-content: center;
|
|
302
|
+
align-self: stretch;
|
|
303
|
+
min-width: 1.875rem;
|
|
304
|
+
margin-left: var(--sp-2);
|
|
305
|
+
padding: 0 var(--sp-1);
|
|
256
306
|
background: transparent;
|
|
257
307
|
border: none;
|
|
258
|
-
|
|
259
|
-
|
|
308
|
+
border-radius: var(--radius-inner);
|
|
309
|
+
color: color-mix(in oklch, var(--text) 60%, transparent);
|
|
310
|
+
font: inherit;
|
|
260
311
|
font-size: 1.1rem;
|
|
312
|
+
line-height: 1;
|
|
313
|
+
cursor: pointer;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.wt-tab-new:hover {
|
|
317
|
+
background: color-mix(in oklch, var(--text) 10%, var(--surface));
|
|
318
|
+
color: var(--text);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* Close-all: a right-anchored text button (pushed to the far end of the strip). */
|
|
322
|
+
.wt-tab-closeall {
|
|
323
|
+
display: inline-flex;
|
|
324
|
+
align-items: center;
|
|
325
|
+
justify-content: center;
|
|
326
|
+
align-self: stretch;
|
|
327
|
+
margin-left: auto;
|
|
261
328
|
padding: 0 var(--sp-2);
|
|
329
|
+
background: transparent;
|
|
330
|
+
border: none;
|
|
262
331
|
border-radius: var(--radius-inner);
|
|
332
|
+
color: color-mix(in oklch, var(--text) 60%, transparent);
|
|
333
|
+
font-family: var(--font-ui);
|
|
334
|
+
font-size: 0.75rem;
|
|
335
|
+
white-space: nowrap;
|
|
336
|
+
cursor: pointer;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.wt-tab-closeall:hover {
|
|
340
|
+
background: var(--text-active);
|
|
341
|
+
color: var(--text);
|
|
263
342
|
}
|
|
264
343
|
|
|
265
344
|
/* Push the terminal surface below the fixed tab strip so it does not overlap
|
|
@@ -357,8 +436,13 @@
|
|
|
357
436
|
display var(--dur-standard) allow-discrete;
|
|
358
437
|
}
|
|
359
438
|
|
|
360
|
-
.wt-animate .wt-tab
|
|
361
|
-
|
|
439
|
+
.wt-animate .wt-tab,
|
|
440
|
+
.wt-animate .wt-tab-close,
|
|
441
|
+
.wt-animate .wt-tab-new,
|
|
442
|
+
.wt-animate .wt-tab-closeall {
|
|
443
|
+
transition:
|
|
444
|
+
background-color var(--dur-micro) var(--ease-standard),
|
|
445
|
+
color var(--dur-micro) var(--ease-standard);
|
|
362
446
|
}
|
|
363
447
|
|
|
364
448
|
/* ===== 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.4",
|
|
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 {
|
|
@@ -86,6 +91,8 @@ const TAB_HTML = `
|
|
|
86
91
|
|
|
87
92
|
const NEW_HTML = `<button type="button" class="wt-tab-new" aria-label="New terminal">+</button>`;
|
|
88
93
|
|
|
94
|
+
const CLOSEALL_HTML = `<button type="button" class="wt-tab-closeall" aria-label="Close all terminals">Close all</button>`;
|
|
95
|
+
|
|
89
96
|
// Mobile bottom-switcher: the active tab (dot + label + position) as a tap/swipe
|
|
90
97
|
// surface, plus a >=44px overview control that carries the aggregate badge.
|
|
91
98
|
const SWITCHER_HTML = `
|
|
@@ -167,6 +174,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
167
174
|
// end of the tabs (addTabChrome inserts each tab before it).
|
|
168
175
|
const newBtn = fromHTML(NEW_HTML);
|
|
169
176
|
bar.appendChild(newBtn);
|
|
177
|
+
// A right-anchored "Close all" button (CSS margin-left: auto pushes it to
|
|
178
|
+
// the far end of the strip). Desktop only, since it lives inside the bar,
|
|
179
|
+
// which is hidden on a coarse pointer.
|
|
180
|
+
const closeAllBtn = fromHTML(CLOSEALL_HTML);
|
|
181
|
+
bar.appendChild(closeAllBtn);
|
|
170
182
|
|
|
171
183
|
// Push the terminal surface below the fixed strip on desktop so the bar
|
|
172
184
|
// does not overlap the first rows. The surface is position:fixed inset:0,
|
|
@@ -217,21 +229,29 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
217
229
|
let lastFocus: HTMLElement | null = null;
|
|
218
230
|
let hintShown = false;
|
|
219
231
|
let tabSeq = 0; // monotonic source for the "Tab N" fallback number
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
232
|
+
// On a touch device, focusing the hidden input opens the virtual keyboard.
|
|
233
|
+
// A switch triggered by tapping/swiping the tab UI must not do that (it is
|
|
234
|
+
// disruptive), so the input is focused on a switch only with a fine
|
|
235
|
+
// pointer; on touch the user taps the terminal itself to open the keyboard.
|
|
236
|
+
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
|
237
|
+
|
|
238
|
+
// labelFor is a tab's base label before de-duplication. Preference order:
|
|
239
|
+
// the title derived from the user's first submitted line (best for a chat
|
|
240
|
+
// like kiro-cli, whose OSC 0 title only ever reflects the cwd for a live
|
|
241
|
+
// session), then the process window title (OSC 0/2, e.g. a shell's), then
|
|
242
|
+
// a stable "Tab N". This fixes both the generic "terminal" label and the
|
|
243
|
+
// static "kiro: /workspace" one: a tab now names itself from what you type.
|
|
226
244
|
function labelFor(tab: Tab): string {
|
|
227
|
-
|
|
245
|
+
// derived is only ever set to a non-empty value, so ?? and || agree on
|
|
246
|
+
// it; the || on the (possibly empty) window title falls through to "Tab N".
|
|
247
|
+
return (tab.derived?.trim() ?? tab.title.trim()) || `Tab ${String(tab.num)}`;
|
|
228
248
|
}
|
|
229
249
|
|
|
230
250
|
// relabelAll recomputes every tab's display label with de-duplication:
|
|
231
|
-
// when two tabs resolve to the same base label (
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
251
|
+
// when two tabs resolve to the same base label (e.g. two tabs whose first
|
|
252
|
+
// submitted line was identical, or two shells with the same window title),
|
|
253
|
+
// the second and later get a " (k)" suffix in creation order, so the strip
|
|
254
|
+
// never shows two identical labels.
|
|
235
255
|
function relabelAll(): void {
|
|
236
256
|
const counts = new Map<string, number>();
|
|
237
257
|
for (const t of tabList) {
|
|
@@ -255,6 +275,71 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
255
275
|
}
|
|
256
276
|
}
|
|
257
277
|
|
|
278
|
+
// --- Title derivation from the user's first submitted line ---
|
|
279
|
+
// kiro-cli's OSC 0 title is only the cwd for a live session (it reloads its
|
|
280
|
+
// session title just when the session id changes, not per turn), so the
|
|
281
|
+
// useful, updating title for a chat is the user's first message. The kernel
|
|
282
|
+
// routes input to the active session, so an input observer's bytes belong
|
|
283
|
+
// to activeId. A tiny line editor tracks the current input line (handling
|
|
284
|
+
// backspace and skipping the escape sequences arrow keys and bracketed
|
|
285
|
+
// paste emit) and locks the tab's title on the first non-empty, non-slash
|
|
286
|
+
// submission. Best-effort: an odd editing sequence just yields no derived
|
|
287
|
+
// title, falling back to the window title or "Tab N".
|
|
288
|
+
const MAX_DERIVED = 60;
|
|
289
|
+
let lineBytes: number[] = [];
|
|
290
|
+
let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
|
|
291
|
+
function resetInputLine(): void {
|
|
292
|
+
lineBytes = [];
|
|
293
|
+
escState = 0;
|
|
294
|
+
}
|
|
295
|
+
function deriveTitleFromInput(bytes: Uint8Array): void {
|
|
296
|
+
const t = tabList.find((x) => x.id === activeId);
|
|
297
|
+
if (!t || t.derived !== undefined) {
|
|
298
|
+
return; // no active tab, or its first submission is already captured
|
|
299
|
+
}
|
|
300
|
+
for (const b of bytes) {
|
|
301
|
+
if (escState === 1) {
|
|
302
|
+
escState = b === 0x5b ? 2 : b === 0x4f ? 3 : 0; // ESC [ = CSI, ESC O = SS3
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (escState === 2) {
|
|
306
|
+
if (b >= 0x40 && b <= 0x7e) {
|
|
307
|
+
escState = 0; // CSI final byte
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (escState === 3) {
|
|
312
|
+
escState = 0; // SS3 final byte
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (b === 0x1b) {
|
|
316
|
+
escState = 1; // ESC: start of an escape sequence
|
|
317
|
+
} else if (b === 0x0d || b === 0x0a) {
|
|
318
|
+
const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
|
|
319
|
+
lineBytes = [];
|
|
320
|
+
if (line && !line.startsWith("/")) {
|
|
321
|
+
t.derived = line.slice(0, MAX_DERIVED);
|
|
322
|
+
syncChrome();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
} else if (b === 0x7f || b === 0x08) {
|
|
326
|
+
// Backspace: drop one codepoint (pop UTF-8 continuation bytes + lead).
|
|
327
|
+
while (lineBytes.length > 0) {
|
|
328
|
+
const last = lineBytes[lineBytes.length - 1];
|
|
329
|
+
if (last === undefined || (last & 0xc0) !== 0x80) {
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
lineBytes.pop();
|
|
333
|
+
}
|
|
334
|
+
lineBytes.pop();
|
|
335
|
+
} else if (b === 0x03) {
|
|
336
|
+
lineBytes = []; // Ctrl-C: cancel the current line
|
|
337
|
+
} else if (b >= 0x20) {
|
|
338
|
+
lineBytes.push(b); // printable ASCII or a UTF-8 byte
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
258
343
|
function focusInput(): void {
|
|
259
344
|
ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
|
|
260
345
|
}
|
|
@@ -402,6 +487,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
402
487
|
e.stopPropagation();
|
|
403
488
|
void close_(tab.id);
|
|
404
489
|
});
|
|
490
|
+
// Middle-click closes the tab (#8). Suppress the middle-click default on
|
|
491
|
+
// mousedown so the browser's autoscroll/paste affordance does not fire.
|
|
492
|
+
el.addEventListener("mousedown", (e) => {
|
|
493
|
+
if (e.button === 1) {
|
|
494
|
+
e.preventDefault();
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
el.addEventListener("auxclick", (e) => {
|
|
498
|
+
if (e.button === 1) {
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
void close_(tab.id);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
405
503
|
return tab;
|
|
406
504
|
}
|
|
407
505
|
|
|
@@ -424,6 +522,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
424
522
|
// rebuild viewport-first, so the last-known screen paints with no
|
|
425
523
|
// round-trip. Then let the kernel reconnect the WS to it (resume delta).
|
|
426
524
|
activeId = next.id;
|
|
525
|
+
resetInputLine(); // a partial line typed in the old tab does not carry over
|
|
427
526
|
ctx.render.bind(next.store);
|
|
428
527
|
ctx.notifySwitch({ id: next.id });
|
|
429
528
|
armCatchup();
|
|
@@ -438,8 +537,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
438
537
|
surface.scrollTop = savedTop;
|
|
439
538
|
}
|
|
440
539
|
});
|
|
441
|
-
ctx.announce(`Switched to ${next.
|
|
442
|
-
|
|
540
|
+
ctx.announce(`Switched to ${next.display}`);
|
|
541
|
+
if (!coarse) {
|
|
542
|
+
focusInput(); // desktop only; on touch this would pop the keyboard (#7)
|
|
543
|
+
}
|
|
443
544
|
}
|
|
444
545
|
|
|
445
546
|
// armCatchup shows the "catching up" cue only if the resume delta has not
|
|
@@ -532,6 +633,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
532
633
|
await dropTab(id, true);
|
|
533
634
|
}
|
|
534
635
|
|
|
636
|
+
// closeAll closes every tab (killing each process) and leaves one fresh
|
|
637
|
+
// terminal. Destructive - each tab is a running agent - so it confirms.
|
|
638
|
+
async function closeAll(): Promise<void> {
|
|
639
|
+
if (tabList.length === 0) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (!window.confirm(`Close all ${String(tabList.length)} terminals?`)) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const victims = [...tabList];
|
|
646
|
+
tabList.length = 0;
|
|
647
|
+
activeId = null;
|
|
648
|
+
for (const t of victims) {
|
|
649
|
+
t.aria.remove();
|
|
650
|
+
t.el.remove();
|
|
651
|
+
ctx.dropSession(t.id);
|
|
652
|
+
await apiClose(t.id);
|
|
653
|
+
}
|
|
654
|
+
syncChrome();
|
|
655
|
+
await create(); // there is always at least one terminal open
|
|
656
|
+
}
|
|
657
|
+
|
|
535
658
|
// One-time "swipe to switch" hint on first multi-tab state, mobile only.
|
|
536
659
|
function maybeSwipeHint(): void {
|
|
537
660
|
if (hintShown || tabList.length < 2) {
|
|
@@ -616,9 +739,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
616
739
|
}
|
|
617
740
|
|
|
618
741
|
// --- Event wiring ---
|
|
742
|
+
// Derive each tab's title from the first line the user submits into it.
|
|
743
|
+
const offInput = ctx.registerInputObserver(deriveTitleFromInput);
|
|
619
744
|
newBtn.addEventListener("click", () => {
|
|
620
745
|
void create();
|
|
621
746
|
});
|
|
747
|
+
closeAllBtn.addEventListener("click", () => {
|
|
748
|
+
void closeAll();
|
|
749
|
+
});
|
|
622
750
|
overviewBtn.addEventListener("click", () => {
|
|
623
751
|
openOverview();
|
|
624
752
|
});
|
|
@@ -722,10 +850,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
722
850
|
create,
|
|
723
851
|
close: close_,
|
|
724
852
|
switchTo,
|
|
725
|
-
list: () =>
|
|
853
|
+
list: () =>
|
|
854
|
+
tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
|
|
726
855
|
},
|
|
727
856
|
teardown() {
|
|
728
857
|
offStatus?.();
|
|
858
|
+
offInput();
|
|
729
859
|
if (pollTimer !== null) {
|
|
730
860
|
clearInterval(pollTimer);
|
|
731
861
|
}
|
|
@@ -741,6 +871,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
741
871
|
tabList.length = 0;
|
|
742
872
|
bar.remove();
|
|
743
873
|
newBtn.remove();
|
|
874
|
+
closeAllBtn.remove();
|
|
744
875
|
switcher.remove();
|
|
745
876
|
sheet.remove();
|
|
746
877
|
scrim.remove();
|