Files
spaceshell/app/src/TerminalView.tsx
T
vasyansk 04ac7cdec2 fix(app): working scrollback search + stop prompt duplication on focus
Search fixes:
- TerminalView sets allowProposedApi (the search addon's match decorations
  use registerMarker/registerDecoration); without it findNext threw before
  firing results, so the counter was stuck at 0/0.
- The search bar now renders inside the panel it targets (in the header)
  instead of a global top-right overlay, so it's obvious which panel is
  searched.
- Search is anchored to the panel it was opened on (searchSurfaceId) and no
  longer follows focus — opening it in one panel and switching away no longer
  shows it open elsewhere.

Prompt duplication:
- The focus border was 1px when unfocused, 2px when focused; with border-box
  that resized the content on every focus switch, firing ResizeObserver -> fit
  -> PTY SIGWINCH and making zsh/powerlevel10k reprint its prompt. The border
  is now a constant 2px, color-only on focus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:34:43 +07:00

84 lines
2.9 KiB
TypeScript

import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { WebglAddon } from "@xterm/addon-webgl";
import { SearchAddon } from "@xterm/addon-search";
import { FitAddon } from "@xterm/addon-fit";
import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge";
import { registerSearch, unregisterSearch } from "./searchRegistry";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
export function TerminalView({ surfaceId }: { surfaceId: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
// allowProposedApi is required by the search addon: its match decorations
// call registerMarker/registerDecoration (proposed API). Without it findNext
// throws and the scrollback search counter never updates.
const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000, allowProposedApi: true });
try {
term.loadAddon(new WebglAddon());
} catch {
// webgl unavailable → fall back to canvas/dom renderer silently
}
term.open(ref.current);
const search = new SearchAddon();
term.loadAddon(search);
registerSearch(surfaceId, search);
const fit = new FitAddon();
term.loadAddon(fit);
// Fit the grid to the container and tell the daemon the new size. Coalesced
// through rAF so a burst of resize callbacks yields one resize per frame.
let rafId = 0;
let lastCols = 0, lastRows = 0;
const doFit = () => {
rafId = 0;
try { fit.fit(); } catch { return; }
if (term.cols !== lastCols || term.rows !== lastRows) {
lastCols = term.cols;
lastRows = term.rows;
void resizeSurface(surfaceId, term.cols, term.rows);
}
};
const scheduleFit = () => { if (!rafId) rafId = requestAnimationFrame(doFit); };
const ro = new ResizeObserver(scheduleFit);
ro.observe(ref.current);
// Input → daemon.
const inputDisposable = term.onData((data) => {
void sendInput(surfaceId, encoder.encode(data));
});
let disposed = false;
// Attach: fresh xterm instance, write snapshot, then stream live output.
void attachSurface(surfaceId, (bytes) => {
if (!disposed) term.write(decoder.decode(bytes));
}).then((res) => {
if (disposed) return;
if (res.snapshot) term.write(res.snapshot);
// Fit to the actual container rather than the daemon's stored geometry,
// then push the resulting size back so the PTY reflows to match.
scheduleFit();
});
return () => {
disposed = true;
if (rafId) cancelAnimationFrame(rafId);
ro.disconnect();
inputDisposable.dispose();
void detachSurface(surfaceId);
unregisterSearch(surfaceId);
term.dispose();
};
}, [surfaceId]);
return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
}