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

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