@cplieger/web-terminal-ui 8.0.1 → 8.1.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.
@@ -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,22 @@ 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. A tab the other pane shows trades places with the tab on that side
110
+ * (or leaves its pane empty when the side showed none); any other tab there
111
+ * becomes ordinary. A tab already shown on that side changes nothing. False
112
+ * when refused: an unknown id, an invalid side, or a split the pane row is too
113
+ * narrow to open. */
110
114
  snap(id: string, side: PaneSide): boolean;
111
115
  /** The current tabs, first-to-last by creation; `active` marks a tab a pane
112
116
  * shows. */
@@ -257,6 +261,12 @@ async function createSessionHonouringRetry(
257
261
  }
258
262
  }
259
263
 
264
+ interface ShowOptions {
265
+ readonly dir?: "next" | "prev" | undefined;
266
+ /** Leave the selection where it is, unless the selected pane shows nothing. */
267
+ readonly keepSelection?: boolean;
268
+ }
269
+
260
270
  function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
261
271
  if (ev.ctrlKey || ev.metaKey || ev.altKey) {
262
272
  return true;
@@ -362,12 +372,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
362
372
  const btn = fromHTML(doc, splitButtonHTML(cls));
363
373
  ctx.defer(holdFocusOnPress(btn));
364
374
  btn.addEventListener("click", () => {
365
- const split = ctx.shell.split;
366
- if (split.isOpen()) {
367
- split.close();
368
- } else {
369
- split.open();
370
- }
375
+ toggleSplit();
371
376
  });
372
377
  splitButtons.push(btn);
373
378
  return btn;
@@ -464,6 +469,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
464
469
  const varRoot = ctx.shell.root;
465
470
  const barResize = new win.ResizeObserver(() => {
466
471
  varRoot.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
472
+ ctx.shell.chromeResized();
467
473
  });
468
474
  barResize.observe(bar);
469
475
  ctx.defer(() => {
@@ -650,6 +656,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
650
656
  // engine produced for THIS font at THIS size, rather than the em constant
651
657
  // in 00-tokens.css that can only be right at one size (see ink-centre.ts).
652
658
  ctx.defer(centreChipLabels(varRoot, { strip: bar, switcher }));
659
+ for (const b of splitButtons) {
660
+ const icon = b.querySelector("svg");
661
+ if (icon) {
662
+ ctx.defer(snapToDevicePixels(icon, ctx.shell.root));
663
+ }
664
+ }
653
665
 
654
666
  // Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
655
667
  // scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
@@ -661,13 +673,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
661
673
  // the root). Measure the bar row (not the expandable list, which just
662
674
  // overlays content). innerHeight - rect.top captures the row plus the
663
675
  // 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.
676
+ // excludes the keyboard lift (viewport.ts adds that separately).
666
677
  const swReserve = new win.ResizeObserver(() => {
667
678
  const rect = swBar.getBoundingClientRect();
668
679
  const px = rect.height > 0 ? Math.max(0, Math.round(win.innerHeight - rect.top)) : 0;
669
680
  varRoot.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
670
- win.visualViewport?.dispatchEvent(new win.Event("resize"));
681
+ ctx.shell.chromeResized();
671
682
  });
672
683
  swReserve.observe(swBar);
673
684
  ctx.defer(() => {
@@ -745,6 +756,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
745
756
  /** The tab that receives typing: the selected pane's. */
746
757
  const selectedId = (): string | null => shownIn(ctx.shell.selected());
747
758
  const otherSide = (side: PaneSide): PaneSide => (side === "left" ? "right" : "left");
759
+ /** Tabs the selected pane has shown, most recent first; fed from
760
+ * `syncChrome`, which every change of the selected tab ends in. */
761
+ const recent: string[] = [];
762
+ function noteSelected(): void {
763
+ const id = selectedId();
764
+ if (id === null || recent[0] === id) {
765
+ return;
766
+ }
767
+ const older = recent.filter((r) => r !== id && tabList.some((t) => t.id === r));
768
+ recent.splice(0, recent.length, id, ...older);
769
+ }
748
770
  // Set by a bootstrap that showed nothing, cleared by the first showIn: the
749
771
  // one case a status event may pick a tab to show.
750
772
  let bootShowedNothing = false;
@@ -905,32 +927,49 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
905
927
  const active = doc.activeElement;
906
928
  return active !== null && (bar.contains(active) || switcher.contains(active));
907
929
  }
930
+ function keyboardInPaneInput(): boolean {
931
+ const active = doc.activeElement;
932
+ return (
933
+ active !== null &&
934
+ active.classList.contains("term-input") &&
935
+ ctx.shell.panes().some((p) => p.root.contains(active))
936
+ );
937
+ }
938
+ function keyboardInOtherPane(): boolean {
939
+ return keyboardInPaneInput() && doc.activeElement !== termInput();
940
+ }
908
941
 
909
942
  // 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.
943
+ // (on a keyboard-less touchscreen every switch would pop the soft keyboard),
944
+ // when the press behind this switch parked it on the chrome, where the
945
+ // strip's own keydown handling would eat every keystroke, or when it sits in
946
+ // the other pane's input, which would keep the typing; moving it from one
947
+ // input to another pops no keyboard. Never while a rename field is open.
914
948
  function focusAfterSwitch(): void {
915
949
  if (editingId !== null) {
916
950
  return;
917
951
  }
918
- if (physicalKeyboardLikely() || (inputFocusedAtPress && keyboardParkedOnChrome())) {
952
+ if (
953
+ physicalKeyboardLikely() ||
954
+ (inputFocusedAtPress && keyboardParkedOnChrome()) ||
955
+ keyboardInOtherPane()
956
+ ) {
919
957
  focusInput();
920
958
  }
921
959
  }
922
960
 
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.
961
+ // The selected pane's chip is revealed in the scroller only when THAT TAB
962
+ // CHANGES, so a user browsing a scrolled strip is never yanked back by an
963
+ // unrelated repaint.
927
964
  let lastRevealedActive = "";
928
965
  function paintActive(): void {
929
966
  const open = ctx.shell.split.isOpen();
967
+ const selectedSide = open ? ctx.shell.selected() : null;
930
968
  for (const t of tabList) {
931
969
  const side = sideOf(t.id);
932
970
  const on = side !== null;
933
971
  t.el.classList.toggle("wt-tab-active", on);
972
+ t.el.classList.toggle("wt-tab-selected", on && side === selectedSide);
934
973
  t.aria.setPanel(side);
935
974
  t.aria.setExpanded(open ? on : null);
936
975
  // setSelected would undo setEditing(true) on the chip hosting the rename
@@ -1158,6 +1197,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1158
1197
 
1159
1198
  // syncChrome refreshes every surface after any state change. Idempotent.
1160
1199
  function syncChrome(): void {
1200
+ noteSelected();
1161
1201
  relabelAll();
1162
1202
  paintActive();
1163
1203
  syncMobile();
@@ -1518,9 +1558,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1518
1558
 
1519
1559
  /** Show `id` in the pane on `side`: the pane's current tab becomes an
1520
1560
  * 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 {
1561
+ * never shown twice), and the pane is selected unless `keepSelection` holds
1562
+ * it on a pane that shows a tab. False when `side` names no pane that can
1563
+ * show a tab (absent, failed or hidden) or `id` no tab. */
1564
+ function showIn(side: PaneSide, id: string, how: ShowOptions = {}): boolean {
1524
1565
  const pane = ctx.shell.pane(side);
1525
1566
  const next = tabList.find((t) => t.id === id);
1526
1567
  if (pane === null || !next) {
@@ -1550,7 +1591,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1550
1591
  }
1551
1592
  // A later tab slides in from the right, an earlier one from the left, so a
1552
1593
  // desktop switch feels like the mobile swipe.
1553
- let slide = dir;
1594
+ let slide = how.dir;
1554
1595
  const fromIdx = curId === null ? -1 : tabList.findIndex((t) => t.id === curId);
1555
1596
  const toIdx = tabList.findIndex((t) => t.id === id);
1556
1597
  if (slide === undefined && fromIdx >= 0) {
@@ -1581,6 +1622,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1581
1622
  // the user just looked at.
1582
1623
  acknowledgeSwitchNotify(next.id);
1583
1624
  markCueSeen(next.id, next.dot.dataset["status"] ?? "");
1625
+ const moveSelection = how.keepSelection !== true || selectedId() === null;
1584
1626
  // The view goes in WITH the bind, which makes the swap atomic: the pane's
1585
1627
  // follow flag is one per renderer, so a bind without it gated the first
1586
1628
  // flush on the state of the tab we LEFT and the cached screen rendered
@@ -1591,7 +1633,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1591
1633
  // add/remove row animation (the reel owns row motion here); set before the
1592
1634
  // selection change, whose listener reconciles the list too.
1593
1635
  reelReconcile = playReel !== undefined;
1594
- ctx.shell.select(side);
1636
+ if (moveSelection) {
1637
+ ctx.shell.select(side);
1638
+ }
1595
1639
  scheduleLayoutWrite();
1596
1640
  // Only when the user is about to wait: a revisited tab with a warm store
1597
1641
  // paints from cache in one frame and must not flash a cue at every switch.
@@ -1605,8 +1649,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1605
1649
  syncChrome(); // reconciles the expanded list into the new order
1606
1650
  reelReconcile = false;
1607
1651
  playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
1608
- ctx.announce(`Switched to ${next.display}`);
1609
- focusAfterSwitch();
1652
+ if (moveSelection) {
1653
+ ctx.announce(`Switched to ${next.display}`);
1654
+ focusAfterSwitch();
1655
+ }
1610
1656
  return true;
1611
1657
  }
1612
1658
 
@@ -1619,21 +1665,58 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1619
1665
  focusAfterSwitch();
1620
1666
  return;
1621
1667
  }
1622
- showIn(ctx.shell.targetFor(id), id, dir);
1668
+ showIn(ctx.shell.targetFor(id), id, { dir });
1623
1669
  }
1624
1670
 
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.
1671
+ // The one rule every split entry point reduces to (`TabsApi.snap`).
1628
1672
  function snap(id: string, side: PaneSide): boolean {
1629
1673
  if (!isPaneSide(side) || !tabList.some((t) => t.id === id)) {
1630
1674
  return false;
1631
1675
  }
1676
+ const from = sideOf(id);
1677
+ if (from === side) {
1678
+ return true;
1679
+ }
1632
1680
  const split = ctx.shell.split;
1633
1681
  if (!split.isOpen() && (!split.canOpen() || !split.open())) {
1634
1682
  return false;
1635
1683
  }
1636
- return showIn(side, id);
1684
+ // Emptying the tab's old pane drops a keyboard its input held to the body.
1685
+ const typing = keyboardInPaneInput();
1686
+ const displaced = from === null ? null : shownIn(side);
1687
+ if (!showIn(side, id)) {
1688
+ return false;
1689
+ }
1690
+ if (from !== null && displaced !== null) {
1691
+ showIn(from, displaced, { keepSelection: true });
1692
+ }
1693
+ if (typing) {
1694
+ focusInput();
1695
+ }
1696
+ return true;
1697
+ }
1698
+
1699
+ function toggleSplit(): void {
1700
+ const split = ctx.shell.split;
1701
+ if (split.isOpen()) {
1702
+ split.close();
1703
+ return;
1704
+ }
1705
+ const partner = splitPartner();
1706
+ if (partner === null) {
1707
+ void create(true);
1708
+ } else if (split.open()) {
1709
+ showIn("right", partner, { keepSelection: true });
1710
+ }
1711
+ }
1712
+ function splitPartner(): string | null {
1713
+ const current = selectedId();
1714
+ const used = recent.find((id) => id !== current && tabList.some((t) => t.id === id));
1715
+ if (used !== undefined) {
1716
+ return used;
1717
+ }
1718
+ const at = tabList.findIndex((t) => t.id === current);
1719
+ return (tabList[at + 1] ?? tabList[at - 1])?.id ?? null;
1637
1720
  }
1638
1721
 
1639
1722
  // A switched-into tab's cached screen is stale until its resume delta lands,
@@ -2233,6 +2316,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2233
2316
  if (after) {
2234
2317
  scroller.insertBefore(tab.el, after.el);
2235
2318
  }
2319
+ if (started) {
2320
+ showCreatedElsewhere(tab.id);
2321
+ }
2236
2322
  }
2237
2323
 
2238
2324
  // The runtime repair for a bootstrap whose list AND create both failed: the
@@ -2255,21 +2341,45 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2255
2341
  // promise needs no threshold: a duplicate lands while the POST is open and
2256
2342
  // collapses into it, while a deliberate second tap still opens a second
2257
2343
  // terminal. It also stops "+" mashing from queueing sessions across the
2258
- // server's retry window. The coalesced second caller's target is ignored.
2344
+ // server's retry window. Any coalesced caller asking for the split gets it.
2259
2345
  let creating: Promise<void> | null = null;
2260
- function create(target?: PaneSide): Promise<void> {
2261
- creating ??= openNewSession(target).finally(() => {
2262
- creating = null;
2263
- });
2346
+ let createIntoSplit = false;
2347
+ function create(intoSplit = false): Promise<void> {
2348
+ if (creating === null) {
2349
+ createIntoSplit = intoSplit;
2350
+ creating = openNewSession().finally(() => {
2351
+ creating = null;
2352
+ });
2353
+ } else {
2354
+ createIntoSplit ||= intoSplit;
2355
+ }
2264
2356
  return creating;
2265
2357
  }
2266
2358
 
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);
2359
+ function showNewTab(id: string): void {
2360
+ const side = ctx.shell.targetFor(id);
2361
+ const other = otherSide(side);
2362
+ // Suppress the swipe reel for a new tab: the list should grow and fade the
2363
+ // new row in (animateRowIn) rather than rotate.
2364
+ creatingTab = true;
2365
+ showIn(shownIn(side) !== null && shownIn(other) !== null ? other : side, id);
2366
+ creatingTab = false;
2367
+ }
2368
+ // This client's own new session can reach the stream before its POST
2369
+ // answers, so the placement waits until a create in flight has shown its tab.
2370
+ function showCreatedElsewhere(id: string): void {
2371
+ if (creating !== null) {
2372
+ void creating.then(() => {
2373
+ showCreatedElsewhere(id);
2374
+ });
2375
+ return;
2376
+ }
2377
+ if (!tornDown() && ctx.shell.split.isOpen() && sideOf(id) === null) {
2378
+ showNewTab(id);
2272
2379
  }
2380
+ }
2381
+
2382
+ async function openNewSession(): Promise<void> {
2273
2383
  let info: SessionInfo;
2274
2384
  try {
2275
2385
  info = await createSessionHonouringRetry(api, ctx, lifetime.signal);
@@ -2295,18 +2405,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2295
2405
  tab = addTabChrome(info);
2296
2406
  tabList.push(tab);
2297
2407
  }
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;
2408
+ // Opened once the tab exists, so a failed create leaves the single view
2409
+ // as it was rather than an empty pane.
2410
+ if (createIntoSplit && !ctx.shell.split.isOpen()) {
2411
+ ctx.shell.split.open();
2412
+ }
2413
+ showNewTab(tab.id);
2310
2414
  }
2311
2415
 
2312
2416
  // dropTab removes a tab's chrome + cache and, for a shown tab, decides what
@@ -2321,10 +2425,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2321
2425
  }
2322
2426
  // Closing the only remaining tab: spawn its replacement BEFORE removing
2323
2427
  // 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.
2428
+ // far left while the create POST is in flight. The replacement lands as any
2429
+ // new tab does, and dropping the old tab is then an ordinary non-last
2430
+ // close, so this intercept does not re-fire. A user close only.
2328
2431
  if (remote && tabList.length === 1 && tabList[0]?.id === id) {
2329
2432
  await create();
2330
2433
  // A failed create adds nothing (and toasts): keep the existing tab
@@ -2338,6 +2441,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2338
2441
  // anything moves.
2339
2442
  const side = sideOf(id);
2340
2443
  const splitOpen = ctx.shell.split.isOpen();
2444
+ // Emptying a pane makes it inert, which drops a keyboard it held to the
2445
+ // body before the view moves; the keyboard follows the view instead.
2446
+ const typing = splitOpen && side !== null && keyboardInPaneInput();
2341
2447
  const [tab] = tabList.splice(idx, 1);
2342
2448
  if (!tab) {
2343
2449
  return;
@@ -2387,26 +2493,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2387
2493
  // What the panes show next is decided here, before the server's answer:
2388
2494
  // selection must not sit on an emptied pane for a DELETE round trip.
2389
2495
  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.
2496
+ if (side !== null && splitOpen && shownIn(otherSide(side)) !== null) {
2497
+ ctx.shell.split.closeSide(side);
2498
+ focusAfterSwitch();
2499
+ } else if (side !== null) {
2500
+ if (splitOpen) {
2501
+ ctx.shell.split.close();
2502
+ }
2393
2503
  const neighbor = tabList[idx] ?? tabList[idx - 1];
2394
2504
  if (neighbor) {
2395
2505
  switchTo(neighbor.id);
2396
2506
  } else {
2397
2507
  replacement = create();
2398
2508
  }
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();
2509
+ }
2510
+ if (typing) {
2511
+ focusInput();
2410
2512
  }
2411
2513
  if (remote) {
2412
2514
  try {
@@ -2467,25 +2569,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2467
2569
  }
2468
2570
  }
2469
2571
  // 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]) {
2572
+ // a round trip.
2573
+ let needsTab = !splitOpen && closingSelected;
2574
+ if (emptiedPane) {
2575
+ const keep = ctx.shell.panes().find((p) => p.state() === "shown")?.side;
2576
+ if (keep === undefined) {
2577
+ ctx.shell.split.close();
2578
+ needsTab = true;
2579
+ } else {
2580
+ ctx.shell.split.closeSide(otherSide(keep));
2581
+ }
2582
+ }
2583
+ if (needsTab && tabList[0]) {
2472
2584
  switchTo(tabList[0].id);
2473
2585
  } else {
2474
2586
  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
2587
  }
2485
- // One terminal always stays open, and an open split always shows one tab.
2588
+ // One terminal always stays open.
2486
2589
  let replacement: Promise<void> | undefined;
2487
- if (tabList.length === 0 || (splitOpen && !anyShown())) {
2488
- replacement = create(ctx.shell.panes().find((p) => p.state() === "empty")?.side);
2590
+ if (tabList.length === 0) {
2591
+ replacement = create();
2489
2592
  }
2490
2593
  // The person asked for these closes, so every DELETE still goes out past a
2491
2594
  // teardown; only what the page shows for them stops.
@@ -3233,10 +3336,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3233
3336
  endRestNet();
3234
3337
  }
3235
3338
  });
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.
3339
+ // The left width rounds as `--wt-split-left` does in 32-split.css, so the
3340
+ // boundary is the one the highlights draw.
3240
3341
  function dropHalf(e: DragEvent): PaneSide | null {
3241
3342
  const root = ctx.shell.root;
3242
3343
  const target = e.target as Node;
@@ -3248,8 +3349,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3248
3349
  ) {
3249
3350
  return null;
3250
3351
  }
3251
- const rect = root.getBoundingClientRect();
3252
- return e.clientX < rect.left + rect.width / 2 ? "left" : "right";
3352
+ const { left, width } = root.getBoundingClientRect();
3353
+ const leftPane = Math.round((width - SPLIT_GUTTER_PX) * ctx.shell.split.state().ratio);
3354
+ return e.clientX < left + leftPane + SPLIT_GUTTER_PX / 2 ? "left" : "right";
3253
3355
  }
3254
3356
  function paintDropHalf(half: PaneSide | null): void {
3255
3357
  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