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

@@ -1,21 +1,17 @@
import { useEffect, useRef } from "react";
import type { TabProps } from "@termix/plugin-sdk/frontend";
import { TerminalTabContent } from "@/features/terminal/TerminalTabContent";
import type { TabRenderProps } from "@/shell/tab-registry";
import type { TerminalHandle } from "@/features/terminal/terminal-types";
import { TerminalTabContent } from "./terminal/TerminalTabContent";
import type { TerminalHandle } from "./terminal/terminal-types";
import { registerSession, setActiveSession } from "./session-registry";
/**
* Wraps the core terminal tab component to register its ref in the session
* registry, so terminal.sendToActive/sendToSession (used by the snippets
* plugin, among others) can reach a live session without core exposing
* terminalRef through the public tab props.
* Registers the tab's terminal handle in the session registry, so
* terminal.sendToActive/sendToSession (used by the snippets plugin, among
* others) can reach a live session.
*/
export function TerminalTabWithRegistry(props: TabProps) {
const tabRecord = props.tab as unknown as {
id: string;
terminalRef?: { current: TerminalHandle | null };
};
const tabRecord = props.tab as { id: string };
const handle = props.handleRef as { current: TerminalHandle | null } | null;
const host = props.host as
| {
id: string;
@@ -33,7 +29,7 @@ export function TerminalTabWithRegistry(props: TabProps) {
const tryRegister = () => {
if (cancelled || registeredRef.current) return;
const ref = tabRecord.terminalRef?.current;
const ref = handle?.current;
if (!ref) {
if (attempts++ < 40) setTimeout(tryRegister, 250);
return;
@@ -64,5 +60,5 @@ export function TerminalTabWithRegistry(props: TabProps) {
if (props.isFocusedPane) setActiveSession(tabRecord.id);
}, [props.isFocusedPane, tabRecord.id]);
return <TerminalTabContent {...(props as unknown as TabRenderProps)} />;
return <TerminalTabContent {...props} />;
}
@@ -0,0 +1,38 @@
import { Suspense, lazy, type Ref } from "react";
import type { TerminalHandle } from "./terminal/terminal-types";
const CommandHistoryProvider = lazy(() =>
import("./terminal/command-history/CommandHistoryContext").then((m) => ({
default: m.CommandHistoryProvider,
})),
);
const Terminal = lazy(() =>
import("./terminal/Terminal").then((m) => ({ default: m.Terminal })),
);
/**
* The terminal as other code embeds it, registered as "terminal.view".
*
* Props are the terminal's own (hostConfig, isVisible, title, splitScreen,
* executeCommand and the rest), plus `handleRef` for the TerminalHandle
* (connect, disconnect, sendInput, fit), since a registered component cannot
* take a React ref across the plugin boundary.
*/
export function TerminalView({
handleRef,
...props
}: {
handleRef?: Ref<TerminalHandle>;
[prop: string]: unknown;
}) {
return (
<Suspense fallback={null}>
<CommandHistoryProvider>
<Terminal
ref={handleRef}
{...(props as unknown as React.ComponentProps<typeof Terminal>)}
/>
</CommandHistoryProvider>
</Suspense>
);
}
@@ -0,0 +1,167 @@
import { useState, useEffect } from "react";
import { Copy, Search, Terminal, Trash2 } from "lucide-react";
import { copyToClipboard, Button, Input } from "@termix/plugin-sdk/ui";
import {
usePluginApi,
useTranslation,
type PanelProps,
} from "@termix/plugin-sdk/frontend";
import {
clearCommandHistory,
deleteCommandFromHistory,
getCommandHistory,
hostSetting,
} from "../terminal-api";
/** Command history for the terminal the user is working in. */
export function HistoryPanel({ targetTab }: PanelProps) {
const { t } = useTranslation();
const api = usePluginApi();
const [search, setSearch] = useState("");
const [commands, setCommands] = useState<string[]>([]);
const activeTab = targetTab;
const activeIsTerminal = !!activeTab;
const hostId = activeTab?.host?.id ? parseInt(activeTab.host.id, 10) : null;
const trackingEnabled = hostSetting(
activeTab?.host,
"enableCommandHistory",
true,
);
useEffect(() => {
if (!hostId || !trackingEnabled) {
setCommands([]);
return;
}
getCommandHistory(api, hostId)
.then(setCommands)
.catch(() => setCommands([]));
}, [api, hostId, trackingEnabled]);
if (activeIsTerminal && !trackingEnabled) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Terminal className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("history.trackingDisabled")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("history.trackingDisabledHint")}
</span>
</div>
</div>
);
}
if (!activeIsTerminal) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Terminal className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("history.noTerminalSelected")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("history.noTerminalSelectedHint")}
</span>
</div>
</div>
);
}
const filtered = search
? commands.filter((c) => c.toLowerCase().includes(search.toLowerCase()))
: commands;
async function handleDelete(cmd: string) {
if (!hostId) return;
try {
await deleteCommandFromHistory(api, hostId, cmd);
setCommands((prev) => prev.filter((c) => c !== cmd));
} catch {
/* ignore */
}
}
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/30 border border-border/60">
<Terminal className="size-3 shrink-0 text-accent-brand" />
<span className="text-xs font-medium truncate text-foreground">
{activeTab.label}
</span>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder={t("history.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t("history.commandCount", { count: filtered.length })}
</span>
<button
onClick={async () => {
if (!hostId) return;
try {
await clearCommandHistory(api, hostId);
} catch {
/* ignore */
}
setCommands([]);
}}
className="text-xs text-accent-brand hover:text-accent-brand/70"
>
{t("history.clearAll")}
</button>
</div>
<div className="flex flex-col gap-1">
{filtered.length === 0 && (
<span className="text-xs text-muted-foreground/60 text-center py-8">
{t("history.noHistoryEntries")}
</span>
)}
{filtered.map((cmd, i) => (
<div
key={i}
className="group flex flex-col gap-1 px-2.5 py-2 border border-border bg-background hover:border-muted-foreground/30 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-mono text-foreground break-all leading-relaxed">
{cmd}
</span>
<div className="flex items-center gap-0.5 shrink-0 md:opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => copyToClipboard(cmd)}
>
<Copy className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(cmd)}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
</div>
))}
</div>
</div>
);
}
+131 -13
View File
@@ -1,19 +1,41 @@
import type { ComponentType } from "react";
import { Terminal } from "lucide-react";
import { lazy, Suspense, type ComponentType } from "react";
import { History, Laptop, Terminal } from "lucide-react";
import type {
PanelProps,
StandaloneViewProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { loadTerminal } from "@/features/terminal/TerminalTabContent";
import TerminalApp from "@/features/terminal/TerminalApp";
import { isElectron } from "@termix/plugin-sdk/ui";
import { loadTerminal } from "./terminal/TerminalTabContent";
import TerminalApp from "./terminal/TerminalApp";
import {
TERMINAL_DOCK_SLOT,
TERMINAL_OVERLAY_SLOT,
TERMINAL_SIDE_PANEL_SLOT,
TERMINAL_TOOLBAR_SLOT,
} from "@/features/terminal/terminal-slots";
TERMINAL_TOOLBAR_STATUS_SLOT,
} from "./terminal/terminal-slots";
import { TerminalTabWithRegistry } from "./TerminalTabWithRegistry";
import { listSessions, sendToActive, sendToSession } from "./session-registry";
import { TerminalView } from "./TerminalView";
import { HistoryPanel } from "./history/HistoryPanel";
import { TouchInputSettings } from "./settings/TouchInputSettings";
import { ImageStorageTest } from "./settings/ImageStorageTest";
import { resetTouchInputSettingsCache } from "./terminal/touch-input-settings-store";
import { hostSetting } from "./terminal-api";
const LocalTerminal = lazy(() =>
import("./local-terminal/LocalTerminal").then((m) => ({
default: m.LocalTerminal,
})),
);
interface TerminalOpenOptions {
path?: string;
joinSharedSessionId?: string;
joinShareId?: string;
label?: string;
}
/** `?view=terminal` full-screen links. */
function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
@@ -25,9 +47,21 @@ function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
);
}
/** The desktop app's own shell, one tab per open. */
function LocalTerminalTab({ tab, isVisible }: TabProps) {
return (
<Suspense fallback={null}>
<LocalTerminal
instanceId={(tab.instanceId as string | undefined) ?? tab.id}
isVisible={isVisible}
/>
</Suspense>
);
}
/**
* The SSH terminal. Its component still lives in core until the terminal's
* own Phase B step; this plugin decides whether the shell offers it.
* The SSH terminal: its tab, the desktop app's local terminal, the command
* history panel and the places other plugins can plug into a session.
*/
export function activate(app: TermixApp): void {
app.registerTab(
@@ -38,6 +72,8 @@ export function activate(app: TermixApp): void {
titleKey: "nav.terminal",
persistent: true,
session: true,
commandTarget: true,
ownBackground: true,
restore: (host) => !!host.enableSsh,
activityTypes: ["terminal"],
standalone: TerminalStandalone,
@@ -54,17 +90,75 @@ export function activate(app: TermixApp): void {
order: 10,
tabType: "terminal",
copyUrlView: "terminal",
when: (host) => !!host.enableSsh && host.enableTerminal !== false,
when: (host) =>
!!host.enableSsh && hostSetting(host, "enableTerminal", true),
});
// Places other plugins can fill: toolbar buttons, a docked side panel and
// overlays that follow the session's connection flow.
if (isElectron()) {
app.registerTab("local-terminal", LocalTerminalTab, {
icon: Laptop,
titleKey: "nav.localTerminal",
hostless: true,
session: true,
ownBackground: true,
multiInstance: true,
inLayouts: false,
});
app.registerRailItem({
id: "local-terminal",
icon: Laptop,
titleKey: "nav.localTerminal",
kind: "tab",
electronOnly: true,
hideable: true,
separatorAfter: true,
});
app.registerPaletteEntry({
id: "local-terminal",
titleKey: "palette.localTerminal",
icon: Laptop,
keywords: ["local", "shell", "terminal"],
scope: "global",
run: (shell) => shell.openSingletonTab("local-terminal"),
});
}
app.registerPanel(
"history",
HistoryPanel as unknown as ComponentType<PanelProps>,
);
app.registerRailItem({
id: "history",
icon: History,
titleKey: "nav.history",
hideable: true,
promotable: true,
rightDockable: true,
separatorAfter: true,
});
// Places other plugins can fill: toolbar buttons and readouts, a side
// panel and overlays that follow the session's connection flow.
app.declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] });
app.declareActionSlot({ id: TERMINAL_DOCK_SLOT, accepts: ["component"] });
app.declareActionSlot({
id: TERMINAL_TOOLBAR_STATUS_SLOT,
accepts: ["component"],
});
app.declareActionSlot({
id: TERMINAL_SIDE_PANEL_SLOT,
accepts: ["component"],
});
app.declareActionSlot({ id: TERMINAL_OVERLAY_SLOT, accepts: ["component"] });
// A terminal other code can embed (collab rooms, the homepage widget, the
// tmux monitor, the file manager's window) without importing this plugin.
app.registerComponent(
"terminal.view",
TerminalView as unknown as ComponentType<Record<string, unknown>>,
);
// Lets another plugin (snippets) push text into a live terminal session
// without reaching into core's tab state itself.
// without reaching into the shell's tab state itself.
app.registerAction("terminal.listSessions", () => listSessions());
app.registerAction("terminal.sendToActive", ((
text: string,
@@ -75,4 +169,28 @@ export function activate(app: TermixApp): void {
text: string,
opts?: { run?: boolean },
) => sendToSession(sessionId, text, opts)) as never);
// Opens a new terminal tab on a host: at a path (the file manager), or
// joining someone else's shared session (the connections panel).
app.registerAction("terminal.open", ((
host: Parameters<TermixApp["tabs"]["openTab"]>[0],
options: TerminalOpenOptions = {},
) =>
app.tabs.openTab(host, "terminal", {
forceNewTab: true,
label: options.label,
data: {
...(options.path ? { initialPath: options.path } : {}),
...(options.joinSharedSessionId
? {
joinSharedSessionId: options.joinSharedSessionId,
joinShareId: options.joinShareId ?? null,
}
: {}),
},
})) as never);
app.registerSettingsComponent("touchInput", TouchInputSettings);
app.registerSettingsComponent("imageStorageTest", ImageStorageTest);
app.onDispose(resetTouchInputSettingsCache);
}
@@ -0,0 +1,103 @@
import type { Terminal } from "@xterm/xterm";
import { type KeybindingAction } from "@termix/plugin-sdk/ui";
import { invokeAction } from "@termix/plugin-sdk/frontend";
export interface SnippetHostContext {
ip?: string;
username?: string;
port?: number | string;
name?: string;
}
export interface KeybindingDispatchContext {
terminal: Terminal;
webSocketRef: React.MutableRefObject<WebSocket | null>;
writeTextToClipboard: (text: string) => Promise<boolean>;
readTextFromClipboard: () => Promise<string>;
/** Host context used to resolve $HOST/$USER/$PORT/$NAME in runSnippet actions. */
hostContext?: SnippetHostContext | null;
/**
* Called instead of sending the snippet directly when its content still has
* unresolved $INPUT_n placeholders after host-variable substitution -- the
* caller is expected to collect values (e.g. via a dialog) and send itself.
*/
onSnippetNeedsInputs?: (snippet: { id: string; content: string }) => void;
}
export function sendRawToSocket(
webSocketRef: React.MutableRefObject<WebSocket | null>,
data: string,
): void {
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
}
}
export function dispatchKeybindingAction(
action: KeybindingAction,
ctx: KeybindingDispatchContext,
): void {
const sendRaw = (data: string) => sendRawToSocket(ctx.webSocketRef, data);
switch (action.type) {
case "copy": {
const selection = ctx.terminal.getSelection();
if (selection) {
ctx.writeTextToClipboard(selection);
ctx.terminal.clearSelection();
}
return;
}
case "paste": {
ctx.readTextFromClipboard().then((text) => {
if (text) ctx.terminal.paste(text);
});
return;
}
case "sendControlCode": {
if (!action.controlCode) return;
const code = action.controlCode.toLowerCase().charCodeAt(0) - 96;
if (code >= 1 && code <= 26) sendRaw(String.fromCharCode(code));
return;
}
case "sendText": {
sendRaw((action.text ?? "") + (action.appendEnter ? "\r" : ""));
return;
}
case "runSnippet": {
if (!action.snippetId) return;
const snippetId = Number(action.snippetId);
if (!Number.isFinite(snippetId)) return;
// Resolving with no inputValues first tells us whether the snippet
// still needs them; the plugin decides and reports back.
void invokeAction(
"snippets.resolveForTerminal",
snippetId,
ctx.hostContext ?? null,
).then((result) => {
const resolved = result as
{ needsInputs: boolean; content: string } | null | undefined;
if (!resolved) return;
if (resolved.needsInputs) {
ctx.onSnippetNeedsInputs?.({
id: action.snippetId!,
content: resolved.content,
});
return;
}
sendRaw(resolved.content + (action.appendEnter !== false ? "\r" : ""));
});
return;
}
case "nextTab":
case "previousTab":
case "openCommandPalette": {
window.dispatchEvent(
new CustomEvent("termix:global-keybinding", {
detail: { type: action.type },
}),
);
return;
}
}
}
@@ -0,0 +1,15 @@
import { isMacPlatform } from "@termix/plugin-sdk/ui";
export function getMacLineNavigationSequence(e: KeyboardEvent): string | null {
if (e.type !== "keydown" || !isMacPlatform()) return null;
if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return null;
switch (e.code) {
case "ArrowLeft":
return "\x01";
case "ArrowRight":
return "\x05";
default:
return null;
}
}
@@ -0,0 +1,100 @@
export type LocalEchoMode = "off" | "auto" | "on";
const PASSWORD_PROMPT =
/(?:password|passphrase|verification code|one[- ]time|otp|token)(?:[^\r\n::]{0,160})?[::]\s*$/i;
const SAFE_INPUT = /^[\x20-\x7e]$/;
type PendingCharacter = { value: string; sentAt: number; predicted: boolean };
export class TerminalLocalEcho {
private pending: PendingCharacter[] = [];
private slowSamples = 0;
private secretInput = false;
private outputTail = "";
constructor(
private readonly mode: LocalEchoMode,
private readonly now: () => number = () => performance.now(),
private readonly latencyThreshold = 120,
) {}
handleInput(data: string): string {
if (data === "\r" || data === "\n") {
return "";
}
if (this.secretInput || !SAFE_INPUT.test(data)) return "";
const predicted =
this.mode === "on" || (this.mode === "auto" && this.slowSamples >= 2);
this.pending.push({ value: data, sentAt: this.now(), predicted });
return predicted ? data : "";
}
handleOutput(data: string): string {
const plainOutput = this.stripAnsi(data);
this.outputTail = (this.outputTail + plainOutput).slice(-128);
if (PASSWORD_PROMPT.test(this.outputTail)) {
const rollback = this.rollback();
this.secretInput = true;
return rollback + data;
}
if (this.secretInput) {
if (data.includes("\n") || data.includes("\r")) this.secretInput = false;
return data;
}
if (this.pending.length === 0) return data;
let consumed = 0;
while (
consumed < data.length &&
consumed < this.pending.length &&
data[consumed] === this.pending[consumed].value
) {
const item = this.pending[consumed];
if (
!item.predicted &&
this.now() - item.sentAt >= this.latencyThreshold
) {
this.slowSamples += 1;
}
consumed += 1;
}
if (consumed > 0) {
const matched = this.pending.splice(0, consumed);
const predictedCount = matched.filter((item) => item.predicted).length;
return data.slice(predictedCount);
}
return this.rollback() + data;
}
reset() {
this.pending = [];
this.slowSamples = 0;
this.secretInput = false;
this.outputTail = "";
}
private rollback() {
const count = this.pending.filter((item) => item.predicted).length;
this.pending = [];
return count > 0 ? `\x1b[${count}D\x1b[${count}X` : "";
}
private stripAnsi(value: string) {
return value.replace(/\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g, "");
}
}
export function resolveLocalEchoMode(
hostMode: "default" | LocalEchoMode | undefined,
storedMode: string | null,
): LocalEchoMode {
if (hostMode && hostMode !== "default") return hostMode;
return storedMode === "on" || storedMode === "off" || storedMode === "auto"
? storedMode
: "auto";
}
@@ -0,0 +1,507 @@
const ANSI = {
reset: "\x1b[0m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
brightBlack: "\x1b[90m",
brightRed: "\x1b[91m",
brightGreen: "\x1b[92m",
brightYellow: "\x1b[93m",
brightBlue: "\x1b[94m",
brightCyan: "\x1b[96m",
bold: "\x1b[1m",
underline: "\x1b[4m",
} as const;
export interface SyntaxHighlightOptions {
logLevels?: boolean;
paths?: boolean;
timestamps?: boolean;
ipAddresses?: boolean;
urls?: boolean;
numbers?: boolean;
}
interface HighlightPattern {
name: string;
regex: RegExp;
ansiCode: string;
priority: number;
category: keyof SyntaxHighlightOptions;
}
interface MatchResult {
start: number;
end: number;
ansiCode: string;
priority: number;
// capture group offset: some patterns capture a sub-group rather than whole match
captureStart?: number;
captureEnd?: number;
}
interface TextSegment {
isAnsi: boolean;
content: string;
// the last active SGR code before this segment (for state restoration)
activeSgr?: string;
}
interface ProtectedRange {
start: number;
end: number;
}
const MAX_LINE_LENGTH = 2000;
// Cursor-positioning, erase, and private mode sequences used by TUI apps
// (mc, nano, vim, htop). If a chunk contains these, highlighting can inject
// extra SGR bytes into a full-screen redraw and corrupt xterm's cursor state.
const TUI_SEQUENCE = /\x1b\[(?:[\d;]*[ABCDEFGHJKST]|\?[\d;]*[hl])/;
const TUI_FRAME_SEQUENCE = /[\u2500-\u257f]|\x1b[()][0B]|\x1b\[[0-9;]*[~`]/;
const CONTROL_STRING_SEQUENCE = /\x1b[\]P^_]/;
// A bare \r (not immediately followed by \n) means the terminal is overwriting
// the current line (shell prompts, progress bars). Highlighting mid-rewrite
// chunks corrupts the cursor state, so we skip the whole chunk.
const MID_LINE_CR = /\r(?!\n)/;
// Detects shell prompt lines (user@host:/path$ or similar) after stripping ANSI.
// These should not be highlighted — the server already colored them, and injecting
// additional ANSI codes into the prompt fragments causes display corruption.
//
// The parameter-byte class (0-9;?>=!) intentionally also includes `<` and `:` —
// SGR mouse-tracking reports (`ESC[<Cb;Cx;CyM`) use `<` as their private-mode
// marker. Without it, mouse reports from TUI apps (especially through a
// multiplexer like screen) aren't recognized as escape sequences and leak
// through as literal "35;191;1M" text on screen.
const STRIP_ANSI_RE = /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g;
const SSH_BRACKET_HEADING_RE =
/(?:(?<=^)|(?<=\s))\[[\w.-]+@[\w.-]+(?:[^\]\r\n]*)?\]/g;
function isShellPromptLine(bare: string): boolean {
const plain = bare.replace(STRIP_ANSI_RE, "");
// Matches a trailing prompt: "user@host:~$ ", "root@pi:/home/pi# ", "[user@host dir]$ "
if (/(?:[\w.-]+@[\w.-]+|[\w.-]+).*?[$#%>]\s*$/.test(plain)) return true;
// Matches a leading prompt followed by a command: "user@host:/path$ cmd arg"
// This is the echoed command line — the shell colors the prompt prefix itself,
// so injecting extra ANSI codes into it causes visual corruption / doubled paths.
if (/^(?:\[?[\w.-]+@[\w.-]+[\w./ ~-]*\]?|[\w.-]+).*?[$#%>]\s+\S/.test(plain))
return true;
return false;
}
// Matches any complete ANSI escape sequence
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g;
// Matches SGR sequences (color/style setters) specifically — used to track active color state
const SGR_REGEX = /\x1b\[[0-9;]*m/;
// All patterns, ordered roughly by specificity. Priority determines which wins on overlap.
const ALL_PATTERNS: HighlightPattern[] = [
// IPv4 with optional :port — well-bounded, very specific
{
name: "ipv4",
regex:
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
ansiCode: ANSI.magenta,
priority: 10,
category: "ipAddresses",
},
// Bracket timestamps [HH:MM] or [HH:MM:SS] — tight range checks prevent false positives
{
name: "timestamp-bracket",
regex: /\[(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?\]/g,
ansiCode: ANSI.brightBlack,
priority: 9,
category: "timestamps",
},
// ISO 8601 date with optional time. Require non-digit before to avoid matching
// inside version strings like "1.2024.3". The \b on the right ensures we don't
// match partial tokens.
{
name: "timestamp-iso",
regex:
/(?<![0-9])\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g,
ansiCode: ANSI.brightBlack,
priority: 9,
category: "timestamps",
},
// Error-level keywords — case-insensitive, catches "Error", "error", "FAILED", etc.
{
name: "log-error",
regex:
/\b(?:error|fatal|critical|fail(?:ed)?|denied|exception)\b|\[(?:error|fatal|critical)\]/gi,
ansiCode: ANSI.brightRed,
priority: 9,
category: "logLevels",
},
// Warning-level keywords — case-insensitive
{
name: "log-warn",
regex: /\b(?:warn(?:ing)?|alert|caution)\b|\[warn(?:ing)?\]/gi,
ansiCode: ANSI.brightYellow,
priority: 9,
category: "logLevels",
},
// Success keywords — kept conservative to avoid noise.
// Negative lookahead prevents matching when directly followed by a path separator
// (e.g. shell `cd` output that prints "success~/new/dir").
{
name: "log-success",
regex:
/\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b(?![\\/~])/gi,
ansiCode: ANSI.brightGreen,
priority: 8,
category: "logLevels",
},
// URLs — blue + underline
{
name: "url",
regex: /https?:\/\/[^\s\])}>"']+/g,
ansiCode: `${ANSI.blue}${ANSI.underline}`,
priority: 8,
category: "urls",
},
// Absolute paths — permissive character class that allows *, ?, [], globs, etc.
// Requires at least one slash after the root segment so we don't match bare /dev or /tmp.
// Boundary: must start at beginning of text or after whitespace/colon/comma/paren.
{
name: "path-absolute",
regex:
/(?:(?<=^)|(?<=[\s:,;('"]))\/[^\s"'`|<>&;\\]+(?:\/[^\s"'`|<>&;\\]+)+/g,
ansiCode: ANSI.cyan,
priority: 7,
category: "paths",
},
// Home-relative paths
{
name: "path-home",
regex: /~\/[^\s"'`|<>&;(){}[\]\\]+/g,
ansiCode: ANSI.cyan,
priority: 7,
category: "paths",
},
// Info keyword
{
name: "log-info",
regex: /\binfo\b|\[info\]/gi,
ansiCode: ANSI.blue,
priority: 6,
category: "logLevels",
},
// Debug/trace
{
name: "log-debug",
regex: /\b(?:debug|trace|verbose)\b|\[(?:debug|trace)\]/gi,
ansiCode: ANSI.brightBlack,
priority: 6,
category: "logLevels",
},
// Labeled numbers: port 8080, exit 1, code 127, status 404, signal 9, returned 0
// Captures just the number portion so only the digit is highlighted.
{
name: "number-labeled",
regex: /\b(?:port|exit|code|status|signal|returned?)\s+(\d+)\b/gi,
ansiCode: ANSI.brightCyan,
priority: 5,
category: "numbers",
},
];
function hasIncompleteAnsiSequence(text: string): boolean {
return /\x1b\[[0-9:;<=>?!]*$/.test(text);
}
/**
* Tracks whether the stream is inside a control string (OSC/DCS/APC/PM) across
* chunk boundaries.
*
* A control string carries text that must never reach the screen — an OSC 0
* title, for instance, contains the user, host and path. Its opening `ESC ]`
* and its terminator often land in different websocket frames, and the
* continuation frame contains no escape byte at all, so every single-chunk
* guard here misses it. Highlighting that continuation injects an SGR sequence
* into the middle of the string, which aborts it early in xterm.js and dumps
* the rest of the payload on screen as ordinary text.
*
* A trailing lone ESC counts as active for the same reason: its intent is only
* knowable from the next chunk.
*/
export function updateControlStringMode(
output: string,
currentMode: boolean,
): { isActive: boolean; wasActive: boolean } {
const wasActive = currentMode;
let isActive = currentMode;
for (let i = 0; i < output.length; i++) {
const char = output[i];
if (isActive) {
if (char === "\x07") {
isActive = false;
} else if (char === "\x1b") {
// ST (ESC \) closes it; any other ESC aborts it.
isActive = false;
if (output[i + 1] === "\\") i++;
}
continue;
}
if (char !== "\x1b") continue;
const next = output[i + 1];
if (next === undefined) return { isActive: true, wasActive };
if (next === "]" || next === "P" || next === "^" || next === "_") {
isActive = true;
i++;
}
}
return { isActive, wasActive };
}
function parseAnsiSegments(text: string): TextSegment[] {
const segments: TextSegment[] = [];
ANSI_REGEX.lastIndex = 0;
let lastIndex = 0;
let activeSgr = "";
let match;
while ((match = ANSI_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({
isAnsi: false,
content: text.slice(lastIndex, match.index),
activeSgr,
});
}
const seq = match[0];
segments.push({ isAnsi: true, content: seq });
// Track the active SGR state so we can restore it after a highlight reset
if (SGR_REGEX.test(seq)) {
activeSgr = seq === ANSI.reset ? "" : seq;
}
lastIndex = ANSI_REGEX.lastIndex;
}
if (lastIndex < text.length) {
segments.push({ isAnsi: false, content: text.slice(lastIndex), activeSgr });
}
return segments;
}
function highlightPlainText(
text: string,
activePatterns: HighlightPattern[],
activeSgr: string,
protectedRanges: ProtectedRange[],
): string {
if (text.length > MAX_LINE_LENGTH || !text.trim()) return text;
const matches: MatchResult[] = [];
for (const pattern of activePatterns) {
pattern.regex.lastIndex = 0;
let m;
while ((m = pattern.regex.exec(text)) !== null) {
// For patterns with a capture group (labeled numbers), highlight only the capture
if (m[1] !== undefined) {
const captureOffset = m[0].indexOf(m[1], m[0].search(/\d/));
const captureStart = m.index + captureOffset;
const captureEnd = captureStart + m[1].length;
if (isProtectedRange(captureStart, captureEnd, protectedRanges)) {
continue;
}
matches.push({
start: m.index,
end: m.index + m[0].length,
ansiCode: pattern.ansiCode,
priority: pattern.priority,
captureStart,
captureEnd,
});
} else {
if (isProtectedRange(m.index, m.index + m[0].length, protectedRanges)) {
continue;
}
matches.push({
start: m.index,
end: m.index + m[0].length,
ansiCode: pattern.ansiCode,
priority: pattern.priority,
});
}
}
}
if (matches.length === 0) return text;
// Highest priority wins; ties broken by earlier start position
matches.sort((a, b) =>
a.priority !== b.priority ? b.priority - a.priority : a.start - b.start,
);
const used: Array<{ start: number; end: number }> = [];
const final = matches.filter((m) => {
const overlaps = used.some(
(r) =>
(m.start >= r.start && m.start < r.end) ||
(m.end > r.start && m.end <= r.end) ||
(m.start <= r.start && m.end >= r.end),
);
if (!overlaps) {
used.push({ start: m.start, end: m.end });
return true;
}
return false;
});
// Apply in reverse order so indices stay valid as we insert escape codes
final.sort((a, b) => a.start - b.start).reverse();
let result = text;
for (const m of final) {
const hs = m.captureStart ?? m.start;
const he = m.captureEnd ?? m.end;
// After reset, re-emit the SGR that was active before this plain-text segment
// so we don't clobber colors set by the server earlier in the line.
const restore = activeSgr || "";
result =
result.slice(0, hs) +
m.ansiCode +
result.slice(hs, he) +
ANSI.reset +
restore +
result.slice(he);
}
return result;
}
function getProtectedRanges(text: string): ProtectedRange[] {
const ranges: ProtectedRange[] = [];
SSH_BRACKET_HEADING_RE.lastIndex = 0;
let match;
while ((match = SSH_BRACKET_HEADING_RE.exec(text)) !== null) {
ranges.push({
start: match.index,
end: match.index + match[0].length,
});
}
return ranges;
}
function isProtectedRange(
start: number,
end: number,
ranges: ProtectedRange[],
): boolean {
return ranges.some((range) => start < range.end && end > range.start);
}
function buildActivePatterns(
options: SyntaxHighlightOptions,
): HighlightPattern[] {
return ALL_PATTERNS.filter((p) => options[p.category] !== false);
}
function highlightLine(
line: string,
activePatterns: HighlightPattern[],
): string {
const cr = line.endsWith("\r");
const bare = cr ? line.slice(0, -1) : line;
if (!bare.trim()) return line;
if (bare.length > MAX_LINE_LENGTH) return line;
if (isShellPromptLine(bare)) return line;
// Compute protected ranges (e.g. SSH bracket headings) against the fully
// stripped line rather than per-ANSI-segment text. A colored prompt theme
// (e.g. "[<color>user<reset>@<color>host<reset>]") splits the heading across
// multiple plain-text segments, so matching per-segment would miss it and
// let a username like "warning" get wrongly highlighted as a log level.
const plainLine = bare.replace(STRIP_ANSI_RE, "");
const lineProtectedRanges = getProtectedRanges(plainLine);
const segments = parseAnsiSegments(bare);
let plainOffset = 0;
const result = segments
.map((s) => {
if (s.isAnsi) return s.content;
const segmentStart = plainOffset;
plainOffset += s.content.length;
const localRanges = lineProtectedRanges
.map((r) => ({
start: r.start - segmentStart,
end: r.end - segmentStart,
}))
.filter((r) => r.start < s.content.length && r.end > 0);
return highlightPlainText(
s.content,
activePatterns,
s.activeSgr ?? "",
localRanges,
);
})
.join("");
return cr ? result + "\r" : result;
}
export function highlightTerminalOutput(
text: string,
options: SyntaxHighlightOptions = {},
): string {
if (!text || !text.trim()) return text;
if (hasIncompleteAnsiSequence(text)) return text;
if (TUI_SEQUENCE.test(text)) return text;
if (TUI_FRAME_SEQUENCE.test(text)) return text;
if (CONTROL_STRING_SEQUENCE.test(text)) return text;
if (MID_LINE_CR.test(text)) return text;
const activePatterns = buildActivePatterns(options);
if (activePatterns.length === 0) return text;
const lines = text.split("\n");
const endsWithNewline = text.endsWith("\n");
const hasMultipleLines = lines.length > 1;
// When a multi-line chunk has a trailing fragment without \n, that fragment
// could be a live readline input line that bash will redraw with \r +
// cursor-positioning sequences. Injecting ANSI bytes into it adds invisible
// chars that bash doesn't count, so bash's cursor arithmetic diverges from
// xterm's actual column position — causing the cursor to jump and text to
// shift when the user types or navigates with arrow keys.
// Only skip the last fragment when the chunk already has complete lines
// before it (single-line chunks with no \n are plain output, not input).
const highlightCount =
hasMultipleLines && !endsWithNewline ? lines.length - 1 : lines.length;
return lines
.map((line, i) =>
i < highlightCount ? highlightLine(line, activePatterns) : line,
)
.join("\n");
}
@@ -0,0 +1,201 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { useXTerm } from "react-xtermjs";
import { toast } from "sonner";
import { getMacLineNavigationSequence } from "../lib/mac-line-navigation";
import {
handleTerminalClipboardKeyEvent,
createTerminalContextMenuHandler,
} from "../terminal/terminal-clipboard";
import {
useAppTheme as useTheme,
resolveTermixThemeColors,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
ensureTerminalFontsLoaded,
RobustClipboardProvider,
copyToClipboard,
readFromClipboard,
} from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export function LocalTerminal({
instanceId,
isVisible,
}: {
instanceId: string;
isVisible: boolean;
}) {
const { t } = useTranslation();
// Read via a ref inside the session-lifecycle effect below so a language
// change (which gives react-i18next a new `t` identity) doesn't tear down
// the running local shell and spawn a new session.
const tRef = useRef(t);
tRef.current = t;
const { theme: appTheme } = useTheme();
const { instance: terminal, ref: xtermRef } = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const sessionIdRef = useRef<string | null>(null);
const [isWindows, setIsWindows] = useState(false);
const [shell, setShell] = useState<"default" | "wsl">("default");
useEffect(() => {
window.electronAPI?.getPlatform().then((platform) => {
setIsWindows(platform === "win32");
});
}, []);
const fit = useCallback(() => {
const fitAddon = fitAddonRef.current;
const sessionId = sessionIdRef.current;
if (!terminal || !fitAddon) return;
fitAddon.fit();
if (sessionId) {
window.electronAPI.resizeLocalTerminal(
sessionId,
terminal.cols,
terminal.rows,
);
}
}, [terminal]);
useEffect(() => {
if (!terminal) return;
const colors = resolveTermixThemeColors("termix", appTheme);
const font = TERMINAL_FONTS.find(
(item) => item.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
);
ensureTerminalFontsLoaded(font?.value ?? TERMINAL_FONTS[0].value);
terminal.options.theme = colors;
terminal.options.fontFamily = font?.fallback ?? TERMINAL_FONTS[0].fallback;
terminal.options.fontSize = DEFAULT_TERMINAL_CONFIG.fontSize;
}, [appTheme, terminal]);
useEffect(() => {
if (!terminal || !window.electronAPI?.isElectron) return;
const fitAddon = new FitAddon();
const clipboardProvider = new RobustClipboardProvider();
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
fitAddon.fit();
async function writeTextToClipboard(text: string): Promise<boolean> {
const ok = await copyToClipboard(text);
if (!ok) toast.error(tRef.current("terminal.clipboardWriteFailed"));
return ok;
}
async function readTextFromClipboard(): Promise<string> {
const text = await readFromClipboard();
if (!text) toast.error(tRef.current("terminal.clipboardReadFailed"));
return text;
}
const clipboardActions = { writeTextToClipboard, readTextFromClipboard };
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
if (e.type !== "keydown") return true;
const sequence = getMacLineNavigationSequence(e);
if (sequence) {
e.preventDefault();
e.stopPropagation();
terminal.input(sequence, true);
return false;
}
// No native "paste" event listener here (unlike the SSH terminal), so
// plain Ctrl/Cmd+V reads the clipboard explicitly rather than relying
// on the browser's own paste event.
return handleTerminalClipboardKeyEvent(e, terminal, clipboardActions, {
plainPasteMode: "explicit",
});
});
const handleContextMenu = createTerminalContextMenuHandler(
terminal,
clipboardActions,
);
const element = xtermRef.current;
element?.addEventListener("contextmenu", handleContextMenu);
let disposed = false;
let removeData = () => {};
let removeExit = () => {};
const input = terminal.onData((data) => {
const sessionId = sessionIdRef.current;
if (sessionId) window.electronAPI.writeLocalTerminal(sessionId, data);
});
window.electronAPI
.startLocalTerminal({ cols: terminal.cols, rows: terminal.rows, shell })
.then(({ sessionId }) => {
if (disposed) {
window.electronAPI.closeLocalTerminal(sessionId);
return;
}
sessionIdRef.current = sessionId;
removeData = window.electronAPI.onLocalTerminalData(sessionId, (data) =>
terminal.write(data),
);
removeExit = window.electronAPI.onLocalTerminalExit(
sessionId,
(exitCode) => {
sessionIdRef.current = null;
terminal.write(
`\r\n\x1b[33mProcess exited (${exitCode})\x1b[0m\r\n`,
);
},
);
return window.electronAPI.readyLocalTerminal(sessionId);
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
terminal.write(`\r\n\x1b[31m${message}\x1b[0m\r\n`);
});
const observer = new ResizeObserver(() => fit());
if (xtermRef.current) observer.observe(xtermRef.current);
return () => {
disposed = true;
terminal.attachCustomKeyEventHandler(() => true);
observer.disconnect();
input.dispose();
removeData();
removeExit();
element?.removeEventListener("contextmenu", handleContextMenu);
clipboardProvider.dispose();
const sessionId = sessionIdRef.current;
sessionIdRef.current = null;
if (sessionId) window.electronAPI.closeLocalTerminal(sessionId);
fitAddonRef.current = null;
fitAddon.dispose();
};
}, [fit, instanceId, shell, terminal, xtermRef]);
useEffect(() => {
if (isVisible) requestAnimationFrame(fit);
}, [fit, isVisible]);
return (
<div className="flex h-full w-full flex-col bg-background">
{isWindows && (
<div className="flex justify-end border-b border-border px-2 py-1">
<select
aria-label="Local terminal shell"
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
value={shell}
onChange={(event) =>
setShell(event.target.value === "wsl" ? "wsl" : "default")
}
>
<option value="default">PowerShell</option>
<option value="wsl">WSL</option>
</select>
</div>
)}
<div ref={xtermRef} className="min-h-0 flex-1 p-2" />
</div>
);
}
@@ -1,4 +1,4 @@
import type { TerminalHandle } from "@/features/terminal/terminal-types";
import type { TerminalHandle } from "./terminal/terminal-types";
export interface TerminalSessionInfo {
id: string;
@@ -0,0 +1,100 @@
import { useState } from "react";
import { toast } from "sonner";
import { Button, Input } from "@termix/plugin-sdk/ui";
import {
usePluginApi,
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
interface TestResult {
mode: string;
connected: boolean;
remoteSftpAvailable: boolean;
localHostVisible: boolean | null;
selectedMode: "local" | "remote-sftp" | "unavailable";
localMappingConfigured: boolean;
}
const SELECTED_MODE_LABEL_KEYS: Record<TestResult["selectedMode"], string> = {
local: "settings.admin.imageStorageMode.local",
"remote-sftp": "settings.admin.imageStorageMode.remoteSftp",
unavailable: "imageStorage.selectedModeUnavailable",
};
/**
* Checks which storage an image upload would use for one of the admin's own
* connected terminals, against the saved settings.
*/
export function ImageStorageTest(_props: SettingsComponentProps) {
const { t } = useTranslation();
const api = usePluginApi();
const [instanceId, setInstanceId] = useState("");
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<TestResult | null>(null);
const runTest = async () => {
setTesting(true);
setResult(null);
try {
const { data } = await api.post<TestResult>("/image-storage/test", {
instanceId: instanceId.trim(),
});
setResult(data);
} catch {
toast.error(t("imageStorage.testFailed"));
} finally {
setTesting(false);
}
};
return (
<div className="flex flex-col gap-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("imageStorage.instanceId")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("imageStorage.instanceIdDesc")}
</span>
</div>
<Input
aria-label={t("imageStorage.instanceId")}
value={instanceId}
onChange={(event) => setInstanceId(event.target.value)}
className="h-8 w-full @md:w-64 text-xs"
/>
{result && (
<div className="text-xs text-muted-foreground">
<div>
{result.connected
? t("imageStorage.testConnected")
: t("imageStorage.testNotConnected")}
</div>
<div>
{t("imageStorage.testSelectedMode", {
mode: t(SELECTED_MODE_LABEL_KEYS[result.selectedMode]),
})}
</div>
{result.localHostVisible !== null && (
<div>
{result.localHostVisible
? t("imageStorage.testLocalVisible")
: t("imageStorage.testLocalNotVisible")}
</div>
)}
</div>
)}
<div>
<Button
size="sm"
variant="outline"
disabled={testing || !instanceId.trim()}
onClick={() => void runTest()}
>
{testing ? t("imageStorage.testing") : t("imageStorage.test")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,156 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import {
normalizeTouchInputSettings,
TOUCH_INPUT_DEFAULTS,
TOUCH_INPUT_NUMERIC_BOUNDS,
type TouchInputNumericKey,
type TouchInputSettings as TouchInputValues,
} from "../../shared/touch-input-settings";
import { Button, Input, SettingRow, Switch } from "@termix/plugin-sdk/ui";
import {
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import { cacheTouchInputSettings } from "../terminal/touch-input-settings-store";
const fields: Array<{
key: TouchInputNumericKey;
label: string;
unit: string;
}> = [
{ key: "dragThresholdPx", label: "dragThreshold", unit: "px" },
{ key: "maxWheelDeltaPx", label: "maxWheelDelta", unit: "px" },
{
key: "momentumSampleWindowMs",
label: "momentumSampleWindow",
unit: "ms",
},
{
key: "releaseGracePeriodMs",
label: "releaseGracePeriod",
unit: "ms",
},
{
key: "minimumVelocityPxPerMs",
label: "minimumVelocity",
unit: "px/ms",
},
{
key: "maximumVelocityPxPerMs",
label: "maximumVelocity",
unit: "px/ms",
},
{ key: "maximumDurationMs", label: "maximumDuration", unit: "ms" },
{ key: "maximumTravelPx", label: "maximumTravel", unit: "px" },
{ key: "decayTimeMs", label: "decayTime", unit: "ms" },
{ key: "pixelsPerTick", label: "pixelsPerTick", unit: "px" },
{
key: "maximumFrameIntervalMs",
label: "maximumFrameInterval",
unit: "ms",
},
{
key: "maximumTicksPerFrame",
label: "maximumTicksPerFrame",
unit: "",
},
];
/**
* Touch scrolling tuning for terminals on touch screens, stored as one
* object in the "touchInput" admin setting.
*/
export function TouchInputSettings({
values,
setValue,
}: SettingsComponentProps) {
const { t } = useTranslation();
const [advancedOpen, setAdvancedOpen] = useState(false);
const settings = normalizeTouchInputSettings(values.touchInput);
const setSettings = (next: TouchInputValues) => {
const normalized = normalizeTouchInputSettings(next);
cacheTouchInputSettings(normalized);
setValue("touchInput", normalized);
};
return (
<div className="flex flex-col gap-0">
<SettingRow
label={t("touchInput.enabled")}
description={t("touchInput.enabledDesc")}
>
<Switch
checked={settings.enabled}
onCheckedChange={(enabled) => setSettings({ ...settings, enabled })}
/>
</SettingRow>
<SettingRow
label={t("touchInput.momentumEnabled")}
description={t("touchInput.momentumEnabledDesc")}
>
<Switch
checked={settings.momentumEnabled}
disabled={!settings.enabled}
onCheckedChange={(momentumEnabled) =>
setSettings({ ...settings, momentumEnabled })
}
/>
</SettingRow>
<button
type="button"
className="flex items-center gap-2 py-3 text-left text-xs font-semibold text-foreground"
onClick={() => setAdvancedOpen((value) => !value)}
>
<ChevronDown
className={`size-3.5 transition-transform ${advancedOpen ? "rotate-180" : ""}`}
/>
{t("touchInput.advanced")}
</button>
{advancedOpen && (
<div className="grid grid-cols-1 gap-3 pb-3 sm:grid-cols-2">
{fields.map(({ key, label, unit }) => {
const bounds = TOUCH_INPUT_NUMERIC_BOUNDS[key];
return (
<label key={key} className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t(`touchInput.${label}`)}
</span>
<div className="flex items-center gap-2">
<Input
aria-label={t(`touchInput.${label}`)}
type="number"
min={bounds.min}
max={bounds.max}
step={bounds.step}
value={settings[key]}
onChange={(event) =>
setSettings({
...settings,
[key]: Number(event.target.value),
})
}
className="h-8 text-xs"
/>
{unit && (
<span className="w-10 text-muted-foreground">{unit}</span>
)}
</div>
</label>
);
})}
</div>
)}
<div className="flex gap-2 border-t border-border pt-3">
<Button
size="sm"
variant="outline"
onClick={() => setSettings({ ...TOUCH_INPUT_DEFAULTS })}
>
{t("touchInput.resetDefaults")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import {
normalizeTouchInputSettings,
type TouchInputSettings,
} from "../shared/touch-input-settings";
/** Calls to this plugin's own routes under /plugin-api/ssh-terminal/. */
export interface TerminalClientSettings {
sessionTimeoutMinutes: number;
sessionPersistence: boolean;
commandHistoryEnabled: boolean;
touchInput: TouchInputSettings;
}
export async function getClientSettings(
api: PluginApiClient,
): Promise<TerminalClientSettings> {
const { data } = await api.get<TerminalClientSettings>("/client-settings");
return { ...data, touchInput: normalizeTouchInputSettings(data?.touchInput) };
}
export async function saveCommandToHistory(
api: PluginApiClient,
hostId: number,
command: string,
): Promise<void> {
await api.post("/command-history", { hostId, command });
}
export async function getCommandHistory(
api: PluginApiClient,
hostId: number,
): Promise<string[]> {
const { data } = await api.get<string[]>(`/command-history/${hostId}`);
return Array.isArray(data) ? data : [];
}
export async function deleteCommandFromHistory(
api: PluginApiClient,
hostId: number,
command: string,
): Promise<void> {
await api.post("/command-history/delete", { hostId, command });
}
export async function clearCommandHistory(
api: PluginApiClient,
hostId: number,
): Promise<void> {
await api.delete(`/command-history/${hostId}`);
}
/** A host-scope setting of this plugin, as the host payload carries it. */
export function hostSetting(
host: object | null | undefined,
key: string,
fallback: boolean,
): boolean {
const settings = (
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
)?.pluginSettings;
const value = settings?.["ssh-terminal"]?.[key];
return typeof value === "boolean" ? value : fallback;
}
@@ -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);
};
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Host and terminal shapes as the terminal reads them. The plugin keeps its
* own copy instead of importing core's types; only the fields it uses matter,
* and the index signatures let the rest of a host record through.
*/
export interface TerminalConfig {
localEcho?: "default" | "off" | "auto" | "on";
cursorBlink: boolean;
cursorStyle: "block" | "underline" | "bar";
fontSize: number;
fontFamily: string;
letterSpacing: number;
lineHeight: number;
theme: string;
scrollback: number;
bellStyle: "none" | "sound" | "visual" | "both";
rightClickSelectsWord: boolean;
macOptionIsMeta: boolean;
fastScrollModifier: "alt" | "ctrl" | "shift";
fastScrollSensitivity: number;
minimumContrastRatio: number;
backspaceMode: "normal" | "control-h";
agentForwarding: boolean;
environmentVariables: Array<{ key: string; value: string }>;
startupSnippetId: number | null;
autoMosh: boolean;
moshCommand: string;
sudoPasswordAutoFill: boolean;
sudoPassword?: string | null;
keepaliveInterval?: number;
keepaliveCountMax?: number;
autoTmux: boolean;
syntaxHighlighting: boolean;
syntaxHighlightingOptions?: {
logLevels: boolean;
paths: boolean;
timestamps: boolean;
ipAddresses: boolean;
urls: boolean;
numbers: boolean;
};
backgroundImage?: string;
backgroundImageOpacity?: number;
allowLegacyAlgorithms?: boolean;
linkClickBehavior?: "confirm" | "direct";
useSSHTitle?: boolean;
agentSocketPath?: string;
agentIdentity?: string;
customThemeColors?: {
background: string;
foreground: string;
cursor?: string;
cursorAccent?: string;
selectionBackground?: string;
selectionForeground?: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
};
}
export type HostBackspaceMode = TerminalConfig["backspaceMode"];
/** A host as the shell hands it to a tab. */
export interface Host {
id: number | string;
name?: string;
ip: string;
port: number;
username: string;
terminalConfig?: Partial<TerminalConfig> | null;
pluginSettings?: Record<string, Record<string, unknown>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
/** Enough of a snippet for the variables dialog. */
export interface Snippet {
id: number;
name: string;
content: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export type TabType = string;