@cplieger/web-terminal-ui 8.4.0-dev.2 → 8.4.0-dev.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/README.md CHANGED
@@ -14,7 +14,7 @@ web-terminal-ui is built for a shell or a coding agent served by web-terminal-en
14
14
 
15
15
  - One `createTerminal()` call builds the UI inside an element you provide. Four presets cover a single, a touch and two tabbed layouts.
16
16
  - On a touchscreen it adds a key bar with Tab, Esc, arrows, Enter and a sticky Ctrl, a long-press paste menu and iOS soft-keyboard handling.
17
- - Tabs show activity dots from the program's `OSC 9;4` progress reports and raise browser notifications. Two tabs can sit side by side.
17
+ - Tabs show activity dots from the program's `OSC 9;4` progress reports and raise browser notifications. Two tabs can sit side by side. A full-page terminal gives each tab and each split its own page address, and names the browser tab after the active tab, followed by the page's own title: `fix build · Web Terminal`.
18
18
  - Native text selection, IME input and dictation work, and predictive echo hides latency.
19
19
 
20
20
  Consider [xterm.js](https://github.com/xtermjs/xterm.js) if you want a terminal component for any PTY backend, such as node-pty. It has an optional GPU-accelerated renderer and a set of addons, and VS Code uses it. To build a UI of your own on web-terminal-engine, depend on the engine alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "8.4.0-dev.2",
3
+ "version": "8.4.0-dev.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": "MPL-2.0",
@@ -33,8 +33,12 @@
33
33
  "test": "vitest --run",
34
34
  "test:watch": "vitest",
35
35
  "test:coverage": "vitest --run --coverage",
36
+ "test:browsers": "playwright install chromium",
37
+ "test:mutation": "stryker run",
36
38
  "lint:eslint": "eslint .",
37
39
  "lint:prettier": "prettier --check .",
40
+ "lint:css": "stylelint \"css/**/*.css\"",
41
+ "lint:html": "html-validate scaffold/index.html",
38
42
  "lint:fix": "eslint . --fix && prettier --write .",
39
43
  "lint:knip": "knip",
40
44
  "verify": "bash scripts/verify.sh"
@@ -58,7 +62,7 @@
58
62
  "eslint": "10.12.0",
59
63
  "fast-check": "4.10.2",
60
64
  "html-validate": "11.16.2",
61
- "knip": "6.39.0",
65
+ "knip": "6.40.0",
62
66
  "playwright": "1.63.0",
63
67
  "prettier": "3.9.9",
64
68
  "stylelint": "17.16.0",
@@ -0,0 +1,138 @@
1
+ /** How a fragment reached the page. A deep link (a fresh load, a typed fragment)
2
+ * earns a notice when it names a tab that is gone; history and restore, which
3
+ * replay entries the page itself wrote, are corrected silently. */
4
+ export type RouteOrigin = "deeplink" | "history" | "restore";
5
+
6
+ interface AddressBarOptions {
7
+ /** The fragment for what the page shows now (`#...`, or `""` for none), or null
8
+ * while there is nothing to name, which leaves the address bar alone. */
9
+ readonly read: () => string | null;
10
+ /** Show what `fragment` names. Writes the page schedules from inside are
11
+ * dropped; the address bar canonicalises once it returns. */
12
+ readonly apply: (fragment: string, origin: RouteOrigin) => void;
13
+ }
14
+
15
+ export interface AddressBar {
16
+ /** Write the current fragment after this task: a push adds a history entry, a
17
+ * replace rewrites the current one, and a push wins when both are asked for.
18
+ * Nothing is written before `start` or while applying, and a fragment already
19
+ * the address bar's adds no entry (an unmarked one is only re-marked). */
20
+ schedule(mode: "push" | "replace"): void;
21
+ /** Where this page load came from, for the boot's notice decision. */
22
+ bootOrigin(): RouteOrigin;
23
+ /** Start answering navigation: replace the current entry with what is shown
24
+ * (when `canonicalize`), then follow every fragment change. */
25
+ start(canonicalize: boolean): void;
26
+ dispose(): void;
27
+ }
28
+
29
+ // Marks a history entry this page wrote, so a back or forward onto it is told
30
+ // from a fragment someone typed (whose entry carries no state).
31
+ const ENTRY_MARK = "wtTabs";
32
+
33
+ function isMarked(state: unknown): boolean {
34
+ return typeof state === "object" && state !== null && Object.hasOwn(state, ENTRY_MARK);
35
+ }
36
+
37
+ /** The page address as the tabs feature's: it writes the fragment, never the
38
+ * path or the query, and it reacts to the fragment changing under it. */
39
+ export function createAddressBar(win: Window, opts: AddressBarOptions): AddressBar {
40
+ let started = false;
41
+ let disposed = false;
42
+ let applying = 0;
43
+ let pending: "push" | "replace" | null = null;
44
+ let timer: number | null = null;
45
+ // The fragment last applied or written. popstate and hashchange both fire for
46
+ // one fragment navigation, and the second must not apply it again.
47
+ let known = win.location.hash;
48
+
49
+ function cancel(): void {
50
+ if (timer !== null) {
51
+ win.clearTimeout(timer);
52
+ timer = null;
53
+ }
54
+ pending = null;
55
+ }
56
+
57
+ function write(mode: "push" | "replace"): void {
58
+ const target = opts.read();
59
+ if (target === null) {
60
+ return;
61
+ }
62
+ const current = win.location.hash;
63
+ if (target === current && isMarked(win.history.state)) {
64
+ known = current;
65
+ return;
66
+ }
67
+ const url = new URL(win.location.href);
68
+ url.hash = target;
69
+ // An entry for a page with no addressable tab is not one worth going back to.
70
+ if (mode === "push" && target !== current && target !== "") {
71
+ win.history.pushState({ [ENTRY_MARK]: 1 }, "", url);
72
+ } else {
73
+ const prior: unknown = win.history.state;
74
+ const state = typeof prior === "object" && prior !== null ? { ...prior } : {};
75
+ win.history.replaceState({ ...state, [ENTRY_MARK]: 1 }, "", url);
76
+ }
77
+ known = win.location.hash;
78
+ }
79
+
80
+ function onNavigate(): void {
81
+ const fragment = win.location.hash;
82
+ if (fragment === known) {
83
+ return;
84
+ }
85
+ known = fragment;
86
+ const origin: RouteOrigin = isMarked(win.history.state) ? "history" : "deeplink";
87
+ cancel();
88
+ applying++;
89
+ try {
90
+ opts.apply(fragment, origin);
91
+ } finally {
92
+ applying--;
93
+ }
94
+ write("replace");
95
+ }
96
+
97
+ return {
98
+ schedule(mode) {
99
+ if (!started || disposed || applying > 0) {
100
+ return;
101
+ }
102
+ if (pending !== "push") {
103
+ pending = mode;
104
+ }
105
+ timer ??= win.setTimeout(() => {
106
+ timer = null;
107
+ const m = pending;
108
+ pending = null;
109
+ if (m !== null && !disposed) {
110
+ write(m);
111
+ }
112
+ }, 0);
113
+ },
114
+ bootOrigin() {
115
+ const entry: unknown = win.performance.getEntriesByType("navigation")[0];
116
+ const type =
117
+ typeof entry === "object" && entry !== null && "type" in entry ? entry.type : null;
118
+ return type === "reload" || type === "back_forward" ? "restore" : "deeplink";
119
+ },
120
+ start(canonicalize) {
121
+ if (started || disposed) {
122
+ return;
123
+ }
124
+ started = true;
125
+ if (canonicalize) {
126
+ write("replace");
127
+ }
128
+ win.addEventListener("popstate", onNavigate);
129
+ win.addEventListener("hashchange", onNavigate);
130
+ },
131
+ dispose() {
132
+ disposed = true;
133
+ cancel();
134
+ win.removeEventListener("popstate", onNavigate);
135
+ win.removeEventListener("hashchange", onNavigate);
136
+ },
137
+ };
138
+ }
@@ -19,6 +19,8 @@ import { createClickSwallow, placeMenuAt } from "../menu-position.js";
19
19
  import { snapToDevicePixels } from "./device-snap.js";
20
20
  import { centreChipLabels } from "./ink-centre.js";
21
21
  import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
22
+ import { createAddressBar, type AddressBar, type RouteOrigin } from "./address-bar.js";
23
+ import { formatTabRoute, parseTabAlias, parseTabRoute, type TabAlias } from "./route.js";
22
24
  import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
23
25
  import {
24
26
  CUE_SEEN_KEY,
@@ -95,6 +97,19 @@ const DEFAULT_POLL_MS = 4000;
95
97
  // URL and NAVIGATES to it when no handler cancels the drop, so a bare session id
96
98
  // loaded /<session-id> on iPadOS. It also keeps the id out of the pasteboard.
97
99
  const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
100
+ const STALE_TAB_NOTICE = "That tab is no longer open";
101
+
102
+ // Whether two records show the same tabs the same way; the handle is a device's
103
+ // preference and does not count.
104
+ function sameShownLayout(a: PaneLayout, b: PaneLayout | null): boolean {
105
+ return (
106
+ b !== null &&
107
+ a.left === b.left &&
108
+ a.right === b.right &&
109
+ a.open === b.open &&
110
+ a.selected === b.selected
111
+ );
112
+ }
98
113
 
99
114
  /** The value a peer or a host reads through `ctx.use(tabs(...))`. `create` and
100
115
  * `close` are server round trips and NEITHER rejects: a refusal is toasted and
@@ -781,6 +796,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
781
796
  // The record is read once and never written back by a read.
782
797
  let applyingRecord = false;
783
798
  let warnedLayout = false;
799
+ // The page address names the shown tabs only when the terminal owns the page.
800
+ const address: AddressBar | null =
801
+ ctx.shell.layoutMode === "viewport"
802
+ ? createAddressBar(win, { read: currentFragment, apply: applyFragment })
803
+ : null;
804
+ if (address !== null) {
805
+ ctx.defer(() => {
806
+ address.dispose();
807
+ });
808
+ }
809
+ // The page that owns its address is named after its active tab too.
810
+ const pageTitle = address === null ? null : ctx.shell.claimPageTitle();
811
+ if (pageTitle !== null) {
812
+ ctx.defer(() => {
813
+ pageTitle.release();
814
+ });
815
+ }
784
816
  // Twin of marotte static-src/tabs-drag.ts; when changing drag behaviour or its
785
817
  // constants, check the other app. Both judge a live drag's viewport against the
786
818
  // PRESS-time frame, so a move a hold lifted across still ends the drag.
@@ -1244,6 +1276,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1244
1276
  // Every list mutation ends in syncChrome, so no path can forget the
1245
1277
  // out-of-page surfaces; the fold is idempotent and the sinks no-op.
1246
1278
  paintAttention();
1279
+ const active = selectedId();
1280
+ pageTitle?.set(tabList.find((t) => t.id === active)?.display ?? null);
1247
1281
  }
1248
1282
 
1249
1283
  // The percentage is deliberately NOT written into the document title: a page
@@ -1391,6 +1425,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1391
1425
 
1392
1426
  const tab: Tab = {
1393
1427
  id: info.id,
1428
+ alias: parseTabAlias(info.alias) ?? undefined,
1394
1429
  born: ++tabEpoch,
1395
1430
  title: info.title,
1396
1431
  pinnedTitle: info.pinnedTitle,
@@ -1650,6 +1685,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1650
1685
  ctx.shell.select(side);
1651
1686
  }
1652
1687
  scheduleLayoutWrite();
1688
+ address?.schedule("push");
1653
1689
  // Only when the user is about to wait: a revisited tab with a warm store
1654
1690
  // paints from cache in one frame and must not flash a cue at every switch.
1655
1691
  if (
@@ -3066,6 +3102,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3066
3102
  if (rec.order !== undefined) {
3067
3103
  t.order = rec.order;
3068
3104
  }
3105
+ // A changed alias renames the tab it already is, so the address it is
3106
+ // shown under is rewritten in place rather than pushed.
3107
+ if (rec.alias !== undefined) {
3108
+ const alias = parseTabAlias(rec.alias) ?? undefined;
3109
+ if (alias !== t.alias) {
3110
+ t.alias = alias;
3111
+ address?.schedule("replace");
3112
+ }
3113
+ }
3069
3114
  paintStatusDot(t.dot, rec.status, reports);
3070
3115
  // Only a PRESENT percentage updates the tab: the polling fallback lists
3071
3116
  // SessionInfo, which carries none, and reading absence as cleared would
@@ -3193,6 +3238,98 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3193
3238
  open: split.open,
3194
3239
  };
3195
3240
  }
3241
+ // Own-field equality over the list, never a keyed object, so an alias such
3242
+ // as `constructor` finds nothing rather than Object.prototype.
3243
+ function tabByAlias(alias: TabAlias | null): Tab | undefined {
3244
+ return alias === null ? undefined : tabList.find((t) => t.alias === alias);
3245
+ }
3246
+ /** The fragment naming what the panes show: `""` when a shown tab has no
3247
+ * alias, null while no pane shows a tab. */
3248
+ function currentFragment(): string | null {
3249
+ const left = shownIn("left");
3250
+ const right = shownIn("right");
3251
+ if (left === null && right === null) {
3252
+ return null;
3253
+ }
3254
+ const aliasOf = (id: string | null): TabAlias | null | undefined =>
3255
+ id === null ? null : tabList.find((t) => t.id === id)?.alias;
3256
+ const l = aliasOf(left);
3257
+ const r = aliasOf(right);
3258
+ if (l === undefined || r === undefined) {
3259
+ return "";
3260
+ }
3261
+ if (!ctx.shell.split.isOpen()) {
3262
+ const tab = l ?? r;
3263
+ return tab === null ? "" : formatTabRoute({ kind: "single", tab });
3264
+ }
3265
+ return formatTabRoute({ kind: "split", left: l, right: r });
3266
+ }
3267
+ /** Show what a fragment the user navigated to names. Only tabs already open
3268
+ * are shown: a URL never creates or closes a session, because a history
3269
+ * entry would otherwise resurrect a tab closed on another device. */
3270
+ function applyFragment(fragment: string, origin: RouteOrigin): void {
3271
+ const route = parseTabRoute(fragment);
3272
+ if (route === null) {
3273
+ return;
3274
+ }
3275
+ const [wantLeft, wantRight] =
3276
+ route.kind === "single" ? [route.tab, null] : [route.left, route.right];
3277
+ const left = tabByAlias(wantLeft);
3278
+ const right = tabByAlias(wantRight);
3279
+ if (
3280
+ origin === "deeplink" &&
3281
+ ((wantLeft !== null && !left) || (wantRight !== null && !right))
3282
+ ) {
3283
+ ctx.toast(STALE_TAB_NOTICE);
3284
+ }
3285
+ const first = left ?? right;
3286
+ if (first === undefined) {
3287
+ return;
3288
+ }
3289
+ const split = ctx.shell.split;
3290
+ let wantSplit = route.kind === "split" && split.enabled;
3291
+ if (wantSplit && !split.isOpen() && !split.canOpen()) {
3292
+ wantSplit = false;
3293
+ ctx.toast("Split view needs a wider window");
3294
+ }
3295
+ if (!wantSplit) {
3296
+ if (split.isOpen()) {
3297
+ // Closing keeps the selected pane's tab, so select the one wanted.
3298
+ const at = sideOf(first.id);
3299
+ if (at !== null) {
3300
+ ctx.shell.select(at);
3301
+ }
3302
+ split.close();
3303
+ }
3304
+ if (sideOf(first.id) === null) {
3305
+ showIn(ctx.shell.selected(), first.id, { keepSelection: true });
3306
+ }
3307
+ return;
3308
+ }
3309
+ if (!split.isOpen() && !split.open()) {
3310
+ return;
3311
+ }
3312
+ const want: Record<PaneSide, Tab | undefined> = { left, right };
3313
+ // The selected side goes first: filling the peer first could empty the
3314
+ // selected pane (a tab moving across), and showIn then moves the
3315
+ // selection to the peer. A moving tab is emptied from its old side by showIn.
3316
+ const kept = ctx.shell.selected();
3317
+ for (const side of [kept, otherSide(kept)]) {
3318
+ const tab = want[side];
3319
+ if (tab !== undefined && shownIn(side) !== tab.id) {
3320
+ showIn(side, tab.id, { keepSelection: true });
3321
+ }
3322
+ }
3323
+ for (const side of ["left", "right"] as const) {
3324
+ if (want[side] === undefined && shownIn(side) !== null) {
3325
+ ctx.shell.pane(side)?.clearActiveSession();
3326
+ }
3327
+ }
3328
+ const sel = ctx.shell.selected();
3329
+ if (shownIn(sel) === null && shownIn(otherSide(sel)) !== null) {
3330
+ ctx.shell.select(otherSide(sel));
3331
+ }
3332
+ }
3196
3333
  async function writeLayout(layout: PaneLayout, retryOnStale: boolean): Promise<void> {
3197
3334
  try {
3198
3335
  await api.setLayout(layout);
@@ -3274,6 +3411,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3274
3411
  syncCatchups();
3275
3412
  syncChrome();
3276
3413
  scheduleLayoutWrite();
3414
+ address?.schedule("push");
3277
3415
  }),
3278
3416
  );
3279
3417
  ctx.defer(ctx.shell.onSelectionChange(syncChrome));
@@ -4173,6 +4311,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4173
4311
  // its bootShowedNothing gate), or the SSE snapshot would win this race on
4174
4312
  // every load.
4175
4313
  if (anyShown()) {
4314
+ address?.start(listed.status === "fulfilled");
4176
4315
  return true;
4177
4316
  }
4178
4317
  // The record's sessions where they still exist, else the oldest LIVE tab:
@@ -4197,7 +4336,64 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4197
4336
  return true;
4198
4337
  };
4199
4338
  let shown = false;
4200
- if (layout?.open === true) {
4339
+ // The address outranks the record when it names an open tab: it is the
4340
+ // explicit instruction for this load. Read only with the server's list in
4341
+ // hand, so a failed list never declares a tab gone.
4342
+ const route =
4343
+ address !== null && listed.status === "fulfilled"
4344
+ ? parseTabRoute(win.location.hash)
4345
+ : null;
4346
+ if (route !== null && address !== null) {
4347
+ const [wantLeft, wantRight] =
4348
+ route.kind === "single" ? [route.tab, null] : [route.left, route.right];
4349
+ const fromRoute = (alias: TabAlias | null): Tab | undefined => {
4350
+ const tab = tabByAlias(alias);
4351
+ return tab === undefined ? undefined : saved(tab.id);
4352
+ };
4353
+ const sides: Record<PaneSide, Tab | undefined> = {
4354
+ left: fromRoute(wantLeft),
4355
+ right: fromRoute(wantRight),
4356
+ };
4357
+ // Gone means absent from the list: an ended tab still in the row is
4358
+ // refused here only because a boot never lands on a corpse.
4359
+ const gone = (alias: TabAlias | null): boolean =>
4360
+ alias !== null && tabByAlias(alias) === undefined;
4361
+ if ((gone(wantLeft) || gone(wantRight)) && address.bootOrigin() === "deeplink") {
4362
+ ctx.toast(STALE_TAB_NOTICE);
4363
+ }
4364
+ applyingRecord = true;
4365
+ try {
4366
+ if (route.kind === "split" && (sides.left || sides.right)) {
4367
+ const record =
4368
+ layout?.open === true &&
4369
+ layout.left === (sides.left?.id ?? null) &&
4370
+ layout.right === (sides.right?.id ?? null)
4371
+ ? layout.selected
4372
+ : null;
4373
+ const selected: PaneSide =
4374
+ record !== null && sides[record] ? record : sides.left ? "left" : "right";
4375
+ if (ctx.shell.restoreSplit(layout?.handle ?? 0.5, selected)) {
4376
+ for (const side of ["left", "right"] as const) {
4377
+ const tab = sides[side];
4378
+ if (tab) {
4379
+ shown = showAtBoot(side, tab) || shown;
4380
+ }
4381
+ }
4382
+ }
4383
+ }
4384
+ const only = sides.left ?? sides.right;
4385
+ if (!shown && only) {
4386
+ shown = showAtBoot(ctx.shell.targetFor(only.id), only);
4387
+ }
4388
+ } finally {
4389
+ applyingRecord = false;
4390
+ }
4391
+ // The shared record follows the address this load was opened at.
4392
+ if (shown && layout !== null && !sameShownLayout(layout, currentLayout())) {
4393
+ scheduleLayoutWrite();
4394
+ }
4395
+ }
4396
+ if (!shown && layout?.open === true) {
4201
4397
  const sides: Record<PaneSide, Tab | undefined> = {
4202
4398
  left: saved(layout.left),
4203
4399
  right: saved(layout.right),
@@ -4233,10 +4429,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4233
4429
  }
4234
4430
  if (!shown) {
4235
4431
  bootShowedNothing = true;
4432
+ address?.start(false);
4236
4433
  return false;
4237
4434
  }
4238
4435
  syncChrome();
4239
4436
  focusInput();
4437
+ address?.start(listed.status === "fulfilled");
4240
4438
  return true;
4241
4439
  };
4242
4440
  live = { resolveInitialLayout, shownIn, showIn };
@@ -4,14 +4,18 @@ import type { LineStore } from "@cplieger/web-terminal-engine";
4
4
  import type { SessionInfo } from "@cplieger/web-terminal-engine";
5
5
  import type { ViewMemory } from "@cplieger/web-terminal-engine";
6
6
  import type { PaneSide, TabHandle } from "../../kernel/types.js";
7
+ import type { TabAlias } from "./route.js";
7
8
 
8
9
  export type { SessionInfo };
9
10
 
10
11
  /** A status record as the tabs feature consumes it: the REST wire shape plus the
11
12
  * percentage that exists only on the status STREAM. The polling fallback lists
12
- * no percentage at all, which means "no information", not "cleared". */
13
+ * no percentage at all, which means "no information", not "cleared". `alias` is
14
+ * read as `unknown` because `parseTabAlias` validates it where it enters, and an
15
+ * engine before 6.2 sends none. */
13
16
  export type StatusRecord = SessionInfo & {
14
17
  readonly progressValue?: number;
18
+ readonly alias?: unknown;
15
19
  };
16
20
 
17
21
  // The status vocabulary is module-private so a status's MEANING has one home:
@@ -392,6 +396,10 @@ export function orderedInsertIndex(current: readonly TabOrderKey[], incoming: Ta
392
396
 
393
397
  export interface Tab {
394
398
  id: string;
399
+ /** The session's public URL name from the server, or undefined when it sent
400
+ * none (a server before engine 6.2) or one outside the alias grammar; such a
401
+ * tab has no address of its own. */
402
+ alias: TabAlias | undefined;
395
403
  /** The local mutation epoch at which this tab was adopted. The list reconcile
396
404
  * snapshots the counter BEFORE its GET and drops an unlisted tab only when
397
405
  * the tab predates the snapshot: a tab born while the list was in flight is
@@ -0,0 +1,65 @@
1
+ /** A session alias that has passed `parseTabAlias`: 1 to 64 characters of
2
+ * `[A-Za-z0-9_-]`, the grammar the engine's `SessionInfo.alias` documents. */
3
+ export type TabAlias = string & {
4
+ // deadset:ignore DS1003 -- the brand only makes TabAlias nominal; nothing reads it
5
+ readonly __brand: "TabAlias";
6
+ };
7
+
8
+ // A fragment rather than a path: only a fragment edit navigates without a reload,
9
+ // and a library mounted in someone else's page must not claim its path.
10
+ /** What a fragment shows: one tab, or the split with a tab (or nothing) in each
11
+ * pane. A split route names at least one side, and never one alias twice. */
12
+ export type TabRoute =
13
+ | { readonly kind: "single"; readonly tab: TabAlias }
14
+ | { readonly kind: "split"; readonly left: TabAlias | null; readonly right: TabAlias | null };
15
+
16
+ const ALIAS = /^[A-Za-z0-9_-]{1,64}$/;
17
+ // Two maximal aliases and the separator.
18
+ const MAX_ROUTE_LENGTH = 129;
19
+
20
+ /** The value as an alias, or null for anything outside the grammar, including a
21
+ * non-string. */
22
+ export function parseTabAlias(raw: unknown): TabAlias | null {
23
+ return typeof raw === "string" && ALIAS.test(raw) ? (raw as TabAlias) : null;
24
+ }
25
+
26
+ /** Parse `location.hash` (with or without its `#`): `#<alias>` is one tab and
27
+ * `#<left>,<right>` the split, either side possibly empty. Null for no fragment
28
+ * and for any malformed one. Nothing is percent-decoded: the alias alphabet
29
+ * needs no escaping, so an escape is malformed. */
30
+ export function parseTabRoute(hash: string): TabRoute | null {
31
+ const body = hash.startsWith("#") ? hash.slice(1) : hash;
32
+ if (body === "" || body.length > MAX_ROUTE_LENGTH) {
33
+ return null;
34
+ }
35
+ const parts = body.split(",");
36
+ if (parts.length === 1) {
37
+ const tab = parseTabAlias(body);
38
+ return tab === null ? null : { kind: "single", tab };
39
+ }
40
+ if (parts.length !== 2) {
41
+ return null;
42
+ }
43
+ const [rawLeft = "", rawRight = ""] = parts;
44
+ const left = rawLeft === "" ? null : parseTabAlias(rawLeft);
45
+ const right = rawRight === "" ? null : parseTabAlias(rawRight);
46
+ if ((rawLeft !== "" && left === null) || (rawRight !== "" && right === null)) {
47
+ return null;
48
+ }
49
+ if ((left === null && right === null) || left === right) {
50
+ return null;
51
+ }
52
+ return { kind: "split", left, right };
53
+ }
54
+
55
+ /** The fragment for `route`, `#` included, or `""` (no fragment) for null. The
56
+ * inverse of `parseTabRoute`. */
57
+ export function formatTabRoute(route: TabRoute | null): string {
58
+ if (route === null) {
59
+ return "";
60
+ }
61
+ if (route.kind === "single") {
62
+ return `#${route.tab}`;
63
+ }
64
+ return `#${route.left ?? ""},${route.right ?? ""}`;
65
+ }
@@ -394,18 +394,41 @@ function createShellInto(
394
394
  }
395
395
 
396
396
  let titleMark = "";
397
+ // The base a feature claimed through claimPageTitle; null text is the served title.
398
+ let claimedTitle: { text: string | null } | null = null;
399
+ // Suffixed from servedTitle, never from doc.title, so a repaint cannot stack it.
400
+ function claimedBase(text: string | null): string {
401
+ if (text === null || text === "") {
402
+ return servedTitle;
403
+ }
404
+ return servedTitle === "" ? text : `${text} · ${servedTitle}`;
405
+ }
406
+ let titleWritten = false;
397
407
  function paintTitle(): void {
398
- const next =
399
- titleMark + (slotAt(selectedSide)?.baseTitle ?? slots[0]?.baseTitle ?? servedTitle);
408
+ if (destroyed) {
409
+ return;
410
+ }
411
+ const base =
412
+ claimedTitle !== null
413
+ ? claimedBase(claimedTitle.text)
414
+ : (slotAt(selectedSide)?.baseTitle ?? slots[0]?.baseTitle ?? servedTitle);
415
+ const next = titleMark + base;
400
416
  // The title doubles as the browser-tab label and the bookmark name, and this
401
417
  // runs on every status sweep, so an unchanged assignment must not churn it.
402
418
  if (doc.title !== next) {
403
419
  doc.title = next;
420
+ titleWritten = true;
404
421
  }
405
422
  }
423
+ // Hands back the served title the terminal overwrote; paintTitle's destroyed
424
+ // guard stops teardown from repainting over it.
425
+ held.push(() => {
426
+ if (titleWritten) {
427
+ doc.title = servedTitle;
428
+ }
429
+ });
406
430
  const attentionSurfaces = new Set<AttentionSurface>();
407
- // The badge is OS-level and the icon links outlive the terminal; the base
408
- // title belongs to the document.
431
+ // The badge is OS-level and the icon links outlive the terminal.
409
432
  held.push(() => {
410
433
  for (const surface of attentionSurfaces) {
411
434
  surface.dispose();
@@ -816,6 +839,7 @@ function createShellInto(
816
839
  : disabledSplit();
817
840
  const shellContext: ShellContext = {
818
841
  root: shellRoot,
842
+ layoutMode,
819
843
  keyboard: {
820
844
  hardwareSeen: () => keyboardPresence.hardwareSeen(),
821
845
  likely: () => keyboardPresence.likely(),
@@ -851,6 +875,25 @@ function createShellInto(
851
875
  split,
852
876
  restoreSplit: (committed, selected) =>
853
877
  splitEnabled && isPaneSide(selected) && openSplitAt(committed, selected),
878
+ claimPageTitle() {
879
+ const claim: { text: string | null } = { text: null };
880
+ claimedTitle = claim;
881
+ paintTitle();
882
+ return {
883
+ set(text) {
884
+ claim.text = text;
885
+ if (claimedTitle === claim) {
886
+ paintTitle();
887
+ }
888
+ },
889
+ release() {
890
+ if (claimedTitle === claim) {
891
+ claimedTitle = null;
892
+ paintTitle();
893
+ }
894
+ },
895
+ };
896
+ },
854
897
  };
855
898
  // One row controller over every pane's tabpanel: a chip names the panel of the
856
899
  // pane showing it, or the one a click would fill.
@@ -293,6 +293,9 @@ export interface KeyboardPresence {
293
293
  export interface ShellContext {
294
294
  /** The shell root; the pane root in the direct-root topology. */
295
295
  readonly root: HTMLElement;
296
+ /** The resolved `layout` option. Only a `"viewport"` terminal owns the page, so
297
+ * only it may write the address bar. */
298
+ readonly layoutMode: "viewport" | "container";
296
299
  readonly keyboard: KeyboardPresence;
297
300
  /** Null while that side has no built kernel. */
298
301
  pane(side: PaneSide): PaneHandle | null;
@@ -322,9 +325,10 @@ export interface ShellContext {
322
325
  /** Bind the page's attention surfaces (the document-title prefix, the app
323
326
  * badge, the icon links) and get the reporter that drives them. The terminal
324
327
  * is the only writer of `document.title`: a program's OSC 0/2 title replaces
325
- * the base and keeps the prefix. The surfaces are restored when the page hides
326
- * and on destroy, and repainted on a back-forward cache restore. Keep the
327
- * report an AGGREGATE, since a page has one title over many sessions. */
328
+ * the base (unless a feature holds `claimPageTitle`) and keeps the prefix.
329
+ * The surfaces are restored when the page hides and on destroy (which also
330
+ * restores the served title), and repainted on a back-forward cache restore.
331
+ * Keep the report an AGGREGATE, since a page has one title over many sessions. */
328
332
  attention(opts: AttentionOptions): AttentionReporter;
329
333
  /** Subscribe to the server's session status stream at `path`. The terminal
330
334
  * holds ONE stream per path for the page and fans it out, because the server
@@ -346,6 +350,12 @@ export interface ShellContext {
346
350
  * shows a tab. False when the split is open already, `committedRatio` is not a
347
351
  * share of 0 to 1, or `selected` is not a side. */
348
352
  restoreSplit(committedRatio: number, selected: PaneSide): boolean;
353
+ /** Take the document title's base over from the selected pane's program title.
354
+ * `set(text)` names the page `<text> · <served title>` (the text alone when
355
+ * the page was served untitled), `set(null)` or `set("")` shows the served
356
+ * title, and `release()` hands the base back. The attention prefix is kept,
357
+ * and a later claim replaces an earlier one. */
358
+ claimPageTitle(): { set(text: string | null): void; release(): void };
349
359
  }
350
360
 
351
361
  /** The inbound-wire + lifecycle events features can subscribe to via ctx.on.