mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user