@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 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
- .wt-tab-close {
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
- opacity: 0.6;
249
- }
250
-
251
- .wt-tab-close:hover {
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-new {
256
- background: transparent;
257
- border: none;
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
- transition: background-color var(--dur-micro) var(--ease-standard);
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.2",
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",
@@ -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: 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.
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
- return tab.title.trim() || `Tab ${String(tab.num)}`;
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 (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.
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.title}`);
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: () => tabList.map((t) => ({ id: t.id, title: t.title, active: t.id === activeId })),
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
  }