Compare commits
4
Commits
70592cd2b8
...
301acca6da
No files matched your search
Generated
+6
-6
@@ -3624,7 +3624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -3645,7 +3645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3670,7 +3670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3699,7 +3699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-core"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"macaddr",
|
||||
"serde",
|
||||
@@ -3711,7 +3711,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-linux"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -3728,7 +3728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-vt100"
|
||||
version = "0.16.2-wakey.1"
|
||||
version = "0.16.2-wakey.2"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"unicode-width",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
+4
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
|
||||
/>
|
||||
<title>Wakey Operator UI</title>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
+297
-198
@@ -6,7 +6,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { Eraser, Plus, RotateCcw, Terminal, X } from "lucide-react";
|
||||
import { Eraser, Keyboard, RotateCcw, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -26,6 +26,23 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
MIN_TERMINAL_FONT_SIZE,
|
||||
TerminalAccessoryBar,
|
||||
} from "@/pages/terminal/TerminalAccessoryBar";
|
||||
import { TerminalSessionTabs } from "@/pages/terminal/TerminalSessionTabs";
|
||||
import {
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
applyTerminalModifiers,
|
||||
mergeTerminalSession,
|
||||
orderTerminalSessions,
|
||||
reconcileTerminalSessions,
|
||||
restoreCandidates,
|
||||
visibleTerminalText,
|
||||
type TerminalConnectionState,
|
||||
type TerminalModifiers,
|
||||
} from "@/pages/terminal/sessionUtils";
|
||||
import { loadTerminalFontFamily } from "@/terminal/terminalFonts";
|
||||
|
||||
type Props = {
|
||||
@@ -34,15 +51,17 @@ type Props = {
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
};
|
||||
|
||||
type ConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "disconnected"
|
||||
| "exited";
|
||||
|
||||
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
||||
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
||||
const DEFAULT_TERMINAL_FONT_SIZE = 14;
|
||||
const TERMINAL_TOUCH_SLOP_PX = 8;
|
||||
|
||||
type TerminalTouch = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
// polyfill for testing in browsers that don't support crypto.randomUUID()
|
||||
const thing = () =>
|
||||
@@ -65,88 +84,17 @@ function terminalOperatorId(): string {
|
||||
return created;
|
||||
}
|
||||
|
||||
function mergeTerminalSession(
|
||||
sessions: TerminalSession[],
|
||||
next: TerminalSession,
|
||||
): TerminalSession[] {
|
||||
const existingIndex = sessions.findIndex(
|
||||
(item) => item.terminal_id === next.terminal_id,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
// Attaching updates session state, not identity or tab position. Removing
|
||||
// and re-inserting here made same-second sessions swap places on click.
|
||||
return sessions.map((item, index) =>
|
||||
index === existingIndex ? next : item,
|
||||
);
|
||||
}
|
||||
return orderTerminalSessions([...sessions, next]);
|
||||
}
|
||||
|
||||
function compareTerminalSessions(
|
||||
left: TerminalSession,
|
||||
right: TerminalSession,
|
||||
): number {
|
||||
return (
|
||||
left.created_at_unix - right.created_at_unix ||
|
||||
left.terminal_id.localeCompare(right.terminal_id)
|
||||
);
|
||||
}
|
||||
|
||||
function orderTerminalSessions(sessions: TerminalSession[]): TerminalSession[] {
|
||||
return [...sessions].sort(compareTerminalSessions);
|
||||
}
|
||||
|
||||
function reconcileTerminalSessions(
|
||||
current: TerminalSession[],
|
||||
listed: TerminalSession[],
|
||||
): TerminalSession[] {
|
||||
const listedById = new Map(listed.map((item) => [item.terminal_id, item]));
|
||||
const retained = current.flatMap((item) => {
|
||||
const updated = listedById.get(item.terminal_id);
|
||||
if (!updated) return [];
|
||||
listedById.delete(item.terminal_id);
|
||||
return [updated];
|
||||
});
|
||||
return [...retained, ...orderTerminalSessions([...listedById.values()])];
|
||||
}
|
||||
|
||||
function restoreCandidates(
|
||||
sessions: TerminalSession[],
|
||||
rememberedId: string | null,
|
||||
selectedAgentId: string,
|
||||
): TerminalSession[] {
|
||||
const remembered = sessions.find((item) => item.terminal_id === rememberedId);
|
||||
const available = sessions.filter(
|
||||
(item) => !item.operator_attached && item !== remembered,
|
||||
);
|
||||
available.sort((left, right) => {
|
||||
const leftPreferred = left.agent_id === selectedAgentId ? 1 : 0;
|
||||
const rightPreferred = right.agent_id === selectedAgentId ? 1 : 0;
|
||||
return (
|
||||
rightPreferred - leftPreferred ||
|
||||
right.created_at_unix - left.created_at_unix
|
||||
);
|
||||
});
|
||||
return remembered ? [remembered, ...available] : available;
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
function terminalCreatedTime(createdAtUnix: number): string {
|
||||
return new Date(createdAtUnix * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function TerminalPage({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
}: Props) {
|
||||
const pageRef = useRef<HTMLElement>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
@@ -154,15 +102,36 @@ export function TerminalPage({
|
||||
const activeSessionRef = useRef<TerminalSession | null>(null);
|
||||
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 });
|
||||
const modifiersRef = useRef<TerminalModifiers>(NO_TERMINAL_MODIFIERS);
|
||||
const terminalTouchRef = useRef<TerminalTouch | null>(null);
|
||||
const terminalInputFocusedBeforeAccessoryRef = useRef(false);
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||
const [connection, setConnection] = useState<ConnectionState>("idle");
|
||||
const [connection, setConnection] = useState<TerminalConnectionState>("idle");
|
||||
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [terminalFontFamily, setTerminalFontFamily] = useState<string>();
|
||||
const [terminalFontSize, setTerminalFontSize] = useState(
|
||||
DEFAULT_TERMINAL_FONT_SIZE,
|
||||
);
|
||||
const [nowUnix, setNowUnix] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [modifiers, setModifiers] = useState<TerminalModifiers>(
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
);
|
||||
const [accessoriesOpen, setAccessoriesOpen] = useState(
|
||||
() => window.matchMedia("(pointer: coarse), (max-width: 640px)").matches,
|
||||
);
|
||||
const [operatorId] = useState(terminalOperatorId);
|
||||
|
||||
useEffect(() => {
|
||||
const clock = window.setInterval(
|
||||
() => setNowUnix(Math.floor(Date.now() / 1000)),
|
||||
30_000,
|
||||
);
|
||||
return () => window.clearInterval(clock);
|
||||
}, []);
|
||||
|
||||
activeSessionRef.current = session;
|
||||
selectedAgentIdRef.current = selectedAgentId;
|
||||
|
||||
@@ -178,10 +147,12 @@ export function TerminalPage({
|
||||
);
|
||||
const agentAtSessionLimit =
|
||||
selectedAgentSessionCount >= selectedAgentSessionLimit;
|
||||
const canRequestStart =
|
||||
const canRequestStart = Boolean(
|
||||
selectedAgent?.connected &&
|
||||
selectedAgent.capabilities.includes("terminal") &&
|
||||
connection !== "connecting";
|
||||
connection !== "connecting",
|
||||
);
|
||||
const terminalInputReady = connection === "ready";
|
||||
|
||||
const detachTransport = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
@@ -189,6 +160,101 @@ export function TerminalPage({
|
||||
socket?.close();
|
||||
}, []);
|
||||
|
||||
const writeTerminalData = useCallback(
|
||||
(data: string, consumeModifiers = false) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
const activeModifiers = modifiersRef.current;
|
||||
const output = consumeModifiers
|
||||
? applyTerminalModifiers(data, activeModifiers)
|
||||
: data;
|
||||
socket.send(new TextEncoder().encode(output));
|
||||
if (consumeModifiers && (activeModifiers.ctrl || activeModifiers.meta)) {
|
||||
modifiersRef.current = NO_TERMINAL_MODIFIERS;
|
||||
setModifiers(NO_TERMINAL_MODIFIERS);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleModifier = useCallback((modifier: keyof TerminalModifiers) => {
|
||||
setModifiers((current) => {
|
||||
const next = { ...current, [modifier]: !current[modifier] };
|
||||
modifiersRef.current = next;
|
||||
return next;
|
||||
});
|
||||
window.requestAnimationFrame(() => terminalRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const copyTerminalText = useCallback(async () => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const text = terminal.hasSelection()
|
||||
? terminal.getSelection()
|
||||
: visibleTerminalText(terminal);
|
||||
if (!text) {
|
||||
toast.info("Nothing to copy");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
terminal.hasSelection() ? "Selection copied" : "Visible screen copied",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pasteTerminalText = useCallback(async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) writeTerminalData(text);
|
||||
terminalRef.current?.focus();
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, [writeTerminalData]);
|
||||
|
||||
const writeAccessoryKey = useCallback(
|
||||
(data: string) => {
|
||||
writeTerminalData(data);
|
||||
window.requestAnimationFrame(() => terminalRef.current?.focus());
|
||||
},
|
||||
[writeTerminalData],
|
||||
);
|
||||
|
||||
const toggleTerminalKeyboard = useCallback(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const blurTerminalInput = () => {
|
||||
terminal.textarea?.blur();
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLElement &&
|
||||
hostRef.current?.contains(activeElement)
|
||||
) {
|
||||
activeElement.blur();
|
||||
}
|
||||
};
|
||||
const shouldHide =
|
||||
terminalInputFocusedBeforeAccessoryRef.current ||
|
||||
Boolean(hostRef.current?.contains(document.activeElement));
|
||||
terminalInputFocusedBeforeAccessoryRef.current = false;
|
||||
if (shouldHide) {
|
||||
blurTerminalInput();
|
||||
// Base UI may restore the previously focused element as its pointer
|
||||
// interaction completes. Dismiss the soft keyboard after that cycle too.
|
||||
window.requestAnimationFrame(blurTerminalInput);
|
||||
} else {
|
||||
terminal.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendResize = useCallback(() => {
|
||||
const terminal = terminalRef.current;
|
||||
const socket = socketRef.current;
|
||||
@@ -212,6 +278,26 @@ export function TerminalPage({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const changeTerminalFontSize = useCallback(
|
||||
(delta: number) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const current = terminal.options.fontSize ?? DEFAULT_TERMINAL_FONT_SIZE;
|
||||
const next = Math.min(
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
Math.max(MIN_TERMINAL_FONT_SIZE, current + delta),
|
||||
);
|
||||
if (next === current) return;
|
||||
terminal.options.fontSize = next;
|
||||
setTerminalFontSize(next);
|
||||
window.requestAnimationFrame(() => {
|
||||
fitRef.current?.fit();
|
||||
sendResize();
|
||||
});
|
||||
},
|
||||
[sendResize],
|
||||
);
|
||||
|
||||
const connect = useCallback(
|
||||
(nextSession: TerminalSession) => {
|
||||
if (!nextSession.attachment_token) {
|
||||
@@ -316,13 +402,33 @@ export function TerminalPage({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
const updateViewportHeight = () => {
|
||||
const height = Math.round(viewport?.height ?? window.innerHeight);
|
||||
pageRef.current?.style.setProperty(
|
||||
"--terminal-visual-viewport-height",
|
||||
`${height}px`,
|
||||
);
|
||||
};
|
||||
updateViewportHeight();
|
||||
viewport?.addEventListener("resize", updateViewportHeight);
|
||||
viewport?.addEventListener("scroll", updateViewportHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", updateViewportHeight);
|
||||
viewport?.removeEventListener("scroll", updateViewportHeight);
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current || !terminalFontFamily) return;
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: false,
|
||||
fontFamily: terminalFontFamily,
|
||||
fontSize: 14,
|
||||
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
|
||||
lineHeight: 1.1,
|
||||
// xterm uses this width for its internal scrollbar as well as the
|
||||
// overview ruler. Reserve a larger touch target on coarse pointers.
|
||||
@@ -448,12 +554,7 @@ export function TerminalPage({
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const input = terminal.onData((data) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(new TextEncoder().encode(data));
|
||||
}
|
||||
});
|
||||
const input = terminal.onData((data) => writeTerminalData(data, true));
|
||||
let resizeFrame = 0;
|
||||
let lastHostWidth = 0;
|
||||
let lastHostHeight = 0;
|
||||
@@ -498,7 +599,13 @@ export function TerminalPage({
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
};
|
||||
}, [connect, detachTransport, sendResize, terminalFontFamily]);
|
||||
}, [
|
||||
connect,
|
||||
detachTransport,
|
||||
sendResize,
|
||||
terminalFontFamily,
|
||||
writeTerminalData,
|
||||
]);
|
||||
|
||||
async function start() {
|
||||
if (!selectedAgentId || !terminalRef.current) return;
|
||||
@@ -644,7 +751,11 @@ export function TerminalPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="terminal-page" aria-label="Remote terminal">
|
||||
<section
|
||||
className="terminal-page"
|
||||
aria-label="Remote terminal"
|
||||
ref={pageRef}
|
||||
>
|
||||
<header className="terminal-toolbar">
|
||||
<div className="terminal-heading">
|
||||
<Terminal className="size-5 text-primary" aria-hidden />
|
||||
@@ -677,112 +788,20 @@ export function TerminalPage({
|
||||
|
||||
<div className="terminal-frame">
|
||||
<div className="terminal-framebar">
|
||||
<div className="terminal-tabs">
|
||||
<div
|
||||
className="terminal-tab-list"
|
||||
role="tablist"
|
||||
aria-label="Terminal sessions"
|
||||
>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="terminal-session-label">
|
||||
<Terminal className="size-4" aria-hidden />
|
||||
<span>No active session</span>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((item) => {
|
||||
const agent = agents.find(
|
||||
(candidate) => candidate.agent_id === item.agent_id,
|
||||
);
|
||||
const active = item.terminal_id === session?.terminal_id;
|
||||
const locked = item.operator_attached && !active;
|
||||
const agentLabel = agent
|
||||
? displayAgentLabel(agent)
|
||||
: item.agent_id;
|
||||
const sessionTitle = sessionTitles[item.terminal_id];
|
||||
const tabLabel = sessionTitle
|
||||
? `${agentLabel} · ${sessionTitle}`
|
||||
: agentLabel;
|
||||
return (
|
||||
<div
|
||||
key={item.terminal_id}
|
||||
role="presentation"
|
||||
className="terminal-tab"
|
||||
data-active={active || undefined}
|
||||
data-locked={locked || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className="terminal-tab-select"
|
||||
disabled={locked || connection === "connecting"}
|
||||
title={
|
||||
locked ? "Attached in another browser" : tabLabel
|
||||
}
|
||||
onClick={() => void activateSession(item)}
|
||||
>
|
||||
<span
|
||||
className="terminal-tab-dot"
|
||||
data-state={
|
||||
active
|
||||
? connection
|
||||
: locked
|
||||
? "attached"
|
||||
: item.agent_attached
|
||||
? "detached"
|
||||
: "disconnected"
|
||||
}
|
||||
aria-hidden
|
||||
<TerminalSessionTabs
|
||||
agents={agents}
|
||||
sessions={sessions}
|
||||
activeTerminalId={session?.terminal_id}
|
||||
connection={connection}
|
||||
sessionTitles={sessionTitles}
|
||||
nowUnix={nowUnix}
|
||||
canRequestStart={canRequestStart}
|
||||
agentAtSessionLimit={agentAtSessionLimit}
|
||||
selectedAgentSessionLimit={selectedAgentSessionLimit}
|
||||
onActivate={(item) => void activateSession(item)}
|
||||
onClose={(item) => void closeSession(item)}
|
||||
onStart={() => void start()}
|
||||
/>
|
||||
<span className="terminal-tab-label">{tabLabel}</span>
|
||||
<time
|
||||
dateTime={new Date(
|
||||
item.created_at_unix * 1000,
|
||||
).toISOString()}
|
||||
>
|
||||
{terminalCreatedTime(item.created_at_unix)}
|
||||
</time>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-tab-close"
|
||||
disabled={locked}
|
||||
aria-label={`Close ${agentLabel} terminal session`}
|
||||
title={
|
||||
locked
|
||||
? "Attached in another browser"
|
||||
: "Close session"
|
||||
}
|
||||
onClick={() => void closeSession(item)}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-new-tab"
|
||||
disabled={!canRequestStart}
|
||||
aria-label="New terminal session"
|
||||
onClick={start}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{agentAtSessionLimit
|
||||
? `${selectedAgentSessionLimit}-session limit reached`
|
||||
: "New session"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="terminal-frame-actions">
|
||||
<Badge
|
||||
@@ -804,6 +823,27 @@ export function TerminalPage({
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-accessories-toggle"
|
||||
data-active={accessoriesOpen || undefined}
|
||||
aria-label={
|
||||
accessoriesOpen
|
||||
? "Hide terminal controls"
|
||||
: "Show terminal controls"
|
||||
}
|
||||
aria-expanded={accessoriesOpen}
|
||||
onClick={() => setAccessoriesOpen((current) => !current)}
|
||||
>
|
||||
<Keyboard className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Terminal controls</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
@@ -821,7 +861,66 @@ export function TerminalPage({
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="terminal-surface" ref={hostRef} />
|
||||
{accessoriesOpen ? (
|
||||
<TerminalAccessoryBar
|
||||
inputReady={terminalInputReady}
|
||||
modifiers={modifiers}
|
||||
fontSize={terminalFontSize}
|
||||
onRememberInputFocus={() => {
|
||||
terminalInputFocusedBeforeAccessoryRef.current = Boolean(
|
||||
hostRef.current?.contains(document.activeElement),
|
||||
);
|
||||
}}
|
||||
onToggleModifier={toggleModifier}
|
||||
onWriteKey={writeAccessoryKey}
|
||||
onScrollPages={(pages) => terminalRef.current?.scrollPages(pages)}
|
||||
onCopy={() => void copyTerminalText()}
|
||||
onPaste={() => void pasteTerminalText()}
|
||||
onToggleKeyboard={toggleTerminalKeyboard}
|
||||
onChangeFontSize={changeTerminalFontSize}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="terminal-surface"
|
||||
ref={hostRef}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== "touch") {
|
||||
terminalRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
terminalTouchRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
moved: false,
|
||||
};
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const touch = terminalTouchRef.current;
|
||||
if (!touch || touch.pointerId !== event.pointerId || touch.moved) {
|
||||
return;
|
||||
}
|
||||
touch.moved =
|
||||
Math.hypot(
|
||||
event.clientX - touch.startX,
|
||||
event.clientY - touch.startY,
|
||||
) > TERMINAL_TOUCH_SLOP_PX;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const touch = terminalTouchRef.current;
|
||||
terminalTouchRef.current = null;
|
||||
if (
|
||||
touch?.pointerId === event.pointerId &&
|
||||
!touch.moved &&
|
||||
event.pointerType === "touch"
|
||||
) {
|
||||
terminalRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
terminalTouchRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ClipboardCopy,
|
||||
ClipboardPaste,
|
||||
Keyboard,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { MouseEvent, PointerEvent } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { TerminalModifiers } from "@/pages/terminal/sessionUtils";
|
||||
|
||||
export const MIN_TERMINAL_FONT_SIZE = 10;
|
||||
export const MAX_TERMINAL_FONT_SIZE = 22;
|
||||
|
||||
type Props = {
|
||||
inputReady: boolean;
|
||||
modifiers: TerminalModifiers;
|
||||
fontSize: number;
|
||||
onRememberInputFocus: () => void;
|
||||
onToggleModifier: (modifier: keyof TerminalModifiers) => void;
|
||||
onWriteKey: (data: string) => void;
|
||||
onScrollPages: (pages: number) => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onToggleKeyboard: () => void;
|
||||
onChangeFontSize: (delta: number) => void;
|
||||
};
|
||||
|
||||
const CONTROL_KEYS: ReadonlyArray<
|
||||
readonly [label: string, sequence: string, title: string]
|
||||
> = [
|
||||
["^C", "\x03", "Interrupt"],
|
||||
["^U", "\x15", "Clear input line"],
|
||||
["^A", "\x01", "Start of line"],
|
||||
["^E", "\x05", "End of line"],
|
||||
];
|
||||
|
||||
const ARROW_KEYS: ReadonlyArray<
|
||||
readonly [label: string, sequence: string, icon: LucideIcon]
|
||||
> = [
|
||||
["Left arrow", "\x1b[D", ArrowLeft],
|
||||
["Down arrow", "\x1b[B", ArrowDown],
|
||||
["Up arrow", "\x1b[A", ArrowUp],
|
||||
["Right arrow", "\x1b[C", ArrowRight],
|
||||
];
|
||||
|
||||
function preserveInputFocus(
|
||||
event: PointerEvent<HTMLDivElement> | MouseEvent<HTMLDivElement>,
|
||||
onRememberInputFocus: () => void,
|
||||
) {
|
||||
if (event.target instanceof Element && event.target.closest("button")) {
|
||||
onRememberInputFocus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function TerminalAccessoryBar({
|
||||
inputReady,
|
||||
modifiers,
|
||||
fontSize,
|
||||
onRememberInputFocus,
|
||||
onToggleModifier,
|
||||
onWriteKey,
|
||||
onScrollPages,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onToggleKeyboard,
|
||||
onChangeFontSize,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
className="terminal-accessories"
|
||||
role="toolbar"
|
||||
aria-label="Terminal controls"
|
||||
onPointerDownCapture={(event) => {
|
||||
// Preserve whether xterm was focused before the button library moves
|
||||
// focus. The keyboard button consumes this to show or hide mobile IME.
|
||||
preserveInputFocus(event, onRememberInputFocus);
|
||||
}}
|
||||
onMouseDownCapture={(event) => {
|
||||
// Firefox can take the mouse-event path without emitting pointerdown.
|
||||
preserveInputFocus(event, onRememberInputFocus);
|
||||
}}
|
||||
>
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
data-active={modifiers.ctrl || undefined}
|
||||
aria-pressed={modifiers.ctrl}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onToggleModifier("ctrl")}
|
||||
>
|
||||
Ctrl
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
data-active={modifiers.meta || undefined}
|
||||
aria-pressed={modifiers.meta}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onToggleModifier("meta")}
|
||||
>
|
||||
Meta
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey("\x1b")}
|
||||
>
|
||||
Esc
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey("\t")}
|
||||
>
|
||||
Tab
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
{CONTROL_KEYS.map(([label, sequence, title]) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--control"
|
||||
aria-label={title}
|
||||
title={title}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey(sequence)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
{ARROW_KEYS.map(([label, sequence, Icon]) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label={label}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey(sequence)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--wide"
|
||||
onClick={() => onScrollPages(-1)}
|
||||
>
|
||||
PgUp
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--wide"
|
||||
onClick={() => onScrollPages(1)}
|
||||
>
|
||||
PgDn
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Copy selection or visible screen"
|
||||
title="Copy selection or visible screen"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<ClipboardCopy className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Paste"
|
||||
title="Paste"
|
||||
disabled={!inputReady}
|
||||
onClick={onPaste}
|
||||
>
|
||||
<ClipboardPaste className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Toggle terminal keyboard"
|
||||
title="Toggle terminal keyboard"
|
||||
disabled={!inputReady}
|
||||
onClick={onToggleKeyboard}
|
||||
>
|
||||
<Keyboard className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group terminal-font-controls">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
|
||||
aria-label="Decrease terminal text size"
|
||||
onClick={() => onChangeFontSize(-1)}
|
||||
>
|
||||
<ZoomOut className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<output aria-label="Terminal text size">{fontSize}</output>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
|
||||
aria-label="Increase terminal text size"
|
||||
onClick={() => onChangeFontSize(1)}
|
||||
>
|
||||
<ZoomIn className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Plus, Terminal, X } from "lucide-react";
|
||||
|
||||
import type { Agent, TerminalSession } from "@/api";
|
||||
import { displayAgentLabel } from "@/components/AgentSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { TerminalConnectionState } from "@/pages/terminal/sessionUtils";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
sessions: TerminalSession[];
|
||||
activeTerminalId?: string;
|
||||
connection: TerminalConnectionState;
|
||||
sessionTitles: Record<string, string>;
|
||||
nowUnix: number;
|
||||
canRequestStart: boolean;
|
||||
agentAtSessionLimit: boolean;
|
||||
selectedAgentSessionLimit: number;
|
||||
onActivate: (session: TerminalSession) => void;
|
||||
onClose: (session: TerminalSession) => void;
|
||||
onStart: () => void;
|
||||
};
|
||||
|
||||
function terminalCreatedTime(createdAtUnix: number): string {
|
||||
return new Date(createdAtUnix * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function terminalTimestamp(timestampUnix: number): string {
|
||||
return new Date(timestampUnix * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function terminalExpiryDisplay(
|
||||
expiresAtUnix: number | null | undefined,
|
||||
nowUnix: number,
|
||||
): { label: string; warning: boolean } | null {
|
||||
if (expiresAtUnix == null) return null;
|
||||
const remainingSeconds = expiresAtUnix - nowUnix;
|
||||
if (remainingSeconds <= 0) return { label: "expired", warning: true };
|
||||
if (remainingSeconds < 60) return { label: "<1m left", warning: true };
|
||||
if (remainingSeconds < 3600) {
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / 60)}m left`,
|
||||
warning: remainingSeconds < 10 * 60,
|
||||
};
|
||||
}
|
||||
if (remainingSeconds < 24 * 3600) {
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / 3600)}h left`,
|
||||
warning: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / (24 * 3600))}d left`,
|
||||
warning: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function TerminalSessionTabs({
|
||||
agents,
|
||||
sessions,
|
||||
activeTerminalId,
|
||||
connection,
|
||||
sessionTitles,
|
||||
nowUnix,
|
||||
canRequestStart,
|
||||
agentAtSessionLimit,
|
||||
selectedAgentSessionLimit,
|
||||
onActivate,
|
||||
onClose,
|
||||
onStart,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="terminal-tabs">
|
||||
<div
|
||||
className="terminal-tab-list"
|
||||
role="tablist"
|
||||
aria-label="Terminal sessions"
|
||||
>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="terminal-session-label">
|
||||
<Terminal className="size-4" aria-hidden />
|
||||
<span>No active session</span>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((item) => {
|
||||
const agent = agents.find(
|
||||
(candidate) => candidate.agent_id === item.agent_id,
|
||||
);
|
||||
const active = item.terminal_id === activeTerminalId;
|
||||
const locked = item.operator_attached && !active;
|
||||
const agentLabel = agent ? displayAgentLabel(agent) : item.agent_id;
|
||||
const sessionTitle = sessionTitles[item.terminal_id];
|
||||
const tabLabel = sessionTitle
|
||||
? `${agentLabel} · ${sessionTitle}`
|
||||
: agentLabel;
|
||||
const expiry = terminalExpiryDisplay(item.expires_at_unix, nowUnix);
|
||||
const timingTitle =
|
||||
item.expires_at_unix != null
|
||||
? `Expires ${terminalTimestamp(item.expires_at_unix)}`
|
||||
: "No expiry";
|
||||
return (
|
||||
<div
|
||||
key={item.terminal_id}
|
||||
role="presentation"
|
||||
className="terminal-tab"
|
||||
data-active={active || undefined}
|
||||
data-locked={locked || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className="terminal-tab-select"
|
||||
disabled={locked || connection === "connecting"}
|
||||
title={`${locked ? "Attached in another browser\n" : ""}${tabLabel}\nCreated ${terminalTimestamp(item.created_at_unix)}\n${timingTitle}`}
|
||||
onClick={() => onActivate(item)}
|
||||
>
|
||||
<span
|
||||
className="terminal-tab-dot"
|
||||
data-state={
|
||||
active
|
||||
? connection
|
||||
: locked
|
||||
? "attached"
|
||||
: item.agent_attached
|
||||
? "detached"
|
||||
: "disconnected"
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="terminal-tab-label">{tabLabel}</span>
|
||||
<time
|
||||
className={
|
||||
expiry?.warning ? "text-amber-400" : "text-[#7f8d9a]"
|
||||
}
|
||||
dateTime={new Date(
|
||||
(item.expires_at_unix ?? item.created_at_unix) * 1000,
|
||||
).toISOString()}
|
||||
>
|
||||
{expiry?.label ?? terminalCreatedTime(item.created_at_unix)}
|
||||
</time>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-tab-close"
|
||||
disabled={locked}
|
||||
aria-label={`Close ${agentLabel} terminal session`}
|
||||
title={
|
||||
locked ? "Attached in another browser" : "Close session"
|
||||
}
|
||||
onClick={() => onClose(item)}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-new-tab"
|
||||
disabled={!canRequestStart}
|
||||
aria-label="New terminal session"
|
||||
onClick={onStart}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{agentAtSessionLimit
|
||||
? `${selectedAgentSessionLimit}-session limit reached`
|
||||
: "New session"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import type { TerminalSession } from "@/api";
|
||||
|
||||
export type TerminalConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "disconnected"
|
||||
| "exited";
|
||||
|
||||
export type TerminalModifiers = {
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
};
|
||||
|
||||
export const NO_TERMINAL_MODIFIERS: TerminalModifiers = {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
};
|
||||
|
||||
export function mergeTerminalSession(
|
||||
sessions: TerminalSession[],
|
||||
next: TerminalSession,
|
||||
): TerminalSession[] {
|
||||
const existingIndex = sessions.findIndex(
|
||||
(item) => item.terminal_id === next.terminal_id,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
// Attaching updates session state, not identity or tab position. Removing
|
||||
// and re-inserting here made same-second sessions swap places on click.
|
||||
return sessions.map((item, index) =>
|
||||
index === existingIndex ? next : item,
|
||||
);
|
||||
}
|
||||
return orderTerminalSessions([...sessions, next]);
|
||||
}
|
||||
|
||||
function compareTerminalSessions(
|
||||
left: TerminalSession,
|
||||
right: TerminalSession,
|
||||
): number {
|
||||
return (
|
||||
left.created_at_unix - right.created_at_unix ||
|
||||
left.terminal_id.localeCompare(right.terminal_id)
|
||||
);
|
||||
}
|
||||
|
||||
export function orderTerminalSessions(
|
||||
sessions: TerminalSession[],
|
||||
): TerminalSession[] {
|
||||
return [...sessions].sort(compareTerminalSessions);
|
||||
}
|
||||
|
||||
export function reconcileTerminalSessions(
|
||||
current: TerminalSession[],
|
||||
listed: TerminalSession[],
|
||||
): TerminalSession[] {
|
||||
const listedById = new Map(listed.map((item) => [item.terminal_id, item]));
|
||||
const retained = current.flatMap((item) => {
|
||||
const updated = listedById.get(item.terminal_id);
|
||||
if (!updated) return [];
|
||||
listedById.delete(item.terminal_id);
|
||||
return [updated];
|
||||
});
|
||||
return [...retained, ...orderTerminalSessions([...listedById.values()])];
|
||||
}
|
||||
|
||||
export function restoreCandidates(
|
||||
sessions: TerminalSession[],
|
||||
rememberedId: string | null,
|
||||
selectedAgentId: string,
|
||||
): TerminalSession[] {
|
||||
const remembered = sessions.find((item) => item.terminal_id === rememberedId);
|
||||
const available = sessions.filter(
|
||||
(item) => !item.operator_attached && item !== remembered,
|
||||
);
|
||||
available.sort((left, right) => {
|
||||
const leftPreferred = left.agent_id === selectedAgentId ? 1 : 0;
|
||||
const rightPreferred = right.agent_id === selectedAgentId ? 1 : 0;
|
||||
return (
|
||||
rightPreferred - leftPreferred ||
|
||||
right.created_at_unix - left.created_at_unix
|
||||
);
|
||||
});
|
||||
return remembered ? [remembered, ...available] : available;
|
||||
}
|
||||
|
||||
export function applyTerminalModifiers(
|
||||
data: string,
|
||||
modifiers: TerminalModifiers,
|
||||
): string {
|
||||
let modified = data;
|
||||
if (modifiers.ctrl && modified.length === 1) {
|
||||
const code = modified.toUpperCase().charCodeAt(0);
|
||||
if (code >= 64 && code <= 95) {
|
||||
modified = String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
}
|
||||
return modifiers.meta ? `\x1b${modified}` : modified;
|
||||
}
|
||||
|
||||
export function visibleTerminalText(terminal: XTerm): string {
|
||||
const buffer = terminal.buffer.active;
|
||||
const firstLine = buffer.viewportY;
|
||||
const lastLine = Math.min(buffer.length, firstLine + terminal.rows);
|
||||
const lines: string[] = [];
|
||||
for (let index = firstLine; index < lastLine; index += 1) {
|
||||
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
|
||||
}
|
||||
while (lines.at(-1) === "") lines.pop();
|
||||
return lines.join("\n");
|
||||
}
|
||||
+100
-3
@@ -326,7 +326,7 @@
|
||||
|
||||
.terminal-page {
|
||||
display: flex;
|
||||
height: calc(100dvh - 2.5rem);
|
||||
height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
@@ -488,7 +488,6 @@
|
||||
|
||||
.terminal-tab time {
|
||||
flex: 0 0 auto;
|
||||
color: #7f8d9a;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
@@ -578,6 +577,7 @@
|
||||
}
|
||||
|
||||
.terminal-surface {
|
||||
order: 2;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -585,6 +585,87 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-accessories-toggle[data-active] {
|
||||
color: #6ee7a8;
|
||||
background: rgb(110 231 168 / 10%);
|
||||
}
|
||||
|
||||
.terminal-accessories {
|
||||
display: flex;
|
||||
order: 3;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.45rem max(0.35rem, env(safe-area-inset-bottom));
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
border-top: 1px solid rgb(255 255 255 / 9%);
|
||||
background: #101820;
|
||||
color: #cbd5e1;
|
||||
scrollbar-width: thin;
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
.terminal-accessory-group {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessory-group + .terminal-accessory-group {
|
||||
padding-left: 0.35rem;
|
||||
border-left: 1px solid rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key {
|
||||
width: auto;
|
||||
min-width: 2.4rem;
|
||||
height: 2rem;
|
||||
padding-inline: 0.55rem;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background: rgb(255 255 255 / 4%);
|
||||
color: #cbd5e1;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key:hover {
|
||||
background: rgb(255 255 255 / 9%);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key[data-active] {
|
||||
border-color: rgb(110 231 168 / 48%);
|
||||
background: rgb(110 231 168 / 14%);
|
||||
color: #9af0bf;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--icon {
|
||||
width: 2rem;
|
||||
min-width: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--control {
|
||||
min-width: 2.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--wide {
|
||||
min-width: 3.15rem;
|
||||
}
|
||||
|
||||
.terminal-font-controls output {
|
||||
min-width: 1.5rem;
|
||||
color: #91a0ae;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.6875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -609,7 +690,7 @@
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.terminal-page {
|
||||
height: calc(100dvh - 2.5rem);
|
||||
height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
@@ -626,6 +707,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.terminal-accessories {
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key {
|
||||
min-width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--icon {
|
||||
width: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Presence badges ──────────────────────────────────────────────────── */
|
||||
|
||||
.presence-dot {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"ignoreDeprecations": "5.0",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/vt100 updated: ea740d5ad1...56e9e0be17.
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
@@ -39,6 +39,6 @@ tracing-subscriber = { version = "0.3", features = [
|
||||
] }
|
||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||
url = "2"
|
||||
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.1" }
|
||||
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.2" }
|
||||
wakey = { path = "..", registry = "gitea", version = "0" }
|
||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ fn public_api_routes(ui_dist_dir: std::path::PathBuf) -> Router<AppState> {
|
||||
.route("/", get(|| async { Redirect::temporary("/ui/") })) // same as caddyfile
|
||||
.nest_service(
|
||||
"/ui/",
|
||||
get_service(ServeDir::new(ui_dist_dir).not_found_service(ServeFile::new(index_file))),
|
||||
get_service(ServeDir::new(ui_dist_dir).fallback(ServeFile::new(index_file))),
|
||||
)
|
||||
.route("/healthz", get(api::healthz))
|
||||
.route("/api/v1/agents/enroll", post(api::enroll))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-core"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-linux"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
Reference in new issue
Block a user