2026-03-01 08:29:43 -08:00
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
2026-02-27 04:29:51 +00:00
|
|
|
|
import { Terminal } from "@xterm/xterm";
|
|
|
|
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
|
|
|
|
import { WebglAddon } from "@xterm/addon-webgl";
|
|
|
|
|
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
|
|
|
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
|
|
|
|
import "@xterm/xterm/css/xterm.css";
|
|
|
|
|
|
import { useTerminal } from "../../hooks/useTerminal";
|
2026-03-11 12:24:16 -07:00
|
|
|
|
import { useAppState } from "../../store/appState";
|
2026-09-01 12:57:29 -07:00
|
|
|
|
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
|
2026-08-11 09:15:12 -07:00
|
|
|
|
import {
|
|
|
|
|
|
awsSsoRefresh,
|
|
|
|
|
|
openPageInContainerBrowser,
|
|
|
|
|
|
uploadHostFileToTerminal,
|
|
|
|
|
|
} from "../../lib/tauri-commands";
|
2026-06-30 14:11:09 -07:00
|
|
|
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
2026-08-23 08:31:39 -07:00
|
|
|
|
import { UrlDetector, type UrlSource } from "../../lib/urlDetector";
|
2026-08-09 16:55:28 -07:00
|
|
|
|
import {
|
|
|
|
|
|
RelayRateLimiter,
|
|
|
|
|
|
URL_RELAY_OSC,
|
2026-08-23 08:31:39 -07:00
|
|
|
|
extendsUrl,
|
2026-08-09 16:55:28 -07:00
|
|
|
|
parseUrlRelayOsc,
|
2026-08-09 19:35:39 -07:00
|
|
|
|
sanitizeRelayUrl,
|
2026-08-09 16:55:28 -07:00
|
|
|
|
} from "../../lib/urlRelay";
|
2026-08-23 15:31:13 -07:00
|
|
|
|
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
|
2026-08-23 11:11:43 -07:00
|
|
|
|
import UrlToast, {
|
|
|
|
|
|
URL_TOAST_PRIMARY_SELECTOR,
|
|
|
|
|
|
URL_TOAST_SELECTOR,
|
|
|
|
|
|
URL_TOAST_SHORTCUT,
|
|
|
|
|
|
} from "./UrlToast";
|
2026-04-17 08:58:56 -07:00
|
|
|
|
import { trimSelection } from "./trimSelection";
|
2026-08-28 12:51:18 -07:00
|
|
|
|
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
2026-04-17 08:58:56 -07:00
|
|
|
|
import TerminalContextMenu from "./TerminalContextMenu";
|
2026-02-27 04:29:51 +00:00
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
sessionId: string;
|
|
|
|
|
|
active: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 08:31:39 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Where a prompted URL came from.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `relay` is the container asking explicitly, over OSC 7777, with the URL
|
|
|
|
|
|
* base64-encoded — exact by construction. `osc8` is lifted verbatim out of a
|
|
|
|
|
|
* hyperlink parameter — also exact, but nobody asked for it. `heuristic` was
|
|
|
|
|
|
* reassembled from painted text and is the only one that can be a *truncated
|
|
|
|
|
|
* guess* at the link it is showing.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export type PromptSource = "relay" | UrlSource;
|
|
|
|
|
|
|
|
|
|
|
|
/** Higher wins. Provenance, not recency. */
|
|
|
|
|
|
const SOURCE_RANK: Record<PromptSource, number> = {
|
|
|
|
|
|
heuristic: 0,
|
|
|
|
|
|
osc8: 1,
|
|
|
|
|
|
relay: 2,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether `next` may take over the prompt slot from `current`.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The bug this exists for: `claude login` relays its OAuth URL over OSC 7777,
|
|
|
|
|
|
* base64-encoded and therefore complete; the screen-scraper's 300 ms debounce
|
|
|
|
|
|
* then fires, finds the same link cut into terminal-width pieces, and — under
|
|
|
|
|
|
* the old last-writer-wins slot — replaced the good URL with a truncated one
|
|
|
|
|
|
* that still parses, still points at the right host, and cannot authorise
|
|
|
|
|
|
* anything. The user is the one who has to notice.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Two rules, in order:
|
|
|
|
|
|
*
|
|
|
|
|
|
* - Better provenance always wins, worse provenance never does. A scraped
|
|
|
|
|
|
* guess cannot displace an exact copy.
|
|
|
|
|
|
* - Between equals, only an *extension* of what is showing may replace it.
|
|
|
|
|
|
* That is {@link extendsUrl}, the same rule and the same reasoning as
|
|
|
|
|
|
* `pickSignInUrl` in `hooks/useClaudeAuth.ts`: a repaint can land a
|
|
|
|
|
|
* truncated copy before the complete one, and a longer string sharing a
|
|
|
|
|
|
* prefix cannot move the origin. The relay is exempt because each OSC 7777
|
|
|
|
|
|
* is a fresh deliberate request rather than another view of the last one —
|
|
|
|
|
|
* a second `gh auth login` must be able to replace the first.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function supersedes(
|
|
|
|
|
|
next: { url: string; source: PromptSource },
|
|
|
|
|
|
current: { url: string; source: PromptSource } | null,
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
if (!current) return true;
|
|
|
|
|
|
if (SOURCE_RANK[next.source] !== SOURCE_RANK[current.source]) {
|
|
|
|
|
|
return SOURCE_RANK[next.source] > SOURCE_RANK[current.source];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (next.source === "relay") return true;
|
|
|
|
|
|
return extendsUrl(next.url, current.url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
|
export default function TerminalView({ sessionId, active }: Props) {
|
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
2026-03-01 08:29:43 -08:00
|
|
|
|
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
2026-02-27 04:29:51 +00:00
|
|
|
|
const termRef = useRef<Terminal | null>(null);
|
|
|
|
|
|
const fitRef = useRef<FitAddon | null>(null);
|
2026-02-28 21:22:54 +00:00
|
|
|
|
const webglRef = useRef<WebglAddon | null>(null);
|
2026-03-01 08:29:43 -08:00
|
|
|
|
const detectorRef = useRef<UrlDetector | null>(null);
|
2026-03-01 10:52:08 -08:00
|
|
|
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
2026-08-28 12:51:18 -07:00
|
|
|
|
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
2026-03-12 13:14:08 -07:00
|
|
|
|
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
2026-09-08 11:02:33 -07:00
|
|
|
|
const setTerminalMouseCaptured = useAppState(s => s.setTerminalMouseCaptured);
|
|
|
|
|
|
const setReleaseActiveMouse = useAppState(s => s.setReleaseActiveMouse);
|
2026-03-05 06:11:33 -08:00
|
|
|
|
|
2026-03-11 12:24:16 -07:00
|
|
|
|
const ssoBufferRef = useRef("");
|
|
|
|
|
|
const ssoTriggeredRef = useRef(false);
|
|
|
|
|
|
const projectId = useAppState(
|
|
|
|
|
|
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-23 08:31:39 -07:00
|
|
|
|
// Which program is on the other end of the PTY. Read through a ref because
|
|
|
|
|
|
// the key handler is registered once, in the mount effect keyed on
|
|
|
|
|
|
// `sessionId`, and a value captured there would go stale if the session
|
|
|
|
|
|
// record arrived after the first render.
|
|
|
|
|
|
const sessionType = useAppState(
|
|
|
|
|
|
(s) => s.sessions.find((sess) => sess.id === sessionId)?.sessionType
|
|
|
|
|
|
);
|
|
|
|
|
|
const sessionTypeRef = useRef(sessionType);
|
|
|
|
|
|
sessionTypeRef.current = sessionType;
|
|
|
|
|
|
|
|
|
|
|
|
// One toast slot, three producers: the container's explicit "open this in the
|
|
|
|
|
|
// host browser" relay (OSC 7777), OSC 8 hyperlink targets, and the heuristic
|
|
|
|
|
|
// long-URL detector. Sharing the slot keeps them from stacking on top of each
|
|
|
|
|
|
// other.
|
2026-08-09 19:35:39 -07:00
|
|
|
|
//
|
2026-08-23 08:31:39 -07:00
|
|
|
|
// All three read the container's PTY output, so all three are untrusted, and
|
|
|
|
|
|
// all three must go through `sanitizeRelayUrl` before anything is stored here
|
|
|
|
|
|
// — see `promptUrl` below, which is the only writer.
|
2026-08-09 19:35:39 -07:00
|
|
|
|
//
|
|
|
|
|
|
// `seq` exists because the slot is shared and long-lived: a second prompt
|
|
|
|
|
|
// replacing a first would otherwise mutate the toast in place, swapping the
|
|
|
|
|
|
// text under a user who is mid-read and mid-click. Keying the toast on it
|
|
|
|
|
|
// remounts the component, so a new URL is unmistakably a new prompt.
|
|
|
|
|
|
const [urlPrompt, setUrlPrompt] = useState<{
|
|
|
|
|
|
url: string;
|
|
|
|
|
|
label: string;
|
2026-08-23 08:31:39 -07:00
|
|
|
|
source: PromptSource;
|
2026-08-09 19:35:39 -07:00
|
|
|
|
seq: number;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
const promptSeqRef = useRef(0);
|
2026-08-09 16:55:28 -07:00
|
|
|
|
const relayLimiterRef = useRef(new RelayRateLimiter());
|
2026-08-23 11:11:43 -07:00
|
|
|
|
// Read by the long-lived keyboard listener below, which is registered once
|
|
|
|
|
|
// and would otherwise close over the prompt as it was at mount.
|
|
|
|
|
|
const urlPromptRef = useRef<{ url: string } | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Empty the prompt slot, and put focus somewhere real if it was inside the
|
|
|
|
|
|
* toast.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The toast never *takes* focus — see the note in `UrlToast` — but a keyboard
|
|
|
|
|
|
* user who jumped into it with {@link URL_TOAST_SHORTCUT} is standing on a
|
|
|
|
|
|
* node that is about to unmount, and React does not rehome focus: it lands on
|
|
|
|
|
|
* `document.body`, where the terminal receives nothing and the next keystroke
|
|
|
|
|
|
* goes nowhere. Every route out of the toast goes through here for that
|
|
|
|
|
|
* reason — Open, In container, ✕, Escape and the auto-dismiss alike.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const dismissUrlPrompt = useCallback(() => {
|
|
|
|
|
|
const wasInside = !!document.activeElement?.closest(URL_TOAST_SELECTOR);
|
|
|
|
|
|
setUrlPrompt(null);
|
|
|
|
|
|
if (wasInside) termRef.current?.focus();
|
|
|
|
|
|
}, []);
|
2026-08-09 19:35:39 -07:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The only writer of the prompt slot. Re-validates whatever the caller
|
|
|
|
|
|
* found: the OSC relay branch has already been through `parseUrlRelayOsc`,
|
|
|
|
|
|
* but the heuristic detector branch has been through nothing at all, and a
|
|
|
|
|
|
* raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse.
|
2026-08-23 08:31:39 -07:00
|
|
|
|
*
|
|
|
|
|
|
* Last-writer-wins is what this used to be, and it lost the OAuth URL every
|
|
|
|
|
|
* time: the relay delivers the link base64-encoded and therefore exact, and
|
|
|
|
|
|
* ~300 ms later the screen-scraper's debounce fired and overwrote it with a
|
|
|
|
|
|
* truncated guess at the same link. `supersedes` is the fix — see there.
|
2026-08-09 19:35:39 -07:00
|
|
|
|
*/
|
2026-08-23 08:31:39 -07:00
|
|
|
|
const promptUrl = useCallback(
|
|
|
|
|
|
(raw: string, label: string, source: PromptSource) => {
|
|
|
|
|
|
const url = sanitizeRelayUrl(raw);
|
|
|
|
|
|
if (!url) {
|
|
|
|
|
|
console.warn("Refusing to prompt for a URL that failed validation");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setUrlPrompt((current) => {
|
|
|
|
|
|
if (!supersedes({ url, source }, current)) return current;
|
|
|
|
|
|
promptSeqRef.current += 1;
|
|
|
|
|
|
return { url, label, source, seq: promptSeqRef.current };
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
2026-08-23 11:11:43 -07:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
urlPromptRef.current = urlPrompt;
|
|
|
|
|
|
}, [urlPrompt]);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The keyboard route into the toast.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Registered on `document` in the capture phase for the same reason
|
|
|
|
|
|
* `useKeyboardShortcuts` does it there: xterm would otherwise forward the
|
|
|
|
|
|
* chord to the shell. It is *not* added to that hook because the target is
|
|
|
|
|
|
* this pane's own toast — the hook has no way to name it, and only one pane
|
|
|
|
|
|
* is on screen at a time, which is what `activeRef` checks.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Nothing is swallowed unless there is a prompt to jump to, so Ctrl+Shift+O
|
|
|
|
|
|
* reaches the terminal untouched the rest of the time.
|
|
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
|
|
|
|
|
|
if (e.key !== "o" && e.key !== "O") return;
|
|
|
|
|
|
if (!activeRef.current || !urlPromptRef.current) return;
|
|
|
|
|
|
const primary = terminalContainerRef.current?.querySelector<HTMLElement>(
|
|
|
|
|
|
`${URL_TOAST_SELECTOR} ${URL_TOAST_PRIMARY_SELECTOR}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!primary) return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
primary.focus();
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener("keydown", onKeyDown, true);
|
|
|
|
|
|
return () => document.removeEventListener("keydown", onKeyDown, true);
|
|
|
|
|
|
}, []);
|
2026-03-01 10:52:08 -08:00
|
|
|
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
2026-04-17 08:58:56 -07:00
|
|
|
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
2026-09-08 11:02:33 -07:00
|
|
|
|
// True while the program in the container holds mouse reporting open (any of
|
|
|
|
|
|
// the DECSET ?1000/?1002/?1003 tracking modes). See `syncMouseCapture`.
|
|
|
|
|
|
const [mouseCaptured, setMouseCaptured] = useState(false);
|
|
|
|
|
|
const mouseCapturedRef = useRef(false);
|
2026-03-01 08:29:43 -08:00
|
|
|
|
|
2026-06-30 14:11:09 -07:00
|
|
|
|
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
|
|
|
|
|
// and the unmount-cleanup effect further down).
|
|
|
|
|
|
const activeRef = useRef(active);
|
|
|
|
|
|
activeRef.current = active;
|
|
|
|
|
|
|
|
|
|
|
|
// File drag-and-drop: dropped files are copied into the container and their
|
|
|
|
|
|
// in-container paths typed into the prompt so Claude Code can read them.
|
|
|
|
|
|
// Tauri intercepts OS file drops at the webview level, so we use
|
|
|
|
|
|
// onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
|
2026-08-23 11:11:43 -07:00
|
|
|
|
//
|
|
|
|
|
|
// The listener is window-wide, so every pane decides for itself whether a
|
2026-08-23 15:31:13 -07:00
|
|
|
|
// drop was meant for it. `classifyDrop` is that decision, shared with the
|
|
|
|
|
|
// Files pane, and it asks two things in order: is the payload position
|
|
|
|
|
|
// inside this pane's rect (a hidden pane is `display:none`, so its zero-size
|
|
|
|
|
|
// rect is what stops two panes both claiming the drop), and — document-wide,
|
|
|
|
|
|
// with no geometry — is a modal or blocking overlay on screen at all? An
|
|
|
|
|
|
// open `Modal` is a `fixed inset-0` portal painted *over* the window and the
|
|
|
|
|
|
// pane underneath still has its rect, so a rect alone uploaded files into
|
|
|
|
|
|
// the directory a dialog was covering. See `lib/dropTarget.ts` for why the
|
|
|
|
|
|
// blocking half is deliberately not a per-point z-order test.
|
2026-08-23 13:03:57 -07:00
|
|
|
|
//
|
|
|
|
|
|
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
|
|
|
|
|
|
// the pane is what the user sees as "the terminal", gutter included, and the
|
2026-09-08 11:02:33 -07:00
|
|
|
|
// chrome painted over it (the mouse-release badge, the URL toast) is a
|
|
|
|
|
|
// sibling of the host rather than a child. Nothing painted over the pane
|
|
|
|
|
|
// refuses a drop on its own account — asking "is this element mine?" once
|
|
|
|
|
|
// turned every pixel under that chrome into a permanent dead zone.
|
2026-06-30 14:11:09 -07:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let unlisten: (() => void) | undefined;
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
2026-06-30 14:28:00 -07:00
|
|
|
|
// Always single-quote: a dropped filename can contain shell metacharacters
|
|
|
|
|
|
// ($(), &&, ', spaces) even with no whitespace, and this path is typed into
|
|
|
|
|
|
// a live shell. Single-quoting with '\'' escaping neutralizes all of them.
|
|
|
|
|
|
const quote = (p: string) => `'${p.replace(/'/g, "'\\''")}'`;
|
2026-06-30 14:11:09 -07:00
|
|
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
|
|
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
|
|
|
|
|
if (event.payload.type !== "drop") return;
|
2026-08-23 13:03:57 -07:00
|
|
|
|
const verdict = classifyDrop(
|
|
|
|
|
|
terminalContainerRef.current,
|
|
|
|
|
|
event.payload.position,
|
|
|
|
|
|
);
|
|
|
|
|
|
// A refused drop is invisible — the file simply does not arrive — so
|
|
|
|
|
|
// the one case where the user aimed at us and we said no gets both a
|
|
|
|
|
|
// log line and something on screen. The toast, not `imagePasteMsg`:
|
|
|
|
|
|
// whatever refused this is painted over the terminal, and `ToastHost`
|
|
|
|
|
|
// sits above it.
|
|
|
|
|
|
if (verdict === "blocked") {
|
|
|
|
|
|
console.warn(
|
2026-08-23 15:31:13 -07:00
|
|
|
|
"[drop] refused: a dialog or overlay is open",
|
2026-08-23 13:03:57 -07:00
|
|
|
|
event.payload.position,
|
|
|
|
|
|
);
|
2026-08-23 15:31:13 -07:00
|
|
|
|
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
|
2026-08-23 13:03:57 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (verdict !== "accept") return;
|
2026-06-30 14:11:09 -07:00
|
|
|
|
|
|
|
|
|
|
const paths = event.payload.paths ?? [];
|
|
|
|
|
|
if (paths.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
setImagePasteMsg(`Adding ${paths.length} file${paths.length > 1 ? "s" : ""}…`);
|
|
|
|
|
|
const containerPaths: string[] = [];
|
|
|
|
|
|
for (const p of paths) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
containerPaths.push(await uploadHostFileToTerminal(sessionId, p));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("File drop upload failed for", p, err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (containerPaths.length === 0) {
|
|
|
|
|
|
setImagePasteMsg("File drop failed");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
sendInput(sessionId, containerPaths.map(quote).join(" ") + " ");
|
|
|
|
|
|
setImagePasteMsg(`Added ${containerPaths.length} file path${containerPaths.length > 1 ? "s" : ""}`);
|
|
|
|
|
|
});
|
|
|
|
|
|
if (cancelled) un();
|
|
|
|
|
|
else unlisten = un;
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
unlisten?.();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [sessionId, sendInput]);
|
|
|
|
|
|
|
2026-09-08 11:02:33 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Reconcile the badge with xterm's live mouse-tracking mode.
|
|
|
|
|
|
*
|
|
|
|
|
|
* There is no event for this, but there does not need to be a poll either:
|
|
|
|
|
|
* the mode only ever changes because the container printed a DECSET/DECRST
|
|
|
|
|
|
* sequence, so checking once per write covers every transition, exactly when
|
|
|
|
|
|
* it happens. The ref gate keeps the common case (mode unchanged, thousands
|
|
|
|
|
|
* of writes a second) down to one string comparison and no re-render.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const syncMouseCapture = useCallback(() => {
|
|
|
|
|
|
const term = termRef.current;
|
|
|
|
|
|
if (!term) return;
|
|
|
|
|
|
const captured = term.modes.mouseTrackingMode !== "none";
|
|
|
|
|
|
if (captured === mouseCapturedRef.current) return;
|
|
|
|
|
|
mouseCapturedRef.current = captured;
|
|
|
|
|
|
setMouseCaptured(captured);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Take the mouse back from a program that grabbed it and never let go.
|
|
|
|
|
|
*
|
|
|
|
|
|
* A TUI that dies mid-menu (or is killed, or detaches) leaves its mouse
|
|
|
|
|
|
* tracking modes set. xterm goes on routing clicks, drags and — under
|
|
|
|
|
|
* `?1003` — every pointer *move* to the PTY, which kills text selection and
|
|
|
|
|
|
* floods the prompt with escape bytes. The result reads as a frozen
|
|
|
|
|
|
* terminal, and until now the only exit was closing the tab.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The reset is `term.write`, deliberately, not `sendInput`: it goes into
|
|
|
|
|
|
* xterm's own parser and never onto the wire. The program that asked for
|
|
|
|
|
|
* tracking is usually already gone; if it is not, telling it the user pulled
|
|
|
|
|
|
* the mouse back would only invite it to grab again on its next repaint.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const releaseMouse = useCallback(() => {
|
|
|
|
|
|
const term = termRef.current;
|
|
|
|
|
|
if (!term) return;
|
|
|
|
|
|
// The three tracking modes, then the two encodings they report in. All
|
|
|
|
|
|
// five, because a program is free to have set any combination and a
|
|
|
|
|
|
// leftover encoding mode outlives the tracking mode that motivated it.
|
|
|
|
|
|
term.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l", syncMouseCapture);
|
|
|
|
|
|
}, [syncMouseCapture]);
|
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!containerRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
const term = new Terminal({
|
|
|
|
|
|
cursorBlink: true,
|
|
|
|
|
|
fontSize: 14,
|
2026-09-08 11:02:33 -07:00
|
|
|
|
// Let the user select text even while a program holds the mouse.
|
|
|
|
|
|
// xterm's force-selection modifier is Shift everywhere *except* macOS,
|
|
|
|
|
|
// where it is Option and is gated behind this option, which defaults to
|
|
|
|
|
|
// false — so without this line Mac users have no force-select at all and
|
|
|
|
|
|
// the only way to copy from a mouse-driven TUI is to take the mouse back
|
|
|
|
|
|
// first. `SelectionService.shouldForceSelection`.
|
|
|
|
|
|
macOptionClickForcesSelection: true,
|
2026-02-27 04:29:51 +00:00
|
|
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
|
|
|
|
|
theme: {
|
|
|
|
|
|
background: "#0d1117",
|
|
|
|
|
|
foreground: "#e6edf3",
|
|
|
|
|
|
cursor: "#58a6ff",
|
|
|
|
|
|
selectionBackground: "#264f78",
|
|
|
|
|
|
black: "#484f58",
|
|
|
|
|
|
red: "#ff7b72",
|
|
|
|
|
|
green: "#3fb950",
|
|
|
|
|
|
yellow: "#d29922",
|
|
|
|
|
|
blue: "#58a6ff",
|
|
|
|
|
|
magenta: "#bc8cff",
|
|
|
|
|
|
cyan: "#39d353",
|
|
|
|
|
|
white: "#b1bac4",
|
|
|
|
|
|
brightBlack: "#6e7681",
|
|
|
|
|
|
brightRed: "#ffa198",
|
|
|
|
|
|
brightGreen: "#56d364",
|
|
|
|
|
|
brightYellow: "#e3b341",
|
|
|
|
|
|
brightBlue: "#79c0ff",
|
|
|
|
|
|
brightMagenta: "#d2a8ff",
|
|
|
|
|
|
brightCyan: "#56d364",
|
|
|
|
|
|
brightWhite: "#f0f6fc",
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const fitAddon = new FitAddon();
|
|
|
|
|
|
term.loadAddon(fitAddon);
|
|
|
|
|
|
|
2026-02-27 14:29:40 +00:00
|
|
|
|
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
|
|
|
|
|
|
// that matches URLs even if they lack trailing path segments (the default regex
|
|
|
|
|
|
// misses OAuth URLs that end mid-line).
|
2026-08-09 19:35:39 -07:00
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
|
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
2026-08-09 19:35:39 -07:00
|
|
|
|
// Same sink, same rule: what xterm matched came off the container's
|
|
|
|
|
|
// output, so it is validated before it reaches the OS opener. A click
|
|
|
|
|
|
// here is a deliberate act on visible text, but "visible" is exactly
|
|
|
|
|
|
// what a userinfo-spoofed URL subverts.
|
|
|
|
|
|
const safe = sanitizeRelayUrl(uri);
|
|
|
|
|
|
if (!safe) {
|
|
|
|
|
|
console.warn("Refusing to open a link that failed validation");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
2026-02-27 14:29:40 +00:00
|
|
|
|
}, { urlRegex });
|
2026-02-27 04:29:51 +00:00
|
|
|
|
term.loadAddon(webLinksAddon);
|
|
|
|
|
|
|
|
|
|
|
|
term.open(containerRef.current);
|
|
|
|
|
|
|
2026-04-17 08:58:56 -07:00
|
|
|
|
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
|
|
|
|
|
|
// stripped, internal indentation preserved). Ctrl+Shift+Alt+C copies raw.
|
|
|
|
|
|
// Both prevent the keystroke from reaching the container (where Ctrl+C
|
|
|
|
|
|
// would send SIGINT and cancel running work).
|
2026-03-12 13:05:10 -07:00
|
|
|
|
term.attachCustomKeyEventHandler((event) => {
|
|
|
|
|
|
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
|
|
|
|
|
|
const sel = term.getSelection();
|
|
|
|
|
|
if (sel) {
|
2026-04-17 08:58:56 -07:00
|
|
|
|
const out = event.altKey ? sel : trimSelection(sel);
|
|
|
|
|
|
navigator.clipboard.writeText(out).catch((e) =>
|
2026-03-12 13:05:10 -07:00
|
|
|
|
console.error("Ctrl+Shift+C clipboard write failed:", e),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return false; // prevent xterm from processing this key
|
|
|
|
|
|
}
|
2026-06-28 19:26:05 -07:00
|
|
|
|
// Ctrl+Shift+M toggles speech-to-text recording (mic lives in the status
|
|
|
|
|
|
// bar, bound to the active session; trigger it via the store).
|
2026-04-13 05:55:52 -07:00
|
|
|
|
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
|
2026-06-28 19:26:05 -07:00
|
|
|
|
useAppState.getState().sttToggle();
|
2026-04-13 05:55:52 -07:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-09-08 11:02:33 -07:00
|
|
|
|
// Ctrl+Shift+X hands the mouse back. Same action as the badge, bound to
|
|
|
|
|
|
// a key because the failure this recovers from is *the pointer not
|
|
|
|
|
|
// working* — a control you have to click can be unreachable in exactly
|
|
|
|
|
|
// the situation that calls for it.
|
|
|
|
|
|
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "X") {
|
|
|
|
|
|
releaseMouse();
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-08-23 08:31:39 -07:00
|
|
|
|
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
|
|
|
|
|
// submitting it. xterm.js does not consult `shiftKey` for Enter
|
|
|
|
|
|
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
|
|
|
|
|
|
// byte-identical to Enter and submits.
|
|
|
|
|
|
//
|
|
|
|
|
|
// `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with
|
|
|
|
|
|
// meta, and it is exactly what its own `/terminal-setup` writes into the
|
|
|
|
|
|
// VS Code, Cursor, Alacritty and Zed keymaps. These are the in-band
|
|
|
|
|
|
// bytes, not a guess, which is why this must NOT be "simplified" to
|
|
|
|
|
|
// `\n`: Claude Code accepts `\n` too, but a shell would *run* the line,
|
|
|
|
|
|
// so the two session types would quietly diverge.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Scoped to Claude sessions for the same reason. A bash tab runs
|
|
|
|
|
|
// `bash -l`, where readline has no binding for `\e\r` and answers with a
|
|
|
|
|
|
// bell — harmless, but there is nothing to gain from sending it.
|
|
|
|
|
|
if (
|
|
|
|
|
|
event.type === "keydown" &&
|
|
|
|
|
|
event.key === "Enter" &&
|
|
|
|
|
|
event.shiftKey &&
|
|
|
|
|
|
!event.ctrlKey &&
|
|
|
|
|
|
!event.altKey &&
|
|
|
|
|
|
!event.metaKey &&
|
|
|
|
|
|
!event.isComposing &&
|
|
|
|
|
|
sessionTypeRef.current === "claude"
|
|
|
|
|
|
) {
|
2026-09-01 12:57:29 -07:00
|
|
|
|
sendInput(sessionId, CLAUDE_SOFT_NEWLINE);
|
2026-08-23 20:47:01 -07:00
|
|
|
|
// **`preventDefault()` is what stops the submit, not the `return false`.**
|
|
|
|
|
|
//
|
|
|
|
|
|
// xterm's `_keyDown` returns the instant a custom handler says `false`
|
|
|
|
|
|
// — *before* it sets `_keyDownHandled` and before it cancels the event.
|
|
|
|
|
|
// `_keyPress` then checks that same flag, finds it still false, and
|
|
|
|
|
|
// emits a bare CR for Enter's charCode 13. So returning `false` alone
|
|
|
|
|
|
// sent ESC+CR *and* a submit: the newline was inserted and the
|
|
|
|
|
|
// half-written prompt went to Claude with a stray blank line in it.
|
|
|
|
|
|
// Cancelling the keydown is what stops the browser firing keypress at
|
|
|
|
|
|
// all. Verified in Chromium; jsdom never synthesizes the follow-up
|
|
|
|
|
|
// keypress, which is why the unit test could not see this.
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
return false;
|
2026-08-23 08:31:39 -07:00
|
|
|
|
}
|
2026-03-12 13:05:10 -07:00
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-28 21:22:54 +00:00
|
|
|
|
// WebGL addon is loaded/disposed dynamically in the active effect
|
|
|
|
|
|
// to avoid exhausting the browser's limited WebGL context pool.
|
2026-02-27 04:29:51 +00:00
|
|
|
|
|
|
|
|
|
|
fitAddon.fit();
|
|
|
|
|
|
termRef.current = term;
|
|
|
|
|
|
fitRef.current = fitAddon;
|
|
|
|
|
|
|
|
|
|
|
|
// Send initial size
|
|
|
|
|
|
resize(sessionId, term.cols, term.rows);
|
|
|
|
|
|
|
2026-03-05 05:47:42 -08:00
|
|
|
|
// Handle OSC 52 clipboard write sequences from programs inside the container.
|
|
|
|
|
|
// When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the
|
|
|
|
|
|
// container's shim emits an OSC 52 escape sequence which xterm.js routes here.
|
|
|
|
|
|
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
|
|
|
|
|
const idx = data.indexOf(";");
|
|
|
|
|
|
if (idx === -1) return false;
|
|
|
|
|
|
const payload = data.substring(idx + 1);
|
|
|
|
|
|
if (payload === "?") return false; // clipboard read request, not supported
|
|
|
|
|
|
try {
|
|
|
|
|
|
const decoded = atob(payload);
|
|
|
|
|
|
navigator.clipboard.writeText(decoded).catch((e) =>
|
|
|
|
|
|
console.error("OSC 52 clipboard write failed:", e),
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error("OSC 52 decode failed:", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-09 16:55:28 -07:00
|
|
|
|
// URL relay (OSC 7777) — a CLI inside the container asked for a URL to be
|
|
|
|
|
|
// opened in a browser. The container has none; `triple-c-open` (installed
|
|
|
|
|
|
// as xdg-open / $BROWSER / sensible-browser / ...) forwards the request
|
|
|
|
|
|
// here instead.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The container is untrusted, so this never opens anything by itself:
|
|
|
|
|
|
// parseUrlRelayOsc enforces the http/https allowlist and the payload is
|
|
|
|
|
|
// rate-limited, then the user gets the same confirmation toast the
|
|
|
|
|
|
// long-URL detector uses. One click is a small price for not handing a
|
|
|
|
|
|
// sandboxed agent a "make the host's logged-in browser fetch this"
|
|
|
|
|
|
// primitive.
|
|
|
|
|
|
const relayDisposable = term.parser.registerOscHandler(URL_RELAY_OSC, (data) => {
|
|
|
|
|
|
const url = parseUrlRelayOsc(data);
|
|
|
|
|
|
if (!url) {
|
|
|
|
|
|
console.warn("URL relay: rejected request from container");
|
|
|
|
|
|
return true; // consumed either way — never let it reach the screen
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!relayLimiterRef.current.allow(url)) {
|
|
|
|
|
|
console.warn("URL relay: rate-limited", url);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-08-23 11:11:43 -07:00
|
|
|
|
// Exact by construction (base64 over OSC 7777), and the detector never
|
|
|
|
|
|
// sees it — so tell it, or a truncated scrape of the same link could
|
|
|
|
|
|
// still fill the slot once this prompt is dismissed.
|
|
|
|
|
|
detectorRef.current?.noteExactUrl(url);
|
2026-08-23 08:31:39 -07:00
|
|
|
|
promptUrl(url, "Container asked to open a URL", "relay");
|
2026-08-09 16:55:28 -07:00
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
|
// Handle user input -> backend
|
|
|
|
|
|
const inputDisposable = term.onData((data) => {
|
2026-08-28 12:51:18 -07:00
|
|
|
|
// Ordered and coalesced by the queue in `useTerminal`; a rejection here
|
|
|
|
|
|
// means the session is gone, which the exit listener already reports.
|
|
|
|
|
|
sendInput(sessionId, data).catch((e) =>
|
|
|
|
|
|
console.error("Failed to send terminal input:", e)
|
|
|
|
|
|
);
|
2026-02-27 04:29:51 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-12 13:14:08 -07:00
|
|
|
|
// Track text selection to show copy hint in status bar
|
|
|
|
|
|
const selectionDisposable = term.onSelectionChange(() => {
|
|
|
|
|
|
setTerminalHasSelection(term.hasSelection());
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-01 10:52:08 -08:00
|
|
|
|
// Handle image paste: intercept paste events with image data,
|
|
|
|
|
|
// upload to the container, and inject the file path into terminal input.
|
|
|
|
|
|
const handlePaste = (e: ClipboardEvent) => {
|
|
|
|
|
|
const items = e.clipboardData?.items;
|
|
|
|
|
|
if (!items) return;
|
|
|
|
|
|
|
|
|
|
|
|
for (const item of Array.from(items)) {
|
|
|
|
|
|
if (item.type.startsWith("image/")) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
|
|
|
|
|
|
const blob = item.getAsFile();
|
|
|
|
|
|
if (!blob) return;
|
|
|
|
|
|
|
|
|
|
|
|
blob.arrayBuffer().then(async (buf) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setImagePasteMsg("Uploading image...");
|
|
|
|
|
|
const data = new Uint8Array(buf);
|
|
|
|
|
|
const filePath = await pasteImage(sessionId, data);
|
|
|
|
|
|
// Inject the file path into terminal stdin
|
|
|
|
|
|
sendInput(sessionId, filePath);
|
|
|
|
|
|
setImagePasteMsg(`Image saved to ${filePath}`);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("Image paste failed:", err);
|
|
|
|
|
|
setImagePasteMsg("Image paste failed");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return; // Only handle the first image
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
containerRef.current.addEventListener("paste", handlePaste, { capture: true });
|
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
|
// Handle backend output -> terminal
|
2026-02-28 20:42:13 +00:00
|
|
|
|
let aborted = false;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
|
2026-08-23 08:31:39 -07:00
|
|
|
|
// The detector samples this getter on every `feed`, so what it reassembles
|
|
|
|
|
|
// with is the width the bytes were *printed* at — only a break the terminal
|
2026-08-11 10:36:30 -07:00
|
|
|
|
// itself inserted may be deleted, and where that is moves with every
|
|
|
|
|
|
// resize.
|
|
|
|
|
|
const detector = new UrlDetector(
|
2026-08-23 08:31:39 -07:00
|
|
|
|
(url, source) =>
|
|
|
|
|
|
promptUrl(
|
|
|
|
|
|
url,
|
|
|
|
|
|
source === "osc8" ? "Link detected" : "Long URL detected",
|
|
|
|
|
|
source,
|
|
|
|
|
|
),
|
2026-08-11 10:36:30 -07:00
|
|
|
|
() => termRef.current?.cols ?? 0,
|
2026-08-09 16:55:28 -07:00
|
|
|
|
);
|
2026-03-01 08:29:43 -08:00
|
|
|
|
detectorRef.current = detector;
|
|
|
|
|
|
|
2026-03-11 12:24:16 -07:00
|
|
|
|
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
|
|
|
|
|
|
const textDecoder = new TextDecoder();
|
|
|
|
|
|
|
2026-02-28 20:42:13 +00:00
|
|
|
|
const outputPromise = onOutput(sessionId, (data) => {
|
|
|
|
|
|
if (aborted) return;
|
2026-09-08 11:02:33 -07:00
|
|
|
|
// Scrolling on new output is xterm's own job, and it already gets it
|
|
|
|
|
|
// right: it follows the tail while the viewport is at the bottom and
|
|
|
|
|
|
// holds position while you are reading further up. The manual
|
|
|
|
|
|
// `scrollToBottom()` that used to live here fought that second half.
|
|
|
|
|
|
term.write(data, syncMouseCapture);
|
2026-03-01 08:29:43 -08:00
|
|
|
|
detector.feed(data);
|
2026-03-11 12:24:16 -07:00
|
|
|
|
|
|
|
|
|
|
// Scan for SSO refresh marker in terminal output
|
|
|
|
|
|
if (!ssoTriggeredRef.current && projectId) {
|
|
|
|
|
|
const text = textDecoder.decode(data, { stream: true });
|
|
|
|
|
|
// Combine with overlap from previous chunk to handle marker spanning chunks
|
|
|
|
|
|
const combined = ssoBufferRef.current + text;
|
|
|
|
|
|
if (combined.includes(SSO_MARKER)) {
|
|
|
|
|
|
ssoTriggeredRef.current = true;
|
|
|
|
|
|
ssoBufferRef.current = "";
|
|
|
|
|
|
awsSsoRefresh(projectId).catch((e) =>
|
|
|
|
|
|
console.error("AWS SSO refresh failed:", e)
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Keep last N chars as overlap for next chunk
|
|
|
|
|
|
ssoBufferRef.current = combined.slice(-SSO_MARKER.length);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-27 04:29:51 +00:00
|
|
|
|
}).then((unlisten) => {
|
2026-02-28 20:42:13 +00:00
|
|
|
|
if (aborted) unlisten();
|
|
|
|
|
|
return unlisten;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-28 20:42:13 +00:00
|
|
|
|
const exitPromise = onExit(sessionId, () => {
|
|
|
|
|
|
if (aborted) return;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
|
|
|
|
|
|
}).then((unlisten) => {
|
2026-02-28 20:42:13 +00:00
|
|
|
|
if (aborted) unlisten();
|
|
|
|
|
|
return unlisten;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-28 21:22:54 +00:00
|
|
|
|
// Handle resize (throttled via requestAnimationFrame to avoid excessive calls).
|
|
|
|
|
|
// Skip resize work for hidden terminals — containerRef will have 0 dimensions.
|
2026-02-28 20:42:13 +00:00
|
|
|
|
let resizeRafId: number | null = null;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
2026-02-28 20:42:13 +00:00
|
|
|
|
if (resizeRafId !== null) return;
|
2026-02-28 21:22:54 +00:00
|
|
|
|
const el = containerRef.current;
|
|
|
|
|
|
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
2026-02-28 20:42:13 +00:00
|
|
|
|
resizeRafId = requestAnimationFrame(() => {
|
|
|
|
|
|
resizeRafId = null;
|
2026-02-28 21:22:54 +00:00
|
|
|
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
2026-09-08 11:02:33 -07:00
|
|
|
|
// Whether the viewport was following the tail has to be sampled
|
|
|
|
|
|
// *before* the fit: reflowing wrapped lines moves `baseY`, so asking
|
|
|
|
|
|
// afterwards cannot tell "was at the bottom" from "was pushed off it".
|
|
|
|
|
|
const wasAtBottom =
|
|
|
|
|
|
term.buffer.active.viewportY >= term.buffer.active.baseY;
|
2026-02-28 20:42:13 +00:00
|
|
|
|
fitAddon.fit();
|
|
|
|
|
|
resize(sessionId, term.cols, term.rows);
|
2026-09-08 11:02:33 -07:00
|
|
|
|
// Only re-anchor a viewport that was already on the tail. This
|
|
|
|
|
|
// observer fires for any pane size change — opening the Notes dock,
|
|
|
|
|
|
// dragging the sidebar, resizing the window — and none of those are a
|
|
|
|
|
|
// reason to yank someone away from the scrollback they are reading.
|
|
|
|
|
|
if (wasAtBottom) term.scrollToBottom();
|
2026-02-28 20:42:13 +00:00
|
|
|
|
});
|
2026-02-27 04:29:51 +00:00
|
|
|
|
});
|
|
|
|
|
|
resizeObserver.observe(containerRef.current);
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
2026-02-28 20:42:13 +00:00
|
|
|
|
aborted = true;
|
2026-03-01 08:29:43 -08:00
|
|
|
|
detector.dispose();
|
|
|
|
|
|
detectorRef.current = null;
|
2026-03-11 12:24:16 -07:00
|
|
|
|
ssoTriggeredRef.current = false;
|
|
|
|
|
|
ssoBufferRef.current = "";
|
2026-03-05 05:47:42 -08:00
|
|
|
|
osc52Disposable.dispose();
|
2026-08-09 16:55:28 -07:00
|
|
|
|
relayDisposable.dispose();
|
2026-02-27 04:29:51 +00:00
|
|
|
|
inputDisposable.dispose();
|
2026-03-12 13:14:08 -07:00
|
|
|
|
selectionDisposable.dispose();
|
|
|
|
|
|
setTerminalHasSelection(false);
|
2026-03-01 10:52:08 -08:00
|
|
|
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
2026-02-28 20:42:13 +00:00
|
|
|
|
outputPromise.then((fn) => fn?.());
|
|
|
|
|
|
exitPromise.then((fn) => fn?.());
|
|
|
|
|
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
2026-02-27 04:29:51 +00:00
|
|
|
|
resizeObserver.disconnect();
|
2026-02-28 21:22:54 +00:00
|
|
|
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
|
|
|
|
|
webglRef.current = null;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
term.dispose();
|
2026-06-28 19:33:27 -07:00
|
|
|
|
termRef.current = null;
|
2026-02-27 04:29:51 +00:00
|
|
|
|
};
|
|
|
|
|
|
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
2026-02-28 21:22:54 +00:00
|
|
|
|
// Manage WebGL lifecycle and re-fit when tab becomes active.
|
|
|
|
|
|
// Only the active terminal holds a WebGL context to avoid exhausting
|
|
|
|
|
|
// the browser's limited pool (~8-16 contexts).
|
2026-02-27 04:29:51 +00:00
|
|
|
|
useEffect(() => {
|
2026-02-28 21:22:54 +00:00
|
|
|
|
const term = termRef.current;
|
|
|
|
|
|
if (!term) return;
|
|
|
|
|
|
|
2026-08-28 12:51:18 -07:00
|
|
|
|
// Auto on macOS/Windows, off on Linux, overridable either way — see
|
|
|
|
|
|
// `resolveTerminalGpuRendering`. Loading the addon under a software-GL
|
|
|
|
|
|
// WebKitGTK is slower than xterm's canvas renderer, not faster.
|
|
|
|
|
|
const useGpu = resolveTerminalGpuRendering(gpuRenderingSetting, navigator.userAgent);
|
|
|
|
|
|
|
|
|
|
|
|
// The renderer and the activation work are independent: a terminal with
|
|
|
|
|
|
// GPU rendering switched off still has to fit and take focus when its tab
|
|
|
|
|
|
// becomes active. Keeping these in one branch made "GPU off" silently mean
|
|
|
|
|
|
// "never re-fit, never focus".
|
|
|
|
|
|
if (active && useGpu) {
|
2026-02-28 21:22:54 +00:00
|
|
|
|
// Attach WebGL renderer
|
|
|
|
|
|
if (!webglRef.current) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const addon = new WebglAddon();
|
|
|
|
|
|
addon.onContextLoss(() => {
|
|
|
|
|
|
try { addon.dispose(); } catch { /* ignore */ }
|
|
|
|
|
|
webglRef.current = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
term.loadAddon(addon);
|
|
|
|
|
|
webglRef.current = addon;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// WebGL not available, canvas renderer is fine
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-28 12:51:18 -07:00
|
|
|
|
} else if (webglRef.current) {
|
|
|
|
|
|
// Release the context — for inactive terminals, and when the setting
|
|
|
|
|
|
// turns GPU rendering off while this terminal is on screen.
|
|
|
|
|
|
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
|
|
|
|
|
webglRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (active) {
|
2026-09-08 11:02:33 -07:00
|
|
|
|
// Same rule as the resize observer: re-anchor only what was already
|
|
|
|
|
|
// anchored, so a tab left scrolled up comes back where it was left.
|
|
|
|
|
|
const wasAtBottom =
|
|
|
|
|
|
term.buffer.active.viewportY >= term.buffer.active.baseY;
|
2026-02-28 21:22:54 +00:00
|
|
|
|
fitRef.current?.fit();
|
2026-09-08 11:02:33 -07:00
|
|
|
|
if (wasAtBottom) term.scrollToBottom();
|
2026-02-28 21:22:54 +00:00
|
|
|
|
term.focus();
|
2026-02-27 04:29:51 +00:00
|
|
|
|
}
|
2026-08-28 12:51:18 -07:00
|
|
|
|
}, [active, gpuRenderingSetting]);
|
2026-02-27 04:29:51 +00:00
|
|
|
|
|
2026-09-02 13:28:31 -07:00
|
|
|
|
// Focus on demand, for the caller that cannot rely on the effect above.
|
|
|
|
|
|
// That one keys off `active`, so it covers switching *to* a terminal and
|
|
|
|
|
|
// nothing else — and the notes dock sends to the terminal already on screen,
|
|
|
|
|
|
// where `active` never changes. Consumed once and cleared, so asking twice
|
|
|
|
|
|
// for the same terminal works.
|
|
|
|
|
|
const pendingTerminalFocus = useAppState((s) => s.pendingTerminalFocus);
|
|
|
|
|
|
const clearPendingTerminalFocus = useAppState(
|
|
|
|
|
|
(s) => s.clearPendingTerminalFocus,
|
|
|
|
|
|
);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (pendingTerminalFocus !== sessionId) return;
|
|
|
|
|
|
termRef.current?.focus();
|
|
|
|
|
|
clearPendingTerminalFocus();
|
|
|
|
|
|
}, [pendingTerminalFocus, sessionId, clearPendingTerminalFocus]);
|
|
|
|
|
|
|
2026-08-23 11:11:43 -07:00
|
|
|
|
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
|
|
|
|
|
// A keyboard user who has just jumped into the toast is mid-decision, and
|
|
|
|
|
|
// pulling it out from under them costs them the only route to finishing a
|
|
|
|
|
|
// sign-in. It goes when they act on it, which is the same thing a mouse user
|
|
|
|
|
|
// does by clicking.
|
2026-03-01 08:29:43 -08:00
|
|
|
|
useEffect(() => {
|
2026-08-09 16:55:28 -07:00
|
|
|
|
if (!urlPrompt) return;
|
2026-08-23 11:11:43 -07:00
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
|
if (document.activeElement?.closest(URL_TOAST_SELECTOR)) return;
|
|
|
|
|
|
dismissUrlPrompt();
|
|
|
|
|
|
}, 30_000);
|
2026-03-01 08:29:43 -08:00
|
|
|
|
return () => clearTimeout(timer);
|
2026-08-23 11:11:43 -07:00
|
|
|
|
}, [urlPrompt, dismissUrlPrompt]);
|
2026-03-01 08:29:43 -08:00
|
|
|
|
|
2026-03-01 10:52:08 -08:00
|
|
|
|
// Auto-dismiss image paste message after 3 seconds
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!imagePasteMsg) return;
|
|
|
|
|
|
const timer = setTimeout(() => setImagePasteMsg(null), 3_000);
|
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
|
}, [imagePasteMsg]);
|
|
|
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
|
const handleOpenUrl = useCallback(() => {
|
2026-08-09 19:35:39 -07:00
|
|
|
|
if (!urlPrompt) return;
|
|
|
|
|
|
// Validated again at the sink. `promptUrl` is the only writer and already
|
|
|
|
|
|
// sanitizes, so this can only fail if that invariant is broken — which is
|
|
|
|
|
|
// precisely when it matters that the last thing before `openUrl` checks.
|
|
|
|
|
|
const safe = sanitizeRelayUrl(urlPrompt.url);
|
2026-08-23 11:11:43 -07:00
|
|
|
|
dismissUrlPrompt();
|
2026-08-09 19:35:39 -07:00
|
|
|
|
if (!safe) {
|
|
|
|
|
|
console.warn("Refusing to open a URL that failed validation");
|
|
|
|
|
|
return;
|
2026-03-01 08:29:43 -08:00
|
|
|
|
}
|
2026-08-09 19:35:39 -07:00
|
|
|
|
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
2026-08-23 11:11:43 -07:00
|
|
|
|
}, [urlPrompt, dismissUrlPrompt]);
|
2026-03-01 08:29:43 -08:00
|
|
|
|
|
2026-08-11 09:15:12 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Open the prompted URL in the container's own browser instead of the host's.
|
|
|
|
|
|
*
|
|
|
|
|
|
* For a sign-in this is the shorter path: the callback listener the tool is
|
|
|
|
|
|
* waiting on is inside the container, so a container-side browser closes the
|
|
|
|
|
|
* loop with nothing crossing to the host. The page is published to the
|
|
|
|
|
|
* project's Browser tab, which is where the user completes it by hand.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handleOpenUrlInContainer = useCallback(() => {
|
|
|
|
|
|
if (!urlPrompt) return;
|
|
|
|
|
|
const safe = sanitizeRelayUrl(urlPrompt.url);
|
2026-08-23 11:11:43 -07:00
|
|
|
|
dismissUrlPrompt();
|
2026-08-11 09:15:12 -07:00
|
|
|
|
if (!safe) {
|
|
|
|
|
|
console.warn("Refusing to open a URL that failed validation");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!projectId) return;
|
2026-08-11 11:13:21 -07:00
|
|
|
|
// Land on the pane that will show it, before the work starts: opening takes
|
|
|
|
|
|
// several seconds, and the progress line lives there.
|
|
|
|
|
|
useAppState.getState().openProjectHomeTab(projectId, "browser");
|
2026-08-11 09:15:12 -07:00
|
|
|
|
// A sign-in page is the one case where the *window* size matters least and
|
|
|
|
|
|
// the layout matters most, so it gets the ordinary desktop viewport.
|
2026-08-11 10:36:30 -07:00
|
|
|
|
// `true`: from a terminal there is no Browser pane on screen, so the page
|
|
|
|
|
|
// needs a window of its own or it opens somewhere the user isn't looking.
|
|
|
|
|
|
openPageInContainerBrowser(projectId, safe, 1280, 720, true)
|
2026-08-11 09:15:12 -07:00
|
|
|
|
.then((result) => {
|
|
|
|
|
|
const push = useAppState.getState().pushToast;
|
|
|
|
|
|
if (result.error) {
|
|
|
|
|
|
push({ kind: "error", message: "The page didn’t open", detail: result.error });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
push({
|
|
|
|
|
|
kind: "success",
|
2026-08-11 10:36:30 -07:00
|
|
|
|
message: "Opened in the container’s browser",
|
2026-08-11 09:15:12 -07:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((e) =>
|
|
|
|
|
|
useAppState.getState().pushToast({
|
|
|
|
|
|
kind: "error",
|
|
|
|
|
|
message: "Could not open it in the container’s browser",
|
|
|
|
|
|
detail: String(e),
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-08-23 11:11:43 -07:00
|
|
|
|
}, [urlPrompt, projectId, dismissUrlPrompt]);
|
2026-08-11 09:15:12 -07:00
|
|
|
|
|
2026-04-17 08:58:56 -07:00
|
|
|
|
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
|
|
|
|
|
const term = termRef.current;
|
|
|
|
|
|
if (!term) return;
|
|
|
|
|
|
const sel = term.getSelection();
|
|
|
|
|
|
if (!sel) return;
|
|
|
|
|
|
const out = mode === "raw" ? sel : trimSelection(sel);
|
|
|
|
|
|
navigator.clipboard.writeText(out).catch((e) =>
|
|
|
|
|
|
console.error("Context menu clipboard write failed:", e),
|
|
|
|
|
|
);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
|
|
|
|
if (!termRef.current?.hasSelection()) return; // let default menu happen
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-08 11:02:33 -07:00
|
|
|
|
// Surface the capture state and its escape hatch to the status bar, but only
|
|
|
|
|
|
// while this is the visible terminal.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!active) return;
|
|
|
|
|
|
setTerminalMouseCaptured(mouseCaptured);
|
|
|
|
|
|
setReleaseActiveMouse(releaseMouse);
|
|
|
|
|
|
}, [active, mouseCaptured, releaseMouse, setTerminalMouseCaptured, setReleaseActiveMouse]);
|
|
|
|
|
|
|
|
|
|
|
|
// On unmount, if this was the active terminal, clear the status-bar state so
|
|
|
|
|
|
// it does not point at a disposed terminal. (Tab switches do not unmount —
|
|
|
|
|
|
// the deactivating terminal stays mounted but hidden — so this only fires
|
|
|
|
|
|
// when the active session is actually closed.)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (activeRef.current) {
|
|
|
|
|
|
setTerminalMouseCaptured(false);
|
|
|
|
|
|
setReleaseActiveMouse(() => {});
|
2026-03-15 07:00:09 -07:00
|
|
|
|
}
|
2026-09-08 11:02:33 -07:00
|
|
|
|
};
|
|
|
|
|
|
}, [setTerminalMouseCaptured, setReleaseActiveMouse]);
|
2026-03-15 07:00:09 -07:00
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
2026-03-01 08:29:43 -08:00
|
|
|
|
ref={terminalContainerRef}
|
|
|
|
|
|
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
|
|
|
|
|
>
|
2026-08-09 16:55:28 -07:00
|
|
|
|
{urlPrompt && (
|
2026-03-01 08:29:43 -08:00
|
|
|
|
<UrlToast
|
2026-08-09 19:35:39 -07:00
|
|
|
|
// A different URL is a different prompt, not an edit of this one.
|
|
|
|
|
|
key={urlPrompt.seq}
|
2026-08-09 16:55:28 -07:00
|
|
|
|
url={urlPrompt.url}
|
|
|
|
|
|
label={urlPrompt.label}
|
2026-03-01 08:29:43 -08:00
|
|
|
|
onOpen={handleOpenUrl}
|
2026-08-11 09:15:12 -07:00
|
|
|
|
onOpenInContainer={handleOpenUrlInContainer}
|
2026-08-23 11:11:43 -07:00
|
|
|
|
onDismiss={dismissUrlPrompt}
|
2026-03-01 08:29:43 -08:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-03-01 10:52:08 -08:00
|
|
|
|
{imagePasteMsg && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute top-2 left-1/2 -translate-x-1/2 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#e6edf3] border border-[#30363d] shadow-lg"
|
|
|
|
|
|
onClick={() => setImagePasteMsg(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{imagePasteMsg}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-06-28 19:26:05 -07:00
|
|
|
|
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
|
|
|
|
|
FitAddon measures the host element it's mounted into; padding there
|
|
|
|
|
|
causes the grid to overhang and clip the rightmost column / bottom
|
|
|
|
|
|
row. The host below fills this wrapper's content box with no padding.
|
2026-06-28 19:33:27 -07:00
|
|
|
|
Kept to a tight, even gutter so the terminal claims as much area as
|
|
|
|
|
|
possible while leaving a little breathing room beside the scrollbar. */}
|
2026-06-28 19:26:05 -07:00
|
|
|
|
<div className="w-full h-full" style={{ padding: "4px 8px 4px 8px" }}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
|
className="w-full h-full"
|
|
|
|
|
|
onContextMenu={handleContextMenu}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-04-17 08:58:56 -07:00
|
|
|
|
{contextMenu && (
|
|
|
|
|
|
<TerminalContextMenu
|
|
|
|
|
|
x={contextMenu.x}
|
|
|
|
|
|
y={contextMenu.y}
|
|
|
|
|
|
onCopyTrimmed={() => {
|
|
|
|
|
|
writeSelection("trimmed");
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onCopyRaw={() => {
|
|
|
|
|
|
writeSelection("raw");
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onDismiss={() => setContextMenu(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-03-01 08:29:43 -08:00
|
|
|
|
</div>
|
2026-02-27 04:29:51 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|