hop2 0.11.52 → 0.11.53

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.
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties, type FormEvent, type ReactElement, type PointerEvent as ReactPointerEvent } from "react";
1
+ import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties, type FormEvent, type ReactElement } from "react";
2
2
  import { FitAddon } from "@xterm/addon-fit";
3
3
  import { WebLinksAddon } from "@xterm/addon-web-links";
4
4
  import { Terminal } from "@xterm/xterm";
@@ -22,6 +22,25 @@ import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
22
22
  import { MobileKeyboard } from "./components/MobileKeyboard";
23
23
  import { SessionSwitcher } from "./components/SessionSwitcher";
24
24
  import { SecondaryPane } from "./components/SecondaryPane";
25
+ import { PaneLayout } from "./components/PaneLayout";
26
+ import {
27
+ PRIMARY_PANE,
28
+ findLeaf,
29
+ leafIds,
30
+ moveLeaf,
31
+ neighborLeaf,
32
+ newPaneId,
33
+ paneTreeHasPrimary,
34
+ paneTreeValid,
35
+ removeLeaf,
36
+ setLeafSession,
37
+ setSplitRatio,
38
+ splitLeaf,
39
+ type Direction,
40
+ type DropZone,
41
+ type PaneNode,
42
+ type Side
43
+ } from "./utils/paneTree";
25
44
  import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "./components/ViewsPanel";
26
45
  import type { ViewsSummary } from "./utils/switcherModel";
27
46
 
@@ -209,30 +228,6 @@ type ConnectionStatus = "idle" | "connecting" | "connected" | "disconnected" | "
209
228
 
210
229
  // Latency compensation (optimistic echo) and auto-fit-on-type are always on;
211
230
  // kept as constants so the gated code paths stay obvious.
212
- // ── Pane layout tree ─────────────────────────────────────────────────────
213
- // Splits are a full binary tree (direction + ratio + two children) so future
214
- // layouts (nested splits, 2x2 grids) need no model changes — today's UI just
215
- // happens to build one split at a time. The primary leaf (session: null) is
216
- // the full-featured terminal; other leaves are lightweight SecondaryPanes.
217
- type PaneNode =
218
- | { kind: "leaf"; id: string; session: string | null }
219
- | { kind: "split"; id: string; dir: "row" | "col"; ratio: number; a: PaneNode; b: PaneNode };
220
-
221
- const PRIMARY_PANE: PaneNode = { kind: "leaf", id: "primary", session: null };
222
- const newPaneId = () => `p${Math.random().toString(36).slice(2, 8)}`;
223
-
224
- const paneTreeValid = (n: unknown): n is PaneNode => {
225
- const x = n as PaneNode;
226
- if (!x || typeof x !== "object") return false;
227
- if (x.kind === "leaf") return typeof x.id === "string" && (x.session === null || typeof x.session === "string");
228
- if (x.kind === "split") {
229
- return (x.dir === "row" || x.dir === "col") && typeof x.ratio === "number" && paneTreeValid(x.a) && paneTreeValid(x.b);
230
- }
231
- return false;
232
- };
233
- const paneTreeHasPrimary = (n: PaneNode): boolean =>
234
- n.kind === "leaf" ? n.session === null : paneTreeHasPrimary(n.a) || paneTreeHasPrimary(n.b);
235
-
236
231
  const LATENCY_COMP = true;
237
232
  // A WebSocket handshake normally completes in well under a second. One still
238
233
  // CONNECTING after this long has hung (a stalled tunnel upgrade) and must be
@@ -261,6 +256,14 @@ type SessionInfo = {
261
256
  foregroundProcess?: string;
262
257
  agentPermitted?: boolean;
263
258
  createdBy?: "user" | "agent";
259
+ createdVia?: string | null;
260
+ agent?: "claude" | "codex" | null;
261
+ attentionReason?: string;
262
+ attentionNote?: string;
263
+ cols?: number;
264
+ rows?: number;
265
+ folderId?: string | null;
266
+ hasLocalCli?: boolean;
264
267
  // Computed against the local seen-markers: new output / an unseen bell since
265
268
  // this client last viewed the session.
266
269
  unread?: boolean;
@@ -739,6 +742,8 @@ const App = () => {
739
742
  shortcutHelpRef.current = shortcutHelpOpen;
740
743
 
741
744
  // ── Panes ──
745
+ // Any binary nesting of splits; the primary leaf can sit anywhere in it
746
+ // (PaneLayout keeps its DOM in place). Persisted per browser.
742
747
  const [paneTree, setPaneTree] = useState<PaneNode>(() => {
743
748
  try {
744
749
  const raw = localStorage.getItem("hay_pane_tree");
@@ -752,7 +757,8 @@ const App = () => {
752
757
  const [focusedPaneId, setFocusedPaneId] = useState("primary");
753
758
  const focusedPaneIdRef = useRef(focusedPaneId);
754
759
  focusedPaneIdRef.current = focusedPaneId;
755
- // ⌘\ opens the palette in pick-a-session-for-the-new-pane mode.
760
+ // Zoom (iTerm's maximize): one pane fills the layout; the tree is kept.
761
+ const [zoomedPaneId, setZoomedPaneId] = useState<string | null>(null);
756
762
  // Holds the empty pane id awaiting a session from the palette (split-first-
757
763
  // then-fill), or null when the palette is a normal session switch.
758
764
  const paneTargetRef = useRef<string | null>(null);
@@ -762,101 +768,69 @@ const App = () => {
762
768
  else localStorage.setItem("hay_pane_tree", JSON.stringify(paneTree));
763
769
  } catch { /* ignore */ }
764
770
  }, [paneTree]);
771
+ const paneTreeRef = useRef(paneTree);
772
+ paneTreeRef.current = paneTree;
765
773
 
766
- // Split the focused pane, adding an EMPTY pane in the given direction (dir:
767
- // "row" = side-by-side, "col" = stacked). Returns the new empty pane's id so
768
- // the caller can focus it and open the palette to fill it (split-first, then
769
- // pick — matching tmux/iTerm muscle memory). Direct fill of an existing empty
770
- // pane is a separate op (fillPane).
771
- const splitEmpty = (dir: "row" | "col"): string => {
774
+ // Split a pane on the given side, adding an EMPTY pane there. Returns the
775
+ // new pane's id so the caller can focus it and open the palette to fill it
776
+ // (split-first, then pick — tmux/iTerm muscle memory). Direct fill of an
777
+ // existing empty pane is a separate op (fillPane).
778
+ const splitPaneAt = (targetId: string, side: Side): string => {
772
779
  const freshId = newPaneId();
773
- const fresh: PaneNode = { kind: "leaf", id: freshId, session: "" };
774
- setPaneTree((tree) => {
775
- const target = focusedPaneIdRef.current;
776
- // Rule: the PRIMARY leaf never re-parents (React would remount the live
777
- // xterm/WebGL terminal and kill it). Splits from the primary nest on the
778
- // secondary side; secondary leaves split in place (they tolerate remount).
779
- if (target === "primary" || tree.kind === "leaf") {
780
- if (tree.kind === "leaf") {
781
- return { kind: "split", id: "root", dir, ratio: 0.55, a: tree, b: fresh };
782
- }
783
- return { ...tree, b: { kind: "split", id: newPaneId(), dir, ratio: 0.5, a: tree.b, b: fresh } };
784
- }
785
- let done = false;
786
- const replace = (n: PaneNode): PaneNode => {
787
- if (n.kind === "leaf") {
788
- if (n.id !== target || done) return n;
789
- done = true;
790
- return { kind: "split", id: newPaneId(), dir, ratio: 0.5, a: n, b: fresh };
791
- }
792
- return { ...n, a: replace(n.a), b: replace(n.b) };
793
- };
794
- const next = replace(tree);
795
- return done ? next : { ...tree, b: { kind: "split", id: newPaneId(), dir, ratio: 0.5, a: (tree as Extract<PaneNode, { kind: "split" }>).b, b: fresh } };
796
- });
780
+ setPaneTree((tree) => splitLeaf(tree, targetId, side, { kind: "leaf", id: freshId, session: "" }));
797
781
  return freshId;
798
782
  };
799
783
  const fillPane = (paneId: string, session: string) => {
800
- setPaneTree((tree) => setPaneLeafSession(tree, paneId, session));
784
+ setPaneTree((tree) => setLeafSession(tree, paneId, session));
801
785
  };
802
786
  // Split + immediately open the palette targeting the new empty pane.
803
- const splitAndPick = (dir: "row" | "col") => {
804
- const id = splitEmpty(dir);
787
+ const splitAndPick = (side: Side, targetId: string = focusedPaneIdRef.current) => {
788
+ const id = splitPaneAt(targetId, side);
789
+ setZoomedPaneId(null);
805
790
  setFocusedPaneId(id);
806
791
  paneTargetRef.current = id;
807
792
  setSwitcherOpen(true);
808
793
  };
809
794
  const closePane = (id: string) => {
810
795
  if (id === "primary") return;
796
+ setZoomedPaneId((z) => (z === id ? null : z));
811
797
  setFocusedPaneId("primary");
812
- setPaneTree((tree) => {
813
- const prune = (n: PaneNode): PaneNode | null => {
814
- if (n.kind === "leaf") return n.id === id ? null : n;
815
- const a = prune(n.a);
816
- const b = prune(n.b);
817
- if (a && b) return { ...n, a, b };
818
- return a || b;
819
- };
820
- return prune(tree) || PRIMARY_PANE;
821
- });
798
+ setPaneTree((tree) => removeLeaf(tree, id) || PRIMARY_PANE);
822
799
  };
823
800
  const setPaneRatio = (id: string, ratio: number) => {
824
- setPaneTree((tree) => {
825
- const walk = (n: PaneNode): PaneNode =>
826
- n.kind === "split" ? (n.id === id ? { ...n, ratio } : { ...n, a: walk(n.a), b: walk(n.b) }) : n;
827
- return walk(tree);
828
- });
801
+ setPaneTree((tree) => setSplitRatio(tree, id, ratio));
829
802
  };
830
- const paneTreeRef = useRef(paneTree);
831
- paneTreeRef.current = paneTree;
832
- const findPaneLeafSession = (n: PaneNode, id: string): string | null => {
833
- if (n.kind === "leaf") return n.id === id ? n.session : null;
834
- return findPaneLeafSession(n.a, id) ?? findPaneLeafSession(n.b, id);
803
+ // Re-dock: a pane dropped on another's edge splits it there; dropped on
804
+ // the middle, the two trade places. The primary moves like any other leaf.
805
+ const movePane = (id: string, targetId: string, where: DropZone) => {
806
+ setPaneTree((tree) => moveLeaf(tree, id, targetId, where));
807
+ setFocusedPaneId(id);
808
+ };
809
+ const toggleZoom = (id: string = focusedPaneIdRef.current) => {
810
+ setZoomedPaneId((z) => (z === id ? null : id));
811
+ setFocusedPaneId(id);
812
+ };
813
+ const focusNeighbor = (dir: Direction) => {
814
+ const next = neighborLeaf(paneTreeRef.current, focusedPaneIdRef.current, dir);
815
+ if (!next) return;
816
+ setZoomedPaneId(null);
817
+ setFocusedPaneId(next);
835
818
  };
836
- const setPaneLeafSession = (n: PaneNode, id: string, session: string): PaneNode =>
837
- n.kind === "leaf"
838
- ? (n.id === id ? { ...n, session } : n)
839
- : { ...n, a: setPaneLeafSession(n.a, id, session), b: setPaneLeafSession(n.b, id, session) };
840
819
  // Promote: the pane's session becomes the primary and the primary's session
841
820
  // moves into the pane. Pure connection retargeting — the primary terminal's
842
- // DOM never moves (a remount would kill the live canvas); the pane
843
- // reconnects when its sessionName prop changes.
821
+ // DOM never moves; the pane reconnects when its sessionName prop changes.
844
822
  const swapPaneWithPrimary = (paneId: string) => {
845
- const paneSession = findPaneLeafSession(paneTreeRef.current, paneId);
823
+ const paneSession = findLeaf(paneTreeRef.current, paneId)?.session;
846
824
  const primaryRoom = activeSessionRoomRef.current;
847
825
  if (!paneSession || !primaryRoom || paneSession === primaryRoom) return;
848
826
  const target = sessionsRef.current.find((x) => x.name === paneSession || x.internalName === paneSession);
849
827
  if (!target) return;
850
- setPaneTree((tree) => setPaneLeafSession(tree, paneId, primaryRoom));
828
+ setPaneTree((tree) => setLeafSession(tree, paneId, primaryRoom));
851
829
  switchSessionRef.current?.(target);
852
830
  setFocusedPaneId("primary");
853
831
  };
854
- // In-order leaves = visual left-to-right/top-to-bottom pane order.
855
- const paneOpsRef = useRef({ splitAndPick, closePane, swapPaneWithPrimary });
856
- paneOpsRef.current = { splitAndPick, closePane, swapPaneWithPrimary };
857
- const paneLeafIds = (n: PaneNode): string[] =>
858
- n.kind === "leaf" ? [n.id] : [...paneLeafIds(n.a), ...paneLeafIds(n.b)];
859
- const paneDragRef = useRef<{ id: string; dir: "row" | "col"; rect: DOMRect } | null>(null);
832
+ const paneOpsRef = useRef({ splitAndPick, closePane, swapPaneWithPrimary, toggleZoom, focusNeighbor });
833
+ paneOpsRef.current = { splitAndPick, closePane, swapPaneWithPrimary, toggleZoom, focusNeighbor };
860
834
  const sessionListLoadedRef = useRef(false);
861
835
  const sessionListFetchedAtRef = useRef(0);
862
836
  // Monotonic fetch id so a stale in-flight /api/sessions response can't
@@ -3192,6 +3166,7 @@ const App = () => {
3192
3166
  agentPermitted: s.agentPermitted === true,
3193
3167
  createdBy: s.createdBy === "agent" ? "agent" : "user",
3194
3168
  createdVia: typeof s.createdVia === "string" ? s.createdVia : null,
3169
+ agent: s.agent === "claude" || s.agent === "codex" ? s.agent : null,
3195
3170
  cols: Number.isInteger(s.cols) ? s.cols : undefined,
3196
3171
  rows: Number.isInteger(s.rows) ? s.rows : undefined,
3197
3172
  // These three were silently dropped here, so the switcher never saw
@@ -3912,6 +3887,21 @@ const App = () => {
3912
3887
  if (target) switchSessionRef.current?.(target);
3913
3888
  };
3914
3889
  const onKey = (event: KeyboardEvent) => {
3890
+ // ⌘⌥← ↑ ↓ → (Ctrl+Alt elsewhere) moves focus to the pane in that
3891
+ // direction — iTerm's chord. Only when there is somewhere to go, so a
3892
+ // lone terminal keeps every key.
3893
+ const arrow = ({ ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down" } as Record<string, Direction | undefined>)[event.key];
3894
+ const navMod = isMacPlatform
3895
+ ? event.metaKey && event.altKey && !event.ctrlKey && !event.shiftKey
3896
+ : event.ctrlKey && event.altKey && !event.metaKey && !event.shiftKey;
3897
+ if (arrow && navMod) {
3898
+ if (leafIds(paneTreeRef.current).length > 1) {
3899
+ event.preventDefault();
3900
+ event.stopPropagation();
3901
+ paneOpsRef.current.focusNeighbor(arrow);
3902
+ }
3903
+ return;
3904
+ }
3915
3905
  const mod = isMacPlatform
3916
3906
  ? event.metaKey && !event.ctrlKey && !event.altKey
3917
3907
  : event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey;
@@ -3949,7 +3939,7 @@ const App = () => {
3949
3939
  if (key === ",") { grab(); setDrawerOpen((v) => !v); return; }
3950
3940
  if (key === "/" || (shifted && key === "?")) { grab(); setShortcutHelpOpen((v) => !v); return; }
3951
3941
  if (key === "e") {
3952
- const leaves = paneLeafIds(paneTreeRef.current).filter((id) => id !== "primary");
3942
+ const leaves = leafIds(paneTreeRef.current).filter((id) => id !== "primary");
3953
3943
  const swapTarget = focusedPaneIdRef.current !== "primary"
3954
3944
  ? focusedPaneIdRef.current
3955
3945
  : (leaves.length === 1 ? leaves[0] : null);
@@ -3966,7 +3956,7 @@ const App = () => {
3966
3956
  return;
3967
3957
  }
3968
3958
  if (key === "]" || key === "[") {
3969
- const ids = paneLeafIds(paneTreeRef.current);
3959
+ const ids = leafIds(paneTreeRef.current);
3970
3960
  if (ids.length > 1) {
3971
3961
  grab();
3972
3962
  const cur = Math.max(0, ids.indexOf(focusedPaneIdRef.current));
@@ -3974,11 +3964,17 @@ const App = () => {
3974
3964
  }
3975
3965
  return;
3976
3966
  }
3977
- // Split-first-then-fill: ⌘\ side-by-side, ⌘⇧\ stacked; each opens an
3978
- // empty pane and the palette to fill it. ⌘⇧K closes the focused pane.
3979
- if (key === "\\" || key === "|") {
3967
+ // Split-first-then-fill: ⌘\ or ⌘D side-by-side, ⌘⇧\ or ⌘⇧D stacked
3968
+ // (iTerm's chords included); each opens an empty pane and the palette to
3969
+ // fill it. ⌘⇧⏎ zooms the focused pane (again restores). ⌘⇧K closes it.
3970
+ if (key === "\\" || key === "|" || key === "d") {
3980
3971
  grab();
3981
- paneOpsRef.current.splitAndPick(shifted || key === "|" ? "col" : "row");
3972
+ const stacked = key === "|" || (isMacPlatform ? shifted : key !== "d");
3973
+ paneOpsRef.current.splitAndPick(stacked ? "bottom" : "right");
3974
+ return;
3975
+ }
3976
+ if (shifted && key === "enter") {
3977
+ if (leafIds(paneTreeRef.current).length > 1) { grab(); paneOpsRef.current.toggleZoom(); }
3982
3978
  return;
3983
3979
  }
3984
3980
  if (shifted && key === "k" && focusedPaneIdRef.current !== "primary") {
@@ -4734,109 +4730,72 @@ const App = () => {
4734
4730
  </div>
4735
4731
  </section>
4736
4732
  );
4737
- const renderDivider = (node: Extract<PaneNode, { kind: "split" }>): ReactElement => (
4738
- <div
4739
- key={`div-${node.id}`}
4740
- className="pane-divider"
4741
- onPointerDown={(e: ReactPointerEvent<HTMLDivElement>) => {
4742
- const parent = (e.currentTarget as HTMLElement).parentElement;
4743
- if (!parent) return;
4744
- paneDragRef.current = { id: node.id, dir: node.dir, rect: parent.getBoundingClientRect() };
4745
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
4746
- }}
4747
- onPointerMove={(e: ReactPointerEvent<HTMLDivElement>) => {
4748
- const d = paneDragRef.current;
4749
- if (!d || d.id !== node.id) return;
4750
- const r = d.dir === "row"
4751
- ? (e.clientX - d.rect.left) / Math.max(1, d.rect.width)
4752
- : (e.clientY - d.rect.top) / Math.max(1, d.rect.height);
4753
- setPaneRatio(d.id, Math.min(0.85, Math.max(0.15, r)));
4754
- }}
4755
- onPointerUp={() => { paneDragRef.current = null; }}
4756
- />
4757
- );
4758
- const renderPaneNode = (node: PaneNode): ReactElement => {
4759
- if (node.kind === "leaf") {
4760
- if (node.session === null) {
4761
- return (
4762
- <div key="primary" className="primary-pane-wrap" onMouseDownCapture={() => setFocusedPaneId("primary")}>
4763
- {renderPrimarySection()}
4764
- </div>
4765
- );
4766
- }
4767
- if (node.session === "") {
4768
- // Empty pane awaiting a session (split-first, then fill).
4769
- return (
4770
- <div
4771
- key={node.id}
4772
- className={`empty-pane${focusedPaneId === node.id ? " focused" : ""}`}
4773
- onMouseDown={() => setFocusedPaneId(node.id)}
4774
- >
4775
- <button
4776
- type="button"
4777
- className="empty-pane-pick"
4778
- onClick={() => { setFocusedPaneId(node.id); paneTargetRef.current = node.id; setSwitcherOpen(true); }}
4779
- >
4780
- <span className="empty-pane-plus">+</span>
4781
- <span>Pick a session&ensp;<kbd>{isMacPlatform ? "⌘K" : "Ctrl+Shift+K"}</kbd></span>
4782
- </button>
4783
- <button type="button" className="empty-pane-close" aria-label="Close empty pane" onClick={(e) => { e.stopPropagation(); closePane(node.id); }}>✕</button>
4784
- </div>
4785
- );
4786
- }
4787
- const paneInfo = sessions.find((x) => x.name === node.session || x.internalName === node.session);
4788
- const paneProcRaw = (paneInfo?.foregroundProcess || "").replace(/^-/, "");
4789
- const paneProc = /^\d+\.\d+\.\d+$/.test(paneProcRaw) ? "claude" : paneProcRaw;
4790
- return (
4791
- <SecondaryPane
4792
- key={node.id}
4793
- sessionName={node.session}
4794
- procLabel={paneProc === "zsh" || paneProc === "bash" ? "" : paneProc}
4795
- wsUrl={resolveWsUrl()}
4796
- userName={name}
4797
- cols={120}
4798
- rows={30}
4799
- fontSize={fontSize}
4800
- theme={resolveTerminalTheme(themeMode)}
4801
- focused={focusedPaneId === node.id}
4802
- onFocus={() => setFocusedPaneId(node.id)}
4803
- onClose={() => closePane(node.id)}
4804
- onPromote={() => swapPaneWithPrimary(node.id)}
4805
- />
4806
- );
4807
- }
4808
- return (
4809
- <div key={node.id} className={`pane-split ${node.dir === "row" ? "dir-row" : "dir-col"}`}>
4810
- <div className="pane-cell" style={{ flexGrow: node.ratio, flexBasis: 0 }}>{renderPaneNode(node.a)}</div>
4811
- {renderDivider(node)}
4812
- <div className="pane-cell" style={{ flexGrow: 1 - node.ratio, flexBasis: 0 }}>{renderPaneNode(node.b)}</div>
4813
- </div>
4814
- );
4815
- };
4816
- const primaryWrap = (
4817
- <div key="primary" className="primary-pane-wrap" onMouseDownCapture={() => setFocusedPaneId("primary")}>
4818
- {renderPrimarySection()}
4819
- </div>
4820
- );
4821
4733
  if (isMobile) return renderPrimarySection();
4822
- // Stable scaffold: the primary's ancestor chain (pane-root → root
4823
- // split → cell A) and keys are IDENTICAL with and without a split,
4824
- // so adding/removing panes never re-parents the live terminal.
4825
- const rootSplit = paneTree.kind === "split" ? paneTree : null;
4734
+ const primaryLabel = sessionLabel || session.room;
4735
+ const infoOf = (key: string) => sessions.find((x) => x.name === key || x.internalName === key);
4826
4736
  return (
4827
- <div className="pane-root" inert={switcherOpen} style={switcherOpen ? { visibility: "hidden" } : undefined}>
4828
- <div key="rootsplit" className={`pane-split ${rootSplit?.dir === "col" ? "dir-col" : "dir-row"}`}>
4829
- <div key="cell-a" className="pane-cell" style={{ flexGrow: rootSplit ? rootSplit.ratio : 1, flexBasis: 0 }}>
4830
- {rootSplit && rootSplit.a.kind !== "leaf" ? renderPaneNode(rootSplit.a) : primaryWrap}
4831
- </div>
4832
- {rootSplit && renderDivider(rootSplit)}
4833
- {rootSplit && (
4834
- <div key="cell-b" className="pane-cell" style={{ flexGrow: 1 - rootSplit.ratio, flexBasis: 0 }}>
4835
- {renderPaneNode(rootSplit.b)}
4836
- </div>
4837
- )}
4838
- </div>
4839
- </div>
4737
+ <PaneLayout
4738
+ tree={paneTree}
4739
+ focusedId={focusedPaneId}
4740
+ zoomedId={zoomedPaneId}
4741
+ hidden={switcherOpen}
4742
+ mac={isMacPlatform}
4743
+ primary={renderPrimarySection()}
4744
+ labelOf={(leaf) =>
4745
+ leaf.session === null ? primaryLabel : (infoOf(leaf.session)?.displayName || leaf.session || "empty pane")}
4746
+ onFocus={setFocusedPaneId}
4747
+ onRatio={setPaneRatio}
4748
+ onSplit={(id, side) => splitAndPick(side, id)}
4749
+ onZoom={toggleZoom}
4750
+ onMove={movePane}
4751
+ renderLeaf={(leaf, api) => {
4752
+ if (leaf.session === "") {
4753
+ // Empty pane awaiting a session (split-first, then fill).
4754
+ return (
4755
+ <div
4756
+ className={`empty-pane${api.focused ? " focused" : ""}`}
4757
+ onMouseDown={() => setFocusedPaneId(leaf.id)}
4758
+ >
4759
+ <button
4760
+ type="button"
4761
+ className="empty-pane-pick"
4762
+ onClick={() => { setFocusedPaneId(leaf.id); paneTargetRef.current = leaf.id; setSwitcherOpen(true); }}
4763
+ >
4764
+ <span className="empty-pane-plus">+</span>
4765
+ <span>Pick a session&ensp;<kbd>{isMacPlatform ? "⌘K" : "Ctrl+Shift+K"}</kbd></span>
4766
+ </button>
4767
+ <button type="button" className="empty-pane-close" aria-label="Close empty pane" onClick={(e) => { e.stopPropagation(); closePane(leaf.id); }}>✕</button>
4768
+ </div>
4769
+ );
4770
+ }
4771
+ const paneInfo = infoOf(leaf.session as string);
4772
+ const paneProcRaw = (paneInfo?.foregroundProcess || "").replace(/^-/, "");
4773
+ // The daemon knows the agent from its hooks; the process name
4774
+ // reports codex as "node" and a restore as its wrapper shell.
4775
+ const paneProc = paneInfo?.agent || (/^\d+\.\d+\.\d+$/.test(paneProcRaw) ? "claude" : paneProcRaw);
4776
+ return (
4777
+ <SecondaryPane
4778
+ sessionName={leaf.session as string}
4779
+ procLabel={paneProc === "zsh" || paneProc === "bash" ? "" : paneProc}
4780
+ wsUrl={resolveWsUrl()}
4781
+ userName={name}
4782
+ cols={120}
4783
+ rows={30}
4784
+ fontSize={fontSize}
4785
+ theme={resolveTerminalTheme(themeMode)}
4786
+ focused={api.focused}
4787
+ zoomed={api.zoomed}
4788
+ mac={isMacPlatform}
4789
+ dragHandle={api.dragHandle}
4790
+ onFocus={() => setFocusedPaneId(leaf.id)}
4791
+ onClose={() => closePane(leaf.id)}
4792
+ onPromote={() => swapPaneWithPrimary(leaf.id)}
4793
+ onSplit={(side) => splitAndPick(side, leaf.id)}
4794
+ onZoom={() => toggleZoom(leaf.id)}
4795
+ />
4796
+ );
4797
+ }}
4798
+ />
4840
4799
  );
4841
4800
  })()}
4842
4801
  <SessionSwitcher
@@ -4900,10 +4859,14 @@ const App = () => {
4900
4859
  ...(isMacPlatform ? [["⌘+ / ⌘− / ⌘0", "terminal font size"]] : []),
4901
4860
  ["Ctrl+Shift+C / V", "copy / paste"],
4902
4861
  ["Shift+PgUp / PgDn", "scrollback"],
4903
- [isMacPlatform ? "⌘\\ / ⌘⇧\\" : "Ctrl+Shift+\\ / +|", "split pane — side / stacked"],
4904
- [isMacPlatform ? "⌘⇧K" : "Ctrl+Shift+K", "close focused pane"],
4862
+ [isMacPlatform ? "⌘D or ⌘\\" : "Ctrl+Shift+D", "split pane to the right"],
4863
+ [isMacPlatform ? "⌘⇧D or ⌘⇧\\" : "Ctrl+Shift+|", "split pane below"],
4864
+ [isMacPlatform ? "⌘⌥← ↑ ↓ →" : "Ctrl+Alt+arrows", "focus the pane in that direction"],
4905
4865
  [isMacPlatform ? "⌘] / ⌘[" : "Ctrl+Shift+] / [", "next / previous pane"],
4866
+ [isMacPlatform ? "⌘⇧⏎" : "Ctrl+Shift+⏎", "zoom the focused pane (again to restore)"],
4906
4867
  [isMacPlatform ? "⌘⇧E" : "Ctrl+Shift+E", "swap pane with primary"],
4868
+ [isMacPlatform ? "⌘⇧K" : "Ctrl+Shift+K", "close focused pane"],
4869
+ ["drag a pane's title bar", "re-dock it — an edge splits there, the middle swaps"],
4907
4870
  [isMacPlatform ? "⌘/" : "Ctrl+Shift+/", "this help"],
4908
4871
  ["Esc", "close panels"]
4909
4872
  ].map(([keys, what]) => (