hop2 0.11.51 → 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.
- package/hay/apps/web/src/App.tsx +165 -202
- package/hay/apps/web/src/components/PaneLayout.tsx +411 -0
- package/hay/apps/web/src/components/SecondaryPane.tsx +70 -15
- package/hay/apps/web/src/components/SessionSwitcher.tsx +55 -21
- package/hay/apps/web/src/styles.css +134 -11
- package/hay/apps/web/src/utils/paneTree.ts +215 -0
- package/hay/apps/web/src/utils/switcherModel.ts +5 -0
- package/hay/apps/web/test/SessionSwitcher.test.tsx +60 -1
- package/hay/apps/web/test/paneTree.test.ts +141 -0
- package/hop +74 -10
- package/package.json +1 -1
- package/scripts/codex-notify-hook.js +108 -0
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties, type FormEvent, type ReactElement
|
|
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
|
-
//
|
|
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
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
|
|
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
|
-
|
|
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) =>
|
|
784
|
+
setPaneTree((tree) => setLeafSession(tree, paneId, session));
|
|
801
785
|
};
|
|
802
786
|
// Split + immediately open the palette targeting the new empty pane.
|
|
803
|
-
const splitAndPick = (
|
|
804
|
-
const id =
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
const
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
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 =
|
|
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) =>
|
|
828
|
+
setPaneTree((tree) => setLeafSession(tree, paneId, primaryRoom));
|
|
851
829
|
switchSessionRef.current?.(target);
|
|
852
830
|
setFocusedPaneId("primary");
|
|
853
831
|
};
|
|
854
|
-
|
|
855
|
-
|
|
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 =
|
|
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 =
|
|
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, ⌘⇧\
|
|
3978
|
-
//
|
|
3979
|
-
|
|
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
|
-
|
|
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 <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
|
-
|
|
4823
|
-
|
|
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
|
-
<
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
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 <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 ? "
|
|
4904
|
-
[isMacPlatform ? "⌘⇧
|
|
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]) => (
|