@cplieger/web-terminal-ui 3.0.1 → 3.0.2

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
@@ -233,7 +233,7 @@
233
233
  }
234
234
 
235
235
  .wt-tab-label {
236
- max-width: 14ch;
236
+ max-width: 20ch;
237
237
  overflow: hidden;
238
238
  text-overflow: ellipsis;
239
239
  }
@@ -262,11 +262,19 @@
262
262
  border-radius: var(--radius-inner);
263
263
  }
264
264
 
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;
265
+ /* Push the terminal surface below the fixed tab strip so it does not overlap
266
+ the first rows. The tabs feature marks the surface (.wt-with-topbar) and keeps
267
+ the measured strip height in --wt-tabbar-h. The surface is position:fixed
268
+ inset:0, so a top offset is what clears it. Desktop only: the strip is hidden
269
+ on a coarse pointer, where the switcher docks to the bottom instead, so the
270
+ surface stays full-height there. (The prior `.wt-tab-bar ~ .term` /
271
+ `:has(> .wt-tab-bar) .term` rule never matched: the bar lives inside a
272
+ display:contents region, so it is neither a DOM sibling nor an ancestor of
273
+ .term.) */
274
+ @media (pointer: fine) {
275
+ .term.wt-with-topbar {
276
+ top: var(--wt-tabbar-h, 40px);
277
+ }
270
278
  }
271
279
 
272
280
  /* ===== Activity dots (activityMonitor via tabs) =====
@@ -278,36 +286,59 @@
278
286
  height: 9px;
279
287
  border-radius: 50%;
280
288
  flex: none;
281
- background: color-mix(in oklch, var(--text) 40%, transparent);
289
+ /* Default / idle (a new session with no turn yet): a hollow grey dot, so
290
+ "nothing has happened" reads as an outline rather than a filled state.
291
+ The fill (solid vs hollow) is the non-hue cue that separates the idle state
292
+ from the filled done/working/input states (WCAG 1.4.1). */
293
+ background: transparent;
294
+ border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
282
295
  }
283
296
 
297
+ .wt-status-dot[data-status="idle"] {
298
+ background: transparent;
299
+ border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
300
+ }
301
+
302
+ /* Agent working: solid pulsing purple, matching Kiro IDE's "thinking" color
303
+ (#a78bfa). Motion is the primary non-hue cue. Server-side this is driven by
304
+ the OSC 9;4 progress signal, so it tracks agent activity, not the user typing
305
+ at the prompt. */
284
306
  .wt-status-dot[data-status="working"] {
285
- background: oklch(75% 0.15 90deg);
307
+ background: #a78bfa;
308
+ border-color: #a78bfa;
286
309
  animation: wt-pulse 1.2s ease-in-out infinite;
287
310
  }
288
311
 
289
- .wt-status-dot[data-status="idle"] {
290
- background: color-mix(in oklch, var(--text) 40%, transparent);
312
+ /* Agent done / ready: solid green, matching Kiro IDE's "Ready" (#22c55e), no
313
+ motion. The filled dot distinguishes it from the hollow idle without hue. */
314
+ .wt-status-dot[data-status="done"] {
315
+ background: #22c55e;
316
+ border-color: #22c55e;
291
317
  }
292
318
 
319
+ /* User action required (permission / approval): solid amber matching Kiro IDE's
320
+ "waiting-approval" (#fbbf24), plus a ring so it reads as "needs you" via shape
321
+ as well as hue. It persists until you act on or switch into the tab. */
293
322
  .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%);
323
+ background: #fbbf24;
324
+ border-color: #fbbf24;
325
+ box-shadow: 0 0 0 2px oklch(85% 0.16 85deg / 40%);
297
326
  }
298
327
 
328
+ /* Exited: a dim hollow dot, static; distinct from idle by its lower opacity. */
299
329
  .wt-status-dot[data-status="exited"] {
300
330
  background: transparent;
301
- border: 1px solid color-mix(in oklch, var(--text) 40%, transparent);
331
+ border: 1.5px solid color-mix(in oklch, var(--text) 30%, transparent);
332
+ opacity: 0.5;
302
333
  }
303
334
 
304
- /* Reduced-motion (or animations feature off): drop the pulse, keep the color. */
305
335
  @keyframes wt-pulse {
306
336
  50% {
307
337
  opacity: 0.4;
308
338
  }
309
339
  }
310
340
 
341
+ /* Reduced-motion (or the animations feature off): drop the pulse, keep color. */
311
342
  @media (prefers-reduced-motion: reduce) {
312
343
  .wt-status-dot[data-status="working"] {
313
344
  animation: none;
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.2",
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;
@@ -152,8 +161,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
152
161
  bar.className = "wt-tab-bar";
153
162
  bar.setAttribute("role", "tablist");
154
163
  slot.appendChild(bar);
164
+ // The "+" button is the last flex item INSIDE the strip. Appending it to
165
+ // the slot (a sibling of the fixed .wt-tab-bar) flowed it out of the strip
166
+ // to the root, so it never appeared; inside the flex bar it renders at the
167
+ // end of the tabs (addTabChrome inserts each tab before it).
155
168
  const newBtn = fromHTML(NEW_HTML);
156
- slot.appendChild(newBtn);
169
+ bar.appendChild(newBtn);
170
+
171
+ // Push the terminal surface below the fixed strip on desktop so the bar
172
+ // does not overlap the first rows. The surface is position:fixed inset:0,
173
+ // so a top offset (gated to a fine pointer in CSS, since the strip is
174
+ // hidden on a coarse pointer where the switcher docks to the bottom
175
+ // instead) is what clears it. A ResizeObserver keeps the offset in step
176
+ // with the real strip height rather than a hard-coded guess.
177
+ const surface = ctx.surface();
178
+ surface.classList.add("wt-with-topbar");
179
+ const barResize = new ResizeObserver(() => {
180
+ surface.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
181
+ });
182
+ barResize.observe(bar);
157
183
 
158
184
  // --- Mobile bottom-switcher (bottom-switcher region) ---
159
185
  const switcher = fromHTML(SWITCHER_HTML);
@@ -190,6 +216,44 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
190
216
  let overviewOpen = false;
191
217
  let lastFocus: HTMLElement | null = null;
192
218
  let hintShown = false;
219
+ 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.
226
+ function labelFor(tab: Tab): string {
227
+ return tab.title.trim() || `Tab ${String(tab.num)}`;
228
+ }
229
+
230
+ // 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.
235
+ function relabelAll(): void {
236
+ const counts = new Map<string, number>();
237
+ for (const t of tabList) {
238
+ const base = labelFor(t);
239
+ counts.set(base, (counts.get(base) ?? 0) + 1);
240
+ }
241
+ const seen = new Map<string, number>();
242
+ for (const t of tabList) {
243
+ const base = labelFor(t);
244
+ let display = base;
245
+ if ((counts.get(base) ?? 0) > 1) {
246
+ const k = (seen.get(base) ?? 0) + 1;
247
+ seen.set(base, k);
248
+ if (k > 1) {
249
+ display = `${base} (${String(k)})`;
250
+ }
251
+ }
252
+ t.display = display;
253
+ t.label.textContent = display;
254
+ t.aria.setLabel(display);
255
+ }
256
+ }
193
257
 
194
258
  function focusInput(): void {
195
259
  ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
@@ -210,7 +274,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
210
274
  function syncMobile(): void {
211
275
  const idx = tabList.findIndex((t) => t.id === activeId);
212
276
  const active = idx >= 0 ? tabList[idx] : undefined;
213
- swLabel.textContent = active ? active.title : "";
277
+ swLabel.textContent = active ? active.display : "";
214
278
  swDot.dataset["status"] = active?.dot.dataset["status"] ?? "idle";
215
279
  swPos.textContent =
216
280
  tabList.length > 1 ? `${String(idx + 1)} / ${String(tabList.length)}` : "";
@@ -240,7 +304,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
240
304
  const rselect = pick(row, ".wt-sheet-select");
241
305
  const rclose = pick(row, ".wt-sheet-close");
242
306
  rdot.dataset["status"] = t.dot.dataset["status"] ?? "idle";
243
- rlabel.textContent = t.title;
307
+ rlabel.textContent = t.display;
244
308
  if (t.id === activeId) {
245
309
  row.classList.add("wt-sheet-row-active");
246
310
  rselect.setAttribute("aria-current", "true");
@@ -261,6 +325,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
261
325
 
262
326
  // syncChrome refreshes every surface after any state change. Idempotent.
263
327
  function syncChrome(): void {
328
+ relabelAll();
264
329
  paintActive();
265
330
  syncMobile();
266
331
  if (overviewOpen) {
@@ -303,16 +368,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
303
368
  if (!label || !dot || !close) {
304
369
  throw new Error("web-terminal-ui: tab chrome missing parts");
305
370
  }
306
- const title = info.title || "terminal";
307
- label.textContent = title;
308
371
  dot.dataset["status"] = info.status || "idle";
309
372
  const aria = tablist.registerTab(el);
310
- aria.setLabel(title);
311
- bar.appendChild(el);
373
+ // Insert before the "+" button so it stays the last item in the strip.
374
+ bar.insertBefore(el, newBtn);
312
375
 
313
376
  const tab: Tab = {
314
377
  id: info.id,
315
- title,
378
+ title: info.title,
379
+ num: ++tabSeq,
380
+ display: "",
316
381
  createdAt: info.createdAt,
317
382
  store: new LineStore(),
318
383
  el,
@@ -322,6 +387,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
322
387
  scrollTop: 0,
323
388
  following: true,
324
389
  };
390
+ // Set an initial label immediately (relabelAll refines it with de-dup
391
+ // once the tab is in tabList and syncChrome runs).
392
+ tab.display = labelFor(tab);
393
+ label.textContent = tab.display;
394
+ aria.setLabel(tab.display);
325
395
  el.addEventListener("click", (e) => {
326
396
  if ((e.target as HTMLElement).closest(".wt-tab-close")) {
327
397
  return; // handled by the close button
@@ -496,10 +566,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
496
566
  return;
497
567
  }
498
568
  t.dot.dataset["status"] = status || "idle";
499
- if (title && title !== t.title) {
569
+ // Record the raw server title; the displayed label (fallback + de-dup)
570
+ // is recomputed by relabelAll via syncChrome, which the callers run
571
+ // right after applyStatus.
572
+ if (title !== undefined) {
500
573
  t.title = title;
501
- t.label.textContent = title;
502
- t.aria.setLabel(title);
503
574
  }
504
575
  }
505
576
 
@@ -658,6 +729,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
658
729
  if (pollTimer !== null) {
659
730
  clearInterval(pollTimer);
660
731
  }
732
+ barResize.disconnect();
733
+ surface.classList.remove("wt-with-topbar");
734
+ surface.style.removeProperty("--wt-tabbar-h");
661
735
  clearCatchup();
662
736
  closeOverview();
663
737
  for (const t of tabList) {