@cplieger/web-terminal-ui 8.0.1 → 8.1.1

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.
@@ -10,12 +10,13 @@ import type {
10
10
  TerminalFeature,
11
11
  Unsubscribe,
12
12
  } from "../../kernel/types.js";
13
- import { isPaneSide, MIN_SPLIT_AREA_PX } from "../../kernel/layout-policy.js";
13
+ import { isPaneSide, MIN_SPLIT_AREA_PX, SPLIT_GUTTER_PX } from "../../kernel/layout-policy.js";
14
14
  import { windowOf } from "../../kernel/realm.js";
15
15
  import type { ActivityMonitorApi } from "../activity-monitor.js";
16
16
  import type { MobileToolbarApi } from "../mobile-toolbar.js";
17
17
  import { fromHTML, holdFocusOnPress } from "../dom.js";
18
18
  import { createClickSwallow, placeMenuAt } from "../menu-position.js";
19
+ import { snapToDevicePixels } from "./device-snap.js";
19
20
  import { centreChipLabels } from "./ink-centre.js";
20
21
  import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
21
22
  import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
@@ -94,19 +95,24 @@ const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
94
95
  * an in-flight create, so a duplicate activation opens one terminal. `list` is a
95
96
  * snapshot of display data, not a live view. */
96
97
  export interface TabsApi {
97
- /** Spawn a fresh session and show it where a tab click would land it. Calls
98
- * made while a create is in flight share that create, so one gesture opens
99
- * exactly one terminal. */
98
+ /** Spawn a fresh session and show it in the first empty pane, else (both
99
+ * panes showing a tab) in the unselected one, and select its pane. Calls made
100
+ * while a create is in flight share that create, so one gesture opens exactly
101
+ * one terminal. */
100
102
  create(): Promise<void>;
101
103
  /** Close a session (kills its process) and drop its tab + cache. */
102
104
  close(id: string): Promise<void>;
103
105
  /** Show a tab: in the first empty pane, else in the selected pane; a tab a
104
106
  * pane already shows has that pane selected instead. */
105
107
  switchTo(id: string): void;
106
- /** Show a tab on one side of the split, opening it when closed; whatever that
107
- * side showed becomes an ordinary tab, the tab's old side is emptied, and the
108
- * tab's pane is selected. False when refused: an unknown id, an invalid side,
109
- * or a split the pane row is too narrow to open. */
108
+ /** Show a tab on one side of the split, opening it when closed, and select its
109
+ * pane. Opening the split keeps the single view's tab on the other side — or,
110
+ * when the moved tab IS it, its split partner — so neither pane opens empty. A
111
+ * tab the other pane shows trades places with the tab on that side (or leaves
112
+ * its pane empty when the side showed none); any other tab there becomes
113
+ * ordinary. A tab already shown on that side changes nothing. False when
114
+ * refused: an unknown id, an invalid side, or a split the pane row is too
115
+ * narrow to open. */
110
116
  snap(id: string, side: PaneSide): boolean;
111
117
  /** The current tabs, first-to-last by creation; `active` marks a tab a pane
112
118
  * shows. */
@@ -257,6 +263,12 @@ async function createSessionHonouringRetry(
257
263
  }
258
264
  }
259
265
 
266
+ interface ShowOptions {
267
+ readonly dir?: "next" | "prev" | undefined;
268
+ /** Leave the selection where it is, unless the selected pane shows nothing. */
269
+ readonly keepSelection?: boolean;
270
+ }
271
+
260
272
  function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
261
273
  if (ev.ctrlKey || ev.metaKey || ev.altKey) {
262
274
  return true;
@@ -362,12 +374,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
362
374
  const btn = fromHTML(doc, splitButtonHTML(cls));
363
375
  ctx.defer(holdFocusOnPress(btn));
364
376
  btn.addEventListener("click", () => {
365
- const split = ctx.shell.split;
366
- if (split.isOpen()) {
367
- split.close();
368
- } else {
369
- split.open();
370
- }
377
+ toggleSplit();
371
378
  });
372
379
  splitButtons.push(btn);
373
380
  return btn;
@@ -464,6 +471,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
464
471
  const varRoot = ctx.shell.root;
465
472
  const barResize = new win.ResizeObserver(() => {
466
473
  varRoot.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
474
+ ctx.shell.chromeResized();
467
475
  });
468
476
  barResize.observe(bar);
469
477
  ctx.defer(() => {
@@ -650,6 +658,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
650
658
  // engine produced for THIS font at THIS size, rather than the em constant
651
659
  // in 00-tokens.css that can only be right at one size (see ink-centre.ts).
652
660
  ctx.defer(centreChipLabels(varRoot, { strip: bar, switcher }));
661
+ for (const b of splitButtons) {
662
+ const icon = b.querySelector("svg");
663
+ if (icon) {
664
+ ctx.defer(snapToDevicePixels(icon, ctx.shell.root));
665
+ }
666
+ }
653
667
 
654
668
  // Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
655
669
  // scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
@@ -661,13 +675,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
661
675
  // the root). Measure the bar row (not the expandable list, which just
662
676
  // overlays content). innerHeight - rect.top captures the row plus the
663
677
  // safe-area beneath it; the RO fires with the keyboard closed, so the value
664
- // excludes the keyboard lift (viewport.ts adds that separately). The
665
- // synthetic visualViewport resize makes viewport.ts recompute immediately.
678
+ // excludes the keyboard lift (viewport.ts adds that separately).
666
679
  const swReserve = new win.ResizeObserver(() => {
667
680
  const rect = swBar.getBoundingClientRect();
668
681
  const px = rect.height > 0 ? Math.max(0, Math.round(win.innerHeight - rect.top)) : 0;
669
682
  varRoot.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
670
- win.visualViewport?.dispatchEvent(new win.Event("resize"));
683
+ ctx.shell.chromeResized();
671
684
  });
672
685
  swReserve.observe(swBar);
673
686
  ctx.defer(() => {
@@ -745,6 +758,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
745
758
  /** The tab that receives typing: the selected pane's. */
746
759
  const selectedId = (): string | null => shownIn(ctx.shell.selected());
747
760
  const otherSide = (side: PaneSide): PaneSide => (side === "left" ? "right" : "left");
761
+ /** Tabs the selected pane has shown, most recent first; fed from
762
+ * `syncChrome`, which every change of the selected tab ends in. */
763
+ const recent: string[] = [];
764
+ function noteSelected(): void {
765
+ const id = selectedId();
766
+ if (id === null || recent[0] === id) {
767
+ return;
768
+ }
769
+ const older = recent.filter((r) => r !== id && tabList.some((t) => t.id === r));
770
+ recent.splice(0, recent.length, id, ...older);
771
+ }
748
772
  // Set by a bootstrap that showed nothing, cleared by the first showIn: the
749
773
  // one case a status event may pick a tab to show.
750
774
  let bootShowedNothing = false;
@@ -905,32 +929,49 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
905
929
  const active = doc.activeElement;
906
930
  return active !== null && (bar.contains(active) || switcher.contains(active));
907
931
  }
932
+ function keyboardInPaneInput(): boolean {
933
+ const active = doc.activeElement;
934
+ return (
935
+ active !== null &&
936
+ active.classList.contains("term-input") &&
937
+ ctx.shell.panes().some((p) => p.root.contains(active))
938
+ );
939
+ }
940
+ function keyboardInOtherPane(): boolean {
941
+ return keyboardInPaneInput() && doc.activeElement !== termInput();
942
+ }
908
943
 
909
944
  // The keyboard goes to the terminal input when a physical keyboard is likely
910
- // (on a keyboard-less touchscreen every switch would pop the soft keyboard)
911
- // or when the press behind this switch parked it on the chrome, where the
912
- // strip's own keydown handling would eat every keystroke; restoring what the
913
- // press displaced pops no keyboard. Never while a rename field is open.
945
+ // (on a keyboard-less touchscreen every switch would pop the soft keyboard),
946
+ // when the press behind this switch parked it on the chrome, where the
947
+ // strip's own keydown handling would eat every keystroke, or when it sits in
948
+ // the other pane's input, which would keep the typing; moving it from one
949
+ // input to another pops no keyboard. Never while a rename field is open.
914
950
  function focusAfterSwitch(): void {
915
951
  if (editingId !== null) {
916
952
  return;
917
953
  }
918
- if (physicalKeyboardLikely() || (inputFocusedAtPress && keyboardParkedOnChrome())) {
954
+ if (
955
+ physicalKeyboardLikely() ||
956
+ (inputFocusedAtPress && keyboardParkedOnChrome()) ||
957
+ keyboardInOtherPane()
958
+ ) {
919
959
  focusInput();
920
960
  }
921
961
  }
922
962
 
923
- // Every shown chip renders active, with no difference between the two panes'
924
- // (selection is the handle's and the cursor's to show). The selected pane's
925
- // chip is revealed in the scroller only when THAT TAB CHANGES, so a user
926
- // browsing a scrolled strip is never yanked back by an unrelated repaint.
963
+ // The selected pane's chip is revealed in the scroller only when THAT TAB
964
+ // CHANGES, so a user browsing a scrolled strip is never yanked back by an
965
+ // unrelated repaint.
927
966
  let lastRevealedActive = "";
928
967
  function paintActive(): void {
929
968
  const open = ctx.shell.split.isOpen();
969
+ const selectedSide = open ? ctx.shell.selected() : null;
930
970
  for (const t of tabList) {
931
971
  const side = sideOf(t.id);
932
972
  const on = side !== null;
933
973
  t.el.classList.toggle("wt-tab-active", on);
974
+ t.el.classList.toggle("wt-tab-selected", on && side === selectedSide);
934
975
  t.aria.setPanel(side);
935
976
  t.aria.setExpanded(open ? on : null);
936
977
  // setSelected would undo setEditing(true) on the chip hosting the rename
@@ -1158,6 +1199,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1158
1199
 
1159
1200
  // syncChrome refreshes every surface after any state change. Idempotent.
1160
1201
  function syncChrome(): void {
1202
+ noteSelected();
1161
1203
  relabelAll();
1162
1204
  paintActive();
1163
1205
  syncMobile();
@@ -1518,9 +1560,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1518
1560
 
1519
1561
  /** Show `id` in the pane on `side`: the pane's current tab becomes an
1520
1562
  * ordinary tab, a pane elsewhere showing `id` is emptied first (a session is
1521
- * never shown twice), and the pane is selected. False when `side` names no
1522
- * pane that can show a tab (absent, failed or hidden) or `id` no tab. */
1523
- function showIn(side: PaneSide, id: string, dir?: "next" | "prev"): boolean {
1563
+ * never shown twice), and the pane is selected unless `keepSelection` holds
1564
+ * it on a pane that shows a tab. False when `side` names no pane that can
1565
+ * show a tab (absent, failed or hidden) or `id` no tab. */
1566
+ function showIn(side: PaneSide, id: string, how: ShowOptions = {}): boolean {
1524
1567
  const pane = ctx.shell.pane(side);
1525
1568
  const next = tabList.find((t) => t.id === id);
1526
1569
  if (pane === null || !next) {
@@ -1550,7 +1593,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1550
1593
  }
1551
1594
  // A later tab slides in from the right, an earlier one from the left, so a
1552
1595
  // desktop switch feels like the mobile swipe.
1553
- let slide = dir;
1596
+ let slide = how.dir;
1554
1597
  const fromIdx = curId === null ? -1 : tabList.findIndex((t) => t.id === curId);
1555
1598
  const toIdx = tabList.findIndex((t) => t.id === id);
1556
1599
  if (slide === undefined && fromIdx >= 0) {
@@ -1581,6 +1624,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1581
1624
  // the user just looked at.
1582
1625
  acknowledgeSwitchNotify(next.id);
1583
1626
  markCueSeen(next.id, next.dot.dataset["status"] ?? "");
1627
+ const moveSelection = how.keepSelection !== true || selectedId() === null;
1584
1628
  // The view goes in WITH the bind, which makes the swap atomic: the pane's
1585
1629
  // follow flag is one per renderer, so a bind without it gated the first
1586
1630
  // flush on the state of the tab we LEFT and the cached screen rendered
@@ -1591,7 +1635,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1591
1635
  // add/remove row animation (the reel owns row motion here); set before the
1592
1636
  // selection change, whose listener reconciles the list too.
1593
1637
  reelReconcile = playReel !== undefined;
1594
- ctx.shell.select(side);
1638
+ if (moveSelection) {
1639
+ ctx.shell.select(side);
1640
+ }
1595
1641
  scheduleLayoutWrite();
1596
1642
  // Only when the user is about to wait: a revisited tab with a warm store
1597
1643
  // paints from cache in one frame and must not flash a cue at every switch.
@@ -1605,8 +1651,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1605
1651
  syncChrome(); // reconciles the expanded list into the new order
1606
1652
  reelReconcile = false;
1607
1653
  playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
1608
- ctx.announce(`Switched to ${next.display}`);
1609
- focusAfterSwitch();
1654
+ if (moveSelection) {
1655
+ ctx.announce(`Switched to ${next.display}`);
1656
+ focusAfterSwitch();
1657
+ }
1610
1658
  return true;
1611
1659
  }
1612
1660
 
@@ -1619,21 +1667,67 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1619
1667
  focusAfterSwitch();
1620
1668
  return;
1621
1669
  }
1622
- showIn(ctx.shell.targetFor(id), id, dir);
1670
+ showIn(ctx.shell.targetFor(id), id, { dir });
1623
1671
  }
1624
1672
 
1625
- // The one rule every split entry point reduces to: the named side shows the
1626
- // tab, whatever was there becomes an ordinary tab, the tab's old side becomes
1627
- // empty, and the tab's pane is selected.
1673
+ // The one rule every split entry point reduces to (`TabsApi.snap`).
1628
1674
  function snap(id: string, side: PaneSide): boolean {
1629
1675
  if (!isPaneSide(side) || !tabList.some((t) => t.id === id)) {
1630
1676
  return false;
1631
1677
  }
1678
+ const from = sideOf(id);
1679
+ if (from === side) {
1680
+ return true;
1681
+ }
1632
1682
  const split = ctx.shell.split;
1633
- if (!split.isOpen() && (!split.canOpen() || !split.open())) {
1683
+ const opening = !split.isOpen();
1684
+ if (opening && (!split.canOpen() || !split.open())) {
1634
1685
  return false;
1635
1686
  }
1636
- return showIn(side, id);
1687
+ // Emptying the tab's old pane drops a keyboard its input held to the body.
1688
+ const typing = keyboardInPaneInput();
1689
+ const displaced = shownIn(side);
1690
+ if (!showIn(side, id)) {
1691
+ return false;
1692
+ }
1693
+ // Opening pairs the moved tab with the single view's tab, or its partner
1694
+ // when they are one, so no pane opens empty; an open split only re-homes
1695
+ // the displaced tab, into a pane the move emptied.
1696
+ const back = displaced ?? (opening ? splitPartner(id) : null);
1697
+ if (
1698
+ (from !== null || opening) &&
1699
+ back !== null &&
1700
+ back !== id &&
1701
+ shownIn(otherSide(side)) === null
1702
+ ) {
1703
+ showIn(otherSide(side), back, { keepSelection: true });
1704
+ }
1705
+ if (typing) {
1706
+ focusInput();
1707
+ }
1708
+ return true;
1709
+ }
1710
+
1711
+ function toggleSplit(): void {
1712
+ const split = ctx.shell.split;
1713
+ if (split.isOpen()) {
1714
+ split.close();
1715
+ return;
1716
+ }
1717
+ const partner = splitPartner(selectedId());
1718
+ if (partner === null) {
1719
+ void create(true);
1720
+ } else if (split.open()) {
1721
+ showIn("right", partner, { keepSelection: true });
1722
+ }
1723
+ }
1724
+ function splitPartner(of: string | null): string | null {
1725
+ const used = recent.find((id) => id !== of && tabList.some((t) => t.id === id));
1726
+ if (used !== undefined) {
1727
+ return used;
1728
+ }
1729
+ const at = tabList.findIndex((t) => t.id === of);
1730
+ return (tabList[at + 1] ?? tabList[at - 1])?.id ?? null;
1637
1731
  }
1638
1732
 
1639
1733
  // A switched-into tab's cached screen is stale until its resume delta lands,
@@ -2233,6 +2327,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2233
2327
  if (after) {
2234
2328
  scroller.insertBefore(tab.el, after.el);
2235
2329
  }
2330
+ if (started) {
2331
+ showCreatedElsewhere(tab.id);
2332
+ }
2236
2333
  }
2237
2334
 
2238
2335
  // The runtime repair for a bootstrap whose list AND create both failed: the
@@ -2255,21 +2352,45 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2255
2352
  // promise needs no threshold: a duplicate lands while the POST is open and
2256
2353
  // collapses into it, while a deliberate second tap still opens a second
2257
2354
  // terminal. It also stops "+" mashing from queueing sessions across the
2258
- // server's retry window. The coalesced second caller's target is ignored.
2355
+ // server's retry window. Any coalesced caller asking for the split gets it.
2259
2356
  let creating: Promise<void> | null = null;
2260
- function create(target?: PaneSide): Promise<void> {
2261
- creating ??= openNewSession(target).finally(() => {
2262
- creating = null;
2263
- });
2357
+ let createIntoSplit = false;
2358
+ function create(intoSplit = false): Promise<void> {
2359
+ if (creating === null) {
2360
+ createIntoSplit = intoSplit;
2361
+ creating = openNewSession().finally(() => {
2362
+ creating = null;
2363
+ });
2364
+ } else {
2365
+ createIntoSplit ||= intoSplit;
2366
+ }
2264
2367
  return creating;
2265
2368
  }
2266
2369
 
2267
- async function openNewSession(target?: PaneSide): Promise<void> {
2268
- // The handle faces the pane the new tab is headed for from the start,
2269
- // not from the POST's answer.
2270
- if (target !== undefined && ctx.shell.split.isOpen() && !anyShown()) {
2271
- ctx.shell.select(target);
2370
+ function showNewTab(id: string): void {
2371
+ const side = ctx.shell.targetFor(id);
2372
+ const other = otherSide(side);
2373
+ // Suppress the swipe reel for a new tab: the list should grow and fade the
2374
+ // new row in (animateRowIn) rather than rotate.
2375
+ creatingTab = true;
2376
+ showIn(shownIn(side) !== null && shownIn(other) !== null ? other : side, id);
2377
+ creatingTab = false;
2378
+ }
2379
+ // This client's own new session can reach the stream before its POST
2380
+ // answers, so the placement waits until a create in flight has shown its tab.
2381
+ function showCreatedElsewhere(id: string): void {
2382
+ if (creating !== null) {
2383
+ void creating.then(() => {
2384
+ showCreatedElsewhere(id);
2385
+ });
2386
+ return;
2387
+ }
2388
+ if (!tornDown() && ctx.shell.split.isOpen() && sideOf(id) === null) {
2389
+ showNewTab(id);
2272
2390
  }
2391
+ }
2392
+
2393
+ async function openNewSession(): Promise<void> {
2273
2394
  let info: SessionInfo;
2274
2395
  try {
2275
2396
  info = await createSessionHonouringRetry(api, ctx, lifetime.signal);
@@ -2295,18 +2416,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2295
2416
  tab = addTabChrome(info);
2296
2417
  tabList.push(tab);
2297
2418
  }
2298
- // Suppress the swipe reel for a create: the list should grow and fade the
2299
- // new row in (animateRowIn) rather than rotate. showIn still slides the
2300
- // terminal content and updates the shown chip.
2301
- creatingTab = true;
2302
- // The asked-for side holds only while that pane is still empty: the split
2303
- // may have closed or the pane been filled during the round trip.
2304
- const side =
2305
- target !== undefined && ctx.shell.pane(target)?.state() === "empty"
2306
- ? target
2307
- : ctx.shell.targetFor(tab.id);
2308
- showIn(side, tab.id);
2309
- creatingTab = false;
2419
+ // Opened once the tab exists, so a failed create leaves the single view
2420
+ // as it was rather than an empty pane.
2421
+ if (createIntoSplit && !ctx.shell.split.isOpen()) {
2422
+ ctx.shell.split.open();
2423
+ }
2424
+ showNewTab(tab.id);
2310
2425
  }
2311
2426
 
2312
2427
  // dropTab removes a tab's chrome + cache and, for a shown tab, decides what
@@ -2321,10 +2436,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2321
2436
  }
2322
2437
  // Closing the only remaining tab: spawn its replacement BEFORE removing
2323
2438
  // this one, so the strip never empties and the "+" never teleports to the
2324
- // far left while the create POST is in flight. The replacement lands where
2325
- // a click would (the other pane, while the split is open), and dropping
2326
- // the old tab is then an ordinary non-last close, so this intercept does
2327
- // not re-fire. A user close only.
2439
+ // far left while the create POST is in flight. The replacement lands as any
2440
+ // new tab does, and dropping the old tab is then an ordinary non-last
2441
+ // close, so this intercept does not re-fire. A user close only.
2328
2442
  if (remote && tabList.length === 1 && tabList[0]?.id === id) {
2329
2443
  await create();
2330
2444
  // A failed create adds nothing (and toasts): keep the existing tab
@@ -2338,6 +2452,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2338
2452
  // anything moves.
2339
2453
  const side = sideOf(id);
2340
2454
  const splitOpen = ctx.shell.split.isOpen();
2455
+ // Emptying a pane makes it inert, which drops a keyboard it held to the
2456
+ // body before the view moves; the keyboard follows the view instead.
2457
+ const typing = splitOpen && side !== null && keyboardInPaneInput();
2341
2458
  const [tab] = tabList.splice(idx, 1);
2342
2459
  if (!tab) {
2343
2460
  return;
@@ -2387,26 +2504,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2387
2504
  // What the panes show next is decided here, before the server's answer:
2388
2505
  // selection must not sit on an emptied pane for a DELETE round trip.
2389
2506
  let replacement: Promise<void> | undefined;
2390
- if (side !== null && !splitOpen) {
2391
- // One pane: switch to a neighbor, or spawn a fresh session if this was
2392
- // the last.
2507
+ if (side !== null && splitOpen && shownIn(otherSide(side)) !== null) {
2508
+ ctx.shell.split.closeSide(side);
2509
+ focusAfterSwitch();
2510
+ } else if (side !== null) {
2511
+ if (splitOpen) {
2512
+ ctx.shell.split.close();
2513
+ }
2393
2514
  const neighbor = tabList[idx] ?? tabList[idx - 1];
2394
2515
  if (neighbor) {
2395
2516
  switchTo(neighbor.id);
2396
2517
  } else {
2397
2518
  replacement = create();
2398
2519
  }
2399
- } else if (side !== null) {
2400
- // While the split is open the pane stays empty and a new tab lands in
2401
- // the other pane, so a close never promotes a tab nobody clicked.
2402
- const other = otherSide(side);
2403
- if (shownIn(other) !== null) {
2404
- ctx.shell.select(other);
2405
- focusAfterSwitch();
2406
- } else if (!anyShown()) {
2407
- replacement = create(other);
2408
- }
2409
- scheduleLayoutWrite();
2520
+ }
2521
+ if (typing) {
2522
+ focusInput();
2410
2523
  }
2411
2524
  if (remote) {
2412
2525
  try {
@@ -2467,25 +2580,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2467
2580
  }
2468
2581
  }
2469
2582
  // Re-home before the DELETEs: selection must not sit on an emptied pane for
2470
- // a round trip. Split open, the emptied panes stay empty (no promotion).
2471
- if (!splitOpen && closingSelected && tabList[0]) {
2583
+ // a round trip.
2584
+ let needsTab = !splitOpen && closingSelected;
2585
+ if (emptiedPane) {
2586
+ const keep = ctx.shell.panes().find((p) => p.state() === "shown")?.side;
2587
+ if (keep === undefined) {
2588
+ ctx.shell.split.close();
2589
+ needsTab = true;
2590
+ } else {
2591
+ ctx.shell.split.closeSide(otherSide(keep));
2592
+ }
2593
+ }
2594
+ if (needsTab && tabList[0]) {
2472
2595
  switchTo(tabList[0].id);
2473
2596
  } else {
2474
2597
  syncChrome();
2475
- if (splitOpen && closingSelected) {
2476
- const shownSide = ctx.shell.panes().find((p) => p.state() === "shown")?.side;
2477
- if (shownSide !== undefined) {
2478
- ctx.shell.select(shownSide);
2479
- }
2480
- }
2481
- if (emptiedPane) {
2482
- scheduleLayoutWrite();
2483
- }
2484
2598
  }
2485
- // One terminal always stays open, and an open split always shows one tab.
2599
+ // One terminal always stays open.
2486
2600
  let replacement: Promise<void> | undefined;
2487
- if (tabList.length === 0 || (splitOpen && !anyShown())) {
2488
- replacement = create(ctx.shell.panes().find((p) => p.state() === "empty")?.side);
2601
+ if (tabList.length === 0) {
2602
+ replacement = create();
2489
2603
  }
2490
2604
  // The person asked for these closes, so every DELETE still goes out past a
2491
2605
  // teardown; only what the page shows for them stops.
@@ -3233,10 +3347,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3233
3347
  endRestNet();
3234
3348
  }
3235
3349
  });
3236
- // The half of the pane row a drag is over, or null off the row, over the
3237
- // switcher, or while the row is too narrow for two panes (the strip is the
3238
- // caller's early return). An empty pane is `inert`, so a tab dragged over it
3239
- // hits the shell root underneath.
3350
+ // The left width rounds as `--wt-split-left` does in 32-split.css, so the
3351
+ // boundary is the one the highlights draw.
3240
3352
  function dropHalf(e: DragEvent): PaneSide | null {
3241
3353
  const root = ctx.shell.root;
3242
3354
  const target = e.target as Node;
@@ -3248,8 +3360,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3248
3360
  ) {
3249
3361
  return null;
3250
3362
  }
3251
- const rect = root.getBoundingClientRect();
3252
- return e.clientX < rect.left + rect.width / 2 ? "left" : "right";
3363
+ const { left, width } = root.getBoundingClientRect();
3364
+ const leftPane = Math.round((width - SPLIT_GUTTER_PX) * ctx.shell.split.state().ratio);
3365
+ return e.clientX < left + leftPane + SPLIT_GUTTER_PX / 2 ? "left" : "right";
3253
3366
  }
3254
3367
  function paintDropHalf(half: PaneSide | null): void {
3255
3368
  ctx.shell.root.classList.toggle("wt-drop-left", half === "left");
@@ -74,9 +74,14 @@ const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" wi
74
74
  // background-tab notification dot rides on it (see switchButtonHTML). Same
75
75
  // viewBox + stroke=currentcolor treatment as the others.
76
76
  const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3"/></svg>`;
77
- // Two rectangles side by side, the same glyph whether the split is open or
78
- // closed: the button's state is its aria-expanded, not its icon.
79
- const SPLIT_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="8" height="14" rx="2"/><rect x="13" y="5" width="8" height="14" rx="2"/></svg>`;
77
+ // The state is the button's aria-expanded, not its icon. The viewBox equals the
78
+ // 20px box 30-tabs.css draws it in, one unit per CSS px: that stylesheet's
79
+ // per-DPR geometry is written in those units.
80
+ const SPLIT_SVG =
81
+ `<svg class="wt-split-icon" viewBox="0 0 20 20" aria-hidden="true">` +
82
+ `<rect class="wt-split-icon-left" x="1" y="4" width="6" height="12" rx="1.5"/>` +
83
+ `<rect class="wt-split-icon-divider" x="9" y="2" width="2" height="16" rx="1"/>` +
84
+ `<rect class="wt-split-icon-right" x="13" y="4" width="6" height="12" rx="1.5"/></svg>`;
80
85
 
81
86
  /** The ONE builder for a tab chip's content (status dot, label, progress bar,
82
87
  * close), shared by the strip chip, the mobile active row and each mobile list