@cplieger/web-terminal-ui 3.1.0 → 3.3.0

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/README.md CHANGED
@@ -85,26 +85,33 @@ render/connection/scroll modules are single-instance per page (tabs multiplex
85
85
  sessions over the one kernel). `scaffold/index.html` is a complete reference page
86
86
  to copy and adapt.
87
87
 
88
- Three presets are provided (import from `@cplieger/web-terminal-ui/presets`); each
88
+ Four presets are provided (import from `@cplieger/web-terminal-ui/presets`); each
89
89
  is a plain feature-array factory, so you can spread and edit it, or hand-pick
90
90
  individual features instead:
91
91
 
92
92
  - `presetSingle()` — single-pane desktop UI (context menu, clipboard,
93
93
  scroll-to-bottom, predictive echo, connection banner).
94
94
  - `presetTouch()` — `presetSingle()` plus the mobile key toolbar.
95
- - `presetTabbed()` — the full reference UI: `presetTouch()` plus tabs, the
96
- activity monitor, and animations. Requires a server that speaks the session
97
- API (`/api/sessions`, the status SSE, and `/ws?session=`), such as
98
- `web-terminal-server` or `vibecli`.
95
+ - `presetTabbed()` — the generic tabbed UI: `presetTouch()` plus tabs and
96
+ animations. Tabs are label-only (no activity dots); each tab's title follows
97
+ the process window title (OSC 0/2) when the program sets one and keeps it
98
+ updated, otherwise the last command submitted. Requires a server that speaks
99
+ the session API (`/api/sessions` and `/ws?session=`), such as
100
+ `web-terminal-server`.
101
+ - `presetAgentTabbed()` — `presetTabbed()` plus the activity monitor, so each
102
+ tab carries a live status dot (idle / working / done / needs-input) from the
103
+ server's status SSE. Same title behavior as `presetTabbed`. For an agent shell
104
+ such as `vibecli`.
99
105
 
100
106
  ### Options
101
107
 
102
- | Option | Default | Purpose |
103
- | ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
104
- | `features` | _(none — bare kernel)_ | The feature list. Omitted or empty builds only the terminal (no chrome). Use a preset from `./presets` or a hand-picked array. |
105
- | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
106
- | `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
107
- | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
108
+ | Option | Default | Purpose |
109
+ | ----------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
110
+ | `features` | _(none — bare kernel)_ | The feature list. Omitted or empty builds only the terminal (no chrome). Use a preset from `./presets` or a hand-picked array. |
111
+ | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
112
+ | `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
113
+ | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
114
+ | `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`. The library ships neutral defaults. |
108
115
 
109
116
  `createTerminal()` returns a handle: `focus()` re-focuses the terminal input
110
117
  (and opens the soft keyboard on touch), and `destroy()` tears every feature down
package/css/05-tabs.css CHANGED
@@ -231,12 +231,13 @@
231
231
  per section 12 / WCAG 1.4.1. The one .wt-status-dot vocabulary is shared by
232
232
  the desktop strip, the mobile switcher, and the overview sheet rows.
233
233
 
234
- Opt-in: the dot is hidden unless its container carries .wt-status, which the
235
- tabs feature adds only when a status source (activityMonitor) is wired. A
236
- generic bash/sh terminal has no activity concept, so its tabs read as clean
237
- labels; the agent consumer (vibecli) supplies the monitor and gets the dots.
238
- The element stays in the DOM either way (JS sets data-status regardless), so
239
- revealing it is a pure CSS switch. */
234
+ Revealed PER TAB: the dot is hidden until its own session reports activity
235
+ (OSC 9;4 progress, or a classified OSC 9 notification), at which point the
236
+ tabs feature adds .wt-reports to that dot (paintStatusDot). A program that
237
+ emits no OSC 9 signal (a plain bash/sh) has no activity concept, so its tabs
238
+ stay clean, label-only; a progress-reporting agent (kiro-cli, Claude Code)
239
+ lights up. The element stays in the DOM either way (JS sets data-status
240
+ regardless), so revealing it is a pure CSS switch. */
240
241
  .wt-status-dot {
241
242
  display: none;
242
243
  width: 9px;
@@ -251,9 +252,10 @@
251
252
  border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
252
253
  }
253
254
 
254
- /* A status source is wired (the tabs feature marked the chrome .wt-status): show
255
- the dots. Without this class the whole activity vocabulary stays invisible. */
256
- .wt-status .wt-status-dot {
255
+ /* Reveal a dot only once its session has reported activity (the tabs feature
256
+ adds .wt-reports per dot; see paintStatusDot). Without it the dot — and the
257
+ whole activity vocabulary for that tab — stays invisible. */
258
+ .wt-status-dot.wt-reports {
257
259
  display: block;
258
260
  }
259
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
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",
@@ -25,7 +25,7 @@
25
25
  "verify": "bash scripts/verify.sh"
26
26
  },
27
27
  "peerDependencies": {
28
- "@cplieger/web-terminal-engine": ">=2.0.0"
28
+ "@cplieger/web-terminal-engine": ">=2.2.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@eslint/js": "10.0.1",
@@ -54,6 +54,10 @@ interface SessionInfo {
54
54
  title: string;
55
55
  createdAt: string;
56
56
  status: string;
57
+ /** Sticky: true once the session has emitted a genuine activity signal
58
+ * (OSC 9;4 progress, or a classified OSC 9 notification). Gates whether the
59
+ * per-tab activity dot is shown; see paintStatusDot / baseLabel. */
60
+ reportsActivity?: boolean;
57
61
  }
58
62
 
59
63
  interface Tab {
@@ -72,10 +76,18 @@ interface Tab {
72
76
  aria: TabHandle;
73
77
  scrollTop: number;
74
78
  following: boolean;
75
- /** A title derived from the first line the user submitted into this tab, used
76
- * in preference to the process window title (kiro-cli's OSC 0 title is only
77
- * the cwd for a live session, so it never reflects the conversation).
78
- * Undefined until the first non-empty, non-slash submission. */
79
+ /** Sticky: true once this session emitted a genuine activity signal (OSC 9;4).
80
+ * Its activity dot is shown only while true; a session that never reports
81
+ * activity (a plain shell) keeps a clean, dot-less tab. Fed from the server's
82
+ * reportsActivity via applyStatus. */
83
+ reports: boolean;
84
+ /** A fallback title derived from the LAST non-empty line the user submitted
85
+ * into this tab (updated on every Enter). Used only when the process sets no
86
+ * window title of its own: the OSC 0/2 title (`title` above) takes precedence
87
+ * when present and keeps updating, and this is what the tab reads as when it
88
+ * does not — a plain shell with no PROMPT_COMMAND title, or kiro-cli with its
89
+ * cwd-only title disabled. Undefined until the first non-empty submission; see
90
+ * baseLabel. */
79
91
  derived?: string;
80
92
  }
81
93
 
@@ -291,14 +303,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
291
303
  });
292
304
  swReserve.observe(swBar);
293
305
 
294
- // Activity dots are opt-in: shown only when a status source
295
- // (activityMonitor) is wired, so a generic bash/sh terminal gets clean,
296
- // label-only tabs. CSS hides .wt-status-dot unless its container carries
297
- // .wt-status; the agent consumer (vibecli) provides the monitor.
298
- if (monitor) {
299
- bar.classList.add("wt-status");
300
- switcher.classList.add("wt-status");
301
- }
306
+ // Activity dots are revealed PER TAB, not chrome-wide: each dot stays
307
+ // hidden (CSS: .wt-status-dot { display: none }) until its session reports
308
+ // activity (OSC 9;4 progress or a classified OSC 9 notification), at which
309
+ // point paintStatusDot adds .wt-reports to reveal it (see applyStatus /
310
+ // syncMobile / updateRow). A program that emits no OSC 9 signal (a plain
311
+ // bash/sh) keeps clean, label-only tabs; an agent's tabs light up. The
312
+ // monitor (below) is the live source; without it the poll fallback feeds
313
+ // the same reportsActivity flag.
302
314
 
303
315
  // Catching-up cue: a switched-into tab's cached screen is stale until its
304
316
  // resume delta lands, so it must not read as live (sections 12/13). Shown
@@ -354,19 +366,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
354
366
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
355
367
 
356
368
  // baseLabel is a tab's label before de-duplication. Preference order: the
357
- // title derived from the user's first submitted line (best for a chat like
358
- // kiro-cli, whose OSC 0 title only ever reflects the cwd for a live
359
- // session), then the process window title (OSC 0/2, e.g. a shell's), then a
360
- // plain "New tab". fallback=true marks that last case so relabelAll leaves
369
+ // process window title (OSC 0/2, e.g. a shell's PROMPT_COMMAND title),
370
+ // which takes precedence whenever the program sets one and keeps updating
371
+ // as it changes (the status SSE re-pushes it); then a fallback derived from
372
+ // the last line the user submitted, for a program that sets no title (a
373
+ // bare shell, or kiro-cli with its cwd-only title disabled); then a plain
374
+ // "New tab". fallback=true marks that last case so relabelAll leaves
361
375
  // untitled tabs as "New tab" with no numeric suffix.
362
376
  function baseLabel(tab: Tab): { text: string; fallback: boolean } {
363
- const real = tab.derived?.trim() ?? tab.title.trim();
377
+ const real = tab.title.trim() || (tab.derived?.trim() ?? "");
364
378
  return real ? { text: real, fallback: false } : { text: "New tab", fallback: true };
365
379
  }
366
380
 
367
381
  // relabelAll recomputes every tab's display label with de-duplication:
368
- // when two tabs resolve to the same base label (e.g. two tabs whose first
369
- // submitted line was identical, or two shells with the same window title),
382
+ // when two tabs resolve to the same base label (e.g. two shells with the
383
+ // same window title, or two tabs whose last submitted line was identical),
370
384
  // the second and later get a " (k)" suffix in creation order, so the strip
371
385
  // never shows two identical labels.
372
386
  function relabelAll(): void {
@@ -396,16 +410,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
396
410
  }
397
411
  }
398
412
 
399
- // --- Title derivation from the user's first submitted line ---
400
- // kiro-cli's OSC 0 title is only the cwd for a live session (it reloads its
401
- // session title just when the session id changes, not per turn), so the
402
- // useful, updating title for a chat is the user's first message. The kernel
403
- // routes input to the active session, so an input observer's bytes belong
404
- // to activeId. A tiny line editor tracks the current input line (handling
405
- // backspace and skipping the escape sequences arrow keys and bracketed
406
- // paste emit) and locks the tab's title on the first non-empty, non-slash
407
- // submission. Best-effort: an odd editing sequence just yields no derived
408
- // title, falling back to the window title or "Tab N".
413
+ // --- Fallback title derivation from the user's submitted lines ---
414
+ // Used only when the process sets no OSC window title of its own (baseLabel
415
+ // prefers tab.title). A shell without a PROMPT_COMMAND title, or kiro-cli
416
+ // with its cwd-only title disabled, sets none — so the useful fallback is
417
+ // the last line the user submitted. The kernel routes input to the active
418
+ // session, so an input observer's bytes belong to activeId. A tiny line
419
+ // editor tracks the current input line (handling backspace and skipping
420
+ // the escape sequences arrow keys and bracketed paste emit) and updates the
421
+ // tab's derived title on every non-empty submission. Best-effort: an odd
422
+ // editing sequence just yields no derived title (the tab then reads "New
423
+ // tab" until the next submission).
409
424
  const MAX_DERIVED = 60;
410
425
  let lineBytes: number[] = [];
411
426
  let escState = 0; // 0 normal, 1 saw ESC, 2 in CSI, 3 in SS3 (one more byte)
@@ -415,8 +430,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
415
430
  }
416
431
  function deriveTitleFromInput(bytes: Uint8Array): void {
417
432
  const t = tabList.find((x) => x.id === activeId);
418
- if (!t || t.derived !== undefined) {
419
- return; // no active tab, or its first submission is already captured
433
+ if (!t) {
434
+ return; // no active tab
420
435
  }
421
436
  for (const b of bytes) {
422
437
  if (escState === 1) {
@@ -438,10 +453,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
438
453
  } else if (b === 0x0d || b === 0x0a) {
439
454
  const line = new TextDecoder().decode(new Uint8Array(lineBytes)).trim();
440
455
  lineBytes = [];
441
- if (line && !line.startsWith("/")) {
456
+ // Every non-empty submitted line updates the fallback title (the
457
+ // last one wins); a leading "/" counts, since it is a valid shell
458
+ // command path.
459
+ if (line) {
442
460
  t.derived = line.slice(0, MAX_DERIVED);
443
461
  syncChrome();
444
- return;
445
462
  }
446
463
  } else if (b === 0x7f || b === 0x08) {
447
464
  // Backspace: drop one codepoint (pop UTF-8 continuation bytes + lead).
@@ -482,7 +499,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
482
499
  const idx = tabList.findIndex((t) => t.id === activeId);
483
500
  const active = idx >= 0 ? tabList[idx] : undefined;
484
501
  swLabel.textContent = active ? active.display : "";
485
- swDot.dataset["status"] = active?.dot.dataset["status"] ?? "idle";
502
+ paintStatusDot(swDot, active?.dot.dataset["status"] ?? "idle", active?.reports ?? false);
486
503
  const bgInput = tabList.some(
487
504
  (t) => t.id !== activeId && t.dot.dataset["status"] === "input",
488
505
  );
@@ -512,7 +529,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
512
529
  }
513
530
  // updateRow refreshes a reused row's live bits (status dot + label).
514
531
  function updateRow(row: HTMLElement, t: Tab): void {
515
- pick(row, ".wt-switcher-row-dot").dataset["status"] = t.dot.dataset["status"] ?? "idle";
532
+ paintStatusDot(
533
+ pick(row, ".wt-switcher-row-dot"),
534
+ t.dot.dataset["status"] ?? "idle",
535
+ t.reports,
536
+ );
516
537
  pick(row, ".wt-switcher-row-label").textContent = t.display;
517
538
  }
518
539
  // animateRowIn / animateRowOut give a listed tab an enter / leave motion:
@@ -813,7 +834,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
813
834
  if (!label || !dot || !close) {
814
835
  throw new Error("web-terminal-ui: tab chrome missing parts");
815
836
  }
816
- dot.dataset["status"] = info.status || "idle";
837
+ paintStatusDot(dot, info.status, info.reportsActivity ?? false);
817
838
  const aria = tablist.registerTab(el);
818
839
  // Insert before the "+" button so it stays the last item in the strip.
819
840
  bar.insertBefore(el, newBtn);
@@ -839,6 +860,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
839
860
  aria,
840
861
  scrollTop: 0,
841
862
  following: true,
863
+ reports: info.reportsActivity ?? false,
842
864
  };
843
865
  // Set an initial label immediately (relabelAll refines it with de-dup
844
866
  // once the tab is in tabList and syncChrome runs).
@@ -1019,6 +1041,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1019
1041
  clearCatchup();
1020
1042
  });
1021
1043
 
1044
+ // Live window-title updates for the active session: the engine sends a
1045
+ // TITLE frame on the live socket whenever the process changes its OSC 0/2
1046
+ // title, which the kernel republishes as wire:title. baseLabel prefers the
1047
+ // OSC title, so applying it here updates the active tab's label at once
1048
+ // rather than waiting for the next status-SSE/poll sweep (background tabs,
1049
+ // which have no live socket, still refresh their title from that sweep).
1050
+ ctx.on("wire:title", ({ session, title }) => {
1051
+ const t = tabList.find((x) => x.id === session);
1052
+ if (t && title !== t.title) {
1053
+ t.title = title;
1054
+ syncChrome();
1055
+ }
1056
+ });
1057
+
1022
1058
  // switchRelative moves delta tabs from the active one (swipe left = next).
1023
1059
  // The direction feeds the slide animation (next slides in from the right,
1024
1060
  // prev from the left).
@@ -1334,14 +1370,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1334
1370
  ctx.toast("Swipe to switch terminals");
1335
1371
  }
1336
1372
 
1337
- // applyStatus updates one tab's dot + title from a status record (shared by
1338
- // the SSE monitor and the polling fallback).
1339
- function applyStatus(id: string, status: string, title: string | undefined): void {
1373
+ // applyStatus updates one tab's dot (status + reveal) + title from a status
1374
+ // record (shared by the SSE monitor and the polling fallback). reports is
1375
+ // the server's sticky reportsActivity flag; it gates the dot's visibility.
1376
+ function applyStatus(
1377
+ id: string,
1378
+ status: string,
1379
+ title: string | undefined,
1380
+ reports: boolean,
1381
+ ): void {
1340
1382
  const t = tabList.find((tab) => tab.id === id);
1341
1383
  if (!t) {
1342
1384
  return;
1343
1385
  }
1344
- t.dot.dataset["status"] = status || "idle";
1386
+ t.reports = reports;
1387
+ paintStatusDot(t.dot, status, reports);
1345
1388
  // Record the raw server title; the displayed label (fallback + de-dup)
1346
1389
  // is recomputed by relabelAll via syncChrome, which the callers run
1347
1390
  // right after applyStatus.
@@ -1363,8 +1406,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1363
1406
  return;
1364
1407
  }
1365
1408
  // Adopt a session created in another browser so all clients converge.
1366
- adoptSession({ id: s.id, title: s.title, createdAt: s.createdAt, status: s.status });
1367
- applyStatus(s.id, s.status, s.title);
1409
+ adoptSession({
1410
+ id: s.id,
1411
+ title: s.title,
1412
+ createdAt: s.createdAt,
1413
+ status: s.status,
1414
+ reportsActivity: s.reportsActivity ?? false,
1415
+ });
1416
+ applyStatus(s.id, s.status, s.title, s.reportsActivity ?? false);
1368
1417
  syncChrome();
1369
1418
  });
1370
1419
  } else {
@@ -1379,7 +1428,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1379
1428
  const seen = new Set(list.map((s) => s.id));
1380
1429
  for (const info of list) {
1381
1430
  adoptSession(info); // add sessions created elsewhere (no local tab)
1382
- applyStatus(info.id, info.status, info.title);
1431
+ applyStatus(info.id, info.status, info.title, info.reportsActivity ?? false);
1383
1432
  }
1384
1433
  // A tab the server no longer lists was reaped/closed elsewhere: drop it
1385
1434
  // locally (no DELETE — it is already gone).
@@ -1928,6 +1977,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1928
1977
  };
1929
1978
  }
1930
1979
 
1980
+ // paintStatusDot applies a status dot's two orthogonal bits: data-status drives
1981
+ // its appearance (idle / working / done / input / exited via CSS), and the
1982
+ // .wt-reports class controls its visibility — the dot is hidden by default and
1983
+ // shown only once the session has reported activity (OSC 9;4 progress or a
1984
+ // classified OSC 9 notification), so a plain shell's tabs stay clean and
1985
+ // label-only while an agent's light up.
1986
+ function paintStatusDot(el: HTMLElement, status: string, reports: boolean): void {
1987
+ el.dataset["status"] = status || "idle";
1988
+ el.classList.toggle("wt-reports", reports);
1989
+ }
1990
+
1931
1991
  // pick returns a required descendant element or throws (static chrome only).
1932
1992
  function pick(root: ParentNode, selector: string): HTMLElement {
1933
1993
  const el = root.querySelector<HTMLElement>(selector);
@@ -156,6 +156,17 @@ export function createTerminal(
156
156
  if (!connection.sendBinary(out)) {
157
157
  return;
158
158
  }
159
+ // Classic-terminal behavior (GNOME/xterm): user input re-engages follow and
160
+ // snaps to the bottom, so typing while scrolled up jumps to the input line.
161
+ // Instant (not the jump-button's smooth scroll), so it isn't janky per
162
+ // keystroke. This is the ONLY thing that scrolls a held view down — program
163
+ // output never does (the scroll controller's follow/hold job is unchanged),
164
+ // so scrolling up to read while output streams is preserved. A no-op on the
165
+ // alt screen (no scrollback) and when already following. NOTE: every caller
166
+ // of sendBytes today is genuine user input (typed keys, paste, mobile
167
+ // toolbar); if mouse tracking is ever wired through ctx.send, its motion
168
+ // bytes must NOT come through here or the view would snap on every move.
169
+ scroll.scrollToBottom();
159
170
  for (const obs of inputObservers) {
160
171
  obs(out);
161
172
  }
package/src/presets.ts CHANGED
@@ -4,11 +4,13 @@
4
4
  // lists (convenient, full UI); a consumer wanting a minimal footprint imports
5
5
  // individual features instead.
6
6
  //
7
- // The tabbed presets split by consumer: presetTabbed is the generic label-only
8
- // tabbed UI (a plain bash/sh terminal, the first-class experience); the derived
9
- // presetAgentTabbed layers the activity monitor on top, so an agent shell like
10
- // vibecli gets per-tab status dots. The split keeps agent semantics out of the
11
- // generic terminal (design "generic first, vibecli is a derivation").
7
+ // The tabbed presets (presetTabbed / presetAgentTabbed) are now identical: both
8
+ // include the activity monitor, because the per-tab activity dot reveals itself
9
+ // only when a session reports activity (OSC 9;4 progress), so a plain shell
10
+ // simply never shows one. The generic-vs-agent distinction is now purely
11
+ // server-side — an agent server sets a status classifier that maps OSC 9
12
+ // notifications to done/needs-input. presetAgentTabbed is kept as an alias for
13
+ // its consumers (vibecli).
12
14
 
13
15
  import type { TerminalFeature } from "./kernel/types.js";
14
16
  import { clipboard } from "./features/clipboard.js";
@@ -19,7 +21,6 @@ import { connectionBanner } from "./features/connection-banner.js";
19
21
  import { mobileToolbar } from "./features/mobile-toolbar.js";
20
22
  import { tabs } from "./features/tabs.js";
21
23
  import { activityMonitor } from "./features/activity-monitor.js";
22
- import type { ActivityMonitorApi } from "./features/activity-monitor.js";
23
24
  import { animations } from "./features/animations.js";
24
25
 
25
26
  /** Single-pane desktop UI: context menu, clipboard, scroll-to-bottom, predictive
@@ -42,35 +43,41 @@ export function presetTouch(): TerminalFeature<unknown>[] {
42
43
  return [...presetSingle(), mobileToolbar()];
43
44
  }
44
45
 
45
- // buildTabbed composes the tabbed UI shared by both tabbed presets: the
46
- // single-pane touch features, an externally-driven mobile toolbar (its grid is
47
- // opened from a keyboard button in the tab bar, not its own toggle), tabs wired
48
- // to that toolbar, and animations. An optional activity monitor adds per-tab
49
- // status dots (the agent variant). The toolbar and monitor are ordered before
50
- // tabs because tabs reads their APIs via ctx.use.
51
- function buildTabbed(monitor?: TerminalFeature<ActivityMonitorApi>): TerminalFeature<unknown>[] {
46
+ // buildTabbed composes the tabbed UI: the single-pane touch features, an
47
+ // externally-driven mobile toolbar (its grid is opened from a keyboard button in
48
+ // the tab bar, not its own toggle), the activity monitor (the status-SSE data
49
+ // source), tabs wired to both, and animations. The activity dot reveals itself
50
+ // per tab only when a session reports activity (OSC 9;4), so the monitor is
51
+ // always included — a plain shell just never reveals a dot. The toolbar and
52
+ // monitor are ordered before tabs because tabs reads their APIs via ctx.use.
53
+ function buildTabbed(): TerminalFeature<unknown>[] {
52
54
  const kb = mobileToolbar({ externalToggle: true });
55
+ const monitor = activityMonitor();
53
56
  return [
54
57
  ...presetSingle(),
55
58
  kb,
56
- ...(monitor ? [monitor] : []),
57
- tabs({ keyboardToggle: kb, ...(monitor ? { activityMonitor: monitor } : {}) }),
59
+ monitor,
60
+ tabs({ keyboardToggle: kb, activityMonitor: monitor }),
58
61
  animations(),
59
62
  ];
60
63
  }
61
64
 
62
- /** Generic tabbed UI: touch features, label-only tabs, the mobile keyboard bar,
63
- * and animations. No activity monitor, so tabs carry no status dots. This is
64
- * the first-class experience for a plain terminal (bash/sh) where "agent
65
- * activity" has no meaning. An agent shell wants presetAgentTabbed instead. */
65
+ /** Tabbed UI: the touch features, tabs, the mobile keyboard bar, the activity
66
+ * monitor, and animations. Requires a server that speaks the session API
67
+ * (`/api/sessions`, `/ws?session=`, and the status SSE `/api/sessions/events`),
68
+ * such as `web-terminal-server` or `vibecli`. Each tab's title follows the
69
+ * process OSC 0/2 window title when the program sets one, else the last
70
+ * submitted line; each tab's activity dot stays hidden until its session
71
+ * reports activity via OSC 9;4 progress (kiro-cli, Claude Code, …), so a plain
72
+ * bash/sh keeps clean, label-only tabs. */
66
73
  export function presetTabbed(): TerminalFeature<unknown>[] {
67
74
  return buildTabbed();
68
75
  }
69
76
 
70
- /** Agent-shell tabbed UI: presetTabbed plus the activity monitor, so each tab
71
- * shows a live status dot (idle / working / done / needs-input) from the
72
- * server's status SSE. vibecli composes this; a generic terminal composes
73
- * presetTabbed. */
77
+ /** Alias of presetTabbed, kept for the agent-shell consumer (vibecli). The UI is
78
+ * identical — the generic-vs-agent distinction is now purely server-side: an
79
+ * agent server sets a status classifier (mapping OSC 9 notifications to
80
+ * done/needs-input), and each tab's dot reveals itself from OSC 9;4 either way. */
74
81
  export function presetAgentTabbed(): TerminalFeature<unknown>[] {
75
- return buildTabbed(activityMonitor());
82
+ return buildTabbed();
76
83
  }