Compare commits
No commits in common. "1bbc6a578339b9212f2af76dfb7eae31d6538cdc" and "8bb080345e1e27e721af33237bbc3656ec917511" have entirely different histories.
1bbc6a5783
...
8bb080345e
9 changed files with 32 additions and 531 deletions
|
|
@ -57,12 +57,10 @@ Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecos
|
||||||
|
|
||||||
**→ Exploring first (user-selected 2026-05-28):**
|
**→ Exploring first (user-selected 2026-05-28):**
|
||||||
- [ ] **Per-session cost / token tracking.** Parse `~/.claude/projects/<project>/<session>.jsonl` (`message.usage`: input/output/cache_read/cache_write + model per assistant line) → tokens + estimated $ per pane and per workspace. Easy parsing; the fiddly bit is mapping a tiletopia pane → its session file (capture session id / cwd at spawn). Difficulty: easy–medium.
|
- [ ] **Per-session cost / token tracking.** Parse `~/.claude/projects/<project>/<session>.jsonl` (`message.usage`: input/output/cache_read/cache_write + model per assistant line) → tokens + estimated $ per pane and per workspace. Easy parsing; the fiddly bit is mapping a tiletopia pane → its session file (capture session id / cwd at spawn). Difficulty: easy–medium.
|
||||||
|
- [ ] **Find in scrollback.** `@xterm/addon-search` — per-pane search box, `findNext`/`findPrevious`, regex + case opts, `searchOptions.decorations` for match highlight. Difficulty: easy.
|
||||||
- [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable — more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium.
|
- [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable — more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium.
|
||||||
- [x] ~~**Find in scrollback.**~~ Done (code) 2026-05-28, commit on `main` — `@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight. **Pending: `pnpm install` on Windows host + runtime verify** (addon not in WSL node_modules; tsc clean otherwise).
|
- [ ] **Unicode 11 + grapheme width.** `@xterm/addon-unicode11` (+ `@xterm/addon-unicode-graphemes`), set `terminal.unicode.activeVersion = '11'`. Fixes emoji/CJK/box-drawing width misalignment + cursor drift in TUIs. Difficulty: easy.
|
||||||
- [x] ~~**Unicode 11 + grapheme width.**~~ Done (code) 2026-05-28 — `@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. Same pending-install caveat. (Skipped the separate `addon-unicode-graphemes` for now.)
|
- [ ] **Pane navigation key handler.** `attachCustomKeyEventHandler()` to intercept tiling-WM chords (Ctrl+hjkl move focus, Alt+number select pane) before the PTY sees them, so shortcuts don't leak into the shell. Difficulty: easy.
|
||||||
- [x] ~~**Pane navigation key handler.**~~ Done (code) 2026-05-28 — Ctrl+Alt+Arrow / Ctrl+Alt+HJKL (spatial via `findNeighborInDirection`) + Alt+1..9 (Nth `walkLeaves` leaf). New `NavigateIntent` union in orchestration.tsx; XtermPane emits intent via new `onNavigate` prop → LeafPane → App `navigateTo` sets active leaf (reuses isActive→focusTrigger refocus). All chords share the one `attachCustomKeyEventHandler`. **Caveats:** Alt+1..9 swallows bare Alt+digit (breaks readline digit-arg / vim buffer-jump); Ctrl+Alt+Arrow may collide with Windows virtual-desktop switching — both noted in shortcuts.ts, v2 mitigation = opt-out toggle or Ctrl+Alt+Shift+Arrow.
|
|
||||||
|
|
||||||
**Implementation note:** the three above were built in one fan-out workflow (parallel design on haiku/sonnet → single sonnet implementer applying to shared files), since all three touch `XtermPane`'s mount + its single `attachCustomKeyEventHandler` (xterm replaces the handler on each call, so they MUST coexist in one registration — don't add a second `attachCustomKeyEventHandler` anywhere).
|
|
||||||
|
|
||||||
**Parked — circle back (saved, not yet prioritized):**
|
**Parked — circle back (saved, not yet prioritized):**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@
|
||||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "^0.10.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/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
|
|
|
||||||
34
src/App.tsx
34
src/App.tsx
|
|
@ -98,7 +98,7 @@ import {
|
||||||
presetTwoRows,
|
presetTwoRows,
|
||||||
presetTwoByTwo,
|
presetTwoByTwo,
|
||||||
} from "./lib/layout/tree";
|
} from "./lib/layout/tree";
|
||||||
import { OrchestrationProvider, type Orchestration, type NavigateIntent } from "./lib/layout/orchestration";
|
import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration";
|
||||||
import LeafPane from "./lib/layout/LeafPane";
|
import LeafPane from "./lib/layout/LeafPane";
|
||||||
import Gutter from "./lib/layout/Gutter";
|
import Gutter from "./lib/layout/Gutter";
|
||||||
import Notifications, { type Toast } from "./components/Notifications";
|
import Notifications, { type Toast } from "./components/Notifications";
|
||||||
|
|
@ -717,36 +717,6 @@ export default function App() {
|
||||||
setActiveLeafId(leafId);
|
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 openHostManager = useCallback(() => setHostManagerOpen(true), []);
|
||||||
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
|
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
|
||||||
|
|
||||||
|
|
@ -1272,7 +1242,6 @@ export default function App() {
|
||||||
toggleMcpAllow,
|
toggleMcpAllow,
|
||||||
openHostManager,
|
openHostManager,
|
||||||
setActive,
|
setActive,
|
||||||
navigateTo,
|
|
||||||
registerPaneId,
|
registerPaneId,
|
||||||
broadcastFrom,
|
broadcastFrom,
|
||||||
notify,
|
notify,
|
||||||
|
|
@ -1297,7 +1266,6 @@ export default function App() {
|
||||||
toggleMcpAllow,
|
toggleMcpAllow,
|
||||||
openHostManager,
|
openHostManager,
|
||||||
setActive,
|
setActive,
|
||||||
navigateTo,
|
|
||||||
registerPaneId,
|
registerPaneId,
|
||||||
broadcastFrom,
|
broadcastFrom,
|
||||||
notify,
|
notify,
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
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}
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="search-nav"
|
|
||||||
title="Next match (Enter)"
|
|
||||||
aria-label="Next match"
|
|
||||||
onClick={findNext}
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="search-close"
|
|
||||||
title="Close (Escape)"
|
|
||||||
aria-label="Close search"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
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 type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import {
|
import {
|
||||||
readText as clipboardReadText,
|
readText as clipboardReadText,
|
||||||
|
|
@ -24,7 +21,6 @@ import {
|
||||||
type PaneId,
|
type PaneId,
|
||||||
type SpawnSpec,
|
type SpawnSpec,
|
||||||
} from "../ipc";
|
} from "../ipc";
|
||||||
import type { NavigateIntent } from "../lib/layout/orchestration";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// base64 helpers (private to this module)
|
// base64 helpers (private to this module)
|
||||||
|
|
@ -76,12 +72,6 @@ interface XtermPaneProps {
|
||||||
focusTrigger?: number;
|
focusTrigger?: number;
|
||||||
/** Absolute font size in px. Changes are applied live (fit + PTY resize). */
|
/** Absolute font size in px. Changes are applied live (fit + PTY resize). */
|
||||||
fontSize?: number;
|
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;
|
const DEFAULT_XTERM_FONT_SIZE = 13;
|
||||||
|
|
@ -100,14 +90,11 @@ export default function XtermPane({
|
||||||
onFocus,
|
onFocus,
|
||||||
focusTrigger = 0,
|
focusTrigger = 0,
|
||||||
fontSize,
|
fontSize,
|
||||||
onNavigate,
|
|
||||||
}: XtermPaneProps) {
|
}: XtermPaneProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
const paneIdRef = useRef<PaneId | 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
|
// Stash the most recent `fontSize` prop so the mount effect can pick
|
||||||
// up the initial value without re-running when it changes (the secondary
|
// up the initial value without re-running when it changes (the secondary
|
||||||
// effect below handles dynamic updates).
|
// effect below handles dynamic updates).
|
||||||
|
|
@ -120,18 +107,12 @@ export default function XtermPane({
|
||||||
const onInputRef = useRef(onInput);
|
const onInputRef = useRef(onInput);
|
||||||
const onDataReceivedRef = useRef(onDataReceived);
|
const onDataReceivedRef = useRef(onDataReceived);
|
||||||
const onFocusRef = useRef(onFocus);
|
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(() => { onStatusRef.current = onStatus; }, [onStatus]);
|
||||||
useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]);
|
useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]);
|
||||||
useEffect(() => { onInputRef.current = onInput; }, [onInput]);
|
useEffect(() => { onInputRef.current = onInput; }, [onInput]);
|
||||||
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
|
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
|
||||||
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
|
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
|
||||||
useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]);
|
|
||||||
useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]);
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Mount / unmount: create terminal, spawn PTY, wire listeners
|
// Mount / unmount: create terminal, spawn PTY, wire listeners
|
||||||
|
|
@ -186,24 +167,6 @@ export default function XtermPane({
|
||||||
console.warn("CanvasAddon load failed; using DOM renderer:", e);
|
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.
|
// Initial size — fit before asking the PTY for its dimensions.
|
||||||
fit.fit();
|
fit.fit();
|
||||||
|
|
||||||
|
|
@ -316,100 +279,36 @@ export default function XtermPane({
|
||||||
onInputRef.current?.(b64);
|
onInputRef.current?.(b64);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Intercept tiling-WM chords before the PTY sees them. All families
|
// Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste from clipboard.
|
||||||
// share ONE attachCustomKeyEventHandler call — xterm.js replaces the
|
// Runs before xterm consumes the key, so the textarea never sees a raw
|
||||||
// previous handler on every call, so a second call anywhere would
|
// Ctrl+V (which would otherwise inject ^V into the PTY). term.paste()
|
||||||
// silently discard all earlier interceptions.
|
// routes through onData → writeToPane, so broadcasting and bracketed
|
||||||
|
// paste both keep working for free.
|
||||||
//
|
//
|
||||||
// Family 1: Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste.
|
// Uses tauri-plugin-clipboard-manager instead of navigator.clipboard so
|
||||||
// Uses tauri-plugin-clipboard-manager so WebView2 never shows its
|
// WebView2 doesn't surface its native "Allow clipboard access?" prompt.
|
||||||
// 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) => {
|
term?.attachCustomKeyEventHandler((e) => {
|
||||||
if (e.type !== "keydown") return true;
|
if (e.type !== "keydown") return true;
|
||||||
|
if (!e.ctrlKey || !e.shiftKey || e.altKey) return true;
|
||||||
// --- Family 1 & 2: Ctrl+Shift+* (no Alt) ---------------------------
|
if (e.code === "KeyC") {
|
||||||
if (e.ctrlKey && e.shiftKey && !e.altKey) {
|
const sel = term?.getSelection();
|
||||||
if (e.code === "KeyF") {
|
if (sel) {
|
||||||
// Ctrl+Shift+F — open find-in-scrollback bar.
|
void clipboardWriteText(sel).catch((err) =>
|
||||||
e.preventDefault();
|
console.warn("clipboard write failed:", err),
|
||||||
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") {
|
||||||
// --- Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L (spatial nav) -----
|
e.preventDefault();
|
||||||
if (e.ctrlKey && e.altKey && !e.shiftKey && onNavigateRef.current) {
|
clipboardReadText()
|
||||||
// Arrow keys
|
.then((text) => {
|
||||||
const ARROW_DIR: Record<string, "left" | "right" | "up" | "down"> = {
|
if (text && term) term.paste(text);
|
||||||
ArrowLeft: "left",
|
})
|
||||||
ArrowRight: "right",
|
.catch((err) => console.warn("clipboard read failed:", err));
|
||||||
ArrowUp: "up",
|
return false;
|
||||||
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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -490,7 +389,6 @@ export default function XtermPane({
|
||||||
term = null;
|
term = null;
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
fitRef.current = null;
|
fitRef.current = null;
|
||||||
searchAddonRef.current = null;
|
|
||||||
paneIdRef.current = null;
|
paneIdRef.current = null;
|
||||||
};
|
};
|
||||||
// spec is read once at mount; intentionally omitted from deps so we
|
// spec is read once at mount; intentionally omitted from deps so we
|
||||||
|
|
@ -537,30 +435,5 @@ export default function XtermPane({
|
||||||
}
|
}
|
||||||
}, [fontSize]);
|
}, [fontSize]);
|
||||||
|
|
||||||
// Close the search bar and return focus to the xterm textarea so the user
|
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,14 +194,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
[orch.setActive, leaf.id],
|
[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) => {
|
const onStatus = useCallback((msg: string, ok: boolean) => {
|
||||||
setStatus(msg);
|
setStatus(msg);
|
||||||
setStatusOk(ok);
|
setStatusOk(ok);
|
||||||
|
|
@ -583,7 +575,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
onInput={onTerminalInput}
|
onInput={onTerminalInput}
|
||||||
onDataReceived={onDataReceived}
|
onDataReceived={onDataReceived}
|
||||||
onFocus={onXtermFocus}
|
onFocus={onXtermFocus}
|
||||||
onNavigate={onPaneNavigate}
|
|
||||||
focusTrigger={focusTrigger}
|
focusTrigger={focusTrigger}
|
||||||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createContext, useContext, type ReactNode } from "react";
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
|
import type { Orientation, NodeId, LeafShellSpec } from "./tree";
|
||||||
import type { PaneId, SshHost } from "../../ipc";
|
import type { PaneId, SshHost } from "../../ipc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -62,16 +62,6 @@ export interface Orchestration {
|
||||||
* The PTY stays alive across the move (the new window's XtermPane
|
* The PTY stays alive across the move (the new window's XtermPane
|
||||||
* adopts the existing PaneId; scrollback ring is replayed). */
|
* adopts the existing PaneId; scrollback ring is replayed). */
|
||||||
moveToNewWindow: (leafId: NodeId) => void;
|
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
|
/** Returns a PaneId only for leaves that just arrived via a window
|
||||||
* transfer (so LeafPane can pass `existingPaneId` to XtermPane to skip
|
* transfer (so LeafPane can pass `existingPaneId` to XtermPane to skip
|
||||||
* the spawn). One-shot — App clears the entry once the pane has
|
* the spawn). One-shot — App clears the entry once the pane has
|
||||||
|
|
@ -79,13 +69,6 @@ export interface Orchestration {
|
||||||
getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined;
|
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);
|
const OrchestrationContext = createContext<Orchestration | null>(null);
|
||||||
|
|
||||||
export function OrchestrationProvider({
|
export function OrchestrationProvider({
|
||||||
|
|
|
||||||
|
|
@ -66,23 +66,7 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
||||||
{ keys: "Ctrl+K", description: "Open jump-to-pane palette" },
|
{ keys: "Ctrl+K", description: "Open jump-to-pane palette" },
|
||||||
{
|
{
|
||||||
keys: "Ctrl+Shift+← / → / ↑ / ↓",
|
keys: "Ctrl+Shift+← / → / ↑ / ↓",
|
||||||
description:
|
description: "Focus neighbour pane in that direction",
|
||||||
"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.",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -116,18 +100,6 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
||||||
keys: "Ctrl+Shift+C / Ctrl+Shift+V",
|
keys: "Ctrl+Shift+C / Ctrl+Shift+V",
|
||||||
description: "Copy selection / paste in terminal",
|
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",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue