make remote terminal usable on mobile
This commit is contained in:
+4
-1
@@ -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 () {
|
||||||
|
|||||||
+525
-11
@@ -6,7 +6,22 @@ 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 {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUp,
|
||||||
|
ClipboardCopy,
|
||||||
|
ClipboardPaste,
|
||||||
|
Eraser,
|
||||||
|
Keyboard,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Terminal,
|
||||||
|
X,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +58,27 @@ type ConnectionState =
|
|||||||
|
|
||||||
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 MIN_TERMINAL_FONT_SIZE = 10;
|
||||||
|
const MAX_TERMINAL_FONT_SIZE = 22;
|
||||||
|
const TERMINAL_TOUCH_SLOP_PX = 8;
|
||||||
|
|
||||||
|
type TerminalModifiers = {
|
||||||
|
ctrl: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TerminalTouch = {
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
moved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NO_TERMINAL_MODIFIERS: TerminalModifiers = {
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
};
|
||||||
|
|
||||||
// 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 = () =>
|
||||||
@@ -142,11 +178,38 @@ function terminalCreatedTime(createdAtUnix: number): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
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,6 +217,9 @@ 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<ConnectionState>("idle");
|
||||||
@@ -161,6 +227,15 @@ export function TerminalPage({
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const [terminalFontFamily, setTerminalFontFamily] = useState<string>();
|
const [terminalFontFamily, setTerminalFontFamily] = useState<string>();
|
||||||
|
const [terminalFontSize, setTerminalFontSize] = useState(
|
||||||
|
DEFAULT_TERMINAL_FONT_SIZE,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
|
||||||
activeSessionRef.current = session;
|
activeSessionRef.current = session;
|
||||||
@@ -182,6 +257,7 @@ export function TerminalPage({
|
|||||||
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 +265,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 +383,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 +507,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 +659,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 +704,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 +856,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 />
|
||||||
@@ -804,6 +1020,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 +1058,284 @@ export function TerminalPage({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="terminal-surface" ref={hostRef} />
|
{accessoriesOpen ? (
|
||||||
|
<div
|
||||||
|
className="terminal-accessories"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Terminal controls"
|
||||||
|
onPointerDownCapture={(event) => {
|
||||||
|
// Keep xterm's hidden textarea focused while tapping accessory
|
||||||
|
// buttons so mobile browsers do not dismiss the soft keyboard.
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
event.target.closest("button")
|
||||||
|
) {
|
||||||
|
terminalInputFocusedBeforeAccessoryRef.current = Boolean(
|
||||||
|
hostRef.current?.contains(document.activeElement),
|
||||||
|
);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDownCapture={(event) => {
|
||||||
|
// Firefox can still take the mouse-event path without emitting
|
||||||
|
// pointerdown, so preserve the same focus state there.
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
event.target.closest("button")
|
||||||
|
) {
|
||||||
|
terminalInputFocusedBeforeAccessoryRef.current = Boolean(
|
||||||
|
hostRef.current?.contains(document.activeElement),
|
||||||
|
);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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={!terminalInputReady}
|
||||||
|
onClick={() => toggleModifier("ctrl")}
|
||||||
|
>
|
||||||
|
Ctrl
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key"
|
||||||
|
data-active={modifiers.meta || undefined}
|
||||||
|
aria-pressed={modifiers.meta}
|
||||||
|
disabled={!terminalInputReady}
|
||||||
|
onClick={() => toggleModifier("meta")}
|
||||||
|
>
|
||||||
|
Meta
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key"
|
||||||
|
disabled={!terminalInputReady}
|
||||||
|
onClick={() => writeAccessoryKey("\x1b")}
|
||||||
|
>
|
||||||
|
Esc
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key"
|
||||||
|
disabled={!terminalInputReady}
|
||||||
|
onClick={() => writeAccessoryKey("\t")}
|
||||||
|
>
|
||||||
|
Tab
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal-accessory-group">
|
||||||
|
{[
|
||||||
|
["^C", "\x03", "Interrupt"],
|
||||||
|
["^U", "\x15", "Clear input line"],
|
||||||
|
["^A", "\x01", "Start of line"],
|
||||||
|
["^E", "\x05", "End of line"],
|
||||||
|
].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={!terminalInputReady}
|
||||||
|
onClick={() => writeAccessoryKey(sequence)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal-accessory-group">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
aria-label="Left arrow"
|
||||||
|
disabled={!terminalInputReady}
|
||||||
|
onClick={() => writeAccessoryKey("\x1b[D")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
aria-label="Down arrow"
|
||||||
|
disabled={!terminalInputReady}
|
||||||
|
onClick={() => writeAccessoryKey("\x1b[B")}
|
||||||
|
>
|
||||||
|
<ArrowDown className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
aria-label="Up arrow"
|
||||||
|
disabled={!terminalInputReady}
|
||||||
|
onClick={() => writeAccessoryKey("\x1b[A")}
|
||||||
|
>
|
||||||
|
<ArrowUp className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
aria-label="Right arrow"
|
||||||
|
disabled={!terminalInputReady}
|
||||||
|
onClick={() => writeAccessoryKey("\x1b[C")}
|
||||||
|
>
|
||||||
|
<ArrowRight 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={() => terminalRef.current?.scrollPages(-1)}
|
||||||
|
>
|
||||||
|
PgUp
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--wide"
|
||||||
|
onClick={() => terminalRef.current?.scrollPages(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={() => void copyTerminalText()}
|
||||||
|
>
|
||||||
|
<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={!terminalInputReady}
|
||||||
|
onClick={() => void pasteTerminalText()}
|
||||||
|
>
|
||||||
|
<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={!terminalInputReady}
|
||||||
|
onClick={toggleTerminalKeyboard}
|
||||||
|
>
|
||||||
|
<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={terminalFontSize <= MIN_TERMINAL_FONT_SIZE}
|
||||||
|
aria-label="Decrease terminal text size"
|
||||||
|
onClick={() => changeTerminalFontSize(-1)}
|
||||||
|
>
|
||||||
|
<ZoomOut className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<output aria-label="Terminal text size">
|
||||||
|
{terminalFontSize}
|
||||||
|
</output>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
disabled={terminalFontSize >= MAX_TERMINAL_FONT_SIZE}
|
||||||
|
aria-label="Increase terminal text size"
|
||||||
|
onClick={() => changeTerminalFontSize(1)}
|
||||||
|
>
|
||||||
|
<ZoomIn className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : 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>
|
||||||
);
|
);
|
||||||
|
|||||||
+100
-2
@@ -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;
|
||||||
@@ -578,6 +578,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.terminal-surface {
|
.terminal-surface {
|
||||||
|
order: 2;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -585,6 +586,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 +691,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 +708,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 {
|
||||||
|
|||||||
Vendored
+1
-1
Submodule vendor/vt100 updated: ea740d5ad1...7065eac180
Reference in New Issue
Block a user