From c77301577f5a23ec23fe4c5d7aa9200e65ac869b Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 23 Sep 2026 08:59:24 -0500 Subject: [PATCH] feat: wire ai, automations, fleets and ssh-terminal to snippets (B2) --- plugins/ai/manifest.json | 8 +- plugins/ai/src/backend/services.ts | 116 ++++++++++++++++++ plugins/ai/src/backend/tools/executor.ts | 40 +++--- plugins/ai/src/backend/tools/read-tools.ts | 16 +-- plugins/automations/manifest.json | 10 ++ .../automations/src/backend/actions/index.ts | 21 ++-- plugins/automations/src/backend/index.ts | 3 + plugins/automations/src/backend/snippets.ts | 64 ++++++++++ .../fleets/src/backend/command-variables.ts | 55 +++++++++ plugins/fleets/src/backend/routes.ts | 4 +- .../src/frontend/HostMetricsTab.tsx | 13 +- .../tests/frontend/HostMetricsTab.test.tsx | 1 + .../src/frontend/TerminalTabWithRegistry.tsx | 68 ++++++++++ plugins/ssh-terminal/src/frontend/index.tsx | 22 +++- .../src/frontend/session-registry.ts | 61 +++++++++ .../tests/frontend/activate.test.tsx | 23 ++++ 16 files changed, 473 insertions(+), 52 deletions(-) create mode 100644 plugins/automations/src/backend/snippets.ts create mode 100644 plugins/fleets/src/backend/command-variables.ts create mode 100644 plugins/ssh-terminal/src/frontend/TerminalTabWithRegistry.tsx create mode 100644 plugins/ssh-terminal/src/frontend/session-registry.ts diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json index f1255ca9e..2eedf7dc5 100644 --- a/plugins/ai/manifest.json +++ b/plugins/ai/manifest.json @@ -31,7 +31,8 @@ }, "optionalDependencies": { "workspaces": "^1.0.0", - "network-topology": "^1.0.0" + "network-topology": "^1.0.0", + "snippets": "^1.0.0" }, "requires": [ { @@ -43,6 +44,11 @@ "service": "network-topology.graph", "versionRange": "^1.0.0", "optional": true + }, + { + "service": "snippets.access", + "versionRange": "^1.0.0", + "optional": true } ], "providesSecret": [ diff --git a/plugins/ai/src/backend/services.ts b/plugins/ai/src/backend/services.ts index e3c9311c3..566d43bef 100644 --- a/plugins/ai/src/backend/services.ts +++ b/plugins/ai/src/backend/services.ts @@ -51,3 +51,119 @@ export async function getNetworkTopology( return null; } } + +export interface SnippetSummary { + id: number; + name: string; + content: string; + description: string | null; + isNote: boolean; + folder: string | null; +} + +/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */ +interface SnippetsAccess { + list: () => Promise; + get: (id: number) => Promise<{ + id: number; + name: string; + content: string; + isNote: boolean; + } | null>; + create: (input: { + name: string; + content: string; + description?: string | null; + folder?: string | null; + }) => Promise<{ id: number; name: string }>; + update: ( + id: number, + changes: { + name?: string; + content?: string; + description?: string | null; + folder?: string | null; + }, + ) => Promise; + remove: (id: number) => Promise; +} + +/** + * The user's own snippets, or null when the snippets plugin is off or the + * user may not use it. Optional: the list_snippets tool disappears without + * it, and the propose_*_snippet tools fail with a clear message. + */ +export async function listSnippets( + userId: string, +): Promise { + if (!current) return null; + try { + return await current + .get("snippets.access", { userId }) + .list(); + } catch { + return null; + } +} + +export async function getSnippet( + userId: string, + id: number, +): Promise<{ + id: number; + name: string; + content: string; + isNote: boolean; +} | null> { + if (!current) return null; + try { + return await current + .get("snippets.access", { userId }) + .get(id); + } catch { + return null; + } +} + +/** + * Requires the snippets plugin, unlike the read helpers above: a failed + * proposal apply must surface as an error the user sees, not silently no-op. + */ +function requireSnippetsAccess(userId: string): SnippetsAccess { + if (!current) { + throw new Error("The snippets plugin is disabled"); + } + return current.get("snippets.access", { userId }); +} + +export async function createSnippet( + userId: string, + input: { + name: string; + content: string; + description?: string | null; + folder?: string | null; + }, +): Promise<{ id: number; name: string }> { + return requireSnippetsAccess(userId).create(input); +} + +export async function updateSnippet( + userId: string, + id: number, + changes: { + name?: string; + content?: string; + description?: string | null; + folder?: string | null; + }, +): Promise { + return requireSnippetsAccess(userId).update(id, changes); +} + +export async function deleteSnippet( + userId: string, + id: number, +): Promise { + return requireSnippetsAccess(userId).remove(id); +} diff --git a/plugins/ai/src/backend/tools/executor.ts b/plugins/ai/src/backend/tools/executor.ts index dd20cf524..6f732403a 100644 --- a/plugins/ai/src/backend/tools/executor.ts +++ b/plugins/ai/src/backend/tools/executor.ts @@ -2,13 +2,18 @@ import { createCurrentAutomationRepository, createCurrentFleetRepository, createCurrentHostRepository, - createCurrentSnippetRepository, } from "../../../../../src/backend/database/repositories/factory.js"; import { validateDefinition } from "../../../../automations/src/backend/routes.js"; import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js"; import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { withSshConnection } from "../ssh.js"; import { getTool } from "./catalog.js"; +import { + createSnippet, + updateSnippet, + deleteSnippet, + getSnippet, +} from "../services.js"; /** Approved commands get a bounded window rather than hanging the request. */ const COMMAND_TIMEOUT_MS = 60_000; @@ -125,18 +130,15 @@ export async function applyProposal( } case "propose_create_snippet": { - const created = await createCurrentSnippetRepository().createSnippet( - userId, - { - name: requireString(payload.name, "name"), - content: requireString(payload.content, "content"), - description: optionalString(payload.description), - folder: optionalString(payload.folder), - } as any, - ); + const created = await createSnippet(userId, { + name: requireString(payload.name, "name"), + content: requireString(payload.content, "content"), + description: optionalString(payload.description), + folder: optionalString(payload.folder), + }); return { ok: true, - summary: `Created snippet ${(created as any)?.name ?? ""}`.trim(), + summary: `Created snippet ${created.name}`.trim(), }; } @@ -144,10 +146,7 @@ export async function applyProposal( const snippetId = requireNumber(payload.snippetId, "snippetId"); const changes = (payload.changes ?? {}) as Record; - const existing = await createCurrentSnippetRepository().findOwnedById( - userId, - snippetId, - ); + const existing = await getSnippet(userId, snippetId); if (!existing) throw new Error("Snippet not found"); const updates: Record = {}; @@ -164,20 +163,13 @@ export async function applyProposal( return { ok: false, summary: "Nothing to change" }; } - await createCurrentSnippetRepository().updateSnippet( - userId, - snippetId, - updates as any, - ); + await updateSnippet(userId, snippetId, updates); return { ok: true, summary: `Updated snippet ${snippetId}` }; } case "propose_delete_snippet": { const snippetId = requireNumber(payload.snippetId, "snippetId"); - const deleted = await createCurrentSnippetRepository().deleteSnippet( - userId, - snippetId, - ); + const deleted = await deleteSnippet(userId, snippetId); if (!deleted) throw new Error("Snippet not found"); return { ok: true, summary: `Deleted snippet ${snippetId}` }; } diff --git a/plugins/ai/src/backend/tools/read-tools.ts b/plugins/ai/src/backend/tools/read-tools.ts index e5a2fedf2..e585a1769 100644 --- a/plugins/ai/src/backend/tools/read-tools.ts +++ b/plugins/ai/src/backend/tools/read-tools.ts @@ -5,10 +5,13 @@ import { createCurrentFleetRepository, createCurrentHomepageItemRepository, createCurrentHostRepository, - createCurrentSnippetRepository, } from "../../../../../src/backend/database/repositories/factory.js"; import { num, objectSchema, type AiTool } from "./types.js"; -import { listSavedWorkspaces, getNetworkTopology } from "../services.js"; +import { + listSavedWorkspaces, + getNetworkTopology, + listSnippets, +} from "../services.js"; /** * Read tools project explicit fields rather than spreading rows. Redaction runs @@ -103,15 +106,14 @@ export const readTools: AiTool[] = [ { name: "list_snippets", description: - "List the user's saved command snippets, including their folder and the command text.", + "List the user's saved command snippets, including their folder and the command text. Unavailable if the snippets plugin is disabled.", category: "read", parameters: objectSchema({}), handler: async (_args, context) => { - const snippets = await createCurrentSnippetRepository().listOwnedSnippets( - context.userId, - ); + const snippets = await listSnippets(context.userId); + if (!snippets) return { unavailable: true, snippets: [] }; return { - snippets: snippets.map((snippet: any) => ({ + snippets: snippets.map((snippet) => ({ id: snippet.id, name: snippet.name, content: snippet.content, diff --git a/plugins/automations/manifest.json b/plugins/automations/manifest.json index 0451d5a37..b25fd90e9 100644 --- a/plugins/automations/manifest.json +++ b/plugins/automations/manifest.json @@ -26,6 +26,16 @@ "network:serve", "ui:surface" ], + "optionalDependencies": { + "snippets": "^1.0.0" + }, + "requires": [ + { + "service": "snippets.access", + "versionRange": "^1.0.0", + "optional": true + } + ], "contributes": { "tabs": [ { diff --git a/plugins/automations/src/backend/actions/index.ts b/plugins/automations/src/backend/actions/index.ts index 25b8639ea..aeecbbcf6 100644 --- a/plugins/automations/src/backend/actions/index.ts +++ b/plugins/automations/src/backend/actions/index.ts @@ -8,11 +8,8 @@ import { shellSingleQuote, } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { withSshConnection } from "../ssh.js"; -import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js"; -import { - createCurrentNotificationChannelRepository, - createCurrentSnippetRepository, -} from "../../../../../src/backend/database/repositories/factory.js"; +import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js"; +import { getSnippet, resolveSnippetCommandFor } from "../snippets.js"; import { sendAutomationNotification } from "../notify.js"; import { automationFetch } from "../http.js"; import { renderRecord, renderTemplate } from "../template.js"; @@ -176,11 +173,9 @@ async function runSnippet( step: Extract, context: StepExecutionContext, ): Promise { - const snippet = await createCurrentSnippetRepository() - .findOwnedById(context.userId, step.snippetId) - .catch(() => null); + const snippet = await getSnippet(context.userId, step.snippetId); - if (!snippet) return fail("Snippet not found"); + if (!snippet) return fail("Snippet not found, or the snippets plugin is off"); if (snippet.isNote) return fail("Notes cannot be executed on a host"); // Template values only ever reach the snippet through inputValues, never by @@ -189,8 +184,9 @@ async function runSnippet( const inputValues = renderRecord(step.inputValues, context.template) ?? {}; return runOnTargets(step.hostSelector, context, async (target) => { - const command = resolveSnippetCommand( - snippet.content, + const command = await resolveSnippetCommandFor( + context.userId, + step.snippetId, { ip: target.host.ip, username: target.host.username, @@ -199,6 +195,9 @@ async function runSnippet( }, inputValues, ); + if (!command) { + return { output: "", error: "Snippet could not be resolved" }; + } if (context.dryRun) { return { output: `Would run snippet on ${target.name}: ${command}` }; diff --git a/plugins/automations/src/backend/index.ts b/plugins/automations/src/backend/index.ts index 638751542..ef67ba29f 100644 --- a/plugins/automations/src/backend/index.ts +++ b/plugins/automations/src/backend/index.ts @@ -1,5 +1,6 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; import { setPluginSsh } from "./ssh.js"; +import { setPluginServices } from "./snippets.js"; import { startAutomationsService, stopAutomationsService } from "./routes.js"; import { startAutomationScheduler, @@ -9,6 +10,8 @@ import { export async function activate(ctx: PluginContext) { setPluginSsh(ctx.ssh); ctx.disposables.add(() => setPluginSsh(null)); + setPluginServices(ctx.services); + ctx.disposables.add(() => setPluginServices(null)); startAutomationsService( // The webhook route authenticates on its own per-automation token rather // than a session, which is the point of an inbound webhook. diff --git a/plugins/automations/src/backend/snippets.ts b/plugins/automations/src/backend/snippets.ts new file mode 100644 index 000000000..db77901b3 --- /dev/null +++ b/plugins/automations/src/backend/snippets.ts @@ -0,0 +1,64 @@ +import type { PluginServices } from "@termix/plugin-sdk/backend"; + +let current: PluginServices | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setPluginServices(services: PluginServices | null): void { + current = services; +} + +/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */ +interface SnippetsAccess { + get: (id: number) => Promise<{ + id: number; + name: string; + content: string; + isNote: boolean; + } | null>; + resolveCommand: ( + id: number, + vars: { ip?: string; username?: string; port?: number; name?: string }, + inputValues?: Record, + ) => Promise; +} + +/** + * A snippet's command, resolved against a target host and run as userId. + * Null when the snippets plugin is off, the user may not use it, or the + * snippet does not exist. Optional: the run_snippet step fails with a clear + * message without it, rather than the plugin failing to activate. + */ +export async function resolveSnippetCommandFor( + userId: string, + id: number, + vars: { ip?: string; username?: string; port?: number; name?: string }, + inputValues?: Record, +): Promise { + if (!current) return null; + try { + return await current + .get("snippets.access", { userId }) + .resolveCommand(id, vars, inputValues); + } catch { + return null; + } +} + +export async function getSnippet( + userId: string, + id: number, +): Promise<{ + id: number; + name: string; + content: string; + isNote: boolean; +} | null> { + if (!current) return null; + try { + return await current + .get("snippets.access", { userId }) + .get(id); + } catch { + return null; + } +} diff --git a/plugins/fleets/src/backend/command-variables.ts b/plugins/fleets/src/backend/command-variables.ts new file mode 100644 index 000000000..1285218a5 --- /dev/null +++ b/plugins/fleets/src/backend/command-variables.ts @@ -0,0 +1,55 @@ +export interface CommandHostVars { + ip?: string; + username?: string; + port?: number | string; + name?: string; +} + +const INPUT_PATTERN = + /\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g; + +function replaceVar(content: string, name: string, value?: string): string { + if (value === undefined) return content; + const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g"); + return content.replace(pattern, value); +} + +/** + * $HOST/$USER/$PORT/$NAME/$INPUT_n substitution for an ad hoc fleet command, + * typed directly into the "run on fleet" box rather than loaded from a saved + * snippet. Mirrors the snippets plugin's own resolveSnippetCommand and the + * frontend's src/ui/lib/snippet-variables.ts - kept as a small standalone + * copy rather than a cross-plugin dependency for one pure function with no + * snippet data of its own to fetch. + */ +export function resolveCommandVariables( + content: string, + host: CommandHostVars | null, + inputValues: Record = {}, +): string { + let resolved = content; + + resolved = replaceVar(resolved, "HOST", host?.ip); + resolved = replaceVar(resolved, "USER", host?.username); + resolved = replaceVar( + resolved, + "PORT", + host?.port !== undefined ? String(host.port) : undefined, + ); + resolved = replaceVar(resolved, "NAME", host?.name); + + resolved = resolved.replace( + INPUT_PATTERN, + ( + fullMatch, + braceDigits: string | undefined, + _label, + plainDigits: string | undefined, + ) => { + const key = `INPUT_${braceDigits ?? plainDigits}`; + return key in inputValues ? inputValues[key] : fullMatch; + }, + ); + + return resolved; +} diff --git a/plugins/fleets/src/backend/routes.ts b/plugins/fleets/src/backend/routes.ts index c130a3f14..fb0446aea 100644 --- a/plugins/fleets/src/backend/routes.ts +++ b/plugins/fleets/src/backend/routes.ts @@ -35,7 +35,7 @@ import { } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { buildPackageActionCommand } from "../../../../src/backend/hosts/metrics-shared/package-commands.js"; import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.js"; -import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js"; +import { resolveCommandVariables } from "./command-variables.js"; export const router = express.Router(); @@ -900,7 +900,7 @@ router.post( return { success: false, error: "Host not found" }; } - const resolvedCommand = resolveSnippetCommand( + const resolvedCommand = resolveCommandVariables( command, { ip: fullHost.ip, diff --git a/plugins/host-metrics/src/frontend/HostMetricsTab.tsx b/plugins/host-metrics/src/frontend/HostMetricsTab.tsx index 725e57f10..f97a91442 100644 --- a/plugins/host-metrics/src/frontend/HostMetricsTab.tsx +++ b/plugins/host-metrics/src/frontend/HostMetricsTab.tsx @@ -16,11 +16,20 @@ import { getSSHHosts, type ServerMetrics, } from "@/main-axios.ts"; -import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog"; +import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui"; import { useAreaPreferences } from "@/contexts/UiPreferencesContext"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { hasSnippetInputs } from "@/lib/snippet-variables.ts"; import type { Snippet } from "@/types/ui-types.ts"; + +// Mirrors the snippets plugin's own hasSnippetInputs: a pure check for +// whether a snippet still has $INPUT_n placeholders once host variables are +// substituted, so the quick-action dialog knows to ask before running. +const SNIPPET_INPUT_PATTERN = + /\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g; +function hasSnippetInputs(content: string): boolean { + SNIPPET_INPUT_PATTERN.lastIndex = 0; + return SNIPPET_INPUT_PATTERN.test(content); +} import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx"; import { useTabsSafe } from "@/shell/TabContext.tsx"; import { useTranslation } from "@termix/plugin-sdk/frontend"; diff --git a/plugins/host-metrics/tests/frontend/HostMetricsTab.test.tsx b/plugins/host-metrics/tests/frontend/HostMetricsTab.test.tsx index fefc079bd..1d9056536 100644 --- a/plugins/host-metrics/tests/frontend/HostMetricsTab.test.tsx +++ b/plugins/host-metrics/tests/frontend/HostMetricsTab.test.tsx @@ -5,6 +5,7 @@ import type { ServerMetrics } from "@/main-axios"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), + initReactI18next: { type: "3rdParty", init: () => {} }, })); const SAMPLE = { diff --git a/plugins/ssh-terminal/src/frontend/TerminalTabWithRegistry.tsx b/plugins/ssh-terminal/src/frontend/TerminalTabWithRegistry.tsx new file mode 100644 index 000000000..098f59b3e --- /dev/null +++ b/plugins/ssh-terminal/src/frontend/TerminalTabWithRegistry.tsx @@ -0,0 +1,68 @@ +import { useEffect, useRef } from "react"; +import type { TabProps } from "@termix/plugin-sdk/frontend"; +import { TerminalTabContent } from "@/features/terminal/TerminalTabContent"; +import type { TabRenderProps } from "@/shell/tab-registry"; +import type { TerminalHandle } from "@/features/terminal/terminal-types"; +import { registerSession, setActiveSession } from "./session-registry"; + +/** + * Wraps the core terminal tab component to register its ref in the session + * registry, so terminal.sendToActive/sendToSession (used by the snippets + * plugin, among others) can reach a live session without core exposing + * terminalRef through the public tab props. + */ +export function TerminalTabWithRegistry(props: TabProps) { + const tabRecord = props.tab as unknown as { + id: string; + terminalRef?: { current: TerminalHandle | null }; + }; + const host = props.host as + | { + id: string; + name?: string; + ip?: string; + username?: string; + port?: number; + } + | undefined; + const registeredRef = useRef<(() => void) | null>(null); + + useEffect(() => { + let cancelled = false; + let attempts = 0; + + const tryRegister = () => { + if (cancelled || registeredRef.current) return; + const ref = tabRecord.terminalRef?.current; + if (!ref) { + if (attempts++ < 40) setTimeout(tryRegister, 250); + return; + } + registeredRef.current = registerSession( + { + id: tabRecord.id, + hostId: host ? Number(host.id) || null : null, + hostName: host?.name, + ip: host?.ip, + username: host?.username, + port: host?.port, + }, + ref, + ); + }; + tryRegister(); + + return () => { + cancelled = true; + registeredRef.current?.(); + registeredRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tabRecord.id]); + + useEffect(() => { + if (props.isFocusedPane) setActiveSession(tabRecord.id); + }, [props.isFocusedPane, tabRecord.id]); + + return ; +} diff --git a/plugins/ssh-terminal/src/frontend/index.tsx b/plugins/ssh-terminal/src/frontend/index.tsx index 87aed4eee..f44e8346a 100644 --- a/plugins/ssh-terminal/src/frontend/index.tsx +++ b/plugins/ssh-terminal/src/frontend/index.tsx @@ -5,16 +5,15 @@ import type { TabProps, TermixApp, } from "@termix/plugin-sdk/frontend"; -import { - TerminalTabContent, - loadTerminal, -} from "@/features/terminal/TerminalTabContent"; +import { loadTerminal } from "@/features/terminal/TerminalTabContent"; import TerminalApp from "@/features/terminal/TerminalApp"; import { TERMINAL_DOCK_SLOT, TERMINAL_OVERLAY_SLOT, TERMINAL_TOOLBAR_SLOT, } from "@/features/terminal/terminal-slots"; +import { TerminalTabWithRegistry } from "./TerminalTabWithRegistry"; +import { listSessions, sendToActive, sendToSession } from "./session-registry"; /** `?view=terminal` full-screen links. */ function TerminalStandalone({ hostId, params }: StandaloneViewProps) { @@ -33,7 +32,7 @@ function TerminalStandalone({ hostId, params }: StandaloneViewProps) { export function activate(app: TermixApp): void { app.registerTab( "terminal", - TerminalTabContent as unknown as ComponentType, + TerminalTabWithRegistry as unknown as ComponentType, { icon: Terminal, titleKey: "nav.terminal", @@ -63,4 +62,17 @@ export function activate(app: TermixApp): void { app.declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] }); app.declareActionSlot({ id: TERMINAL_DOCK_SLOT, accepts: ["component"] }); app.declareActionSlot({ id: TERMINAL_OVERLAY_SLOT, accepts: ["component"] }); + + // Lets another plugin (snippets) push text into a live terminal session + // without reaching into core's tab state itself. + app.registerAction("terminal.listSessions", () => listSessions()); + app.registerAction("terminal.sendToActive", (( + text: string, + opts?: { run?: boolean }, + ) => sendToActive(text, opts)) as never); + app.registerAction("terminal.sendToSession", (( + sessionId: string, + text: string, + opts?: { run?: boolean }, + ) => sendToSession(sessionId, text, opts)) as never); } diff --git a/plugins/ssh-terminal/src/frontend/session-registry.ts b/plugins/ssh-terminal/src/frontend/session-registry.ts new file mode 100644 index 000000000..841ec9d92 --- /dev/null +++ b/plugins/ssh-terminal/src/frontend/session-registry.ts @@ -0,0 +1,61 @@ +import type { TerminalHandle } from "@/features/terminal/terminal-types"; + +export interface TerminalSessionInfo { + id: string; + hostId: number | null; + hostName?: string; + ip?: string; + username?: string; + port?: number; +} + +interface Session extends TerminalSessionInfo { + ref: TerminalHandle; +} + +const sessions = new Map(); +let activeId: string | null = null; + +/** Called by the terminal tab wrapper on mount/unmount and focus change. */ +export function registerSession( + info: TerminalSessionInfo, + ref: TerminalHandle, +): () => void { + sessions.set(info.id, { ...info, ref }); + return () => { + sessions.delete(info.id); + if (activeId === info.id) activeId = null; + }; +} + +export function setActiveSession(id: string | null): void { + activeId = id; +} + +export function listSessions(): TerminalSessionInfo[] { + return [...sessions.values()].map(({ ref: _ref, ...info }) => info); +} + +function send(session: Session, text: string, run?: boolean): void { + if (run) session.ref.sendInput(text + "\r"); + else session.ref.paste(text); +} + +export function sendToActive(text: string, opts?: { run?: boolean }): boolean { + if (!activeId) return false; + const session = sessions.get(activeId); + if (!session) return false; + send(session, text, opts?.run); + return true; +} + +export function sendToSession( + sessionId: string, + text: string, + opts?: { run?: boolean }, +): boolean { + const session = sessions.get(sessionId); + if (!session) return false; + send(session, text, opts?.run); + return true; +} diff --git a/plugins/ssh-terminal/tests/frontend/activate.test.tsx b/plugins/ssh-terminal/tests/frontend/activate.test.tsx index 814097280..74cae956e 100644 --- a/plugins/ssh-terminal/tests/frontend/activate.test.tsx +++ b/plugins/ssh-terminal/tests/frontend/activate.test.tsx @@ -50,4 +50,27 @@ describe(`${manifest.id} activate`, () => { expect(app.registered.settingsComponents()).toEqual([]); expect(app.registered.actions()).toEqual([]); }); + + it("registers the terminal session actions snippets and other plugins call into", async () => { + rendered = await renderWithApp(plugin, { manifest, locales }); + expect(rendered.registered.actions()).toEqual( + expect.arrayContaining([ + "terminal.listSessions", + "terminal.sendToActive", + "terminal.sendToSession", + ]), + ); + }); + + it("terminal.sendToActive and terminal.listSessions report no session when none is registered", async () => { + rendered = await renderWithApp(plugin, { manifest, locales }); + await expect( + rendered.app.invokeAction("terminal.listSessions"), + ).resolves.toEqual([]); + await expect( + rendered.app.invokeAction("terminal.sendToActive", "echo hi", { + run: true, + }), + ).resolves.toBe(false); + }); });