feat: move snippets, keybinding actions and macros out of core into plugins

This commit is contained in:
LukeGus committed 2026-09-29 11:39:14 -05:00
1 parent d9bba10612
commit e0c30a4e6f
262 files changed
+9091 -10744

No files matched your search

+5 -1
View File
@@ -22,7 +22,10 @@ import {
import { ADMIN_KEYS } from "./settings.js";
import { commandHistory } from "./tables.js";
import { createTerminalSocket } from "./terminal-socket.js";
import { validateAdminSettings } from "./settings-validation.js";
import {
validateAdminSettings,
validateUserSettings,
} from "./settings-validation.js";
function toInfo(session: TerminalSession): LiveSessionInfo {
return {
@@ -57,6 +60,7 @@ function optionalService<T extends object>(
export async function activate(ctx: PluginContext) {
const log = createTerminalLogger(ctx.log);
ctx.settings.onValidate("admin", validateAdminSettings);
ctx.settings.onValidate("user", validateUserSettings);
const table = await ctx.db.define(commandHistory);
const history = createHistoryRepository(ctx.db, table);
@@ -30,3 +30,38 @@ export function validateAdminSettings(
}
return errors;
}
const MAX_MACROS = 100;
const MAX_MACROS_BYTES = 512 * 1024;
/** Save-time checks for the user settings: macros stay bounded. */
export function validateUserSettings(
values: Record<string, unknown>,
): Record<string, string> {
const errors: Record<string, string> = {};
if (values.macros === undefined || values.macros === null) return errors;
let macros = values.macros;
if (typeof macros === "string") {
try {
macros = JSON.parse(macros);
} catch {
macros = null;
}
}
const valid =
Array.isArray(macros) &&
macros.length <= MAX_MACROS &&
JSON.stringify(macros).length <= MAX_MACROS_BYTES &&
macros.every(
(macro) =>
!!macro &&
typeof macro === "object" &&
typeof (macro as { id?: unknown }).id === "string" &&
typeof (macro as { name?: unknown }).name === "string" &&
Array.isArray((macro as { steps?: unknown }).steps),
);
if (!valid) {
errors.macros = `Macros must be a list of at most ${MAX_MACROS} valid macros`;
}
return errors;
}
@@ -1,5 +1,6 @@
import { lazy, Suspense, type ComponentType } from "react";
import {
Braces,
Hammer,
History,
Laptop,
@@ -33,6 +34,17 @@ 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 { MacrosPanel } from "./macros/MacrosPanel";
import {
TERMINAL_KEYBINDING_DEFAULTS,
validateSendControlCode,
validateSendText,
} from "./lib/keybinding-dispatch";
import {
PasteNote,
SendControlCodeEditor,
SendTextEditor,
} from "./lib/keybinding-editors";
import { TerminalDefaultsSettings } from "./settings/TerminalDefaultsSettings";
import {
NewHostFontSetting,
@@ -191,6 +203,45 @@ export function activate(app: TermixApp): void {
separatorAfter: true,
});
app.registerPanel(
"macros",
MacrosPanel as unknown as ComponentType<PanelProps>,
);
app.registerRailItem({
id: "macros",
icon: Braces,
titleKey: "nav.macros",
after: "ssh-tools",
hideable: true,
promotable: true,
rightDockable: true,
separatorAfter: true,
});
// The keys a user can bind to the terminal's own actions, and its
// built-in keys they can rebind. The terminal runs these itself.
app.registerKeybindingAction({ id: "copy", titleKey: "keybindings.copy" });
app.registerKeybindingAction({
id: "paste",
titleKey: "keybindings.paste",
editor: PasteNote,
});
app.registerKeybindingAction({
id: "sendControlCode",
titleKey: "keybindings.sendControlCode",
editor: SendControlCodeEditor,
validate: validateSendControlCode,
});
app.registerKeybindingAction({
id: "sendText",
titleKey: "keybindings.sendText",
editor: SendTextEditor,
validate: validateSendText,
});
for (const binding of TERMINAL_KEYBINDING_DEFAULTS) {
app.registerKeybindingDefault(binding);
}
// 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"] });
@@ -1,27 +1,26 @@
import type { Terminal } from "@xterm/xterm";
import { type KeybindingAction } from "@termix/plugin-sdk/ui";
import { invokeAction } from "@termix/plugin-sdk/frontend";
export interface SnippetHostContext {
ip?: string;
username?: string;
port?: number | string;
name?: string;
}
import type {
KeybindingAction,
KeybindingDefaultContribution,
} from "@termix/plugin-sdk/frontend";
export interface KeybindingDispatchContext {
terminal: Terminal;
webSocketRef: React.MutableRefObject<WebSocket | null>;
writeTextToClipboard: (text: string) => Promise<boolean>;
readTextFromClipboard: () => Promise<string>;
/** Host context used to resolve $HOST/$USER/$PORT/$NAME in runSnippet actions. */
hostContext?: SnippetHostContext | null;
/**
* Called instead of sending the snippet directly when its content still has
* unresolved $INPUT_n placeholders after host-variable substitution -- the
* caller is expected to collect values (e.g. via a dialog) and send itself.
*/
onSnippetNeedsInputs?: (snippet: { id: string; content: string }) => void;
}
/** The keybinding actions the terminal runs itself. */
export const TERMINAL_KEYBINDING_ACTIONS = [
"copy",
"paste",
"sendControlCode",
"sendText",
] as const;
export function isTerminalKeybindingAction(type: string): boolean {
return (TERMINAL_KEYBINDING_ACTIONS as readonly string[]).includes(type);
}
export function sendRawToSocket(
@@ -33,10 +32,19 @@ export function sendRawToSocket(
}
}
function text(action: KeybindingAction, key: string): string {
const value = action[key];
return typeof value === "string" ? value : "";
}
/**
* Runs one of the terminal's own bound actions. False for any other type,
* which the terminal hands on to whoever registered it.
*/
export function dispatchKeybindingAction(
action: KeybindingAction,
ctx: KeybindingDispatchContext,
): void {
): boolean {
const sendRaw = (data: string) => sendRawToSocket(ctx.webSocketRef, data);
switch (action.type) {
@@ -46,58 +54,96 @@ export function dispatchKeybindingAction(
ctx.writeTextToClipboard(selection);
ctx.terminal.clearSelection();
}
return;
return true;
}
case "paste": {
ctx.readTextFromClipboard().then((text) => {
if (text) ctx.terminal.paste(text);
ctx.readTextFromClipboard().then((value) => {
if (value) ctx.terminal.paste(value);
});
return;
return true;
}
case "sendControlCode": {
if (!action.controlCode) return;
const code = action.controlCode.toLowerCase().charCodeAt(0) - 96;
const letter = text(action, "controlCode");
if (!letter) return true;
const code = letter.toLowerCase().charCodeAt(0) - 96;
if (code >= 1 && code <= 26) sendRaw(String.fromCharCode(code));
return;
return true;
}
case "sendText": {
sendRaw((action.text ?? "") + (action.appendEnter ? "\r" : ""));
return;
}
case "runSnippet": {
if (!action.snippetId) return;
const snippetId = Number(action.snippetId);
if (!Number.isFinite(snippetId)) return;
// Resolving with no inputValues first tells us whether the snippet
// still needs them; the plugin decides and reports back.
void invokeAction(
"snippets.resolveForTerminal",
snippetId,
ctx.hostContext ?? null,
).then((result) => {
const resolved = result as
{ needsInputs: boolean; content: string } | null | undefined;
if (!resolved) return;
if (resolved.needsInputs) {
ctx.onSnippetNeedsInputs?.({
id: action.snippetId!,
content: resolved.content,
});
return;
}
sendRaw(resolved.content + (action.appendEnter !== false ? "\r" : ""));
});
return;
}
case "nextTab":
case "previousTab":
case "openCommandPalette": {
window.dispatchEvent(
new CustomEvent("termix:global-keybinding", {
detail: { type: action.type },
}),
);
return;
sendRaw(text(action, "text") + (action.appendEnter === true ? "\r" : ""));
return true;
}
default:
return false;
}
}
export function validateSendText(action: KeybindingAction): string | null {
return text(action, "text").trim() ? null : "keybindings.textRequired";
}
export function validateSendControlCode(
action: KeybindingAction,
): string | null {
return /^[a-zA-Z]$/.test(text(action, "controlCode"))
? null
: "keybindings.controlCodeRequired";
}
function combo(
key: string,
modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean },
) {
return {
key,
isCode: false,
ctrl: !!modifiers.ctrl,
alt: !!modifiers.alt,
shift: !!modifiers.shift,
meta: !!modifiers.meta,
};
}
/** The terminal's built-in keys, listed so a user can rebind them. */
export const TERMINAL_KEYBINDING_DEFAULTS: KeybindingDefaultContribution[] = [
{
id: "default-copy-ctrlc",
combo: combo("c", { ctrl: true }),
descriptionKey: "keybindings.builtIn.copyCtrlC",
},
{
id: "default-copy-ctrlshiftc",
combo: combo("c", { ctrl: true, shift: true }),
descriptionKey: "keybindings.builtIn.copyCtrlShiftC",
},
{
id: "default-copy-cmdc",
combo: combo("c", { meta: true }),
descriptionKey: "keybindings.builtIn.copyCmdC",
},
{
id: "default-paste-ctrlshiftv",
combo: combo("v", { ctrl: true, shift: true }),
descriptionKey: "keybindings.builtIn.pasteCtrlShiftV",
},
{
id: "default-ctrlaltw",
combo: combo("w", { ctrl: true, alt: true }),
descriptionKey: "keybindings.builtIn.ctrlW",
},
{
id: "default-ctrlaltt",
combo: combo("t", { ctrl: true, alt: true }),
descriptionKey: "keybindings.builtIn.ctrlT",
},
{
id: "default-ctrlaltn",
combo: combo("n", { ctrl: true, alt: true }),
descriptionKey: "keybindings.builtIn.ctrlN",
},
{
id: "default-ctrlaltq",
combo: combo("q", { ctrl: true, alt: true }),
descriptionKey: "keybindings.builtIn.ctrlQ",
},
];
@@ -0,0 +1,81 @@
import {
useTranslation,
type KeybindingActionEditorProps,
} from "@termix/plugin-sdk/frontend";
import { Input } from "@termix/plugin-sdk/ui";
export function PasteNote() {
const { t } = useTranslation();
return (
<span className="text-xs text-muted-foreground">
{t("keybindings.clipboardPermissionNote")}
</span>
);
}
export function SendControlCodeEditor({
action,
onChange,
}: KeybindingActionEditorProps) {
const { t } = useTranslation();
const value =
typeof action.controlCode === "string" ? action.controlCode : "";
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("keybindings.controlCodeLabel")}
</label>
<Input
aria-label={t("keybindings.controlCodeLabel")}
value={value}
maxLength={1}
onChange={(e) =>
onChange({
type: action.type,
controlCode: e.target.value.toLowerCase() || undefined,
})
}
placeholder="w"
className="w-16 font-mono"
/>
</div>
);
}
export function SendTextEditor({
action,
onChange,
}: KeybindingActionEditorProps) {
const { t } = useTranslation();
const text = typeof action.text === "string" ? action.text : "";
const appendEnter = action.appendEnter === true;
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("keybindings.textLabel")}
</label>
<textarea
aria-label={t("keybindings.textLabel")}
value={text}
onChange={(e) =>
onChange({ type: action.type, text: e.target.value, appendEnter })
}
className="w-full h-24 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={appendEnter}
onChange={(e) =>
onChange({
type: action.type,
text,
appendEnter: e.target.checked,
})
}
/>
{t("keybindings.appendEnterLabel")}
</label>
</div>
);
}
@@ -0,0 +1,864 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import {
useSettings,
useTranslation,
type PanelProps,
} from "@termix/plugin-sdk/frontend";
import {
ArrowLeft,
Braces,
ChevronDown,
Clock,
CornerDownRight,
GitBranch,
Plus,
Repeat,
Save,
Square,
Terminal as TerminalIcon,
Trash2,
Play,
} from "lucide-react";
import { toast } from "sonner";
import {
Button,
Input,
Textarea,
Checkbox,
EmptyState,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import {
mergeLocalMacros,
sanitizeTerminalMacros,
runTerminalMacro,
type MacroStep,
type TerminalMacro,
} from "./terminal-macros";
import {
getSessionHandle,
sessionsSnapshot,
subscribeSessions,
} from "../session-registry";
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "";
}
const STEP_TYPES = ["send", "wait", "delay", "if", "repeat"] as const;
const STEP_ICONS = {
send: TerminalIcon,
wait: CornerDownRight,
delay: Clock,
if: GitBranch,
repeat: Repeat,
} as const;
function id(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function createStep(type: MacroStep["type"]): MacroStep {
const stepId = id();
switch (type) {
case "send":
return { id: stepId, type, text: "", pressEnter: true };
case "delay":
return { id: stepId, type, milliseconds: 1000 };
case "wait":
return {
id: stepId,
type,
pattern: "",
isRegex: false,
timeoutMs: 10_000,
onTimeout: "stop",
};
case "if":
return {
id: stepId,
type,
pattern: "",
isRegex: false,
then: [],
else: [],
};
case "repeat":
return { id: stepId, type, count: 2, steps: [] };
}
}
function isValidRegex(pattern: string): boolean {
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
}
/** Milliseconds are an implementation detail, so the editor works in seconds. */
function SecondsInput({
valueMs,
onChangeMs,
minMs,
label,
}: {
valueMs: number;
onChangeMs: (ms: number) => void;
minMs: number;
label: string;
}) {
const { t } = useTranslation();
return (
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{label}</span>
<Input
className="h-7 w-20 rounded-none text-xs"
type="number"
min={minMs / 1000}
max={300}
step={0.5}
value={valueMs / 1000}
onChange={(event) => {
const seconds = Number(event.target.value);
onChangeMs(
Number.isFinite(seconds)
? Math.max(minMs, Math.min(300_000, Math.round(seconds * 1000)))
: minMs,
);
}}
/>
<span className="text-muted-foreground">{t("macros.seconds")}</span>
</label>
);
}
/** Shared text/regex matcher field used by both "wait" and "if". */
function MatchField({
pattern,
isRegex,
label,
placeholder,
onChange,
}: {
pattern: string;
isRegex: boolean;
label: string;
placeholder: string;
onChange: (next: { pattern?: string; isRegex?: boolean }) => void;
}) {
const { t } = useTranslation();
const badRegex = isRegex && pattern.length > 0 && !isValidRegex(pattern);
return (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">{label}</div>
<Input
className={`h-8 rounded-none font-mono text-xs ${
badRegex ? "border-destructive" : ""
}`}
placeholder={placeholder}
value={pattern}
onChange={(event) => onChange({ pattern: event.target.value })}
/>
{badRegex && (
<div className="text-[11px] text-destructive">
{t("macros.match.invalidRegex")}
</div>
)}
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
<Checkbox
checked={isRegex}
onCheckedChange={(checked) => onChange({ isRegex: checked === true })}
/>
{t("macros.match.useRegex")}
</label>
</div>
);
}
function AddStepMenu({
onAdd,
compact,
}: {
onAdd: (type: MacroStep["type"]) => void;
compact?: boolean;
}) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className={`rounded-none ${compact ? "h-7 text-[11px]" : "h-8 w-full text-xs"}`}
>
<Plus className="mr-1 size-3" />
{t("macros.addStep")}
<ChevronDown className="ml-1 size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72 rounded-none">
{STEP_TYPES.map((type) => {
const Icon = STEP_ICONS[type];
return (
<DropdownMenuItem
key={type}
className="flex-col items-start gap-0.5 rounded-none"
onSelect={() => onAdd(type)}
>
<span className="flex items-center gap-2 text-xs font-medium">
<Icon className="size-3.5" />
{t(`macros.stepTypes.${type}.label`)}
</span>
<span className="pl-5 text-[11px] leading-snug text-muted-foreground">
{t(`macros.stepTypes.${type}.help`)}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
function StepList({
steps,
onChange,
depth = 0,
}: {
steps: MacroStep[];
onChange: (steps: MacroStep[]) => void;
depth?: number;
}) {
const { t } = useTranslation();
const update = (index: number, step: MacroStep) => {
const next = [...steps];
next[index] = step;
onChange(next);
};
return (
<div className="flex flex-col gap-2">
{steps.map((step, index) => {
const Icon = STEP_ICONS[step.type];
return (
<div key={step.id} className="border border-border bg-muted/20">
<div className="flex items-center gap-2 border-b border-border/60 px-2 py-1.5">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xs font-medium">
{t(`macros.stepTypes.${step.type}.label`)}
</span>
<Button
size="icon"
variant="ghost"
className="ml-auto size-6 rounded-none"
aria-label={t("macros.removeStep")}
onClick={() => onChange(steps.filter((_, i) => i !== index))}
>
<Trash2 className="size-3" />
</Button>
</div>
<div className="space-y-2 p-2">
{step.type === "send" && (
<div className="space-y-2">
<Textarea
className="min-h-16 rounded-none font-mono text-xs"
placeholder={t("macros.send.textPlaceholder")}
value={step.text}
onChange={(event) =>
update(index, { ...step, text: event.target.value })
}
/>
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
<Checkbox
checked={step.pressEnter}
onCheckedChange={(checked) =>
update(index, { ...step, pressEnter: checked === true })
}
/>
{t("macros.send.pressEnter")}
</label>
</div>
)}
{step.type === "delay" && (
<SecondsInput
label={t("macros.delay.duration")}
valueMs={step.milliseconds}
minMs={0}
onChangeMs={(milliseconds) =>
update(index, { ...step, milliseconds })
}
/>
)}
{step.type === "wait" && (
<div className="space-y-2">
<MatchField
pattern={step.pattern}
isRegex={step.isRegex !== false}
label={t("macros.wait.pattern")}
placeholder={t("macros.wait.patternPlaceholder")}
onChange={(next) => update(index, { ...step, ...next })}
/>
<SecondsInput
label={t("macros.wait.timeout")}
valueMs={step.timeoutMs}
minMs={100}
onChangeMs={(timeoutMs) =>
update(index, { ...step, timeoutMs })
}
/>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
{t("macros.wait.onTimeout")}
</div>
<div className="flex gap-1">
{(["stop", "continue"] as const).map((mode) => (
<Button
key={mode}
size="sm"
variant={
step.onTimeout === mode ? "default" : "outline"
}
className="h-7 flex-1 rounded-none text-[11px]"
onClick={() =>
update(index, { ...step, onTimeout: mode })
}
>
{t(`macros.wait.${mode}`)}
</Button>
))}
</div>
</div>
</div>
)}
{step.type === "if" && (
<div className="space-y-3">
<MatchField
pattern={step.pattern}
isRegex={step.isRegex !== false}
label={t("macros.ifStep.pattern")}
placeholder={t("macros.ifStep.patternPlaceholder")}
onChange={(next) => update(index, { ...step, ...next })}
/>
<div className="border-l-2 border-emerald-500/40 pl-2">
<div className="mb-1 text-[11px] font-medium text-emerald-500">
{t("macros.ifStep.then")}
</div>
<StepList
steps={step.then}
depth={depth + 1}
onChange={(then) => update(index, { ...step, then })}
/>
</div>
<div className="border-l-2 border-amber-500/40 pl-2">
<div className="mb-1 text-[11px] font-medium text-amber-500">
{t("macros.ifStep.else")}
</div>
<StepList
steps={step.else}
depth={depth + 1}
onChange={(elseSteps) =>
update(index, { ...step, else: elseSteps })
}
/>
</div>
</div>
)}
{step.type === "repeat" && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("macros.repeat.count")}
</span>
<Input
className="h-7 w-20 rounded-none text-xs"
type="number"
min={1}
max={100}
value={step.count}
onChange={(event) =>
update(index, {
...step,
count: Math.max(
1,
Math.min(100, Number(event.target.value) || 1),
),
})
}
/>
<span className="text-muted-foreground">
{t("macros.repeat.times")}
</span>
</label>
<div className="border-l-2 border-border pl-2">
<StepList
steps={step.steps}
depth={depth + 1}
onChange={(nested) =>
update(index, { ...step, steps: nested })
}
/>
</div>
</div>
)}
</div>
</div>
);
})}
{depth < 4 && (
<AddStepMenu
compact={depth > 0}
onAdd={(type) => onChange([...steps, createStep(type)])}
/>
)}
</div>
);
}
/**
* Macros: a saved series of steps typed into the terminal the user is
* working in, waiting for and branching on what the server sends back.
* They are the user's own setting, so they follow them to every browser.
*/
export function MacrosPanel({ targetTab }: PanelProps) {
const { t } = useTranslation();
const settings = useSettings("user");
const [draft, setDraft] = useState<TerminalMacro | null>(null);
const [dirty, setDirty] = useState(false);
const [runningId, setRunningId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<TerminalMacro | null>(
null,
);
const [pendingRun, setPendingRun] = useState<TerminalMacro | null>(null);
const abortRef = useRef<AbortController | null>(null);
const confirmedRef = useRef<Set<string>>(new Set());
const movedLocalRef = useRef(false);
const sessions = useSyncExternalStore(subscribeSessions, sessionsSnapshot);
const macros = useMemo(
() => sanitizeTerminalMacros(settings.values.macros),
[settings.values.macros],
);
const { save: saveSettings } = settings;
useEffect(() => () => abortRef.current?.abort(), []);
// Macros a browser kept before 2.9.0 move into the setting once.
useEffect(() => {
if (!settings.loaded || movedLocalRef.current) return;
movedLocalRef.current = true;
const merged = mergeLocalMacros(macros);
if (!merged) return;
saveSettings({ macros: merged.macros })
.then(merged.clear)
.catch(() => toast.error(t("macros.saveFailed")));
}, [settings.loaded, macros, saveSettings, t]);
const target = useMemo(
() =>
sessions.find((session) => session.id === targetTab?.id) ?? sessions[0],
[sessions, targetTab?.id],
);
const updateDraft = useCallback((next: TerminalMacro) => {
setDraft(next);
setDirty(true);
}, []);
const createMacro = () => {
const now = new Date().toISOString();
setDraft({
id: id(),
name: "",
description: "",
steps: [],
createdAt: now,
updatedAt: now,
});
setDirty(true);
};
const persist = async (next: TerminalMacro[]) => {
await saveSettings({ macros: next });
};
const save = async () => {
if (!draft) return;
if (!draft.name.trim()) {
toast.error(t("macros.nameRequired"));
return;
}
const saved: TerminalMacro = {
...draft,
name: draft.name.trim(),
updatedAt: new Date().toISOString(),
};
try {
await persist(
macros.some((macro) => macro.id === saved.id)
? macros.map((macro) => (macro.id === saved.id ? saved : macro))
: [...macros, saved],
);
setDraft(saved);
setDirty(false);
toast.success(t("macros.saved"));
} catch (error) {
toast.error(getErrorMessage(error) || t("macros.saveFailed"));
}
};
const confirmDelete = async () => {
if (!pendingDelete) return;
const next = macros.filter((macro) => macro.id !== pendingDelete.id);
try {
await persist(next);
if (draft?.id === pendingDelete.id) {
setDraft(null);
setDirty(false);
}
toast.success(t("macros.deleted"));
} catch (error) {
toast.error(getErrorMessage(error) || t("macros.saveFailed"));
} finally {
setPendingDelete(null);
}
};
const execute = useCallback(
async (macro: TerminalMacro) => {
const terminal = target ? getSessionHandle(target.id) : null;
if (!terminal?.sendInput || !terminal.subscribeOutput) {
toast.error(t("macros.needTerminal"));
return;
}
const controller = new AbortController();
abortRef.current = controller;
setRunningId(macro.id);
try {
await runTerminalMacro(
macro,
{ send: terminal.sendInput, subscribe: terminal.subscribeOutput },
{ signal: controller.signal },
);
toast.success(t("macros.completed"));
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
toast.error(getErrorMessage(error) || t("macros.failed"));
}
} finally {
abortRef.current = null;
setRunningId(null);
}
},
[target, t],
);
const requestRun = (macro: TerminalMacro) => {
if (!target || !getSessionHandle(target.id)?.sendInput) {
toast.error(t("macros.needTerminal"));
return;
}
if (confirmedRef.current.has(macro.id)) {
void execute(macro);
return;
}
setPendingRun(macro);
};
const targetLabel = target?.label ?? t("macros.noTerminal");
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center gap-2 border-b border-border p-3">
{draft ? (
<Button
size="icon"
variant="ghost"
className="size-7 rounded-none"
aria-label={t("macros.back")}
onClick={() => {
setDraft(null);
setDirty(false);
}}
>
<ArrowLeft className="size-4" />
</Button>
) : (
<Braces className="size-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">{t("macros.title")}</div>
<div className="truncate text-[11px] text-muted-foreground">
{t("macros.target")}: {targetLabel}
</div>
{!draft && (
<a
href="https://docs.termix.site/features/terminal/macros"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
)}
</div>
{!draft && (
<Button
size="icon"
variant="outline"
className="size-7 rounded-none"
aria-label={t("macros.create")}
onClick={createMacro}
>
<Plus className="size-3.5" />
</Button>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{!draft ? (
macros.length === 0 ? (
<EmptyState
icon={Braces}
title={t("macros.empty")}
hint={t("macros.emptyHint")}
action={
<Button
size="sm"
variant="outline"
className="mt-1 rounded-none"
onClick={createMacro}
>
<Plus className="mr-1 size-3" />
{t("macros.create")}
</Button>
}
/>
) : (
<div className="flex flex-col gap-1">
{macros.map((macro) => {
const isRunning = runningId === macro.id;
return (
<div
key={macro.id}
className="group flex items-center gap-2 border border-border bg-muted/20 px-2 py-1.5 hover:bg-muted/40"
>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
setDraft(structuredClone(macro));
setDirty(false);
}}
>
<div className="truncate text-xs font-medium">
{macro.name}
</div>
{macro.description && (
<div className="truncate text-[11px] text-muted-foreground">
{macro.description}
</div>
)}
</button>
<Button
size="icon"
variant="ghost"
className="size-6 shrink-0 rounded-none"
aria-label={
isRunning ? t("macros.stop") : t("macros.run")
}
onClick={() =>
isRunning
? abortRef.current?.abort()
: requestRun(macro)
}
>
{isRunning ? (
<Square className="size-3" />
) : (
<Play className="size-3" />
)}
</Button>
</div>
);
})}
</div>
)
) : (
<div className="space-y-3">
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
{t("macros.name")}
</div>
<Input
className="h-8 rounded-none text-xs"
value={draft.name}
placeholder={t("macros.namePlaceholder")}
onChange={(event) =>
updateDraft({ ...draft, name: event.target.value })
}
/>
</div>
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
{t("macros.descriptionLabel")}
</div>
<Textarea
className="min-h-14 rounded-none text-xs"
value={draft.description ?? ""}
placeholder={t("macros.descriptionPlaceholder")}
onChange={(event) =>
updateDraft({ ...draft, description: event.target.value })
}
/>
</div>
{draft.steps.length === 0 && (
<div className="border border-dashed border-border px-3 py-4 text-center text-[11px] text-muted-foreground">
{t("macros.noSteps")}
</div>
)}
<StepList
steps={draft.steps}
onChange={(steps) => updateDraft({ ...draft, steps })}
/>
<div className="flex items-center gap-2 border-t border-border pt-3">
<Button
size="sm"
variant="destructive"
className="rounded-none"
onClick={() => setPendingDelete(draft)}
>
<Trash2 className="mr-1 size-3" />
{t("macros.delete")}
</Button>
<Button
size="sm"
variant="outline"
className="rounded-none"
onClick={save}
>
<Save className="mr-1 size-3" />
{t("macros.save")}
</Button>
<Button
size="sm"
className="ml-auto rounded-none"
onClick={() =>
runningId === draft.id
? abortRef.current?.abort()
: requestRun(draft)
}
>
{runningId === draft.id ? (
<>
<Square className="mr-1 size-3" />
{t("macros.stop")}
</>
) : (
<>
<Play className="mr-1 size-3" />
{t("macros.run")}
</>
)}
</Button>
</div>
{dirty && (
<div className="text-[11px] text-amber-500">
{t("macros.unsaved")}
</div>
)}
</div>
)}
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(open) => !open && setPendingDelete(null)}
>
<AlertDialogContent className="rounded-none">
<AlertDialogHeader>
<AlertDialogTitle>{t("macros.deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("macros.deleteBody", { name: pendingDelete?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-none">
{t("macros.cancel")}
</AlertDialogCancel>
<AlertDialogAction className="rounded-none" onClick={confirmDelete}>
{t("macros.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={pendingRun !== null}
onOpenChange={(open) => !open && setPendingRun(null)}
>
<AlertDialogContent className="rounded-none">
<AlertDialogHeader>
<AlertDialogTitle>{t("macros.confirmRunTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("macros.confirmRunBody", {
name: pendingRun?.name ?? "",
host: targetLabel,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-none">
{t("macros.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="rounded-none"
onClick={() => {
const macro = pendingRun;
setPendingRun(null);
if (macro) {
confirmedRef.current.add(macro.id);
void execute(macro);
}
}}
>
{t("macros.confirmRunAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,341 @@
export type MacroStep =
| { id: string; type: "send"; text: string; pressEnter: boolean }
| { id: string; type: "delay"; milliseconds: number }
| {
id: string;
type: "wait";
pattern: string;
isRegex?: boolean;
flags?: string;
timeoutMs: number;
onTimeout: "stop" | "continue";
}
| {
id: string;
type: "if";
pattern: string;
isRegex?: boolean;
flags?: string;
then: MacroStep[];
else: MacroStep[];
}
| { id: string; type: "repeat"; count: number; steps: MacroStep[] };
export interface TerminalMacro {
id: string;
name: string;
description?: string;
steps: MacroStep[];
createdAt: string;
updatedAt: string;
}
export interface MacroTerminalAdapter {
send(data: string): void;
subscribe(listener: (data: string) => void): () => void;
}
export type MacroRunEvent =
| { type: "step"; stepId: string }
| { type: "matched"; stepId: string; match: string }
| { type: "timeout"; stepId: string }
| { type: "complete" };
const MAX_OUTPUT = 100_000;
function bounded(value: number, minimum: number, maximum: number): number {
return Number.isFinite(value)
? Math.min(maximum, Math.max(minimum, Math.floor(value)))
: minimum;
}
function escapeLiteral(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function regex(pattern: string, flags = "", isRegex = true): RegExp {
const source = isRegex ? pattern : escapeLiteral(pattern);
return new RegExp(source, flags.replace(/[gy]/g, ""));
}
// Literal text is the default in the editor, so a bad regex should never kill a
// run. Fall back to matching the pattern literally instead.
function safeRegex(pattern: string, flags = "", isRegex = true): RegExp {
try {
return regex(pattern, flags, isRegex);
} catch {
return new RegExp(escapeLiteral(pattern), flags.replace(/[gy]/g, ""));
}
}
function abortError(): DOMException {
return new DOMException("Macro cancelled", "AbortError");
}
function delay(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(abortError());
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, milliseconds);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(abortError());
},
{ once: true },
);
});
}
export async function runTerminalMacro(
macro: TerminalMacro,
terminal: MacroTerminalAdapter,
options: {
signal?: AbortSignal;
onEvent?: (event: MacroRunEvent) => void;
} = {},
): Promise<void> {
let output = "";
let scanFrom = 0;
let wake: (() => void) | null = null;
const unsubscribe = terminal.subscribe((data) => {
const combined = output + data;
const removed = Math.max(0, combined.length - MAX_OUTPUT);
output = combined.slice(removed);
scanFrom = Math.max(0, scanFrom - removed);
wake?.();
});
const runSteps = async (steps: MacroStep[]): Promise<void> => {
for (const step of steps) {
if (options.signal?.aborted) throw abortError();
options.onEvent?.({ type: "step", stepId: step.id });
if (step.type === "send") {
scanFrom = output.length;
terminal.send(step.text + (step.pressEnter ? "\r" : ""));
continue;
}
if (step.type === "delay") {
await delay(bounded(step.milliseconds, 0, 300_000), options.signal);
continue;
}
if (step.type === "if") {
await runSteps(
safeRegex(step.pattern, step.flags, step.isRegex !== false).test(
output,
)
? step.then
: step.else,
);
continue;
}
if (step.type === "repeat") {
const count = bounded(step.count, 1, 100);
for (let index = 0; index < count; index += 1) {
await runSteps(step.steps);
}
continue;
}
const matcher = safeRegex(
step.pattern,
step.flags,
step.isRegex !== false,
);
const deadline = Date.now() + bounded(step.timeoutMs, 100, 300_000);
let match = matcher.exec(output.slice(scanFrom));
while (!match && Date.now() < deadline) {
await Promise.race([
new Promise<void>((resolve) => {
wake = resolve;
}),
delay(Math.min(250, deadline - Date.now()), options.signal),
]);
wake = null;
match = matcher.exec(output.slice(scanFrom));
}
if (match) {
scanFrom += match.index + match[0].length;
options.onEvent?.({
type: "matched",
stepId: step.id,
match: match[0],
});
} else {
options.onEvent?.({ type: "timeout", stepId: step.id });
if (step.onTimeout === "stop") {
throw new Error(`Timed out waiting for /${step.pattern}/`);
}
}
}
};
try {
await runSteps(macro.steps);
options.onEvent?.({ type: "complete" });
} finally {
unsubscribe();
}
}
/** Stored macros, bounded and cleaned; accepts the array or its JSON. */
export function sanitizeTerminalMacros(value: unknown): TerminalMacro[] {
if (value === null || value === undefined || value === "") return [];
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
if (!Array.isArray(parsed)) return [];
let remainingSteps = 500;
const sanitizeSteps = (input: unknown, depth: number): MacroStep[] => {
if (!Array.isArray(input) || depth > 4 || remainingSteps <= 0) return [];
return input.flatMap((raw): MacroStep[] => {
if (!raw || typeof raw !== "object" || remainingSteps-- <= 0) return [];
const step = raw as Record<string, unknown>;
const stepId =
typeof step.id === "string" ? step.id : `step-${remainingSteps}`;
if (step.type === "send") {
return [
{
id: stepId,
type: "send",
text:
typeof step.text === "string" ? step.text.slice(0, 65_536) : "",
pressEnter: step.pressEnter !== false,
},
];
}
if (step.type === "delay") {
return [
{
id: stepId,
type: "delay",
milliseconds: bounded(Number(step.milliseconds), 0, 300_000),
},
];
}
if (step.type === "wait") {
return [
{
id: stepId,
type: "wait",
pattern:
typeof step.pattern === "string"
? step.pattern.slice(0, 1000)
: "",
isRegex: step.isRegex !== false,
flags:
typeof step.flags === "string"
? step.flags.replace(/[^imsu]/g, "")
: undefined,
timeoutMs: bounded(Number(step.timeoutMs), 100, 300_000),
onTimeout: step.onTimeout === "continue" ? "continue" : "stop",
},
];
}
if (step.type === "if") {
return [
{
id: stepId,
type: "if",
pattern:
typeof step.pattern === "string"
? step.pattern.slice(0, 1000)
: "",
isRegex: step.isRegex !== false,
flags:
typeof step.flags === "string"
? step.flags.replace(/[^imsu]/g, "")
: undefined,
then: sanitizeSteps(step.then, depth + 1),
else: sanitizeSteps(step.else, depth + 1),
},
];
}
if (step.type === "repeat") {
return [
{
id: stepId,
type: "repeat",
count: bounded(Number(step.count), 1, 100),
steps: sanitizeSteps(step.steps, depth + 1),
},
];
}
return [];
});
};
return parsed.slice(0, 100).flatMap((raw): TerminalMacro[] => {
if (!raw || typeof raw !== "object") return [];
const macro = raw as Record<string, unknown>;
if (typeof macro.id !== "string" || typeof macro.name !== "string")
return [];
const now = new Date().toISOString();
return [
{
id: macro.id,
name: macro.name.slice(0, 200),
description:
typeof macro.description === "string"
? macro.description.slice(0, 2000)
: undefined,
steps: sanitizeSteps(macro.steps, 0),
createdAt:
typeof macro.createdAt === "string" ? macro.createdAt : now,
updatedAt:
typeof macro.updatedAt === "string" ? macro.updatedAt : now,
},
];
});
} catch {
return [];
}
}
export function parseTerminalMacros(value?: string | null): TerminalMacro[] {
return sanitizeTerminalMacros(value);
}
/** Where 2.8 kept macros for a user whose storage mode was local. */
export const LOCAL_MACROS_KEY = "terminalMacros";
/**
* The saved macros plus any this browser kept on its own before 2.9.0 that
* the saved list does not already have, or null when there is nothing to
* move. `clear` removes the browser copy once the merged list is saved.
*/
export function mergeLocalMacros(
saved: TerminalMacro[],
storage: Pick<Storage, "getItem" | "removeItem"> | null = safeStorage(),
): { macros: TerminalMacro[]; clear: () => void } | null {
let raw: string | null = null;
try {
raw = storage?.getItem(LOCAL_MACROS_KEY) ?? null;
} catch {
return null;
}
if (raw === null) return null;
const clear = () => {
try {
storage?.removeItem(LOCAL_MACROS_KEY);
} catch {
// storage unavailable
}
};
const known = new Set(saved.map((macro) => macro.id));
const extra = sanitizeTerminalMacros(raw).filter(
(macro) => !known.has(macro.id),
);
if (extra.length === 0) {
clear();
return null;
}
return { macros: [...saved, ...extra].slice(0, 100), clear };
}
function safeStorage(): Storage | null {
try {
return typeof localStorage === "undefined" ? null : localStorage;
} catch {
return null;
}
}
@@ -8,7 +8,6 @@ export function HostTerminalSection({
setField,
updateForm,
host,
snippets,
}: HostEditorSectionProps) {
return (
<>
@@ -18,7 +17,6 @@ export function HostTerminalSection({
setField={setField}
updateForm={updateForm}
host={host}
snippets={snippets}
/>
</>
);
@@ -59,22 +59,16 @@ 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.
* and saved with the host.
*/
export function HostTerminalSettings({
form: editorForm,
setField: setCoreField,
updateForm,
host,
snippets: rawSnippets,
}: Pick<
HostEditorSectionProps,
"form" | "setField" | "updateForm" | "host" | "snippets"
>) {
}: Pick<HostEditorSectionProps, "form" | "setField" | "updateForm" | "host">) {
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)?.[
@@ -91,8 +85,7 @@ export function HostTerminalSettings({
const form = {
...own,
...(own.inheritAppearance ? userDefaults : {}),
startupSnippetId: (editorForm?.startupSnippetId ?? null) as number | null,
} as HostTerminalValues & { startupSnippetId: number | null };
} as HostTerminalValues;
const writeValues = (values: Record<string, unknown>) =>
updateForm((current) => {
@@ -883,30 +876,6 @@ export function HostTerminalSettings({
/>
</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>
</>
@@ -74,7 +74,7 @@ import { toast } from "sonner";
import { Save } from "lucide-react";
import { TerminalToolbar } from "./TerminalToolbar.tsx";
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
import type { Host, Snippet, TabType } from "../types";
import type { Host, TabType } from "../types";
import { isTabKeyEvent } from "./terminal-key-event.ts";
import { installTouchWheelCoordinator } from "./touch-wheel-coordinator.ts";
import { loadTouchInputSettings } from "./touch-input-settings-store.ts";
@@ -85,6 +85,7 @@ import {
import { quoteTerminalImagePath } from "./terminal-image-path.ts";
import {
dispatchKeybindingAction,
isTerminalKeybindingAction,
sendRawToSocket,
} from "../lib/keybinding-dispatch";
import {
@@ -114,7 +115,6 @@ import {
Button,
hydrateLocalSharedHostAuth,
findMatchingKeybinding,
SnippetVariablesDialog,
type CustomKeybinding,
isElectron,
} from "@termix/plugin-sdk/ui";
@@ -124,6 +124,7 @@ import {
usePluginApi,
useSlotContributions,
getCustomKeybindings,
runKeybindingAction,
getClientPreference,
logActivity,
getHostPassword,
@@ -164,17 +165,6 @@ 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) {
@@ -291,11 +281,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
const [pendingKeybindingSnippet, setPendingKeybindingSnippet] = useState<{
id: string;
content: string;
appendEnter: boolean;
} | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -1966,7 +1951,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
| null
| undefined
)?.environmentVariables ?? [];
const startupSnippetId = startupSnippetOf(settingsHost);
if (environmentVariables.length > 0) {
for (const envVar of environmentVariables) {
@@ -1981,36 +1965,26 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}
if (startupSnippetId) {
try {
const resolved = (await invokeAction(
"snippets.resolveForTerminal",
startupSnippetId,
{
ip: hostConfig.ip,
username: hostConfig.username,
port: hostConfig.port,
name: hostConfig.name,
},
)) as
| { needsInputs: boolean; content: string }
| null
| undefined;
if (
resolved &&
!resolved.needsInputs &&
ws.readyState === 1
) {
ws.send(
JSON.stringify({
type: "input",
data: resolved.content + "\n",
}),
);
}
} catch (err) {
console.warn("Failed to execute startup snippet:", err);
// The snippets plugin owns a host's startup command; while it
// is off nothing runs.
try {
const startup = await invokeAction(
"snippets.startupCommand",
settingsHost,
{
ip: hostConfig.ip,
username: hostConfig.username,
port: hostConfig.port,
name: hostConfig.name,
},
);
if (typeof startup === "string" && ws.readyState === 1) {
ws.send(
JSON.stringify({ type: "input", data: startup + "\n" }),
);
}
} catch (err) {
console.warn("Failed to run the startup command:", err);
}
if (settings.autoMosh && ws.readyState === 1) {
@@ -2988,26 +2962,30 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
e,
customKeybindingsRef.current,
);
if (matched) {
// The terminal's own actions run here; anything else goes to
// whoever registered it (the shell, or another plugin), and the key
// passes through while nothing does.
const handled =
!!matched &&
(isTerminalKeybindingAction(matched.action.type)
? dispatchKeybindingAction(matched.action, {
terminal,
webSocketRef,
writeTextToClipboard,
readTextFromClipboard,
})
: runKeybindingAction(matched.action, {
host: {
ip: hostConfig.ip,
username: hostConfig.username,
port: hostConfig.port,
name: hostConfig.name,
},
send: (data) => sendRawToSocket(webSocketRef, data),
}));
if (handled) {
e.preventDefault();
e.stopPropagation();
dispatchKeybindingAction(matched.action, {
terminal,
webSocketRef,
writeTextToClipboard,
readTextFromClipboard,
hostContext: {
ip: hostConfig.ip,
username: hostConfig.username,
port: hostConfig.port,
name: hostConfig.name,
},
onSnippetNeedsInputs: (snippet) =>
setPendingKeybindingSnippet({
...snippet,
appendEnter: matched.action.appendEnter !== false,
}),
});
return false;
}
}
@@ -3834,51 +3812,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
/>
)}
{pendingKeybindingSnippet && (
<SnippetVariablesDialog
snippet={
{
id: 0,
name: t("newUi.sidebar.keybindings.actionRunSnippet"),
content: pendingKeybindingSnippet.content,
folder: null,
order: 0,
} as Snippet as never
}
host={{
ip: hostConfig.ip,
username: hostConfig.username,
port: hostConfig.port,
name: hostConfig.name,
}}
onCancel={() => setPendingKeybindingSnippet(null)}
onConfirm={(resolvedContent) => {
const appendEnter = pendingKeybindingSnippet.appendEnter;
setPendingKeybindingSnippet(null);
const send = () =>
sendRawToSocket(
webSocketRef,
resolvedContent + (appendEnter ? "\r" : ""),
);
const shouldConfirm =
localStorage.getItem("confirmSnippetExecution") === "true";
if (shouldConfirm) {
confirmWithToast(
t("newUi.sidebar.snippets.confirmRunMessage", {
name: t("newUi.sidebar.keybindings.actionRunSnippet"),
}),
send,
t("newUi.sidebar.snippets.confirmRunButton"),
t("newUi.sidebar.snippets.cancel"),
{ confirmOnEnter: true, duration: 6000 },
);
} else {
send();
}
}}
/>
)}
{tmuxSessionPicker && (
<TmuxSessionPicker
isOpen={true}