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

+284
View File
@@ -0,0 +1,284 @@
import dns from "dns/promises";
import net from "net";
import type { RawData } from "ws";
import ssh2Pkg, {
type GetStreamCallback,
type IdentityCallback,
type ParsedKey,
type SignCallback,
type SigningRequestOptions,
} from "ssh2";
import type { PluginLogger } from "@termix/plugin-sdk/backend";
const { AgentProtocol, BaseAgent } = ssh2Pkg;
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
/** The terminal's structured logging, on top of ctx.log. */
export interface TerminalLogger {
info: (message: string, meta?: Record<string, unknown>) => void;
success: (message: string, meta?: Record<string, unknown>) => void;
warn: (message: string, meta?: Record<string, unknown>) => void;
error: (
message: string,
error?: unknown,
meta?: Record<string, unknown>,
) => void;
}
function withMeta(message: string, meta?: Record<string, unknown>): string {
if (!meta || Object.keys(meta).length === 0) return message;
try {
return `${message} ${JSON.stringify(meta)}`;
} catch {
return message;
}
}
export function createTerminalLogger(log: PluginLogger): TerminalLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
success: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, error, meta) => {
// Some call sites pass the meta object where the error goes.
if (error && !(error instanceof Error) && typeof error === "object") {
log.error(withMeta(message, error as Record<string, unknown>));
return;
}
log.error(
withMeta(message, meta),
error instanceof Error
? error
: error
? new Error(String(error))
: undefined,
);
},
};
}
// Cap on a single decoded text frame. Legitimate control messages are tiny,
// and terminal input is bounded by what a user can type or paste.
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
export class WsMessageError extends Error {}
function rawByteLength(raw: RawData): number {
if (Buffer.isBuffer(raw)) return raw.length;
if (Array.isArray(raw))
return raw.reduce((sum, part) => sum + part.length, 0);
if (raw instanceof ArrayBuffer) return raw.byteLength;
return 0;
}
/** Parses a frame into `{ type, data }`, throwing WsMessageError on anything else. */
export function parseWsMessage(raw: RawData): {
type: string;
data: unknown;
} {
if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
throw new WsMessageError("Message too large");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
throw new WsMessageError("Invalid JSON");
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new WsMessageError("Message must be a JSON object");
}
const { type, data } = parsed as { type?: unknown; data?: unknown };
if (typeof type !== "string") {
throw new WsMessageError("Message type must be a string");
}
return { type, data };
}
export function asObject(data: unknown): Record<string, unknown> {
return data !== null && typeof data === "object" && !Array.isArray(data)
? (data as Record<string, unknown>)
: {};
}
export function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
/** A sane integer width or height, or 0 when the value is unusable. */
export function toTerminalDimension(value: unknown): number {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return 0;
const rounded = Math.floor(n);
if (rounded < 1) return 0;
return Math.min(rounded, 10000);
}
export function isWindowsSftpPath(path: string): boolean {
const normalized = path.replace(/\\/g, "/");
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
}
export function sftpPathToLocalPath(sftpPath: string): string {
const normalized = sftpPath.replace(/\\/g, "/");
if (/^\/[A-Za-z]:\//.test(normalized)) {
return normalized.slice(1).replace(/\//g, "\\");
}
if (/^[A-Za-z]:\//.test(normalized)) {
return normalized.replace(/\//g, "\\");
}
return sftpPath;
}
export const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
type Lookup = typeof dns.lookup;
type Sleep = (ms: number) => Promise<void>;
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export function isRetriableDnsError(error: unknown): boolean {
const err = error as { code?: unknown; message?: unknown };
return (
err.code === "EAI_AGAIN" ||
(typeof err.message === "string" && err.message.includes("EAI_AGAIN"))
);
}
/** Resolves a hostname up front, retrying transient container DNS failures. */
export async function resolveHostForSshConnect(
host: string,
lookup: Lookup = dns.lookup,
retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS,
wait: Sleep = sleep,
): Promise<{ host: string; resolvedAddress?: string; attempts: number }> {
const normalized = host.replace(/^\[|\]$/g, "").trim();
if (!normalized || net.isIP(normalized) !== 0) {
return { host: normalized || host, attempts: 0 };
}
for (let attempt = 0; ; attempt += 1) {
try {
const result = await lookup(normalized);
return {
host: result.address,
resolvedAddress: result.address,
attempts: attempt + 1,
};
} catch (error) {
if (!isRetriableDnsError(error) || attempt >= retryDelaysMs.length) {
throw error;
}
await wait(retryDelaysMs[attempt]);
}
}
}
function normalizeHostAddress(value: unknown): string {
if (typeof value !== "string") return "";
return value
.replace(/^\[|\]$/g, "")
.trim()
.toLowerCase();
}
/**
* Whether a numeric host id resolved to a different machine than the client
* meant. Ids from the desktop app and a sync server drift apart, and the
* resolved row supplies the credentials, so a mismatch must be refused.
*/
export function hostAddressMismatch(
clientAddress: unknown,
resolvedAddress: unknown,
): boolean {
const resolved = normalizeHostAddress(resolvedAddress);
if (!resolved) return false;
return resolved !== normalizeHostAddress(clientAddress);
}
export const HOST_ADDRESS_MISMATCH_MESSAGE =
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
"The host ids on this device and on the sync server have drifted apart. " +
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.';
export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
"This host does not exist on the sync server, so the connection was refused. " +
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
export function resolveServerJumpHosts(
clientJumpHosts: Array<{ hostId: number }> | undefined,
serverJumpHosts: Array<{ hostId: number }> | undefined,
hostSyncId?: string | null,
): Array<{ hostId: number }> | undefined {
if (hostSyncId) return serverJumpHosts ?? [];
return clientJumpHosts?.length ? clientJumpHosts : serverJumpHosts;
}
export function resolveServerHostId(
clientHostId: number | null,
resolvedHost: { id?: unknown } | null,
): number | null {
return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId;
}
/** An in-memory SSH agent holding one key, for agent forwarding. */
export class MemoryAgent extends BaseAgent {
private key: ParsedKey;
constructor(key: ParsedKey) {
super();
this.key = key;
}
getIdentities(cb: IdentityCallback<ParsedKey>): void {
cb(null, [this.key]);
}
getStream(cb: GetStreamCallback): void {
const protocol = new AgentProtocol(false);
protocol.on("identities", (request) => {
protocol.getIdentitiesReply(request, [this.key]);
});
protocol.on("sign", (request, publicKey, data, options) => {
this.sign(publicKey, data, options, (error, signature) => {
if (error || !signature) return protocol.failureReply(request);
protocol.signReply(request, signature);
});
});
cb(null, protocol);
}
sign(
_pubKey: ParsedKey | Buffer | string,
data: Buffer,
optionsOrCb: SigningRequestOptions | SignCallback,
cb?: SignCallback,
): void {
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
try {
const algo =
options.hash === "sha256"
? "rsa-sha2-256"
: options.hash === "sha512"
? "rsa-sha2-512"
: undefined;
const signature = this.key.sign(data, algo);
callback(null, signature);
} catch (err) {
callback(err instanceof Error ? err : new Error(String(err)));
}
}
}
@@ -0,0 +1,91 @@
import { and, desc, eq, sql } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// The table comes from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type HistoryRepository = ReturnType<typeof createHistoryRepository>;
/** Command history storage. Writes are followed by persist(), reads are not. */
export function createHistoryRepository(db: PluginDatabase, table: Table) {
const client = () => db.client<Drizzle>();
return {
async create(
userId: string,
hostId: number,
command: string,
executedAt = new Date().toISOString(),
): Promise<void> {
const drizzle = await client();
await drizzle
.insert(table)
.values({ userId, hostId, command, executedAt });
await db.persist();
},
async listUniqueCommandsForHost(
userId: string,
hostId: number,
limit = 500,
): Promise<string[]> {
const drizzle = await client();
const rows = await drizzle
.select({
command: table.command,
maxExecutedAt: sql<number>`MAX(${table.executedAt})`,
})
.from(table)
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
.groupBy(table.command)
.orderBy(desc(sql`MAX(${table.executedAt})`))
.limit(limit);
return rows.map((row: { command: string }) => row.command);
},
async listCommandsForHost(
userId: string,
hostId: number,
limit = 200,
): Promise<Array<{ command: string; executedAt: string }>> {
const drizzle = await client();
const rows = await drizzle
.select({ command: table.command, executedAt: table.executedAt })
.from(table)
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
.orderBy(desc(table.executedAt))
.limit(limit);
return rows as Array<{ command: string; executedAt: string }>;
},
async deleteCommandForHost(
userId: string,
hostId: number,
command: string,
): Promise<void> {
const drizzle = await client();
await drizzle
.delete(table)
.where(
and(
eq(table.userId, userId),
eq(table.hostId, hostId),
eq(table.command, command),
),
);
await db.persist();
},
async deleteByUserAndHost(userId: string, hostId: number): Promise<void> {
const drizzle = await client();
await drizzle
.delete(table)
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)));
await db.persist();
},
};
}
@@ -0,0 +1,20 @@
/**
* Registered as ctx.registry.provide("ssh-terminal.hostImportNormalizer", ...)
* so a Termix-JSON host import keeps its terminal switches, which used to be
* host columns and are this plugin's host settings now.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const keys = [
"enableTerminal",
"enableTerminalToolbar",
"enableCommandHistory",
] as const;
if (keys.every((key) => raw[key] === undefined)) return null;
const values: Record<string, unknown> = {};
for (const key of keys) {
if (raw[key] !== undefined) values[key] = raw[key] !== false;
}
return values;
}
@@ -0,0 +1,262 @@
import path from "path";
/**
* Terminal image storage modes.
*
* - `local`: always write to the backend's mapped local storage. Deterministic:
* never falls back to the remote SFTP path.
* - `remote-sftp`: always write to the connected terminal's SSH host over SFTP.
* Deterministic: never falls back to local storage.
* - `auto`: pick by capability only — remote SFTP when a connected terminal
* session exists, local storage otherwise. Configuration never influences
* this choice.
*/
export const TERMINAL_IMAGE_STORAGE_MODES = [
"auto",
"local",
"remote-sftp",
] as const;
export type TerminalImageStorageMode =
(typeof TERMINAL_IMAGE_STORAGE_MODES)[number];
export interface TerminalImageStorageSettings {
mode: TerminalImageStorageMode;
/** Absolute path on the Termix backend where local-mode files are written. */
localDir: string;
/**
* Absolute path handed to the terminal agent in local mode. This is the
* host-side view of `localDir` (e.g. /tmp mapped into the container); it is
* the only path ever exposed to callers.
*/
hostPath: string;
ttlMs: number;
maxCount: number;
maxBytes: number;
/** Both localDir and hostPath were explicitly configured and must be probed. */
localMappingConfigured: boolean;
}
/** Settings-table keys. Persisted values always win over environment. */
export const TERMINAL_IMAGE_STORAGE_KEYS = {
mode: "terminal_image_storage_mode",
localDir: "terminal_image_local_dir",
hostPath: "terminal_image_host_path",
ttlMs: "terminal_image_ttl_ms",
maxCount: "terminal_image_max_count",
maxBytes: "terminal_image_max_storage_bytes",
} as const;
/**
* Legacy environment variables from the original env-only configuration. They
* seed defaults only when no database value exists for the same field.
*/
export const TERMINAL_IMAGE_STORAGE_ENV = {
mode: "TERMIX_IMAGE_STORAGE_MODE",
localDir: "TERMIX_IMAGE_DIR",
hostPath: "TERMIX_IMAGE_HOST_PATH",
ttlMs: "TERMIX_IMAGE_TTL_MS",
maxCount: "TERMIX_MAX_IMAGE_COUNT",
maxBytes: "TERMIX_MAX_IMAGE_STORAGE_BYTES",
} as const;
export const DEFAULT_IMAGE_TTL_MS = 3_600_000;
export const DEFAULT_IMAGE_MAX_COUNT = 100;
export const DEFAULT_IMAGE_MAX_BYTES = 5_368_709_120;
export const DEFAULT_IMAGE_HOST_PATH = "/tmp/termix-image-v0";
export const MIN_IMAGE_MAX_BYTES = 1_048_576;
export function defaultImageLocalDir(env: NodeJS.ProcessEnv): string {
return path.resolve(
path.join(env.DATA_DIR || "./db/data", "termix-image-v0"),
);
}
export function parseTerminalImageStorageMode(
value: unknown,
): TerminalImageStorageMode | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return (TERMINAL_IMAGE_STORAGE_MODES as readonly string[]).includes(
normalized,
)
? (normalized as TerminalImageStorageMode)
: null;
}
/**
* Local write directory must be an absolute path without NUL bytes. Relative
* values are rejected rather than resolved: a relative entry silently depends
* on the process cwd, which differs between Docker, systemd and dev runs.
*/
export function parseImageLocalDir(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
if (!path.isAbsolute(trimmed)) return null;
return path.resolve(trimmed);
}
/** Agent-visible path. Always POSIX-style and absolute. */
export function parseImageHostPath(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
if (!path.posix.isAbsolute(trimmed)) return null;
return path.posix.normalize(trimmed);
}
/**
* Numeric fields: unparseable values are rejected (caller falls through to the
* next source); parseable but out-of-range values are clamped, matching the
* original env-only behavior.
*/
function parseClampedInt(value: unknown, min: number): number | null {
if (typeof value !== "string" && typeof value !== "number") return null;
const parsed =
typeof value === "number" ? value : Number.parseInt(value.trim(), 10);
if (!Number.isFinite(parsed)) return null;
return Math.max(min, Math.trunc(parsed));
}
export interface SettingSource {
get(key: string): Promise<string | null>;
/** Told about a stored or environment value that was ignored. */
warn?: (message: string) => void;
}
function hasUnsafePathSyntax(value: string): boolean {
return (
/[\u0000-\u001f\u007f]/.test(value) ||
value.split(/[\\/]+/).some((segment) => segment === "..") ||
value.includes("//") ||
value.includes("\\")
);
}
/**
* Resolves the effective image storage settings.
*
* Per-field precedence: a valid persisted database value wins; a legacy
* TERMIX_IMAGE_* variable seeds the default only when no database value
* exists; otherwise the built-in default applies. Invalid values are skipped
* with a warning and resolution falls through to the next source.
*
* Mode compatibility: with no mode in the database or environment, an explicit
* legacy local mapping (TERMIX_IMAGE_DIR) keeps old deployments on `local`;
* everything else defaults to `auto`.
*/
export async function resolveTerminalImageStorageSettings(
settings: SettingSource,
env: NodeJS.ProcessEnv = process.env,
): Promise<TerminalImageStorageSettings> {
const warnInvalid = (key: string, source: string) =>
settings.warn?.(
`Ignoring invalid terminal image storage setting ${key} from ${source}`,
);
async function pick<T>(
key: string,
envName: string,
parse: (value: unknown) => T | null,
fallback: T,
): Promise<T> {
const stored = await settings.get(key);
if (stored !== null) {
const parsed = parse(stored);
if (parsed !== null) return parsed;
warnInvalid(key, "database");
}
const fromEnv = env[envName];
if (fromEnv !== undefined) {
const parsed = parse(fromEnv);
if (parsed !== null) return parsed;
warnInvalid(key, "environment");
}
return fallback;
}
const dbModeRaw = await settings.get(TERMINAL_IMAGE_STORAGE_KEYS.mode);
let mode: TerminalImageStorageMode | null = null;
if (dbModeRaw !== null) {
mode = parseTerminalImageStorageMode(dbModeRaw);
if (mode === null)
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "database");
}
if (mode === null) {
const envModeRaw = env[TERMINAL_IMAGE_STORAGE_ENV.mode];
if (envModeRaw !== undefined) {
mode = parseTerminalImageStorageMode(envModeRaw);
if (mode === null)
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "environment");
}
}
if (mode === null) {
// Legacy deployments configured local storage purely through
// TERMIX_IMAGE_DIR; keep them on local mode unless a database value says
// otherwise.
mode =
env[TERMINAL_IMAGE_STORAGE_ENV.localDir] !== undefined ? "local" : "auto";
}
const legacyLocalDir = parseImageLocalDir(
env[TERMINAL_IMAGE_STORAGE_ENV.localDir],
);
const [localDir, hostPath, ttlMs, maxCount, maxBytes] = await Promise.all([
pick(
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
TERMINAL_IMAGE_STORAGE_ENV.localDir,
parseImageLocalDir,
defaultImageLocalDir(env),
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
TERMINAL_IMAGE_STORAGE_ENV.hostPath,
parseImageHostPath,
legacyLocalDir ?? DEFAULT_IMAGE_HOST_PATH,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
TERMINAL_IMAGE_STORAGE_ENV.ttlMs,
(value) => parseClampedInt(value, 0),
DEFAULT_IMAGE_TTL_MS,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
TERMINAL_IMAGE_STORAGE_ENV.maxCount,
(value) => parseClampedInt(value, 1),
DEFAULT_IMAGE_MAX_COUNT,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
TERMINAL_IMAGE_STORAGE_ENV.maxBytes,
(value) => parseClampedInt(value, MIN_IMAGE_MAX_BYTES),
DEFAULT_IMAGE_MAX_BYTES,
),
]);
const persistedLocalDir = await settings.get(
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
);
const persistedHostPath = await settings.get(
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
);
const localMappingConfigured =
((persistedLocalDir !== null &&
parseImageLocalDir(persistedLocalDir) !== null) ||
parseImageLocalDir(env[TERMINAL_IMAGE_STORAGE_ENV.localDir]) !== null) &&
((persistedHostPath !== null &&
parseImageHostPath(persistedHostPath) !== null) ||
parseImageHostPath(env[TERMINAL_IMAGE_STORAGE_ENV.hostPath]) !== null ||
legacyLocalDir !== null);
return {
mode,
localDir,
hostPath,
ttlMs,
maxCount,
maxBytes,
localMappingConfigured,
};
}
@@ -0,0 +1,675 @@
import fs from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import {
exceedsImageStorageLimit,
isExpiredImage,
isImageFilename,
} from "./image-utils.js";
import type { TerminalImageStorageSettings } from "./image-storage-settings.js";
/**
* Stable error codes for image storage failures. These are part of the upload
* route's contract; `IMAGE_REMOTE_WRITE_FAILED` predates this module and must
* not change.
*/
export type TerminalImageStorageErrorCode =
| "IMAGE_STORAGE_LIMIT_REACHED"
| "IMAGE_LOCAL_WRITE_FAILED"
| "IMAGE_LOCAL_INSPECTION_FAILED"
| "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
| "IMAGE_REMOTE_WRITE_FAILED";
export class TerminalImageStorageError extends Error {
constructor(
readonly code: TerminalImageStorageErrorCode,
message: string,
readonly cause?: unknown,
) {
super(message);
this.name = "TerminalImageStorageError";
}
}
export interface StoredTerminalImage {
id: string;
filename: string;
/** Agent-visible path; never a backend-internal path. */
shellPath: string;
storage: "local" | "remote-sftp";
}
/**
* Mode selection for one upload. Explicit modes are deterministic — they are
* returned regardless of capability and the route reports the failure; only
* `auto` falls back, and only on capability (a connected terminal session
* with SFTP), never on configuration.
*/
export function selectImageStorageMode(
settings: Pick<
TerminalImageStorageSettings,
"mode" | "localMappingConfigured"
>,
capability: { remoteSftpAvailable: boolean; localHostVisible?: boolean },
): "local" | "remote-sftp" | "unavailable" {
if (settings.mode === "local") return "local";
if (settings.mode === "remote-sftp") return "remote-sftp";
if (settings.localMappingConfigured && capability.localHostVisible === true) {
return "local";
}
return capability.remoteSftpAvailable ? "remote-sftp" : "unavailable";
}
// Capacity checks and writes are serialized so concurrent uploads cannot
// bypass the count or byte limits.
let imageStorageQueue: Promise<unknown> = Promise.resolve();
function withImageStorageLock<T>(operation: () => Promise<T>): Promise<T> {
const result = imageStorageQueue.then(operation, operation);
imageStorageQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}
async function cleanupExpiredImages(
localDir: string,
ttlMs: number,
): Promise<void> {
const entries = await fs.readdir(localDir, { withFileTypes: true });
const now = Date.now();
await Promise.all(
entries
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
.map(async (entry) => {
const filePath = path.join(localDir, entry.name);
const stat = await fs.stat(filePath);
if (stat && isExpiredImage(stat.mtimeMs, now, ttlMs)) {
await fs.unlink(filePath).catch(() => undefined);
}
}),
);
}
async function getActiveImageStorageUsage(
localDir: string,
ttlMs: number,
): Promise<{ fileCount: number; totalBytes: number }> {
const entries = await fs.readdir(localDir, { withFileTypes: true });
const now = Date.now();
const stats = await Promise.all(
entries
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
.map(async (entry) => {
const stat = await fs.stat(path.join(localDir, entry.name));
return !isExpiredImage(stat.mtimeMs, now, ttlMs) ? stat : null;
}),
);
return stats.reduce(
(usage, stat) => {
if (stat) {
usage.fileCount += 1;
usage.totalBytes += stat.size;
}
return usage;
},
{ fileCount: 0, totalBytes: 0 },
);
}
/**
* Local mapped-storage adapter. Enforces the TTL/count/byte policy and raises
* `IMAGE_STORAGE_LIMIT_REACHED` (HTTP 507 at the route) when the caps are hit.
* The returned shellPath is built from the agent-visible hostPath — the
* backend's own localDir is never exposed.
*/
export async function storeImageLocally(
image: Buffer,
settings: TerminalImageStorageSettings,
): Promise<StoredTerminalImage> {
return withImageStorageLock(async () => {
let usage: { fileCount: number; totalBytes: number };
try {
await fs.mkdir(settings.localDir, { recursive: true });
await cleanupExpiredImages(settings.localDir, settings.ttlMs);
usage = await getActiveImageStorageUsage(
settings.localDir,
settings.ttlMs,
);
} catch (error) {
throw new TerminalImageStorageError(
"IMAGE_LOCAL_INSPECTION_FAILED",
"Unable to inspect local image storage",
error,
);
}
if (
exceedsImageStorageLimit(
usage.fileCount,
usage.totalBytes,
image.length,
settings.maxCount,
settings.maxBytes,
)
) {
throw new TerminalImageStorageError(
"IMAGE_STORAGE_LIMIT_REACHED",
"Image storage limit reached",
);
}
const id = randomUUID();
const filename = `${id}.png`;
try {
await fs.writeFile(path.join(settings.localDir, filename), image);
} catch (error) {
await fs
.rm(path.join(settings.localDir, filename), { force: true })
.catch(() => undefined);
throw new TerminalImageStorageError(
"IMAGE_LOCAL_WRITE_FAILED",
"Failed to write image to local storage",
error,
);
}
return {
id,
filename,
shellPath: path.posix.join(settings.hostPath, filename),
storage: "local",
};
});
}
// Remote directory (on the SSH host the terminal is connected to) that
// uploaded/pasted images are written into. Always POSIX-style: this is a
// path on the remote shell, not on the Termix backend's own filesystem.
export const REMOTE_IMAGE_DIR = "/tmp/termix-images";
/** Minimal SFTP surface the remote adapter needs (satisfied by ssh2). */
export interface ImageSftpClient {
mkdir(
dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
callback?: (err?: Error) => void,
): void;
createWriteStream(
remotePath: string,
options?: { mode?: number },
): NodeJS.WritableStream;
stat?: (
dir: string,
callback: (
error: Error | undefined,
attrs?: { mode?: number; mtime?: number },
) => void,
) => void;
lstat?: (
dir: string,
callback: (
error: Error | undefined,
attrs?: { mode?: number; mtime?: number },
) => void,
) => void;
chmod?: (
dir: string,
mode: number,
callback: (error?: Error) => void,
) => void;
readdir?: (
dir: string,
callback: (error: Error | undefined, entries: ImageSftpEntry[]) => void,
) => void;
unlink?: (remotePath: string, callback: (error?: Error) => void) => void;
rmdir?: (dir: string, callback: (error?: Error) => void) => void;
end?: () => void;
}
interface ImageSftpEntry {
filename: string;
attrs?: { mtime?: number; size?: number };
}
export interface ImageSshExecClient {
exec(
command: string,
callback: (error: Error | undefined, stream?: ImageExecStream) => void,
): void;
}
interface ImageExecStream {
on(event: "close", listener: (code: number | null) => void): this;
on(event: "error", listener: (error: Error) => void): this;
resume(): void;
}
function quoteRemotePath(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function execBounded(
sshConn: ImageSshExecClient,
command: string,
timeoutMs = 3_000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: boolean) => {
if (settled) return;
settled = true;
resolve(result);
};
const timer = setTimeout(() => finish(false), timeoutMs);
sshConn.exec(command, (error, stream) => {
if (error || !stream) {
clearTimeout(timer);
finish(false);
return;
}
stream.on("close", (code) => {
clearTimeout(timer);
finish(code === 0);
});
stream.on("error", () => {
clearTimeout(timer);
finish(false);
});
stream.resume();
});
});
}
function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
operation.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
/** Verify a configured local mapping from the currently connected SSH session. */
export async function probeLocalImageVisibility(
sshConn: ImageSshExecClient,
settings: Pick<TerminalImageStorageSettings, "localDir" | "hostPath">,
): Promise<boolean> {
const filename = `.termix-image-probe-${randomUUID()}`;
const localProbe = path.join(settings.localDir, filename);
const remoteProbe = path.posix.join(settings.hostPath, filename);
await fs.mkdir(settings.localDir, { recursive: true });
await fs.writeFile(localProbe, "termix-image-probe", { flag: "wx" });
try {
return await execBounded(
sshConn,
`test -f -- ${quoteRemotePath(remoteProbe)}`,
);
} finally {
await fs.unlink(localProbe).catch(() => undefined);
await execBounded(sshConn, `rm -f -- ${quoteRemotePath(remoteProbe)}`);
}
}
function sftpMkdir(sftp: ImageSftpClient, dir: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(dir, { mode: 0o700 }, (err) => {
if (!err) {
resolve();
return;
}
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
if (!inspect) {
reject(err);
return;
}
inspect(dir, (inspectError, attrs) => {
if (inspectError || !attrs) {
reject(inspectError ?? err);
return;
}
if (attrs.mode !== undefined && (attrs.mode & 0o170000) !== 0o040000) {
reject(new Error("Remote image path is not a directory"));
return;
}
if (!sftp.chmod) {
reject(
new Error("Remote image directory permissions cannot be verified"),
);
return;
}
sftp.chmod(dir, 0o700, (chmodError) => {
if (chmodError) reject(chmodError);
else resolve();
});
});
});
});
}
const REMOTE_IMAGE_LOCK_DIR = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
const REMOTE_IMAGE_LOCK_LEASE_MS = 60_000;
function waitForRemoteImageLock(
sftp: ImageSftpClient,
attempts = 60,
): Promise<() => Promise<void>> {
if (!sftp.rmdir) {
return Promise.reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock cannot be verified",
),
);
}
return new Promise((resolve, reject) => {
let remaining = attempts;
const tryAcquire = () => {
sftp.mkdir(REMOTE_IMAGE_LOCK_DIR, { mode: 0o700 }, (error) => {
if (!error) {
resolve(
() =>
new Promise<void>((releaseResolve, releaseReject) => {
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (releaseError) =>
releaseError ? releaseReject(releaseError) : releaseResolve(),
);
}),
);
return;
}
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
if (!inspect) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock cannot be verified",
error,
),
);
return;
}
inspect(REMOTE_IMAGE_LOCK_DIR, (inspectError, attrs) => {
const stale =
!inspectError &&
typeof attrs?.mtime === "number" &&
Date.now() - attrs.mtime * 1000 > REMOTE_IMAGE_LOCK_LEASE_MS;
if (stale) {
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (removeError) => {
if (removeError) {
if (--remaining <= 0) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Stale remote image storage lock cannot be removed",
removeError,
),
);
return;
}
setTimeout(tryAcquire, 50);
return;
}
tryAcquire();
});
return;
}
if (--remaining <= 0) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock is unavailable",
error,
),
);
return;
}
setTimeout(tryAcquire, 50);
});
});
};
tryAcquire();
});
}
function sftpWriteFile(
sftp: ImageSftpClient,
remotePath: string,
data: Buffer,
timeoutMs = 10_000,
): Promise<void> {
return new Promise((resolve, reject) => {
const stream = sftp.createWriteStream(remotePath, {
mode: 0o600,
}) as NodeJS.WritableStream & {
destroy?: () => void;
};
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
stream.destroy?.();
reject(new Error("SFTP image write timed out"));
}, timeoutMs);
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) reject(error);
else resolve();
};
stream.on("error", (error: Error) => finish(error));
stream.on("close", () => finish());
stream.end(data);
});
}
async function cleanupExpiredRemoteImages(
sftp: ImageSftpClient,
ttlMs: number | undefined,
nowMs = Date.now(),
): Promise<void> {
if (!ttlMs || ttlMs <= 0 || !sftp.readdir || !sftp.unlink) return;
const entries = await new Promise<ImageSftpEntry[]>((resolve) => {
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
resolve(error ? [] : result);
});
});
const cutoffSeconds = (nowMs - ttlMs) / 1000;
const uuidPng =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
await Promise.all(
entries
.filter(
(entry) =>
uuidPng.test(entry.filename) &&
typeof entry.attrs?.mtime === "number" &&
entry.attrs.mtime < cutoffSeconds,
)
.map((entry) =>
withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(`${REMOTE_IMAGE_DIR}/${entry.filename}`, () =>
resolve(),
);
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined),
),
);
}
const REMOTE_UUID_PNG_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
async function enforceRemoteImageLimits(
sftp: ImageSftpClient,
imageBytes: number,
maxCount: number,
maxBytes: number,
): Promise<void> {
if (!sftp.readdir) {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
);
}
let entries: ImageSftpEntry[];
try {
entries = await new Promise<ImageSftpEntry[]>((resolve, reject) => {
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
if (error) reject(error);
else resolve(result);
});
});
} catch (error) {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
error,
);
}
const images = entries.filter((entry) =>
REMOTE_UUID_PNG_PATTERN.test(entry.filename),
);
const totalBytes = images.reduce((sum, entry) => {
if (typeof entry.attrs?.size !== "number") {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
);
}
return sum + entry.attrs.size;
}, 0);
if (images.length >= maxCount || totalBytes + imageBytes > maxBytes) {
throw new TerminalImageStorageError(
"IMAGE_STORAGE_LIMIT_REACHED",
"Image storage limit reached",
);
}
}
async function storeImageViaSftpUnlocked(
sftp: ImageSftpClient,
image: Buffer,
options: {
writeTimeoutMs?: number;
ttlMs?: number;
maxCount?: number;
maxBytes?: number;
nowMs?: number;
} = {},
): Promise<StoredTerminalImage> {
const id = randomUUID();
const filename = `${id}.png`;
const remotePath = `${REMOTE_IMAGE_DIR}/${filename}`;
let releaseRemoteLock: (() => Promise<void>) | undefined;
let operationError: TerminalImageStorageError | undefined;
try {
await withTimeout(
sftpMkdir(sftp, REMOTE_IMAGE_DIR),
3_000,
"SFTP directory operation timed out",
);
releaseRemoteLock = await withTimeout(
waitForRemoteImageLock(sftp),
10_000,
"SFTP lock operation timed out",
);
await withTimeout(
cleanupExpiredRemoteImages(sftp, options.ttlMs, options.nowMs),
5_000,
"SFTP cleanup operation timed out",
);
if (options.maxCount !== undefined || options.maxBytes !== undefined) {
await withTimeout(
enforceRemoteImageLimits(
sftp,
image.length,
options.maxCount ?? 100,
options.maxBytes ?? 5_368_709_120,
),
5_000,
"SFTP quota operation timed out",
);
}
await sftpWriteFile(sftp, remotePath, image, options.writeTimeoutMs);
} catch (error) {
if (sftp.unlink) {
await withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(remotePath, () => resolve());
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined);
}
operationError =
error instanceof TerminalImageStorageError
? error
: new TerminalImageStorageError(
"IMAGE_REMOTE_WRITE_FAILED",
"Failed to write image to the remote host",
error,
);
}
if (releaseRemoteLock) {
try {
await withTimeout(
releaseRemoteLock(),
3_000,
"SFTP lock release timed out",
);
} catch (releaseError) {
if (!operationError) {
if (sftp.unlink) {
await withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(remotePath, () => resolve());
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined);
}
operationError = new TerminalImageStorageError(
"IMAGE_REMOTE_WRITE_FAILED",
"Failed to release remote image storage lock",
releaseError,
);
}
}
}
if (operationError) throw operationError;
return { id, filename, shellPath: remotePath, storage: "remote-sftp" };
}
export function storeImageViaSftp(
sftp: ImageSftpClient,
image: Buffer,
options: Parameters<typeof storeImageViaSftpUnlocked>[2] = {},
): Promise<StoredTerminalImage> {
return withImageStorageLock(() =>
withTimeout(
storeImageViaSftpUnlocked(sftp, image, options),
20_000,
"SFTP image operation timed out",
),
);
}
@@ -0,0 +1,100 @@
// Accepted decoded input formats; uploads are normalized to PNG by the route.
export const IMAGE_FORMAT_EXTENSIONS: Record<string, string> = {
avif: "avif",
gif: "gif",
heif: "heif",
jpeg: "jpg",
jp2: "jp2",
jxl: "jxl",
png: "png",
tiff: "tiff",
webp: "webp",
};
export function imageExtensionForFormat(
format: string | undefined,
): string | undefined {
return format ? IMAGE_FORMAT_EXTENSIONS[format] : undefined;
}
export const MAX_NORMALIZED_IMAGE_BYTES = 10 * 1024 * 1024;
export function exceedsNormalizedImageSize(
byteLength: number,
maxBytes = MAX_NORMALIZED_IMAGE_BYTES,
): boolean {
return byteLength > maxBytes;
}
export function createConcurrencyLimiter(
limit: number,
maxQueued = Number.POSITIVE_INFINITY,
): {
acquire: () => Promise<() => void>;
readonly active: number;
readonly queued: number;
} {
const max = Math.max(1, Math.floor(limit));
const queueLimit = Math.max(0, Math.floor(maxQueued));
let active = 0;
const waiters: Array<() => void> = [];
const startNext = () => {
if (active >= max || waiters.length === 0) return;
active += 1;
waiters.shift()!();
};
return {
acquire: () =>
new Promise<() => void>((resolve, reject) => {
if (active >= max && waiters.length >= queueLimit) {
reject(new Error("Concurrency admission queue is full"));
return;
}
waiters.push(() => {
let released = false;
resolve(() => {
if (released) return;
released = true;
active -= 1;
startNext();
});
});
startNext();
}),
get active() {
return active;
},
get queued() {
return waiters.length;
},
};
}
export const IMAGE_FILENAME_PATTERN = /^[0-9a-f-]{36}\.[a-z0-9]+$/i;
export function isImageFilename(filename: string): boolean {
return IMAGE_FILENAME_PATTERN.test(filename);
}
export function isExpiredImage(
modifiedAtMs: number,
nowMs: number,
ttlMs: number,
): boolean {
if (ttlMs <= 0) return false;
return nowMs - modifiedAtMs > ttlMs;
}
export function exceedsImageStorageLimit(
fileCount: number,
totalBytes: number,
incomingBytes: number,
maxFileCount: number,
maxStorageBytes: number,
): boolean {
return (
fileCount >= maxFileCount || totalBytes + incomingBytes > maxStorageBytes
);
}
+166 -21
View File
@@ -1,32 +1,177 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createTerminalLogger } from "./helpers.js";
import { hostImportNormalizer } from "./host-import.js";
import { createHistoryRepository } from "./history-repository.js";
import { registerTerminalRoutes } from "./routes.js";
import {
handleTerminalUpgrade,
sessionManager,
stopTerminalServer,
} from "../../../../src/backend/hosts/terminal/index.js";
DEFAULT_TIMEOUT_MINUTES,
TerminalSessionManager,
type TerminalSession,
} from "./session-manager.js";
import {
SESSION_GUESTS_KEY,
type LiveSessionInfo,
type LiveSessionsV1,
type RecordingsWriterV1,
type SessionGuestsV1,
type SessionSharingV1,
type TerminalHistoryV1,
type TmuxSessionsV1,
} from "./services.js";
import { ADMIN_KEYS } from "./settings.js";
import { commandHistory } from "./tables.js";
import { createTerminalSocket } from "./terminal-socket.js";
function toInfo(session: TerminalSession): LiveSessionInfo {
return {
id: session.id,
userId: session.userId,
hostId: session.hostId,
hostName: session.hostName,
isConnected: session.isConnected,
createdAt: session.createdAt,
lastDetachedAt: session.lastDetachedAt,
tabInstanceId:
session.attachedTabInstanceId ?? session.tabInstanceId ?? null,
tmuxSessionName: session.tmuxSessionName,
cols: session.cols,
rows: session.rows,
};
}
/**
* A service another plugin may or may not provide. The handle always exists;
* whether its provider is up is asked through the proxy's `in` check.
*/
function optionalService<T extends object>(
ctx: PluginContext,
service: string,
probe: keyof T & string,
): T | null {
const handle = ctx.services.get<T>(service);
return probe in handle ? handle : null;
}
export async function activate(ctx: PluginContext) {
// Public because the terminal authenticates each socket itself: a share-link
// guest arrives with a share token instead of a session, so the check has to
// happen where that token is understood.
ctx.ws.upgrade(
"/terminal",
(request, socket, head) =>
handleTerminalUpgrade(
request as Parameters<typeof handleTerminalUpgrade>[0],
socket as Parameters<typeof handleTerminalUpgrade>[1],
head as Buffer,
),
{ public: true },
);
const log = createTerminalLogger(ctx.log);
// Published so collab and session-sharing can reach live sessions without
// importing a plugin. Revoked automatically when this plugin is disabled.
ctx.registry.provide("terminal.sessions", sessionManager ?? null);
const table = await ctx.db.define(commandHistory);
const history = createHistoryRepository(ctx.db, table);
// Read on every detach, so it is kept current rather than awaited there.
let timeoutMinutes = DEFAULT_TIMEOUT_MINUTES;
const applyTimeout = (value: unknown) => {
const minutes = Number(value);
timeoutMinutes =
Number.isFinite(minutes) && minutes > 0
? minutes
: DEFAULT_TIMEOUT_MINUTES;
};
const refreshTimeout = async () =>
applyTimeout(await ctx.settings.get(ADMIN_KEYS.sessionTimeoutMinutes));
await refreshTimeout();
ctx.settings.onChange(ADMIN_KEYS.sessionTimeoutMinutes, applyTimeout);
const sessionManager = new TerminalSessionManager({
log,
getTimeoutMinutes: () => timeoutMinutes,
getRecordings: () =>
optionalService<RecordingsWriterV1>(ctx, "recordings.writer", "open"),
});
ctx.disposables.add(() => sessionManager.destroyAll());
const socket = createTerminalSocket({
ctx,
log,
sessionManager,
getTmux: () =>
optionalService<TmuxSessionsV1>(ctx, "tmux.sessions", "detect"),
getSharing: () =>
optionalService<SessionSharingV1>(
ctx,
"sessions.sharing",
"authorizeJoin",
),
getGuests: () =>
ctx.registry.consume<SessionGuestsV1>(SESSION_GUESTS_KEY) ?? null,
});
// Public with optional auth: a share-link guest arrives with a share token
// instead of a session, so that check happens where the token is understood.
ctx.ws.route(
"/terminal",
(connection) => {
// Also picks up a value the boot migration wrote after activation.
void refreshTimeout().catch(() => {});
return socket.handleConnection(connection);
},
{
public: true,
optionalAuth: true,
},
);
ctx.disposables.add(() => socket.closeAll());
registerTerminalRoutes(ctx.http.router<Router>({ rawBody: true }), {
ctx,
log,
sessionManager,
history,
});
const liveSessions: LiveSessionsV1 = {
getSession: (sessionId) => {
const session = sessionManager.getSession(sessionId);
return session ? toInfo(session) : null;
},
listForUser: (userId) => {
// A plugin caller only ever sees its actor's sessions; core calls this
// outside any actor, for the user its own route authenticated.
const actor = ctx.currentActor();
if (actor && actor !== userId) return [];
return sessionManager.getUserSessions(userId).map(toInfo);
},
ownerEndSession: (sessionId, reason) =>
sessionManager.ownerEndSession(sessionId, reason),
disconnectParticipants: (sessionId, shareId, options) =>
sessionManager.disconnectShareParticipants(sessionId, shareId, options),
setRoomShareControl: (sessionId, shareId, controllerUserId) =>
sessionManager.setRoomShareControl(sessionId, shareId, controllerUserId),
subscribe: (sessionId, onData) => {
const session = sessionManager.getSession(sessionId);
if (!session) return () => {};
session.dataListeners.add(onData);
return () => session.dataListeners.delete(onData);
},
write: (sessionId, data) => {
const session = sessionManager.getSession(sessionId);
if (!session?.sshStream || session.sshStream.destroyed) return false;
sessionManager.bufferInput(sessionId, data);
session.sshStream.write(data);
return true;
},
idleTimeoutMinutes: () => timeoutMinutes,
};
ctx.services.provide("sessions.live", liveSessions);
const terminalHistory: TerminalHistoryV1 = {
list: async (hostId, limit = 200) => {
const userId = ctx.currentActor();
if (!userId) return [];
return history.listCommandsForHost(userId, hostId, limit);
},
};
ctx.services.provide("terminal.history", terminalHistory);
ctx.registry.provide(
"ssh-terminal.hostImportNormalizer",
hostImportNormalizer,
);
ctx.log.info("SSH terminal mounted at /plugin-ws/ssh-terminal/terminal");
}
export async function deactivate() {
await stopTerminalServer();
// Everything above was registered through ctx and is disposed by core.
}
@@ -0,0 +1,301 @@
/**
* Keyboard-interactive answering over the terminal WebSocket.
*
* Core's classifier (ctx.ssh.classifyKeyboardInteractive) decides what a
* round is; this keeps the terminal's message protocol (totp_required,
* totp_retry, password_required, warpgate_auth_required) and its timeouts
* exactly as the client expects.
*/
import type { WebSocket } from "ws";
import type {
PluginKeyboardInteractivePrompt,
PluginSsh,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import type { TerminalLogger } from "./helpers.js";
type KeyboardInteractivePrompt = PluginKeyboardInteractivePrompt;
const PASSWORD_PATTERN = /password/i;
/** The answer at one index, the stored password for password prompts, else empty. */
export function responsesWithAnswer(
prompts: KeyboardInteractivePrompt[],
answerIndex: number,
answer: string,
password: string | null | undefined,
): string[] {
return prompts.map((p, index) => {
if (index === answerIndex) return answer;
if (PASSWORD_PATTERN.test(p.prompt) && password) return password;
return "";
});
}
interface ResolvedCredentials {
username: string;
password?: string;
authType?: string;
}
interface HostFlags {
useWarpgate?: boolean;
[key: string]: unknown;
}
interface AuthContext {
ssh: Pick<PluginSsh, "classifyKeyboardInteractive">;
log: TerminalLogger;
userId: string;
ws: WebSocket;
hostId: number;
isKeyboardInteractive: boolean;
keyboardInteractiveResponded: boolean;
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
totpPromptSent: boolean;
warpgateAuthPromptSent: boolean;
totpTimeout: NodeJS.Timeout | null;
warpgateAuthTimeout: NodeJS.Timeout | null;
totpAttempts: number;
}
const TOTP_TIMEOUT_MS = 180000;
const PUSH_TIMEOUT_MS = 300000;
const WARPGATE_TIMEOUT_MS = 300000;
export class SSHAuthManager {
public context: AuthContext;
constructor(context: AuthContext) {
this.context = context;
}
handleKeyboardInteractive(
name: string,
instructions: string,
_instructionsLang: string,
prompts: KeyboardInteractivePrompt[],
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
hostConfig?: HostFlags,
): void {
this.context.isKeyboardInteractive = true;
const host = {
...(hostConfig ?? {}),
id: this.context.hostId,
ip: "",
port: 22,
username: resolvedCredentials.username,
password: resolvedCredentials.password,
authType: resolvedCredentials.authType,
} as PluginSshHost;
const decision = this.context.ssh.classifyKeyboardInteractive(
{ name, instructions, prompts },
host,
);
switch (decision.kind) {
case "auto":
finish(decision.responses);
return;
case "warpgate":
this.handleWarpgate(decision, finish);
return;
case "totp":
this.handleTotp(
prompts,
decision.promptIndex,
finish,
host.password as string | undefined,
);
return;
case "input":
this.handleInput(
prompts,
decision.promptIndex,
decision.isPush,
finish,
host.password as string | undefined,
);
return;
}
}
private handleWarpgate(
decision: { url: string | null; securityKey: string; instructions: string },
finish: (responses: string[]) => void,
): void {
this.context.keyboardInteractiveFinish = () => {
finish([""]);
};
this.context.warpgateAuthPromptSent = true;
this.sendLog("auth", "info", "Warpgate authentication required");
this.context.ws.send(
JSON.stringify({
type: "warpgate_auth_required",
url: decision.url,
securityKey: decision.securityKey,
instructions: decision.instructions,
}),
);
this.context.warpgateAuthTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.warpgateAuthPromptSent = false;
this.context.log.warn("Warpgate authentication timeout", {
operation: "warpgate_timeout",
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Warpgate authentication timeout. Please reconnect.",
}),
);
}
}, WARPGATE_TIMEOUT_MS);
}
private handleTotp(
prompts: KeyboardInteractivePrompt[],
promptIndex: number,
finish: (responses: string[]) => void,
password: string | undefined,
): void {
if (this.context.totpPromptSent) {
this.context.log.warn("TOTP prompt asked again - invalid code", {
operation: "ssh_keyboard_interactive_totp_retry",
hostId: this.context.hostId,
});
this.context.log.warn("TOTP verification failed for SSH session", {
operation: "terminal_totp_failed",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.sendLog("auth", "warning", "Invalid TOTP code");
this.context.ws.send(JSON.stringify({ type: "totp_retry" }));
return;
}
this.context.totpPromptSent = true;
this.context.keyboardInteractiveResponded = true;
this.context.keyboardInteractiveFinish = (answers: string[]) => {
finish(
responsesWithAnswer(
prompts,
promptIndex,
(answers[0] || "").trim(),
password,
),
);
};
this.armTimeout(TOTP_TIMEOUT_MS, "totp");
this.sendLog("auth", "info", "TOTP verification required");
this.context.log.info("TOTP verification prompt sent to client", {
operation: "terminal_totp_prompt",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "totp_required",
prompt: prompts[promptIndex].prompt,
}),
);
}
private handleInput(
prompts: KeyboardInteractivePrompt[],
promptIndex: number,
isPush: boolean,
finish: (responses: string[]) => void,
password: string | undefined,
): void {
if (this.context.keyboardInteractiveResponded) return;
this.context.keyboardInteractiveResponded = true;
this.context.keyboardInteractiveFinish = (answers: string[]) => {
finish(
responsesWithAnswer(
prompts,
promptIndex,
(answers[0] || "").trim(),
password,
),
);
};
this.armTimeout(isPush ? PUSH_TIMEOUT_MS : TOTP_TIMEOUT_MS, "password");
this.sendLog("auth", "info", "Password authentication required");
this.context.ws.send(
JSON.stringify({
type: "password_required",
prompt: prompts[promptIndex].prompt,
echo: prompts[promptIndex].echo,
}),
);
}
private armTimeout(ms: number, kind: "totp" | "password"): void {
if (this.context.totpTimeout) clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = setTimeout(() => {
if (!this.context.keyboardInteractiveFinish) return;
this.context.keyboardInteractiveFinish = null;
if (kind === "totp") {
this.context.totpPromptSent = false;
} else {
this.context.keyboardInteractiveResponded = false;
}
this.context.log.warn(
`${kind === "totp" ? "TOTP" : "Password"} prompt timeout`,
{
operation: `${kind}_timeout`,
hostId: this.context.hostId,
},
);
this.context.ws.send(
JSON.stringify({
type: "error",
message:
kind === "totp"
? "TOTP verification timeout. Please reconnect."
: "Password verification timeout. Please reconnect.",
}),
);
}, ms);
}
sendLog(
stage: string,
level: string,
message: string,
details?: Record<string, unknown>,
): void {
this.context.ws.send(
JSON.stringify({
type: "connection_log",
data: { stage, level, message, details },
}),
);
}
cleanup(): void {
if (this.context.totpTimeout) {
clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = null;
}
if (this.context.warpgateAuthTimeout) {
clearTimeout(this.context.warpgateAuthTimeout);
this.context.warpgateAuthTimeout = null;
}
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
this.context.warpgateAuthPromptSent = false;
this.context.keyboardInteractiveResponded = false;
}
}
+801
View File
@@ -0,0 +1,801 @@
import express, {
type NextFunction,
type Request,
type Response,
type Router,
} from "express";
import { randomUUID } from "crypto";
import multer from "multer";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { getErrorMessage, type TerminalLogger } from "./helpers.js";
import type { HistoryRepository } from "./history-repository.js";
import type { TerminalSessionManager } from "./session-manager.js";
import {
ADMIN_KEYS,
HOST_KEYS,
readClientSettings,
readImageStorageSettings,
} from "./settings.js";
import {
createConcurrencyLimiter,
exceedsNormalizedImageSize,
imageExtensionForFormat,
} from "./images/image-utils.js";
import {
selectImageStorageMode,
probeLocalImageVisibility,
storeImageLocally,
storeImageViaSftp,
TerminalImageStorageError,
type ImageSftpClient,
} from "./images/image-storage.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export const SENSITIVE_COMMAND_PATTERNS = [
/passw(or)?d/i,
/\bsecret\b/i,
/\btoken\b/i,
/\bapi.?key\b/i,
/PASS(WORD)?=/i,
/AWS_SECRET/i,
/mysql\b.*-p/i,
/sudo\s+-S\b/,
/htpasswd/i,
/sshpass/i,
/curl\b.*-u\s/i,
/export\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
];
type SharpFactory = typeof import("sharp").default;
export interface TerminalRouteDeps {
ctx: PluginContext;
log: TerminalLogger;
sessionManager: TerminalSessionManager;
history: HistoryRepository;
}
/**
* The terminal's HTTP routes, on a router core mounts at
* /plugin-api/ssh-terminal/. It is created with rawBody so multer can read
* the upload stream; JSON routes parse their own body.
*/
export function registerTerminalRoutes(
router: Router,
{ ctx, log, sessionManager, history }: TerminalRouteDeps,
): void {
const json = express.json({ limit: "2mb" });
// Browser image handoff for local terminal-agent workflows.
const imageUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024,
fields: 4,
fieldSize: 64 * 1024,
files: 1,
parts: 5,
headerPairs: 200,
},
});
const imageUploadMiddleware = imageUpload.single("image");
let sharpFactory: SharpFactory | null = null;
let sharpLoadFailure: string | null = null;
// A static import of sharp takes the plugin down with it when the native
// binary for the running architecture is missing. Resolve on first use so
// only image upload is lost.
async function loadSharp(): Promise<SharpFactory> {
if (sharpFactory) return sharpFactory;
if (sharpLoadFailure !== null) throw new Error(sharpLoadFailure);
try {
sharpFactory = (await import("sharp")).default;
return sharpFactory;
} catch (error) {
sharpLoadFailure = getErrorMessage(error, "unknown");
throw new Error(sharpLoadFailure);
}
}
const imageProcessingLimiter = createConcurrencyLimiter(4, 4);
const imageMultipartAdmissionLimiter = createConcurrencyLimiter(4, 4);
let imageUploadSequence = 0;
async function handleImageUploadMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
let releaseAdmission: (() => void) | undefined;
try {
releaseAdmission = await imageMultipartAdmissionLimiter.acquire();
} catch {
res.status(503).json({
error: "Image upload capacity is temporarily unavailable",
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
});
return;
}
imageUploadMiddleware(req, res, (error: unknown) => {
try {
if (!error) {
next();
return;
}
if (error instanceof multer.MulterError) {
log.warn("Image upload multipart request rejected", {
operation: "terminal_image_upload_multipart_rejected",
code: error.code,
field: error.field,
contentType: req.headers["content-type"]?.split(";", 1)[0],
});
res.status(400).json({
error: "Image upload request rejected",
code: error.code,
field: error.field,
});
return;
}
log.warn("Image upload multipart request malformed", {
operation: "terminal_image_upload_multipart_invalid",
contentType: req.headers["content-type"]?.split(";", 1)[0],
});
res.status(400).json({
error: "Malformed image upload request",
code: "IMAGE_MULTIPART_INVALID",
});
} finally {
releaseAdmission?.();
}
});
}
function findTerminalSession(userId: string, instanceId: string) {
return sessionManager
.getUserSessions(userId)
.find(
(session) =>
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
instanceId && session.isConnected,
);
}
/**
* @openapi
* /plugin-api/ssh-terminal/image-upload:
* post:
* summary: Upload an image into a terminal session
* description: Normalizes an uploaded or pasted image to PNG and stores it where the terminal's programs can read it, over SFTP on the connected host or in the backend's mapped local storage, depending on the image storage settings. Returns the path to hand to the program.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* properties:
* image:
* type: string
* format: binary
* instanceId:
* type: string
* source:
* type: string
* enum: [file, clipboard]
* clientUploadTimestamp:
* type: string
* responses:
* 200:
* description: The stored image's path.
* 400:
* description: Missing or invalid image or session.
* 409:
* description: The terminal is not connected.
* 413:
* description: The normalized image is too large.
* 502:
* description: Writing to the remote host failed.
* 503:
* description: Image storage or processing is unavailable.
* 507:
* description: The image storage limit was reached.
*/
router.post(
"/image-upload",
handleImageUploadMiddleware,
async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const instanceId = req.body?.instanceId;
if (!req.file) {
return res.status(400).json({
error: "Image required",
code: "IMAGE_FILE_MISSING",
});
}
if (!isNonEmptyString(userId)) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
const requestId = randomUUID();
const sequence = ++imageUploadSequence;
const source =
req.body?.source === "file" || req.body?.source === "clipboard"
? req.body.source
: undefined;
const clientUploadTimestamp =
typeof req.body?.clientUploadTimestamp === "string" &&
!Number.isNaN(Date.parse(req.body.clientUploadTimestamp))
? req.body.clientUploadTimestamp
: undefined;
const serverReceivedAt = new Date().toISOString();
log.info("Terminal image upload received", {
operation: "terminal_image_upload_received",
requestId,
sequence,
source,
clientUploadTimestamp,
serverReceivedAt,
bytes: req.file.size,
});
const storageSettings = await readImageStorageSettings(ctx);
const session = isNonEmptyString(instanceId)
? findTerminalSession(userId, instanceId)
: undefined;
let localHostVisible = false;
if (storageSettings.localMappingConfigured && session?.sshConn) {
localHostVisible = await probeLocalImageVisibility(
session.sshConn,
storageSettings,
).catch(() => false);
}
const storageMode = selectImageStorageMode(storageSettings, {
remoteSftpAvailable: !!session?.sshConn,
localHostVisible,
});
if (storageMode === "unavailable") {
return res.status(503).json({
error: "Image storage is unavailable",
code: "IMAGE_STORAGE_UNAVAILABLE",
});
}
if (storageMode === "local" && !storageSettings.localMappingConfigured) {
return res.status(503).json({
error: "Local image storage is not configured",
code: "IMAGE_LOCAL_STORAGE_NOT_CONFIGURED",
});
}
if (storageMode === "remote-sftp") {
if (!isNonEmptyString(instanceId)) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
if (!session || !session.sshConn) {
return res.status(409).json({
error: "Terminal is not connected",
code: "IMAGE_TERMINAL_NOT_CONNECTED",
});
}
}
let sharp: SharpFactory;
try {
sharp = await loadSharp();
} catch (error) {
log.error("Image processing unavailable: sharp failed to load", error, {
operation: "terminal_image_upload_sharp_unavailable",
reason: getErrorMessage(error, "unknown"),
});
return res.status(503).json({
error: "Image processing is unavailable on this installation",
code: "IMAGE_PROCESSING_UNAVAILABLE",
});
}
let normalizedImage: Buffer;
let releaseImageProcessingSlot: (() => void) | undefined;
try {
releaseImageProcessingSlot = await imageProcessingLimiter.acquire();
} catch {
return res.status(503).json({
error: "Image upload capacity is temporarily exhausted",
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
});
}
try {
const image = sharp(req.file.buffer, {
failOn: "error",
limitInputPixels: 40_000_000,
});
const { format } = await image.metadata();
if (!imageExtensionForFormat(format)) {
return res.status(400).json({
error: "Unsupported image format",
code: "IMAGE_FORMAT_UNSUPPORTED",
});
}
normalizedImage = await image.rotate().png().toBuffer();
if (exceedsNormalizedImageSize(normalizedImage.length)) {
return res.status(413).json({
error: "Normalized image is too large",
code: "IMAGE_NORMALIZED_SIZE_LIMIT",
});
}
} catch (error) {
log.warn("Image upload failed image decoding", {
operation: "terminal_image_upload_decode",
mimeType: req.file.mimetype,
bytes: req.file.size,
reason: getErrorMessage(error, "unknown"),
});
return res.status(400).json({
error: "Invalid image data",
code: "IMAGE_DECODE_FAILED",
});
} finally {
releaseImageProcessingSlot?.();
}
let remoteSftp: ImageSftpClient | undefined;
try {
const stored =
storageMode === "remote-sftp"
? await (async () => {
remoteSftp = await new Promise<ImageSftpClient>(
(resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
settled = true;
reject(new Error("SFTP channel acquisition timed out"));
}, 3_000);
session!.sshConn!.sftp((err, sftp) => {
if (settled) {
sftp?.end?.();
return;
}
settled = true;
clearTimeout(timer);
if (err) return reject(err);
resolve(sftp);
});
},
);
return storeImageViaSftp(remoteSftp, normalizedImage, {
ttlMs: storageSettings.ttlMs,
maxCount: storageSettings.maxCount,
maxBytes: storageSettings.maxBytes,
});
})()
: await storeImageLocally(normalizedImage, storageSettings);
res.json(stored);
} catch (error) {
if (error instanceof TerminalImageStorageError) {
const status =
error.code === "IMAGE_STORAGE_LIMIT_REACHED"
? 507
: error.code === "IMAGE_REMOTE_WRITE_FAILED"
? 502
: error.code === "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
? 503
: error.code === "IMAGE_LOCAL_INSPECTION_FAILED"
? 503
: 500;
log.warn("Image upload storage write failed", {
operation:
error.code === "IMAGE_REMOTE_WRITE_FAILED"
? "terminal_image_upload_sftp_failed"
: "terminal_image_upload_local_failed",
code: error.code,
userId,
instanceId,
reason: getErrorMessage(error.cause ?? error, "unknown"),
});
return res.status(status).json({
error: error.message,
code: error.code,
});
}
log.warn("Image upload failed to acquire remote channel", {
operation: "terminal_image_upload_sftp_failed",
code: "IMAGE_REMOTE_WRITE_FAILED",
userId,
instanceId,
reason: getErrorMessage(error, "unknown"),
});
return res.status(502).json({
error: "Failed to write image to the remote host",
code: "IMAGE_REMOTE_WRITE_FAILED",
});
} finally {
remoteSftp?.end?.();
}
},
);
/**
* @openapi
* /plugin-api/ssh-terminal/image-storage/test:
* post:
* summary: Test image storage visibility (admin only)
* description: Reports which storage mode an upload would take for one of the caller's already-connected terminal sessions. Uses the bounded local-mapping probe only; it never opens new connections.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* instanceId:
* type: string
* responses:
* 200:
* description: Visibility test result.
* 400:
* description: Missing terminal session instanceId.
* 403:
* description: Admin access required.
* 500:
* description: Failed to run the test.
*/
router.post("/image-storage/test", json, async (req, res) => {
if (!(await ctx.rbac.has("admin.plugins.manage"))) {
return res.status(403).json({ error: "Admin access required" });
}
const userId = ctx.currentActor() ?? "";
const instanceId = req.body?.instanceId;
if (typeof instanceId !== "string" || instanceId.trim().length === 0) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
try {
const settings = await readImageStorageSettings(ctx);
const session = findTerminalSession(userId, instanceId);
const remoteSftpAvailable = !!session?.sshConn;
let localHostVisible: boolean | null = null;
if (settings.localMappingConfigured && session?.sshConn) {
localHostVisible = await probeLocalImageVisibility(
session.sshConn,
settings,
).catch(() => false);
}
const selectedMode = selectImageStorageMode(settings, {
remoteSftpAvailable,
...(localHostVisible !== null ? { localHostVisible } : {}),
});
res.json({
mode: settings.mode,
connected: !!session,
remoteSftpAvailable,
localHostVisible,
selectedMode,
localMappingConfigured: settings.localMappingConfigured,
});
} catch (err) {
log.error("Failed to test image storage visibility", err);
res
.status(500)
.json({ error: "Failed to test image storage visibility" });
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/client-settings:
* get:
* summary: Terminal settings every user's terminal needs
* description: The admin-set session timeout and persistence flag, whether command history is on, and the touch input tuning. Readable by any signed-in user; changing them goes through the plugin's admin settings.
* tags:
* - Terminal
* responses:
* 200:
* description: The settings.
* 500:
* description: Failed to load settings.
*/
router.get("/client-settings", async (_req, res) => {
try {
res.json(await readClientSettings(ctx));
} catch (err) {
log.error("Failed to load terminal client settings", err);
res.status(500).json({ error: "Failed to load settings" });
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/command-history:
* post:
* summary: Save command to history
* description: Saves a command to the caller's history for a host. Commands that look like they carry a secret, and anything while command history is off globally or for the host, are acknowledged but not stored.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 201:
* description: Command saved, or skipped on purpose.
* 400:
* description: Missing required parameters.
* 500:
* description: Failed to save command.
*/
router.post("/command-history", json, async (req, res) => {
const userId = ctx.currentActor();
const { hostId, command } = req.body ?? {};
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
log.warn("Invalid command history save request", {
operation: "command_history_save",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
const hostIdNum = parseInt(hostId, 10);
const trimmedCommand = command.trim();
const skipped = {
id: 0,
userId,
hostId: hostIdNum,
command: trimmedCommand,
executedAt: new Date().toISOString(),
};
if (SENSITIVE_COMMAND_PATTERNS.some((p) => p.test(trimmedCommand))) {
return res.status(201).json(skipped);
}
if ((await ctx.settings.get(ADMIN_KEYS.commandHistoryEnabled)) === false) {
return res.status(201).json(skipped);
}
if (
(await ctx.settings.getHost(
hostIdNum,
HOST_KEYS.enableCommandHistory,
)) === false
) {
return res.status(201).json(skipped);
}
try {
const executedAt = new Date().toISOString();
await history.create(userId, hostIdNum, trimmedCommand, executedAt);
res.status(201).json({
userId,
hostId: hostIdNum,
command: trimmedCommand,
executedAt,
});
} catch (err) {
log.error("Failed to save command to history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to save command"),
});
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/command-history/{hostId}:
* get:
* summary: Get command history
* description: The caller's distinct commands on a host, most recently used first, for autocomplete.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of commands.
* 400:
* description: Invalid request parameters.
* 500:
* description: Failed to fetch history.
*/
router.get("/command-history/:hostId", async (req, res) => {
const userId = ctx.currentActor();
const hostIdNum = parseInt(String(req.params.hostId), 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
log.warn("Invalid command history fetch request", {
userId,
hostId: hostIdNum,
});
return res.status(400).json({ error: "Invalid request parameters" });
}
try {
res.json(await history.listUniqueCommandsForHost(userId, hostIdNum));
} catch (err) {
log.error("Failed to fetch command history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to fetch history"),
});
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/command-history/{hostId}/recent:
* get:
* summary: Get recent command history
* description: The caller's commands on a host, newest first, duplicates included. Needs hosts.view.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of commands.
* 400:
* description: Invalid host id.
* 403:
* description: Missing the hosts.view permission.
* 500:
* description: Failed to fetch command history.
*/
router.get("/command-history/:hostId/recent", async (req, res) => {
if (!(await ctx.rbac.has("hosts.view"))) {
return res.status(403).json({ error: "Insufficient permissions" });
}
const userId = ctx.currentActor();
const hostIdNum = parseInt(String(req.params.hostId), 10);
if (!isNonEmptyString(userId) || !hostIdNum) {
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const rows = await history.listCommandsForHost(userId, hostIdNum);
res.json(rows.map((row) => row.command));
} catch (err) {
log.error("Failed to fetch command history from database", err, {
operation: "command_history_fetch",
hostId: hostIdNum,
});
res.status(500).json({ error: "Failed to fetch command history" });
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/command-history/delete:
* post:
* summary: Delete a specific command from history
* description: Deletes one command from the caller's history for a host.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 200:
* description: Command deleted successfully.
* 400:
* description: Missing required parameters.
* 500:
* description: Failed to delete command.
*/
router.post("/command-history/delete", json, async (req, res) => {
const userId = ctx.currentActor();
const { hostId, command } = req.body ?? {};
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
log.warn("Invalid command delete request", {
operation: "command_history_delete",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
try {
await history.deleteCommandForHost(
userId,
parseInt(hostId, 10),
command.trim(),
);
res.json({ success: true });
} catch (err) {
log.error("Failed to delete command from history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to delete command"),
});
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/command-history/{hostId}:
* delete:
* summary: Clear command history
* description: Clears the caller's whole command history for a host.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Command history cleared successfully.
* 400:
* description: Invalid request.
* 500:
* description: Failed to clear history.
*/
router.delete("/command-history/:hostId", async (req, res) => {
const userId = ctx.currentActor();
const hostIdNum = parseInt(String(req.params.hostId), 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
log.warn("Invalid command history clear request");
return res.status(400).json({ error: "Invalid request" });
}
try {
await history.deleteByUserAndHost(userId, hostIdNum);
log.info("Terminal history cleared", {
operation: "terminal_history_clear",
userId,
hostId: hostIdNum,
});
res.json({ success: true });
} catch (err) {
log.error("Failed to clear command history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to clear history"),
});
}
});
}
@@ -0,0 +1,143 @@
/**
* The services this plugin provides and consumes.
*
* Provided: "sessions.live" (live SSH sessions, for session sharing, collab
* and recording) and "terminal.history" (command history, for the AI
* assistant). Consumed, all optional: "tmux.sessions" (B10),
* "sessions.sharing" (B12) and "recordings.writer" (B13). The terminal keeps
* working without any of them: no tmux attach, no shared joins, no recording.
*/
export interface LiveSessionInfo {
id: string;
userId: string;
hostId: number;
hostName: string;
isConnected: boolean;
createdAt: number;
lastDetachedAt: number | null;
tabInstanceId: string | null;
tmuxSessionName: string | null;
cols: number;
rows: number;
}
export interface LiveSessionsV1 {
getSession: (sessionId: string) => LiveSessionInfo | null;
/** The caller's own sessions. A plugin caller only ever gets its actor's. */
listForUser: (userId: string) => LiveSessionInfo[];
/** Tells every guest the owner ended it, then closes the session. */
ownerEndSession: (sessionId: string, reason: string) => void;
/** Removes the participants that joined through one share. */
disconnectParticipants: (
sessionId: string,
shareId: string,
options: { reason: string; userId?: string | null },
) => number;
/** Hands input control of a room share to one member, or back to the owner. */
setRoomShareControl: (
sessionId: string,
shareId: string,
controllerUserId: string | null,
) => void;
/** Output as it arrives. Returns the unsubscribe. */
subscribe: (sessionId: string, onData: (data: string) => void) => () => void;
/** Types into the session. False when it is not live. */
write: (sessionId: string, data: string) => boolean;
/** How long a detached session is kept, in minutes. */
idleTimeoutMinutes: () => number;
}
export interface CommandHistoryEntry {
command: string;
executedAt: string;
}
export interface TerminalHistoryV1 {
/** The acting user's most recent commands on one host, newest first. */
list: (hostId: number, limit?: number) => Promise<CommandHistoryEntry[]>;
}
export interface TmuxDetection {
available: boolean;
sessions: string[];
}
/**
* What the terminal asks of the tmux plugin. Clients are ssh2 objects. Every
* method is async to a consumer: a service call goes through the permission
* check and the audit line first.
*/
export interface TmuxSessionsV1 {
detect: (client: unknown) => Promise<TmuxDetection>;
/** Writes the attach (or new-session) command into a shell stream. */
attachOrCreate: (
stream: unknown,
name?: string,
newName?: string,
) => Promise<void>;
/** Waits for a new session to exist and returns its confirmed name. */
waitForSession: (client: unknown, name: string) => Promise<string>;
}
export interface SharedSessionRef {
id: string;
sessionId: string;
permissionLevel: "read-write" | "read-only";
}
/** For signed-in members; every call runs as the acting user. */
export interface SessionSharingV1 {
/** A user share targeting the actor, or a room stage they are a member of. */
authorizeJoin: (
shareId: string,
) => Promise<{ share: SharedSessionRef; displayName: string } | null>;
recordJoin: (shareId: string) => Promise<void>;
/** Joins the socket to a collab room's event fan-out. False when not a member. */
subscribeRoom: (roomId: string, socket: unknown) => Promise<boolean>;
unsubscribeRoom: (socket: unknown, roomId?: string) => Promise<void>;
}
/**
* Guest joins through a share link or a room guest link. A guest has no user,
* so the per-call RBAC check a service runs cannot apply: the token is the
* authority. Published on ctx.registry as SESSION_GUESTS_KEY instead.
*/
export interface SessionGuestsV1 {
resolve: (request: {
shareToken?: string;
roomGuestToken?: string;
clientIp: string;
}) => Promise<
{ ok: true; share: SharedSessionRef } | { ok: false; reason: string }
>;
recordJoin: (shareId: string) => Promise<void>;
}
export const SESSION_GUESTS_KEY = "sessions.sharing.guests";
export interface RecordingSink {
/** Appends one batch; the first batch starts with the asciicast header. */
append: (chunk: string) => Promise<void>;
/** Writes or updates the recording row. */
persist: (summary: {
endedAt: number;
durationSeconds: number;
terminatedByOwner: boolean;
terminationReason: string | null;
}) => Promise<void>;
/** Nothing was recorded; drop whatever was set up. */
discard: () => void;
}
export interface RecordingsWriterV1 {
/** Null when recording is off for this user or host. */
open: (meta: {
sessionId: string;
hostId: number;
userId: string;
protocol: "ssh";
format: "asciicast";
startedAt: number;
}) => Promise<RecordingSink | null>;
}
@@ -0,0 +1,945 @@
import { randomUUID } from "crypto";
import { type Client, type ClientChannel } from "ssh2";
import { WebSocket } from "ws";
import type { TerminalLogger } from "./helpers.js";
import type { RecordingSink, RecordingsWriterV1 } from "./services.js";
const MAX_BUFFER_BYTES = 512 * 1024;
export const DEFAULT_TIMEOUT_MINUTES = 30;
const HEALTH_CHECK_INTERVAL_MS = 60_000;
const MAX_SESSIONS_PER_USER = 10;
// Coalesces recording writes: a chatty SSH stream can emit dozens of "data"
// events per second, and appending to disk on every single one saturates the
// libuv threadpool (default size 4), starving unrelated fs/DNS/crypto work
// and stalling the WS ping/pong health check enough to look like connection
// drops. Batch pending lines and flush on a short trailing edge instead.
const RECORDING_FLUSH_INTERVAL_MS = 300;
export interface SessionParticipant {
ws: WebSocket;
userId: string | null; // null for anonymous link guests
permissionLevel: "read-write" | "read-only";
isOwner: boolean;
displayName?: string;
guestLabel?: string;
tabInstanceId?: string;
joinedViaShareId?: string;
}
export interface TerminalSession {
id: string;
userId: string;
hostId: number;
hostName: string;
tabInstanceId?: string;
attachedTabInstanceId?: string;
sshConn: Client | null;
sshStream: ClientChannel | null;
jumpClient: Client | null;
cols: number;
rows: number;
isConnected: boolean;
createdAt: number;
participants: Map<string, SessionParticipant>;
lastDetachedAt: number | null;
detachTimeout: NodeJS.Timeout | null;
outputBuffer: string[];
outputBufferBytes: number;
/** Output listeners from the sessions.live service. */
dataListeners: Set<(data: string) => void>;
/** Resolves once the recordings service answered; null when off. */
recordingSink: Promise<RecordingSink | null> | null;
recordingHeader: string | null;
recordingBytes: number;
recordingWriteChain: Promise<void>;
recordingPersistChain: Promise<void>;
pendingRecordingData: string;
recordingFlushTimer: NodeJS.Timeout | null;
tmuxSessionName: string | null;
sessionLoggingEnabled: boolean;
sessionStartedAt: number;
lastPersistedBytes: number;
terminatedByOwner: boolean;
terminationReason: string | null;
}
/** Message types a non-owner participant may legally send. */
const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([
"input",
"ping",
"disconnect",
]);
/**
* Server-side gate for whether a participant may send a given WS message
* type. The owner may send anything; non-owners are limited to input (if
* read-write), ping, and disconnect. Pure function so read-only enforcement
* is unit-testable without a real WebSocketServer.
*/
export function isMessageAllowedForParticipant(
participant: Pick<SessionParticipant, "isOwner" | "permissionLevel"> | null,
messageType: string,
): boolean {
if (!participant || participant.isOwner) return true;
if (!NON_OWNER_ALLOWED_MESSAGE_TYPES.has(messageType)) return false;
if (messageType === "input" && participant.permissionLevel === "read-only") {
return false;
}
return true;
}
export interface SessionManagerDeps {
log: TerminalLogger;
/** Minutes a detached session is kept; read on every detach. */
getTimeoutMinutes: () => number;
/** The recordings.writer service, when a plugin provides it. */
getRecordings: () => RecordingsWriterV1 | null;
}
export class TerminalSessionManager {
private sessions = new Map<string, TerminalSession>();
private healthCheckTimer: NodeJS.Timeout | null = null;
private readonly log: TerminalLogger;
constructor(private readonly deps: SessionManagerDeps) {
this.log = deps.log;
this.healthCheckTimer = setInterval(
() => this.healthCheck(),
HEALTH_CHECK_INTERVAL_MS,
);
}
createSession(
userId: string,
hostId: number,
hostName: string,
cols: number,
rows: number,
tabInstanceId?: string,
sessionLoggingEnabled = true,
): string {
const userSessions = this.getUserSessions(userId);
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
const detached = userSessions
.filter((s) => this.getOwnerParticipant(s) === null)
.sort(
(a, b) =>
(a.lastDetachedAt ?? a.createdAt) -
(b.lastDetachedAt ?? b.createdAt),
);
if (detached.length > 0) {
this.destroySession(detached[0].id);
}
}
if (tabInstanceId) {
const tabSessions = userSessions.filter(
(s) => s.tabInstanceId === tabInstanceId,
);
for (const existing of tabSessions) {
const isLiveSession =
existing.isConnected &&
existing.sshStream != null &&
!existing.sshStream.destroyed;
if (isLiveSession) {
// Don't destroy a live session (even if detached) — the caller should attach instead
this.log.warn(
"Tab instance has live session, skipping duplicate create",
{
operation: "session_tab_duplicate_skip",
existingSessionId: existing.id,
tabInstanceId,
hasAttachedWs: this.getOwnerParticipant(existing) !== null,
},
);
return existing.id;
}
this.log.warn("Tab instance already has session, destroying old", {
operation: "session_tab_duplicate_cleanup",
existingSessionId: existing.id,
tabInstanceId,
});
this.destroySession(existing.id);
}
}
const id = randomUUID();
const now = Date.now();
let recordingSink: Promise<RecordingSink | null> | null = null;
let recordingHeader: string | null = null;
const recordings = sessionLoggingEnabled ? this.deps.getRecordings() : null;
if (recordings) {
// Events recorded before this resolves wait in pendingRecordingData.
recordingSink = Promise.resolve()
.then(() =>
recordings.open({
sessionId: id,
hostId,
userId,
protocol: "ssh",
format: "asciicast",
startedAt: now,
}),
)
.catch((err) => {
this.log.warn("Could not start a session recording", {
operation: "session_recording_open_error",
sessionId: id,
error: err instanceof Error ? err.message : String(err),
});
return null;
});
}
if (recordingSink) {
recordingHeader = `${JSON.stringify({
version: 2,
width: cols,
height: rows,
timestamp: Math.floor(now / 1000),
env: { TERM: "xterm-256color", SHELL: "/bin/sh" },
})}\n`;
}
const session: TerminalSession = {
id,
userId,
hostId,
hostName,
tabInstanceId,
sshConn: null,
sshStream: null,
jumpClient: null,
cols,
rows,
isConnected: false,
createdAt: now,
participants: new Map(),
lastDetachedAt: null,
detachTimeout: null,
outputBuffer: [],
outputBufferBytes: 0,
dataListeners: new Set(),
recordingSink,
recordingHeader,
recordingBytes: 0,
recordingWriteChain: Promise.resolve(),
recordingPersistChain: Promise.resolve(),
pendingRecordingData: "",
recordingFlushTimer: null,
tmuxSessionName: null,
sessionLoggingEnabled: !!recordingSink,
sessionStartedAt: now,
lastPersistedBytes: 0,
terminatedByOwner: false,
terminationReason: null,
};
this.sessions.set(id, session);
this.log.info("Terminal session created", {
operation: "session_created",
sessionId: id,
userId,
hostId,
});
return id;
}
getSession(sessionId: string | null): TerminalSession | null {
if (!sessionId) return null;
return this.sessions.get(sessionId) ?? null;
}
setSSHState(
sessionId: string,
conn: Client,
stream: ClientChannel,
jumpClient?: Client | null,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.sshConn = conn;
session.sshStream = stream;
session.jumpClient = jumpClient ?? null;
session.isConnected = true;
}
/** Finds the owner's participant entry, if currently attached. */
private getOwnerParticipant(
session: TerminalSession,
): SessionParticipant | null {
for (const participant of session.participants.values()) {
if (participant.isOwner) return participant;
}
return null;
}
private getOwnerEntry(
session: TerminalSession,
): [string, SessionParticipant] | null {
for (const entry of session.participants.entries()) {
if (entry[1].isOwner) return entry;
}
return null;
}
attachWs(
sessionId: string,
userId: string,
ws: WebSocket,
tabInstanceId?: string,
): TerminalSession | null {
const session = this.sessions.get(sessionId);
if (!session) {
this.log.warn("Session not found for attachment", {
operation: "session_attach_not_found",
sessionId,
userId,
});
return null;
}
if (session.userId !== userId) {
this.log.warn("Session userId mismatch", {
operation: "session_attach_user_mismatch",
sessionId,
expectedUserId: session.userId,
providedUserId: userId,
});
return null;
}
if (!session.isConnected) {
this.log.warn("Session not connected", {
operation: "session_attach_not_connected",
sessionId,
userId,
createdAt: session.createdAt,
elapsed: Date.now() - session.createdAt,
});
return null;
}
const ownerParticipant = this.getOwnerParticipant(session);
const isDetached =
!ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
const isOriginalTab =
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
tabInstanceId;
if (
!isDetached &&
!isOriginalTab &&
session.tabInstanceId &&
tabInstanceId
) {
this.log.warn("Session actively attached to different tab instance", {
operation: "session_attach_instance_conflict",
sessionId,
sessionInstanceId: session.tabInstanceId,
providedInstanceId: tabInstanceId,
});
try {
ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId,
message: "Session belongs to a different tab instance",
}),
);
} catch {
/* ignore */
}
return null;
}
if (
session.tabInstanceId &&
tabInstanceId &&
session.tabInstanceId !== tabInstanceId
) {
this.log.info(
"Session attached to different tab instance (split-screen)",
{
operation: "session_attach_split_screen",
originalInstanceId: session.tabInstanceId,
newInstanceId: tabInstanceId,
sessionId,
},
);
}
const ownerEntry = this.getOwnerEntry(session);
if (ownerEntry && ownerEntry[1].ws !== ws) {
try {
ownerEntry[1].ws.send(
JSON.stringify({
type: "sessionTakenOver",
sessionId,
message: "Session was attached from another tab",
}),
);
} catch {
/* ignore */
}
session.participants.delete(ownerEntry[0]);
}
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
const participantId = randomUUID();
session.participants.set(participantId, {
ws,
userId,
permissionLevel: "read-write",
isOwner: true,
tabInstanceId,
});
session.attachedTabInstanceId = tabInstanceId;
session.lastDetachedAt = null;
this.broadcastParticipants(sessionId);
this.log.info("WebSocket attached to session", {
operation: "session_attach",
sessionId,
userId,
tabInstanceId,
});
return session;
}
/**
* Adds a non-owner participant (in-app share join or anonymous link guest).
* Purely additive - never evicts the owner or any other participant.
*/
joinAsParticipant(
sessionId: string,
ws: WebSocket,
opts: {
userId: string | null;
permissionLevel: "read-write" | "read-only";
displayName?: string;
guestLabel?: string;
tabInstanceId?: string;
shareId?: string;
},
): TerminalSession | null {
const session = this.sessions.get(sessionId);
if (!session || !session.isConnected) return null;
const participantId = randomUUID();
session.participants.set(participantId, {
ws,
userId: opts.userId,
permissionLevel: opts.permissionLevel,
isOwner: false,
displayName: opts.displayName,
guestLabel: opts.guestLabel,
tabInstanceId: opts.tabInstanceId,
joinedViaShareId: opts.shareId,
});
this.broadcastParticipants(sessionId);
this.log.info("Participant joined shared session", {
operation: "session_join_participant",
sessionId,
userId: opts.userId,
permissionLevel: opts.permissionLevel,
shareId: opts.shareId,
});
return session;
}
/**
* Tells everyone in a shared session who is present. Sent on join and
* leave, and only while someone besides the owner is (or just was) in the
* room - a solo owner never receives presence traffic.
*/
private broadcastParticipants(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
const participants = Array.from(session.participants.values()).map(
(participant) => ({
isOwner: participant.isOwner,
permissionLevel: participant.permissionLevel,
label: participant.displayName ?? participant.guestLabel ?? null,
}),
);
if (participants.every((participant) => participant.isOwner)) return;
this.broadcast(sessionId, { type: "participants", participants });
}
/**
* Grants stage control: participants joined via this share become
* read-write only while they are the controller. The owner is untouched.
*/
setRoomShareControl(
sessionId: string,
shareId: string,
controllerUserId: string | null,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
for (const participant of session.participants.values()) {
if (participant.isOwner || participant.joinedViaShareId !== shareId)
continue;
participant.permissionLevel =
controllerUserId && participant.userId === controllerUserId
? "read-write"
: "read-only";
}
}
/**
* Disconnects non-owner participants that joined through one share.
* Supplying userId narrows the kick to that authenticated user; null targets
* anonymous guests. Omitting it revokes the share for every participant.
*/
disconnectShareParticipants(
sessionId: string,
shareId: string,
options: { userId?: string | null; reason: string },
): number {
const session = this.sessions.get(sessionId);
if (!session) return 0;
const filterByUser = Object.hasOwn(options, "userId");
let disconnected = 0;
for (const [id, participant] of session.participants.entries()) {
if (
participant.isOwner ||
participant.joinedViaShareId !== shareId ||
(filterByUser && participant.userId !== options.userId)
) {
continue;
}
session.participants.delete(id);
disconnected += 1;
if (participant.ws.readyState === WebSocket.OPEN) {
try {
participant.ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId,
message: options.reason,
}),
);
participant.ws.close(1008, options.reason);
} catch {
participant.ws.terminate();
}
}
}
if (disconnected > 0) this.broadcastParticipants(sessionId);
return disconnected;
}
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
broadcast(sessionId: string, message: object): void {
const session = this.sessions.get(sessionId);
if (!session) return;
const payload = JSON.stringify(message);
for (const participant of session.participants.values()) {
if (participant.ws.readyState !== WebSocket.OPEN) continue;
try {
participant.ws.send(payload);
} catch {
/* ignore individual send failures, keep broadcasting to the rest */
}
}
}
/** Finds the participant entry (owner or not) for a given socket. */
getParticipantForWs(
session: TerminalSession,
ws: WebSocket,
): SessionParticipant | null {
for (const participant of session.participants.values()) {
if (participant.ws === ws) return participant;
}
return null;
}
/**
* Removes a non-owner participant's socket. No detach timeout or session
* destruction side effects - a guest leaving must never end the session.
*/
removeParticipant(sessionId: string, ws: WebSocket): void {
const session = this.sessions.get(sessionId);
if (!session) return;
for (const [id, participant] of session.participants.entries()) {
if (participant.ws === ws && !participant.isOwner) {
session.participants.delete(id);
this.broadcastParticipants(sessionId);
this.log.info("Participant left shared session", {
operation: "session_leave_participant",
sessionId,
userId: participant.userId,
});
return;
}
}
}
/** Broadcasts termination to all guests, then destroys the session. */
ownerEndSession(sessionId: string, reason: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason });
session.terminatedByOwner = true;
session.terminationReason = reason;
this.log.info("Owner ended shared session", {
operation: "session_owner_end",
sessionId,
reason,
});
this.destroySession(sessionId);
}
detachWs(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
const ownerEntry = this.getOwnerEntry(session);
if (ownerEntry) {
session.participants.delete(ownerEntry[0]);
}
session.lastDetachedAt = Date.now();
// Persist log immediately when the user detaches so it appears right away,
// regardless of whether the session is later reattached or times out.
this.maybePersistLog(session);
const timeoutMs = this.getTimeoutMs();
session.detachTimeout = setTimeout(() => {
this.log.info("Session idle timeout expired", {
operation: "session_idle_timeout",
sessionId,
userId: session.userId,
});
this.destroySession(sessionId);
}, timeoutMs);
this.log.info("WebSocket detached from session", {
operation: "session_detach",
sessionId,
userId: session.userId,
timeoutMinutes: timeoutMs / 60_000,
});
}
destroySession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
this.maybePersistLog(session, true);
if (session.recordingSink && session.recordingBytes === 0) {
void session.recordingSink.then((sink) => sink?.discard());
}
session.dataListeners.clear();
for (const participant of session.participants.values()) {
if (participant.isOwner) continue;
if (participant.ws.readyState !== WebSocket.OPEN) continue;
try {
participant.ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId,
message: "Session has ended",
}),
);
} catch {
/* ignore */
}
}
session.participants.clear();
if (session.sshStream) {
try {
session.sshStream.end();
} catch {
/* ignore */
}
session.sshStream = null;
}
if (session.sshConn) {
try {
session.sshConn.end();
} catch {
/* ignore */
}
session.sshConn = null;
}
if (session.jumpClient) {
try {
session.jumpClient.end();
} catch {
/* ignore */
}
session.jumpClient = null;
}
session.isConnected = false;
session.outputBuffer = [];
session.outputBufferBytes = 0;
this.sessions.delete(sessionId);
this.log.info("Terminal session destroyed", {
operation: "session_destroyed",
sessionId,
userId: session.userId,
hostId: session.hostId,
});
}
private maybePersistLog(session: TerminalSession, force = false): void {
if (!session.sessionLoggingEnabled) return;
if (session.recordingFlushTimer) {
clearTimeout(session.recordingFlushTimer);
session.recordingFlushTimer = null;
this.flushRecording(session);
}
if (session.recordingBytes === 0) return;
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
session.lastPersistedBytes = session.recordingBytes;
session.recordingPersistChain = session.recordingPersistChain
.then(() => this.persistSessionLog(session))
.catch((err) => {
this.log.warn("Failed to persist session log", {
operation: "session_log_persist_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
private async persistSessionLog(session: TerminalSession): Promise<void> {
if (!session.recordingSink) return;
await session.recordingWriteChain;
const sink = await session.recordingSink;
if (!sink) return;
const endedAt = Date.now();
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
try {
await sink.persist({
endedAt,
durationSeconds: duration,
terminatedByOwner: session.terminatedByOwner,
terminationReason: session.terminationReason,
});
} catch (err) {
this.log.warn("Failed to insert session recording row", {
operation: "session_recording_insert_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
}
this.log.info("Session log persisted", {
operation: "session_log_persisted",
sessionId: session.id,
userId: session.userId,
hostId: session.hostId,
duration,
bytes: session.recordingBytes,
});
}
getUserSessions(userId: string): TerminalSession[] {
const result: TerminalSession[] = [];
for (const session of this.sessions.values()) {
if (session.userId === userId) {
result.push(session);
}
}
return result;
}
bufferOutput(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.outputBuffer.push(data);
session.outputBufferBytes += data.length;
while (
session.outputBufferBytes > MAX_BUFFER_BYTES &&
session.outputBuffer.length > 0
) {
const removed = session.outputBuffer.shift();
if (removed) session.outputBufferBytes -= removed.length;
}
for (const listener of session.dataListeners) {
try {
listener(data);
} catch {
// A listener that throws must not break the session.
}
}
this.recordSessionEvent(session, "o", data);
}
bufferInput(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "i", data);
}
bufferResize(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "r", `${cols}x${rows}`);
}
private recordSessionEvent(
session: TerminalSession,
type: "i" | "o" | "r",
data: string,
): void {
if (!session.sessionLoggingEnabled || !session.recordingSink || !data)
return;
const elapsed = (Date.now() - session.sessionStartedAt) / 1000;
const line = `${JSON.stringify([elapsed, type, data])}\n`;
session.recordingBytes += Buffer.byteLength(line);
session.pendingRecordingData += line;
if (!session.recordingFlushTimer) {
session.recordingFlushTimer = setTimeout(() => {
session.recordingFlushTimer = null;
this.flushRecording(session);
}, RECORDING_FLUSH_INTERVAL_MS);
}
}
/**
* Coalesces buffered recording lines into one write, chained so they land
* in order. Never one write per chunk: that starved the libuv threadpool
* (issue #1049).
*/
private flushRecording(session: TerminalSession): void {
const pendingSink = session.recordingSink;
if (!pendingSink || !session.pendingRecordingData) return;
const chunk = session.pendingRecordingData;
session.pendingRecordingData = "";
const firstWrite = session.recordingBytes === Buffer.byteLength(chunk);
session.recordingWriteChain = session.recordingWriteChain
.then(async () => {
const sink = await pendingSink;
if (!sink) return;
await sink.append(
firstWrite ? `${session.recordingHeader}${chunk}` : chunk,
);
})
.catch((err) => {
this.log.warn("Failed to write session recording", {
operation: "session_recording_write_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
flushBuffer(session: TerminalSession): string | null {
if (session.outputBuffer.length === 0) return null;
const data = session.outputBuffer.join("");
session.outputBuffer = [];
session.outputBufferBytes = 0;
return data;
}
getBuffer(session: TerminalSession): string | null {
if (session.outputBuffer.length === 0) return null;
return session.outputBuffer.join("");
}
private getTimeoutMs(): number {
const minutes = this.deps.getTimeoutMinutes();
return (
(Number.isFinite(minutes) && minutes > 0
? minutes
: DEFAULT_TIMEOUT_MINUTES) * 60_000
);
}
private healthCheck(): void {
const toDestroy: string[] = [];
const now = Date.now();
const GRACE_PERIOD_MS = 10_000;
for (const [id, session] of this.sessions) {
if (!session.isConnected) continue;
const hasOpenParticipant = Array.from(session.participants.values()).some(
(p) => p.ws.readyState === WebSocket.OPEN,
);
if (hasOpenParticipant) {
continue;
}
if (session.sshStream?.destroyed) {
const detachedDuration = session.lastDetachedAt
? now - session.lastDetachedAt
: 0;
if (detachedDuration > GRACE_PERIOD_MS) {
this.log.info(
"SSH stream destroyed during detach window, cleaning up",
{
operation: "session_health_check_stream_destroyed",
sessionId: id,
userId: session.userId,
detachedFor: detachedDuration,
},
);
toDestroy.push(id);
}
}
if (!session.sshConn) {
toDestroy.push(id);
}
}
for (const id of toDestroy) {
this.destroySession(id);
}
}
destroyAll(): void {
for (const id of [...this.sessions.keys()]) {
this.destroySession(id);
}
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
}
}
@@ -0,0 +1,91 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
normalizeTouchInputSettings,
type TouchInputSettings,
} from "../shared/touch-input-settings.js";
import {
resolveTerminalImageStorageSettings,
TERMINAL_IMAGE_STORAGE_KEYS,
type TerminalImageStorageSettings,
} from "./images/image-storage-settings.js";
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
/**
* Admin setting keys, as declared in manifest.json. The image storage fields
* have no default on purpose: an unset field falls through to the old
* TERMIX_IMAGE_* environment variables, the way the settings table did.
*/
export const ADMIN_KEYS = {
sessionTimeoutMinutes: "sessionTimeoutMinutes",
sessionPersistence: "sessionPersistence",
commandHistoryEnabled: "commandHistoryEnabled",
touchInput: "touchInput",
imageStorageMode: "imageStorageMode",
imageLocalDir: "imageLocalDir",
imageHostPath: "imageHostPath",
imageTtlMs: "imageTtlMs",
imageMaxCount: "imageMaxCount",
imageMaxBytes: "imageMaxBytes",
} as const;
export const HOST_KEYS = {
enableTerminal: "enableTerminal",
enableTerminalToolbar: "enableTerminalToolbar",
enableCommandHistory: "enableCommandHistory",
} as const;
const IMAGE_FIELD_BY_LEGACY_KEY: Record<string, string> = {
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: ADMIN_KEYS.imageStorageMode,
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: ADMIN_KEYS.imageLocalDir,
[TERMINAL_IMAGE_STORAGE_KEYS.hostPath]: ADMIN_KEYS.imageHostPath,
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: ADMIN_KEYS.imageTtlMs,
[TERMINAL_IMAGE_STORAGE_KEYS.maxCount]: ADMIN_KEYS.imageMaxCount,
[TERMINAL_IMAGE_STORAGE_KEYS.maxBytes]: ADMIN_KEYS.imageMaxBytes,
};
export async function readImageStorageSettings(
ctx: PluginContext,
): Promise<TerminalImageStorageSettings> {
return resolveTerminalImageStorageSettings({
get: async (legacyKey) => {
const field = IMAGE_FIELD_BY_LEGACY_KEY[legacyKey];
if (!field) return null;
const value = await ctx.settings.get(field);
if (value === undefined || value === null || value === "") return null;
return String(value);
},
warn: (message) => ctx.log.warn(message),
});
}
export interface ClientSettings {
sessionTimeoutMinutes: number;
sessionPersistence: boolean;
commandHistoryEnabled: boolean;
touchInput: TouchInputSettings;
}
/** What every signed-in user's terminal needs, whether or not they are an admin. */
export async function readClientSettings(
ctx: PluginContext,
): Promise<ClientSettings> {
const all = await ctx.settings.getAll("admin");
const timeout = Number(all[ADMIN_KEYS.sessionTimeoutMinutes]);
let touch: unknown = all[ADMIN_KEYS.touchInput];
if (typeof touch === "string") {
try {
touch = JSON.parse(touch);
} catch {
touch = null;
}
}
return {
sessionTimeoutMinutes:
Number.isFinite(timeout) && timeout > 0
? timeout
: DEFAULT_TIMEOUT_MINUTES,
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
touchInput: normalizeTouchInputSettings(touch),
};
}
@@ -0,0 +1,39 @@
import {
adoptLegacyTable,
defineTable,
id,
refHost,
refUser,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* Commands typed into terminal sessions, per user and host.
*
* Adopted from core's command_history, so the column and index names are the
* legacy ones: the rename carries the existing rows and index across.
*/
export const commandHistory = adoptLegacyTable(
"command_history",
defineTable(
"command_history",
{
id: id(),
userId: refUser(),
hostId: refHost(),
command: text().notNull(),
executedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_command_history_user_host",
columns: ["userId", "hostId"],
},
],
},
),
);
export const tables = [commandHistory];
File diff suppressed because it is too large. Load diff
@@ -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;
@@ -0,0 +1,127 @@
export const TOUCH_INPUT_SETTING_KEY = "touch_input_settings";
export interface TouchInputSettings {
enabled: boolean;
momentumEnabled: boolean;
dragThresholdPx: number;
maxWheelDeltaPx: number;
momentumSampleWindowMs: number;
releaseGracePeriodMs: number;
minimumVelocityPxPerMs: number;
maximumVelocityPxPerMs: number;
maximumDurationMs: number;
maximumTravelPx: number;
decayTimeMs: number;
pixelsPerTick: number;
maximumFrameIntervalMs: number;
maximumTicksPerFrame: number;
}
export const TOUCH_INPUT_DEFAULTS: TouchInputSettings = {
enabled: true,
momentumEnabled: true,
dragThresholdPx: 6,
maxWheelDeltaPx: 120,
momentumSampleWindowMs: 100,
releaseGracePeriodMs: 120,
minimumVelocityPxPerMs: 0.15,
maximumVelocityPxPerMs: 2.5,
maximumDurationMs: 1_000,
maximumTravelPx: 720,
decayTimeMs: 300,
pixelsPerTick: 12,
maximumFrameIntervalMs: 32,
maximumTicksPerFrame: 4,
};
export type TouchInputNumericKey = Exclude<
keyof TouchInputSettings,
"enabled" | "momentumEnabled"
>;
export const TOUCH_INPUT_NUMERIC_BOUNDS: Record<
TouchInputNumericKey,
{ min: number; max: number; step: number }
> = {
dragThresholdPx: { min: 0, max: 100, step: 1 },
maxWheelDeltaPx: { min: 1, max: 1_000, step: 1 },
momentumSampleWindowMs: { min: 10, max: 1_000, step: 10 },
releaseGracePeriodMs: { min: 0, max: 2_000, step: 10 },
minimumVelocityPxPerMs: { min: 0, max: 10, step: 0.01 },
maximumVelocityPxPerMs: { min: 0.01, max: 20, step: 0.01 },
maximumDurationMs: { min: 0, max: 10_000, step: 100 },
maximumTravelPx: { min: 0, max: 10_000, step: 10 },
decayTimeMs: { min: 1, max: 5_000, step: 10 },
pixelsPerTick: { min: 1, max: 500, step: 1 },
maximumFrameIntervalMs: { min: 1, max: 1_000, step: 1 },
maximumTicksPerFrame: { min: 1, max: 100, step: 1 },
};
export function normalizeTouchInputSettings(
value: unknown,
): TouchInputSettings {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { ...TOUCH_INPUT_DEFAULTS };
}
const input = value as Record<string, unknown>;
const normalized: TouchInputSettings = { ...TOUCH_INPUT_DEFAULTS };
for (const key of ["enabled", "momentumEnabled"] as const) {
if (typeof input[key] === "boolean") normalized[key] = input[key];
}
for (const [key, bounds] of Object.entries(TOUCH_INPUT_NUMERIC_BOUNDS) as [
TouchInputNumericKey,
{ min: number; max: number },
][]) {
const candidate = input[key];
if (
typeof candidate === "number" &&
Number.isFinite(candidate) &&
candidate >= bounds.min &&
candidate <= bounds.max
) {
normalized[key] = candidate;
}
}
return normalized;
}
export function validateTouchInputSettingsUpdate(
value: unknown,
): string | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return "settings must be an object";
}
const input = value as Record<string, unknown>;
const validKeys = new Set(Object.keys(TOUCH_INPUT_DEFAULTS));
const unknownKey = Object.keys(input).find((key) => !validKeys.has(key));
if (unknownKey) return `Unknown touch input setting: ${unknownKey}`;
for (const key of ["enabled", "momentumEnabled"] as const) {
if (key in input && typeof input[key] !== "boolean") {
return `${key} must be a boolean`;
}
}
for (const [key, bounds] of Object.entries(TOUCH_INPUT_NUMERIC_BOUNDS) as [
TouchInputNumericKey,
{ min: number; max: number },
][]) {
if (!(key in input)) continue;
const candidate = input[key];
if (
typeof candidate !== "number" ||
!Number.isFinite(candidate) ||
candidate < bounds.min ||
candidate > bounds.max
) {
return `${key} must be a number between ${bounds.min} and ${bounds.max}`;
}
}
if (
typeof input.minimumVelocityPxPerMs === "number" &&
typeof input.maximumVelocityPxPerMs === "number" &&
input.minimumVelocityPxPerMs > input.maximumVelocityPxPerMs
) {
return "minimumVelocityPxPerMs must not exceed maximumVelocityPxPerMs";
}
return null;
}