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) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-28 21:51:29 +01:00
parent 8bb080345e
commit baa00dfc5c
8 changed files with 526 additions and 29 deletions

View file

@ -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",

View file

@ -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,

View file

@ -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;
}

View file

@ -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<HTMLInputElement>(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<HTMLInputElement>) {
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<HTMLInputElement>) {
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 (
<div className="search-bar" role="search" aria-label="Find in terminal">
<input
ref={inputRef}
type="text"
className="search-input"
placeholder="Find…"
onChange={handleInput}
onKeyDown={handleKeyDown}
aria-label="Search term"
spellCheck={false}
/>
<button
className="search-toggle"
title="Case-sensitive"
aria-label="Toggle case-sensitive"
aria-pressed={caseSensitive ? "true" : "false"}
onClick={toggleCase}
>
Aa
</button>
<button
className="search-toggle"
title="Regular expression"
aria-label="Toggle regular expression"
aria-pressed={useRegex ? "true" : "false"}
onClick={toggleRegex}
>
.*
</button>
<button
className="search-nav"
title="Previous match (Shift+Enter)"
aria-label="Previous match"
onClick={findPrev}
>
&#8593;
</button>
<button
className="search-nav"
title="Next match (Enter)"
aria-label="Next match"
onClick={findNext}
>
&#8595;
</button>
<button
className="search-close"
title="Close (Escape)"
aria-label="Close search"
onClick={onClose}
>
×
</button>
</div>
);
}

View file

@ -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<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const paneIdRef = useRef<PaneId | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(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<string, "left" | "right" | "up" | "down"> = {
ArrowLeft: "left",
ArrowRight: "right",
ArrowUp: "up",
ArrowDown: "down",
};
// Vim-style HJKL
const VIM_DIR: Record<string, "left" | "right" | "up" | "down"> = {
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 <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
// 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<HTMLTextAreaElement>(
".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 (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{searchOpen && searchAddonRef.current && (
<SearchBar
searchAddon={searchAddonRef.current}
onClose={closeSearch}
/>
)}
</div>
);
}

View file

@ -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<typeof orch.navigateTo>[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)}
/>

View file

@ -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<Orchestration | null>(null);
export function OrchestrationProvider({

View file

@ -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",
},
],
},
{