@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 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
- opacity: 0.6;
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
- opacity: 1;
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
- color: var(--text);
259
- cursor: pointer;
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
- transition: background-color var(--dur-micro) var(--ease-standard);
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.2",
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",
@@ -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
- // labelFor is a tab's base label before de-duplication: the server title
222
- // (the OSC 0/2 window title the process set, e.g. kiro-cli's
223
- // "kiro: <first message>" when chat.terminalTitle is on) when present,
224
- // else a stable "Tab N". This is the fix for every tab reading "terminal":
225
- // a real title flows through, and untitled tabs are numbered, not generic.
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
- return tab.title.trim() || `Tab ${String(tab.num)}`;
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 (fresh vibecli tabs share
232
- // the cwd-derived title until their first message gives each a distinct
233
- // one), the second and later get a " (k)" suffix in creation order, so the
234
- // strip never shows two identical labels.
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.title}`);
442
- focusInput();
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: () => tabList.map((t) => ({ id: t.id, title: t.title, active: t.id === activeId })),
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();