feat: move terminal settings, look and ssh tools into ssh-terminal plugin

This commit is contained in:
LukeGus committed 2026-09-29 02:38:37 -05:00
1 parent ffa872ebd3
commit d9bba10612
242 files changed
+25071 -7207

No files matched your search

@@ -1,20 +1,50 @@
import {
hostSettingsFromTerminalConfig,
terminalConfigFromHostSettings,
} from "../shared/terminal-settings.js";
const SWITCH_KEYS = [
"enableTerminal",
"enableTerminalToolbar",
"enableCommandHistory",
] as const;
/**
* 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.
* host columns, and its terminal look and behavior, which a 2.8 or early 2.9
* export carried in terminalConfig. An export that already carries this
* plugin's pluginSettings has had them written by core, so terminalConfig is
* only read without them.
*/
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) {
for (const key of SWITCH_KEYS) {
if (raw[key] !== undefined) values[key] = raw[key] !== false;
}
return values;
const carried = (raw.pluginSettings as Record<string, unknown> | undefined)?.[
"ssh-terminal"
];
const hasCarried =
!!carried && typeof carried === "object" && Object.keys(carried).length > 0;
if (!hasCarried) {
Object.assign(values, hostSettingsFromTerminalConfig(raw.terminalConfig));
}
return Object.keys(values).length > 0 ? values : null;
}
/**
* Registered as "ssh-terminal.hostPayloadLegacy": the terminal keys of the
* 2.8 terminalConfig, for clients that still read it (Termix-Mobile). Core
* merges them into the terminalConfig it sends, never over a key it set.
* Remove once the mobile app reads pluginSettings["ssh-terminal"].
*/
export function hostPayloadLegacy(
values: Record<string, unknown>,
): Record<string, unknown> {
return { terminalConfig: terminalConfigFromHostSettings(values) };
}
+2 -1
View File
@@ -1,7 +1,7 @@
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 { hostImportNormalizer, hostPayloadLegacy } from "./host-import.js";
import { createHistoryRepository } from "./history-repository.js";
import { registerTerminalRoutes } from "./routes.js";
import {
@@ -171,6 +171,7 @@ export async function activate(ctx: PluginContext) {
"ssh-terminal.hostImportNormalizer",
hostImportNormalizer,
);
ctx.registry.provide("ssh-terminal.hostPayloadLegacy", hostPayloadLegacy);
ctx.log.info("SSH terminal mounted at /plugin-ws/ssh-terminal/terminal");
}
@@ -13,6 +13,7 @@ import type { TerminalSessionManager } from "./session-manager.js";
import {
ADMIN_KEYS,
HOST_KEYS,
MOVABLE_USER_KEYS,
readClientSettings,
readImageStorageSettings,
} from "./settings.js";
@@ -524,6 +525,99 @@ export function registerTerminalRoutes(
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/user-settings:
* put:
* summary: Save some of the caller's terminal settings
* description: Writes the caller's local echo, link click and command autocomplete settings. The terminal uses it once, to move values a browser kept before 2.9.0.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* localEcho:
* type: string
* linkClickBehavior:
* type: string
* commandAutocomplete:
* type: boolean
* responses:
* 200:
* description: Saved.
* 400:
* description: A value the setting does not accept.
*/
router.put("/user-settings", json, async (req, res) => {
const userId = ctx.currentActor();
if (!userId) return res.status(401).json({ error: "Sign in first" });
const body = (req.body ?? {}) as Record<string, unknown>;
try {
for (const key of MOVABLE_USER_KEYS) {
if (body[key] !== undefined) {
await ctx.settings.setUser(userId, key, body[key]);
}
}
res.json({ success: true });
} catch (err) {
res.status(400).json({ error: getErrorMessage(err, "Invalid setting") });
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/hosts/{hostId}/auto-tmux:
* put:
* summary: Turn auto tmux on or off for a host
* description: Sets the host's autoTmux terminal setting. Used by the "enable Auto-Tmux" action shown when a persisted session expires. Needs edit access to the host.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* responses:
* 200:
* description: Saved.
* 400:
* description: Invalid host id or value.
* 403:
* description: No edit access to the host.
*/
router.put("/hosts/:hostId/auto-tmux", json, async (req, res) => {
const hostId = Number(req.params.hostId);
const enabled = (req.body ?? {}).enabled;
if (!Number.isInteger(hostId) || typeof enabled !== "boolean") {
return res.status(400).json({ error: "Invalid host or value" });
}
try {
const access = await ctx.hosts.checkAccess(hostId, "edit");
if (!access.hasAccess) {
return res.status(403).json({ error: "Access denied to host" });
}
await ctx.settings.setHost(hostId, HOST_KEYS.autoTmux, enabled);
res.json({ success: true, autoTmux: enabled });
} catch (err) {
log.error("Failed to update auto tmux", err);
res.status(500).json({ error: "Failed to update auto tmux" });
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/command-history:
@@ -9,6 +9,14 @@ import {
type TerminalImageStorageSettings,
} from "./images/image-storage-settings.js";
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
import {
DEFAULT_APPEARANCE,
NEW_HOST_ADMIN_KEYS,
pickTerminalValues,
readUserSettings,
type NewHostTerminalDefaults,
type TerminalUserSettings,
} from "../shared/terminal-settings.js";
/**
* Admin setting keys, as declared in manifest.json. The image storage fields
@@ -37,8 +45,16 @@ export const HOST_KEYS = {
terminalToolbarShowStatus: "terminalToolbarShowStatus",
terminalToolbarFade: "terminalToolbarFade",
enableCommandHistory: "enableCommandHistory",
autoTmux: "autoTmux",
} as const;
/** User settings a browser kept in localStorage before 2.9.0. */
export const MOVABLE_USER_KEYS = [
"localEcho",
"linkClickBehavior",
"commandAutocomplete",
] 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,
@@ -68,6 +84,10 @@ export interface ClientSettings {
sessionPersistence: boolean;
commandHistoryEnabled: boolean;
touchInput: TouchInputSettings;
/** What a new host's terminal starts with. */
newHostDefaults: NewHostTerminalDefaults;
/** The caller's own terminal settings. */
user: TerminalUserSettings;
}
/** What every signed-in user's terminal needs, whether or not they are an admin. */
@@ -84,6 +104,8 @@ export async function readClientSettings(
touch = null;
}
}
const actor = ctx.currentActor();
const user = actor ? await ctx.settings.getAll("user", actor) : {};
return {
sessionTimeoutMinutes:
Number.isFinite(timeout) && timeout > 0
@@ -92,5 +114,26 @@ export async function readClientSettings(
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
touchInput: normalizeTouchInputSettings(touch),
newHostDefaults: readNewHostDefaults(all),
user: readUserSettings(user),
};
}
/** The admin's new-host terminal defaults, typed, with the built-in ones under them. */
export function readNewHostDefaults(
admin: Record<string, unknown>,
): NewHostTerminalDefaults {
const byHostKey: Record<string, unknown> = {};
for (const [adminKey, hostKey] of Object.entries(NEW_HOST_ADMIN_KEYS)) {
byHostKey[hostKey] = admin[adminKey];
}
return {
fontSize: DEFAULT_APPEARANCE.fontSize,
fontFamily: DEFAULT_APPEARANCE.fontFamily,
theme: DEFAULT_APPEARANCE.theme,
cursorStyle: DEFAULT_APPEARANCE.cursorStyle,
cursorBlink: DEFAULT_APPEARANCE.cursorBlink,
autoTmux: false,
...pickTerminalValues(byHostKey, Object.values(NEW_HOST_ADMIN_KEYS)),
} as NewHostTerminalDefaults;
}
@@ -8,6 +8,7 @@ import ssh2Pkg, {
} from "ssh2";
import {
PluginSshInteractionError,
type HostSshOptions,
type PluginContext,
type PluginSshHost,
type PluginSshPrepared,
@@ -32,6 +33,7 @@ import {
type TerminalLogger,
} from "./helpers.js";
import { SSHAuthManager } from "./keyboard-prompt.js";
import { HOST_KEYS } from "./settings.js";
import {
isMessageAllowedForParticipant,
type TerminalSessionManager,
@@ -76,11 +78,8 @@ interface ConnectToHostData {
protocol?: "tcp" | "udp";
delay?: number;
}>;
terminalConfig?: {
keepaliveInterval?: number;
keepaliveCountMax?: number;
[key: string]: unknown;
};
/** Keepalive, legacy algorithms, agent and environment options. */
sshOptions?: HostSshOptions | null;
/** When true, ignore key material and force password auth (fallback path). */
passwordFallbackOnly?: boolean;
};
@@ -1434,7 +1433,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
socks5Password?: string;
socks5ProxyChain?: unknown;
portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"];
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
sshOptions?: HostSshOptions | null;
})
| null = null;
@@ -1520,8 +1519,8 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
hostConfig.socks5ProxyChain = resolvedHostData.socks5ProxyChain;
}
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
hostConfig.terminalConfig = resolvedHostData.terminalConfig;
if (!hostConfig.sshOptions && resolvedHostData.sshOptions) {
hostConfig.sshOptions = resolvedHostData.sshOptions;
}
if (
@@ -1549,6 +1548,17 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
const serverHostId = resolveServerHostId(id, resolvedHostData);
let autoTmuxEnabled = false;
if (serverHostId != null) {
try {
autoTmuxEnabled =
(await ctx.settings.getHost(serverHostId, HOST_KEYS.autoTmux)) ===
true;
} catch {
autoTmuxEnabled = false;
}
}
// Resolve credentials server-side when frontend doesn't provide them
let resolvedCredentials = {
username,
@@ -2115,7 +2125,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
}
});
const autoTmux = hostConfig.terminalConfig?.autoTmux === true;
const autoTmux = autoTmuxEnabled;
// Helper to run initialPath/executeCommand after the shell
// (or tmux session) is ready
@@ -2692,7 +2702,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyType: resolvedCredentials.keyType,
certPublicKey: resolvedCredentials.certPublicKey,
forceKeyboardInteractive: hostConfig.forceKeyboardInteractive,
terminalConfig: hostConfig.terminalConfig,
sshOptions: hostConfig.sshOptions,
jumpHosts: hostConfig.jumpHosts,
useSocks5: hostConfig.useSocks5,
socks5Host: hostConfig.socks5Host,
@@ -2758,7 +2768,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
return;
}
if (hostConfig.terminalConfig?.agentForwarding) {
if (hostConfig.sshOptions?.agentForwarding) {
if (connectConfig.privateKey) {
try {
const parsed = ssh2Utils.parseKey(
@@ -39,6 +39,7 @@ export function TerminalTabWithRegistry(props: TabProps) {
id: tabRecord.id,
hostId: host ? Number(host.id) || null : null,
hostName: host?.name,
label: props.label,
ip: host?.ip,
username: host?.username,
port: host?.port,
+75 -1
View File
@@ -1,7 +1,15 @@
import { lazy, Suspense, type ComponentType } from "react";
import { History, Laptop, SquareTerminal, Terminal } from "lucide-react";
import {
Hammer,
History,
Laptop,
SquareTerminal,
Terminal,
} from "lucide-react";
import { listHosts } from "@termix/plugin-sdk/frontend";
import type {
PanelProps,
PluginHostRecord,
StandaloneViewProps,
TabProps,
TermixApp,
@@ -24,6 +32,25 @@ import { ImageStorageTest } from "./settings/ImageStorageTest";
import { HostTerminalSection } from "./settings/HostTerminalSection";
import { resetTouchInputSettingsCache } from "./terminal/touch-input-settings-store";
import { hostSetting } from "./terminal-api";
import { SshToolsPanel } from "./ssh-tools/SshToolsPanel";
import { TerminalDefaultsSettings } from "./settings/TerminalDefaultsSettings";
import {
NewHostFontSetting,
NewHostThemeSetting,
} from "./settings/NewHostLookSettings";
import { TerminalPreview } from "./look/TerminalPreview";
import { installTerminalGlobalStyles } from "./look/terminal-global-styles";
import {
listTerminalThemes,
resolveTerminalLook,
type ResolveLookRequest,
} from "./look/look-actions";
import {
invalidateTerminalClientSettings,
loadTerminalClientSettings,
resetTerminalClientSettings,
} from "./terminal-settings";
import { moveLocalTerminalPreferences } from "./settings/local-preferences-migration";
const LocalTerminal = lazy(() =>
import("./local-terminal/LocalTerminal").then((m) => ({
@@ -65,6 +92,7 @@ function LocalTerminalTab({ tab, isVisible }: TabProps) {
* history panel and the places other plugins can plug into a session.
*/
export function activate(app: TermixApp): void {
app.onDispose(installTerminalGlobalStyles());
app.registerTab(
"terminal",
TerminalTabWithRegistry as unknown as ComponentType<TabProps>,
@@ -147,6 +175,22 @@ export function activate(app: TermixApp): void {
separatorAfter: true,
});
app.registerPanel(
"ssh-tools",
SshToolsPanel as unknown as ComponentType<PanelProps>,
);
app.registerRailItem({
id: "ssh-tools",
icon: Hammer,
titleKey: "nav.sshTools",
after: "quick-connect",
hideable: true,
mobilePrimary: 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"] });
@@ -199,8 +243,38 @@ export function activate(app: TermixApp): void {
},
})) as never);
// The terminal look for other terminal-like surfaces (docker, serial,
// proxmox), which must not import this plugin. Each answers undefined
// while this plugin is off, and the caller falls back to plain colors.
app.registerComponent(
"terminal.preview",
TerminalPreview as unknown as ComponentType<Record<string, unknown>>,
);
app.registerAction("terminal.resolveTheme", (async (
request: ResolveLookRequest & { hostId?: number | string } = {},
) => {
const settings = await loadTerminalClientSettings(app.api).catch(
() => null,
);
let host: PluginHostRecord | null | undefined = request.host;
if (!host && request.hostId !== undefined) {
host = (await listHosts().catch(() => [])).find(
(entry) => String(entry.id) === String(request.hostId),
);
}
return resolveTerminalLook({ ...request, host }, settings?.user);
}) as never);
app.registerAction("terminal.themes", () => listTerminalThemes());
app.registerSettingsComponent("touchInput", TouchInputSettings);
app.registerSettingsComponent("imageStorageTest", ImageStorageTest);
app.registerSettingsComponent("terminalDefaults", TerminalDefaultsSettings);
app.registerSettingsComponent("newHostTheme", NewHostThemeSetting);
app.registerSettingsComponent("newHostFontFamily", NewHostFontSetting);
app.onSettingsChanged(() => invalidateTerminalClientSettings());
if (!app.guest) void moveLocalTerminalPreferences(app);
app.onDispose(resetTouchInputSettingsCache);
app.onDispose(resetTerminalClientSettings);
}
@@ -10,14 +10,16 @@ import {
} from "../terminal/terminal-clipboard";
import {
useAppTheme as useTheme,
resolveTermixThemeColors,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
ensureTerminalFontsLoaded,
RobustClipboardProvider,
copyToClipboard,
readFromClipboard,
} from "@termix/plugin-sdk/ui";
import { resolveTermixThemeColors } from "../look/terminal-theme";
import {
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
} from "../look/terminal-themes";
import { ensureTerminalFontsLoaded } from "../look/terminal-global-styles";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export function LocalTerminal({
@@ -0,0 +1,129 @@
import { useAppTheme } from "@termix/plugin-sdk/ui";
import { TERMINAL_THEMES, resolveTerminalFontFamily } from "./terminal-themes";
import type { TerminalThemeColors } from "../../shared/terminal-settings";
/**
* A few lines of sample output in a terminal look. Also registered as the
* "terminal.preview" component for other plugins' pickers.
*/
export interface TerminalPreviewProps {
theme: string;
fontSize?: number;
fontFamily?: string;
cursorStyle?: "block" | "underline" | "bar";
cursorBlink?: boolean;
letterSpacing?: number;
lineHeight?: number;
customThemeColors?: TerminalThemeColors | null;
}
export function TerminalPreview({
theme = "termix",
fontSize = 14,
fontFamily = "Caskaydia Cove Nerd Font Mono",
cursorStyle = "bar",
cursorBlink = true,
letterSpacing = 0,
lineHeight = 1.0,
customThemeColors,
}: TerminalPreviewProps) {
const { theme: appTheme } = useAppTheme();
const resolvedTheme =
theme === "termix"
? appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
? "termixDark"
: "termixLight"
: theme;
const colors =
theme === "custom" && customThemeColors
? customThemeColors
: TERMINAL_THEMES[resolvedTheme]?.colors;
const fontFallback = resolveTerminalFontFamily(fontFamily);
return (
<div className="border border-input overflow-hidden">
<div
className="p-3"
style={{
fontSize: `${fontSize}px`,
fontFamily: fontFallback,
letterSpacing: `${letterSpacing}px`,
lineHeight,
background: colors?.background || "var(--bg-base)",
color: colors?.foreground || "var(--foreground)",
}}
>
<div>
<span style={{ color: colors?.green }}>deploy@web-01</span>
<span style={{ color: colors?.brightBlack }}>:</span>
<span style={{ color: colors?.blue }}>~</span>
<span style={{ color: colors?.brightBlack }}>$</span>
<span> ls -la</span>
</div>
<div style={{ color: colors?.brightBlack }}>total 48</div>
<div>
<span style={{ color: colors?.cyan }}>drwxr-xr-x</span>
<span style={{ color: colors?.brightBlack }}>
{" "}
5 deploy deploy 4096 May 1 09:12{" "}
</span>
<span style={{ color: colors?.blue }}>.</span>
</div>
<div>
<span style={{ color: colors?.cyan }}>drwxr-xr-x</span>
<span style={{ color: colors?.brightBlack }}>
{" "}
3 root root 4096 Apr 15 18:44{" "}
</span>
<span style={{ color: colors?.blue }}>..</span>
</div>
<div>
<span style={{ color: colors?.cyan }}>-rw-r--r--</span>
<span style={{ color: colors?.brightBlack }}>
{" "}
1 deploy deploy 220 Apr 15 18:44{" "}
</span>
<span>.bash_logout</span>
</div>
<div>
<span style={{ color: colors?.cyan }}>-rwxr-xr-x</span>
<span style={{ color: colors?.brightBlack }}>
{" "}
1 deploy deploy 8192 May 1 08:55{" "}
</span>
<span style={{ color: colors?.green }}>deploy.sh</span>
</div>
<div className="flex items-center gap-0.5 mt-0.5">
<span style={{ color: colors?.green }}>deploy@web-01</span>
<span style={{ color: colors?.brightBlack }}>:</span>
<span style={{ color: colors?.blue }}>~</span>
<span style={{ color: colors?.brightBlack }}>$</span>
<span> </span>
<span
className="inline-block"
style={{
width: cursorStyle === "block" ? "0.6em" : "0.12em",
height: cursorStyle === "underline" ? "0.12em" : `${fontSize}px`,
background: colors?.cursor || colors?.foreground || "#f7f7f7",
animation: cursorBlink
? "termPreviewBlink 1s step-end infinite"
: "none",
verticalAlign:
cursorStyle === "underline" ? "bottom" : "text-bottom",
}}
/>
</div>
</div>
<style>{`
@keyframes termPreviewBlink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,85 @@
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import {
TERMINAL_THEMES,
resolveTerminalFontFamily,
type TerminalTheme,
} from "./terminal-themes";
import { resolveTermixThemeColors } from "./terminal-theme";
import { ensureTerminalFontsLoaded } from "./terminal-global-styles";
import { resolveForHost } from "../terminal-settings";
import type {
CursorStyle,
TerminalThemeColors,
TerminalUserSettings,
} from "../../shared/terminal-settings";
/**
* What `terminal.resolveTheme` answers: an xterm theme and the font options
* a terminal-like surface (a docker console, a serial console) should use to
* look like the SSH terminal on the same host.
*/
export interface ResolvedTerminalLook {
themeId: string;
colors: TerminalThemeColors;
/** A CSS font stack, ready for xterm's fontFamily option. */
fontFamily: string;
fontSize: number;
cursorStyle: CursorStyle;
cursorBlink: boolean;
letterSpacing: number;
lineHeight: number;
scrollback: number;
minimumContrastRatio: number;
backgroundImage: string;
backgroundImageOpacity: number;
}
export interface ResolveLookRequest {
/** A host record from the shell; its terminal settings decide the look. */
host?: PluginHostRecord | null;
/** Force a theme id instead of the host's. */
theme?: string;
/** The app theme ("dark", "light", "system" or a named theme). */
appTheme?: string;
}
export function resolveTerminalLook(
request: ResolveLookRequest,
user: TerminalUserSettings | undefined,
): ResolvedTerminalLook {
const config = resolveForHost(request.host ?? null, user);
const themeId = request.theme || config.theme;
ensureTerminalFontsLoaded(config.fontFamily);
return {
themeId,
colors: resolveTermixThemeColors(
themeId,
request.appTheme ?? "dark",
config.customThemeColors,
),
fontFamily: resolveTerminalFontFamily(config.fontFamily),
fontSize: config.fontSize,
cursorStyle: config.cursorStyle,
cursorBlink: config.cursorBlink,
letterSpacing: config.letterSpacing,
lineHeight: config.lineHeight,
scrollback: config.scrollback,
minimumContrastRatio: config.minimumContrastRatio,
backgroundImage: config.backgroundImage,
backgroundImageOpacity: config.backgroundImageOpacity,
};
}
/** What `terminal.themes` answers: every built-in theme, for a picker. */
export function listTerminalThemes(): Array<
{ id: string } & Pick<TerminalTheme, "name" | "category" | "colors">
> {
return Object.entries(TERMINAL_THEMES)
.filter(([id]) => id !== "termixDark" && id !== "termixLight")
.map(([id, theme]) => ({
id,
name: theme.name,
category: theme.category,
colors: theme.colors,
}));
}
@@ -0,0 +1,37 @@
export const TERMINAL_FONT_ZOOM_MIN = 8;
export const TERMINAL_FONT_ZOOM_MAX = 36;
type ZoomKeyEvent = Pick<
KeyboardEvent,
"altKey" | "code" | "ctrlKey" | "key" | "metaKey"
>;
export function getTerminalFontZoomDirection(event: ZoomKeyEvent): -1 | 0 | 1 {
if ((!event.ctrlKey && !event.metaKey) || event.altKey) return 0;
if (
event.key === "+" ||
event.code === "Equal" ||
event.code === "NumpadAdd"
) {
return 1;
}
if (
event.key === "-" ||
event.code === "Minus" ||
event.code === "NumpadSubtract"
) {
return -1;
}
return 0;
}
export function getNextTerminalFontSize(
currentFontSize: number,
direction: -1 | 1,
): number {
return Math.min(
TERMINAL_FONT_ZOOM_MAX,
Math.max(TERMINAL_FONT_ZOOM_MIN, currentFontSize + direction),
);
}
@@ -0,0 +1,107 @@
const TERMINAL_STYLES = `
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
font-weight: bold;
font-style: italic;
font-display: swap;
}
.xterm .xterm-viewport::-webkit-scrollbar {
width: 8px;
background: transparent;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.3);
border-radius: 4px;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.5);
}
.xterm .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.3) transparent;
background-color: transparent !important;
}
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3);
}
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.5);
}
.dark .xterm .xterm-viewport {
scrollbar-color: rgba(255,255,255,0.3) transparent;
}
.xterm {
font-feature-settings: "liga" 0, "calt" 0;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.xterm .xterm-screen {
font-variant-ligatures: none;
}
.xterm .xterm-screen .xterm-char {
font-feature-settings: "liga" 0, "calt" 0;
}
`;
/**
* The terminal font faces and xterm scrollbar rules, added to the page while
* this plugin is active. Returns the disposer.
*/
export function installTerminalGlobalStyles(): () => void {
if (typeof document === "undefined") return () => {};
const style = document.createElement("style");
style.dataset.termixTerminalStyles = "";
style.innerHTML = TERMINAL_STYLES;
document.head.appendChild(style);
return () => style.remove();
}
// Canvas fillText() does not reliably trigger @font-face fetches on every
// browser engine (notably Android WebView) the way rendering real DOM text
// does. xterm.js draws glyphs to a <canvas>, so without an explicit load the
// terminal can keep painting the fallback font's tofu boxes even after
// document.fonts.ready resolves. Forcing the load here ensures the glyph
// data is actually fetched before the terminal renders with it.
export function ensureTerminalFontsLoaded(fontFamily: string): void {
if (typeof document === "undefined" || !document.fonts) return;
const specs = [
`400 16px "${fontFamily}"`,
`700 16px "${fontFamily}"`,
`italic 400 16px "${fontFamily}"`,
`italic 700 16px "${fontFamily}"`,
];
for (const spec of specs) {
document.fonts.load(spec).catch(() => {});
}
}
@@ -0,0 +1,52 @@
import { TERMINAL_THEMES, type TerminalTheme } from "./terminal-themes";
import type { TerminalThemeColors } from "../../shared/terminal-settings";
// Background/foreground per UI theme for "Termix Default" - must match index.css
const TERMIX_DEFAULT_COLORS: Record<
string,
{ background: string; foreground: string }
> = {
dark: { background: "#0c0d0b", foreground: "#fafafa" },
light: { background: "#ffffff", foreground: "#111210" },
dracula: { background: "#282a36", foreground: "#f8f8f2" },
catppuccin: { background: "#1e1e2e", foreground: "#cdd6f4" },
nord: { background: "#2e3440", foreground: "#eceff4" },
solarized: { background: "#002b36", foreground: "#839496" },
"tokyo-night": { background: "#1a1b26", foreground: "#a9b1d6" },
"one-dark": { background: "#282c34", foreground: "#abb2bf" },
gruvbox: { background: "#282828", foreground: "#ebdbb2" },
};
export function resolveTermixThemeColors(
activeTheme: string,
appTheme: string,
customThemeColors?: TerminalThemeColors | null,
): TerminalTheme["colors"] {
if (activeTheme === "custom") {
if (customThemeColors) {
return customThemeColors;
}
return TERMINAL_THEMES.termixDark.colors;
}
if (activeTheme !== "termix") {
return (
TERMINAL_THEMES[activeTheme]?.colors || TERMINAL_THEMES.termixDark.colors
);
}
let resolvedUiTheme = appTheme;
if (appTheme === "system") {
resolvedUiTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
const uiColors =
TERMIX_DEFAULT_COLORS[resolvedUiTheme] ?? TERMIX_DEFAULT_COLORS.dark;
const base = TERMINAL_THEMES.termixDark.colors;
return {
...base,
background: uiColors.background,
foreground: uiColors.foreground,
cursor: uiColors.foreground,
cursorAccent: uiColors.background,
};
}
@@ -0,0 +1,831 @@
import {
DEFAULT_APPEARANCE,
DEFAULT_BEHAVIOR,
DEFAULT_MOSH_COMMAND,
type TerminalThemeColors,
} from "../../shared/terminal-settings";
export interface TerminalTheme {
name: string;
category: "dark" | "light" | "colorful";
colors: TerminalThemeColors;
}
export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
termix: {
name: "Termix Default",
category: "dark",
colors: {
background: "#0c0d0b",
foreground: "#f7f7f7",
cursor: "#f7f7f7",
cursorAccent: "#0c0d0b",
selectionBackground: "#3a3a3d",
black: "#2e3436",
red: "#cc0000",
green: "#4e9a06",
yellow: "#c4a000",
blue: "#3465a4",
magenta: "#75507b",
cyan: "#06989a",
white: "#d3d7cf",
brightBlack: "#555753",
brightRed: "#ef2929",
brightGreen: "#8ae234",
brightYellow: "#fce94f",
brightBlue: "#729fcf",
brightMagenta: "#ad7fa8",
brightCyan: "#34e2e2",
brightWhite: "#eeeeec",
},
},
termixDark: {
name: "Termix Dark",
category: "dark",
colors: {
background: "#0c0d0b",
foreground: "#f7f7f7",
cursor: "#f7f7f7",
cursorAccent: "#0c0d0b",
selectionBackground: "#3a3a3d",
black: "#2e3436",
red: "#cc0000",
green: "#4e9a06",
yellow: "#c4a000",
blue: "#3465a4",
magenta: "#75507b",
cyan: "#06989a",
white: "#d3d7cf",
brightBlack: "#555753",
brightRed: "#ef2929",
brightGreen: "#8ae234",
brightYellow: "#fce94f",
brightBlue: "#729fcf",
brightMagenta: "#ad7fa8",
brightCyan: "#34e2e2",
brightWhite: "#eeeeec",
},
},
termixLight: {
name: "Termix Light",
category: "light",
colors: {
background: "#ffffff",
foreground: "#18181b",
cursor: "#18181b",
cursorAccent: "#ffffff",
selectionBackground: "#d1d5db",
black: "#18181b",
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
blue: "#2563eb",
magenta: "#9333ea",
cyan: "#0891b2",
white: "#f4f4f5",
brightBlack: "#71717a",
brightRed: "#ef4444",
brightGreen: "#22c55e",
brightYellow: "#eab308",
brightBlue: "#3b82f6",
brightMagenta: "#a855f7",
brightCyan: "#06b6d4",
brightWhite: "#ffffff",
},
},
dracula: {
name: "Dracula",
category: "dark",
colors: {
background: "#282a36",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#282a36",
selectionBackground: "#44475a",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
brightBlack: "#6272a4",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#d6acff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
},
},
monokai: {
name: "Monokai",
category: "dark",
colors: {
background: "#272822",
foreground: "#f8f8f2",
cursor: "#f8f8f0",
cursorAccent: "#272822",
selectionBackground: "#49483e",
black: "#272822",
red: "#f92672",
green: "#a6e22e",
yellow: "#f4bf75",
blue: "#66d9ef",
magenta: "#ae81ff",
cyan: "#a1efe4",
white: "#f8f8f2",
brightBlack: "#75715e",
brightRed: "#f92672",
brightGreen: "#a6e22e",
brightYellow: "#f4bf75",
brightBlue: "#66d9ef",
brightMagenta: "#ae81ff",
brightCyan: "#a1efe4",
brightWhite: "#f9f8f5",
},
},
nord: {
name: "Nord",
category: "dark",
colors: {
background: "#2e3440",
foreground: "#d8dee9",
cursor: "#d8dee9",
cursorAccent: "#2e3440",
selectionBackground: "#434c5e",
black: "#3b4252",
red: "#bf616a",
green: "#a3be8c",
yellow: "#ebcb8b",
blue: "#81a1c1",
magenta: "#b48ead",
cyan: "#88c0d0",
white: "#e5e9f0",
brightBlack: "#4c566a",
brightRed: "#bf616a",
brightGreen: "#a3be8c",
brightYellow: "#ebcb8b",
brightBlue: "#81a1c1",
brightMagenta: "#b48ead",
brightCyan: "#8fbcbb",
brightWhite: "#eceff4",
},
},
gruvboxDark: {
name: "Gruvbox Dark",
category: "dark",
colors: {
background: "#282828",
foreground: "#ebdbb2",
cursor: "#ebdbb2",
cursorAccent: "#282828",
selectionBackground: "#504945",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
brightBlack: "#928374",
brightRed: "#fb4934",
brightGreen: "#b8bb26",
brightYellow: "#fabd2f",
brightBlue: "#83a598",
brightMagenta: "#d3869b",
brightCyan: "#8ec07c",
brightWhite: "#ebdbb2",
},
},
gruvboxLight: {
name: "Gruvbox Light",
category: "light",
colors: {
background: "#fbf1c7",
foreground: "#3c3836",
cursor: "#3c3836",
cursorAccent: "#fbf1c7",
selectionBackground: "#d5c4a1",
black: "#fbf1c7",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#7c6f64",
brightBlack: "#928374",
brightRed: "#9d0006",
brightGreen: "#79740e",
brightYellow: "#b57614",
brightBlue: "#076678",
brightMagenta: "#8f3f71",
brightCyan: "#427b58",
brightWhite: "#3c3836",
},
},
solarizedDark: {
name: "Solarized Dark",
category: "dark",
colors: {
background: "#002b36",
foreground: "#839496",
cursor: "#839496",
cursorAccent: "#002b36",
selectionBackground: "#073642",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
solarizedLight: {
name: "Solarized Light",
category: "light",
colors: {
background: "#fdf6e3",
foreground: "#657b83",
cursor: "#657b83",
cursorAccent: "#fdf6e3",
selectionBackground: "#eee8d5",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
oneDark: {
name: "One Dark",
category: "dark",
colors: {
background: "#282c34",
foreground: "#abb2bf",
cursor: "#528bff",
cursorAccent: "#282c34",
selectionBackground: "#3e4451",
black: "#282c34",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
},
},
tokyoNight: {
name: "Tokyo Night",
category: "dark",
colors: {
background: "#1a1b26",
foreground: "#a9b1d6",
cursor: "#a9b1d6",
cursorAccent: "#1a1b26",
selectionBackground: "#283457",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
},
},
ayuDark: {
name: "Ayu Dark",
category: "dark",
colors: {
background: "#0a0e14",
foreground: "#b3b1ad",
cursor: "#e6b450",
cursorAccent: "#0a0e14",
selectionBackground: "#253340",
black: "#01060e",
red: "#ea6c73",
green: "#91b362",
yellow: "#f9af4f",
blue: "#53bdfa",
magenta: "#fae994",
cyan: "#90e1c6",
white: "#c7c7c7",
brightBlack: "#686868",
brightRed: "#f07178",
brightGreen: "#c2d94c",
brightYellow: "#ffb454",
brightBlue: "#59c2ff",
brightMagenta: "#ffee99",
brightCyan: "#95e6cb",
brightWhite: "#ffffff",
},
},
ayuLight: {
name: "Ayu Light",
category: "light",
colors: {
background: "#fafafa",
foreground: "#5c6166",
cursor: "#ff9940",
cursorAccent: "#fafafa",
selectionBackground: "#d1e4f4",
black: "#000000",
red: "#f51818",
green: "#86b300",
yellow: "#f2ae49",
blue: "#399ee6",
magenta: "#a37acc",
cyan: "#4cbf99",
white: "#c7c7c7",
brightBlack: "#686868",
brightRed: "#ff3333",
brightGreen: "#b8e532",
brightYellow: "#ffc849",
brightBlue: "#59c2ff",
brightMagenta: "#bf7ce0",
brightCyan: "#5cf7a0",
brightWhite: "#ffffff",
},
},
materialTheme: {
name: "Material Theme",
category: "dark",
colors: {
background: "#263238",
foreground: "#eeffff",
cursor: "#ffcc00",
cursorAccent: "#263238",
selectionBackground: "#546e7a",
black: "#000000",
red: "#e53935",
green: "#91b859",
yellow: "#ffb62c",
blue: "#6182b8",
magenta: "#7c4dff",
cyan: "#39adb5",
white: "#ffffff",
brightBlack: "#546e7a",
brightRed: "#ff5370",
brightGreen: "#c3e88d",
brightYellow: "#ffcb6b",
brightBlue: "#82aaff",
brightMagenta: "#c792ea",
brightCyan: "#89ddff",
brightWhite: "#ffffff",
},
},
palenight: {
name: "Palenight",
category: "dark",
colors: {
background: "#292d3e",
foreground: "#a6accd",
cursor: "#ffcc00",
cursorAccent: "#292d3e",
selectionBackground: "#676e95",
black: "#292d3e",
red: "#f07178",
green: "#c3e88d",
yellow: "#ffcb6b",
blue: "#82aaff",
magenta: "#c792ea",
cyan: "#89ddff",
white: "#d0d0d0",
brightBlack: "#434758",
brightRed: "#ff8b92",
brightGreen: "#ddffa7",
brightYellow: "#ffe585",
brightBlue: "#9cc4ff",
brightMagenta: "#e1acff",
brightCyan: "#a3f7ff",
brightWhite: "#ffffff",
},
},
oceanicNext: {
name: "Oceanic Next",
category: "dark",
colors: {
background: "#1b2b34",
foreground: "#cdd3de",
cursor: "#c0c5ce",
cursorAccent: "#1b2b34",
selectionBackground: "#343d46",
black: "#343d46",
red: "#ec5f67",
green: "#99c794",
yellow: "#fac863",
blue: "#6699cc",
magenta: "#c594c5",
cyan: "#5fb3b3",
white: "#cdd3de",
brightBlack: "#65737e",
brightRed: "#ec5f67",
brightGreen: "#99c794",
brightYellow: "#fac863",
brightBlue: "#6699cc",
brightMagenta: "#c594c5",
brightCyan: "#5fb3b3",
brightWhite: "#d8dee9",
},
},
nightOwl: {
name: "Night Owl",
category: "dark",
colors: {
background: "#011627",
foreground: "#d6deeb",
cursor: "#80a4c2",
cursorAccent: "#011627",
selectionBackground: "#1d3b53",
black: "#011627",
red: "#ef5350",
green: "#22da6e",
yellow: "#c5e478",
blue: "#82aaff",
magenta: "#c792ea",
cyan: "#21c7a8",
white: "#ffffff",
brightBlack: "#575656",
brightRed: "#ef5350",
brightGreen: "#22da6e",
brightYellow: "#ffeb95",
brightBlue: "#82aaff",
brightMagenta: "#c792ea",
brightCyan: "#7fdbca",
brightWhite: "#ffffff",
},
},
synthwave84: {
name: "Synthwave '84",
category: "colorful",
colors: {
background: "#241b2f",
foreground: "#f92aad",
cursor: "#f92aad",
cursorAccent: "#241b2f",
selectionBackground: "#495495",
black: "#000000",
red: "#f6188f",
green: "#1eff8e",
yellow: "#ffe261",
blue: "#03edf9",
magenta: "#f10596",
cyan: "#03edf9",
white: "#ffffff",
brightBlack: "#5a5a5a",
brightRed: "#ff1a8e",
brightGreen: "#1eff8e",
brightYellow: "#ffff00",
brightBlue: "#00d8ff",
brightMagenta: "#ff00d4",
brightCyan: "#00ffff",
brightWhite: "#ffffff",
},
},
cobalt2: {
name: "Cobalt2",
category: "dark",
colors: {
background: "#193549",
foreground: "#ffffff",
cursor: "#f0cc09",
cursorAccent: "#193549",
selectionBackground: "#0050a4",
black: "#000000",
red: "#ff0000",
green: "#38de21",
yellow: "#ffe50a",
blue: "#1460d2",
magenta: "#ff005d",
cyan: "#00bbbb",
white: "#bbbbbb",
brightBlack: "#555555",
brightRed: "#f40e17",
brightGreen: "#3bd01d",
brightYellow: "#edc809",
brightBlue: "#5555ff",
brightMagenta: "#ff55ff",
brightCyan: "#6ae3fa",
brightWhite: "#ffffff",
},
},
snazzy: {
name: "Snazzy",
category: "dark",
colors: {
background: "#282a36",
foreground: "#eff0eb",
cursor: "#97979b",
cursorAccent: "#282a36",
selectionBackground: "#97979b",
black: "#282a36",
red: "#ff5c57",
green: "#5af78e",
yellow: "#f3f99d",
blue: "#57c7ff",
magenta: "#ff6ac1",
cyan: "#9aedfe",
white: "#f1f1f0",
brightBlack: "#686868",
brightRed: "#ff5c57",
brightGreen: "#5af78e",
brightYellow: "#f3f99d",
brightBlue: "#57c7ff",
brightMagenta: "#ff6ac1",
brightCyan: "#9aedfe",
brightWhite: "#eff0eb",
},
},
atomOneDark: {
name: "Atom One Dark",
category: "dark",
colors: {
background: "#1e2127",
foreground: "#abb2bf",
cursor: "#528bff",
cursorAccent: "#1e2127",
selectionBackground: "#3e4451",
black: "#000000",
red: "#e06c75",
green: "#98c379",
yellow: "#d19a66",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#d19a66",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
},
},
catppuccinMocha: {
name: "Catppuccin Mocha",
category: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "#585b70",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
},
},
gentlemansChoice: {
name: "Gentleman's Choice",
category: "dark",
colors: {
background: "#1a1c1a",
foreground: "#d1c7a3",
cursor: "#d1c7a3",
cursorAccent: "#1a1c1a",
selectionBackground: "#3e4437",
black: "#1a1c1a",
red: "#9d3a3a",
green: "#5a7a3a",
yellow: "#b39a3a",
blue: "#3a5a7a",
magenta: "#7a3a5a",
cyan: "#3a7a7a",
white: "#d1c7a3",
brightBlack: "#3e4437",
brightRed: "#bf4a4a",
brightGreen: "#7a9a4a",
brightYellow: "#d1b34a",
brightBlue: "#4a7abf",
brightMagenta: "#9a4abf",
brightCyan: "#4abfbf",
brightWhite: "#e3dbc3",
},
},
midnightEspresso: {
name: "Midnight Espresso",
category: "dark",
colors: {
background: "#120f0d",
foreground: "#ceb195",
cursor: "#ceb195",
cursorAccent: "#120f0d",
selectionBackground: "#3d2b1f",
black: "#120f0d",
red: "#a05a4a",
green: "#7a8a5a",
yellow: "#b08a4a",
blue: "#5a7a9a",
magenta: "#8a5a7a",
cyan: "#5a8a8a",
white: "#ceb195",
brightBlack: "#3d2b1f",
brightRed: "#c07a6a",
brightGreen: "#9aaa7a",
brightYellow: "#d0aa6a",
brightBlue: "#7a9aba",
brightMagenta: "#aa7aba",
brightCyan: "#7ababa",
brightWhite: "#e0cbb5",
},
},
custom: {
name: "Custom",
category: "dark",
colors: {
background: "#0c0d0b",
foreground: "#f7f7f7",
cursor: "#f7f7f7",
cursorAccent: "#0c0d0b",
selectionBackground: "#3a3a3d",
black: "#2e3436",
red: "#cc0000",
green: "#4e9a06",
yellow: "#c4a000",
blue: "#3465a4",
magenta: "#75507b",
cyan: "#06989a",
white: "#d3d7cf",
brightBlack: "#555753",
brightRed: "#ef2929",
brightGreen: "#8ae234",
brightYellow: "#fce94f",
brightBlue: "#729fcf",
brightMagenta: "#ad7fa8",
brightCyan: "#34e2e2",
brightWhite: "#eeeeec",
},
},
};
export const TERMINAL_FONTS = [
{
value: "Caskaydia Cove Nerd Font Mono",
label: "Caskaydia Cove Nerd Font Mono",
fallback:
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "JetBrains Mono",
label: "JetBrains Mono",
fallback:
'"JetBrains Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "Fira Code",
label: "Fira Code",
fallback: '"Fira Code", "SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "Cascadia Code",
label: "Cascadia Code",
fallback:
'"Cascadia Code", "SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "Source Code Pro",
label: "Source Code Pro",
fallback:
'"Source Code Pro", "SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "SF Mono",
label: "SF Mono",
fallback: '"SF Mono", Consolas, "Liberation Mono", monospace',
},
{
value: "Consolas",
label: "Consolas",
fallback: 'Consolas, "Liberation Mono", monospace',
},
{
value: "Monaco",
label: "Monaco",
fallback: 'Monaco, "Liberation Mono", monospace',
},
];
// Unknown values are treated as a custom font installed on the user's system.
export function resolveTerminalFontFamily(fontFamily?: string): string {
const known = TERMINAL_FONTS.find((f) => f.value === fontFamily);
if (known) return known.fallback;
if (fontFamily && fontFamily.trim()) {
return `"${fontFamily.trim()}", "SF Mono", Consolas, "Liberation Mono", monospace`;
}
return TERMINAL_FONTS[0].fallback;
}
export const CURSOR_STYLES = [
{ value: "block", label: "Block", labelKey: "hosts.cursorStyleBlock" },
{
value: "underline",
label: "Underline",
labelKey: "hosts.cursorStyleUnderline",
},
{ value: "bar", label: "Bar", labelKey: "hosts.cursorStyleBar" },
] as const;
export const BELL_STYLES = [
{ value: "none", label: "None", labelKey: "hosts.bellStyleNone" },
{ value: "sound", label: "Sound", labelKey: "hosts.bellStyleSound" },
{ value: "visual", label: "Visual", labelKey: "hosts.bellStyleVisual" },
{ value: "both", label: "Both", labelKey: "hosts.bellStyleBoth" },
] as const;
export const FAST_SCROLL_MODIFIERS = [
{ value: "alt", label: "Alt", labelKey: "hosts.fastScrollModifierAlt" },
{ value: "ctrl", label: "Ctrl", labelKey: "hosts.fastScrollModifierCtrl" },
{ value: "shift", label: "Shift", labelKey: "hosts.fastScrollModifierShift" },
] as const;
/** A terminal's full configuration before anything is overridden. */
export const DEFAULT_TERMINAL_CONFIG = {
...DEFAULT_APPEARANCE,
...DEFAULT_BEHAVIOR,
moshCommand: DEFAULT_MOSH_COMMAND,
};
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
@@ -4,6 +4,8 @@ export interface TerminalSessionInfo {
id: string;
hostId: number | null;
hostName?: string;
/** The tab's title. */
label?: string;
ip?: string;
username?: string;
port?: number;
@@ -15,6 +17,18 @@ interface Session extends TerminalSessionInfo {
const sessions = new Map<string, Session>();
let activeId: string | null = null;
const listeners = new Set<() => void>();
let snapshot: TerminalSessionInfo[] = [];
function notify(): void {
snapshot = listSessions();
for (const listener of [...listeners]) listener();
}
/** The open sessions, the same array until one opens or closes. */
export function sessionsSnapshot(): TerminalSessionInfo[] {
return snapshot;
}
/** Called by the terminal tab wrapper on mount/unmount and focus change. */
export function registerSession(
@@ -22,12 +36,25 @@ export function registerSession(
ref: TerminalHandle,
): () => void {
sessions.set(info.id, { ...info, ref });
notify();
return () => {
sessions.delete(info.id);
if (activeId === info.id) activeId = null;
notify();
};
}
/** Called whenever a session opens or closes. Returns the unsubscribe. */
export function subscribeSessions(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** The live terminal of a session, for tools that type into several at once. */
export function getSessionHandle(sessionId: string): TerminalHandle | null {
return sessions.get(sessionId)?.ref ?? null;
}
export function setActiveSession(id: string | null): void {
activeId = id;
}
@@ -1,11 +1,13 @@
import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend";
import { HostFeatureFields, HostTerminalSettings } from "@termix/plugin-sdk/ui";
import { HostFeatureFields } from "@termix/plugin-sdk/ui";
import { HostTerminalSettings } from "./HostTerminalSettings";
/** The host editor's Terminal tab: this plugin's switches, then the look. */
export function HostTerminalSection({
form,
setField,
updateForm,
host,
snippets,
}: HostEditorSectionProps) {
return (
@@ -14,6 +16,8 @@ export function HostTerminalSection({
<HostTerminalSettings
form={form}
setField={setField}
updateForm={updateForm}
host={host}
snippets={snippets}
/>
</>
@@ -0,0 +1,914 @@
import { useMemo, useState } from "react";
import { Info, Palette, X, Zap } from "lucide-react";
import { toast } from "sonner";
import {
Button,
FakeSwitch,
Input,
SectionCard,
Select2,
SettingRow,
Slider,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
useTabsSafe,
} from "@termix/plugin-sdk/ui";
import {
useSettings,
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import { TerminalPreview } from "../look/TerminalPreview";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
BELL_STYLES,
FAST_SCROLL_MODIFIERS,
CURSOR_STYLES,
} from "../look/terminal-themes";
import {
TERMINAL_FONT_ZOOM_MIN,
TERMINAL_FONT_ZOOM_MAX,
} from "../look/terminal-font-zoom";
import {
APPEARANCE_KEYS,
INHERIT_APPEARANCE_KEY,
pickTerminalValues,
readHostTerminalSettings,
readUserSettings,
type BackspaceMode,
type BellStyle,
type CursorStyle,
type FastScrollModifier,
type HostTerminalSettings as HostTerminalValues,
type SavedCustomTheme,
} from "../../shared/terminal-settings";
import {
invalidateTerminalClientSettings,
useTerminalClientSettings,
} from "../terminal-settings";
const PLUGIN_ID = "ssh-terminal";
const CUSTOM_FONT_OPTION = "__custom__";
const APPEARANCE = new Set<string>(APPEARANCE_KEYS);
type HostPluginSettings = Record<string, Record<string, unknown>>;
/**
* The host editor's terminal appearance and behavior cards. The values are
* this plugin's host settings, kept on the editor form under pluginSettings
* and saved with the host; the startup snippet is still a core host field.
*/
export function HostTerminalSettings({
form: editorForm,
setField: setCoreField,
updateForm,
host,
snippets: rawSnippets,
}: Pick<
HostEditorSectionProps,
"form" | "setField" | "updateForm" | "host" | "snippets"
>) {
const { t } = useTranslation();
const client = useTerminalClientSettings();
const userSettings = useSettings("user");
const snippets = (rawSnippets ?? []) as { id: number; name: string }[];
const { setPreviewTerminalTheme } = useTabsSafe();
const stored = ((editorForm?.pluginSettings as HostPluginSettings)?.[
PLUGIN_ID
] ?? {}) as Record<string, unknown>;
// A new host starts from the admin's new-host defaults.
const seed = host ? {} : (client?.newHostDefaults ?? {});
const own = readHostTerminalSettings({ ...seed, ...stored });
const userDefaults = pickTerminalValues(
client?.user.terminalDefaults ?? {},
APPEARANCE_KEYS,
);
// While the host follows the user, show the look it will actually get.
const form = {
...own,
...(own.inheritAppearance ? userDefaults : {}),
startupSnippetId: (editorForm?.startupSnippetId ?? null) as number | null,
} as HostTerminalValues & { startupSnippetId: number | null };
const writeValues = (values: Record<string, unknown>) =>
updateForm((current) => {
const all = (current.pluginSettings ?? {}) as HostPluginSettings;
return {
...current,
pluginSettings: {
...all,
[PLUGIN_ID]: { ...(all[PLUGIN_ID] ?? {}), ...values },
},
};
});
const setField = <K extends keyof HostTerminalValues>(
key: K,
value: HostTerminalValues[K],
) =>
writeValues({
[key]: value,
// Touching the look takes the host off the user's defaults.
...(APPEARANCE.has(key) ? { [INHERIT_APPEARANCE_KEY]: false } : {}),
});
const setInheritAppearance = (inherit: boolean) => {
if (inherit) {
writeValues({ [INHERIT_APPEARANCE_KEY]: true });
return;
}
// Keep what was shown, so switching off does not change the look.
const shown: Record<string, unknown> = {};
for (const key of APPEARANCE_KEYS) shown[key] = form[key];
writeValues({ ...shown, [INHERIT_APPEARANCE_KEY]: false });
};
const [isCustomFont, setIsCustomFont] = useState(
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
);
const savedThemes: SavedCustomTheme[] = useMemo(
() => readUserSettings(userSettings.values).customThemes,
[userSettings.values],
);
const [savingTheme, setSavingTheme] = useState(false);
const saveThemes = async (updated: SavedCustomTheme[]) => {
await userSettings.save({ customThemes: updated });
invalidateTerminalClientSettings();
};
const handleSaveAsGlobalTheme = async () => {
const colors = form.customThemeColors;
if (!colors) return;
const name = window.prompt(t("hosts.saveGlobalThemeNamePrompt"));
if (!name || !name.trim()) return;
setSavingTheme(true);
try {
await saveThemes([
...savedThemes,
{
id:
"theme-" +
Date.now() +
"-" +
Math.random().toString(36).slice(2, 8),
name: name.trim(),
colors,
},
]);
toast.success(t("hosts.saveGlobalThemeSuccess"));
} catch {
toast.error(t("hosts.saveGlobalThemeError"));
} finally {
setSavingTheme(false);
}
};
const handleDeleteGlobalTheme = async (id: string) => {
try {
await saveThemes(savedThemes.filter((theme) => theme.id !== id));
} catch {
toast.error(t("hosts.saveGlobalThemeError"));
}
};
const handleApplyGlobalTheme = (id: string) => {
const theme = savedThemes.find((entry) => entry.id === id);
if (!theme) return;
setField("customThemeColors", { ...theme.colors });
};
return (
<>
<SectionCard
title={t("hosts.terminalAppearance")}
icon={<Palette className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.useUserDefaults")}
description={t("hosts.useUserTerminalDefaultsDesc")}
>
<FakeSwitch
checked={form.inheritAppearance}
onChange={setInheritAppearance}
/>
</SettingRow>
<fieldset
disabled={form.inheritAppearance}
className={
form.inheritAppearance ? "contents opacity-60" : "contents"
}
>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.themePreview")}
</label>
<TerminalPreview
theme={form.theme}
fontSize={form.fontSize}
fontFamily={form.fontFamily}
cursorStyle={form.cursorStyle}
cursorBlink={form.cursorBlink}
letterSpacing={form.letterSpacing}
lineHeight={form.lineHeight}
customThemeColors={form.customThemeColors ?? undefined}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.colorTheme")}
</label>
<Select2
value={form.theme}
onChange={(e) => {
const newTheme = e.target.value;
setField("theme", newTheme);
setPreviewTerminalTheme(newTheme);
if (newTheme === "custom" && !form.customThemeColors) {
setField("customThemeColors", {
...TERMINAL_THEMES.termixDark.colors,
});
}
}}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{Object.entries(TERMINAL_THEMES)
.filter(
([key]) => key !== "termixDark" && key !== "termixLight",
)
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fontFamilyLabel")}
</label>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="top">
{t("hosts.fontFamilyCustomHint")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Select2
value={isCustomFont ? CUSTOM_FONT_OPTION : form.fontFamily}
onChange={(e) => {
if (e.target.value === CUSTOM_FONT_OPTION) {
setIsCustomFont(true);
setField("fontFamily", "");
} else {
setIsCustomFont(false);
setField("fontFamily", e.target.value);
}
}}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
>
{TERMINAL_FONTS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
<option value={CUSTOM_FONT_OPTION}>
{t("hosts.fontFamilyCustomOption")}
</option>
</Select2>
{isCustomFont && (
<Input
value={form.fontFamily}
onChange={(e) => setField("fontFamily", e.target.value)}
placeholder={t("hosts.fontFamilyCustomPlaceholder")}
className="h-9 text-xs font-mono"
/>
)}
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fontSizeLabel")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.fontSize}px
</span>
</div>
<Slider
min={TERMINAL_FONT_ZOOM_MIN}
max={TERMINAL_FONT_ZOOM_MAX}
step={1}
value={[form.fontSize]}
onValueChange={([v]) => setField("fontSize", v)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.cursorStyleLabel")}
</label>
<Select2
value={form.cursorStyle}
onChange={(e) =>
setField("cursorStyle", e.target.value as CursorStyle)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{CURSOR_STYLES.map((s) => (
<option key={s.value} value={s.value}>
{t(s.labelKey)}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.letterSpacingPx")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.letterSpacing}px
</span>
</div>
<Slider
min={-2}
max={10}
step={0.5}
value={[form.letterSpacing]}
onValueChange={([v]) => setField("letterSpacing", v)}
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.lineHeightLabel")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.lineHeight.toFixed(1)}
</span>
</div>
<Slider
min={1.0}
max={2.0}
step={0.1}
value={[form.lineHeight]}
onValueChange={([v]) => setField("lineHeight", v)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.bellStyleLabel")}
</label>
<Select2
value={form.bellStyle}
onChange={(e) =>
setField("bellStyle", e.target.value as BellStyle)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{BELL_STYLES.map((b) => (
<option key={b.value} value={b.value}>
{t(b.labelKey)}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backspaceModeLabel")}
</label>
<Select2
value={form.backspaceMode}
onChange={(e) =>
setField("backspaceMode", e.target.value as BackspaceMode)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="normal">
{t("hosts.backspaceModeNormal")}
</option>
<option value="control-h">
{t("hosts.backspaceModeControlH")}
</option>
</Select2>
</div>
</div>
</fieldset>
{form.theme === "custom" && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.savedThemesLabel")}
</label>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-[10px]"
disabled={savingTheme || !form.customThemeColors}
onClick={handleSaveAsGlobalTheme}
>
{t("hosts.saveAsGlobalTheme")}
</Button>
</div>
{savedThemes.length === 0 ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.noSavedThemes")}
</p>
) : (
<div className="flex flex-col gap-1">
{savedThemes.map((theme) => (
<div
key={theme.id}
className="flex items-center justify-between gap-2 border border-border px-2 py-1"
>
<button
type="button"
title={t("hosts.applyGlobalThemeTooltip")}
onClick={() => handleApplyGlobalTheme(theme.id)}
className="flex items-center gap-2 text-xs text-left flex-1 min-w-0 hover:text-foreground transition-colors"
>
<span
className="size-3.5 shrink-0 border border-border"
style={{
background: theme.colors.background,
}}
/>
<span className="truncate">{theme.name}</span>
</button>
<button
type="button"
title={t("hosts.deleteGlobalThemeTooltip")}
onClick={() => handleDeleteGlobalTheme(theme.id)}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.customThemeColors")}
</label>
<button
type="button"
title={t("hosts.customThemeResetTooltip")}
onClick={() =>
setField("customThemeColors", {
...TERMINAL_THEMES.termixDark.colors,
})
}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
{t("hosts.customThemeResetTooltip")}
</button>
</div>
{(
[
["background", "customThemeBackground"],
["foreground", "customThemeForeground"],
["cursor", "customThemeCursor"],
["cursorAccent", "customThemeCursorAccent"],
["selectionBackground", "customThemeSelection"],
] as const
).map(([key, labelKey]) => (
<div
key={key}
className="flex items-center justify-between gap-2"
>
<label className="text-xs text-muted-foreground min-w-0 flex-1">
{t(`hosts.${labelKey}`)}
</label>
<div className="flex items-center gap-1.5">
<input
type="color"
value={
form.customThemeColors?.[key] ??
TERMINAL_THEMES.termixDark.colors[key]
}
onChange={(e) =>
setField("customThemeColors", {
...(form.customThemeColors ??
TERMINAL_THEMES.termixDark.colors),
[key]: e.target.value,
})
}
className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5"
/>
<span className="text-[10px] font-mono text-muted-foreground w-16 tabular-nums">
{form.customThemeColors?.[key] ??
TERMINAL_THEMES.termixDark.colors[key]}
</span>
</div>
</div>
))}
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-1">
{t("hosts.customThemeAnsiColors")}
</label>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{(
[
["black", "customThemeBlack"],
["brightBlack", "customThemeBrightBlack"],
["red", "customThemeRed"],
["brightRed", "customThemeBrightRed"],
["green", "customThemeGreen"],
["brightGreen", "customThemeBrightGreen"],
["yellow", "customThemeYellow"],
["brightYellow", "customThemeBrightYellow"],
["blue", "customThemeBlue"],
["brightBlue", "customThemeBrightBlue"],
["magenta", "customThemeMagenta"],
["brightMagenta", "customThemeBrightMagenta"],
["cyan", "customThemeCyan"],
["brightCyan", "customThemeBrightCyan"],
["white", "customThemeWhite"],
["brightWhite", "customThemeBrightWhite"],
] as const
).map(([key, labelKey]) => (
<div
key={key}
className="flex items-center justify-between gap-2"
>
<label className="text-xs text-muted-foreground min-w-0 flex-1 truncate">
{t(`hosts.${labelKey}`)}
</label>
<div className="flex items-center gap-1.5">
<input
type="color"
value={
form.customThemeColors?.[key] ??
TERMINAL_THEMES.termixDark.colors[key]
}
onChange={(e) =>
setField("customThemeColors", {
...(form.customThemeColors ??
TERMINAL_THEMES.termixDark.colors),
[key]: e.target.value,
})
}
className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5"
/>
<span className="text-[10px] font-mono text-muted-foreground w-16 tabular-nums">
{form.customThemeColors?.[key] ??
TERMINAL_THEMES.termixDark.colors[key]}
</span>
</div>
</div>
))}
</div>
</div>
)}
<SettingRow
label={t("hosts.cursorBlinking")}
description={t("hosts.cursorBlinkingDesc")}
>
<FakeSwitch
checked={form.cursorBlink}
onChange={(v) => setField("cursorBlink", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.rightClickSelectsWordLabel")}
description={t("hosts.rightClickSelectsWordShortDesc")}
>
<FakeSwitch
checked={form.rightClickSelectsWord}
onChange={(v) => setField("rightClickSelectsWord", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.macOptionIsMetaLabel")}
description={t("hosts.macOptionIsMetaShortDesc")}
>
<FakeSwitch
checked={form.macOptionIsMeta}
onChange={(v) => setField("macOptionIsMeta", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.syntaxHighlightingLabel")}
description={t("hosts.syntaxHighlightingDesc")}
>
<FakeSwitch
checked={form.syntaxHighlighting}
onChange={(v) => setField("syntaxHighlighting", v)}
/>
</SettingRow>
{form.syntaxHighlighting && (
<div className="flex flex-col ml-4">
{(
[
[
"logLevels",
"syntaxCategoryLogLevels",
"syntaxCategoryLogLevelsDesc",
],
["paths", "syntaxCategoryPaths", "syntaxCategoryPathsDesc"],
[
"timestamps",
"syntaxCategoryTimestamps",
"syntaxCategoryTimestampsDesc",
],
[
"ipAddresses",
"syntaxCategoryIpAddresses",
"syntaxCategoryIpAddressesDesc",
],
["urls", "syntaxCategoryUrls", "syntaxCategoryUrlsDesc"],
[
"numbers",
"syntaxCategoryNumbers",
"syntaxCategoryNumbersDesc",
],
] as const
).map(([key, labelKey, descKey]) => (
<SettingRow
key={key}
label={t(`hosts.${labelKey}`)}
description={t(`hosts.${descKey}`)}
>
<FakeSwitch
checked={form.syntaxHighlightingOptions?.[key] ?? true}
onChange={(v) =>
setField("syntaxHighlightingOptions", {
...form.syntaxHighlightingOptions,
[key]: v,
})
}
/>
</SettingRow>
))}
</div>
)}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backgroundImageLabel")}
</label>
<p className="text-[10px] text-muted-foreground">
{t("hosts.backgroundImageDesc")}
</p>
<input
type="url"
value={form.backgroundImage}
onChange={(e) => setField("backgroundImage", e.target.value)}
placeholder="https://example.com/image.jpg"
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
{form.backgroundImage && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backgroundImageOpacityLabel")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{Math.round(form.backgroundImageOpacity * 100)}%
</span>
</div>
<Slider
min={0.05}
max={1}
step={0.05}
value={[form.backgroundImageOpacity]}
onValueChange={([v]) => setField("backgroundImageOpacity", v)}
/>
</div>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.behaviorAndAdvanced")}
icon={<Zap className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.scrollbackBufferLabel")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.scrollback.toLocaleString()}{" "}
{t("hosts.scrollbackMaxLines")}
</span>
</div>
<Slider
min={1000}
max={100000}
step={1000}
value={[form.scrollback]}
onValueChange={([v]) => setField("scrollback", v)}
/>
</div>
<SettingRow
label={t("hosts.enableAutoTmux")}
description={
<>
{t("hosts.enableAutoTmuxDesc")}{" "}
<a
href="https://docs.termix.site/features/terminal/tmux"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={form.autoTmux}
onChange={(v) => setField("autoTmux", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.useSSHTitleLabel")}
description={t("hosts.useSSHTitleDesc")}
>
<FakeSwitch
checked={form.useSSHTitle}
onChange={(v) => setField("useSSHTitle", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.enableAutoMosh")}
description={t("hosts.enableAutoMoshDesc")}
>
<FakeSwitch
checked={form.autoMosh}
onChange={(v) => setField("autoMosh", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.passwordPromptAutoFillLabel")}
description={t("hosts.passwordPromptAutoFillDesc")}
>
<FakeSwitch
checked={form.passwordPromptAutoFill}
onChange={(v) => setField("passwordPromptAutoFill", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.sudoPasswordAutoFillLabel")}
description={t("hosts.sudoPasswordAutoFillDesc")}
>
<FakeSwitch
checked={form.sudoPasswordAutoFill}
onChange={(v) => setField("sudoPasswordAutoFill", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.localEchoLabel")}
</label>
<Select2
value={form.localEcho}
onChange={(e) =>
setField(
"localEcho",
e.target.value as "default" | "off" | "auto" | "on",
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="default">{t("hosts.localEchoDefault")}</option>
<option value="off">{t("hosts.localEchoOff")}</option>
<option value="auto">{t("hosts.localEchoAuto")}</option>
<option value="on">{t("hosts.localEchoOn")}</option>
</Select2>
<p className="text-[10px] text-muted-foreground">
{t("hosts.localEchoDesc")}{" "}
<a
href="https://docs.termix.site/features/terminal/appearance"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.linkClickBehaviorLabel")}
</label>
<Select2
value={form.linkClickBehavior}
onChange={(e) =>
setField(
"linkClickBehavior",
e.target.value as "default" | "confirm" | "direct",
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="default">
{t("hosts.linkClickBehaviorDefault")}
</option>
<option value="confirm">
{t("hosts.linkClickBehaviorConfirm")}
</option>
<option value="direct">
{t("hosts.linkClickBehaviorDirect")}
</option>
</Select2>
<p className="text-[10px] text-muted-foreground">
{t("hosts.linkClickBehaviorDesc")}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fastScrollModifierLabel")}
</label>
<Select2
value={form.fastScrollModifier}
onChange={(e) =>
setField(
"fastScrollModifier",
e.target.value as FastScrollModifier,
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{FAST_SCROLL_MODIFIERS.map((m) => (
<option key={m.value} value={m.value}>
{t(m.labelKey)}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fastScrollSensitivityLabel")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.fastScrollSensitivity}
</span>
</div>
<Slider
min={1}
max={10}
step={1}
value={[form.fastScrollSensitivity]}
onValueChange={([v]) => setField("fastScrollSensitivity", v)}
/>
</div>
</div>
{form.autoMosh && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.moshCommandLabel")}
</label>
<Input
placeholder="mosh"
value={form.moshCommand}
onChange={(e) => setField("moshCommand", e.target.value)}
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.startupSnippetLabel")}
</label>
<Select2
value={form.startupSnippetId ?? ""}
onChange={(e) =>
setCoreField(
"startupSnippetId",
e.target.value ? Number(e.target.value) : null,
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.none")}</option>
{snippets.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select2>
</div>
</div>
</div>
</SectionCard>
</>
);
}
@@ -0,0 +1,78 @@
import { SettingRow, Select2 } from "@termix/plugin-sdk/ui";
import {
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import { TERMINAL_FONTS, TERMINAL_THEMES } from "../look/terminal-themes";
import { DEFAULT_APPEARANCE } from "../../shared/terminal-settings";
const selectClass =
"flex h-8 w-48 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring";
/** Admin setting `newHostTheme`: the theme a new host starts with. */
export function NewHostThemeSetting({
values,
setValue,
running,
}: SettingsComponentProps) {
const { t } = useTranslation();
const value =
typeof values.newHostTheme === "string"
? values.newHostTheme
: DEFAULT_APPEARANCE.theme;
return (
<SettingRow
label={t("settings.admin.newHostTheme.label")}
description={t("settings.admin.newHostTheme.description")}
>
<Select2
value={value}
disabled={!running}
onChange={(e) => setValue("newHostTheme", e.target.value)}
className={selectClass}
>
{Object.entries(TERMINAL_THEMES)
.filter(([key]) => key !== "termixDark" && key !== "termixLight")
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</Select2>
</SettingRow>
);
}
/** Admin setting `newHostFontFamily`: the font a new host starts with. */
export function NewHostFontSetting({
values,
setValue,
running,
}: SettingsComponentProps) {
const { t } = useTranslation();
const value =
typeof values.newHostFontFamily === "string"
? values.newHostFontFamily
: DEFAULT_APPEARANCE.fontFamily;
const known = TERMINAL_FONTS.some((font) => font.value === value);
return (
<SettingRow
label={t("settings.admin.newHostFontFamily.label")}
description={t("settings.admin.newHostFontFamily.description")}
>
<Select2
value={value}
disabled={!running}
onChange={(e) => setValue("newHostFontFamily", e.target.value)}
className={`${selectClass} font-mono`}
>
{!known && <option value={value}>{value}</option>}
{TERMINAL_FONTS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</Select2>
</SettingRow>
);
}
@@ -0,0 +1,317 @@
import { useMemo, useState } from "react";
import type React from "react";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
SettingRow,
} from "@termix/plugin-sdk/ui";
import {
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import {
CURSOR_STYLES,
TERMINAL_FONTS,
TERMINAL_THEMES,
} from "../look/terminal-themes";
import { fromTriState, toTriState, type TriState } from "./tri-state";
import {
readUserSettings,
type TerminalDefaults,
} from "../../shared/terminal-settings";
const inputClass =
"h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring";
const labelClass =
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<span className={labelClass}>{label}</span>
{children}
</div>
);
}
function TriStateSelect({
value,
onChange,
}: {
value?: boolean;
onChange: (value: boolean | undefined) => void;
}) {
const { t } = useTranslation();
return (
<select
className="h-7 w-28 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
value={toTriState(value)}
onChange={(e) => onChange(fromTriState(e.target.value as TriState))}
>
<option value="inherit">{t("terminalDefaults.inherit")}</option>
<option value="on">{t("terminalDefaults.on")}</option>
<option value="off">{t("terminalDefaults.off")}</option>
</select>
);
}
/**
* The user's terminal defaults (the `terminalDefaults` user setting): the
* look every host that follows the user starts from. Applied to the form,
* saved with the rest of the section.
*/
export function TerminalDefaultsSettings({
values,
setValue,
running,
}: SettingsComponentProps) {
const { t } = useTranslation();
const saved = useMemo(() => readUserSettings(values), [values]);
const [open, setOpen] = useState(false);
const [terminal, setTerminal] = useState<TerminalDefaults>({});
const configuredCount = Object.values(saved.terminalDefaults).filter(
(value) => value !== undefined,
).length;
const openDialog = () => {
setTerminal(saved.terminalDefaults);
setOpen(true);
};
const updateTerminal = <K extends keyof TerminalDefaults>(
key: K,
value: TerminalDefaults[K],
) => setTerminal((current) => ({ ...current, [key]: value }));
const applySavedTheme = (id: string) => {
const theme = saved.customThemes.find((entry) => entry.id === id);
if (!theme) return;
setTerminal((current) => ({
...current,
theme: "custom",
customThemeColors: { ...theme.colors },
}));
};
const apply = () => {
const cleaned = Object.fromEntries(
Object.entries(terminal).filter(([, value]) => value !== undefined),
);
setValue("terminalDefaults", cleaned);
setOpen(false);
};
const inheritLabel = t("terminalDefaults.inherit");
return (
<>
<SettingRow
label={t("terminalDefaults.title")}
description={t("terminalDefaults.description")}
badge={configuredCount > 0 ? String(configuredCount) : undefined}
>
<Button
variant="outline"
size="sm"
disabled={!running}
onClick={openDialog}
>
{t("terminalDefaults.manage")}
</Button>
</SettingRow>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-base font-bold">
{t("terminalDefaults.title")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("terminalDefaults.dialogDescription")}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/connection-defaults"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</DialogDescription>
</DialogHeader>
<div className="max-h-[55vh] overflow-y-auto pr-1">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t("hosts.fontFamilyLabel")}>
<select
className={inputClass}
value={terminal.fontFamily ?? ""}
onChange={(e) =>
updateTerminal("fontFamily", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{TERMINAL_FONTS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</select>
</Field>
<Field label={t("hosts.colorTheme")}>
<select
className={inputClass}
value={terminal.theme ?? ""}
onChange={(e) =>
updateTerminal("theme", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{Object.entries(TERMINAL_THEMES)
.filter(
([key]) => key !== "termixDark" && key !== "termixLight",
)
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</select>
</Field>
{saved.customThemes.length > 0 && (
<Field label={t("terminalDefaults.savedThemeLabel")}>
<select
className={inputClass}
value=""
onChange={(e) => applySavedTheme(e.target.value)}
>
<option value="">
{t("terminalDefaults.savedThemePlaceholder")}
</option>
{saved.customThemes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</select>
</Field>
)}
<Field label={t("hosts.fontSizeLabel")}>
<input
className={inputClass}
type="number"
min={8}
max={32}
value={terminal.fontSize ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"fontSize",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.lineHeightLabel")}>
<input
className={inputClass}
type="number"
min={0.8}
max={2}
step={0.05}
value={terminal.lineHeight ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"lineHeight",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.cursorStyleLabel")}>
<select
className={inputClass}
value={terminal.cursorStyle ?? ""}
onChange={(e) =>
updateTerminal(
"cursorStyle",
(e.target.value || undefined) as
TerminalDefaults["cursorStyle"] | undefined,
)
}
>
<option value="">{inheritLabel}</option>
{CURSOR_STYLES.map((style) => (
<option key={style.value} value={style.value}>
{t(style.labelKey)}
</option>
))}
</select>
</Field>
<Field label={t("hosts.scrollbackBufferLabel")}>
<input
className={inputClass}
type="number"
min={1000}
max={100000}
step={1000}
value={terminal.scrollback ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"scrollback",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<div className="sm:col-span-2">
<SettingRow
label={t("hosts.cursorBlinking")}
description={t("hosts.cursorBlinkingDesc")}
>
<TriStateSelect
value={terminal.cursorBlink}
onChange={(value) => updateTerminal("cursorBlink", value)}
/>
</SettingRow>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
<Button variant="ghost" size="sm" onClick={() => setTerminal({})}>
{t("terminalDefaults.clearAll")}
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={apply}>
{t("terminalDefaults.apply")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,89 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import {
invalidateTerminalClientSettings,
loadTerminalClientSettings,
} from "../terminal-settings";
import { DEFAULT_USER_SETTINGS } from "../../shared/terminal-settings";
/**
* Browser-only terminal preferences from before 2.9.0, and the user setting
* each became. Read once: the key is removed after it is saved.
*/
const LOCAL_KEYS: Array<{
storageKey: string;
setting: string;
parse: (raw: string) => unknown;
}> = [
{
storageKey: "terminalLocalEchoMode",
setting: "localEcho",
parse: (raw) => (["off", "auto", "on"].includes(raw) ? raw : undefined),
},
{
storageKey: "terminalLinkClickBehavior",
setting: "linkClickBehavior",
parse: (raw) => (["confirm", "direct"].includes(raw) ? raw : undefined),
},
{
storageKey: "commandAutocomplete",
setting: "commandAutocomplete",
parse: (raw) =>
raw === "true" ? true : raw === "false" ? false : undefined,
},
];
function readStorage(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
/**
* Moves the old localStorage values into this plugin's user settings, the
* first time this browser runs the plugin. A value the user already changed
* on the server wins: the browser's value only replaces a default.
*/
export async function moveLocalTerminalPreferences(
app: Pick<TermixApp, "api">,
): Promise<void> {
const found = LOCAL_KEYS.map((entry) => ({
...entry,
raw: readStorage(entry.storageKey),
})).filter((entry) => entry.raw !== null);
if (found.length === 0) return;
try {
const { user } = await loadTerminalClientSettings(app.api);
const current = user as unknown as Record<string, unknown>;
const defaults = DEFAULT_USER_SETTINGS as unknown as Record<
string,
unknown
>;
const values: Record<string, unknown> = {};
for (const entry of found) {
const value = entry.parse(entry.raw as string);
if (
value !== undefined &&
value !== current[entry.setting] &&
current[entry.setting] === defaults[entry.setting]
) {
values[entry.setting] = value;
}
}
if (Object.keys(values).length > 0) {
await app.api.put("/user-settings", values);
invalidateTerminalClientSettings();
}
for (const entry of found) {
try {
localStorage.removeItem(entry.storageKey);
} catch {
// storage unavailable
}
}
} catch {
// Try again next time the plugin starts.
}
}
@@ -0,0 +1,12 @@
// Booleans have three states in the defaults UI: unset means the host decides.
export type TriState = "inherit" | "on" | "off";
export function toTriState(value?: boolean): TriState {
if (value === undefined) return "inherit";
return value ? "on" : "off";
}
export function fromTriState(value: TriState): boolean | undefined {
if (value === "inherit") return undefined;
return value === "on";
}
@@ -0,0 +1,387 @@
import { useState, useRef, useEffect, useSyncExternalStore } from "react";
import { KeyRound, Terminal } from "lucide-react";
import { toast } from "sonner";
import { Button, Separator } from "@termix/plugin-sdk/ui";
import {
getClientPreference,
getHostPassword,
setClientPreference,
useTranslation,
type PanelProps,
} from "@termix/plugin-sdk/frontend";
import {
getSessionHandle,
sessionsSnapshot,
subscribeSessions,
} from "../session-registry";
/**
* SSH Tools: type into several open terminals at once, fill their saved
* passwords, and the terminal's clipboard preferences.
*/
export function SshToolsPanel({ targetTab }: PanelProps) {
const { t } = useTranslation();
const terminalTabs = useSyncExternalStore(
subscribeSessions,
sessionsSnapshot,
);
const activeTabId = targetTab?.id ?? "";
const [keyRecording, setKeyRecording] = useState(false);
const [rightClickPaste, setRightClickPaste] = useState(
() => getClientPreference("rightClickCopyPaste") !== "false",
);
const [copyOnSelect, setCopyOnSelect] = useState(
() => getClientPreference("copyOnSelect") === "true",
);
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
() =>
new Set(
activeTabId && terminalTabs.some((tab) => tab.id === activeTabId)
? [activeTabId]
: [],
),
);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (keyRecording) inputRef.current?.focus();
}, [keyRecording]);
function toggleTab(id: string) {
setSelectedTabIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function selectAll() {
setSelectedTabIds(new Set(terminalTabs.map((tab) => tab.id)));
}
function deselectAll() {
setSelectedTabIds(new Set());
}
function broadcast(data: string) {
for (const tabId of selectedTabIds) {
getSessionHandle(tabId)?.sendInput(data);
}
}
async function fillPassword() {
let filled = 0;
let missing = 0;
for (const tabId of selectedTabIds) {
const tab = terminalTabs.find((session) => session.id === tabId);
const hostId = tab?.hostId ?? null;
const ref = getSessionHandle(tabId);
if (!hostId || !ref) {
missing++;
continue;
}
const password = await getHostPassword(hostId, "password");
if (!password) {
missing++;
continue;
}
ref.sendInput(password + "\r");
filled++;
}
if (filled > 0) {
toast.success(t("sshTools.fillPasswordSuccess", { count: filled }));
}
if (missing > 0) {
toast.error(t("sshTools.fillPasswordMissing", { count: missing }));
}
}
function broadcastArrow(normalSeq: string, appSeq: string) {
for (const tabId of selectedTabIds) {
const ref = getSessionHandle(tabId);
if (!ref) continue;
const appMode = ref.getApplicationCursorKeysMode?.() ?? false;
ref.sendInput(appMode ? appSeq : normalSeq);
}
}
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
e.preventDefault();
e.stopPropagation();
const text = e.clipboardData.getData("text");
if (text) broadcast(text);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
const ctrl = e.ctrlKey;
const { key } = e;
// Let Ctrl/Cmd+V fall through so the browser fires a paste event
// instead of being swallowed by the preventDefault() below.
if ((ctrl || e.metaKey) && key.toLowerCase() === "v") {
return;
}
e.preventDefault();
e.stopPropagation();
if (ctrl) {
const ctrlMap: Record<string, string> = {
c: "\x03",
d: "\x04",
l: "\x0C",
u: "\x15",
k: "\x0B",
a: "\x01",
e: "\x05",
w: "\x17",
z: "\x1A",
r: "\x12",
};
const seq = ctrlMap[key.toLowerCase()];
if (seq) {
broadcast(seq);
return;
}
}
if (key === "ArrowUp") {
broadcastArrow("\x1B[A", "\x1BOA");
return;
}
if (key === "ArrowDown") {
broadcastArrow("\x1B[B", "\x1BOB");
return;
}
if (key === "ArrowRight") {
broadcastArrow("\x1B[C", "\x1BOC");
return;
}
if (key === "ArrowLeft") {
broadcastArrow("\x1B[D", "\x1BOD");
return;
}
const specialMap: Record<string, string> = {
Enter: "\r",
Backspace: "\x7F",
Delete: "\x1B[3~",
Tab: "\t",
Escape: "\x1B",
Home: "\x1B[H",
End: "\x1B[F",
PageUp: "\x1B[5~",
PageDown: "\x1B[6~",
Insert: "\x1B[2~",
F1: "\x1BOP",
F2: "\x1BOQ",
F3: "\x1BOR",
F4: "\x1BOS",
F5: "\x1B[15~",
F6: "\x1B[17~",
F7: "\x1B[18~",
F8: "\x1B[19~",
F9: "\x1B[20~",
F10: "\x1B[21~",
F11: "\x1B[23~",
F12: "\x1B[24~",
};
const seq = specialMap[key];
if (seq) {
broadcast(seq);
return;
}
if (!ctrl && !e.altKey && !e.metaKey && key.length === 1) {
broadcast(key);
}
}
function toggleRecording() {
const next = !keyRecording;
if (!next) {
// clear the phantom text when stopping
if (inputRef.current) inputRef.current.value = "";
}
setKeyRecording(next);
}
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest">
{t("sshTools.keyRecordingTitle")}
</span>
{/* Terminal selector */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t("sshTools.recordToTerminals")}
</span>
<div className="flex items-center gap-2">
<button
onClick={selectAll}
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
>
{t("sshTools.selectAll")}
</button>
<button
onClick={deselectAll}
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
>
{t("sshTools.selectNone")}
</button>
</div>
</div>
{terminalTabs.length === 0 ? (
<div className="flex items-center gap-1.5 px-2.5 py-2 border border-dashed border-border/60 text-muted-foreground/40">
<Terminal className="size-3 shrink-0" />
<span className="text-xs">
{t("sshTools.noTerminalTabsOpen")}
</span>
</div>
) : (
<div className="flex flex-col gap-1">
{terminalTabs.map((tab) => {
const selected = selectedTabIds.has(tab.id);
return (
<button
key={tab.id}
onClick={() => toggleTab(tab.id)}
className={`flex items-center gap-2 px-2.5 py-1.5 border text-left transition-colors ${
selected
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40"
}`}
>
<div
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
selected
? "border-accent-brand bg-accent-brand"
: "border-border/60"
}`}
>
{selected && <div className="size-1.5 bg-background" />}
</div>
<Terminal className="size-3 shrink-0 opacity-60" />
<span className="text-xs font-medium truncate flex-1">
{tab.label || tab.hostName || tab.ip}
</span>
</button>
);
})}
</div>
)}
</div>
<Button
variant="outline"
disabled={selectedTabIds.size === 0}
className={`w-full ${keyRecording ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10 hover:bg-accent-brand/20 hover:text-accent-brand" : ""}`}
onClick={toggleRecording}
>
{keyRecording
? `${t("sshTools.stopRecording")} (${selectedTabIds.size})`
: selectedTabIds.size === 0
? t("sshTools.selectTerminalsAbove")
: `${t("sshTools.startRecording")} (${selectedTabIds.size})`}
</Button>
{keyRecording && (
<input
ref={inputRef}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onChange={(e) => {
// Keystrokes are broadcast directly via handleKeyDown; the
// field itself must stay empty. This also catches paste
// insertion on browsers that fire "input" before we can
// intercept it in onPaste.
e.target.value = "";
}}
placeholder={t("sshTools.broadcastInputPlaceholder")}
className="w-full px-2.5 py-2 text-xs bg-background border border-accent-brand/40 text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-accent-brand/70 caret-transparent"
/>
)}
<Button
variant="outline"
disabled={selectedTabIds.size === 0}
className="w-full"
onClick={() => {
void fillPassword();
}}
>
<KeyRound className="size-3.5 mr-2" />
{selectedTabIds.size === 0
? t("sshTools.selectTerminalsAbove")
: `${t("sshTools.fillPassword")} (${selectedTabIds.size})`}
</Button>
</div>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest">
{t("sshTools.settingsTitle")}
</span>
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">
{t("sshTools.enableRightClickCopyPaste")}
</span>
<button
onClick={() => {
const next = !rightClickPaste;
setRightClickPaste(next);
setClientPreference(
"rightClickCopyPaste",
next ? "true" : "false",
);
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
rightClickPaste
? "bg-accent-brand border-accent-brand"
: "bg-muted border-border"
}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${rightClickPaste ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">
{t("sshTools.copyOnSelect")}
</span>
<button
onClick={() => {
const next = !copyOnSelect;
setCopyOnSelect(next);
setClientPreference("copyOnSelect", next ? "true" : "false");
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
copyOnSelect
? "bg-accent-brand border-accent-brand"
: "bg-muted border-border"
}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${copyOnSelect ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
</div>
</div>
</div>
);
}
@@ -3,6 +3,11 @@ import {
normalizeTouchInputSettings,
type TouchInputSettings,
} from "../shared/touch-input-settings";
import {
readUserSettings,
type NewHostTerminalDefaults,
type TerminalUserSettings,
} from "../shared/terminal-settings";
/** Calls to this plugin's own routes under /plugin-api/ssh-terminal/. */
@@ -11,13 +16,27 @@ export interface TerminalClientSettings {
sessionPersistence: boolean;
commandHistoryEnabled: boolean;
touchInput: TouchInputSettings;
newHostDefaults?: NewHostTerminalDefaults;
user: TerminalUserSettings;
}
export async function getClientSettings(
api: PluginApiClient,
): Promise<TerminalClientSettings> {
const { data } = await api.get<TerminalClientSettings>("/client-settings");
return { ...data, touchInput: normalizeTouchInputSettings(data?.touchInput) };
return {
...data,
touchInput: normalizeTouchInputSettings(data?.touchInput),
user: readUserSettings(data?.user as unknown as Record<string, unknown>),
};
}
/** Turns auto tmux on for a host (its terminal host setting). */
export async function enableHostAutoTmux(
api: PluginApiClient,
hostId: number,
): Promise<void> {
await api.put(`/hosts/${hostId}/auto-tmux`, { enabled: true });
}
export async function saveCommandToHistory(
@@ -0,0 +1,132 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
usePluginApi,
type PluginApiClient,
} from "@termix/plugin-sdk/frontend";
import { getClientSettings, type TerminalClientSettings } from "./terminal-api";
import {
DEFAULT_MOSH_COMMAND,
DEFAULT_USER_SETTINGS,
readHostTerminalSettings,
resolveTerminalSettings,
type TerminalAppearance,
type TerminalBehavior,
type TerminalUserSettings,
} from "../shared/terminal-settings";
/**
* The signed-in user's terminal settings, fetched once and shared by every
* terminal. Invalidated when one of this plugin's settings is saved.
*/
let cached: TerminalClientSettings | null = null;
let pending: Promise<TerminalClientSettings> | null = null;
const listeners = new Set<() => void>();
export function loadTerminalClientSettings(
api: PluginApiClient,
): Promise<TerminalClientSettings> {
if (cached) return Promise.resolve(cached);
if (!pending) {
const request = getClientSettings(api)
.then((settings) => {
if (pending === request) cached = settings;
return settings;
})
.finally(() => {
if (pending === request) pending = null;
});
pending = request;
}
return pending;
}
export function peekTerminalClientSettings(): TerminalClientSettings | null {
return cached;
}
/** Drops the cache and has every mounted terminal read the settings again. */
export function invalidateTerminalClientSettings(): void {
cached = null;
pending = null;
for (const listener of [...listeners]) listener();
}
export function resetTerminalClientSettings(): void {
cached = null;
pending = null;
listeners.clear();
}
export function useTerminalClientSettings(): TerminalClientSettings | null {
const api = usePluginApi();
const apiRef = useRef(api);
apiRef.current = api;
const [value, setValue] = useState<TerminalClientSettings | null>(cached);
useEffect(() => {
let active = true;
const load = () => {
loadTerminalClientSettings(apiRef.current)
.then((settings) => {
if (active) setValue(settings);
})
.catch(() => {
// A guest on a share link has no account to read settings for.
});
};
load();
listeners.add(load);
return () => {
active = false;
listeners.delete(load);
};
}, []);
return value;
}
export type ResolvedTerminalConfig = TerminalAppearance & TerminalBehavior;
export interface TerminalSettingsForHost {
config: ResolvedTerminalConfig;
user: TerminalUserSettings;
}
/** This plugin's host values off a host record, as the host payload carries them. */
export function hostTerminalValues(
host: object | null | undefined,
): Record<string, unknown> | undefined {
const settings = (
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
)?.pluginSettings;
return settings?.["ssh-terminal"];
}
export function resolveForHost(
host: object | null | undefined,
user: TerminalUserSettings = DEFAULT_USER_SETTINGS,
): ResolvedTerminalConfig {
const resolved = resolveTerminalSettings(
readHostTerminalSettings(hostTerminalValues(host)),
user.terminalDefaults,
);
return {
...resolved,
moshCommand: resolved.moshCommand || DEFAULT_MOSH_COMMAND,
};
}
/** What a terminal on this host runs with, kept current as settings change. */
export function useTerminalSettings(
host: object | null | undefined,
): TerminalSettingsForHost {
const client = useTerminalClientSettings();
const own = hostTerminalValues(host);
const ownKey = own ? JSON.stringify(own) : "";
return useMemo(() => {
const user = client?.user ?? DEFAULT_USER_SETTINGS;
return { config: resolveForHost(host, user), user };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ownKey, client]);
}
@@ -27,10 +27,23 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { SearchAddon } from "@xterm/addon-search";
import {
deleteCommandFromHistory,
enableHostAutoTmux,
getClientSettings,
getCommandHistory,
hostSetting,
} from "../terminal-api";
import { useTerminalSettings } from "../terminal-settings";
import {
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
resolveTerminalFontFamily,
} from "../look/terminal-themes";
import { resolveTermixThemeColors } from "../look/terminal-theme";
import { ensureTerminalFontsLoaded } from "../look/terminal-global-styles";
import {
getNextTerminalFontSize,
getTerminalFontZoomDirection,
} from "../look/terminal-font-zoom";
import { TmuxSessionPicker } from "./TmuxSessionPicker";
import { getTerminalBufferText } from "./terminal-buffer-text.ts";
import { getMacLineNavigationSequence } from "../lib/mac-line-navigation";
@@ -90,10 +103,6 @@ import {
PassphraseDialog,
BrowserSignInDialog,
HostKeyVerificationDialog,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
resolveTerminalFontFamily,
ensureTerminalFontsLoaded,
useAppTheme as useTheme,
globalShortcutHandler,
isTabJumpHotkey,
@@ -103,14 +112,10 @@ import {
useConnectionLog,
ConnectionScreen,
Button,
resolveTermixThemeColors,
getNextTerminalFontSize,
getTerminalFontZoomDirection,
hydrateLocalSharedHostAuth,
findMatchingKeybinding,
SnippetVariablesDialog,
type CustomKeybinding,
useConnectionDefaults,
isElectron,
} from "@termix/plugin-sdk/ui";
import {
@@ -123,7 +128,7 @@ import {
logActivity,
getHostPassword,
patchOpenTab,
setHostAutoTmux,
useHost,
} from "@termix/plugin-sdk/frontend";
type HostKeyVerificationData = Omit<
@@ -159,6 +164,17 @@ interface SSHTerminalProps {
onSessionReady?: (sessionId: string) => void;
}
/**
* The host's startup snippet. Core still keeps it in the host's terminal
* config until snippets owns it.
*/
function startupSnippetOf(host: object): number | null {
const config = (host as { terminalConfig?: { startupSnippetId?: unknown } })
.terminalConfig;
const id = Number(config?.startupSnippetId);
return Number.isInteger(id) && id > 0 ? id : null;
}
const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g;
function updateAlternateScreenMode(output: string, currentMode: boolean) {
@@ -205,7 +221,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const { confirmWithToast } = useConfirmation();
const { theme: appTheme } = useTheme();
const { addLog } = useConnectionLog();
const { terminal: terminalDefaults } = useConnectionDefaults();
// An embedded terminal (a widget, another plugin's window) gets only a
// connect config; the shell's record for the host carries its settings.
const shellHost = useHost(host ? undefined : hostConfig.id);
const settingsHost: object = host ?? shellHost ?? hostConfig;
const { config: termSettings, user: termUser } =
useTerminalSettings(settingsHost);
// Callbacks bound once (socket handlers, xterm hooks) read these.
const termSettingsRef = useRef(termSettings);
termSettingsRef.current = termSettings;
const termUserRef = useRef(termUser);
termUserRef.current = termUser;
const showToolbar = hostSetting(host, "enableTerminalToolbar", true);
const outputListenersRef = useRef(new Set<(data: string) => void>());
@@ -213,14 +239,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
`terminal_theme_host_${hostConfig.id}`,
);
const config = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
theme:
savedTheme ||
hostConfig.terminalConfig?.theme ||
terminalDefaults.theme ||
DEFAULT_TERMINAL_CONFIG.theme,
...termSettings,
theme: savedTheme || termSettings.theme || DEFAULT_TERMINAL_CONFIG.theme,
};
// Ctrl+/- / Ctrl+wheel terminal zoom is persisted per-host and takes
@@ -530,8 +550,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}, [showHistoryDialog, hostConfig.id]);
useEffect(() => {
const autocompleteEnabled =
localStorage.getItem("commandAutocomplete") === "true";
const autocompleteEnabled = termUser.commandAutocomplete;
if (hostConfig.id && autocompleteEnabled) {
getCommandHistory(api, hostConfig.id!)
@@ -545,7 +564,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
} else {
autocompleteHistory.current = [];
}
}, [hostConfig.id]);
}, [hostConfig.id, termUser.commandAutocomplete]);
useEffect(() => {
showAutocompleteRef.current = showAutocomplete;
@@ -591,7 +610,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}, [autosuggestion]);
const isAutocompleteEnabled = useCallback(
() => localStorage.getItem("commandAutocomplete") === "true",
() => termUserRef.current.commandAutocomplete,
[],
);
@@ -1208,7 +1227,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
controlStringModeRef.current = controlString.isActive;
const syntaxHighlightingEnabled =
hostConfig.terminalConfig?.syntaxHighlighting !== false;
termSettingsRef.current.syntaxHighlighting !== false;
if (
!syntaxHighlightingEnabled ||
alternateScreen.sawSequence ||
@@ -1221,7 +1240,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
return highlightTerminalOutput(
output,
hostConfig.terminalConfig?.syntaxHighlightingOptions,
termSettingsRef.current.syntaxHighlightingOptions,
);
}
@@ -1244,9 +1263,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
async function resolvePasswordForPrompt(isSudoPrompt: boolean) {
const sudoPassword = hostConfig.sudoPassword as string | undefined;
let passwordToFill = isSudoPrompt
? hostConfig.terminalConfig?.sudoPassword || hostConfig.password
: hostConfig.password || hostConfig.terminalConfig?.sudoPassword;
? sudoPassword || hostConfig.password
: hostConfig.password || sudoPassword;
if (!passwordToFill && hostConfig.id) {
passwordToFill = isSudoPrompt
@@ -1262,7 +1282,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
function maybeOfferPasswordFill(strippedData: string) {
if (hostConfig.terminalConfig?.passwordPromptAutoFill === false) return;
if (termSettingsRef.current.passwordPromptAutoFill === false) return;
// PTY output can split a short prompt like "[sudo] password for user: "
// across multiple WebSocket chunks, so match against a rolling buffer
@@ -1280,12 +1300,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
// Sudo autofill is opt-in: the saved sudo password must not be sent
// to a privilege-escalation prompt unless the host explicitly enables it.
if (isSudoPrompt && !hostConfig.terminalConfig?.sudoPasswordAutoFill) {
if (isSudoPrompt && !termSettingsRef.current.sudoPasswordAutoFill) {
return;
}
const hasStoredPassword =
hostConfig.terminalConfig?.sudoPassword ||
hostConfig.sudoPassword ||
hostConfig.password ||
hostConfig.hasSudoPassword ||
hostConfig.hasPassword;
@@ -1542,7 +1562,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const canEnable =
typeof hostConfig.id === "number" &&
!hostConfig.terminalConfig?.autoTmux &&
!termSettingsRef.current.autoTmux &&
!hostConfig.joinShareId;
toast.warning(notice, {
duration: 15000,
@@ -1551,7 +1571,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
action: {
label: t("terminal.enableAutoTmuxAction"),
onClick: () => {
void setHostAutoTmux(hostConfig.id as number, true)
void enableHostAutoTmux(api, hostConfig.id as number)
.then(() => {
window.dispatchEvent(
new CustomEvent("termix:hosts-changed"),
@@ -1746,8 +1766,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
terminalInputDisposableRef.current?.dispose();
localEchoRef.current = new TerminalLocalEcho(
resolveLocalEchoMode(
hostConfig.terminalConfig?.localEcho,
localStorage.getItem("terminalLocalEchoMode"),
termSettingsRef.current.localEcho,
termUserRef.current.localEcho,
),
);
terminalInputDisposableRef.current = terminal.onData((data) => {
@@ -1936,17 +1956,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
logTerminalActivity();
setTimeout(async () => {
const terminalConfig = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
};
const settings = termSettingsRef.current;
const environmentVariables =
(
hostConfig.sshOptions as
| {
environmentVariables?: { key: string; value: string }[];
}
| null
| undefined
)?.environmentVariables ?? [];
const startupSnippetId = startupSnippetOf(settingsHost);
if (
terminalConfig.environmentVariables &&
terminalConfig.environmentVariables.length > 0
) {
for (const envVar of terminalConfig.environmentVariables) {
if (environmentVariables.length > 0) {
for (const envVar of environmentVariables) {
if (envVar.key && envVar.value && ws.readyState === 1) {
ws.send(
JSON.stringify({
@@ -1958,11 +1981,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}
if (terminalConfig.startupSnippetId) {
if (startupSnippetId) {
try {
const resolved = (await invokeAction(
"snippets.resolveForTerminal",
terminalConfig.startupSnippetId,
startupSnippetId,
{
ip: hostConfig.ip,
username: hostConfig.username,
@@ -1990,11 +2013,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}
if (terminalConfig.autoMosh && ws.readyState === 1) {
if (settings.autoMosh && ws.readyState === 1) {
ws.send(
JSON.stringify({
type: "input",
data: terminalConfig.moshCommand + "\n",
data: settings.moshCommand + "\n",
}),
);
}
@@ -2491,11 +2514,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
useEffect(() => {
if (!terminal) return;
const config = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
};
const config = termSettingsRef.current;
const activeTheme = previewTheme || config.theme;
const themeColors = resolveTermixThemeColors(
@@ -2560,23 +2579,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
// Refresh terminal to apply new theme colors to existing buffer content
hardRefresh();
}, [
terminal,
terminalDefaults,
hostConfig.terminalConfig,
previewTheme,
appTheme,
isFitted,
]);
}, [terminal, termSettings, previewTheme, appTheme, isFitted]);
useEffect(() => {
if (!terminal || !xtermRef.current) return;
const config = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
};
const config = termSettingsRef.current;
const fontFamily = resolveTerminalFontFamily(config.fontFamily);
ensureTerminalFontsLoaded(config.fontFamily || TERMINAL_FONTS[0].value);
@@ -2647,10 +2655,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
? uri
: `https://${uri}`;
const hostBehavior = hostConfig.terminalConfig?.linkClickBehavior;
const globalBehavior =
localStorage.getItem("terminalLinkClickBehavior") ?? "confirm";
const behavior = hostBehavior ?? globalBehavior;
const hostBehavior = termSettingsRef.current.linkClickBehavior;
const behavior =
hostBehavior && hostBehavior !== "default"
? hostBehavior
: termUserRef.current.linkClickBehavior;
if (behavior === "direct") {
window.open(url, "_blank");
@@ -2701,11 +2710,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
return false;
}
const cfg = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
};
const cfg = termSettingsRef.current;
const mod = cfg.fastScrollModifier;
const modHeld =
(mod === "alt" && ev.altKey) ||
@@ -2835,12 +2840,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (e.key !== "Backspace") return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const config = {
...DEFAULT_TERMINAL_CONFIG,
...terminalDefaults,
...hostConfig.terminalConfig,
};
if (config.backspaceMode !== "control-h") return;
if (termSettingsRef.current.backspaceMode !== "control-h") return;
e.preventDefault();
e.stopPropagation();
@@ -3042,7 +3042,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const sequence = getAndroidHardwareKeySequence(
e,
terminal.modes.applicationCursorKeysMode,
hostConfig.terminalConfig?.backspaceMode,
termSettingsRef.current.backspaceMode,
);
if (sequence) {
e.preventDefault();
@@ -3300,8 +3300,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
};
const autocompleteEnabled =
localStorage.getItem("commandAutocomplete") === "true";
const autocompleteEnabled = termUserRef.current.commandAutocomplete;
if (!autocompleteEnabled) {
clearAutosuggestion();
@@ -8,6 +8,8 @@ import {
type TabProps,
} from "@termix/plugin-sdk/frontend";
import type { Host } from "../types";
import { hostTerminalValues } from "../terminal-settings";
import { readHostTerminalSettings } from "../../shared/terminal-settings";
/** The session fields the shell keeps on a terminal tab. */
interface TerminalTabRecord {
@@ -63,6 +65,9 @@ export function TerminalTabContent({
const { t } = useTranslation();
const { previewTerminalTheme } = useTabsSafe();
const isMobile = useIsMobile();
const useSshTitle = readHostTerminalSettings(
hostTerminalValues(host),
).useSSHTitle;
if (!host) {
return (
@@ -102,7 +107,7 @@ export function TerminalTabContent({
splitScreen={inSplit}
onClose={() => shell.closeTab(tab.id)}
onTitleChange={
host.terminalConfig?.useSSHTitle
useSshTitle
? (title) => shell.renameTab(tab.id, title)
: undefined
}
@@ -1,4 +1,4 @@
import type { TerminalConfig } from "../types";
import type { HostSshOptions } from "@termix/plugin-sdk/frontend";
export interface TerminalHostConfig {
id?: number;
@@ -17,7 +17,7 @@ export interface TerminalHostConfig {
keyType?: string;
authType?: string;
credentialId?: number;
terminalConfig?: Partial<TerminalConfig>;
sshOptions?: HostSshOptions | null;
[key: string]: unknown;
}
+5 -75
View File
@@ -1,82 +1,12 @@
import type { HostSshOptions } from "@termix/plugin-sdk/frontend";
/**
* Host and terminal shapes as the terminal reads them. The plugin keeps its
* Host 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;
passwordPromptAutoFill?: boolean;
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"];
export type { BackspaceMode as HostBackspaceMode } from "../shared/terminal-settings";
/** A host as the shell hands it to a tab. */
export interface Host {
@@ -85,7 +15,7 @@ export interface Host {
ip: string;
port: number;
username: string;
terminalConfig?: Partial<TerminalConfig> | null;
sshOptions?: HostSshOptions | null;
pluginSettings?: Record<string, Record<string, unknown>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
@@ -0,0 +1,438 @@
/**
* The terminal's look and behavior: this plugin's host, user and admin
* settings, and the 2.8 `terminalConfig` shape they replaced.
*
* Shared by the backend (import, the 2.8 host payload) and the frontend (the
* terminal and the host editor), so both read one list of keys.
*/
export interface TerminalThemeColors {
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 interface SyntaxHighlightingOptions {
logLevels: boolean;
paths: boolean;
timestamps: boolean;
ipAddresses: boolean;
urls: boolean;
numbers: boolean;
}
export type CursorStyle = "block" | "underline" | "bar";
export type BellStyle = "none" | "sound" | "visual" | "both";
export type FastScrollModifier = "alt" | "ctrl" | "shift";
export type BackspaceMode = "normal" | "control-h";
export type HostLocalEcho = "default" | "off" | "auto" | "on";
export type HostLinkClickBehavior = "default" | "confirm" | "direct";
/** How a terminal looks. A host follows the user's defaults unless it opts out. */
export interface TerminalAppearance {
theme: string;
cursorBlink: boolean;
cursorStyle: CursorStyle;
fontSize: number;
fontFamily: string;
scrollback: number;
letterSpacing: number;
lineHeight: number;
bellStyle: BellStyle;
minimumContrastRatio: number;
backgroundImage: string;
backgroundImageOpacity: number;
customThemeColors: TerminalThemeColors | null;
}
/** How a terminal behaves on one host. Always the host's own. */
export interface TerminalBehavior {
rightClickSelectsWord: boolean;
macOptionIsMeta: boolean;
fastScrollModifier: FastScrollModifier;
fastScrollSensitivity: number;
backspaceMode: BackspaceMode;
autoMosh: boolean;
moshCommand: string;
autoTmux: boolean;
useSSHTitle: boolean;
syntaxHighlighting: boolean;
syntaxHighlightingOptions: SyntaxHighlightingOptions;
linkClickBehavior: HostLinkClickBehavior;
localEcho: HostLocalEcho;
passwordPromptAutoFill: boolean;
sudoPasswordAutoFill: boolean;
}
/** Everything the host editor's Terminal tab edits. */
export interface HostTerminalSettings
extends TerminalAppearance, TerminalBehavior {
inheritAppearance: boolean;
}
export const APPEARANCE_KEYS = [
"theme",
"cursorBlink",
"cursorStyle",
"fontSize",
"fontFamily",
"scrollback",
"letterSpacing",
"lineHeight",
"bellStyle",
"minimumContrastRatio",
"backgroundImage",
"backgroundImageOpacity",
"customThemeColors",
] as const satisfies readonly (keyof TerminalAppearance)[];
export const BEHAVIOR_KEYS = [
"rightClickSelectsWord",
"macOptionIsMeta",
"fastScrollModifier",
"fastScrollSensitivity",
"backspaceMode",
"autoMosh",
"moshCommand",
"autoTmux",
"useSSHTitle",
"syntaxHighlighting",
"syntaxHighlightingOptions",
"linkClickBehavior",
"localEcho",
"passwordPromptAutoFill",
"sudoPasswordAutoFill",
] as const satisfies readonly (keyof TerminalBehavior)[];
export const INHERIT_APPEARANCE_KEY = "inheritAppearance";
export const DEFAULT_MOSH_COMMAND = "mosh-server new -s -l LANG=en_US.UTF-8";
export const DEFAULT_FONT_FAMILY = "Caskaydia Cove Nerd Font Mono";
export const DEFAULT_SYNTAX_HIGHLIGHTING: SyntaxHighlightingOptions = {
logLevels: true,
paths: true,
timestamps: true,
ipAddresses: true,
urls: true,
numbers: true,
};
export const DEFAULT_APPEARANCE: TerminalAppearance = {
theme: "termix",
cursorBlink: true,
cursorStyle: "bar",
fontSize: 14,
fontFamily: DEFAULT_FONT_FAMILY,
scrollback: 10000,
letterSpacing: 0,
lineHeight: 1,
bellStyle: "none",
minimumContrastRatio: 1,
backgroundImage: "",
backgroundImageOpacity: 0.15,
customThemeColors: null,
};
export const DEFAULT_BEHAVIOR: TerminalBehavior = {
rightClickSelectsWord: false,
macOptionIsMeta: false,
fastScrollModifier: "alt",
fastScrollSensitivity: 5,
backspaceMode: "normal",
autoMosh: false,
moshCommand: "",
autoTmux: false,
useSSHTitle: false,
syntaxHighlighting: true,
syntaxHighlightingOptions: DEFAULT_SYNTAX_HIGHLIGHTING,
linkClickBehavior: "default",
localEcho: "default",
passwordPromptAutoFill: true,
sudoPasswordAutoFill: false,
};
export const DEFAULT_HOST_TERMINAL_SETTINGS: HostTerminalSettings = {
...DEFAULT_APPEARANCE,
...DEFAULT_BEHAVIOR,
inheritAppearance: true,
};
/** The user's terminal defaults: any appearance key, unset ones inherit. */
export type TerminalDefaults = Partial<TerminalAppearance>;
export interface SavedCustomTheme {
id: string;
name: string;
colors: TerminalThemeColors;
}
/** The ssh-terminal user settings the terminal reads. */
export interface TerminalUserSettings {
terminalDefaults: TerminalDefaults;
customThemes: SavedCustomTheme[];
commandAutocomplete: boolean;
localEcho: "off" | "auto" | "on";
linkClickBehavior: "confirm" | "direct";
}
export const DEFAULT_USER_SETTINGS: TerminalUserSettings = {
terminalDefaults: {},
customThemes: [],
commandAutocomplete: false,
localEcho: "auto",
linkClickBehavior: "confirm",
};
/** The admin's defaults for a new host's terminal. */
export interface NewHostTerminalDefaults {
fontSize: number;
fontFamily: string;
theme: string;
cursorStyle: CursorStyle;
cursorBlink: boolean;
autoTmux: boolean;
}
/** Admin field -> the host field it seeds (manifest defaultFrom). */
export const NEW_HOST_ADMIN_KEYS = {
newHostFontSize: "fontSize",
newHostFontFamily: "fontFamily",
newHostTheme: "theme",
newHostCursorStyle: "cursorStyle",
newHostCursorBlink: "cursorBlink",
newHostAutoTmux: "autoTmux",
} as const satisfies Record<string, keyof NewHostTerminalDefaults>;
const ENUMS: Record<string, readonly string[]> = {
cursorStyle: ["block", "underline", "bar"],
bellStyle: ["none", "sound", "visual", "both"],
fastScrollModifier: ["alt", "ctrl", "shift"],
backspaceMode: ["normal", "control-h"],
linkClickBehavior: ["default", "confirm", "direct"],
localEcho: ["default", "off", "auto", "on"],
};
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/** A JSON string or an object as an object, anything else as null. */
export function asObject(value: unknown): Record<string, unknown> | null {
let parsed = value;
if (typeof parsed === "string") {
if (!parsed) return null;
try {
parsed = JSON.parse(parsed);
} catch {
return null;
}
}
return isObject(parsed) ? parsed : null;
}
function finite(value: unknown): number | undefined {
if (value === null || value === undefined || value === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
/** One key's value, typed, or undefined when it does not fit the key. */
function pick(key: string, value: unknown): unknown {
if (value === undefined) return undefined;
if (ENUMS[key]) {
return typeof value === "string" && ENUMS[key].includes(value)
? value
: undefined;
}
switch (key) {
case "theme":
if (typeof value !== "string" || !value) return undefined;
// 2.8 spelled the app-following theme several ways.
return [
"Termix Dark",
"Termix Light",
"termixDark",
"termixLight",
].includes(value)
? "termix"
: value;
case "fontFamily":
return typeof value === "string" && value ? value : undefined;
case "backgroundImage":
case "moshCommand":
return value === null
? ""
: typeof value === "string"
? value
: undefined;
case "customThemeColors":
return value === null ? null : isObject(value) ? value : undefined;
case "syntaxHighlightingOptions":
return isObject(value)
? { ...DEFAULT_SYNTAX_HIGHLIGHTING, ...value }
: undefined;
case "fontSize":
case "scrollback":
case "letterSpacing":
case "lineHeight":
case "minimumContrastRatio":
case "backgroundImageOpacity":
case "fastScrollSensitivity":
return finite(value);
default:
return typeof value === "boolean" ? value : undefined;
}
}
/** The keys of an object that fit, typed, with everything else dropped. */
export function pickTerminalValues(
source: Record<string, unknown> | null | undefined,
keys: readonly string[],
): Record<string, unknown> {
const values: Record<string, unknown> = {};
if (!source) return values;
for (const key of keys) {
const value = pick(key, source[key]);
if (value !== undefined) values[key] = value;
}
return values;
}
/**
* The host settings a 2.8 `terminalConfig` carried: every behavior key it
* set, and the appearance keys only when it set any, which is also what
* turns `inheritAppearance` off (2.8 stripped them to follow the user).
*/
export function hostSettingsFromTerminalConfig(
terminalConfig: unknown,
): Record<string, unknown> {
const config = asObject(terminalConfig);
if (!config) return {};
const behavior = pickTerminalValues(config, BEHAVIOR_KEYS);
const appearance = pickTerminalValues(config, APPEARANCE_KEYS);
const values: Record<string, unknown> = { ...behavior };
if (APPEARANCE_KEYS.some((key) => key in config)) {
Object.assign(values, appearance, { [INHERIT_APPEARANCE_KEY]: false });
}
return values;
}
/** Stored host values over the defaults, each one typed. */
export function readHostTerminalSettings(
values: Record<string, unknown> | null | undefined,
): HostTerminalSettings {
const typed = pickTerminalValues(values, [
...APPEARANCE_KEYS,
...BEHAVIOR_KEYS,
]);
const inherit = values?.[INHERIT_APPEARANCE_KEY];
return {
...DEFAULT_HOST_TERMINAL_SETTINGS,
...typed,
inheritAppearance: typeof inherit === "boolean" ? inherit : true,
} as HostTerminalSettings;
}
export function readUserSettings(
values: Record<string, unknown> | null | undefined,
): TerminalUserSettings {
const source = values ?? {};
const defaults = asObject(source.terminalDefaults);
const themes = Array.isArray(source.customThemes)
? source.customThemes
: typeof source.customThemes === "string"
? (() => {
try {
const parsed = JSON.parse(source.customThemes as string);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
})()
: [];
return {
terminalDefaults: pickTerminalValues(
defaults,
APPEARANCE_KEYS,
) as TerminalDefaults,
customThemes: themes.filter(
(theme): theme is SavedCustomTheme =>
isObject(theme) &&
typeof theme.id === "string" &&
typeof theme.name === "string" &&
isObject(theme.colors),
),
commandAutocomplete: source.commandAutocomplete === true,
localEcho:
source.localEcho === "off" || source.localEcho === "on"
? source.localEcho
: "auto",
linkClickBehavior:
source.linkClickBehavior === "direct" ? "direct" : "confirm",
};
}
/**
* What a terminal on a host runs with: the defaults, then the user's
* terminal defaults, then the host's own look when it does not follow the
* user, then the host's behavior.
*/
export function resolveTerminalSettings(
host: HostTerminalSettings | null | undefined,
userDefaults: TerminalDefaults = {},
): TerminalAppearance & TerminalBehavior {
const own = host ?? DEFAULT_HOST_TERMINAL_SETTINGS;
const appearance: TerminalAppearance = {
...DEFAULT_APPEARANCE,
...pickTerminalValues(userDefaults, APPEARANCE_KEYS),
};
if (!own.inheritAppearance) {
for (const key of APPEARANCE_KEYS) {
(appearance as unknown as Record<string, unknown>)[key] = own[key];
}
}
const behavior = {} as TerminalBehavior;
for (const key of BEHAVIOR_KEYS) {
(behavior as unknown as Record<string, unknown>)[key] = own[key];
}
return { ...appearance, ...behavior };
}
/**
* The host's settings in the 2.8 `terminalConfig` shape, for clients that
* still read it (Termix-Mobile). The look is left out while the host follows
* the user, the way 2.8 stored it.
*/
export function terminalConfigFromHostSettings(
values: Record<string, unknown>,
): Record<string, unknown> {
const settings = readHostTerminalSettings(values);
const config: Record<string, unknown> = {};
for (const key of BEHAVIOR_KEYS) config[key] = settings[key];
if (!settings.inheritAppearance) {
for (const key of APPEARANCE_KEYS) config[key] = settings[key];
}
if (config.linkClickBehavior === "default") delete config.linkClickBehavior;
if (config.localEcho === "default") delete config.localEcho;
return config;
}