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

+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 "";
}
}