Files
Triple-C/app/src/components/terminal/TerminalView.tsx
T
shadowdaoandClaude Opus 5 c0e4c87cec
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-linux (pull_request) Successful in 5m8s
Build App (Preview) / build-windows (pull_request) Successful in 6m28s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build Container / build-container (pull_request) Failing after 14m49s
Give the mouse back, retire the follow controls, update Claude per session
Three things the terminal was getting wrong.

**A program that grabs the mouse and dies used to freeze the tab.** A TUI sets
DECSET ?1000/?1002/?1003; if it exits without resetting them, xterm keeps
routing clicks, drags and — under ?1003 — every pointer *move* to the PTY.
Text selection dies and escape bytes flood the prompt. The only exit was
closing the tab. `TerminalView` now reconciles a flag against
`term.modes.mouseTrackingMode` in the `term.write()` callback — the mode only
changes because the container printed a sequence, so one check per write
catches every transition with no polling — and `Ctrl+Shift+X` or a status-bar
button writes the resets back through `term.write`, never `sendInput`: the
reset belongs to xterm's parser, and a still-live TUI told about it would just
re-grab on its next repaint.

The control is in the status bar deliberately. Mouse tracking is the *normal*
state of htop, vim, lazygit and Claude Code, so a badge over the terminal
would be on screen for the whole life of those programs and would swallow
clicks aimed at their own top-right corner. `macOptionClickForcesSelection` is
also on now: xterm's force-select is Shift everywhere except macOS, where it
is Option and is gated behind that option, which defaults to false — so until
now Mac users had no way to select text while a program held the mouse.

**"Following" and "Jump to Current" are gone.** Claude Code draws on the
alternate screen, which has no scrollback, so `viewportY` always equalled
`baseY` and neither control could do anything. They did still work in bash
tabs; xterm's native follow covers that, and the per-write `scrollToBottom()`
went with them because it fought exactly that. What remains, on activate and
after a refit, now samples `viewportY >= baseY` *before* the fit, so opening
the Notes dock no longer yanks a reader to the tail.

**`claude update` runs before every Claude session, not just at container
start.** Containers here stop/start and often just keep running, so a
long-lived one never re-checked. Both copies take the same flock: the
entrypoint prints "container ready" only after its own update finishes, so
opening a tab immediately would otherwise run two updaters against the same
~/.claude/bin, with `|| echo` hiding a half-written install one line before
`exec claude` ran it.

This turns the non-Bedrock path from a bare argv into a `bash -c` wrapper, so
flags and session names are shell-interpolated now and must go through
`shell_quote_arg`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 11:02:33 -07:00

941 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from "react";
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";
import { useAppState } from "../../store/appState";
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
import {
awsSsoRefresh,
openPageInContainerBrowser,
uploadHostFileToTerminal,
} from "../../lib/tauri-commands";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { UrlDetector, type UrlSource } from "../../lib/urlDetector";
import {
RelayRateLimiter,
URL_RELAY_OSC,
extendsUrl,
parseUrlRelayOsc,
sanitizeRelayUrl,
} from "../../lib/urlRelay";
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
import UrlToast, {
URL_TOAST_PRIMARY_SELECTOR,
URL_TOAST_SELECTOR,
URL_TOAST_SHORTCUT,
} from "./UrlToast";
import { trimSelection } from "./trimSelection";
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import TerminalContextMenu from "./TerminalContextMenu";
interface Props {
sessionId: string;
active: boolean;
}
/**
* 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);
}
export default function TerminalView({ sessionId, active }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const terminalContainerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null);
const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const setTerminalMouseCaptured = useAppState(s => s.setTerminalMouseCaptured);
const setReleaseActiveMouse = useAppState(s => s.setReleaseActiveMouse);
const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false);
const projectId = useAppState(
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
);
// 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.
//
// 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.
//
// `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;
source: PromptSource;
seq: number;
} | null>(null);
const promptSeqRef = useRef(0);
const relayLimiterRef = useRef(new RelayRateLimiter());
// 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();
}, []);
/**
* 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.
*
* 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.
*/
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 };
});
},
[],
);
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);
}, []);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
// 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);
// 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).
//
// The listener is window-wide, so every pane decides for itself whether a
// 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.
//
// 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
// 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.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
// 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, "'\\''")}'`;
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== "drop") return;
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(
"[drop] refused: a dialog or overlay is open",
event.payload.position,
);
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
return;
}
if (verdict !== "accept") return;
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]);
/**
* 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]);
useEffect(() => {
if (!containerRef.current) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
// 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,
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);
// 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).
// eslint-disable-next-line no-control-regex
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
const webLinksAddon = new WebLinksAddon((_event, uri) => {
// 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));
}, { urlRegex });
term.loadAddon(webLinksAddon);
term.open(containerRef.current);
// 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).
term.attachCustomKeyEventHandler((event) => {
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
const sel = term.getSelection();
if (sel) {
const out = event.altKey ? sel : trimSelection(sel);
navigator.clipboard.writeText(out).catch((e) =>
console.error("Ctrl+Shift+C clipboard write failed:", e),
);
}
return false; // prevent xterm from processing this key
}
// Ctrl+Shift+M toggles speech-to-text recording (mic lives in the status
// bar, bound to the active session; trigger it via the store).
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
useAppState.getState().sttToggle();
return false;
}
// 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;
}
// 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"
) {
sendInput(sessionId, CLAUDE_SOFT_NEWLINE);
// **`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;
}
return true;
});
// WebGL addon is loaded/disposed dynamically in the active effect
// to avoid exhausting the browser's limited WebGL context pool.
fitAddon.fit();
termRef.current = term;
fitRef.current = fitAddon;
// Send initial size
resize(sessionId, term.cols, term.rows);
// 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;
});
// 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;
}
// 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);
promptUrl(url, "Container asked to open a URL", "relay");
return true;
});
// Handle user input -> backend
const inputDisposable = term.onData((data) => {
// 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)
);
});
// Track text selection to show copy hint in status bar
const selectionDisposable = term.onSelectionChange(() => {
setTerminalHasSelection(term.hasSelection());
});
// 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 });
// Handle backend output -> terminal
let aborted = false;
// 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
// itself inserted may be deleted, and where that is moves with every
// resize.
const detector = new UrlDetector(
(url, source) =>
promptUrl(
url,
source === "osc8" ? "Link detected" : "Long URL detected",
source,
),
() => termRef.current?.cols ?? 0,
);
detectorRef.current = detector;
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
const textDecoder = new TextDecoder();
const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
// 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);
detector.feed(data);
// 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);
}
}
}).then((unlisten) => {
if (aborted) unlisten();
return unlisten;
});
const exitPromise = onExit(sessionId, () => {
if (aborted) return;
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
}).then((unlisten) => {
if (aborted) unlisten();
return unlisten;
});
// Handle resize (throttled via requestAnimationFrame to avoid excessive calls).
// Skip resize work for hidden terminals — containerRef will have 0 dimensions.
let resizeRafId: number | null = null;
const resizeObserver = new ResizeObserver(() => {
if (resizeRafId !== null) return;
const el = containerRef.current;
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
resizeRafId = requestAnimationFrame(() => {
resizeRafId = null;
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
// 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;
fitAddon.fit();
resize(sessionId, term.cols, term.rows);
// 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();
});
});
resizeObserver.observe(containerRef.current);
return () => {
aborted = true;
detector.dispose();
detectorRef.current = null;
ssoTriggeredRef.current = false;
ssoBufferRef.current = "";
osc52Disposable.dispose();
relayDisposable.dispose();
inputDisposable.dispose();
selectionDisposable.dispose();
setTerminalHasSelection(false);
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.());
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
webglRef.current = null;
term.dispose();
termRef.current = null;
};
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
// 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).
useEffect(() => {
const term = termRef.current;
if (!term) return;
// 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) {
// 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
}
}
} 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) {
// 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;
fitRef.current?.fit();
if (wasAtBottom) term.scrollToBottom();
term.focus();
}
}, [active, gpuRenderingSetting]);
// 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]);
// 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.
useEffect(() => {
if (!urlPrompt) return;
const timer = setTimeout(() => {
if (document.activeElement?.closest(URL_TOAST_SELECTOR)) return;
dismissUrlPrompt();
}, 30_000);
return () => clearTimeout(timer);
}, [urlPrompt, dismissUrlPrompt]);
// Auto-dismiss image paste message after 3 seconds
useEffect(() => {
if (!imagePasteMsg) return;
const timer = setTimeout(() => setImagePasteMsg(null), 3_000);
return () => clearTimeout(timer);
}, [imagePasteMsg]);
const handleOpenUrl = useCallback(() => {
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);
dismissUrlPrompt();
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
return;
}
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
}, [urlPrompt, dismissUrlPrompt]);
/**
* 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);
dismissUrlPrompt();
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
return;
}
if (!projectId) return;
// 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");
// 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.
// `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)
.then((result) => {
const push = useAppState.getState().pushToast;
if (result.error) {
push({ kind: "error", message: "The page didnt open", detail: result.error });
} else {
push({
kind: "success",
message: "Opened in the containers browser",
});
}
})
.catch((e) =>
useAppState.getState().pushToast({
kind: "error",
message: "Could not open it in the containers browser",
detail: String(e),
}),
);
}, [urlPrompt, projectId, dismissUrlPrompt]);
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 });
}, []);
// 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(() => {});
}
};
}, [setTerminalMouseCaptured, setReleaseActiveMouse]);
return (
<div
ref={terminalContainerRef}
className={`w-full h-full relative ${active ? "" : "hidden"}`}
>
{urlPrompt && (
<UrlToast
// A different URL is a different prompt, not an edit of this one.
key={urlPrompt.seq}
url={urlPrompt.url}
label={urlPrompt.label}
onOpen={handleOpenUrl}
onOpenInContainer={handleOpenUrlInContainer}
onDismiss={dismissUrlPrompt}
/>
)}
{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>
)}
{/* 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.
Kept to a tight, even gutter so the terminal claims as much area as
possible while leaving a little breathing room beside the scrollbar. */}
<div className="w-full h-full" style={{ padding: "4px 8px 4px 8px" }}>
<div
ref={containerRef}
className="w-full h-full"
onContextMenu={handleContextMenu}
/>
</div>
{contextMenu && (
<TerminalContextMenu
x={contextMenu.x}
y={contextMenu.y}
onCopyTrimmed={() => {
writeSelection("trimmed");
setContextMenu(null);
}}
onCopyRaw={() => {
writeSelection("raw");
setContextMenu(null);
}}
onDismiss={() => setContextMenu(null)}
/>
)}
</div>
);
}