feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

No files matched your search

@@ -0,0 +1,525 @@
import { useEffect, useRef, useState } from "react";
import {
ChevronUp,
ChevronDown,
ChevronLeft,
ChevronRight,
Clipboard,
Pencil,
X,
Plus,
RotateCcw,
} from "lucide-react";
import { toast } from "sonner";
import type { TerminalHandle } from "./terminal-types";
import {
cn,
Button,
Input,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface MobileTerminalKeyboardProps {
terminalRef: React.RefObject<TerminalHandle | null>;
}
const CTRL_MAP: Record<string, string> = {
a: "\x01",
c: "\x03",
d: "\x04",
k: "\x0B",
l: "\x0C",
r: "\x12",
u: "\x15",
w: "\x17",
z: "\x1A",
};
const DEFAULT_QUICK_KEYS = [
"/",
"|",
"~",
"-",
"_",
"#",
"\\",
'"',
"'",
";",
":",
"!",
"&",
];
const LS_KEY = "termix:mobileQuickKeys";
function loadQuickKeys(): string[] {
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string"))
return parsed;
}
} catch {
/* ignore */
}
return DEFAULT_QUICK_KEYS;
}
function saveQuickKeys(keys: string[]) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(keys));
} catch {
/* ignore */
}
}
// Shared button styles
const KEY_BASE =
"flex items-center justify-center rounded border transition-colors select-none touch-none active:scale-95 shrink-0";
const KEY_NORMAL = "border-border bg-muted/50 text-foreground hover:bg-muted";
const KEY_ACTIVE =
"border-accent-brand bg-accent-brand/20 text-accent-brand shadow-[0_0_0_1px_color-mix(in_oklab,var(--accent-brand)_30%,transparent)]";
const KEY_MD = "h-9 px-3 min-w-[2.75rem] text-xs font-medium";
const KEY_SM = "h-9 w-9";
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
// --- QuickKeysSheet ---
interface QuickKeysSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
quickKeys: string[];
onUpdateKeys: (keys: string[]) => void;
}
function QuickKeysSheet({
open,
onOpenChange,
quickKeys,
onUpdateKeys,
}: QuickKeysSheetProps) {
const { t } = useTranslation();
const [newSymbol, setNewSymbol] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) setNewSymbol("");
}, [open]);
function addKey() {
const sym = newSymbol.trim();
if (!sym || quickKeys.includes(sym) || sym.length > 8) {
setNewSymbol("");
return;
}
onUpdateKeys([...quickKeys, sym]);
setNewSymbol("");
inputRef.current?.focus();
}
function removeKey(index: number) {
onUpdateKeys(quickKeys.filter((_, i) => i !== index));
}
function resetDefaults() {
onUpdateKeys(DEFAULT_QUICK_KEYS);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
className="max-h-[70vh] flex flex-col border-border"
>
<SheetHeader className="flex-row items-start justify-between pr-8">
<div>
<SheetTitle>{t("mobileKeyboard.quickKeysTitle")}</SheetTitle>
<SheetDescription>
{t("mobileKeyboard.quickKeysDesc")}
</SheetDescription>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={resetDefaults}
>
<RotateCcw className="size-3 mr-1" />
{t("mobileKeyboard.resetDefaults")}
</Button>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-2">
<div className="flex flex-wrap gap-2">
{quickKeys.map((sym, i) => (
<div
key={i}
className="flex items-center gap-1 h-8 pl-3 pr-1 rounded border border-border bg-muted/50 text-xs font-medium text-foreground"
>
<span className="font-mono">{sym}</span>
<button
className="size-5 flex items-center justify-center rounded hover:bg-destructive hover:text-destructive-foreground transition-colors"
onClick={() => removeKey(i)}
title={t("mobileKeyboard.removeQuickKey")}
>
<X className="size-3" />
</button>
</div>
))}
</div>
</div>
<div className="flex gap-2 px-4 py-2 border-t border-border">
<Input
ref={inputRef}
value={newSymbol}
onChange={(e) => setNewSymbol(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") addKey();
}}
maxLength={8}
placeholder={t("mobileKeyboard.quickKeyPlaceholder")}
className="h-9 text-xs font-mono"
/>
<Button
variant="outline"
size="sm"
className="h-9 shrink-0"
onClick={addKey}
disabled={!newSymbol.trim() || quickKeys.includes(newSymbol.trim())}
>
<Plus className="size-3.5 mr-1" />
{t("mobileKeyboard.addQuickKey")}
</Button>
</div>
<SheetFooter className="pt-0">
<Button className="w-full" onClick={() => onOpenChange(false)}>
{t("mobileKeyboard.done")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
// --- CtrlPanel ---
interface CtrlPanelProps {
onSend: (letter: string) => void;
}
function CtrlPanel({ onSend }: CtrlPanelProps) {
const CTRL_KEYS = ["c", "d", "l", "u", "z", "a", "r", "w", "k"];
return (
<div className="flex items-center gap-1 px-2 py-1 bg-muted/30 border-t border-border overflow-x-auto">
{CTRL_KEYS.map((k) => (
<button
key={k}
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
onPointerDown={(e) => {
e.preventDefault();
onSend(k);
}}
title={`Ctrl+${k.toUpperCase()}`}
>
^{k.toUpperCase()}
</button>
))}
</div>
);
}
// --- MobileTerminalKeyboard ---
export function MobileTerminalKeyboard({
terminalRef,
}: MobileTerminalKeyboardProps) {
const { t } = useTranslation();
const [ctrlActive, setCtrlActive] = useState(false);
const [shiftActive, setShiftActive] = useState(false);
const [quickKeys, setQuickKeys] = useState<string[]>(loadQuickKeys);
const [sheetOpen, setSheetOpen] = useState(false);
function send(seq: string) {
terminalRef.current?.sendInput?.(seq);
}
async function handlePaste() {
try {
const text = window.electronClipboard
? await window.electronClipboard.readText()
: ((await navigator.clipboard?.readText?.()) ?? "");
if (text) terminalRef.current?.paste?.(text);
else toast.error(t("terminal.clipboardReadFailed"));
} catch {
toast.error(t("terminal.clipboardReadFailed"));
}
}
function toggleCtrl() {
setCtrlActive((v) => !v);
setShiftActive(false);
}
function toggleShift() {
setShiftActive((v) => !v);
setCtrlActive(false);
}
function sendArrow(normalSeq: string, appSeq: string, shiftSeq: string) {
if (shiftActive) {
send(shiftSeq);
setShiftActive(false);
return;
}
const appMode =
terminalRef.current?.getApplicationCursorKeysMode?.() ?? false;
send(appMode ? appSeq : normalSeq);
}
function handleTab() {
send(shiftActive ? "\x1b[Z" : "\t");
setShiftActive(false);
}
function handleCtrlKey(letter: string) {
const seq = CTRL_MAP[letter];
if (seq) send(seq);
setCtrlActive(false);
}
function updateQuickKeys(next: string[]) {
setQuickKeys(next);
saveQuickKeys(next);
}
return (
<div className="md:hidden flex flex-col bg-sidebar border-t border-border shrink-0">
{/* Row 1 — special keys */}
<div className="flex items-center gap-1 px-2 py-1.5 overflow-x-auto">
{/* ESC */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b");
}}
title={t("mobileKeyboard.esc")}
>
{t("mobileKeyboard.esc")}
</button>
{/* Tab / back-tab */}
<button
className={cn(
KEY_BASE,
KEY_NORMAL,
KEY_MD,
shiftActive && "ring-1 ring-accent-brand/50",
)}
onPointerDown={(e) => {
e.preventDefault();
handleTab();
}}
title={shiftActive ? "Shift+Tab" : t("mobileKeyboard.tab")}
>
{shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")}
</button>
{/* Paste */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
handlePaste();
}}
title={t("mobileKeyboard.paste")}
>
<Clipboard className="size-4" />
</button>
<div className={SEP} />
{/* Ctrl */}
<button
className={cn(KEY_BASE, KEY_MD, ctrlActive ? KEY_ACTIVE : KEY_NORMAL)}
onPointerDown={(e) => {
e.preventDefault();
toggleCtrl();
}}
title={t("mobileKeyboard.ctrl")}
>
{t("mobileKeyboard.ctrl")}
</button>
{/* Shift */}
<button
className={cn(
KEY_BASE,
KEY_MD,
shiftActive ? KEY_ACTIVE : KEY_NORMAL,
)}
onPointerDown={(e) => {
e.preventDefault();
toggleShift();
}}
title={t("mobileKeyboard.shift")}
>
{t("mobileKeyboard.shift")}
</button>
<div className={SEP} />
{/* Arrow keys */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
sendArrow("\x1b[A", "\x1bOA", "\x1b[1;2A");
}}
title={t("mobileKeyboard.arrowUp")}
>
<ChevronUp className="size-4" />
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
sendArrow("\x1b[B", "\x1bOB", "\x1b[1;2B");
}}
title={t("mobileKeyboard.arrowDown")}
>
<ChevronDown className="size-4" />
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
sendArrow("\x1b[D", "\x1bOD", "\x1b[1;2D");
}}
title={t("mobileKeyboard.arrowLeft")}
>
<ChevronLeft className="size-4" />
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
sendArrow("\x1b[C", "\x1bOC", "\x1b[1;2C");
}}
title={t("mobileKeyboard.arrowRight")}
>
<ChevronRight className="size-4" />
</button>
<div className={SEP} />
{/* Home / End */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b[H");
}}
title={t("mobileKeyboard.home")}
>
{t("mobileKeyboard.home")}
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b[F");
}}
title={t("mobileKeyboard.end")}
>
{t("mobileKeyboard.end")}
</button>
<div className={SEP} />
{/* PgUp / PgDn / Del */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b[5~");
}}
title={t("mobileKeyboard.pageUp")}
>
{t("mobileKeyboard.pageUp")}
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b[6~");
}}
title={t("mobileKeyboard.pageDown")}
>
{t("mobileKeyboard.pageDown")}
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b[3~");
}}
title={t("mobileKeyboard.delete")}
>
{t("mobileKeyboard.delete")}
</button>
</div>
{/* Ctrl combos panel */}
{ctrlActive && <CtrlPanel onSend={handleCtrlKey} />}
{/* Row 2 — quick keys */}
<div className="flex items-center gap-1 px-2 pb-1.5 overflow-x-auto">
{quickKeys.map((sym, i) => (
<button
key={i}
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
onPointerDown={(e) => {
e.preventDefault();
send(sym);
}}
title={sym}
>
{sym}
</button>
))}
<div className="ml-auto shrink-0">
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
setSheetOpen(true);
}}
title={t("mobileKeyboard.editQuickKeys")}
>
<Pencil className="size-3.5" />
</button>
</div>
</div>
<QuickKeysSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
quickKeys={quickKeys}
onUpdateKeys={updateQuickKeys}
/>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,63 @@
import React from "react";
import { Terminal } from "./Terminal";
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface TerminalAppProps {
hostId?: string;
/** tmux session to attach to once the shell is ready (tmux monitor "Attach"). */
tmuxSession?: string;
}
// Only the session name travels in the URL (never a raw command), so a crafted
// link cannot execute arbitrary input. `=` forces exact-name matching in tmux.
function tmuxAttachCommand(session: string): string {
return `tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`;
}
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId, tmuxSession }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
return (
<Terminal
hostConfig={hostConfig}
isVisible={true}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
showTitle={false}
splitScreen={false}
onClose={() => {}}
executeCommand={
tmuxSession ? tmuxAttachCommand(tmuxSession) : undefined
}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default TerminalApp;
@@ -0,0 +1,135 @@
import { lazy, Suspense } from "react";
import { TerminalSquare } from "lucide-react";
import type { TerminalHandle, TerminalHostConfig } from "./Terminal";
import { useIsMobile, useTabsSafe } from "@termix/plugin-sdk/ui";
import {
useTranslation,
invokeAction,
type TabProps,
} from "@termix/plugin-sdk/frontend";
import type { Host } from "../types";
/** The session fields the shell keeps on a terminal tab. */
interface TerminalTabRecord {
id: string;
instanceId?: string;
restoredSessionId?: string | null;
joinSharedSessionId?: string | null;
joinShareId?: string | null;
initialFilePath?: string;
data?: {
initialPath?: string;
joinSharedSessionId?: string | null;
joinShareId?: string | null;
};
}
const CommandHistoryProvider = lazy(() =>
import("./command-history/CommandHistoryContext").then((m) => ({
default: m.CommandHistoryProvider,
})),
);
export const loadTerminal = () =>
import("./Terminal").then((m) => ({ default: m.Terminal }));
const TerminalFeature = lazy(loadTerminal);
const MobileTerminalKeyboard = lazy(() =>
import("./MobileTerminalKeyboard").then((m) => ({
default: m.MobileTerminalKeyboard,
})),
);
function TabChunkFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
/** The SSH terminal as a tab. */
export function TerminalTabContent({
tab: tabRecord,
host: hostRecord,
sshHost,
label,
isVisible,
isFocusedPane,
handleRef,
shell,
}: TabProps) {
const tab = tabRecord as unknown as TerminalTabRecord;
const host = hostRecord as unknown as Host | undefined;
const { t } = useTranslation();
const { previewTerminalTheme } = useTabsSafe();
const isMobile = useIsMobile();
if (!host) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<TerminalSquare className="size-5 text-muted-foreground/30" />
<span className="text-sm font-semibold text-muted-foreground/60">
{t("terminal.noHostSelected")}
</span>
</div>
);
}
return (
<Suspense fallback={<TabChunkFallback />}>
<CommandHistoryProvider>
<div className="flex flex-col h-full w-full">
<div className="flex-1 min-h-0">
<TerminalFeature
ref={handleRef as React.Ref<TerminalHandle>}
hostConfig={
{
...sshHost,
sshPort: host.sshPort ?? host.port,
instanceId: tab.instanceId ?? tab.id,
restoredSessionId: tab.restoredSessionId ?? null,
joinSharedSessionId:
tab.data?.joinSharedSessionId ??
tab.joinSharedSessionId ??
null,
joinShareId: tab.data?.joinShareId ?? tab.joinShareId ?? null,
} as unknown as TerminalHostConfig
}
isVisible={isVisible}
initialPath={tab.data?.initialPath ?? tab.initialFilePath}
title={label}
showTitle={false}
splitScreen={false}
onClose={() => shell.closeTab(tab.id)}
onTitleChange={
host.terminalConfig?.useSSHTitle
? (title) => shell.renameTab(tab.id, title)
: undefined
}
previewTheme={previewTerminalTheme}
onOpenFileInEditor={(filePath) =>
void invokeAction("files.openEditor", host, filePath)
}
onOpenFileManager={(path) =>
void invokeAction("files.openHost", host, path)
}
isQuickConnect={String(host.id).startsWith("quick-connect-")}
onSaveQuickConnect={
shell.saveQuickConnect
? () => shell.saveQuickConnect!(tabRecord, hostRecord!)
: undefined
}
host={host}
onOpenTab={(type) => shell.openTab(hostRecord!, type)}
isFocusedPane={isFocusedPane}
/>
</div>
{isMobile && (
<MobileTerminalKeyboard
terminalRef={handleRef as React.RefObject<TerminalHandle | null>}
/>
)}
</div>
</CommandHistoryProvider>
</Suspense>
);
}
@@ -0,0 +1,714 @@
import React, {
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ChevronDown,
ClipboardPaste,
GripVertical,
ImagePlus,
LayoutGrid,
LogOut,
Maximize2,
Minimize2,
X,
} from "lucide-react";
import {
TERMINAL_TOOLBAR_SLOT,
TERMINAL_TOOLBAR_STATUS_SLOT,
type TerminalSlotApi,
} from "./terminal-slots";
import type { Host } from "../types";
import {
clampToolbarPosition,
getResponsiveToolbarDensity,
persistToolbarPosition,
readStoredToolbarPosition,
type ToolbarPosition,
type ToolbarDensity,
} from "./toolbar-geometry";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
cn,
ActionSlot,
ComponentSlot,
useIsMobile,
} from "@termix/plugin-sdk/ui";
import {
useTranslation,
useSlotContributions,
} from "@termix/plugin-sdk/frontend";
type SelectedToolbarDensity = ToolbarDensity;
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
/** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */
const TOOLBAR_SLOT_ID = TERMINAL_TOOLBAR_SLOT;
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
{ value: "icon" },
{ value: "labeled" },
{ value: "expanded" },
];
const DENSITIES = DENSITY_OPTIONS.map((option) => option.value);
function readStoredDensity(): ToolbarDensity {
if (typeof window === "undefined") return "labeled";
try {
const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY);
return DENSITIES.includes(stored as ToolbarDensity)
? (stored as ToolbarDensity)
: "labeled";
} catch {
return "labeled";
}
}
const CONTROL =
"inline-flex min-h-8 min-w-8 items-center justify-center gap-1.5 rounded-sm px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:translate-y-px active:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40 data-[state=open]:bg-muted data-[state=open]:text-foreground";
const SEPARATOR = "mx-0.5 h-5 w-px shrink-0 bg-border";
interface TerminalToolbarProps {
host: Host;
terminalIdentity?: string;
isConnected: boolean;
isTmuxAttached: boolean;
onTmuxDetach: () => void;
isImageUploading: boolean;
onUploadImage: (file: File) => void | Promise<void>;
onPasteImage: () => void | Promise<void>;
isFocused: boolean;
/** Hides every contributed action, e.g. while the toolbar is measuring. */
actionsEnabled?: boolean;
/** Handed to contributed actions when they are invoked. */
slotApi?: TerminalSlotApi;
}
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
host,
terminalIdentity,
isConnected,
isTmuxAttached,
onTmuxDetach,
isImageUploading,
onUploadImage,
onPasteImage,
isFocused,
actionsEnabled = true,
slotApi,
}) => {
const { t } = useTranslation();
// Drives both the measurement copy and the separator, so the toolbar sizes
// itself correctly whether or not anything contributed.
const allSlotContributions = useSlotContributions(TOOLBAR_SLOT_ID, { host });
const statusContributions = useSlotContributions(
TERMINAL_TOOLBAR_STATUS_SLOT,
{ host },
);
const slotContributions = useMemo(
() =>
allSlotContributions.filter(
(contribution) => contribution.kind !== "component",
),
[allSlotContributions],
);
const [density, setDensity] =
useState<SelectedToolbarDensity>(readStoredDensity);
const [responsiveDensity, setResponsiveDensity] =
useState<SelectedToolbarDensity>(readStoredDensity);
const [position, setPosition] = useState<ToolbarPosition>(
readStoredToolbarPosition,
);
const positionRef = useRef(position);
const [collapsed, setCollapsed] = useState(false);
const [densityOpen, setDensityOpen] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
const [retryImageAction, setRetryImageAction] = useState<(() => void) | null>(
null,
);
const fileInputRef = useRef<HTMLInputElement>(null);
const hideToolbarRef = useRef<HTMLButtonElement>(null);
const pendingExpansionFocusRef = useRef(false);
const pendingRightEdgeRef = useRef<number | null>(null);
const mountedRef = useRef(false);
const imageGenerationRef = useRef(0);
const hostRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const measurementRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
basePosition: ToolbarPosition;
} | null>(null);
const capturedPointerIdRef = useRef<number | null>(null);
const dragMovedRef = useRef(false);
const densityId = useId();
const [desktopViewportReady, setDesktopViewportReady] = useState<boolean>();
const hostIdentity = terminalIdentity ?? `${host.id ?? "unknown"}`;
positionRef.current = position;
const effectiveDensity: ToolbarDensity = isFocused
? responsiveDensity
: "icon";
const showStats =
effectiveDensity === "expanded" && statusContributions.length > 0;
const isMobile = useIsMobile();
useLayoutEffect(() => {
setDesktopViewportReady(
typeof window !== "undefined" && window.innerWidth >= 768,
);
}, []);
useEffect(() => {
if (isMobile === false) setDesktopViewportReady(true);
}, [isMobile]);
useEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
try {
window.localStorage.setItem(DENSITY_STORAGE_KEY, density);
} catch {
// Storage can be unavailable in hardened browser contexts.
}
}, [density, desktopViewportReady, isMobile]);
// Picking an interface preset seeds this key, so pick the new value up
// without needing the tab to remount.
useEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
const handler = () => setDensity(readStoredDensity());
window.addEventListener("terminalToolbarDensityChanged", handler);
return () =>
window.removeEventListener("terminalToolbarDensityChanged", handler);
}, [desktopViewportReady, isMobile]);
useEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
const measurement = measurementRef.current;
const host = hostRef.current;
if (!measurement || !host) return;
let frameId: number | null = null;
let remeasurements = 0;
const measure = () => {
frameId = null;
const available = host.getBoundingClientRect().width;
const required = measurement.getBoundingClientRect().width;
if (available > 0 && required > 0) {
setResponsiveDensity((current) =>
getResponsiveToolbarDensity(density, current, available, required),
);
remeasurements = 0;
} else if (remeasurements < 2) {
remeasurements += 1;
frameId = window.requestAnimationFrame(measure);
}
};
const schedule = () => {
if (frameId == null) frameId = window.requestAnimationFrame(measure);
};
schedule();
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(schedule);
observer?.observe(host);
observer?.observe(measurement);
return () => {
if (frameId != null) window.cancelAnimationFrame(frameId);
observer?.disconnect();
};
}, [
density,
desktopViewportReady,
isConnected,
isMobile,
isTmuxAttached,
actionsEnabled,
slotContributions,
t,
]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
imageGenerationRef.current += 1;
dragRef.current = null;
capturedPointerIdRef.current = null;
};
}, []);
useLayoutEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
const toolbar = toolbarRef.current;
const host = hostRef.current;
if (!toolbar || !host || dragRef.current) return;
const hostRect = host.getBoundingClientRect();
const toolbarRect = toolbar.getBoundingClientRect();
const extremeOffset =
hostRect.width > 0 &&
hostRect.height > 0 &&
Math.max(
Math.abs(positionRef.current.x),
Math.abs(positionRef.current.y),
) >
Math.max(hostRect.width, hostRect.height) * 4;
if (extremeOffset) {
positionRef.current = { x: 0, y: 0 };
setPosition({ x: 0, y: 0 });
persistToolbarPosition({ x: 0, y: 0 });
return;
}
if (toolbarRect.width > 0 && toolbarRect.height > 0) {
const pendingRightEdge = pendingRightEdgeRef.current;
pendingRightEdgeRef.current = null;
const baseRight = toolbarRect.right - positionRef.current.x;
const next = clampToolbarPosition(
{
x:
pendingRightEdge == null
? positionRef.current.x
: pendingRightEdge - baseRight,
y: positionRef.current.y,
},
toolbarRect,
hostRect,
positionRef.current,
);
if (
next.x !== positionRef.current.x ||
next.y !== positionRef.current.y
) {
positionRef.current = next;
setPosition(next);
persistToolbarPosition(next);
}
}
if (!collapsed && pendingExpansionFocusRef.current) {
pendingExpansionFocusRef.current = false;
hideToolbarRef.current?.focus();
}
}, [
collapsed,
density,
desktopViewportReady,
effectiveDensity,
isConnected,
isMobile,
isTmuxAttached,
responsiveDensity,
]);
useEffect(() => {
imageGenerationRef.current += 1;
setImageError(null);
setRetryImageAction(null);
}, [isConnected, hostIdentity]);
useEffect(() => {
if (collapsed || !pendingExpansionFocusRef.current) return;
pendingExpansionFocusRef.current = false;
hideToolbarRef.current?.focus();
}, [collapsed]);
if (!isConnected || desktopViewportReady !== true || isMobile !== false)
return null;
const setAndStoreDensity = (next: ToolbarDensity) => {
setDensity(next);
};
const reportImageResult = (
action: () => void | Promise<void>,
retry: () => void,
) => {
const generation = ++imageGenerationRef.current;
let result: void | Promise<void>;
try {
result = action();
} catch (error) {
result = Promise.reject(error);
}
void Promise.resolve(result).then(
() => {
if (!mountedRef.current || generation !== imageGenerationRef.current)
return;
setImageError(null);
setRetryImageAction(null);
},
(error: unknown) => {
if (!mountedRef.current || generation !== imageGenerationRef.current)
return;
setImageError(
error instanceof Error
? error.message
: t("terminalToolbar.imageActionFailed"),
);
setRetryImageAction(() => retry);
},
);
};
const uploadFile = (file: File) => {
setImageError(null);
reportImageResult(
() => onUploadImage(file),
() => uploadFile(file),
);
};
const pasteImage = () => {
setImageError(null);
// Invoke clipboard access in this click's user-activation call chain.
reportImageResult(onPasteImage, pasteImage);
};
const chooseFile = () => fileInputRef.current?.click();
const handleGrabPointerDown = (
event: React.PointerEvent<HTMLButtonElement>,
) => {
if (event.button !== 0 || event.pointerType === "touch") return;
event.currentTarget.setPointerCapture?.(event.pointerId);
capturedPointerIdRef.current = event.pointerId;
dragMovedRef.current = false;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
basePosition: positionRef.current,
};
};
const handleGrabPointerMove = (
event: React.PointerEvent<HTMLButtonElement>,
) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const deltaX = event.clientX - drag.startX;
const deltaY = event.clientY - drag.startY;
const toolbar = toolbarRef.current;
const host = hostRef.current;
if (!toolbar || !host) return;
const next = clampToolbarPosition(
{
x: drag.basePosition.x + deltaX,
y: drag.basePosition.y + deltaY,
},
toolbar.getBoundingClientRect(),
host.getBoundingClientRect(),
positionRef.current,
);
if (event.clientX !== drag.startX || event.clientY !== drag.startY) {
dragMovedRef.current = true;
}
positionRef.current = next;
setPosition(next);
};
const handleGrabPointerEnd = (
event: React.PointerEvent<HTMLButtonElement>,
) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
persistToolbarPosition(positionRef.current);
dragRef.current = null;
if (capturedPointerIdRef.current === event.pointerId) {
capturedPointerIdRef.current = null;
if (event.type !== "lostpointercapture") {
try {
event.currentTarget.releasePointerCapture?.(event.pointerId);
} catch {
// The browser may have already released capture for this pointer.
}
}
}
};
const densityLabels: Record<ToolbarDensity, string> = {
icon: t("terminalToolbar.layoutIcon"),
labeled: t("terminalToolbar.layoutLabeled"),
expanded: t("terminalToolbar.layoutExpanded"),
};
const densitySelect = (
<Select
value={density}
open={densityOpen}
onOpenChange={setDensityOpen}
onValueChange={(value) => setAndStoreDensity(value as ToolbarDensity)}
>
<SelectTrigger
id={densityId}
size="sm"
aria-label={t("terminalToolbar.layout")}
title={t("terminalToolbar.layout")}
className={cn(
CONTROL,
"h-8 border-0 bg-transparent px-2 shadow-none dark:bg-transparent dark:hover:bg-muted",
)}
>
<LayoutGrid className="size-4 shrink-0" />
</SelectTrigger>
<SelectContent>
{DENSITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{densityLabels[option.value]}
</SelectItem>
))}
</SelectContent>
</Select>
);
const imageButtons = (
<>
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.uploadImage")}
title={t("terminalToolbar.uploadImage")}
disabled={isImageUploading}
onClick={chooseFile}
>
<ImagePlus className="size-4 shrink-0" />
{effectiveDensity !== "icon" && t("terminalToolbar.upload")}
</button>
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.pasteImage")}
title={t("terminalToolbar.pasteImage")}
disabled={isImageUploading}
onClick={pasteImage}
>
<ClipboardPaste className="size-4 shrink-0" />
{effectiveDensity !== "icon" && t("terminalToolbar.paste")}
</button>
</>
);
const expandButton = (
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.showToolbar")}
title={t("terminalToolbar.showToolbar")}
onClick={() => {
pendingRightEdgeRef.current =
toolbarRef.current?.getBoundingClientRect().right ?? null;
pendingExpansionFocusRef.current = true;
setCollapsed(false);
}}
>
<Maximize2 className="size-4" />
<span className="sr-only">{t("terminalToolbar.showToolbar")}</span>
</button>
);
const grabButton = () => (
<button
type="button"
className={cn(CONTROL, "cursor-grab active:cursor-grabbing")}
aria-label={t("terminalToolbar.moveToolbar")}
title={t("terminalToolbar.moveToolbarHint")}
onPointerDown={handleGrabPointerDown}
onPointerMove={handleGrabPointerMove}
onPointerUp={handleGrabPointerEnd}
onPointerCancel={handleGrabPointerEnd}
onLostPointerCapture={handleGrabPointerEnd}
>
<GripVertical className="size-4" />
</button>
);
return (
<>
<div
ref={hostRef}
data-terminal-toolbar-host
className={cn(
"pointer-events-none @container absolute inset-0 z-[110] flex items-end justify-center pb-2",
)}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="sr-only"
tabIndex={-1}
disabled={isImageUploading}
aria-hidden="true"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) uploadFile(file);
}}
/>
<span role="status" aria-live="polite" className="sr-only">
{isImageUploading ? t("terminalToolbar.uploadingImage") : ""}
</span>
{imageError && (
<div
role="alert"
className="pointer-events-auto absolute bottom-full mb-2 flex min-h-11 max-w-sm items-center gap-2 rounded-sm border border-destructive bg-background p-2 text-xs text-destructive shadow-lg"
>
<span>{imageError}</span>
{retryImageAction && (
<button
type="button"
className={cn(CONTROL, "text-foreground")}
title={t("terminalToolbar.retry")}
disabled={isImageUploading}
onClick={retryImageAction}
>
{t("terminalToolbar.retry")}
</button>
)}
<button
type="button"
className={cn(CONTROL, "px-2")}
aria-label={t("terminalToolbar.dismissError")}
title={t("terminalToolbar.dismissError")}
onClick={() => {
setImageError(null);
setRetryImageAction(null);
}}
>
<X className="size-4" />
</button>
</div>
)}
<div
ref={measurementRef}
aria-hidden="true"
className="pointer-events-none invisible absolute left-0 top-0 flex whitespace-nowrap"
>
{isTmuxAttached && (
<span className={CONTROL}>
<LogOut className="size-4" />
{density !== "icon" && t("terminalToolbar.detachTmux")}
</span>
)}
<span className={CONTROL}>
<ImagePlus className="size-4" />
{density !== "icon" && t("terminalToolbar.upload")}
</span>
<span className={CONTROL}>
<ClipboardPaste className="size-4" />
{density !== "icon" && t("terminalToolbar.paste")}
</span>
{actionsEnabled &&
slotContributions.map((contribution) => (
<span key={contribution.actionId} className={CONTROL}>
{contribution.icon && <contribution.icon className="size-4" />}
{density !== "icon" && t(contribution.titleKey)}
</span>
))}
<span className={cn(CONTROL, "h-8 px-2")}>
<LayoutGrid className="size-4" />
<ChevronDown className="size-4" />
</span>
<span className={CONTROL}>
<Minimize2 className="size-4" />
</span>
<span className={CONTROL}>
<GripVertical className="size-4" />
</span>
</div>
<div
ref={toolbarRef}
data-terminal-toolbar-wide
className={cn(
"pointer-events-auto transition-opacity duration-300 hover:duration-0 focus-within:duration-0 hover:opacity-100 focus-within:opacity-100",
collapsed || densityOpen ? "opacity-100" : "opacity-30",
)}
style={{
transform: `translate(${position.x}px, ${position.y}px)`,
}}
>
{collapsed ? (
<div className="terminal-toolbar-collapsed-shell flex rounded-sm border border-border bg-background/90 p-0.5 shadow-lg backdrop-blur-sm">
{expandButton}
{grabButton()}
</div>
) : (
<div className="flex flex-col overflow-hidden rounded-sm border border-border bg-background/90 shadow-lg backdrop-blur-sm">
{showStats && (
<div className="flex flex-wrap items-center justify-center gap-x-1 border-b border-border px-1.5 py-0.5">
<ComponentSlot
slotId={TERMINAL_TOOLBAR_STATUS_SLOT}
when={{ host }}
props={{
host,
isConnected,
active:
isConnected &&
desktopViewportReady === true &&
isMobile === false,
}}
/>
</div>
)}
<div className={cn("flex gap-0.5 p-0.5", "items-center")}>
{isTmuxAttached && (
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.detachTmuxDescription")}
title={t("terminalToolbar.detachTmuxDescription")}
onClick={onTmuxDetach}
>
<LogOut className="size-4" />
{effectiveDensity !== "icon" &&
t("terminalToolbar.detachTmux")}
</button>
)}
{isTmuxAttached && <div className={SEPARATOR} />}
<div
role="group"
aria-label={t("terminalToolbar.image")}
className="flex items-center gap-0.5"
>
{imageButtons}
</div>
{actionsEnabled && slotContributions.length > 0 && (
<>
<div className={SEPARATOR} />
<ActionSlot
slotId={TOOLBAR_SLOT_ID}
className={CONTROL}
enabled={actionsEnabled}
hideLabels={effectiveDensity === "icon"}
when={{ host }}
context={() => [slotApi]}
/>
</>
)}
<div className={SEPARATOR} />
{densitySelect}
<button
ref={hideToolbarRef}
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.hideToolbar")}
title={t("terminalToolbar.hideToolbar")}
onClick={() => {
pendingRightEdgeRef.current =
toolbarRef.current?.getBoundingClientRect().right ?? null;
setCollapsed(true);
}}
>
<Minimize2 className="size-4" />
<span className="sr-only">
{t("terminalToolbar.hideToolbar")}
</span>
</button>
<div className={SEPARATOR} />
{grabButton()}
</div>
</div>
)}
</div>
</div>
</>
);
};
@@ -0,0 +1,129 @@
import React from "react";
import { Terminal, Monitor, Users, Clock } from "lucide-react";
import { Button } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface TmuxSessionInfo {
name: string;
created: number;
lastActivity: number;
windows: number;
attachedClients: number;
}
interface TmuxSessionPickerProps {
isOpen: boolean;
sessions: TmuxSessionInfo[];
onSelect: (sessionName: string) => void;
onCreateNew: () => void;
onCancel: () => void;
backgroundColor?: string;
}
function formatTimestamp(
unix: number,
t: (key: string, opts?: Record<string, unknown>) => string,
): string {
if (!unix) return "---";
const date = new Date(unix * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHr / 24);
if (diffMin < 1) return t("terminal.tmuxTimeJustNow");
if (diffMin < 60) return t("terminal.tmuxTimeMinutes", { count: diffMin });
if (diffHr < 24) return t("terminal.tmuxTimeHours", { count: diffHr });
if (diffDays < 7) return t("terminal.tmuxTimeDays", { count: diffDays });
return date.toLocaleDateString();
}
export function TmuxSessionPicker({
isOpen,
sessions,
onSelect,
onCreateNew,
onCancel,
backgroundColor,
}: TmuxSessionPickerProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-sm mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<Terminal className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{t("terminal.tmuxSessionPickerTitle")}
</h3>
</div>
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
{t("terminal.tmuxSessionPickerDesc")}
</p>
</div>
<div className="flex flex-col max-h-60 overflow-y-auto">
{sessions.map((session) => (
<button
key={session.name}
onClick={() => onSelect(session.name)}
className="w-full text-left px-4 py-3 border-b border-border hover:bg-muted/50 transition-colors last:border-b-0"
>
<div className="font-mono text-xs font-bold">{session.name}</div>
<div className="flex gap-3 mt-1 text-[10px] text-muted-foreground">
<span
className="flex items-center gap-1"
title={t("terminal.tmuxWindows")}
>
<Monitor className="size-3" />
{t("terminal.tmuxWindowCount", { count: session.windows })}
</span>
{session.attachedClients > 0 && (
<span
className="flex items-center gap-1"
title={t("terminal.tmuxAttached")}
>
<Users className="size-3" />
{t("terminal.tmuxAttachedCount", {
count: session.attachedClients,
})}
</span>
)}
<span
className="flex items-center gap-1"
title={t("terminal.tmuxLastActivity")}
>
<Clock className="size-3" />
{formatTimestamp(session.lastActivity, t)}
</span>
</div>
</button>
))}
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<Button
onClick={onCancel}
variant="ghost"
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
onClick={onCreateNew}
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("terminal.tmuxCreateNew")}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import type { HostBackspaceMode } from "../types";
const CURSOR_SEQUENCES: Record<string, [normal: string, application: string]> =
{
ArrowUp: ["\x1b[A", "\x1bOA"],
ArrowDown: ["\x1b[B", "\x1bOB"],
ArrowRight: ["\x1b[C", "\x1bOC"],
ArrowLeft: ["\x1b[D", "\x1bOD"],
};
export function getAndroidHardwareKeySequence(
event: Pick<
KeyboardEvent,
"key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey"
>,
applicationCursorKeys: boolean,
backspaceMode: HostBackspaceMode | undefined,
): string | null {
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
return null;
}
const cursor = CURSOR_SEQUENCES[event.key];
if (cursor) return cursor[applicationCursorKeys ? 1 : 0];
if (event.key === "Delete") return "\x1b[3~";
if (event.key === "Backspace" && backspaceMode !== "control-h") {
return "\x7f";
}
return null;
}
@@ -0,0 +1,110 @@
import React, { useEffect, useRef } from "react";
import { cn } from "@termix/plugin-sdk/ui";
interface CommandAutocompleteProps {
suggestions: string[];
selectedIndex: number;
onSelect: (command: string) => void;
position: { top: number; left: number };
visible: boolean;
}
interface CommandAutosuggestionProps {
suggestion: string;
position: { top: number; left: number };
style?: React.CSSProperties;
visible: boolean;
}
export function CommandAutosuggestion({
suggestion,
position,
style,
visible,
}: CommandAutosuggestionProps) {
if (!visible || !suggestion) {
return null;
}
return (
<div
className="pointer-events-none fixed z-[9998] whitespace-pre text-muted-foreground/45"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
fontVariantLigatures: "none",
fontFeatureSettings: '"liga" 0, "calt" 0',
textRendering: "optimizeLegibility",
WebkitFontSmoothing: "antialiased",
transition: "opacity 80ms ease-out",
willChange: "contents",
...style,
}}
>
{suggestion}
</div>
);
}
export function CommandAutocomplete({
suggestions,
selectedIndex,
onSelect,
position,
visible,
}: CommandAutocompleteProps) {
const containerRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (selectedRef.current && containerRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [selectedIndex]);
if (!visible || suggestions.length === 0) {
return null;
}
const footerHeight = 32;
const maxSuggestionsHeight = 240 - footerHeight;
return (
<div
ref={containerRef}
className="fixed z-[9999] bg-popover border border-border shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
maxHeight: "240px",
}}
>
<div
className="overflow-y-auto thin-scrollbar"
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
>
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors text-foreground",
"hover:bg-muted",
index === selectedIndex && "bg-accent text-accent-foreground",
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => {}}
>
{suggestion}
</div>
))}
</div>
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-border bg-muted/50 shrink-0">
Tab/Enter to complete • ↑↓ to navigate • Esc to close
</div>
</div>
);
}
@@ -0,0 +1,86 @@
/* eslint-disable react-refresh/only-export-components */
import React, {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
interface CommandHistoryContextType {
commandHistory: string[];
isLoading: boolean;
setCommandHistory: (history: string[]) => void;
setIsLoading: (loading: boolean) => void;
onSelectCommand?: (command: string) => void;
setOnSelectCommand: (callback: (command: string) => void) => void;
onDeleteCommand?: (command: string) => void;
setOnDeleteCommand: (callback: (command: string) => void) => void;
openCommandHistory: () => void;
setOpenCommandHistory: (callback: () => void) => void;
}
const CommandHistoryContext = createContext<
CommandHistoryContextType | undefined
>(undefined);
export function CommandHistoryProvider({ children }: { children: ReactNode }) {
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [onSelectCommand, setOnSelectCommand] = useState<
((command: string) => void) | undefined
>(undefined);
const [onDeleteCommand, setOnDeleteCommand] = useState<
((command: string) => void) | undefined
>(undefined);
const [openCommandHistory, setOpenCommandHistory] = useState<
(() => void) | undefined
>(() => () => {});
const handleSetOnSelectCommand = useCallback(
(callback: (command: string) => void) => {
setOnSelectCommand(() => callback);
},
[],
);
const handleSetOnDeleteCommand = useCallback(
(callback: (command: string) => void) => {
setOnDeleteCommand(() => callback);
},
[],
);
const handleSetOpenCommandHistory = useCallback((callback: () => void) => {
setOpenCommandHistory(() => callback);
}, []);
return (
<CommandHistoryContext.Provider
value={{
commandHistory,
isLoading,
setCommandHistory,
setIsLoading,
onSelectCommand,
setOnSelectCommand: handleSetOnSelectCommand,
onDeleteCommand,
setOnDeleteCommand: handleSetOnDeleteCommand,
openCommandHistory: openCommandHistory || (() => {}),
setOpenCommandHistory: handleSetOpenCommandHistory,
}}
>
{children}
</CommandHistoryContext.Provider>
);
}
export function useCommandHistory() {
const context = useContext(CommandHistoryContext);
if (context === undefined) {
throw new Error(
"useCommandHistory must be used within a CommandHistoryProvider",
);
}
return context;
}
@@ -0,0 +1,132 @@
import { useRef, useCallback } from "react";
import { usePluginApi } from "@termix/plugin-sdk/frontend";
import { saveCommandToHistory } from "../../terminal-api";
const SENSITIVE_PATTERNS = [
/\bpassw(or)?d\b/i,
/\bsecret\b/i,
/\btoken\b/i,
/\bapi.?key\b/i,
/\bPASS(WORD)?=/i,
/\bAWS_SECRET/i,
/\bmysql\b.*-p/i,
/\bsudo\s+-S\b/,
/\bhtpasswd\b/i,
/\bsshpass\b/i,
/\bcurl\b.*-u\s/i,
/\bexport\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
];
interface UseCommandTrackerOptions {
hostId?: number;
enabled?: boolean;
onCommandExecuted?: (command: string) => void;
}
interface CommandTrackerResult {
trackInput: (data: string) => void;
getCurrentCommand: () => string;
clearCurrentCommand: () => void;
updateCurrentCommand: (command: string) => void;
}
export function useCommandTracker({
hostId,
enabled = true,
onCommandExecuted,
}: UseCommandTrackerOptions): CommandTrackerResult {
const api = usePluginApi();
const currentCommandRef = useRef<string>("");
const isInEscapeSequenceRef = useRef<boolean>(false);
const trackInput = useCallback(
(data: string) => {
if (!enabled || !hostId) {
return;
}
for (let i = 0; i < data.length; i++) {
const char = data[i];
const charCode = char.charCodeAt(0);
if (charCode === 27) {
isInEscapeSequenceRef.current = true;
continue;
}
if (isInEscapeSequenceRef.current) {
if (
(charCode >= 65 && charCode <= 90) ||
(charCode >= 97 && charCode <= 122) ||
charCode === 126
) {
isInEscapeSequenceRef.current = false;
}
continue;
}
if (charCode === 13 || charCode === 10) {
const command = currentCommandRef.current.trim();
if (command.length > 0) {
const isSensitive = SENSITIVE_PATTERNS.some((p) => p.test(command));
if (!isSensitive) {
saveCommandToHistory(api, hostId, command).catch((error) => {
console.error("Failed to save command to history:", error);
});
}
if (onCommandExecuted) {
onCommandExecuted(command);
}
}
currentCommandRef.current = "";
continue;
}
if (charCode === 8 || charCode === 127) {
if (currentCommandRef.current.length > 0) {
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
}
continue;
}
if (charCode === 3 || charCode === 4) {
currentCommandRef.current = "";
continue;
}
if (charCode === 21) {
currentCommandRef.current = "";
continue;
}
if (charCode >= 32 && charCode <= 126) {
currentCommandRef.current += char;
}
}
},
[enabled, hostId, onCommandExecuted],
);
const getCurrentCommand = useCallback(() => {
return currentCommandRef.current;
}, []);
const clearCurrentCommand = useCallback(() => {
currentCommandRef.current = "";
}, []);
const updateCurrentCommand = useCallback((command: string) => {
currentCommandRef.current = command;
}, []);
return {
trackInput,
getCurrentCommand,
clearCurrentCommand,
updateCurrentCommand,
};
}
@@ -0,0 +1,40 @@
export function fontSizeStorageKey(identity: unknown): string | null {
return typeof identity === "string" || typeof identity === "number"
? `terminal_fontsize_host_${identity}`
: null;
}
export function readFontSize(
storage: Storage,
key: string | null,
configured: number,
): number | null {
if (!key) return null;
try {
const value = JSON.parse(storage.getItem(key) || "null");
if (
value?.configured === configured &&
Number.isFinite(value.size) &&
value.size > 0
)
return value.size;
storage.removeItem(key);
} catch {
/* Storage may be unavailable. */
}
return null;
}
export function saveFontSize(
storage: Storage,
key: string | null,
configured: number,
size: number,
): void {
if (!key) return;
try {
storage.setItem(key, JSON.stringify({ configured, size }));
} catch {
/* Storage may be unavailable. */
}
}
@@ -0,0 +1,249 @@
import React from "react";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import {
Input,
Button,
Separator,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
cn,
} from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface TerminalSearchBarProps {
visible: boolean;
query: string;
onQueryChange: (query: string) => void;
onFindNext: () => void;
onFindPrevious: () => void;
onClose: () => void;
caseSensitive: boolean;
onToggleCaseSensitive: () => void;
wholeWord: boolean;
onToggleWholeWord: () => void;
regex: boolean;
onToggleRegex: () => void;
resultIndex: number;
resultCount: number;
inputRef: React.RefObject<HTMLInputElement | null>;
}
interface SearchToggleProps {
label: string;
active: boolean;
onClick: () => void;
className?: string;
children: React.ReactNode;
}
function SearchToggle({
label,
active,
onClick,
className,
children,
}: SearchToggleProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={label}
aria-pressed={active}
onClick={onClick}
className={cn(
"font-mono text-[11px] leading-none text-muted-foreground",
active &&
"border-border bg-muted text-foreground hover:bg-muted dark:bg-muted/60",
className,
)}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
);
}
export function TerminalSearchBar({
visible,
query,
onQueryChange,
onFindNext,
onFindPrevious,
onClose,
caseSensitive,
onToggleCaseSensitive,
wholeWord,
onToggleWholeWord,
regex,
onToggleRegex,
resultIndex,
resultCount,
inputRef,
}: TerminalSearchBarProps) {
const { t } = useTranslation();
if (!visible) {
return null;
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
onFindPrevious();
} else {
onFindNext();
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
// Stop keys like Ctrl+C/Ctrl+V from bubbling up to xterm's document-level
// paste/clipboard handling while the search input is focused.
e.stopPropagation();
};
const hasQuery = query.length > 0;
const noResults = hasQuery && resultCount === 0;
const resultLabel = !hasQuery
? ""
: resultCount === 0
? t("terminal.searchNoResults")
: t("terminal.searchResultCount", {
index: resultIndex + 1,
count: resultCount,
});
return (
<TooltipProvider delayDuration={500}>
<div
className="absolute top-2 right-2 z-[130] flex items-center gap-1 border border-border bg-background/95 p-1 shadow-lg backdrop-blur-sm"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="relative flex items-center">
<Input
ref={inputRef}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("terminal.searchPlaceholder")}
aria-label={t("terminal.searchPlaceholder")}
className={cn(
"h-7 w-48 pr-16 font-mono",
noResults &&
"border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
/>
<span
className={cn(
"pointer-events-none absolute right-2 font-mono text-[11px] tabular-nums select-none",
noResults ? "text-destructive" : "text-muted-foreground",
)}
>
{resultLabel}
</span>
</div>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<div className="flex items-center gap-0.5">
<SearchToggle
label={t("terminal.searchCaseSensitive")}
active={caseSensitive}
onClick={onToggleCaseSensitive}
>
Aa
</SearchToggle>
<SearchToggle
label={t("terminal.searchWholeWord")}
active={wholeWord}
onClick={onToggleWholeWord}
className="underline underline-offset-2"
>
ab
</SearchToggle>
<SearchToggle
label={t("terminal.searchRegex")}
active={regex}
onClick={onToggleRegex}
>
.*
</SearchToggle>
</div>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t("terminal.searchPrevious")}
disabled={!hasQuery || resultCount === 0}
onClick={onFindPrevious}
>
<ChevronUp />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("terminal.searchPrevious")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t("terminal.searchNext")}
disabled={!hasQuery || resultCount === 0}
onClick={onFindNext}
>
<ChevronDown />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("terminal.searchNext")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t("terminal.searchClose")}
onClick={onClose}
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("terminal.searchClose")}
</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,47 @@
import type { Terminal } from "@xterm/xterm";
/**
* How much scrollback to hand out by default. Bounded on purpose: a
* long-running session holds megabytes, and nothing consuming this wants all
* of it.
*/
export const DEFAULT_BUFFER_CONTEXT_LINES = 200;
/**
* Serializes the tail of a terminal's buffer to plain text.
*
* Wrapped rows are joined back onto the line they continue. xterm splits one
* logical line across several buffer rows when it wraps, so emitting a newline
* per row would chop long commands in half.
*/
export function getTerminalBufferText(
terminal: Terminal | null | undefined,
maxLines: number = DEFAULT_BUFFER_CONTEXT_LINES,
): string {
if (!terminal || maxLines <= 0) return "";
try {
const buffer = terminal.buffer.active;
const end = buffer.baseY + terminal.rows;
const start = Math.max(0, end - maxLines);
const lines: string[] = [];
for (let i = start; i < end; i++) {
const line = buffer.getLine(i);
if (!line) continue;
const text = line.translateToString(true);
if (line.isWrapped && lines.length > 0) {
lines[lines.length - 1] += text;
} else {
lines.push(text);
}
}
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines.join("\n");
} catch {
// Called from a click handler. A disposed terminal returns nothing
// rather than breaking the click.
return "";
}
}
@@ -0,0 +1,26 @@
export type TerminalContextMenuAction = "native" | "copy" | "paste";
export function resolveTerminalContextMenuAction({
rightClickCopyPaste,
copyOnSelect,
hasSelection,
}: {
rightClickCopyPaste: boolean;
copyOnSelect: boolean;
hasSelection: boolean;
}): TerminalContextMenuAction {
if (!rightClickCopyPaste) return "native";
return hasSelection && !copyOnSelect ? "copy" : "paste";
}
export function selectedTextToCopy({
copyOnSelect,
button,
selection,
}: {
copyOnSelect: boolean;
button: number;
selection: string;
}): string | null {
return copyOnSelect && button === 0 && selection ? selection : null;
}
@@ -0,0 +1,152 @@
import type { Terminal } from "@xterm/xterm";
import { getCookie } from "@termix/plugin-sdk/ui";
import { isPhysicalShortcutKey } from "./terminal-key-event";
export function getUseRightClickCopyPaste(): boolean {
return getCookie("rightClickCopyPaste") !== "false";
}
export interface TerminalClipboardActions {
writeTextToClipboard: (text: string) => Promise<unknown>;
readTextFromClipboard: () => Promise<string>;
}
export interface TerminalClipboardKeyOptions {
/**
* How a plain Ctrl/Cmd+V with no other modifiers is handled.
* "native" (default, matches the SSH terminal) leaves the key alone so the
* browser's own paste event fires - this avoids re-triggering the OS
* clipboard permission prompt on every keystroke, but requires the caller
* to also listen for the DOM "paste" event.
* "explicit" reads the clipboard directly instead, for terminals with no
* such paste event listener of their own.
*/
plainPasteMode?: "native" | "explicit";
}
/**
* Shared copy/paste keyboard shortcuts for xterm-based terminals:
* Ctrl/Cmd+C copies the current selection (Ctrl+C also clears it, matching
* its dual use as SIGINT when nothing is selected), Ctrl+Insert copies
* without clearing, and Ctrl+Shift+C / Ctrl+Shift+V always copy/paste
* regardless of selection state. Returns false (having called
* preventDefault/stopPropagation) once it has handled the event, true
* otherwise so callers can keep processing the keydown.
*/
export function handleTerminalClipboardKeyEvent(
e: KeyboardEvent,
terminal: Terminal,
{ writeTextToClipboard, readTextFromClipboard }: TerminalClipboardActions,
{ plainPasteMode = "native" }: TerminalClipboardKeyOptions = {},
): boolean {
const isCopyKey = isPhysicalShortcutKey(e, "KeyC", "c");
const isPasteKey = isPhysicalShortcutKey(e, "KeyV", "v");
if (
e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
isCopyKey &&
terminal.hasSelection()
) {
const selection = terminal.getSelection();
if (selection) {
e.preventDefault();
e.stopPropagation();
writeTextToClipboard(selection);
terminal.clearSelection();
return false;
}
}
if (
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey && isCopyKey) ||
(e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.key === "Insert")
) {
const selection = terminal.getSelection();
if (selection) {
e.preventDefault();
e.stopPropagation();
writeTextToClipboard(selection);
return false;
}
}
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isCopyKey) {
const selection = terminal.getSelection();
if (selection) {
e.preventDefault();
e.stopPropagation();
writeTextToClipboard(selection);
terminal.clearSelection();
return false;
}
}
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isPasteKey) {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard().then((text) => {
if (text) terminal.paste(text);
});
return false;
}
const plainModPaste =
((e.ctrlKey && !e.metaKey) || (e.metaKey && !e.ctrlKey)) &&
!e.shiftKey &&
!e.altKey &&
isPasteKey;
if (plainModPaste) {
if (plainPasteMode === "explicit") {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard().then((text) => {
if (text) terminal.paste(text);
});
}
// "native": leave the event alone so the browser's own paste event fires.
return false;
}
return true;
}
export interface TerminalContextMenuOptions {
/** Optional Ctrl+right-click passthrough, e.g. to open a file manager. */
onCtrlClick?: () => void;
}
/**
* Builds a "contextmenu" listener implementing right-click copy/paste:
* copies the selection if one exists, otherwise pastes. Gated behind the
* user's "rightClickCopyPaste" preference cookie.
*/
export function createTerminalContextMenuHandler(
terminal: Terminal,
{ writeTextToClipboard, readTextFromClipboard }: TerminalClipboardActions,
{ onCtrlClick }: TerminalContextMenuOptions = {},
): (e: MouseEvent) => void {
return (e: MouseEvent) => {
if (e.ctrlKey && onCtrlClick) {
e.preventDefault();
e.stopPropagation();
onCtrlClick();
return;
}
if (!getUseRightClickCopyPaste()) return;
e.preventDefault();
e.stopPropagation();
if (terminal.hasSelection()) {
const text = terminal.getSelection();
writeTextToClipboard(text).then(() => terminal.clearSelection());
} else {
readTextFromClipboard().then((text) => {
if (text) terminal.paste(text);
});
}
};
}
@@ -0,0 +1,6 @@
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
@@ -0,0 +1,10 @@
/**
* Return an absolute POSIX path as one safe shell argument for terminal input.
*
* The backend already constrains this to an absolute agent-visible path. This
* helper handles spaces and shell metacharacters at the final terminal-input
* boundary so configured paths cannot become shell syntax.
*/
export function quoteTerminalImagePath(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
@@ -0,0 +1,15 @@
export type TerminalImageUploadSource = "file" | "clipboard";
export function buildImageUploadFormData(
file: File,
instanceId: string,
source: TerminalImageUploadSource,
clientUploadTimestamp = new Date().toISOString(),
): FormData {
const form = new FormData();
form.append("image", file);
form.append("instanceId", instanceId);
form.append("source", source);
form.append("clientUploadTimestamp", clientUploadTimestamp);
return form;
}
@@ -0,0 +1,13 @@
export function isTabKeyEvent(event: KeyboardEvent): boolean {
return event.key === "Tab" || event.code === "Tab" || event.keyCode === 9;
}
export function isPhysicalShortcutKey(
event: Pick<KeyboardEvent, "code" | "key">,
code: string,
fallbackKey: string,
): boolean {
return event.code
? event.code === code
: event.key.toLowerCase() === fallbackKey;
}
@@ -0,0 +1,72 @@
import type { Host } from "../types";
/**
* The slots the terminal offers plugins, and what it hands them.
*
* - "terminal.toolbar" (buttons): actions are invoked with a TerminalSlotApi.
* - "terminal.toolbarStatus" (components): small live readouts at the end
* of the toolbar (host metrics), rendered with TerminalToolbarStatusProps.
* - "terminal.sidePanel" (components): a side panel an action opens with
* `openSidePanel(id)`, rendered with TerminalSidePanelProps.
* - "terminal.overlay" (components): always mounted over the terminal and
* given the session's server messages, for connection flows a plugin owns
* (a Tailscale check, say).
*
* Every contribution's `when` sees `{ host }`.
*/
export const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar";
export const TERMINAL_TOOLBAR_STATUS_SLOT = "terminal.toolbarStatus";
export const TERMINAL_SIDE_PANEL_SLOT = "terminal.sidePanel";
export const TERMINAL_OVERLAY_SLOT = "terminal.overlay";
export interface TerminalSlotApi {
host: Host | undefined;
/** The visible scrollback, read at call time. */
getBufferText: () => string;
/** The live session's id, once the server created it. */
sessionId: () => string | null;
/** Opens a "terminal.sidePanel" contribution by its actionId. */
openSidePanel: (panelId: string, props?: Record<string, unknown>) => void;
/** Types a command into the session and runs it. */
runCommand: (command: string) => void;
}
export interface TerminalToolbarStatusProps {
host: Host;
isConnected: boolean;
/** Visible on a desktop viewport and connected: poll only while true. */
active: boolean;
}
export interface TerminalSidePanelProps {
host: Host | undefined;
hostId: number | undefined;
hostLabel: string;
/** Whatever the opening action passed to openSidePanel. */
panelProps: Record<string, unknown>;
onClose: () => void;
onRunInTerminal: (command: string) => void;
}
export interface TerminalSessionMessage {
type: string;
[key: string]: unknown;
}
export interface TerminalOverlayProps {
host: Host | undefined;
backgroundColor?: string;
/** Server messages for this session, plus a local "session_closed". */
subscribe: (
listener: (message: TerminalSessionMessage) => void,
) => () => void;
/**
* Holds the connect timeout while the overlay waits on the user, e.g. an
* approval in another window. Release it when done.
*/
holdConnectTimeout: (held: boolean) => void;
/** Ends the attempt with an error shown in the terminal. */
fail: (message: string) => void;
/** Closes the session's connection. */
disconnect: () => void;
}
@@ -0,0 +1,43 @@
import type { TerminalConfig } from "../types";
export interface TerminalHostConfig {
id?: number;
instanceId?: string;
restoredSessionId?: string | null;
/** Set when this tab joins someone else's live shared SSH session instead of connecting/attaching. */
joinSharedSessionId?: string | null;
joinShareId?: string | null;
name?: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
credentialId?: number;
terminalConfig?: Partial<TerminalConfig>;
[key: string]: unknown;
}
export interface TerminalHandle {
disconnect: () => void;
reconnect: () => void;
isConnected: () => boolean;
fit: () => void;
focus: () => void;
sendInput: (data: string) => void;
subscribeOutput: (listener: (data: string) => void) => () => void;
paste: (text: string) => void;
notifyResize: () => void;
refresh: () => void;
getApplicationCursorKeysMode: () => boolean;
/** What the shell's share action needs, or null while there is nothing to share. */
getShareTarget: () => {
hostId: number;
sessionId: string;
protocol: "ssh";
tabInstanceId?: string;
} | null;
}
@@ -0,0 +1,118 @@
export interface ToolbarPosition {
x: number;
y: number;
}
export interface RectLike {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
}
export type ToolbarDensity = "icon" | "labeled" | "expanded";
export const TOOLBAR_POSITION_STORAGE_KEY =
"termix-terminal-toolbar-position-v2";
const TOOLBAR_MARGIN = 8;
const RECOVERY_SIZE = 44;
export function getDefaultToolbarPosition(): ToolbarPosition {
return { x: 0, y: 0 };
}
export function sanitizeToolbarPosition(value: unknown): ToolbarPosition {
if (!value || typeof value !== "object") return getDefaultToolbarPosition();
const candidate = value as Partial<ToolbarPosition>;
return Number.isFinite(candidate.x) && Number.isFinite(candidate.y)
? { x: Number(candidate.x), y: Number(candidate.y) }
: getDefaultToolbarPosition();
}
function readStoredPosition(storageKey: string): ToolbarPosition {
if (typeof window === "undefined") return getDefaultToolbarPosition();
try {
return sanitizeToolbarPosition(
JSON.parse(window.localStorage.getItem(storageKey) ?? "null"),
);
} catch {
return getDefaultToolbarPosition();
}
}
function persistPosition(storageKey: string, position: ToolbarPosition): void {
try {
window.localStorage.setItem(
storageKey,
JSON.stringify(sanitizeToolbarPosition(position)),
);
} catch {
// Storage may be unavailable in hardened browser contexts.
}
}
export function readStoredToolbarPosition(): ToolbarPosition {
return readStoredPosition(TOOLBAR_POSITION_STORAGE_KEY);
}
export function persistToolbarPosition(position: ToolbarPosition): void {
persistPosition(TOOLBAR_POSITION_STORAGE_KEY, position);
}
export function clampToolbarPosition(
position: ToolbarPosition,
toolbarRect: RectLike,
hostRect: RectLike,
renderedPosition: ToolbarPosition = position,
): ToolbarPosition {
if (
!toolbarRect.width ||
!toolbarRect.height ||
!hostRect.width ||
!hostRect.height
)
return sanitizeToolbarPosition(position);
const left = hostRect.left + TOOLBAR_MARGIN;
const right = hostRect.right - TOOLBAR_MARGIN;
const top = hostRect.top + TOOLBAR_MARGIN;
const bottom = hostRect.bottom - TOOLBAR_MARGIN;
const baseLeft = toolbarRect.left - renderedPosition.x;
const baseRight = toolbarRect.right - renderedPosition.x;
const baseTop = toolbarRect.top - renderedPosition.y;
const baseBottom = toolbarRect.bottom - renderedPosition.y;
const clamp = (min: number, max: number, value: number) =>
Math.min(max, Math.max(min, value));
const xMin =
toolbarRect.width <= right - left
? left - baseLeft
: left + RECOVERY_SIZE - baseRight;
const xMax = right - baseRight;
const yMin =
toolbarRect.height <= bottom - top
? top - baseTop
: top + RECOVERY_SIZE - baseBottom;
const yMax = bottom - baseBottom;
return { x: clamp(xMin, xMax, position.x), y: clamp(yMin, yMax, position.y) };
}
export function getResponsiveToolbarDensity(
selectedDensity: ToolbarDensity,
currentDensity: ToolbarDensity,
availableInlineSize: number,
requiredInlineSize: number,
): ToolbarDensity {
if (selectedDensity === "icon") return "icon";
if (!Number.isFinite(availableInlineSize) || availableInlineSize <= 0)
return currentDensity;
if (!Number.isFinite(requiredInlineSize) || requiredInlineSize <= 0)
return currentDensity;
if (currentDensity === "icon")
return availableInlineSize >= requiredInlineSize * 1.15
? selectedDensity
: currentDensity;
return availableInlineSize < requiredInlineSize * 1.1
? "icon"
: selectedDensity;
}
@@ -0,0 +1,31 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import { getClientSettings } from "../terminal-api";
import {
TOUCH_INPUT_DEFAULTS,
type TouchInputSettings,
} from "../../shared/touch-input-settings";
let cached: TouchInputSettings | undefined;
let pending: Promise<TouchInputSettings> | undefined;
export function loadTouchInputSettings(
api: PluginApiClient,
): Promise<TouchInputSettings> {
if (cached) return Promise.resolve(cached);
pending ??= getClientSettings(api)
.then((settings) => (cached = settings.touchInput))
.catch(() => (cached = { ...TOUCH_INPUT_DEFAULTS }))
.finally(() => {
pending = undefined;
});
return pending;
}
export function cacheTouchInputSettings(settings: TouchInputSettings): void {
cached = settings;
}
export function resetTouchInputSettingsCache(): void {
cached = undefined;
pending = undefined;
}
@@ -0,0 +1,335 @@
import {
TOUCH_INPUT_DEFAULTS,
type TouchInputSettings,
} from "../../shared/touch-input-settings";
export interface TouchWheelPoint {
clientX: number;
clientY: number;
}
export interface SyntheticWheelInput extends TouchWheelPoint {
deltaY: number;
deltaMode: number;
}
interface TouchWheelTiming {
now: () => number;
requestFrame: (callback: FrameRequestCallback) => number;
cancelFrame: (handle: number) => void;
momentumAllowed: () => boolean;
}
const defaultTiming: TouchWheelTiming = {
now: () => performance.now(),
requestFrame: (callback) => requestAnimationFrame(callback),
cancelFrame: (handle) => cancelAnimationFrame(handle),
momentumAllowed: () => true,
};
interface VelocitySample {
deltaY: number;
durationMs: number;
endedAt: number;
}
export class TouchWheelCoordinator {
private startY: number | undefined;
private lastPoint: TouchWheelPoint | undefined;
private pendingDeltaY = 0;
private scrolling = false;
private cancelled = false;
private velocitySamples: VelocitySample[] = [];
private lastMoveTime: number | undefined;
private momentumFrame: number | undefined;
private momentumRemainderY = 0;
constructor(
private readonly emitWheel: (input: SyntheticWheelInput) => void,
private readonly dragThresholdPx: number = TOUCH_INPUT_DEFAULTS.dragThresholdPx,
private readonly maxWheelDeltaPx: number = TOUCH_INPUT_DEFAULTS.maxWheelDeltaPx,
private readonly timing: TouchWheelTiming = defaultTiming,
private readonly settings: TouchInputSettings = TOUCH_INPUT_DEFAULTS,
) {}
start(point: TouchWheelPoint | undefined, time = this.timing.now()): void {
this.reset();
if (!point) {
this.cancelled = true;
return;
}
this.startY = point.clientY;
this.lastPoint = point;
this.lastMoveTime = time;
}
move(point: TouchWheelPoint | undefined, time = this.timing.now()): boolean {
if (this.cancelled || !point || !this.lastPoint) {
if (!point) this.cancel();
return false;
}
const deltaY = this.lastPoint.clientY - point.clientY;
this.pendingDeltaY += deltaY;
this.lastPoint = point;
if (this.lastMoveTime !== undefined && time > this.lastMoveTime) {
this.velocitySamples.push({
deltaY,
durationMs: time - this.lastMoveTime,
endedAt: time,
});
this.velocitySamples = this.velocitySamples.filter(
(sample) =>
time - sample.endedAt <= this.settings.momentumSampleWindowMs,
);
}
this.lastMoveTime = time;
if (!this.scrolling) {
if (Math.abs(point.clientY - this.startY!) < this.dragThresholdPx) {
return true;
}
this.scrolling = true;
}
this.flush(point);
return true;
}
end(time = this.timing.now()): void {
const point = this.lastPoint;
const velocity = this.scrolling ? this.recentVelocity(time) : 0;
this.clearGesture();
if (
point &&
this.timing.momentumAllowed() &&
Math.abs(velocity) >= this.settings.minimumVelocityPxPerMs
) {
this.startMomentum(point, velocity, time);
}
}
cancel(): void {
this.reset();
this.cancelled = true;
}
private flush(point: TouchWheelPoint): void {
while (Math.abs(this.pendingDeltaY) > this.maxWheelDeltaPx) {
const deltaY = Math.sign(this.pendingDeltaY) * this.maxWheelDeltaPx;
this.emitWheel({
...point,
deltaY,
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
});
this.pendingDeltaY -= deltaY;
}
if (this.pendingDeltaY !== 0) {
this.emitWheel({
...point,
deltaY: this.pendingDeltaY,
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
});
this.pendingDeltaY = 0;
}
}
private reset(): void {
this.stopMomentum();
this.clearGesture();
this.cancelled = false;
}
private clearGesture(): void {
this.startY = undefined;
this.lastPoint = undefined;
this.pendingDeltaY = 0;
this.scrolling = false;
this.velocitySamples = [];
this.lastMoveTime = undefined;
}
private recentVelocity(time: number): number {
const newestSampleTime = this.velocitySamples.at(-1)?.endedAt;
if (
newestSampleTime === undefined ||
time - newestSampleTime > this.settings.releaseGracePeriodMs
) {
return 0;
}
const samples = this.velocitySamples.filter(
(sample) =>
newestSampleTime - sample.endedAt <=
this.settings.momentumSampleWindowMs,
);
const duration = samples.reduce(
(total, sample) => total + sample.durationMs,
0,
);
if (duration === 0) return 0;
const distance = samples.reduce(
(total, sample) => total + sample.deltaY,
0,
);
return Math.max(
-this.settings.maximumVelocityPxPerMs,
Math.min(this.settings.maximumVelocityPxPerMs, distance / duration),
);
}
private startMomentum(
point: TouchWheelPoint,
initialVelocity: number,
startedAt: number,
): void {
let lastTime = startedAt;
let travelled = 0;
const step = (time: number) => {
if (!this.timing.momentumAllowed()) {
this.momentumFrame = undefined;
this.momentumRemainderY = 0;
return;
}
const elapsed = Math.max(0, time - startedAt);
const remainingTravel = this.settings.maximumTravelPx - travelled;
if (elapsed > this.settings.maximumDurationMs || remainingTravel <= 0) {
this.momentumFrame = undefined;
return;
}
const duration = Math.min(
Math.max(0, time - lastTime),
this.settings.maximumFrameIntervalMs,
);
if (duration > 0) {
const frameStart = Math.max(0, elapsed - duration);
const distance =
Math.abs(initialVelocity) *
this.settings.decayTimeMs *
(Math.exp(-frameStart / this.settings.decayTimeMs) -
Math.exp(-elapsed / this.settings.decayTimeMs));
const deltaY =
Math.sign(initialVelocity) * Math.min(distance, remainingTravel);
this.flushMomentum(point, deltaY);
travelled += Math.abs(deltaY);
}
lastTime = time;
if (
elapsed < this.settings.maximumDurationMs &&
travelled < this.settings.maximumTravelPx
) {
this.momentumFrame = this.timing.requestFrame(step);
} else {
this.momentumFrame = undefined;
}
};
this.momentumFrame = this.timing.requestFrame(step);
}
private flushMomentum(point: TouchWheelPoint, deltaY: number): void {
this.momentumRemainderY += deltaY;
const availableTicks = Math.trunc(
Math.abs(this.momentumRemainderY) / this.settings.pixelsPerTick,
);
const tickCount = Math.min(
availableTicks,
this.settings.maximumTicksPerFrame,
);
const direction = Math.sign(this.momentumRemainderY);
for (let index = 0; index < tickCount; index++) {
this.emitWheel({
...point,
deltaY: direction,
deltaMode: WheelEvent.DOM_DELTA_LINE,
});
}
this.momentumRemainderY -=
direction * tickCount * this.settings.pixelsPerTick;
}
private stopMomentum(): void {
if (this.momentumFrame !== undefined) {
this.timing.cancelFrame(this.momentumFrame);
this.momentumFrame = undefined;
}
this.momentumRemainderY = 0;
}
}
export function installTouchWheelCoordinator(
terminalElement: HTMLElement,
momentumAllowed: () => boolean = () => true,
settings: TouchInputSettings = TOUCH_INPUT_DEFAULTS,
): () => void {
if (!settings.enabled) return () => {};
const wheelElement = terminalElement.querySelector<HTMLElement>(
".xterm-scrollable-element",
);
if (!wheelElement) return () => {};
const coordinator = new TouchWheelCoordinator(
({ deltaY, deltaMode, clientX, clientY }) => {
wheelElement.dispatchEvent(
new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
composed: true,
clientX,
clientY,
deltaMode,
deltaY,
}),
);
},
settings.dragThresholdPx,
settings.maxWheelDeltaPx,
{
...defaultTiming,
momentumAllowed: () =>
settings.momentumEnabled &&
momentumAllowed() &&
!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches,
},
settings,
);
const getSingleTouch = (event: TouchEvent): Touch | undefined =>
event.touches.length === 1
? (event.touches.item(0) ?? undefined)
: undefined;
const handleTouchStart = (event: TouchEvent) => {
const touch = getSingleTouch(event);
coordinator.start(touch);
};
const handleTouchMove = (event: TouchEvent) => {
if (coordinator.move(getSingleTouch(event))) event.preventDefault();
};
const handleTouchEnd = () => coordinator.end();
const handleTouchCancel = () => coordinator.cancel();
terminalElement.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
terminalElement.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
terminalElement.addEventListener("touchend", handleTouchEnd, {
passive: true,
});
terminalElement.addEventListener("touchcancel", handleTouchCancel, {
passive: true,
});
return () => {
coordinator.cancel();
terminalElement.removeEventListener("touchstart", handleTouchStart);
terminalElement.removeEventListener("touchmove", handleTouchMove);
terminalElement.removeEventListener("touchend", handleTouchEnd);
terminalElement.removeEventListener("touchcancel", handleTouchCancel);
};
}