mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
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:
1 parent
225f618337
commit
2d6c1cdecb
29 files changed
+1971
-40
No files matched your search
@@ -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>(
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user