Files
spaceshell/app/src/TerminalView.tsx
T
vasyansk ee845e15b3 Add full disk access checks and settings
Add background themes and custom images

Add shell command logging toggle

Add UTF-8 locale guarantee for PTY

Add Claude hook settings injection

Add hotkey system for GUI

Add glass panel styling

Add search disabled state for agent panels

Add zoom toggle command

Add device report filtering

Add entitlements for notarization

Update version to 0.1.27
2026-06-15 22:26:06 +07:00

179 lines
7.8 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();
// xterm.js auto-answers device queries (Device Attributes, cursor/status reports,
// OSC color queries, DECRPM mode reports) by emitting the reply through onData.
// But the daemon's alacritty grid is the authoritative emulator and already
// answers these on the PTY (see spacesh-core grid.rs `take_replies` →
// `write_input`). Forwarding xterm's duplicate — which arrives a full IPC
// roundtrip late — lands in the shell's input buffer after it stopped reading
// the reply, so the shell echoes it as literal escape gibberish and the prompt
// shifts. Drop these standalone reports; never the user's keystrokes/paste/mouse
// (mouse reports end in M/m and are real user input the program asked for).
function isDeviceReport(data: string): boolean {
if (data.charCodeAt(0) !== 0x1b) return false;
return (
/^\x1b\[[?>=]?[0-9;]*[cntR]$/.test(data) || // DA1/DA2 (c), DSR status (n), text-area/cell-size (t), cursor position (R)
/^\x1b\[\?[0-9;]*u$/.test(data) || // kitty keyboard QUERY reply (\x1b[?flags u) — NOT key input \x1b[<code>u
/^\x1b\[\?[0-9;]*\$[py]$/.test(data) || // DECRPM mode report
/^\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\)$/.test(data) || // OSC color / query reply (BEL- or ST-terminated)
/^\x1bP[\s\S]*\x1b\\$/.test(data) || // any DCS report (XTVERSION / DECRQSS / status string)
/^\x1b_[\s\S]*\x1b\\$/.test(data) // any APC report (kitty graphics)
);
}
// Appended after the user font so Nerd Font icon glyphs (Private Use Area) render
// via fallback instead of blank boxes, without changing the base monospace font.
const NERD_FALLBACK = "'Symbols Nerd Font Mono'";
const fontStack = (family: string | null) =>
family ? `'${family}', ${NERD_FALLBACK}, monospace`
: `'JetBrains Mono Variable', 'JetBrains Mono', ${NERD_FALLBACK}, monospace`;
function xtermTheme(p: Record<string, string>) {
return {
background: p["term-bg"] ?? p["bg-panel"],
foreground: p["text-primary"],
cursor: p["text-primary"],
selectionBackground: p["search-match"],
};
}
export function TerminalView({ surfaceId, font, palette, focused }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null; focused?: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null);
// A background theme makes term-bg fully transparent so the panel's glass fill
// shows through. allowTransparency is construction-time only, so it's part of the
// effect key to force a remount when it flips. WebGL stays on in both modes — the
// glass/blur lives on a sibling layer (see LayoutEngine), not an ancestor, so the
// WebGL canvas composites its transparent background without the WKWebView
// clipping/smearing artifacts that backdrop-filter ancestors cause.
const transparent = palette?.["term-bg"] === "rgba(0,0,0,0)";
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: fontStack(font?.family ?? null),
fontSize: font?.size ?? 13,
convertEol: false,
scrollback: 10000,
allowProposedApi: true,
allowTransparency: transparent,
theme: palette ? xtermTheme(palette) : undefined,
});
termRef.current = term;
try {
const webgl = new WebglAddon();
term.loadAddon(webgl);
webglRef.current = webgl;
} 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);
fitRef.current = 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) => {
if (isDeviceReport(data)) return; // daemon answers the PTY authoritatively; xterm's dup arrives late and echoes
void sendInput(surfaceId, encoder.encode(data));
});
let disposed = false;
// The Nerd Font fallback may finish loading after the first paint; once it's
// ready, drop the WebGL glyph atlas so cached blank cells re-rasterize with icons.
void document.fonts.load("16px 'Symbols Nerd Font Mono'").then(() => {
if (!disposed) webglRef.current?.clearTextureAtlas();
}).catch(() => {});
// 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();
termRef.current = null;
fitRef.current = null;
webglRef.current = null;
};
}, [surfaceId, transparent]); // eslint-disable-line react-hooks/exhaustive-deps
// Keyboard focus cycling (cmd+]/[) only changes the focusedId state — it never
// touches the DOM, so the new panel's xterm textarea stays unfocused and keys
// keep flowing to the old terminal. Mouse clicks don't hit this because the
// click lands on the textarea directly. Drive xterm focus from the prop.
useEffect(() => {
if (focused) termRef.current?.focus();
}, [focused]);
// Live re-apply font and theme when config changes without remounting.
// font and palette are memoized in App.tsx so stable identity = no spurious re-applies.
useEffect(() => {
const t = termRef.current;
if (!t) return;
if (font) {
t.options.fontFamily = fontStack(font.family);
t.options.fontSize = font.size;
// The WebGL renderer caches rasterized glyphs in a texture atlas keyed by
// the old font/size; without clearing it the grid keeps rendering stale
// glyphs after a font change.
webglRef.current?.clearTextureAtlas();
}
if (palette) t.options.theme = xtermTheme(palette);
requestAnimationFrame(() => { try { fitRef.current?.fit(); } catch { /* ignore */ } });
}, [font, palette]); // eslint-disable-line react-hooks/exhaustive-deps
return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
}