add remote fleet terminal sessions
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
|
||||
Generated
+22
@@ -16,6 +16,12 @@ importers:
|
||||
"@tailwindcss/vite":
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2([email protected](@types/[email protected])([email protected]))
|
||||
"@xterm/addon-fit":
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
"@xterm/xterm":
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -1242,6 +1248,18 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
"@xterm/[email protected]":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==,
|
||||
}
|
||||
|
||||
"@xterm/[email protected]":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==,
|
||||
}
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
@@ -4203,6 +4221,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@xterm/[email protected]": {}
|
||||
|
||||
"@xterm/[email protected]": {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
|
||||
+24
-1
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
setAgentNickname,
|
||||
} from "@/api";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AppLayout } from "@/layout/AppLayout";
|
||||
import { AgentsPage } from "@/pages/AgentsPage";
|
||||
import { AlertsPage } from "@/pages/AlertsPage";
|
||||
@@ -23,6 +24,12 @@ import { CommandsPage } from "@/pages/CommandsPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { DevicesPage } from "@/pages/DevicesPage";
|
||||
import { TokensPage } from "@/pages/TokensPage";
|
||||
|
||||
const TerminalPage = lazy(() =>
|
||||
import("@/pages/TerminalPage").then((module) => ({
|
||||
default: module.TerminalPage,
|
||||
})),
|
||||
);
|
||||
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||
|
||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||
@@ -215,6 +222,22 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="tokens" element={<TokensPage />} />
|
||||
<Route
|
||||
path="terminal"
|
||||
element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<Skeleton className="h-[calc(100dvh-2.5rem)] w-full" />
|
||||
}
|
||||
>
|
||||
<TerminalPage
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -2,6 +2,17 @@ export type Agent = {
|
||||
agent_id: string;
|
||||
connected: boolean;
|
||||
nickname?: string | null;
|
||||
capabilities: "terminal"[];
|
||||
};
|
||||
|
||||
export type TerminalSession = {
|
||||
terminal_id: string;
|
||||
agent_id: string;
|
||||
created_at_unix: number;
|
||||
agent_attached: boolean;
|
||||
operator_attached: boolean;
|
||||
websocket_url: string;
|
||||
attachment_token?: string;
|
||||
};
|
||||
|
||||
export type Alert = {
|
||||
@@ -225,6 +236,34 @@ export function fetchAgents(): Promise<Agent[]> {
|
||||
return request<Agent[]>("/api/v1/control/agents");
|
||||
}
|
||||
|
||||
export function createTerminal(
|
||||
agentId: string,
|
||||
rows: number,
|
||||
cols: number,
|
||||
): Promise<TerminalSession> {
|
||||
return request<TerminalSession>("/api/v1/control/terminals", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ agent_id: agentId, rows, cols }),
|
||||
});
|
||||
}
|
||||
|
||||
export function attachTerminal(terminalId: string): Promise<TerminalSession> {
|
||||
return request<TerminalSession>(
|
||||
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}/attach`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeTerminal(terminalId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to close terminal: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchAlerts(): Promise<Alert[]> {
|
||||
return request<Alert[]>("/api/v1/control/alerts");
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FileText,
|
||||
Key,
|
||||
Terminal,
|
||||
SquareTerminal,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
} from "lucide-react";
|
||||
@@ -57,6 +58,7 @@ const navSections: NavSection[] = [
|
||||
{ to: "/audit", label: "Audit", icon: FileText },
|
||||
{ to: "/tokens", label: "Tokens", icon: Key },
|
||||
{ to: "/commands", label: "Commands", icon: Terminal },
|
||||
{ to: "/terminal", label: "Terminal", icon: SquareTerminal },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { App } from "@/App";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { Eraser, PlugZap, RotateCcw, Square, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
type Agent,
|
||||
type TerminalSession,
|
||||
attachTerminal,
|
||||
closeTerminal,
|
||||
createTerminal,
|
||||
} from "@/api";
|
||||
import { AgentSelector, displayAgentLabel } from "@/components/AgentSelector";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
selectedAgentId: string;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
};
|
||||
|
||||
type ConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "disconnected"
|
||||
| "exited";
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
export function TerminalPage({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
}: Props) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const intentionalCloseRef = useRef(false);
|
||||
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 });
|
||||
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||
const [connection, setConnection] = useState<ConnectionState>("idle");
|
||||
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agent_id === selectedAgentId,
|
||||
);
|
||||
const canStart =
|
||||
selectedAgent?.connected &&
|
||||
selectedAgent.capabilities.includes("terminal") &&
|
||||
connection === "idle";
|
||||
|
||||
const sendResize = useCallback(() => {
|
||||
const terminal = terminalRef.current;
|
||||
const socket = socketRef.current;
|
||||
if (!terminal || socket?.readyState !== WebSocket.OPEN) return;
|
||||
if (
|
||||
lastTerminalSizeRef.current.rows === terminal.rows &&
|
||||
lastTerminalSizeRef.current.cols === terminal.cols
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastTerminalSizeRef.current = {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
};
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(
|
||||
(nextSession: TerminalSession) => {
|
||||
if (!nextSession.attachment_token) {
|
||||
throw new Error(
|
||||
"Control plane did not issue a terminal attachment token",
|
||||
);
|
||||
}
|
||||
socketRef.current?.close();
|
||||
intentionalCloseRef.current = false;
|
||||
setConnection("connecting");
|
||||
const socket = new WebSocket(websocketUrl(nextSession.websocket_url));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "attach",
|
||||
attachment_token: nextSession.attachment_token,
|
||||
}),
|
||||
);
|
||||
fitRef.current?.fit();
|
||||
sendResize();
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== "string") {
|
||||
terminalRef.current?.write(new Uint8Array(event.data as ArrayBuffer));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const control = JSON.parse(event.data) as {
|
||||
type: string;
|
||||
exit_code?: number | null;
|
||||
message?: string;
|
||||
};
|
||||
if (control.type === "ready") {
|
||||
setConnection("ready");
|
||||
window.requestAnimationFrame(() => {
|
||||
fitRef.current?.fit();
|
||||
lastTerminalSizeRef.current = { rows: 0, cols: 0 };
|
||||
sendResize();
|
||||
terminalRef.current?.focus();
|
||||
});
|
||||
} else if (control.type === "exited") {
|
||||
setConnection("exited");
|
||||
terminalRef.current?.writeln(
|
||||
`\r\n[process exited${control.exit_code == null ? "" : ` ${control.exit_code}`}]`,
|
||||
);
|
||||
} else if (control.type === "error") {
|
||||
setConnection("exited");
|
||||
terminalRef.current?.writeln(
|
||||
`\r\n[terminal error: ${control.message ?? "unknown error"}]`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
terminalRef.current?.writeln("\r\n[invalid terminal control frame]");
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
terminalRef.current?.writeln("\r\n[terminal transport error]");
|
||||
};
|
||||
socket.onclose = () => {
|
||||
socketRef.current = null;
|
||||
setConnection((current) => {
|
||||
if (intentionalCloseRef.current || current === "exited")
|
||||
return current;
|
||||
return "disconnected";
|
||||
});
|
||||
};
|
||||
},
|
||||
[sendResize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return;
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: false,
|
||||
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.1,
|
||||
scrollback: 5000,
|
||||
theme: {
|
||||
background: "#0b1117",
|
||||
foreground: "#e5e7eb",
|
||||
cursor: "#6ee7a8",
|
||||
},
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
terminal.loadAddon(fit);
|
||||
terminal.open(hostRef.current);
|
||||
fit.fit();
|
||||
terminalRef.current = terminal;
|
||||
fitRef.current = fit;
|
||||
|
||||
let cancelled = false;
|
||||
void document.fonts.ready.then(() => {
|
||||
if (cancelled) return;
|
||||
fit.fit();
|
||||
lastTerminalSizeRef.current = { rows: 0, cols: 0 };
|
||||
sendResize();
|
||||
});
|
||||
|
||||
const input = terminal.onData((data) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(new TextEncoder().encode(data));
|
||||
}
|
||||
});
|
||||
let resizeFrame = 0;
|
||||
let lastHostWidth = 0;
|
||||
let lastHostHeight = 0;
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const width = Math.round(entry.contentRect.width);
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
if (width === lastHostWidth && height === lastHostHeight) return;
|
||||
lastHostWidth = width;
|
||||
lastHostHeight = height;
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
fit.fit();
|
||||
sendResize();
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(hostRef.current);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
input.dispose();
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
socketRef.current?.close();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
};
|
||||
}, [sendResize]);
|
||||
|
||||
async function start() {
|
||||
if (!selectedAgentId || !terminalRef.current) return;
|
||||
setConnection("connecting");
|
||||
terminalRef.current.clear();
|
||||
try {
|
||||
fitRef.current?.fit();
|
||||
const created = await createTerminal(
|
||||
selectedAgentId,
|
||||
terminalRef.current.rows,
|
||||
terminalRef.current.cols,
|
||||
);
|
||||
setSession(created);
|
||||
connect(created);
|
||||
} catch (error) {
|
||||
setConnection("idle");
|
||||
toast.error("Could not start terminal", { description: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnect() {
|
||||
if (!session) return;
|
||||
try {
|
||||
const attached = await attachTerminal(session.terminal_id);
|
||||
setSession(attached);
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
setConnection("exited");
|
||||
toast.error("Terminal session is no longer available", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (!session) return;
|
||||
intentionalCloseRef.current = true;
|
||||
socketRef.current?.send(JSON.stringify({ type: "close" }));
|
||||
socketRef.current?.close();
|
||||
try {
|
||||
await closeTerminal(session.terminal_id);
|
||||
} catch (error) {
|
||||
toast.error("Terminal cleanup failed", { description: String(error) });
|
||||
} finally {
|
||||
setSession(null);
|
||||
setConnection("idle");
|
||||
terminalRef.current?.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="terminal-page" aria-label="Remote terminal">
|
||||
<header className="terminal-toolbar">
|
||||
<div className="terminal-heading">
|
||||
<Terminal className="size-5 text-primary" aria-hidden />
|
||||
<h1>Remote Terminal</h1>
|
||||
</div>
|
||||
|
||||
<div className="terminal-controls">
|
||||
<AgentSelector
|
||||
agents={agents.filter(
|
||||
(agent) =>
|
||||
agent.connected && agent.capabilities.includes("terminal"),
|
||||
)}
|
||||
value={selectedAgentId}
|
||||
onChange={onSelectAgent}
|
||||
disabled={connection !== "idle"}
|
||||
className="w-full min-w-0 sm:w-64"
|
||||
/>
|
||||
{connection === "idle" ? (
|
||||
<Button type="button" onClick={start} disabled={!canStart}>
|
||||
<PlugZap className="size-4" aria-hidden />
|
||||
Connect
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!selectedAgent && connection === "idle" ? (
|
||||
<p className="terminal-notice">
|
||||
No connected terminal-capable agent is selected.
|
||||
</p>
|
||||
) : selectedAgent && !selectedAgent.capabilities.includes("terminal") ? (
|
||||
<p className="terminal-notice">
|
||||
This agent has not enabled remote terminal capability.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="terminal-frame">
|
||||
<div className="terminal-framebar">
|
||||
<div className="terminal-session-label">
|
||||
<Terminal className="size-4" aria-hidden />
|
||||
<span>
|
||||
{session && selectedAgent
|
||||
? displayAgentLabel(selectedAgent)
|
||||
: "No active session"}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="terminal-status"
|
||||
data-state={connection}
|
||||
>
|
||||
<span aria-hidden />
|
||||
{connection}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="terminal-frame-actions">
|
||||
{connection === "disconnected" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={reconnect}
|
||||
>
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={connection === "idle"}
|
||||
aria-label="Clear terminal"
|
||||
onClick={() => terminalRef.current?.clear()}
|
||||
>
|
||||
<Eraser className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear terminal</TooltipContent>
|
||||
</Tooltip>
|
||||
{session ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
aria-label="Close terminal session"
|
||||
onClick={close}
|
||||
>
|
||||
<Square className="size-3.5 fill-current" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Close session</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="terminal-surface" ref={hostRef} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -192,6 +192,12 @@
|
||||
transition: background 100ms ease-in-out;
|
||||
}
|
||||
|
||||
/* xterm focuses an input near the active row. Keep that focus operation from
|
||||
scrolling the page shell and moving the terminal toolbar off-screen. */
|
||||
.app-main:has(.terminal-page) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-brand {
|
||||
@@ -316,6 +322,172 @@
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Remote terminal ─────────────────────────────────────────────────── */
|
||||
|
||||
.terminal-page {
|
||||
display: flex;
|
||||
height: calc(100dvh - 2.5rem);
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.terminal-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.terminal-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.terminal-status {
|
||||
gap: 0.4rem;
|
||||
height: 1.4rem;
|
||||
padding-inline: 0.45rem;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.terminal-status > span {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--presence-unknown);
|
||||
}
|
||||
|
||||
.terminal-status[data-state="ready"] > span {
|
||||
background: var(--presence-online);
|
||||
}
|
||||
|
||||
.terminal-status[data-state="connecting"] > span {
|
||||
background: var(--presence-likely);
|
||||
}
|
||||
|
||||
.terminal-status[data-state="disconnected"] > span,
|
||||
.terminal-status[data-state="exited"] > span {
|
||||
background: var(--presence-offline);
|
||||
}
|
||||
|
||||
.terminal-notice {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.terminal-frame {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #0b1117;
|
||||
}
|
||||
|
||||
.terminal-framebar {
|
||||
display: flex;
|
||||
min-height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.35rem 0.45rem 0.35rem 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in oklab, white 9%, transparent);
|
||||
background: #101820;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.terminal-session-label,
|
||||
.terminal-frame-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-session-label > span:not(.terminal-status) {
|
||||
overflow: hidden;
|
||||
color: #dbe4ee;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 550;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-framebar button {
|
||||
color: #aab7c4;
|
||||
}
|
||||
|
||||
.terminal-framebar button:hover {
|
||||
color: #f1f5f9;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.terminal-surface {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #0b1117;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #0b1117;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm-viewport {
|
||||
/* xterm defaults this element to pure black. It remains visible around the
|
||||
fitted canvas, so keep it on the same surface as the terminal theme. */
|
||||
background-color: #0b1117;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-color: color-mix(in oklab, white 28%, transparent) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.terminal-page {
|
||||
height: calc(100dvh - 2.5rem);
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.terminal-surface {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Presence badges ──────────────────────────────────────────────────── */
|
||||
|
||||
.presence-dot {
|
||||
|
||||
Reference in New Issue
Block a user