From baa00dfc5cfa49101e4439bc72691ad044f9e218 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 21:51:29 +0100 Subject: [PATCH] Add find-in-scrollback, unicode11, and keyboard pane navigation Three xterm.js features, implemented together because they share the XtermPane mount + the single attachCustomKeyEventHandler: - Unicode 11: load @xterm/addon-unicode11, set activeVersion='11' after the canvas renderer so emoji/CJK/box-drawing widths stop drifting. - Find in scrollback: @xterm/addon-search + a new per-pane SearchBar overlay (Ctrl+Shift+F to open, Enter/Shift+Enter next/prev, regex + case toggles, Esc to close & refocus). Overlay is an absolutely- positioned sibling in a position:relative wrapper so fit() is unaffected. - Pane navigation: Ctrl+Alt+Arrow / Ctrl+Alt+HJKL (spatial neighbour via findNeighborInDirection) and Alt+1..9 (Nth leaf in walkLeaves order). XtermPane emits a NavigateIntent; App resolves the target leaf and sets it active, reusing the existing isActive->focusTrigger refocus chain. All chords live in one attachCustomKeyEventHandler (xterm replaces the handler on each call). Shortcuts added to shortcuts.ts (SoT for README + Help), including the Alt+digit shell-conflict caveat. tsc clean apart from the three not-yet-installed addon modules. Needs pnpm install on the Windows host + runtime verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 + src/App.tsx | 34 +++++- src/components/SearchBar.css | 105 ++++++++++++++++++ src/components/SearchBar.tsx | 177 ++++++++++++++++++++++++++++++ src/components/XtermPane.tsx | 179 ++++++++++++++++++++++++++----- src/lib/layout/LeafPane.tsx | 9 ++ src/lib/layout/orchestration.tsx | 19 +++- src/lib/shortcuts.ts | 30 +++++- 8 files changed, 526 insertions(+), 29 deletions(-) create mode 100644 src/components/SearchBar.css create mode 100644 src/components/SearchBar.tsx diff --git a/package.json b/package.json index 393ccb7..102c58e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@tauri-apps/plugin-opener": "^2.0.0", "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^5.5.0", "react": "^18.3.0", diff --git a/src/App.tsx b/src/App.tsx index 07fefe2..ffceee2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -98,7 +98,7 @@ import { presetTwoRows, presetTwoByTwo, } from "./lib/layout/tree"; -import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration"; +import { OrchestrationProvider, type Orchestration, type NavigateIntent } from "./lib/layout/orchestration"; import LeafPane from "./lib/layout/LeafPane"; import Gutter from "./lib/layout/Gutter"; import Notifications, { type Toast } from "./components/Notifications"; @@ -717,6 +717,36 @@ export default function App() { setActiveLeafId(leafId); }, []); + // navigateTo — called from XtermPane's attachCustomKeyEventHandler via + // LeafPane's onNavigate prop. Resolves the target leaf from the current + // layout tree and sets it active; the LeafPane isActive→focusTrigger + // effect then refocuses the xterm textarea automatically. + const navigateTo = useCallback((intent: NavigateIntent) => { + const currentTree = treeRef.current; + const currentActiveId = activeLeafId; + + if (intent.kind === "direction") { + const layout = flattenLayout(currentTree); + if (!currentActiveId) { + const first = layout.leaves[0]?.leaf.id; + if (first) setActiveLeafId(first); + return; + } + const nextId = findNeighborInDirection( + layout.leaves, + currentActiveId, + intent.dir, + ); + if (nextId) setActiveLeafId(nextId); + } else { + // intent.kind === "index" + const leaves = Array.from(walkLeaves(currentTree)); + // Clamp: Alt+9 with 3 panes picks the 3rd pane. + const target = leaves[Math.min(intent.n, leaves.length) - 1]; + if (target) setActiveLeafId(target.id); + } + }, [activeLeafId]); // treeRef is a ref — stable, intentionally not listed + const openHostManager = useCallback(() => setHostManagerOpen(true), []); const closeHostManager = useCallback(() => setHostManagerOpen(false), []); @@ -1242,6 +1272,7 @@ export default function App() { toggleMcpAllow, openHostManager, setActive, + navigateTo, registerPaneId, broadcastFrom, notify, @@ -1266,6 +1297,7 @@ export default function App() { toggleMcpAllow, openHostManager, setActive, + navigateTo, registerPaneId, broadcastFrom, notify, diff --git a/src/components/SearchBar.css b/src/components/SearchBar.css new file mode 100644 index 0000000..0f389bd --- /dev/null +++ b/src/components/SearchBar.css @@ -0,0 +1,105 @@ +/* --------------------------------------------------------------------------- + SearchBar — find-in-scrollback overlay. + + Positioned absolutely inside XtermPane's container div (which must be + position: relative). Sits at the top-right of the pane, z-index 10 so it + floats above the xterm canvas but below any app-level modals (z-index 100). + Colour palette matches Palette.css / Help.css: #181818 surface, #2a2a2a + borders, #e6e6e6 text, #1a3a5c accent. +--------------------------------------------------------------------------- */ + +.search-bar { + position: absolute; + top: 4px; + right: 4px; + z-index: 10; + display: flex; + align-items: center; + gap: 3px; + background: #181818; + border: 1px solid #2a2a2a; + border-radius: 5px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.55); + padding: 3px 4px; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 12px; + color: #e6e6e6; +} + +.search-input { + font: inherit; + font-size: 12px; + color: #e6e6e6; + background: #1f1f1f; + border: 1px solid #2a2a2a; + border-radius: 3px; + padding: 3px 7px; + outline: none; + width: 180px; + caret-color: #e6e6e6; +} + +.search-input:focus { + border-color: #1a3a5c; + box-shadow: 0 0 0 1px #1a3a5c; +} + +.search-input::placeholder { + color: #555; +} + +/* Toggle buttons (Aa / .*) */ +.search-toggle { + font: inherit; + font-size: 11px; + background: transparent; + border: 1px solid #2a2a2a; + border-radius: 3px; + color: #888; + padding: 2px 5px; + cursor: pointer; + line-height: 1; + transition: background 0.1s, color 0.1s; +} + +.search-toggle:hover, +.search-toggle[aria-pressed="true"] { + background: #1a3a5c; + border-color: #1a5c8a; + color: #cce6ff; +} + +/* Prev / Next navigation arrows */ +.search-nav { + font: inherit; + font-size: 13px; + background: transparent; + border: 1px solid #2a2a2a; + border-radius: 3px; + color: #aaa; + padding: 1px 6px; + cursor: pointer; + line-height: 1; +} + +.search-nav:hover { + background: #2a2a2a; + color: #e6e6e6; +} + +/* Close button */ +.search-close { + background: transparent; + border: none; + color: #666; + font-size: 16px; + line-height: 1; + padding: 1px 5px; + cursor: pointer; + border-radius: 3px; +} + +.search-close:hover { + background: #2a2a2a; + color: #ddd; +} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..1d41a99 --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,177 @@ +import { useRef, useEffect, useState } from "react"; +import type { SearchAddon } from "@xterm/addon-search"; +import "./SearchBar.css"; + +// --------------------------------------------------------------------------- +// SearchBar — per-pane find-in-scrollback overlay. +// +// Rendered as an absolutely-positioned sibling of the xterm canvas inside +// XtermPane's container div (position: relative). The SearchAddon instance +// is owned by XtermPane and passed down as a prop; no IPC or Context needed. +// +// Toggle state (caseSensitive, regex) uses useState so aria-pressed reflects +// the live value on every render — refs alone don't trigger re-renders. +// --------------------------------------------------------------------------- + +interface SearchBarProps { + searchAddon: SearchAddon; + onClose: () => void; +} + +export default function SearchBar({ searchAddon, onClose }: SearchBarProps) { + const inputRef = useRef(null); + const queryRef = useRef(""); + const [caseSensitive, setCaseSensitive] = useState(false); + const [useRegex, setUseRegex] = useState(false); + + // Keep stable refs to toggle values so findNext/findPrev closures always + // see the current value without needing to be recreated on each state change. + const caseSensitiveRef = useRef(caseSensitive); + const useRegexRef = useRef(useRegex); + useEffect(() => { caseSensitiveRef.current = caseSensitive; }, [caseSensitive]); + useEffect(() => { useRegexRef.current = useRegex; }, [useRegex]); + + // Autofocus the input when the bar mounts. + useEffect(() => { + queueMicrotask(() => inputRef.current?.focus()); + }, []); + + function getOptions() { + return { + caseSensitive: caseSensitiveRef.current, + regex: useRegexRef.current, + // Highlight all matches and mark the active one distinctly. + decorations: { + matchBackground: "#3a3a00", + matchBorder: "#888800", + matchOverviewRuler: "#888800", + activeMatchBackground: "#b5890080", + activeMatchBorder: "#e6c000", + activeMatchColorOverviewRuler: "#e6c000", + }, + }; + } + + function findNext() { + if (!queryRef.current) return; + searchAddon.findNext(queryRef.current, getOptions()); + } + + function findPrev() { + if (!queryRef.current) return; + searchAddon.findPrevious(queryRef.current, getOptions()); + } + + function handleInput(e: React.ChangeEvent) { + queryRef.current = e.target.value; + // Live-search: jump to next match as you type. + if (queryRef.current) { + searchAddon.findNext(queryRef.current, getOptions()); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } else if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) { + findPrev(); + } else { + findNext(); + } + } + } + + function toggleCase() { + setCaseSensitive((v) => { + const next = !v; + caseSensitiveRef.current = next; + // Re-run with the new option so decorations update immediately. + if (queryRef.current) { + searchAddon.findNext(queryRef.current, { + ...getOptions(), + caseSensitive: next, + }); + } + return next; + }); + } + + function toggleRegex() { + setUseRegex((v) => { + const next = !v; + useRegexRef.current = next; + if (queryRef.current) { + searchAddon.findNext(queryRef.current, { + ...getOptions(), + regex: next, + }); + } + return next; + }); + } + + return ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index 1e76493..a993c11 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -1,8 +1,11 @@ -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { CanvasAddon } from "@xterm/addon-canvas"; +import { SearchAddon } from "@xterm/addon-search"; +import { Unicode11Addon } from "@xterm/addon-unicode11"; +import SearchBar from "./SearchBar"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { readText as clipboardReadText, @@ -21,6 +24,7 @@ import { type PaneId, type SpawnSpec, } from "../ipc"; +import type { NavigateIntent } from "../lib/layout/orchestration"; // --------------------------------------------------------------------------- // base64 helpers (private to this module) @@ -72,6 +76,12 @@ interface XtermPaneProps { focusTrigger?: number; /** Absolute font size in px. Changes are applied live (fit + PTY resize). */ fontSize?: number; + /** Called when the user presses a tiling-WM navigation chord inside the + * terminal. XtermPane only emits the intent; the parent (LeafPane/App) + * resolves the target leaf from the current layout and sets it active. + * Defined as an optional callback so single-pane windows don't require + * wiring it up. */ + onNavigate?: (intent: NavigateIntent) => void; } const DEFAULT_XTERM_FONT_SIZE = 13; @@ -90,11 +100,14 @@ export default function XtermPane({ onFocus, focusTrigger = 0, fontSize, + onNavigate, }: XtermPaneProps) { const containerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const paneIdRef = useRef(null); + const searchAddonRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); // Stash the most recent `fontSize` prop so the mount effect can pick // up the initial value without re-running when it changes (the secondary // effect below handles dynamic updates). @@ -107,12 +120,18 @@ export default function XtermPane({ const onInputRef = useRef(onInput); const onDataReceivedRef = useRef(onDataReceived); const onFocusRef = useRef(onFocus); + const onNavigateRef = useRef(onNavigate); + // Stable ref for setSearchOpen so it can be called from inside the + // attachCustomKeyEventHandler closure without the closure going stale. + const setSearchOpenRef = useRef<(v: boolean) => void>(setSearchOpen); useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]); useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]); useEffect(() => { onInputRef.current = onInput; }, [onInput]); useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]); useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]); + useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]); + useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]); // ------------------------------------------------------------------------- // Mount / unmount: create terminal, spawn PTY, wire listeners @@ -167,6 +186,24 @@ export default function XtermPane({ console.warn("CanvasAddon load failed; using DOM renderer:", e); } + // Load Unicode 11 addon for correct width handling of emoji, CJK, and + // box-drawing characters. This prevents cursor drift in TUIs that rely on + // Unicode 11 character widths. Loaded after CanvasAddon so the renderer + // surface is set before width calculations begin. + try { + term.loadAddon(new Unicode11Addon()); + term.unicode.activeVersion = "11"; + } catch (e) { + console.warn("Unicode11Addon load failed:", e); + } + + // Load the search addon so find-in-scrollback works. Must be loaded + // after open() so the terminal viewport exists for decoration rendering, + // and after CanvasAddon since it decorates the same canvas surface. + const search = new SearchAddon(); + searchAddonRef.current = search; + term.loadAddon(search); + // Initial size — fit before asking the PTY for its dimensions. fit.fit(); @@ -279,36 +316,100 @@ export default function XtermPane({ onInputRef.current?.(b64); }); - // Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste from clipboard. - // Runs before xterm consumes the key, so the textarea never sees a raw - // Ctrl+V (which would otherwise inject ^V into the PTY). term.paste() - // routes through onData → writeToPane, so broadcasting and bracketed - // paste both keep working for free. + // Intercept tiling-WM chords before the PTY sees them. All families + // share ONE attachCustomKeyEventHandler call — xterm.js replaces the + // previous handler on every call, so a second call anywhere would + // silently discard all earlier interceptions. // - // Uses tauri-plugin-clipboard-manager instead of navigator.clipboard so - // WebView2 doesn't surface its native "Allow clipboard access?" prompt. + // Family 1: Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste. + // Uses tauri-plugin-clipboard-manager so WebView2 never shows its + // native "Allow clipboard access?" prompt. term.paste() routes + // through onData → writeToPane so broadcasting + bracketed paste + // keep working for free. + // + // Family 2: Ctrl+Shift+F — open/focus the find-in-scrollback bar. + // Swallowed before xterm or the PTY sees the raw keypress. Uses the + // stable setSearchOpenRef so the closure never goes stale. + // + // Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L — spatial pane focus. + // XtermPane emits onNavigate({ kind: "direction", dir }) and returns + // false so the chord is swallowed before it reaches the PTY. The + // parent (LeafPane → App) resolves the neighbour and bumps + // focusTrigger on the new active pane. + // + // Family 4: Alt+1..9 — index-based pane focus. + // Emits onNavigate({ kind: "index", n }) and swallows. Note: bare + // Alt+digit is used by some shells (readline digit-argument, vim/nvim) + // — this interception is an accepted v1 trade-off (see shortcuts.ts). term?.attachCustomKeyEventHandler((e) => { if (e.type !== "keydown") return true; - if (!e.ctrlKey || !e.shiftKey || e.altKey) return true; - if (e.code === "KeyC") { - const sel = term?.getSelection(); - if (sel) { - void clipboardWriteText(sel).catch((err) => - console.warn("clipboard write failed:", err), - ); + + // --- Family 1 & 2: Ctrl+Shift+* (no Alt) --------------------------- + if (e.ctrlKey && e.shiftKey && !e.altKey) { + if (e.code === "KeyF") { + // Ctrl+Shift+F — open find-in-scrollback bar. + e.preventDefault(); + setSearchOpenRef.current(true); + return false; + } + if (e.code === "KeyC") { + // Ctrl+Shift+C — copy selection to clipboard. + const sel = term?.getSelection(); + if (sel) { + void clipboardWriteText(sel).catch((err) => + console.warn("clipboard write failed:", err), + ); + } + e.preventDefault(); + return false; + } + if (e.code === "KeyV") { + // Ctrl+Shift+V — paste from clipboard via term.paste() so + // broadcasting and bracketed paste work for free. + e.preventDefault(); + clipboardReadText() + .then((text) => { + if (text && term) term.paste(text); + }) + .catch((err) => console.warn("clipboard read failed:", err)); + return false; } - e.preventDefault(); - return false; } - if (e.code === "KeyV") { - e.preventDefault(); - clipboardReadText() - .then((text) => { - if (text && term) term.paste(text); - }) - .catch((err) => console.warn("clipboard read failed:", err)); - return false; + + // --- Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L (spatial nav) ----- + if (e.ctrlKey && e.altKey && !e.shiftKey && onNavigateRef.current) { + // Arrow keys + const ARROW_DIR: Record = { + ArrowLeft: "left", + ArrowRight: "right", + ArrowUp: "up", + ArrowDown: "down", + }; + // Vim-style HJKL + const VIM_DIR: Record = { + KeyH: "left", + KeyJ: "down", + KeyK: "up", + KeyL: "right", + }; + const dir = ARROW_DIR[e.code] ?? VIM_DIR[e.code]; + if (dir) { + e.preventDefault(); + onNavigateRef.current({ kind: "direction", dir }); + return false; + } } + + // --- Family 4: Alt+1..9 (index-based pane focus) ------------------- + if (e.altKey && !e.ctrlKey && !e.shiftKey && onNavigateRef.current) { + const digit = e.code.match(/^Digit([1-9])$/); + if (digit) { + e.preventDefault(); + onNavigateRef.current({ kind: "index", n: parseInt(digit[1], 10) }); + return false; + } + } + return true; }); @@ -389,6 +490,7 @@ export default function XtermPane({ term = null; termRef.current = null; fitRef.current = null; + searchAddonRef.current = null; paneIdRef.current = null; }; // spec is read once at mount; intentionally omitted from deps so we @@ -435,5 +537,30 @@ export default function XtermPane({ } }, [fontSize]); - return
; + // Close the search bar and return focus to the xterm textarea so the user + // can resume typing immediately. Queries the well-known xterm helper + // textarea selector — the same pattern used in the focusTrigger effect. + function closeSearch() { + setSearchOpen(false); + const ta = containerRef.current?.querySelector( + ".xterm-helper-textarea", + ); + ta?.focus(); + } + + // The outer wrapper is position:relative so the absolutely-positioned + // SearchBar anchors inside the pane without escaping to a positioned + // ancestor further up the tree. The FitAddon measures containerRef's div + // (the inner one), which still fills 100% of the wrapper — no sizing break. + return ( +
+
+ {searchOpen && searchAddonRef.current && ( + + )} +
+ ); } diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index 75ad84c..cbd53af 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -194,6 +194,14 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { [orch.setActive, leaf.id], ); + // Delegate keyboard navigation intents from XtermPane up to App via + // orch.navigateTo. XtermPane stays dumb (emits intent only); App resolves + // the target leaf from the current layout and bumps focusTrigger. + const onPaneNavigate = useCallback( + (intent: Parameters[0]) => orch.navigateTo(intent), + [orch.navigateTo], + ); + const onStatus = useCallback((msg: string, ok: boolean) => { setStatus(msg); setStatusOk(ok); @@ -575,6 +583,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { onInput={onTerminalInput} onDataReceived={onDataReceived} onFocus={onXtermFocus} + onNavigate={onPaneNavigate} focusTrigger={focusTrigger} fontSize={resolveFontSize(leaf.fontSizeOffset)} /> diff --git a/src/lib/layout/orchestration.tsx b/src/lib/layout/orchestration.tsx index cd381ff..46effad 100644 --- a/src/lib/layout/orchestration.tsx +++ b/src/lib/layout/orchestration.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, type ReactNode } from "react"; -import type { Orientation, NodeId, LeafShellSpec } from "./tree"; +import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree"; import type { PaneId, SshHost } from "../../ipc"; /** @@ -62,6 +62,16 @@ export interface Orchestration { * The PTY stays alive across the move (the new window's XtermPane * adopts the existing PaneId; scrollback ring is replayed). */ moveToNewWindow: (leafId: NodeId) => void; + /** + * Navigate focus from within a pane's key-handler. XtermPane emits the + * intent; LeafPane/App resolve the target leaf and set it active. + * + * `{ kind: "direction", dir }` — move to the spatial neighbour in that + * direction using the same flattenLayout geometry as Ctrl+Shift+Arrow. + * `{ kind: "index", n }` — focus the Nth leaf in DFS (walkLeaves) order, + * 1-indexed, clamped to the leaf count (so Alt+9 with 3 panes picks pane 3). + */ + navigateTo: (intent: NavigateIntent) => void; /** Returns a PaneId only for leaves that just arrived via a window * transfer (so LeafPane can pass `existingPaneId` to XtermPane to skip * the spawn). One-shot — App clears the entry once the pane has @@ -69,6 +79,13 @@ export interface Orchestration { getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined; } +/** Discriminated intent emitted by XtermPane's key handler. App resolves + * the actual target leaf from the current tree without XtermPane needing + * to know anything about layout geometry or leaf ordering. */ +export type NavigateIntent = + | { kind: "direction"; dir: Direction } + | { kind: "index"; n: number }; + const OrchestrationContext = createContext(null); export function OrchestrationProvider({ diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 5b1b789..a72cddb 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -66,7 +66,23 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ { keys: "Ctrl+K", description: "Open jump-to-pane palette" }, { keys: "Ctrl+Shift+← / → / ↑ / ↓", - description: "Focus neighbour pane in that direction", + description: + "Focus neighbour pane in that direction (window-level — works even when no terminal is focused)", + }, + { + keys: "Ctrl+Alt+← / → / ↑ / ↓", + description: + "Focus neighbour pane in that direction (from inside the terminal — intercepted before the PTY sees it)", + }, + { + keys: "Ctrl+Alt+H / J / K / L", + description: + "Same as Ctrl+Alt+Arrow but in Vim-style HJKL order (left / down / up / right)", + }, + { + keys: "Alt+1 … Alt+9", + description: + "Focus the Nth pane in layout order (DFS: left-to-right, top-to-bottom); clamped to pane count. Note: swallows bare Alt+digit — shells using readline digit-argument or vim buffer-jump may conflict.", }, ], }, @@ -100,6 +116,18 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ keys: "Ctrl+Shift+C / Ctrl+Shift+V", description: "Copy selection / paste in terminal", }, + { + keys: "Ctrl+Shift+F", + description: "Open find-in-scrollback bar for the focused pane", + }, + { + keys: "Enter / Shift+Enter", + description: "Next / previous match (while search bar is focused)", + }, + { + keys: "Escape", + description: "Close find bar and return focus to terminal", + }, ], }, {