feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

No files matched your search

+7 -1
View File
@@ -33,7 +33,8 @@
"workspaces": "^1.0.0",
"network-topology": "^1.0.0",
"snippets": "^1.0.0",
"fleets": "^1.0.0"
"fleets": "^1.0.0",
"ssh-terminal": "^1.0.0"
},
"requires": [
{
@@ -55,6 +56,11 @@
"service": "fleets.access",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "terminal.history",
"versionRange": "^1.0.0",
"optional": true
}
],
"providesSecret": [
+27
View File
@@ -221,3 +221,30 @@ export async function deleteSnippet(
): Promise<boolean> {
return requireSnippetsAccess(userId).remove(id);
}
/** The terminal's command history, as ctx.services.get("terminal.history") returns it. */
interface TerminalHistory {
list: (
hostId: number,
limit?: number,
) => Promise<Array<{ command: string; executedAt: string }>>;
}
/**
* The user's recent commands on one host, or null when the ssh-terminal
* plugin is off or the user may not read its history.
*/
export async function listCommandHistory(
userId: string,
hostId: number,
limit: number,
): Promise<Array<{ command: string; executedAt: string }> | null> {
if (!current) return null;
try {
return await current
.get<TerminalHistory>("terminal.history", { userId })
.list(hostId, limit);
} catch {
return null;
}
}
+4 -8
View File
@@ -1,7 +1,6 @@
import {
createCurrentNotificationChannelRepository,
createCurrentAutomationRepository,
createCurrentCommandHistoryRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
@@ -11,6 +10,7 @@ import {
getNetworkTopology,
listSnippets,
listFleets,
listCommandHistory,
} from "../services.js";
/**
@@ -244,13 +244,9 @@ export const readTools: AiTool[] = [
);
if (!host) return { error: "Host not found" };
const commands =
await createCurrentCommandHistoryRepository().listCommandsForHost(
context.userId,
hostId,
limit,
);
return { commands };
const history = await listCommandHistory(context.userId, hostId, limit);
if (history === null) return { commands: [], unavailable: true };
return { commands: history.map((entry) => entry.command) };
},
},
{
+14 -12
View File
@@ -6,9 +6,9 @@ import type {
TermixApp,
} from "@termix/plugin-sdk/frontend";
import type {
TerminalDockProps,
TerminalSidePanelProps,
TerminalSlotApi,
} from "@/features/terminal/terminal-slots";
} from "./terminal/terminal-slot-types";
import { AiPanel } from "./AiPanel";
import { TerminalAiPanel } from "./terminal/TerminalAiPanel";
import { AiAssistantStep } from "./AiAssistantStep";
@@ -18,7 +18,7 @@ import { getAiStatus } from "./ai-api";
import { AI_STATUS_CHANGED_EVENT } from "./use-ai-availability";
const ASSISTANT_ACTION = "ai.openWithContext";
const DOCK_ID = "ai.assistant";
const SIDE_PANEL_ID = "ai.assistant";
function AssistantPanel({ activeTabType }: PanelProps) {
return (
@@ -32,15 +32,15 @@ function AssistantTab() {
return <AiPanel />;
}
/** The assistant docked beside a terminal. */
function TerminalDock({
/** The assistant beside a terminal, in its "terminal.sidePanel" slot. */
function TerminalSidePanel({
host,
hostId,
hostLabel,
dockProps,
panelProps,
onClose,
onRunInTerminal,
}: TerminalDockProps) {
}: TerminalSidePanelProps) {
if (!hostId) return null;
return (
<TerminalAiPanel
@@ -48,7 +48,7 @@ function TerminalDock({
hostId={hostId}
activeTab={`terminal:${host?.name || host?.ip || ""}`}
initialContext={
typeof dockProps.context === "string" ? dockProps.context : ""
typeof panelProps.context === "string" ? panelProps.context : ""
}
onClose={onClose}
onRunInTerminal={onRunInTerminal}
@@ -114,12 +114,12 @@ export function activate(app: TermixApp): void {
}),
);
surface.push(
app.registerSlotContribution("terminal.dock", {
actionId: DOCK_ID,
app.registerSlotContribution("terminal.sidePanel", {
actionId: SIDE_PANEL_ID,
titleKey: "ai.assistant",
icon: Bot,
kind: "component",
component: TerminalDock as unknown as ComponentType<
component: TerminalSidePanel as unknown as ComponentType<
Record<string, unknown>
>,
when: offeredOnHost,
@@ -165,7 +165,9 @@ export function activate(app: TermixApp): void {
app.registerAction(
ASSISTANT_ACTION,
(terminal: TerminalSlotApi) =>
terminal?.openDock(DOCK_ID, { context: terminal.getBufferText() }),
terminal?.openSidePanel(SIDE_PANEL_ID, {
context: terminal.getBufferText(),
}),
{ permission: "services.use" },
);
@@ -18,7 +18,7 @@ import {
persistAiPanelPosition,
readStoredAiPanelPosition,
type ToolbarPosition,
} from "@/features/terminal/toolbar-geometry";
} from "./panel-geometry";
interface HistoryEntry {
role: "user" | "assistant";
@@ -0,0 +1,90 @@
/**
* Where the docked assistant panel sits over a terminal. Same clamping rules
* as the terminal's own toolbar, kept here so the panel owns its geometry.
*/
export interface ToolbarPosition {
x: number;
y: number;
}
export interface RectLike {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
}
const AI_PANEL_POSITION_STORAGE_KEY = "termix-terminal-ai-panel-position";
const TOOLBAR_MARGIN = 8;
const RECOVERY_SIZE = 44;
function sanitizeToolbarPosition(value: unknown): ToolbarPosition {
if (!value || typeof value !== "object") return { x: 0, y: 0 };
const candidate = value as Partial<ToolbarPosition>;
return Number.isFinite(candidate.x) && Number.isFinite(candidate.y)
? { x: Number(candidate.x), y: Number(candidate.y) }
: { x: 0, y: 0 };
}
export function readStoredAiPanelPosition(): ToolbarPosition {
if (typeof window === "undefined") return { x: 0, y: 0 };
try {
return sanitizeToolbarPosition(
JSON.parse(
window.localStorage.getItem(AI_PANEL_POSITION_STORAGE_KEY) ?? "null",
),
);
} catch {
return { x: 0, y: 0 };
}
}
export function persistAiPanelPosition(position: ToolbarPosition): void {
try {
window.localStorage.setItem(
AI_PANEL_POSITION_STORAGE_KEY,
JSON.stringify(sanitizeToolbarPosition(position)),
);
} catch {
// Storage may be unavailable in hardened browser contexts.
}
}
export function clampToolbarPosition(
position: ToolbarPosition,
toolbarRect: RectLike,
hostRect: RectLike,
renderedPosition: ToolbarPosition = position,
): ToolbarPosition {
if (
!toolbarRect.width ||
!toolbarRect.height ||
!hostRect.width ||
!hostRect.height
)
return sanitizeToolbarPosition(position);
const left = hostRect.left + TOOLBAR_MARGIN;
const right = hostRect.right - TOOLBAR_MARGIN;
const top = hostRect.top + TOOLBAR_MARGIN;
const bottom = hostRect.bottom - TOOLBAR_MARGIN;
const baseLeft = toolbarRect.left - renderedPosition.x;
const baseRight = toolbarRect.right - renderedPosition.x;
const baseTop = toolbarRect.top - renderedPosition.y;
const baseBottom = toolbarRect.bottom - renderedPosition.y;
const clamp = (min: number, max: number, value: number) =>
Math.min(max, Math.max(min, value));
const xMin =
toolbarRect.width <= right - left
? left - baseLeft
: left + RECOVERY_SIZE - baseRight;
const xMax = right - baseRight;
const yMin =
toolbarRect.height <= bottom - top
? top - baseTop
: top + RECOVERY_SIZE - baseBottom;
const yMax = bottom - baseBottom;
return { x: clamp(xMin, xMax, position.x), y: clamp(yMin, yMax, position.y) };
}
@@ -0,0 +1,31 @@
/**
* What the ssh-terminal plugin hands its slots, as that plugin documents it.
* Kept as a local copy: a plugin never imports another plugin's source.
*/
interface TerminalHost {
id: number | string;
name?: string;
ip?: string;
enableAiAssistant?: boolean;
[key: string]: unknown;
}
/** What a "terminal.toolbar" action is invoked with. */
export interface TerminalSlotApi {
host: TerminalHost | undefined;
getBufferText: () => string;
sessionId: () => string | null;
openSidePanel: (panelId: string, props?: Record<string, unknown>) => void;
runCommand: (command: string) => void;
}
/** What a "terminal.sidePanel" component is rendered with. */
export interface TerminalSidePanelProps {
host: TerminalHost | undefined;
hostId: number | undefined;
hostLabel: string;
panelProps: Record<string, unknown>;
onClose: () => void;
onRunInTerminal: (command: string) => void;
}
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
listCommandHistory,
setPluginServices,
} from "../../src/backend/services.js";
afterEach(() => setPluginServices(null));
describe("listCommandHistory", () => {
it("reads the user's history through terminal.history", async () => {
const list = vi.fn(async () => [
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
]);
const get = vi.fn(() => ({ list }));
setPluginServices({ get, provide: vi.fn() } as never);
const history = await listCommandHistory("user-1", 4, 25);
expect(get).toHaveBeenCalledWith("terminal.history", { userId: "user-1" });
expect(list).toHaveBeenCalledWith(4, 25);
expect(history).toEqual([
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
]);
});
it("answers null while the terminal is off or refuses the user", async () => {
expect(await listCommandHistory("user-1", 4, 25)).toBeNull();
setPluginServices({
get: () => ({
list: async () => {
throw new Error("unavailable");
},
}),
provide: vi.fn(),
} as never);
expect(await listCommandHistory("user-1", 4, 25)).toBeNull();
});
});