Add keyboard shortcuts (Ctrl+Shift chord style)

| Ctrl+K           | palette                                |
| Ctrl+Shift+E     | split active pane right                |
| Ctrl+Shift+O     | split active pane down                 |
| Ctrl+Shift+W     | close active pane                      |
| Ctrl+Shift+B     | toggle broadcast on active             |
| Ctrl+Shift+Alt+B | toggle broadcast on ALL panes          |
| Ctrl+Shift+Arrow | focus neighbour pane in that direction |

The handler attaches at capture phase on window so it wins against
xterm.js. It bails when a non-terminal <input>/<textarea> is focused
so label edits and the palette input keep working normally.

Spatial neighbour-finding lives in tree.ts as findNeighborInDirection
— picks the leaf whose centre is most aligned in the perpendicular
axis, breaking ties by primary-axis distance.

Tooltips on toolbar/titlebar buttons now mention their shortcuts;
README has a key-binding table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 21:32:51 +01:00
parent 9aa0c5a828
commit a4cd82440b
4 changed files with 188 additions and 21 deletions

View file

@ -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 <input> (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<string, Direction | undefined> = {
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}