Initial commit: Triple-C app, container, and CI

Tauri v2 desktop app (React/TypeScript + Rust) for managing
containerized Claude Code environments. Includes Gitea Actions
workflow for building and pushing the sandbox container image,
and a BUILDING.md guide for manual app builds on Linux and Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 04:29:51 +00:00
commit 97a0745ead
65 changed files with 17202 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import { useState, useEffect, useCallback } from "react";
import { listSiblingContainers } from "../../lib/tauri-commands";
import type { SiblingContainer } from "../../lib/types";
export default function SiblingContainers() {
const [containers, setContainers] = useState<SiblingContainer[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const list = await listSiblingContainers();
setContainers(list);
} catch {
// Silently fail
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
return (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">Sibling Containers</h3>
<button
onClick={refresh}
disabled={loading}
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
{loading ? "..." : "Refresh"}
</button>
</div>
{containers.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">No other containers found.</p>
) : (
<div className="space-y-2">
{containers.map((c) => (
<div
key={c.id}
className="px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs"
>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
c.state === "running"
? "bg-[var(--success)]"
: "bg-[var(--text-secondary)]"
}`}
/>
<span className="font-medium truncate">
{c.names?.[0]?.replace(/^\//, "") ?? c.id.slice(0, 12)}
</span>
</div>
<div className="text-[var(--text-secondary)] mt-0.5 ml-4">
{c.image} {c.status}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useAppState } from "../../store/appState";
import ProjectList from "../projects/ProjectList";
import SettingsPanel from "../settings/SettingsPanel";
export default function Sidebar() {
const { sidebarView, setSidebarView } = useAppState();
return (
<div className="flex flex-col h-full w-64 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
{/* Nav tabs */}
<div className="flex border-b border-[var(--border-color)]">
<button
onClick={() => setSidebarView("projects")}
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
sidebarView === "projects"
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
Projects
</button>
<button
onClick={() => setSidebarView("settings")}
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
sidebarView === "settings"
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
Settings
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { useAppState } from "../../store/appState";
export default function StatusBar() {
const { projects, sessions } = useAppState();
const running = projects.filter((p) => p.status === "running").length;
return (
<div className="flex items-center h-6 px-3 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
<span>
{projects.length} project{projects.length !== 1 ? "s" : ""}
</span>
<span className="mx-2">|</span>
<span>
{running} running
</span>
<span className="mx-2">|</span>
<span>
{sessions.length} terminal{sessions.length !== 1 ? "s" : ""}
</span>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import TerminalTabs from "../terminal/TerminalTabs";
import { useAppState } from "../../store/appState";
export default function TopBar() {
const { dockerAvailable, imageExists } = useAppState();
return (
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
<div className="flex-1 overflow-x-auto">
<TerminalTabs />
</div>
<div className="flex items-center gap-2 px-3 text-xs text-[var(--text-secondary)]">
<StatusDot ok={dockerAvailable === true} label="Docker" />
<StatusDot ok={imageExists === true} label="Image" />
</div>
</div>
);
}
function StatusDot({ ok, label }: { ok: boolean; label: string }) {
return (
<span className="flex items-center gap-1">
<span
className={`inline-block w-2 h-2 rounded-full ${
ok ? "bg-[var(--success)]" : "bg-[var(--text-secondary)]"
}`}
/>
{label}
</span>
);
}

View File

@@ -0,0 +1,99 @@
import { useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { useProjects } from "../../hooks/useProjects";
interface Props {
onClose: () => void;
}
export default function AddProjectDialog({ onClose }: Props) {
const { add } = useProjects();
const [name, setName] = useState("");
const [path, setPath] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleBrowse = async () => {
const selected = await open({ directory: true, multiple: false });
if (typeof selected === "string") {
setPath(selected);
if (!name) {
const parts = selected.replace(/[/\\]$/, "").split(/[/\\]/);
setName(parts[parts.length - 1]);
}
}
};
const handleSubmit = async () => {
if (!name.trim() || !path.trim()) {
setError("Name and path are required");
return;
}
setLoading(true);
setError(null);
try {
await add(name.trim(), path.trim());
onClose();
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-96 shadow-xl">
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
<label className="block text-sm text-[var(--text-secondary)] mb-1">
Project Name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-project"
className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
/>
<label className="block text-sm text-[var(--text-secondary)] mb-1">
Project Path
</label>
<div className="flex gap-2 mb-4">
<input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/path/to/project"
className="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
/>
<button
onClick={handleBrowse}
className="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded text-sm hover:bg-[var(--border-color)] transition-colors"
>
Browse
</button>
</div>
{error && (
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
{loading ? "Adding..." : "Add Project"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,291 @@
import { useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import type { Project, AuthMode } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
interface Props {
project: Project;
}
export default function ProjectCard({ project }: Props) {
const { selectedProjectId, setSelectedProject } = useAppState();
const { start, stop, rebuild, remove, update } = useProjects();
const { open: openTerminal } = useTerminal();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfig, setShowConfig] = useState(false);
const isSelected = selectedProjectId === project.id;
const isStopped = project.status === "stopped" || project.status === "error";
const handleStart = async () => {
setLoading(true);
setError(null);
try {
await start(project.id);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
};
const handleStop = async () => {
setLoading(true);
setError(null);
try {
await stop(project.id);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
};
const handleOpenTerminal = async () => {
try {
await openTerminal(project.id, project.name);
} catch (e) {
setError(String(e));
}
};
const handleAuthModeChange = async (mode: AuthMode) => {
try {
await update({ ...project, auth_mode: mode });
} catch (e) {
setError(String(e));
}
};
const handleBrowseSSH = async () => {
const selected = await open({ directory: true, multiple: false });
if (selected) {
try {
await update({ ...project, ssh_key_path: selected as string });
} catch (e) {
setError(String(e));
}
}
};
const statusColor = {
stopped: "bg-[var(--text-secondary)]",
starting: "bg-[var(--warning)]",
running: "bg-[var(--success)]",
stopping: "bg-[var(--warning)]",
error: "bg-[var(--error)]",
}[project.status];
return (
<div
onClick={() => setSelectedProject(project.id)}
className={`px-3 py-2 rounded cursor-pointer transition-colors ${
isSelected
? "bg-[var(--bg-tertiary)]"
: "hover:bg-[var(--bg-tertiary)]"
}`}
>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
</div>
<div className="text-xs text-[var(--text-secondary)] truncate mt-0.5 ml-4">
{project.path}
</div>
{isSelected && (
<div className="mt-2 ml-4 space-y-2">
{/* Auth mode selector */}
<div className="flex items-center gap-1 text-xs">
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("login"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "login"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
>
/login
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("api_key"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "api_key"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
>
API key
</button>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1">
{isStopped ? (
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
) : project.status === "running" ? (
<>
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
<ActionButton
onClick={async () => {
setLoading(true);
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
setLoading(false);
}}
disabled={loading}
label="Reset"
/>
</>
) : (
<span className="text-xs text-[var(--text-secondary)]">
{project.status}...
</span>
)}
<ActionButton
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
disabled={false}
label={showConfig ? "Hide" : "Config"}
/>
<ActionButton
onClick={async () => {
if (confirm(`Remove project "${project.name}"?`)) {
await remove(project.id);
}
}}
disabled={loading}
label="Remove"
danger
/>
</div>
{/* Config panel */}
{showConfig && (
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]" onClick={(e) => e.stopPropagation()}>
{/* SSH Key */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
<div className="flex gap-1">
<input
value={project.ssh_key_path ?? ""}
onChange={async (e) => {
try { await update({ ...project, ssh_key_path: e.target.value || null }); } catch {}
}}
placeholder="~/.ssh"
disabled={!isStopped}
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
/>
<button
onClick={handleBrowseSSH}
disabled={!isStopped}
className="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
>
...
</button>
</div>
</div>
{/* Git Name */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
<input
value={project.git_user_name ?? ""}
onChange={async (e) => {
try { await update({ ...project, git_user_name: e.target.value || null }); } catch {}
}}
placeholder="Your Name"
disabled={!isStopped}
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
/>
</div>
{/* Git Email */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
<input
value={project.git_user_email ?? ""}
onChange={async (e) => {
try { await update({ ...project, git_user_email: e.target.value || null }); } catch {}
}}
placeholder="you@example.com"
disabled={!isStopped}
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
/>
</div>
{/* Git Token (HTTPS) */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
<input
type="password"
value={project.git_token ?? ""}
onChange={async (e) => {
try { await update({ ...project, git_token: e.target.value || null }); } catch {}
}}
placeholder="ghp_..."
disabled={!isStopped}
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
/>
</div>
{/* Docker access toggle */}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
<button
onClick={async () => {
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch {}
}}
disabled={!isStopped}
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
project.allow_docker_access
? "bg-[var(--success)] text-white"
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
{project.allow_docker_access ? "ON" : "OFF"}
</button>
</div>
</div>
)}
</div>
)}
{error && (
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
)}
</div>
);
}
function ActionButton({
onClick,
disabled,
label,
accent,
danger,
}: {
onClick: (e?: React.MouseEvent) => void;
disabled: boolean;
label: string;
accent?: boolean;
danger?: boolean;
}) {
let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]";
if (danger) color = "text-[var(--error)] hover:text-[var(--error)]";
return (
<button
onClick={(e) => { e.stopPropagation(); onClick(e); }}
disabled={disabled}
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
>
{label}
</button>
);
}

View File

@@ -0,0 +1,40 @@
import { useState } from "react";
import { useProjects } from "../../hooks/useProjects";
import ProjectCard from "./ProjectCard";
import AddProjectDialog from "./AddProjectDialog";
export default function ProjectList() {
const { projects } = useProjects();
const [showAdd, setShowAdd] = useState(false);
return (
<div className="p-2">
<div className="flex items-center justify-between px-2 py-1 mb-2">
<span className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
Projects
</span>
<button
onClick={() => setShowAdd(true)}
className="text-lg leading-none text-[var(--text-secondary)] hover:text-[var(--accent)] transition-colors"
title="Add project"
>
+
</button>
</div>
{projects.length === 0 ? (
<p className="px-2 text-sm text-[var(--text-secondary)]">
No projects yet. Click + to add one.
</p>
) : (
<div className="flex flex-col gap-1">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
{showAdd && <AddProjectDialog onClose={() => setShowAdd(false)} />}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import { useSettings } from "../../hooks/useSettings";
export default function ApiKeyInput() {
const { hasKey, saveApiKey, removeApiKey } = useSettings();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!key.trim()) return;
setSaving(true);
setError(null);
try {
await saveApiKey(key.trim());
setKey("");
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
return (
<div>
<label className="block text-sm font-medium mb-1">Authentication</label>
<p className="text-xs text-[var(--text-secondary)] mb-3">
Each project can use either <strong>claude login</strong> (OAuth, run inside the terminal) or an <strong>API key</strong>. Set auth mode per-project.
</p>
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">
API Key (for projects using API key mode)
</label>
{hasKey ? (
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--success)]">Key configured</span>
<button
onClick={async () => {
try { await removeApiKey(); } catch (e) { setError(String(e)); }
}}
className="text-xs text-[var(--error)] hover:underline"
>
Remove
</button>
</div>
) : (
<div className="space-y-2">
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="sk-ant-..."
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
<button
onClick={handleSave}
disabled={saving || !key.trim()}
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
{saving ? "Saving..." : "Save Key"}
</button>
</div>
)}
{error && <div className="text-xs text-[var(--error)] mt-1">{error}</div>}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { useState } from "react";
import { useDocker } from "../../hooks/useDocker";
export default function DockerSettings() {
const { dockerAvailable, imageExists, checkDocker, checkImage, buildImage } =
useDocker();
const [building, setBuilding] = useState(false);
const [buildLog, setBuildLog] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const handleBuild = async () => {
setBuilding(true);
setBuildLog([]);
setError(null);
try {
await buildImage((msg) => {
setBuildLog((prev) => [...prev, msg]);
});
} catch (e) {
setError(String(e));
} finally {
setBuilding(false);
}
};
return (
<div>
<label className="block text-sm font-medium mb-2">Docker</label>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Docker Status</span>
<span className={dockerAvailable ? "text-[var(--success)]" : "text-[var(--error)]"}>
{dockerAvailable === null ? "Checking..." : dockerAvailable ? "Connected" : "Not Available"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Image</span>
<span className={imageExists ? "text-[var(--success)]" : "text-[var(--text-secondary)]"}>
{imageExists === null ? "Checking..." : imageExists ? "Built" : "Not Built"}
</span>
</div>
<div className="flex gap-2">
<button
onClick={async () => { await checkDocker(); await checkImage(); }}
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Refresh Status
</button>
<button
onClick={handleBuild}
disabled={building || !dockerAvailable}
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
{building ? "Building..." : imageExists ? "Rebuild Image" : "Build Image"}
</button>
</div>
{buildLog.length > 0 && (
<div className="max-h-40 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2 text-xs font-mono text-[var(--text-secondary)]">
{buildLog.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
)}
{error && <div className="text-xs text-[var(--error)]">{error}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import ApiKeyInput from "./ApiKeyInput";
import DockerSettings from "./DockerSettings";
export default function SettingsPanel() {
return (
<div className="p-4 space-y-6">
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
Settings
</h2>
<ApiKeyInput />
<DockerSettings />
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useTerminal } from "../../hooks/useTerminal";
export default function TerminalTabs() {
const { sessions, activeSessionId, setActiveSession, close } = useTerminal();
if (sessions.length === 0) {
return (
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
No active terminals
</div>
);
}
return (
<div className="flex items-center h-full">
{sessions.map((session) => (
<div
key={session.id}
onClick={() => setActiveSession(session.id)}
className={`flex items-center gap-2 px-3 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
activeSessionId === session.id
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<span className="truncate max-w-[120px]">{session.projectName}</span>
<button
onClick={(e) => {
e.stopPropagation();
close(session.id);
}}
className="text-[var(--text-secondary)] hover:text-[var(--error)] transition-colors"
title="Close terminal"
>
×
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useRef } 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";
interface Props {
sessionId: string;
active: boolean;
}
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 { sendInput, resize, onOutput, onExit } = useTerminal();
useEffect(() => {
if (!containerRef.current) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
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
const webLinksAddon = new WebLinksAddon((_event, uri) => {
openUrl(uri).catch((e) => console.error("Failed to open URL:", e));
});
term.loadAddon(webLinksAddon);
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
}
fitAddon.fit();
termRef.current = term;
fitRef.current = fitAddon;
// Send initial size
resize(sessionId, term.cols, term.rows);
// Handle user input -> backend
const inputDisposable = term.onData((data) => {
sendInput(sessionId, data);
});
// Handle backend output -> terminal
let unlistenOutput: (() => void) | null = null;
let unlistenExit: (() => void) | null = null;
onOutput(sessionId, (data) => {
term.write(data);
}).then((unlisten) => {
unlistenOutput = unlisten;
});
onExit(sessionId, () => {
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
}).then((unlisten) => {
unlistenExit = unlisten;
});
// Handle resize
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
resize(sessionId, term.cols, term.rows);
});
resizeObserver.observe(containerRef.current);
return () => {
inputDisposable.dispose();
unlistenOutput?.();
unlistenExit?.();
resizeObserver.disconnect();
term.dispose();
};
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
// Re-fit when tab becomes active
useEffect(() => {
if (active && fitRef.current && termRef.current) {
fitRef.current.fit();
termRef.current.focus();
}
}, [active]);
return (
<div
ref={containerRef}
className={`w-full h-full ${active ? "" : "hidden"}`}
style={{ padding: "4px" }}
/>
);
}