Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4721950eae | |||
| fba4b9442c | |||
| 48f0e2f64c |
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 41 KiB |
@@ -120,6 +120,9 @@ pub async fn create_container(
|
||||
|
||||
let mut env_vars: Vec<String> = Vec::new();
|
||||
|
||||
// Tell CLI tools the terminal supports 24-bit RGB color
|
||||
env_vars.push("COLORTERM=truecolor".to_string());
|
||||
|
||||
// Pass host UID/GID so the entrypoint can remap the container user
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -392,6 +395,10 @@ pub async fn stop_container(container_id: &str) -> Result<(), String> {
|
||||
|
||||
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
log::info!(
|
||||
"Removing container {} (v=false: named volumes such as claude config are preserved)",
|
||||
container_id
|
||||
);
|
||||
docker
|
||||
.remove_container(
|
||||
container_id,
|
||||
|
||||
@@ -269,8 +269,6 @@ export default function ProjectCard({ project }: Props) {
|
||||
{project.paths.map((pp, i) => (
|
||||
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
|
||||
<span className="font-mono">/workspace/{pp.mount_name}</span>
|
||||
<span className="mx-1">←</span>
|
||||
<span>{pp.host_path}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,6 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
|
||||
/** Strip ANSI escape sequences from a string. */
|
||||
function stripAnsi(s: string): string {
|
||||
return s.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07/g, "");
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
active: boolean;
|
||||
@@ -21,6 +16,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,13 +64,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
term.open(containerRef.current);
|
||||
|
||||
// Try WebGL renderer, fall back silently
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
term.loadAddon(webglAddon);
|
||||
} catch {
|
||||
// WebGL not available, canvas renderer is fine
|
||||
}
|
||||
// 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;
|
||||
@@ -88,50 +79,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
sendInput(sessionId, data);
|
||||
});
|
||||
|
||||
// ── URL accumulator ──────────────────────────────────────────────
|
||||
// Claude Code login emits a long OAuth URL that gets split across
|
||||
// hard newlines (\n / \r\n). The WebLinksAddon only joins
|
||||
// soft-wrapped lines (the `isWrapped` flag), so the URL match is
|
||||
// truncated and the link fails when clicked.
|
||||
//
|
||||
// Fix: buffer recent output, strip ANSI codes, and after a short
|
||||
// debounce check for a URL that spans multiple lines. When found,
|
||||
// write a single clean clickable copy to the terminal.
|
||||
const textDecoder = new TextDecoder();
|
||||
let outputBuffer = "";
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flushUrlBuffer = () => {
|
||||
const plain = stripAnsi(outputBuffer);
|
||||
// Reassemble: strip hard newlines and carriage returns to join
|
||||
// fragments that were split across terminal lines.
|
||||
const joined = plain.replace(/[\r\n]+/g, "");
|
||||
// Look for a long OAuth/auth URL (Claude login URLs contain
|
||||
// "oauth" or "console.anthropic.com" or "/authorize").
|
||||
const match = joined.match(/https?:\/\/[^\s'"\x07]{80,}/);
|
||||
if (match) {
|
||||
const url = match[0];
|
||||
term.write("\r\n\x1b[36m🔗 Clickable login URL:\x1b[0m\r\n");
|
||||
term.write(`\x1b[4;34m${url}\x1b[0m\r\n`);
|
||||
}
|
||||
outputBuffer = "";
|
||||
};
|
||||
|
||||
// Handle backend output -> terminal
|
||||
let aborted = false;
|
||||
|
||||
const outputPromise = onOutput(sessionId, (data) => {
|
||||
if (aborted) return;
|
||||
term.write(data);
|
||||
|
||||
// Accumulate for URL detection (data is a Uint8Array, so decode it)
|
||||
outputBuffer += textDecoder.decode(data);
|
||||
// Cap buffer size to avoid memory growth
|
||||
if (outputBuffer.length > 8192) {
|
||||
outputBuffer = outputBuffer.slice(-4096);
|
||||
}
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(flushUrlBuffer, 150);
|
||||
}).then((unlisten) => {
|
||||
if (aborted) unlisten();
|
||||
return unlisten;
|
||||
@@ -145,12 +98,16 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
return unlisten;
|
||||
});
|
||||
|
||||
// Handle resize (throttled via requestAnimationFrame to avoid excessive calls)
|
||||
// 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;
|
||||
fitAddon.fit();
|
||||
resize(sessionId, term.cols, term.rows);
|
||||
});
|
||||
@@ -159,21 +116,47 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
inputDisposable.dispose();
|
||||
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();
|
||||
};
|
||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-fit when tab becomes active
|
||||
// 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(() => {
|
||||
if (active && fitRef.current && termRef.current) {
|
||||
fitRef.current.fit();
|
||||
termRef.current.focus();
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
if (active) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
fitRef.current?.fit();
|
||||
term.focus();
|
||||
} else {
|
||||
// Release WebGL context for inactive terminals
|
||||
if (webglRef.current) {
|
||||
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||
webglRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
|
||||