4 Commits
15 changed files with 977 additions and 217 deletions

No files matched your search

Generated
+6 -6
View File
@@ -3624,7 +3624,7 @@ dependencies = [
[[package]] [[package]]
name = "wakey" name = "wakey"
version = "0.3.3" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -3645,7 +3645,7 @@ dependencies = [
[[package]] [[package]]
name = "wakey-agent" name = "wakey-agent"
version = "0.3.3" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3670,7 +3670,7 @@ dependencies = [
[[package]] [[package]]
name = "wakey-control-plane" name = "wakey-control-plane"
version = "0.3.3" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -3699,7 +3699,7 @@ dependencies = [
[[package]] [[package]]
name = "wakey-core" name = "wakey-core"
version = "0.3.3" version = "0.3.4"
dependencies = [ dependencies = [
"macaddr", "macaddr",
"serde", "serde",
@@ -3711,7 +3711,7 @@ dependencies = [
[[package]] [[package]]
name = "wakey-linux" name = "wakey-linux"
version = "0.3.3" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"futures", "futures",
@@ -3728,7 +3728,7 @@ dependencies = [
[[package]] [[package]]
name = "wakey-vt100" name = "wakey-vt100"
version = "0.16.2-wakey.1" version = "0.16.2-wakey.2"
dependencies = [ dependencies = [
"itoa", "itoa",
"unicode-width", "unicode-width",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey" name = "wakey"
version = "0.3.3" version = "0.3.4"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+4 -1
View File
@@ -2,7 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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> <title>Wakey Operator UI</title>
<script> <script>
(function () { (function () {
+297 -198
View File
@@ -6,7 +6,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"; import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebLinksAddon } from "@xterm/addon-web-links";
import { Terminal as XTerm } from "@xterm/xterm"; 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 { toast } from "sonner";
import { import {
@@ -26,6 +26,23 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } 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"; import { loadTerminalFontFamily } from "@/terminal/terminalFonts";
type Props = { type Props = {
@@ -34,15 +51,17 @@ type Props = {
onSelectAgent: (agentId: string) => void; onSelectAgent: (agentId: string) => void;
}; };
type ConnectionState =
| "idle"
| "connecting"
| "ready"
| "disconnected"
| "exited";
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id"; const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-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() // polyfill for testing in browsers that don't support crypto.randomUUID()
const thing = () => const thing = () =>
@@ -65,88 +84,17 @@ function terminalOperatorId(): string {
return created; 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 { function websocketUrl(path: string): string {
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:"; const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${scheme}//${window.location.host}${path}`; 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({ export function TerminalPage({
agents, agents,
selectedAgentId, selectedAgentId,
onSelectAgent, onSelectAgent,
}: Props) { }: Props) {
const pageRef = useRef<HTMLElement>(null);
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<XTerm | null>(null); const terminalRef = useRef<XTerm | null>(null);
const fitRef = useRef<FitAddon | null>(null); const fitRef = useRef<FitAddon | null>(null);
@@ -154,15 +102,36 @@ export function TerminalPage({
const activeSessionRef = useRef<TerminalSession | null>(null); const activeSessionRef = useRef<TerminalSession | null>(null);
const selectedAgentIdRef = useRef(selectedAgentId); const selectedAgentIdRef = useRef(selectedAgentId);
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 }); 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 [sessions, setSessions] = useState<TerminalSession[]>([]);
const [session, setSession] = useState<TerminalSession | null>(null); 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 [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
{}, {},
); );
const [terminalFontFamily, setTerminalFontFamily] = useState<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); const [operatorId] = useState(terminalOperatorId);
useEffect(() => {
const clock = window.setInterval(
() => setNowUnix(Math.floor(Date.now() / 1000)),
30_000,
);
return () => window.clearInterval(clock);
}, []);
activeSessionRef.current = session; activeSessionRef.current = session;
selectedAgentIdRef.current = selectedAgentId; selectedAgentIdRef.current = selectedAgentId;
@@ -178,10 +147,12 @@ export function TerminalPage({
); );
const agentAtSessionLimit = const agentAtSessionLimit =
selectedAgentSessionCount >= selectedAgentSessionLimit; selectedAgentSessionCount >= selectedAgentSessionLimit;
const canRequestStart = const canRequestStart = Boolean(
selectedAgent?.connected && selectedAgent?.connected &&
selectedAgent.capabilities.includes("terminal") && selectedAgent.capabilities.includes("terminal") &&
connection !== "connecting"; connection !== "connecting",
);
const terminalInputReady = connection === "ready";
const detachTransport = useCallback(() => { const detachTransport = useCallback(() => {
const socket = socketRef.current; const socket = socketRef.current;
@@ -189,6 +160,101 @@ export function TerminalPage({
socket?.close(); 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 sendResize = useCallback(() => {
const terminal = terminalRef.current; const terminal = terminalRef.current;
const socket = socketRef.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( const connect = useCallback(
(nextSession: TerminalSession) => { (nextSession: TerminalSession) => {
if (!nextSession.attachment_token) { 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(() => { useEffect(() => {
if (!hostRef.current || !terminalFontFamily) return; if (!hostRef.current || !terminalFontFamily) return;
const terminal = new XTerm({ const terminal = new XTerm({
cursorBlink: true, cursorBlink: true,
convertEol: false, convertEol: false,
fontFamily: terminalFontFamily, fontFamily: terminalFontFamily,
fontSize: 14, fontSize: DEFAULT_TERMINAL_FONT_SIZE,
lineHeight: 1.1, lineHeight: 1.1,
// xterm uses this width for its internal scrollbar as well as the // xterm uses this width for its internal scrollbar as well as the
// overview ruler. Reserve a larger touch target on coarse pointers. // overview ruler. Reserve a larger touch target on coarse pointers.
@@ -448,12 +554,7 @@ export function TerminalPage({
} }
let cancelled = false; let cancelled = false;
const input = terminal.onData((data) => { const input = terminal.onData((data) => writeTerminalData(data, true));
const socket = socketRef.current;
if (socket?.readyState === WebSocket.OPEN) {
socket.send(new TextEncoder().encode(data));
}
});
let resizeFrame = 0; let resizeFrame = 0;
let lastHostWidth = 0; let lastHostWidth = 0;
let lastHostHeight = 0; let lastHostHeight = 0;
@@ -498,7 +599,13 @@ export function TerminalPage({
terminalRef.current = null; terminalRef.current = null;
fitRef.current = null; fitRef.current = null;
}; };
}, [connect, detachTransport, sendResize, terminalFontFamily]); }, [
connect,
detachTransport,
sendResize,
terminalFontFamily,
writeTerminalData,
]);
async function start() { async function start() {
if (!selectedAgentId || !terminalRef.current) return; if (!selectedAgentId || !terminalRef.current) return;
@@ -644,7 +751,11 @@ export function TerminalPage({
} }
return ( return (
<section className="terminal-page" aria-label="Remote terminal"> <section
className="terminal-page"
aria-label="Remote terminal"
ref={pageRef}
>
<header className="terminal-toolbar"> <header className="terminal-toolbar">
<div className="terminal-heading"> <div className="terminal-heading">
<Terminal className="size-5 text-primary" aria-hidden /> <Terminal className="size-5 text-primary" aria-hidden />
@@ -677,112 +788,20 @@ export function TerminalPage({
<div className="terminal-frame"> <div className="terminal-frame">
<div className="terminal-framebar"> <div className="terminal-framebar">
<div className="terminal-tabs"> <TerminalSessionTabs
<div agents={agents}
className="terminal-tab-list" sessions={sessions}
role="tablist" activeTerminalId={session?.terminal_id}
aria-label="Terminal sessions" connection={connection}
> sessionTitles={sessionTitles}
{sessions.length === 0 ? ( nowUnix={nowUnix}
<div className="terminal-session-label"> canRequestStart={canRequestStart}
<Terminal className="size-4" aria-hidden /> agentAtSessionLimit={agentAtSessionLimit}
<span>No active session</span> selectedAgentSessionLimit={selectedAgentSessionLimit}
</div> onActivate={(item) => void activateSession(item)}
) : ( onClose={(item) => void closeSession(item)}
sessions.map((item) => { onStart={() => void start()}
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
/> />
<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"> <div className="terminal-frame-actions">
<Badge <Badge
@@ -804,6 +823,27 @@ export function TerminalPage({
Reconnect Reconnect
</Button> </Button>
) : null} ) : 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> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Button <Button
@@ -821,7 +861,66 @@ export function TerminalPage({
</Tooltip> </Tooltip>
</div> </div>
</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> </div>
</section> </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>
);
}
+113
View File
@@ -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
View File
@@ -326,7 +326,7 @@
.terminal-page { .terminal-page {
display: flex; display: flex;
height: calc(100dvh - 2.5rem); height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
min-height: 0; min-height: 0;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -488,7 +488,6 @@
.terminal-tab time { .terminal-tab time {
flex: 0 0 auto; flex: 0 0 auto;
color: #7f8d9a;
font-size: 0.6875rem; font-size: 0.6875rem;
} }
@@ -578,6 +577,7 @@
} }
.terminal-surface { .terminal-surface {
order: 2;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
@@ -585,6 +585,87 @@
padding: 0; 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 { .terminal-surface .xterm {
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -609,7 +690,7 @@
@media (max-width: 640px) { @media (max-width: 640px) {
.terminal-page { .terminal-page {
height: calc(100dvh - 2.5rem); height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
} }
.terminal-controls { .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 badges ──────────────────────────────────────────────────── */
.presence-dot { .presence-dot {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"ignoreDeprecations": "5.0", "ignoreDeprecations": "5.0",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
+1 -1
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-agent" name = "wakey-agent"
version = "0.3.3" version = "0.3.4"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
@@ -39,6 +39,6 @@ tracing-subscriber = { version = "0.3", features = [
] } ] }
time = { version = "0.3", features = ["formatting", "local-offset"] } time = { version = "0.3", features = ["formatting", "local-offset"] }
url = "2" 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 = { path = "..", registry = "gitea", version = "0" }
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" } wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-control-plane" name = "wakey-control-plane"
version = "0.3.3" version = "0.3.4"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+1 -1
View File
@@ -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 .route("/", get(|| async { Redirect::temporary("/ui/") })) // same as caddyfile
.nest_service( .nest_service(
"/ui/", "/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("/healthz", get(api::healthz))
.route("/api/v1/agents/enroll", post(api::enroll)) .route("/api/v1/agents/enroll", post(api::enroll))
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-core" name = "wakey-core"
version = "0.3.3" version = "0.3.4"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-linux" name = "wakey-linux"
version = "0.3.3" version = "0.3.4"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]