feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
This commit is contained in:
LukeGus committed 2026-09-21 16:56:15 -05:00
1 parent 225f618337
commit 2d6c1cdecb
29 files changed
+1971 -40

No files matched your search

+3
View File
@@ -27,6 +27,7 @@ import {
import { createPortal } from "react-dom";
import { useIsMobile } from "@/hooks/use-mobile";
import { useAiAvailability } from "@/hooks/use-ai-availability";
import { resetPermissionsCache } from "@/hooks/use-permissions";
import { MobileBottomBar } from "@/shell/MobileBottomBar";
import { AppRail, type RailView } from "@/sidebar/AppRail";
import {
@@ -967,6 +968,8 @@ export function AppShell({
const manual =
event instanceof CustomEvent &&
(event.detail as { manual?: boolean } | undefined)?.manual === true;
// Drop the cached grants so the next user never inherits them.
resetPermissionsCache();
onLogout(manual ? { manual: true } : undefined);
};
window.addEventListener("termix:logout", handle);
+22
View File
@@ -54,6 +54,13 @@ export async function updateRole(
): Promise<{ role: Role }> {
try {
const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
if (roleData.permissions) {
// The editing admin may hold this role, so re-read our own grants
// rather than leaving gated UI stale until the next reload.
const { notifyPermissionsChanged } =
await import("@/hooks/use-permissions");
notifyPermissionsChanged();
}
return response.data;
} catch (error) {
throw handleApiError(error, "update role");
@@ -277,6 +284,21 @@ export async function getPermissionsCatalog(): Promise<{
}
}
export interface MyPermissions {
permissions: string[];
isAdmin: boolean;
}
/** The current user's own grants, for hiding UI they could not use. */
export async function getMyPermissions(): Promise<MyPermissions> {
try {
const response = await rbacApi.get("/rbac/permissions/me");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch permissions");
}
}
export async function getSharedHosts(): Promise<{
sharedHosts: Array<{
id: number;
+19 -2
View File
@@ -55,6 +55,7 @@ import {
resolveTerminalFontFamily,
} from "@/lib/terminal-themes.ts";
import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts";
import { getTerminalBufferText } from "./terminal-buffer-text.ts";
import { useTheme } from "@/components/theme-provider.tsx";
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
import { getMacLineNavigationSequence } from "@/lib/mac-line-navigation";
@@ -463,6 +464,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const [autosuggestionStyle, setAutosuggestionStyle] =
useState<React.CSSProperties>({});
const [aiAssistantOpen, setAiAssistantOpen] = useState(false);
const [aiAssistantContext, setAiAssistantContext] = useState("");
const { userEnabled: aiAssistantEnabledForUser } = useAiAvailability();
const isAiAssistantAvailable =
aiAssistantEnabledForUser && host?.enableAiAssistant === true;
@@ -733,6 +735,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setAiAssistantOpen((open) => !open);
}, []);
// A plugin-contributed action (the toolbar's AI button) asks for the panel
// this way, so the contribution needs no reference into the terminal.
useEffect(() => {
if (!isFocusedPane) return;
const open = (event: Event) => {
const detail = (event as CustomEvent<{ context?: string }>).detail;
setAiAssistantContext(detail?.context ?? "");
setAiAssistantOpen(true);
};
window.addEventListener("termix:ai:openWithContext", open);
return () =>
window.removeEventListener("termix:ai:openWithContext", open);
}, [isFocusedPane]);
const closeAiAssistant = useCallback(() => {
setAiAssistantOpen(false);
setTimeout(() => terminal?.focus(), 50);
@@ -3844,8 +3860,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}}
isFocused={isFocusedPane}
showAiAssistant={isAiAssistantAvailable}
onToggleAiAssistant={toggleAiAssistant}
actionsEnabled={isAiAssistantAvailable}
getBufferText={() => getTerminalBufferText(terminal)}
/>
)}
@@ -3854,6 +3870,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
hostLabel={`${hostConfig.username}@${hostConfig.name || hostConfig.ip}`}
hostId={hostConfig.id}
activeTab={`terminal:${hostConfig.name || hostConfig.ip}`}
initialContext={aiAssistantContext}
onClose={closeAiAssistant}
onRunInTerminal={handleRunCommandInTerminal}
/>
@@ -43,6 +43,8 @@ interface TerminalAiPanelProps {
hostLabel: string;
hostId: number;
activeTab?: string | null;
/** Terminal output to open with, from a contributed action. */
initialContext?: string;
onClose: () => void;
onRunInTerminal: (command: string) => void;
}
@@ -57,6 +59,7 @@ export function TerminalAiPanel({
hostLabel,
hostId,
activeTab,
initialContext,
onClose,
onRunInTerminal,
}: TerminalAiPanelProps) {
@@ -73,6 +76,17 @@ export function TerminalAiPanel({
>({});
const conversationIdRef = useRef<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Prefilled rather than sent: the user sees exactly what the terminal is
// about to hand the model, and can trim it or add a question first.
useEffect(() => {
if (!initialContext) return;
setInput((current) =>
current
? current
: t("ai.terminalContextPrefill", { output: initialContext }),
);
}, [initialContext, t]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [position, setPosition] = useState<ToolbarPosition>(
+34 -22
View File
@@ -7,7 +7,6 @@ import React, {
} from "react";
import { useTranslation } from "react-i18next";
import {
Bot,
ChevronDown,
ClipboardPaste,
GripVertical,
@@ -26,6 +25,8 @@ import {
} from "@/components/select";
import { cn } from "@/lib/utils";
import { ActionSlot } from "@/shell/ActionSlot";
import { useActionSlot } from "@/hooks/use-action-slot";
import { useIsMobile } from "@/hooks/use-mobile";
import { getSshActions } from "@/sidebar/tree/HostItem/HostItem";
import {
@@ -50,6 +51,9 @@ type SelectedToolbarDensity = ToolbarDensity;
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
/** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */
const TOOLBAR_SLOT_ID = "terminal.toolbar";
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
{ value: "icon" },
{ value: "labeled" },
@@ -120,8 +124,14 @@ interface TerminalToolbarProps {
onOpenTab?: (type: TabType) => void;
onOpenFiles?: () => void;
isFocused: boolean;
showAiAssistant?: boolean;
onToggleAiAssistant?: () => void;
/**
* Whether this host offers the toolbar's contributed actions at all. The
* slot itself is generic; the host-level AI flag is not something a
* contributing plugin can know about.
*/
actionsEnabled?: boolean;
/** Read at click time by contributions that want the visible scrollback. */
getBufferText?: () => string;
}
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
@@ -136,10 +146,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onOpenTab,
onOpenFiles,
isFocused,
showAiAssistant,
onToggleAiAssistant,
actionsEnabled = true,
getBufferText,
}) => {
const { t } = useTranslation();
// Drives both the measurement copy and the separator, so the toolbar sizes
// itself correctly whether or not anything contributed.
const slotContributions = useActionSlot(TOOLBAR_SLOT_ID);
const [density, setDensity] =
useState<SelectedToolbarDensity>(readStoredDensity);
const [responsiveDensity, setResponsiveDensity] =
@@ -261,7 +274,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
isConnected,
isMobile,
isTmuxAttached,
showAiAssistant,
actionsEnabled,
slotContributions,
t,
]);
@@ -745,12 +759,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
<ClipboardPaste className="size-4" />
{density !== "icon" && t("terminalToolbar.paste")}
</span>
{showAiAssistant && (
<span className={CONTROL}>
<Bot className="size-4" />
{density !== "icon" && t("ai.assistant")}
</span>
)}
{actionsEnabled &&
slotContributions.map((contribution) => (
<span key={contribution.actionId} className={CONTROL}>
{contribution.icon && <contribution.icon className="size-4" />}
{density !== "icon" && t(contribution.titleKey)}
</span>
))}
<span className={cn(CONTROL, "h-8 px-2")}>
<LayoutGrid className="size-4" />
<ChevronDown className="size-4" />
@@ -832,19 +847,16 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
>
{imageButtons}
</div>
{showAiAssistant && (
{actionsEnabled && slotContributions.length > 0 && (
<>
<div className={SEPARATOR} />
<button
type="button"
<ActionSlot
slotId={TOOLBAR_SLOT_ID}
className={CONTROL}
aria-label={t("ai.assistant")}
title={t("ai.assistant")}
onClick={onToggleAiAssistant}
>
<Bot className="size-4 shrink-0" />
{effectiveDensity !== "icon" && t("ai.assistant")}
</button>
enabled={actionsEnabled}
hideLabels={effectiveDensity === "icon"}
context={() => [getBufferText?.() ?? ""]}
/>
</>
)}
<div className={SEPARATOR} />
@@ -0,0 +1,47 @@
import type { Terminal } from "@xterm/xterm";
/**
* How much scrollback to hand out by default. Bounded on purpose: a
* long-running session holds megabytes, and nothing consuming this wants all
* of it.
*/
export const DEFAULT_BUFFER_CONTEXT_LINES = 200;
/**
* Serializes the tail of a terminal's buffer to plain text.
*
* Wrapped rows are joined back onto the line they continue. xterm splits one
* logical line across several buffer rows when it wraps, so emitting a newline
* per row would chop long commands in half.
*/
export function getTerminalBufferText(
terminal: Terminal | null | undefined,
maxLines: number = DEFAULT_BUFFER_CONTEXT_LINES,
): string {
if (!terminal || maxLines <= 0) return "";
try {
const buffer = terminal.buffer.active;
const end = buffer.baseY + terminal.rows;
const start = Math.max(0, end - maxLines);
const lines: string[] = [];
for (let i = start; i < end; i++) {
const line = buffer.getLine(i);
if (!line) continue;
const text = line.translateToString(true);
if (line.isWrapped && lines.length > 0) {
lines[lines.length - 1] += text;
} else {
lines.push(text);
}
}
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines.join("\n");
} catch {
// Called from a click handler. A disposed terminal returns nothing
// rather than breaking the click.
return "";
}
}
+37
View File
@@ -0,0 +1,37 @@
import { useMemo, useSyncExternalStore } from "react";
import {
getSlotContributions,
subscribeToActionRegistry,
getActionPermission,
type SlotContribution,
} from "@/shell/action-registry";
import { usePermissions } from "@/hooks/use-permissions";
/**
* The contributions in a slot that the current user may actually see.
*
* A slot owner calls this without knowing which plugins contributed, or
* whether any did. Contributions the user lacks the permission for are
* dropped entirely rather than disabled, which is how admin-gated UI already
* behaves elsewhere in the app.
*/
export function useActionSlot(slotId: string): SlotContribution[] {
const contributions = useSyncExternalStore(
subscribeToActionRegistry,
() => getSlotContributions(slotId),
() => getSlotContributions(slotId),
);
const { has, loaded } = usePermissions();
return useMemo(() => {
// Render nothing until the grants are known. A button that appears and
// then vanishes is worse than one that appears a moment late.
if (!loaded) return [];
return contributions.filter((contribution) => {
const permission =
getActionPermission(contribution.actionId) ?? undefined;
return !permission || has(permission);
});
}, [contributions, has, loaded]);
}
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from "react";
import { matchesPermission } from "@/lib/permissions";
export interface PermissionsState {
permissions: string[];
isAdmin: boolean;
/** False until the call answers, so gated UI does not flash in or out. */
loaded: boolean;
has: (permission: string) => boolean;
}
/** Fired when a user's grants may have changed, so every surface re-reads them. */
export const PERMISSIONS_CHANGED_EVENT = "permissionsChanged";
interface CachedPermissions {
permissions: string[];
isAdmin: boolean;
}
/**
* Shared across every hook instance. A terminal per split pane would otherwise
* fire one identical request each.
*/
let cache: CachedPermissions | null = null;
let inFlight: Promise<CachedPermissions> | null = null;
function load(): Promise<CachedPermissions> {
if (cache) return Promise.resolve(cache);
if (inFlight) return inFlight;
inFlight = import("@/api/rbac-api")
.then(({ getMyPermissions }) => getMyPermissions())
.then((result) => {
cache = {
permissions: result.permissions ?? [],
isAdmin: !!result.isAdmin,
};
return cache;
})
.catch(() => {
// Deny by default. A failed lookup hides gated UI rather than showing
// something the server would refuse anyway.
cache = { permissions: [], isAdmin: false };
return cache;
})
.finally(() => {
inFlight = null;
});
return inFlight;
}
export function notifyPermissionsChanged(): void {
cache = null;
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
}
/**
* The current user's grants.
*
* Freshness is eventually consistent: the backend caches per user with a TTL
* and this caches until notifyPermissionsChanged fires, so a revoke can leave
* a stale button on screen briefly. That is acceptable because the button is
* not the boundary - clicking it hits a route that checks again and refuses.
*/
export function usePermissions(): PermissionsState {
const [state, setState] = useState<CachedPermissions & { loaded: boolean }>(
() =>
cache
? { ...cache, loaded: true }
: { permissions: [], isAdmin: false, loaded: false },
);
useEffect(() => {
let cancelled = false;
const refresh = () => {
load().then((result) => {
if (!cancelled) setState({ ...result, loaded: true });
});
};
refresh();
window.addEventListener(PERMISSIONS_CHANGED_EVENT, refresh);
return () => {
cancelled = true;
window.removeEventListener(PERMISSIONS_CHANGED_EVENT, refresh);
};
}, []);
const has = useCallback(
(permission: string) =>
matchesPermission(state.permissions, state.isAdmin, permission),
[state.permissions, state.isAdmin],
);
return { ...state, has };
}
/** Test seam. */
export function resetPermissionsCache(): void {
cache = null;
inFlight = null;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Frontend copy of the permission matching rules.
*
* This mirrors PermissionManager.hasPermission in
* src/backend/utils/permission-manager.ts and has to stay in step with it. It
* is a second implementation of one rule, which is a real cost, accepted here
* only because this copy is cosmetic: it decides what UI to show, never what
* a request is allowed to do. The server checks again on every route.
*/
/**
* Whether a granted permission set covers the one required.
*
* Order matches the backend: the "*" superuser grant, an exact match, then
* progressively shorter dotted wildcards, then admin last.
*/
export function matchesPermission(
granted: readonly string[],
isAdmin: boolean,
required: string,
): boolean {
if (granted.includes("*")) return true;
if (granted.includes(required)) return true;
const parts = required.split(".");
for (let i = parts.length; i > 0; i--) {
if (granted.includes(parts.slice(0, i).join(".") + ".*")) return true;
}
return isAdmin;
}
+2 -1
View File
@@ -5048,7 +5048,8 @@
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Run"
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n"
},
"macros": {
"title": "Macros",
+82
View File
@@ -0,0 +1,82 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useActionSlot } from "@/hooks/use-action-slot";
import { invokeAction, type SlotContribution } from "@/shell/action-registry";
interface ActionSlotProps {
slotId: string;
/**
* Arguments for the action, read at click time rather than on render. A
* getter keeps expensive context (a serialized terminal buffer, say) off the
* render path.
*/
context?: () => unknown[];
/** Applied to the default button so a slot owner can match its own controls. */
className?: string;
/** Hides the whole slot. For a surface that is contextually unavailable. */
enabled?: boolean;
/** Renders a contribution the owner's own way instead of as a button. */
renderItem?: (
contribution: SlotContribution,
invoke: () => void,
) => React.ReactNode;
/** Icon-only, for toolbars that collapse their labels. */
hideLabels?: boolean;
}
/**
* Renders whatever plugins have contributed to a named slot.
*
* Renders null when there is nothing to show, which covers both "no plugin
* contributed" and "the user may not see what was contributed". Those two
* cases are deliberately identical in the DOM: no placeholder, no disabled
* button, nothing hinting at a feature the user cannot reach.
*/
export function ActionSlot({
slotId,
context,
className,
enabled = true,
renderItem,
hideLabels,
}: ActionSlotProps) {
const { t } = useTranslation();
const contributions = useActionSlot(slotId);
if (!enabled || contributions.length === 0) return null;
return (
<>
{contributions.map((contribution) => {
const invoke = () => {
void invokeAction(contribution.actionId, ...(context?.() ?? []));
};
if (renderItem) {
return (
<React.Fragment key={contribution.actionId}>
{renderItem(contribution, invoke)}
</React.Fragment>
);
}
const Icon = contribution.icon;
const label = t(contribution.titleKey);
return (
<button
key={contribution.actionId}
type="button"
className={className}
aria-label={label}
title={label}
onClick={invoke}
>
{Icon && <Icon className="size-4 shrink-0" />}
{!hideLabels && label}
</button>
);
})}
</>
);
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Runtime registry for plugin-contributed UI actions and the slots they fill.
*
* This is the frontend sibling of src/backend/plugins/service-registry.ts, and
* the difference between them is the thing to be clear about:
*
* - The backend registry's proxy IS a boundary. It checks the acting user's
* RBAC permission on every call before the provider ever runs.
* - This one is NOT. It runs in the browser, where anyone can call
* invokeAction from the console. The permission attached to an action
* decides whether a button is DRAWN, nothing more.
*
* Real enforcement stays where it already is: the backend route the handler
* eventually calls. Treating this file as a security boundary is the easy
* mistake to make when reading the two side by side, so it is said plainly
* here rather than left to be inferred.
*
* Unlike the rail and tab registries (rail-items.ts, tabUtils.tsx), which are
* plain Maps read during render, this one is reactive. A slot renders live and
* has to react when a plugin is enabled or disabled at runtime, so it carries
* subscribe/getSnapshot plumbing for useSyncExternalStore.
*/
import type { LucideIcon } from "lucide-react";
/**
* Args are unknown[] because the registry never inspects them: a slot owner
* passes whatever its surface offers (the terminal passes its buffer text) and
* the contributing plugin knows what to expect. Handlers are written with
* concrete parameter types, so registerAction accepts those too.
*/
export type ActionHandler = (...args: never[]) => unknown;
export interface RegisteredAction {
id: string;
handler: ActionHandler;
/** Role permission a user needs before this action is offered. */
permission?: string;
pluginId?: string;
}
export interface ActionSlotDefinition {
id: string;
/** Contribution kinds this slot will render. */
accepts: string[];
}
export interface SlotContribution {
/** Action invoked when this contribution is activated. */
actionId: string;
titleKey: string;
icon?: LucideIcon;
/** Checked against the slot's accepts. Defaults to "button". */
kind?: string;
pluginId?: string;
/** Lower sorts first. Ties break on actionId so ordering is stable. */
order?: number;
}
const actions = new Map<string, RegisteredAction>();
const slots = new Map<string, ActionSlotDefinition>();
/** slotId -> actionId -> contribution. */
const contributions = new Map<string, Map<string, SlotContribution>>();
const listeners = new Set<() => void>();
/**
* Cached per-slot arrays.
*
* Required, not an optimization: useSyncExternalStore compares snapshots by
* identity, so building a fresh array per call makes React throw "The result
* of getSnapshot should be cached" and re-render forever.
*/
const snapshots = new Map<string, SlotContribution[]>();
const EMPTY: SlotContribution[] = [];
function emit(): void {
snapshots.clear();
for (const listener of listeners) listener();
}
export function registerAction(
id: string,
handler: ActionHandler,
options: { permission?: string; pluginId?: string } = {},
): void {
actions.set(id, { id, handler, ...options });
emit();
}
export function unregisterAction(id: string): void {
if (actions.delete(id)) emit();
}
/** The permission gating an action, if it declared one. */
export function getActionPermission(id: string): string | undefined {
return actions.get(id)?.permission;
}
/**
* Runs an action. Resolves quietly when the id is unknown: a contribution can
* outlive the plugin that registered its handler, and a click should not throw
* into the React event handler because of it.
*/
export async function invokeAction(
id: string,
...args: unknown[]
): Promise<unknown> {
const action = actions.get(id);
if (!action) {
console.warn(`[actions] no handler registered for "${id}"`);
return undefined;
}
return (action.handler as (...a: unknown[]) => unknown)(...args);
}
export function declareActionSlot(slot: ActionSlotDefinition): void {
slots.set(slot.id, slot);
emit();
}
export function undeclareActionSlot(slotId: string): void {
if (slots.delete(slotId)) emit();
}
/**
* Adds a contribution to a slot.
*
* A contribution for a slot nobody has declared yet is kept rather than
* dropped: plugin activation order is not guaranteed, and the slot owner may
* register after the contributor. It becomes visible once the slot appears.
*/
export function registerSlotContribution(
slotId: string,
contribution: SlotContribution,
): void {
const slot = slots.get(slotId);
const kind = contribution.kind ?? "button";
if (slot && !slot.accepts.includes(kind)) {
console.warn(
`[actions] slot "${slotId}" does not accept "${kind}" (accepts: ${slot.accepts.join(", ")})`,
);
return;
}
let forSlot = contributions.get(slotId);
if (!forSlot) {
forSlot = new Map();
contributions.set(slotId, forSlot);
}
forSlot.set(contribution.actionId, { ...contribution, kind });
emit();
}
export function unregisterSlotContribution(
slotId: string,
actionId: string,
): void {
const forSlot = contributions.get(slotId);
if (!forSlot?.delete(actionId)) return;
if (forSlot.size === 0) contributions.delete(slotId);
emit();
}
/**
* Contributions for a slot, sorted and referentially stable between changes.
* Unfiltered: callers that render go through useActionSlot, which drops the
* ones the current user may not see.
*/
export function getSlotContributions(slotId: string): SlotContribution[] {
const cached = snapshots.get(slotId);
if (cached) return cached;
const forSlot = contributions.get(slotId);
if (!forSlot || forSlot.size === 0) {
snapshots.set(slotId, EMPTY);
return EMPTY;
}
const sorted = [...forSlot.values()].sort(
(a, b) =>
(a.order ?? 0) - (b.order ?? 0) || a.actionId.localeCompare(b.actionId),
);
snapshots.set(slotId, sorted);
return sorted;
}
export function subscribeToActionRegistry(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** Test seam, mirroring resetPluginState(). */
export function resetActionRegistry(): void {
actions.clear();
slots.clear();
contributions.clear();
snapshots.clear();
emit();
}
+55
View File
@@ -15,9 +15,17 @@
* bigger regression risk to a working terminal than gating the entry points.
*/
import { Bot } from "lucide-react";
import { unregisterRailItem } from "@/sidebar/rail-items";
import { unregisterTabComponent } from "@/shell/tabUtils";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import {
declareActionSlot,
registerAction,
registerSlotContribution,
unregisterAction,
unregisterSlotContribution,
} from "@/shell/action-registry";
/**
* Built-in tab types owned by a plugin, keyed by plugin id. The terminal has
@@ -38,6 +46,50 @@ const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
let pluginEnabled = new Map<string, boolean>();
let disabledTabTypes = new Set<string>();
const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar";
const AI_OPEN_WITH_CONTEXT = "ai.openWithContext";
/**
* Applies the UI action contributions the first-party plugins declare.
*
* These live in plugins/*_/frontend/index.mjs, but nothing imports those yet:
* there is no frontend plugin loader. Until there is, the registration is
* mirrored here so the contributed UI is actually reachable in the running
* app rather than only in tests. The two must be kept in step, and this block
* goes away when a real loader lands.
*/
function applyFirstPartyActions(enabled: Map<string, boolean>): void {
if (enabled.get("ssh-terminal") !== false) {
declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] });
}
if (enabled.get("ai") === false) {
unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT);
unregisterAction(AI_OPEN_WITH_CONTEXT);
return;
}
registerAction(
AI_OPEN_WITH_CONTEXT,
(context: unknown) => {
window.dispatchEvent(
new CustomEvent("termix:ai:openWithContext", {
detail: { context: typeof context === "string" ? context : "" },
}),
);
},
{ permission: "ai.services.use", pluginId: "ai" },
);
registerSlotContribution(TERMINAL_TOOLBAR_SLOT, {
actionId: AI_OPEN_WITH_CONTEXT,
titleKey: "ai.assistant",
icon: Bot,
kind: "button",
pluginId: "ai",
});
}
export function applyPluginState(plugins: PluginSummary[]): void {
const nextEnabled = new Map<string, boolean>();
const nextDisabledTabs = new Set<string>();
@@ -65,6 +117,7 @@ export function applyPluginState(plugins: PluginSummary[]): void {
pluginEnabled = nextEnabled;
disabledTabTypes = nextDisabledTabs;
applyFirstPartyActions(nextEnabled);
}
export async function refreshPluginState(): Promise<PluginSummary[]> {
@@ -92,4 +145,6 @@ export function isPluginEnabled(pluginId: string): boolean {
export function resetPluginState(): void {
pluginEnabled = new Map();
disabledTabTypes = new Set();
unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT);
unregisterAction(AI_OPEN_WITH_CONTEXT);
}
@@ -29,6 +29,16 @@ vi.mock("@/sidebar/tree/HostItem/HostItem", () => hostActionsApi);
vi.mock("@/hooks/use-mobile", () => ({
useIsMobile: () => mobileApi.isMobile,
}));
const perms = vi.hoisted(() => ({ granted: [] as string[] }));
vi.mock("@/hooks/use-permissions", () => ({
usePermissions: () => ({
permissions: perms.granted,
isAdmin: false,
loaded: true,
has: (permission: string) => perms.granted.includes(permission),
}),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) =>
@@ -54,11 +64,18 @@ vi.mock("react-i18next", () => ({
"terminalToolbar.imageActionFailed": "Image action failed",
"terminalToolbar.retry": "Retry",
"terminalToolbar.dismissError": "Dismiss image error",
"ai.assistant": "AI Assistant",
})[key] ?? key,
}),
}));
import { TerminalToolbar } from "@/features/terminal/TerminalToolbar";
import {
declareActionSlot,
registerAction,
registerSlotContribution,
resetActionRegistry,
} from "@/shell/action-registry";
import {
clampToolbarPosition,
getResponsiveToolbarDensity,
@@ -96,6 +113,8 @@ beforeEach(() => {
value: 1024,
});
localStorage.clear();
perms.granted = [];
resetActionRegistry();
hostActionsApi.getSshActions.mockReturnValue([]);
api.startMetricsPolling.mockResolvedValue({ viewerSessionId: "viewer" });
api.getServerMetricsById.mockResolvedValue(null);
@@ -891,4 +910,67 @@ describe("TerminalToolbar Phase 1", () => {
expect(api.stopMetricsPolling).toHaveBeenCalledWith(7, "late-viewer"),
);
});
describe("contributed toolbar actions", () => {
const ACTION = "ai.openWithContext";
function contribute() {
const handler = vi.fn();
declareActionSlot({ id: "terminal.toolbar", accepts: ["button"] });
registerAction(ACTION, handler, { permission: "ai.services.use" });
registerSlotContribution("terminal.toolbar", {
actionId: ACTION,
titleKey: "ai.assistant",
});
return handler;
}
it("shows no contributed button and no stray separator when nothing contributed", () => {
declareActionSlot({ id: "terminal.toolbar", accepts: ["button"] });
const { container } = renderToolbar();
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
const separatorsWithout = container.querySelectorAll(".w-px").length;
cleanup();
contribute();
perms.granted = ["ai.services.use"];
const withButton = renderToolbar().container;
expect(withButton.querySelectorAll(".w-px").length).toBe(
separatorsWithout + 1,
);
});
it("hides the contributed button when the user lacks the permission", () => {
contribute();
renderToolbar();
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
});
it("invokes the action with the terminal buffer text when permitted", async () => {
const handler = contribute();
perms.granted = ["ai.services.use"];
const getBufferText = vi.fn(() => "$ whoami\nroot");
renderToolbar({ getBufferText });
expect(getBufferText).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "AI Assistant" }),
);
expect(handler).toHaveBeenCalledWith("$ whoami\nroot");
});
it("hides the slot when the host has contributed actions disabled", () => {
contribute();
perms.granted = ["ai.services.use"];
renderToolbar({ actionsEnabled: false });
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
});
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { Terminal } from "@xterm/xterm";
import { getTerminalBufferText } from "@/features/terminal/terminal-buffer-text";
interface FakeLine {
text: string;
wrapped?: boolean;
}
/** Minimal stand-in for the parts of xterm this reads. */
function fakeTerminal(lines: FakeLine[], rows = lines.length): Terminal {
return {
rows,
buffer: {
active: {
baseY: Math.max(0, lines.length - rows),
getLine: (i: number) => {
const line = lines[i];
if (!line) return undefined;
return {
isWrapped: !!line.wrapped,
translateToString: () => line.text,
};
},
},
},
} as unknown as Terminal;
}
describe("getTerminalBufferText", () => {
it("returns an empty string for a missing terminal", () => {
expect(getTerminalBufferText(null)).toBe("");
expect(getTerminalBufferText(undefined)).toBe("");
});
it("joins buffer rows with newlines", () => {
const terminal = fakeTerminal([{ text: "$ whoami" }, { text: "root" }]);
expect(getTerminalBufferText(terminal)).toBe("$ whoami\nroot");
});
it("joins a wrapped row onto the line it continues", () => {
const terminal = fakeTerminal([
{ text: "$ echo start" },
{ text: "a-very-long-command " },
{ text: "that-wrapped", wrapped: true },
]);
expect(getTerminalBufferText(terminal)).toBe(
"$ echo start\na-very-long-command that-wrapped",
);
});
it("drops trailing blank lines", () => {
const terminal = fakeTerminal([
{ text: "output" },
{ text: "" },
{ text: "" },
]);
expect(getTerminalBufferText(terminal)).toBe("output");
});
it("keeps blank lines in the middle", () => {
const terminal = fakeTerminal([
{ text: "one" },
{ text: "" },
{ text: "two" },
]);
expect(getTerminalBufferText(terminal)).toBe("one\n\ntwo");
});
it("bounds the result to maxLines, keeping the tail", () => {
const lines = Array.from({ length: 50 }, (_, i) => ({ text: `line ${i}` }));
const terminal = fakeTerminal(lines, 50);
const result = getTerminalBufferText(terminal, 3);
expect(result).toBe("line 47\nline 48\nline 49");
});
it("returns an empty string for a non-positive maxLines", () => {
const terminal = fakeTerminal([{ text: "anything" }]);
expect(getTerminalBufferText(terminal, 0)).toBe("");
});
it("returns an empty string when the buffer throws", () => {
const disposed = {
rows: 1,
get buffer(): never {
throw new Error("terminal disposed");
},
} as unknown as Terminal;
expect(getTerminalBufferText(disposed)).toBe("");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { matchesPermission } from "@/lib/permissions";
/**
* These cases mirror PermissionManager.hasPermission on the backend. If that
* changes, this file is the tripwire.
*/
describe("matchesPermission", () => {
it("grants on an exact match", () => {
expect(
matchesPermission(["ai.services.use"], false, "ai.services.use"),
).toBe(true);
});
it("grants on the superuser wildcard", () => {
expect(matchesPermission(["*"], false, "anything.at.all")).toBe(true);
});
it("grants on each dotted wildcard level", () => {
expect(matchesPermission(["ai.services.*"], false, "ai.services.use")).toBe(
true,
);
expect(matchesPermission(["ai.*"], false, "ai.services.use")).toBe(true);
});
it("grants to an admin with no explicit permissions", () => {
expect(matchesPermission([], true, "ai.services.use")).toBe(true);
});
it("denies a non-admin without a matching grant", () => {
expect(matchesPermission([], false, "ai.services.use")).toBe(false);
expect(matchesPermission(["hosts.view"], false, "ai.services.use")).toBe(
false,
);
});
it("does not treat a prefix as a wildcard without the star", () => {
expect(matchesPermission(["ai.services"], false, "ai.services.use")).toBe(
false,
);
});
it("does not let a sibling wildcard grant an unrelated permission", () => {
expect(matchesPermission(["hosts.*"], false, "ai.services.use")).toBe(
false,
);
});
it("handles a single-segment permission", () => {
expect(matchesPermission(["admin"], false, "admin")).toBe(true);
// "admin.*" covers bare "admin" too: the backend builds the same
// single-segment wildcard, so the frontend must agree.
expect(matchesPermission(["admin.*"], false, "admin")).toBe(true);
expect(matchesPermission(["other"], false, "admin")).toBe(false);
});
});
+298
View File
@@ -0,0 +1,298 @@
/**
* The three states a permission-gated slot contribution can be in, plus the
* reactivity that makes a slot different from the rail/tab registries.
*
* Follows the pattern of sidebar/plugin-extension-seam.test.tsx: register into
* the real registry, assert through a real render, then clean up. The point is
* that states (a) "nothing contributed" and (b) "contributed but not
* permitted" are indistinguishable in the DOM.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { Bot } from "lucide-react";
const perms = vi.hoisted(() => ({
permissions: [] as string[],
isAdmin: false,
loaded: true,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/hooks/use-permissions", async () => {
const { matchesPermission } = await import("@/lib/permissions");
return {
usePermissions: () => ({
permissions: perms.permissions,
isAdmin: perms.isAdmin,
loaded: perms.loaded,
has: (permission: string) =>
matchesPermission(perms.permissions, perms.isAdmin, permission),
}),
};
});
import { ActionSlot } from "@/shell/ActionSlot";
import {
declareActionSlot,
registerAction,
registerSlotContribution,
resetActionRegistry,
getSlotContributions,
unregisterSlotContribution,
} from "@/shell/action-registry";
const SLOT = "terminal.toolbar";
const ACTION = "ai.openWithContext";
function contributeAi(handler = vi.fn()) {
registerAction(ACTION, handler, { permission: "ai.services.use" });
registerSlotContribution(SLOT, {
actionId: ACTION,
titleKey: "ai.assistant",
icon: Bot,
});
return handler;
}
describe("ActionSlot", () => {
beforeEach(() => {
perms.permissions = [];
perms.isAdmin = false;
perms.loaded = true;
declareActionSlot({ id: SLOT, accepts: ["button"] });
});
afterEach(() => {
resetActionRegistry();
});
it("renders nothing when no plugin has contributed", () => {
const { container } = render(<ActionSlot slotId={SLOT} />);
expect(container).toBeEmptyDOMElement();
expect(screen.queryByRole("button")).toBeNull();
});
it("renders nothing when the user lacks the permission", () => {
const handler = contributeAi();
const { container } = render(<ActionSlot slotId={SLOT} />);
// Identical to the no-contribution case: no placeholder, and nothing
// merely disabled or greyed out that hints at a feature out of reach.
expect(container).toBeEmptyDOMElement();
expect(screen.queryByRole("button")).toBeNull();
expect(screen.queryByText("ai.assistant")).toBeNull();
expect(container.querySelector("[disabled]")).toBeNull();
expect(container.querySelector("[aria-disabled]")).toBeNull();
expect(handler).not.toHaveBeenCalled();
});
it("renders the button and invokes the action when permitted", async () => {
const handler = contributeAi();
perms.permissions = ["ai.services.use"];
render(<ActionSlot slotId={SLOT} context={() => ["buffer text"]} />);
const button = screen.getByRole("button", { name: "ai.assistant" });
await act(async () => {
button.click();
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith("buffer text");
});
it("reads its context at click time, not on render", async () => {
const handler = contributeAi();
perms.permissions = ["ai.services.use"];
const context = vi.fn(() => ["later"]);
render(<ActionSlot slotId={SLOT} context={context} />);
expect(context).not.toHaveBeenCalled();
await act(async () => {
screen.getByRole("button", { name: "ai.assistant" }).click();
});
expect(context).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith("later");
});
it("appears when a contribution is registered after mount", () => {
perms.permissions = ["ai.services.use"];
render(<ActionSlot slotId={SLOT} />);
expect(screen.queryByRole("button")).toBeNull();
// No re-render from the test: useSyncExternalStore has to do this. A
// plain Map like the rail/tab registries would fail here.
act(() => {
contributeAi();
});
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
});
it("disappears again when the contribution is removed", () => {
contributeAi();
perms.permissions = ["ai.services.use"];
render(<ActionSlot slotId={SLOT} />);
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
act(() => {
unregisterSlotContribution(SLOT, ACTION);
});
expect(screen.queryByRole("button")).toBeNull();
});
it("renders a wildcard-granted contribution", () => {
contributeAi();
perms.permissions = ["ai.*"];
render(<ActionSlot slotId={SLOT} />);
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
});
it("renders for an admin with no explicit grants", () => {
contributeAi();
perms.isAdmin = true;
render(<ActionSlot slotId={SLOT} />);
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
});
it("renders an ungated contribution for everyone", () => {
registerAction("demo.ping", vi.fn());
registerSlotContribution(SLOT, {
actionId: "demo.ping",
titleKey: "demo.ping",
});
render(<ActionSlot slotId={SLOT} />);
expect(screen.getByRole("button", { name: "demo.ping" })).toBeTruthy();
});
it("renders nothing while permissions are still loading", () => {
contributeAi();
perms.permissions = ["ai.services.use"];
perms.loaded = false;
const { container } = render(<ActionSlot slotId={SLOT} />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when the owner disables the slot", () => {
contributeAi();
perms.permissions = ["ai.services.use"];
const { container } = render(<ActionSlot slotId={SLOT} enabled={false} />);
expect(container).toBeEmptyDOMElement();
});
it("lets the slot owner render contributions its own way", () => {
contributeAi();
perms.permissions = ["ai.services.use"];
render(
<ActionSlot
slotId={SLOT}
renderItem={(contribution, invoke) => (
<span data-testid="custom" onClick={invoke}>
{contribution.titleKey}
</span>
)}
/>,
);
expect(screen.getByTestId("custom")).toHaveTextContent("ai.assistant");
expect(screen.queryByRole("button")).toBeNull();
});
});
describe("action registry", () => {
afterEach(() => {
resetActionRegistry();
});
it("refuses a contribution whose kind the slot does not accept", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
declareActionSlot({ id: SLOT, accepts: ["button"] });
registerSlotContribution(SLOT, {
actionId: "demo.menu",
titleKey: "demo.menu",
kind: "menu-item",
});
expect(getSlotContributions(SLOT)).toHaveLength(0);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("keeps a contribution registered before its slot is declared", () => {
registerSlotContribution(SLOT, {
actionId: ACTION,
titleKey: "ai.assistant",
});
expect(getSlotContributions(SLOT)).toHaveLength(1);
declareActionSlot({ id: SLOT, accepts: ["button"] });
expect(getSlotContributions(SLOT)).toHaveLength(1);
});
it("returns a stable snapshot reference between mutations", () => {
declareActionSlot({ id: SLOT, accepts: ["button"] });
registerSlotContribution(SLOT, {
actionId: ACTION,
titleKey: "ai.assistant",
});
// The guard against useSyncExternalStore's infinite re-render loop.
expect(getSlotContributions(SLOT)).toBe(getSlotContributions(SLOT));
const before = getSlotContributions(SLOT);
registerSlotContribution(SLOT, {
actionId: "demo.other",
titleKey: "demo.other",
});
expect(getSlotContributions(SLOT)).not.toBe(before);
});
it("sorts by order then action id", () => {
declareActionSlot({ id: SLOT, accepts: ["button"] });
registerSlotContribution(SLOT, { actionId: "z.last", titleKey: "k" });
registerSlotContribution(SLOT, { actionId: "a.first", titleKey: "k" });
registerSlotContribution(SLOT, {
actionId: "m.leading",
titleKey: "k",
order: -1,
});
expect(getSlotContributions(SLOT).map((c) => c.actionId)).toEqual([
"m.leading",
"a.first",
"z.last",
]);
});
it("resolves quietly when a contribution points at a missing action", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { invokeAction } = await import("@/shell/action-registry");
await expect(invokeAction("nobody.home")).resolves.toBeUndefined();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});