@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 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: 14ch;
237
+ max-width: 20ch;
237
238
  overflow: hidden;
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
- /* Shift the terminal down so the fixed tab bar does not overlap the first
266
- rows. The tab bar is ~36px tall; the terminal padding-top absorbs it. */
267
- .wt-tab-bar ~ .term,
268
- :has(> .wt-tab-bar) .term {
269
- padding-top: 40px;
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
- background: color-mix(in oklch, var(--text) 40%, transparent);
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: oklch(75% 0.15 90deg);
311
+ background: #a78bfa;
312
+ border-color: #a78bfa;
286
313
  animation: wt-pulse 1.2s ease-in-out infinite;
287
314
  }
288
315
 
289
- .wt-status-dot[data-status="idle"] {
290
- background: color-mix(in oklch, var(--text) 40%, transparent);
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: oklch(70% 0.18 25deg);
295
- /* A ring so needs-input reads distinctly without relying on hue alone. */
296
- box-shadow: 0 0 0 2px oklch(70% 0.18 25deg / 40%);
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: 1px solid color-mix(in oklch, var(--text) 40%, transparent);
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
- 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);
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.1",
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",
@@ -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
- slot.appendChild(newBtn);
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.title : "";
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.title;
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
- aria.setLabel(title);
311
- bar.appendChild(el);
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.title}`);
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
- if (title && title !== t.title) {
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: () => 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 })),
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) {