feat: add companion panels beside host tabs

This commit is contained in:
LukeGus committed 2026-10-19 21:46:02 -05:00
1 parent 3b3fd7f135
commit 802b3003d5
12 files changed
+706 -5

No files matched your search

+1
View File
@@ -27,6 +27,7 @@
- Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user
- FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine
- Pasting a security key file now explains how to use it instead of failing to decode it
- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it
### Changed
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 1.0.17
Plugin API 1.9.0.
### Added
- `app.registerCompanion` and `app.toggleCompanion`, so a plugin can add a panel beside any host tab. The user opens, resizes and moves it, and the tab keeps running
## 1.0.16
Plugin API 1.8.0.
+2 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.16 | Plugin API 1.8.0
SDK 1.0.17 | Plugin API 1.9.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -495,6 +495,7 @@ your plugin's error boundary and Suspense.
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `registerRailItem` | A sidebar rail button |
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
| `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle |
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
| `registerHostAction` | A host button, palette entry or default connect |
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.16",
"version": "1.0.17",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
+52
View File
@@ -814,6 +814,46 @@ export interface SettingsComponentProps {
running: boolean;
}
/** What a companion starts with. Read from the plugin's own settings. */
export interface CompanionDefaults {
/** Open by itself the first time a matching tab shows. */
open?: boolean;
side?: "left" | "right" | "bottom";
/** Pixels of width, or height at the bottom. */
size?: number;
/** Show the edge button that opens and closes it. Default true. */
edgeButton?: boolean;
}
export interface CompanionProps {
/** The tab it sits beside. */
tab: PluginTabRecord;
host?: PluginHostRecord;
/** The host in the shape the connection APIs take. */
sshHost?: Record<string, unknown>;
/** The tab's surface handle (see TabHandle), read at call time. */
getHandle: () => TabHandle | null;
side: "left" | "right" | "bottom";
/** False while the tab is in the background or the panel is closed. */
isVisible: boolean;
close: () => void;
setSide: (side: "left" | "right" | "bottom") => void;
}
export interface CompanionOptions {
icon?: IconComponent;
titleKey?: string;
order?: number;
/** Tab types it applies to. Any tab with a host when empty. */
tabTypes?: string[];
when?: (tab: PluginTabRecord, host?: PluginHostRecord) => boolean;
/**
* A hook returning the starting state. It runs in the plugin's scope, so it
* can read the plugin's settings.
*/
useDefaults?: () => CompanionDefaults;
}
export type ActionHandler = (...args: never[]) => unknown;
export interface ActionSlotDefinition {
@@ -1068,6 +1108,18 @@ export interface TermixApp extends TermixAppInfo {
component: ComponentType<TabProps>,
options?: TabOptions,
) => Disposer;
/**
* A panel that sits beside any host tab the options match (a terminal, a
* desktop, ...), bound to that tab's host. The user opens, resizes and moves
* it; the tab keeps running underneath.
*/
registerCompanion: (
id: string,
component: ComponentType<CompanionProps>,
options?: CompanionOptions,
) => Disposer;
/** Opens a companion on a tab, or closes it when it is showing. */
toggleCompanion: (tab: PluginTabRecord, id?: string) => void;
registerHostEditorSection: (
section: HostEditorSectionContribution,
) => Disposer;
+1 -1
View File
@@ -37,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.8.0";
export const PLUGIN_API_VERSION = "1.9.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
+5
View File
@@ -235,6 +235,11 @@
"ready": "Termix {{version}} is ready to install",
"readyHint": "It installs the next time Termix restarts."
},
"companion": {
"close": "Close panel",
"moveSide": "Move panel",
"resize": "Resize panel"
},
"nav": {
"home": "Home",
"terminal": "Terminal",
+48 -1
View File
@@ -1,4 +1,5 @@
import type { ComponentType } from "react";
import { useEffect, type ComponentType } from "react";
import type { Tab } from "@/types/ui-types";
import type {
Disposer,
PluginManifest,
@@ -10,6 +11,12 @@ import {
} from "@/api/plugins-api";
import { registerRailItem } from "@/sidebar/rail-items";
import { registerTabType, type TabRenderProps } from "@/shell/tab-registry";
import {
registerCompanionDef,
toggleCompanion,
type CompanionDefaults,
type CompanionRenderProps,
} from "@/shell/companions";
import { registerPanel, type PanelRenderProps } from "@/shell/panel-registry";
import {
registerHostEditorSection,
@@ -225,6 +232,46 @@ export function createPluginApp(
);
},
registerCompanion(id, component, options = {}) {
const useDefaults = options.useDefaults;
const Defaults = useDefaults
? scoped(function CompanionDefaultsReader({
onValue,
}: {
onValue: (value: CompanionDefaults) => void;
}) {
const value = useDefaults();
const { open, side, size, edgeButton } = value;
useEffect(() => {
onValue({ open, side, size, edgeButton });
}, [onValue, open, side, size, edgeButton]);
return null;
})
: undefined;
return track(
registerCompanionDef({
id: `${pluginId}.${id}`,
pluginId,
component: scoped(
component as unknown as ComponentType<CompanionRenderProps>,
),
icon: withIconBoundary(pluginId, options.icon as never) as never,
titleKey: options.titleKey ? key(options.titleKey) : undefined,
order: options.order,
tabTypes: options.tabTypes,
when: options.when as never,
Defaults,
}),
);
},
toggleCompanion(tab, id) {
toggleCompanion(
tab as unknown as Tab,
id ? `${pluginId}.${id}` : undefined,
);
},
registerTab(type, component, options = {}) {
requireDeclared("tab", type, "tab type");
return track(
+345
View File
@@ -0,0 +1,345 @@
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDownToLine,
ArrowLeftToLine,
ArrowRightToLine,
PanelRight,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useIsMobile } from "@/hooks/use-mobile";
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
import type { Tab } from "@/types/ui-types";
import {
companionApplies,
companionKey,
patchCompanionState,
useCompanionDefs,
useCompanionState,
type CompanionDef,
type CompanionDefaults,
type CompanionSide,
} from "./companions";
const DEFAULT_SIDE_SIZE = 360;
const DEFAULT_BOTTOM_SIZE = 280;
const MIN_SIZE = 200;
function DefaultsReader({
def,
onValue,
}: {
def: CompanionDef;
onValue: (id: string, value: CompanionDefaults) => void;
}) {
const Reader = def.Defaults;
const report = useCallback(
(value: CompanionDefaults) => onValue(def.id, value),
[def.id, onValue],
);
return Reader ? <Reader onValue={report} /> : null;
}
function sideIcon(side: CompanionSide) {
if (side === "left") return ArrowLeftToLine;
if (side === "bottom") return ArrowDownToLine;
return ArrowRightToLine;
}
/**
* Puts a pane beside a tab for the companions that apply to it. The tab keeps
* its place in the tree, so opening or closing a companion never remounts it.
*/
export const CompanionFrame = memo(function CompanionFrame({
tab,
isVisible,
children,
}: {
tab: Tab;
isVisible: boolean;
children: ReactNode;
}) {
const defs = useCompanionDefs();
const applicable = useMemo(
() => defs.filter((def) => companionApplies(def, tab)),
// tab.host and tab.type decide it
// eslint-disable-next-line react-hooks/exhaustive-deps
[defs, tab.type, tab.host],
);
if (applicable.length === 0) return <>{children}</>;
return (
<CompanionLayout tab={tab} defs={applicable} isVisible={isVisible}>
{children}
</CompanionLayout>
);
});
function CompanionLayout({
tab,
defs,
isVisible,
children,
}: {
tab: Tab;
defs: CompanionDef[];
isVisible: boolean;
children: ReactNode;
}) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const key = companionKey(tab);
const state = useCompanionState(key);
const [defaults, setDefaults] = useState<Record<string, CompanionDefaults>>(
{},
);
const [dragging, setDragging] = useState(false);
// A companion stays mounted once opened, so it keeps its place and session.
const [mounted, setMounted] = useState<Set<string>>(new Set());
const rootRef = useRef<HTMLDivElement>(null);
const onDefaults = useCallback((id: string, value: CompanionDefaults) => {
setDefaults((prev) => {
const old = prev[id];
if (
old &&
old.open === value.open &&
old.side === value.side &&
old.size === value.size &&
old.edgeButton === value.edgeButton
) {
return prev;
}
return { ...prev, [id]: value };
});
}, []);
// The first time a tab shows, a companion can open itself.
useEffect(() => {
if (state.seen || !isVisible) return;
const ready = defs.every(
(def) => !def.Defaults || defaults[def.id] !== undefined,
);
if (!ready) return;
const auto = defs.find((def) => defaults[def.id]?.open);
patchCompanionState(key, { seen: true, openId: auto?.id ?? null });
}, [state.seen, isVisible, defs, defaults, key]);
const active = defs.find((def) => def.id === state.openId) ?? null;
useEffect(() => {
if (!active) return;
setMounted((prev) =>
prev.has(active.id) ? prev : new Set(prev).add(active.id),
);
}, [active]);
const activeDefaults = active ? defaults[active.id] : undefined;
const side: CompanionSide = isMobile
? "bottom"
: (state.side ?? activeDefaults?.side ?? "right");
const baseSize =
state.size ??
activeDefaults?.size ??
(side === "bottom" ? DEFAULT_BOTTOM_SIZE : DEFAULT_SIDE_SIZE);
const setSide = useCallback(
(next: CompanionSide) => patchCompanionState(key, { side: next }),
[key],
);
const close = useCallback(
() => patchCompanionState(key, { openId: null }),
[key],
);
const startResize = (event: React.MouseEvent) => {
event.preventDefault();
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const total = side === "bottom" ? rect.height : rect.width;
const max = Math.max(MIN_SIZE, total - MIN_SIZE);
setDragging(true);
const move = (e: MouseEvent) => {
const raw =
side === "right"
? rect.right - e.clientX
: side === "left"
? e.clientX - rect.left
: rect.bottom - e.clientY;
patchCompanionState(key, {
size: Math.round(Math.min(max, Math.max(MIN_SIZE, raw))),
});
};
const up = () => {
setDragging(false);
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
const getHandle = useCallback(
() => (tab.terminalRef?.current as Record<string, unknown> | null) ?? null,
[tab.terminalRef],
);
const sshHost = useMemo(
() =>
tab.host
? (hostToSSHHost(tab.host) as unknown as Record<string, unknown>)
: undefined,
[tab.host],
);
const showEdge = defs.filter((def) => defaults[def.id]?.edgeButton !== false);
const vertical = side === "bottom";
const clamped = vertical ? Math.min(baseSize, 2000) : baseSize;
const paneStyle = isMobile
? { height: "55%" }
: vertical
? { height: clamped }
: { width: clamped };
const SideIcon = sideIcon(side);
return (
<div className="flex h-full w-full min-h-0 min-w-0">
{defs.map((def) =>
def.Defaults ? (
<DefaultsReader key={def.id} def={def} onValue={onDefaults} />
) : null,
)}
<div
ref={rootRef}
className={cn(
"relative flex flex-1 min-h-0 min-w-0",
vertical
? "flex-col"
: side === "left"
? "flex-row-reverse"
: "flex-row",
)}
>
<div className="relative flex-1 min-h-0 min-w-0">{children}</div>
{defs.map((def) => {
if (!mounted.has(def.id)) return null;
const open = active?.id === def.id;
const Component = def.component;
const Icon = def.icon;
return (
<div
key={def.id}
className={cn(
"relative flex-shrink-0 flex-col min-h-0 min-w-0 bg-background border-border",
open ? "flex" : "hidden",
vertical
? "border-t"
: side === "left"
? "border-r"
: "border-l",
isMobile && "absolute inset-x-0 bottom-0 z-20 shadow-lg",
)}
style={paneStyle}
>
{!isMobile && (
<div
role="separator"
aria-label={t("companion.resize")}
onMouseDown={startResize}
className={cn(
"absolute z-10",
vertical
? "inset-x-0 -top-1 h-2 cursor-row-resize"
: side === "left"
? "inset-y-0 -right-1 w-2 cursor-col-resize"
: "inset-y-0 -left-1 w-2 cursor-col-resize",
dragging && "bg-primary/30",
)}
/>
)}
<div className="flex items-center gap-1.5 h-8 px-2 border-b border-border flex-shrink-0">
{Icon && <Icon className="size-3.5 text-muted-foreground" />}
<span className="text-xs font-medium truncate flex-1">
{def.titleKey ? t(def.titleKey) : def.id}
</span>
{!isMobile && (
<button
type="button"
className="p-1 text-muted-foreground hover:text-foreground"
title={t("companion.moveSide")}
onClick={() =>
setSide(
side === "right"
? "bottom"
: side === "bottom"
? "left"
: "right",
)
}
>
<SideIcon className="size-3.5" />
</button>
)}
<button
type="button"
className="p-1 text-muted-foreground hover:text-foreground"
title={t("companion.close")}
onClick={close}
>
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 min-h-0 min-w-0 overflow-hidden">
<Component
tab={tab}
host={tab.host}
sshHost={sshHost}
getHandle={getHandle}
side={side}
isVisible={isVisible && open}
close={close}
setSide={setSide}
/>
</div>
</div>
);
})}
</div>
{showEdge.length > 0 && !isMobile && (
<div className="flex flex-col flex-shrink-0 w-6 items-center gap-1 pt-1 border-l border-border bg-background">
{showEdge.map((def) => {
const Icon = def.icon ?? PanelRight;
const open = active?.id === def.id;
return (
<button
key={def.id}
type="button"
title={def.titleKey ? t(def.titleKey) : def.id}
aria-pressed={open}
onClick={() =>
patchCompanionState(key, {
seen: true,
openId: open ? null : def.id,
})
}
className={cn(
"p-1 text-muted-foreground hover:text-foreground",
open && "text-foreground bg-accent",
)}
>
<Icon className="size-3.5" />
</button>
);
})}
</div>
)}
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { useSyncExternalStore, type ComponentType } from "react";
import type { Host, Tab } from "@/types/ui-types";
import { byOrderThenId, createRegistry } from "@/lib/registry";
export type CompanionSide = "left" | "right" | "bottom";
export interface CompanionDefaults {
/** Open by itself the first time a matching tab shows. */
open?: boolean;
side?: CompanionSide;
/** Pixels of width, or height at the bottom. */
size?: number;
/** Show the edge button that opens and closes it. */
edgeButton?: boolean;
}
export interface CompanionRenderProps {
tab: Tab;
host?: Host;
sshHost?: Record<string, unknown>;
/** The tab's surface handle, read at call time. */
getHandle: () => Record<string, unknown> | null;
side: CompanionSide;
isVisible: boolean;
close: () => void;
setSide: (side: CompanionSide) => void;
}
export interface CompanionDef {
id: string;
pluginId?: string;
titleKey?: string;
icon?: ComponentType<{ className?: string }>;
component: ComponentType<CompanionRenderProps>;
order?: number;
/** Tab types it applies to. Any tab with a host when empty. */
tabTypes?: string[];
when?: (tab: Tab, host?: Host) => boolean;
/** Renders nothing and reports the defaults, inside the plugin's scope. */
Defaults?: ComponentType<{ onValue: (value: CompanionDefaults) => void }>;
}
const registry = createRegistry<CompanionDef>(byOrderThenId);
export const registerCompanionDef = registry.register;
export const getCompanionDef = registry.get;
export const listCompanionDefs = registry.list;
export const useCompanionDefs = registry.useList;
export const resetCompanions = registry.reset;
export function companionApplies(def: CompanionDef, tab: Tab): boolean {
if (!tab.host) return false;
if (def.tabTypes?.length && !def.tabTypes.includes(tab.type)) return false;
return def.when ? def.when(tab, tab.host) : true;
}
export interface CompanionTabState {
/** The companion shown, or null when closed. */
openId: string | null;
/** Set once the defaults ran, so closing it is not undone. */
seen: boolean;
side?: CompanionSide;
size?: number;
}
const STORAGE_KEY = "termix:companions";
const EMPTY: CompanionTabState = { openId: null, seen: false };
let states = new Map<string, CompanionTabState>();
const listeners = new Set<() => void>();
let version = 0;
function load(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, CompanionTabState>;
states = new Map(Object.entries(parsed));
} catch {
// storage unavailable or corrupt
}
}
function save(): void {
try {
const entries = [...states.entries()].slice(-200);
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(Object.fromEntries(entries)),
);
} catch {
// storage unavailable
}
}
load();
/** Open state follows the tab through a restore. */
export function companionKey(tab: Tab): string {
return tab.instanceId ?? tab.id;
}
export function getCompanionState(key: string): CompanionTabState {
return states.get(key) ?? EMPTY;
}
export function patchCompanionState(
key: string,
patch: Partial<CompanionTabState>,
): void {
const next = { ...getCompanionState(key), ...patch };
states = new Map(states);
states.delete(key);
states.set(key, next);
version += 1;
save();
for (const listener of listeners) listener();
}
export function forgetCompanionState(key: string): void {
if (!states.has(key)) return;
states = new Map(states);
states.delete(key);
version += 1;
save();
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useCompanionState(key: string): CompanionTabState {
useSyncExternalStore(
subscribe,
() => version,
() => version,
);
return getCompanionState(key);
}
/** Opens a companion, or closes it when it is the one showing. */
export function toggleCompanion(tab: Tab, id?: string): void {
const key = companionKey(tab);
const current = getCompanionState(key);
const target =
id ??
current.openId ??
listCompanionDefs().find((def) => companionApplies(def, tab))?.id ??
null;
if (!target) return;
patchCompanionState(key, {
seen: true,
openId: current.openId === target ? null : target,
});
}
export function resetCompanionStates(): void {
states = new Map();
version += 1;
for (const listener of listeners) listener();
}
+4 -1
View File
@@ -12,6 +12,7 @@ import {
type TabTypeDef,
} from "./tab-registry";
import { getPanel, type PanelDef } from "./panel-registry";
import { CompanionFrame } from "./CompanionFrame";
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
@@ -144,7 +145,9 @@ const RegisteredTab = memo(function RegisteredTab({
/>
);
return withTabSuspense(
def.panelFrame ? <PanelTabFrame>{content}</PanelTabFrame> : content,
<CompanionFrame tab={tab} isVisible={isVisible}>
{def.panelFrame ? <PanelTabFrame>{content}</PanelTabFrame> : content}
</CompanionFrame>,
);
});
+76
View File
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { Tab } from "@/types/ui-types";
import {
companionApplies,
getCompanionState,
registerCompanionDef,
resetCompanions,
resetCompanionStates,
toggleCompanion,
} from "../../shell/companions";
import { CompanionFrame } from "../../shell/CompanionFrame";
const tab = (extra: Partial<Tab> = {}) =>
({
id: "t1",
type: "terminal",
label: "box",
host: { id: 1 },
...extra,
}) as unknown as Tab;
afterEach(() => {
resetCompanions();
resetCompanionStates();
});
describe("companions", () => {
it("applies by tab type and host", () => {
const def = {
id: "a",
component: () => null,
tabTypes: ["terminal"],
};
expect(companionApplies(def, tab())).toBe(true);
expect(companionApplies(def, tab({ type: "files" }))).toBe(false);
expect(companionApplies(def, tab({ host: undefined }))).toBe(false);
});
it("toggles open and closed, defaulting to the first match", () => {
registerCompanionDef({ id: "a", component: () => null });
toggleCompanion(tab());
expect(getCompanionState("t1").openId).toBe("a");
toggleCompanion(tab(), "a");
expect(getCompanionState("t1").openId).toBeNull();
});
it("renders only the tab when nothing applies", () => {
render(
<CompanionFrame tab={tab()} isVisible>
<div>content</div>
</CompanionFrame>,
);
expect(screen.getByText("content")).toBeTruthy();
expect(screen.queryByRole("button")).toBeNull();
});
it("shows the panel from the edge button and keeps the tab mounted", () => {
registerCompanionDef({
id: "a",
titleKey: "a.title",
component: () => <div>panel body</div>,
});
render(
<CompanionFrame tab={tab()} isVisible>
<div>content</div>
</CompanionFrame>,
);
expect(screen.queryByText("panel body")).toBeNull();
fireEvent.click(screen.getByTitle("a.title"));
expect(screen.getByText("panel body")).toBeTruthy();
expect(screen.getByText("content")).toBeTruthy();
act(() => toggleCompanion(tab(), "a"));
expect(getCompanionState("t1").openId).toBeNull();
});
});