diff --git a/README.md b/README.md
index c8e9b81..a747ef5 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,20 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
- **Preset layouts** — titlebar buttons: `1` / `2H` / `3H` / `2V` / `2×2`. Confirms before replacing a multi-pane layout.
- **Active pane** — click any pane → blue border + keyboard focus.
- **Jump to pane** — `Ctrl+K` opens a fuzzy picker over label / distro / cwd. ↑/↓ to navigate, Enter to focus, Esc to close.
+
+### Keyboard shortcuts
+
+| Key | Action |
+|---|---|
+| `Ctrl+K` | open the jump-to-pane palette |
+| `Ctrl+Shift+E` | split active pane to the right |
+| `Ctrl+Shift+O` | split active pane downward |
+| `Ctrl+Shift+W` | close active pane |
+| `Ctrl+Shift+B` | toggle broadcast on active pane |
+| `Ctrl+Shift+Alt+B` | toggle broadcast on ALL panes (titlebar 📡) |
+| `Ctrl+Shift+←/→/↑/↓` | focus neighbour pane in that direction |
+
+Shortcuts work while a terminal is focused (we capture before xterm.js sees the key). They DON'T fire while you're typing into a label edit or the palette input, so those still work normally.
- **Idle toasts** — top-right notification when a pane goes quiet for 5 s. Useful for "I started a long task; tell me when it's done."
Layout + per-pane settings auto-save to `%APPDATA%\com.megaproxy.tiletopia\workspace.json` (debounced 500 ms).
diff --git a/src/App.tsx b/src/App.tsx
index 51d0c7c..5bd84e2 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -26,6 +26,8 @@ import {
flattenLayout,
updateSplitRatio,
swapLeaves,
+ findNeighborInDirection,
+ type Direction,
serialize,
deserialize,
presetSingle,
@@ -128,19 +130,6 @@ export default function App() {
return () => clearTimeout(id);
}, [tree, ready]);
- // ---- Ctrl+K palette toggle (capture phase to beat xterm) ----------------
- useEffect(() => {
- function onKey(e: KeyboardEvent) {
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
- e.preventDefault();
- e.stopPropagation();
- setPaletteOpen((v) => !v);
- }
- }
- window.addEventListener("keydown", onKey, true);
- return () => window.removeEventListener("keydown", onKey, true);
- }, []);
-
// ---- focus polling → setActive (xterm.js eats pointerdown) --------------
useEffect(() => {
let lastLeafId: string | null = null;
@@ -199,6 +188,107 @@ export default function App() {
setActiveLeafId(leafId);
}, []);
+ // ---- global keyboard shortcuts ------------------------------------------
+ // Capture phase beats xterm.js's own keystroke handlers. We intentionally
+ // don't intercept when the user is typing into a regular (label
+ // edits etc.) — but DO intercept when focus is in the xterm textarea,
+ // which is what makes shortcuts work while a terminal is focused.
+ const kbdStateRef = useRef({ activeLeafId, tree });
+ useEffect(() => {
+ kbdStateRef.current = { activeLeafId, tree };
+ });
+ useEffect(() => {
+ const DIR_MAP: Record = {
+ arrowleft: "left",
+ arrowright: "right",
+ arrowup: "up",
+ arrowdown: "down",
+ };
+
+ function shouldIgnore(): boolean {
+ const ae = document.activeElement as HTMLElement | null;
+ if (!ae) return false;
+ const tag = ae.tagName;
+ if (tag !== "INPUT" && tag !== "TEXTAREA") return false;
+ if (ae.classList.contains("xterm-helper-textarea")) return false;
+ return true;
+ }
+
+ function onKey(e: KeyboardEvent) {
+ if (shouldIgnore()) return;
+ const ctrl = e.ctrlKey || e.metaKey;
+ const shift = e.shiftKey;
+ const alt = e.altKey;
+ const key = e.key.toLowerCase();
+ const { activeLeafId, tree } = kbdStateRef.current;
+
+ // Ctrl+K — palette
+ if (ctrl && !shift && !alt && key === "k") {
+ e.preventDefault();
+ e.stopPropagation();
+ setPaletteOpen((v) => !v);
+ return;
+ }
+
+ // Ctrl+Shift+Alt+B — global broadcast all/none
+ if (ctrl && shift && alt && key === "b") {
+ e.preventDefault();
+ e.stopPropagation();
+ let anyOn = false;
+ for (const leaf of walkLeaves(tree)) {
+ if (leaf.broadcast) {
+ anyOn = true;
+ break;
+ }
+ }
+ setTree((t) => setAllBroadcast(t, !anyOn));
+ return;
+ }
+
+ // All remaining shortcuts require Ctrl+Shift with no Alt.
+ if (!ctrl || !shift || alt) return;
+
+ // Ctrl+Shift+Arrow — pane navigation
+ const dir = DIR_MAP[key];
+ if (dir) {
+ e.preventDefault();
+ e.stopPropagation();
+ const layout = flattenLayout(tree);
+ if (!activeLeafId) {
+ const first = layout.leaves[0]?.leaf.id;
+ if (first) setActiveLeafId(first);
+ return;
+ }
+ const nextId = findNeighborInDirection(layout.leaves, activeLeafId, dir);
+ if (nextId) setActiveLeafId(nextId);
+ return;
+ }
+
+ if (!activeLeafId) return;
+
+ if (key === "e") {
+ e.preventDefault();
+ e.stopPropagation();
+ split(activeLeafId, "h");
+ } else if (key === "o") {
+ e.preventDefault();
+ e.stopPropagation();
+ split(activeLeafId, "v");
+ } else if (key === "w") {
+ e.preventDefault();
+ e.stopPropagation();
+ close(activeLeafId);
+ } else if (key === "b") {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleBroadcast(activeLeafId);
+ }
+ }
+
+ window.addEventListener("keydown", onKey, true);
+ return () => window.removeEventListener("keydown", onKey, true);
+ }, [split, close, toggleBroadcast]);
+
const registerPaneId = useCallback(
(leafId: NodeId, paneId: PaneId | null) => {
if (paneId == null) paneIdByLeafRef.current.delete(leafId);
@@ -432,10 +522,10 @@ export default function App() {
onClick={toggleBroadcastAll}
title={
broadcastStats.on === 0
- ? "Click to broadcast input to ALL panes"
+ ? "Broadcast to ALL panes (Ctrl+Shift+Alt+B)"
: broadcastStats.on === broadcastStats.total
- ? "All panes broadcasting — click to disable"
- : `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all`
+ ? "All panes broadcasting — click to disable (Ctrl+Shift+Alt+B)"
+ : `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all (Ctrl+Shift+Alt+B)`
}
>
{broadcastBtnLabel}
diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx
index f98dfa6..71babf5 100644
--- a/src/lib/layout/LeafPane.tsx
+++ b/src/lib/layout/LeafPane.tsx
@@ -303,8 +303,8 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
}}
title={
isBroadcasting
- ? "Broadcasting (click to leave group)"
- : "Click to broadcast input to other broadcast panes"
+ ? "Broadcasting (click or Ctrl+Shift+B to leave group)"
+ : "Click or Ctrl+Shift+B to broadcast input to other broadcast panes"
}
aria-pressed={isBroadcasting ? "true" : "false"}
>
@@ -322,7 +322,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {