874 lines
26 KiB
TypeScript
874 lines
26 KiB
TypeScript
import { ipcRenderer } from "electron";
|
||
import { Terminal } from "xterm";
|
||
import { FitAddon } from "xterm-addon-fit";
|
||
|
||
interface SessionConfig {
|
||
projectDir: string;
|
||
parentBranch: string;
|
||
codingAgent: string;
|
||
skipPermissions: boolean;
|
||
}
|
||
|
||
interface PersistedSession {
|
||
id: string;
|
||
number: number;
|
||
name: string;
|
||
config: SessionConfig;
|
||
worktreePath: string;
|
||
createdAt: number;
|
||
}
|
||
|
||
interface Session {
|
||
id: string;
|
||
terminal: Terminal | null;
|
||
fitAddon: FitAddon | null;
|
||
element: HTMLDivElement | null;
|
||
name: string;
|
||
config: SessionConfig;
|
||
worktreePath: string;
|
||
hasActivePty: boolean;
|
||
}
|
||
|
||
interface McpServer {
|
||
name: string;
|
||
connected?: boolean;
|
||
command?: string;
|
||
args?: string[];
|
||
env?: Record<string, string>;
|
||
url?: string;
|
||
type?: "stdio" | "sse";
|
||
}
|
||
|
||
const sessions = new Map<string, Session>();
|
||
let activeSessionId: string | null = null;
|
||
let mcpServers: McpServer[] = [];
|
||
|
||
function createTerminalUI(sessionId: string) {
|
||
const term = new Terminal({
|
||
cursorBlink: true,
|
||
fontSize: 14,
|
||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||
theme: {
|
||
background: "#000000",
|
||
foreground: "#ffffff",
|
||
},
|
||
});
|
||
|
||
const fitAddon = new FitAddon();
|
||
term.loadAddon(fitAddon);
|
||
|
||
const sessionElement = document.createElement("div");
|
||
sessionElement.className = "session-wrapper";
|
||
sessionElement.id = `session-${sessionId}`;
|
||
|
||
const container = document.getElementById("session-container");
|
||
if (container) {
|
||
container.appendChild(sessionElement);
|
||
}
|
||
|
||
term.open(sessionElement);
|
||
fitAddon.fit();
|
||
|
||
term.onData((data) => {
|
||
ipcRenderer.send("session-input", sessionId, data);
|
||
});
|
||
|
||
// Handle resize
|
||
const resizeHandler = () => {
|
||
if (activeSessionId === sessionId) {
|
||
fitAddon.fit();
|
||
ipcRenderer.send("session-resize", sessionId, term.cols, term.rows);
|
||
}
|
||
};
|
||
window.addEventListener("resize", resizeHandler);
|
||
|
||
return { terminal: term, fitAddon, element: sessionElement };
|
||
}
|
||
|
||
function addSession(persistedSession: PersistedSession, hasActivePty: boolean) {
|
||
const session: Session = {
|
||
id: persistedSession.id,
|
||
terminal: null,
|
||
fitAddon: null,
|
||
element: null,
|
||
name: persistedSession.name,
|
||
config: persistedSession.config,
|
||
worktreePath: persistedSession.worktreePath,
|
||
hasActivePty,
|
||
};
|
||
|
||
sessions.set(persistedSession.id, session);
|
||
|
||
// Add to sidebar
|
||
addToSidebar(persistedSession.id, persistedSession.name, hasActivePty);
|
||
|
||
// Only add tab if terminal is active
|
||
if (hasActivePty) {
|
||
addTab(persistedSession.id, persistedSession.name);
|
||
}
|
||
|
||
return session;
|
||
}
|
||
|
||
function activateSession(sessionId: string) {
|
||
const session = sessions.get(sessionId);
|
||
if (!session) return;
|
||
|
||
// If terminal UI doesn't exist yet, create it
|
||
if (!session.terminal) {
|
||
const ui = createTerminalUI(sessionId);
|
||
session.terminal = ui.terminal;
|
||
session.fitAddon = ui.fitAddon;
|
||
session.element = ui.element;
|
||
}
|
||
|
||
session.hasActivePty = true;
|
||
updateSessionState(sessionId, true);
|
||
|
||
// Add tab if it doesn't exist
|
||
if (!document.getElementById(`tab-${sessionId}`)) {
|
||
addTab(sessionId, session.name);
|
||
}
|
||
|
||
// Switch to this session
|
||
switchToSession(sessionId);
|
||
}
|
||
|
||
function updateSessionState(sessionId: string, isActive: boolean) {
|
||
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
|
||
const indicator = sidebarItem?.querySelector(".session-indicator");
|
||
|
||
if (indicator) {
|
||
if (isActive) {
|
||
indicator.classList.add("active");
|
||
} else {
|
||
indicator.classList.remove("active");
|
||
}
|
||
}
|
||
}
|
||
|
||
function addToSidebar(sessionId: string, name: string, hasActivePty: boolean) {
|
||
const list = document.getElementById("session-list");
|
||
if (!list) return;
|
||
|
||
const item = document.createElement("div");
|
||
item.id = `sidebar-${sessionId}`;
|
||
item.className = "session-list-item";
|
||
item.innerHTML = `
|
||
<div class="flex items-center space-x-2 flex-1 session-name-container">
|
||
<span class="session-indicator ${hasActivePty ? 'active' : ''}"></span>
|
||
<span class="truncate session-name-text" data-id="${sessionId}">${name}</span>
|
||
<input type="text" class="session-name-input hidden" data-id="${sessionId}" value="${name}" />
|
||
</div>
|
||
<button class="session-delete-btn" data-id="${sessionId}" title="Delete session">×</button>
|
||
`;
|
||
|
||
// Click on session name to edit
|
||
const nameText = item.querySelector(".session-name-text");
|
||
const nameInput = item.querySelector(".session-name-input") as HTMLInputElement;
|
||
|
||
nameText?.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
startEditingSessionName(sessionId);
|
||
});
|
||
|
||
// Handle input blur and enter key
|
||
nameInput?.addEventListener("blur", () => {
|
||
finishEditingSessionName(sessionId);
|
||
});
|
||
|
||
nameInput?.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
finishEditingSessionName(sessionId);
|
||
} else if (e.key === "Escape") {
|
||
cancelEditingSessionName(sessionId);
|
||
}
|
||
});
|
||
|
||
item.addEventListener("click", (e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (!target.classList.contains("session-delete-btn") &&
|
||
!target.classList.contains("session-name-text") &&
|
||
!target.classList.contains("session-name-input")) {
|
||
handleSessionClick(sessionId);
|
||
}
|
||
});
|
||
|
||
const deleteBtn = item.querySelector(".session-delete-btn");
|
||
deleteBtn?.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
deleteSession(sessionId);
|
||
});
|
||
|
||
list.appendChild(item);
|
||
}
|
||
|
||
function startEditingSessionName(sessionId: string) {
|
||
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
|
||
const nameText = sidebarItem?.querySelector(".session-name-text");
|
||
const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement;
|
||
|
||
if (nameText && nameInput) {
|
||
nameText.classList.add("hidden");
|
||
nameInput.classList.remove("hidden");
|
||
nameInput.focus();
|
||
nameInput.select();
|
||
}
|
||
}
|
||
|
||
function finishEditingSessionName(sessionId: string) {
|
||
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
|
||
const nameText = sidebarItem?.querySelector(".session-name-text");
|
||
const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement;
|
||
const session = sessions.get(sessionId);
|
||
|
||
if (nameText && nameInput && session) {
|
||
const newName = nameInput.value.trim();
|
||
if (newName && newName !== session.name) {
|
||
// Update session name
|
||
session.name = newName;
|
||
nameText.textContent = newName;
|
||
|
||
// Update tab name if exists
|
||
const tab = document.getElementById(`tab-${sessionId}`);
|
||
const tabName = tab?.querySelector(".tab-name");
|
||
if (tabName) {
|
||
tabName.textContent = newName;
|
||
}
|
||
|
||
// Save to backend
|
||
ipcRenderer.send("rename-session", sessionId, newName);
|
||
}
|
||
|
||
nameInput.classList.add("hidden");
|
||
nameText.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
function cancelEditingSessionName(sessionId: string) {
|
||
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
|
||
const nameText = sidebarItem?.querySelector(".session-name-text");
|
||
const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement;
|
||
const session = sessions.get(sessionId);
|
||
|
||
if (nameText && nameInput && session) {
|
||
// Reset to original name
|
||
nameInput.value = session.name;
|
||
nameInput.classList.add("hidden");
|
||
nameText.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
function handleSessionClick(sessionId: string) {
|
||
const session = sessions.get(sessionId);
|
||
if (!session) return;
|
||
|
||
if (session.hasActivePty) {
|
||
// Just switch to it
|
||
switchToSession(sessionId);
|
||
} else {
|
||
// Reopen the session
|
||
ipcRenderer.send("reopen-session", sessionId);
|
||
}
|
||
}
|
||
|
||
function addTab(sessionId: string, name: string) {
|
||
const tabsContainer = document.getElementById("tabs");
|
||
if (!tabsContainer) return;
|
||
|
||
const tab = document.createElement("div");
|
||
tab.id = `tab-${sessionId}`;
|
||
tab.className = "tab";
|
||
tab.innerHTML = `
|
||
<span class="tab-name">${name}</span>
|
||
<button class="tab-close-btn" data-id="${sessionId}">×</button>
|
||
`;
|
||
|
||
tab.addEventListener("click", (e) => {
|
||
if (!(e.target as HTMLElement).classList.contains("tab-close-btn")) {
|
||
switchToSession(sessionId);
|
||
}
|
||
});
|
||
|
||
const closeBtn = tab.querySelector(".tab-close-btn");
|
||
closeBtn?.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
closeSession(sessionId);
|
||
});
|
||
|
||
tabsContainer.appendChild(tab);
|
||
}
|
||
|
||
function switchToSession(sessionId: string) {
|
||
// Hide all sessions
|
||
sessions.forEach((session, id) => {
|
||
if (session.element) {
|
||
session.element.classList.remove("active");
|
||
}
|
||
document.getElementById(`tab-${id}`)?.classList.remove("active");
|
||
document.getElementById(`sidebar-${id}`)?.classList.remove("active");
|
||
});
|
||
|
||
// Show active session
|
||
const session = sessions.get(sessionId);
|
||
if (session && session.element && session.terminal && session.fitAddon) {
|
||
session.element.classList.add("active");
|
||
document.getElementById(`tab-${sessionId}`)?.classList.add("active");
|
||
document.getElementById(`sidebar-${sessionId}`)?.classList.add("active");
|
||
activeSessionId = sessionId;
|
||
|
||
// Focus and resize
|
||
session.terminal.focus();
|
||
setTimeout(() => {
|
||
if (session.fitAddon && session.terminal) {
|
||
session.fitAddon.fit();
|
||
ipcRenderer.send("session-resize", sessionId, session.terminal.cols, session.terminal.rows);
|
||
}
|
||
}, 0);
|
||
}
|
||
}
|
||
|
||
function closeSession(sessionId: string) {
|
||
const session = sessions.get(sessionId);
|
||
if (!session) return;
|
||
|
||
// Remove terminal UI
|
||
if (session.element) {
|
||
session.element.remove();
|
||
}
|
||
if (session.terminal) {
|
||
session.terminal.dispose();
|
||
}
|
||
|
||
// Remove tab
|
||
document.getElementById(`tab-${sessionId}`)?.remove();
|
||
|
||
// Update session state
|
||
session.terminal = null;
|
||
session.fitAddon = null;
|
||
session.element = null;
|
||
session.hasActivePty = false;
|
||
|
||
// Update UI indicator
|
||
updateSessionState(sessionId, false);
|
||
|
||
// Close PTY in main process
|
||
ipcRenderer.send("close-session", sessionId);
|
||
|
||
// Switch to another active session
|
||
if (activeSessionId === sessionId) {
|
||
const activeSessions = Array.from(sessions.values()).filter(s => s.hasActivePty);
|
||
if (activeSessions.length > 0) {
|
||
switchToSession(activeSessions[0].id);
|
||
} else {
|
||
activeSessionId = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
function deleteSession(sessionId: string) {
|
||
const session = sessions.get(sessionId);
|
||
if (!session) return;
|
||
|
||
// Confirm deletion
|
||
if (!confirm(`Delete ${session.name}? This will remove the git worktree.`)) {
|
||
return;
|
||
}
|
||
|
||
// Remove from UI
|
||
if (session.element) {
|
||
session.element.remove();
|
||
}
|
||
if (session.terminal) {
|
||
session.terminal.dispose();
|
||
}
|
||
document.getElementById(`tab-${sessionId}`)?.remove();
|
||
document.getElementById(`sidebar-${sessionId}`)?.remove();
|
||
|
||
// Remove from sessions map
|
||
sessions.delete(sessionId);
|
||
|
||
// Delete in main process (handles worktree removal)
|
||
ipcRenderer.send("delete-session", sessionId);
|
||
|
||
// Switch to another session
|
||
if (activeSessionId === sessionId) {
|
||
const remainingSessions = Array.from(sessions.values()).filter(s => s.hasActivePty);
|
||
if (remainingSessions.length > 0) {
|
||
switchToSession(remainingSessions[0].id);
|
||
} else {
|
||
activeSessionId = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle session output
|
||
ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
|
||
const session = sessions.get(sessionId);
|
||
if (session && session.terminal) {
|
||
session.terminal.write(data);
|
||
}
|
||
});
|
||
|
||
// Handle session created
|
||
ipcRenderer.on("session-created", (_event, sessionId: string, persistedSession: any) => {
|
||
const session = addSession(persistedSession, true);
|
||
activateSession(sessionId);
|
||
});
|
||
|
||
// Handle session reopened
|
||
ipcRenderer.on("session-reopened", (_event, sessionId: string) => {
|
||
activateSession(sessionId);
|
||
});
|
||
|
||
// Handle session deleted
|
||
ipcRenderer.on("session-deleted", (_event, sessionId: string) => {
|
||
const session = sessions.get(sessionId);
|
||
if (session) {
|
||
if (session.element) session.element.remove();
|
||
if (session.terminal) session.terminal.dispose();
|
||
document.getElementById(`tab-${sessionId}`)?.remove();
|
||
document.getElementById(`sidebar-${sessionId}`)?.remove();
|
||
sessions.delete(sessionId);
|
||
|
||
if (activeSessionId === sessionId) {
|
||
const remainingSessions = Array.from(sessions.values()).filter(s => s.hasActivePty);
|
||
if (remainingSessions.length > 0) {
|
||
switchToSession(remainingSessions[0].id);
|
||
} else {
|
||
activeSessionId = null;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Load persisted sessions on startup
|
||
ipcRenderer.on("load-persisted-sessions", (_event, persistedSessions: PersistedSession[]) => {
|
||
persistedSessions.forEach(ps => {
|
||
addSession(ps, false);
|
||
});
|
||
});
|
||
|
||
// Modal handling
|
||
const modal = document.getElementById("config-modal");
|
||
const projectDirInput = document.getElementById("project-dir") as HTMLInputElement;
|
||
const parentBranchSelect = document.getElementById("parent-branch") as HTMLSelectElement;
|
||
const codingAgentSelect = document.getElementById("coding-agent") as HTMLSelectElement;
|
||
const skipPermissionsCheckbox = document.getElementById("skip-permissions") as HTMLInputElement;
|
||
const skipPermissionsGroup = skipPermissionsCheckbox?.parentElement?.parentElement;
|
||
const browseDirBtn = document.getElementById("browse-dir");
|
||
const cancelBtn = document.getElementById("cancel-session");
|
||
const createBtn = document.getElementById("create-session");
|
||
|
||
let selectedDirectory = "";
|
||
|
||
// Toggle skip permissions checkbox visibility based on coding agent
|
||
codingAgentSelect?.addEventListener("change", () => {
|
||
if (codingAgentSelect.value === "claude") {
|
||
skipPermissionsGroup?.classList.remove("hidden");
|
||
} else {
|
||
skipPermissionsGroup?.classList.add("hidden");
|
||
}
|
||
});
|
||
|
||
// New session button - opens modal
|
||
document.getElementById("new-session")?.addEventListener("click", async () => {
|
||
modal?.classList.remove("hidden");
|
||
|
||
// Load last used settings
|
||
const lastSettings = await ipcRenderer.invoke("get-last-settings");
|
||
|
||
if (lastSettings.projectDir) {
|
||
selectedDirectory = lastSettings.projectDir;
|
||
projectDirInput.value = lastSettings.projectDir;
|
||
|
||
// Load git branches for the last directory
|
||
const branches = await ipcRenderer.invoke("get-branches", lastSettings.projectDir);
|
||
parentBranchSelect.innerHTML = "";
|
||
|
||
if (branches.length === 0) {
|
||
parentBranchSelect.innerHTML = '<option value="">No git repository found</option>';
|
||
} else {
|
||
branches.forEach((branch: string) => {
|
||
const option = document.createElement("option");
|
||
option.value = branch;
|
||
option.textContent = branch;
|
||
if (branch === lastSettings.parentBranch) {
|
||
option.selected = true;
|
||
}
|
||
parentBranchSelect.appendChild(option);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Set last used coding agent
|
||
if (lastSettings.codingAgent) {
|
||
codingAgentSelect.value = lastSettings.codingAgent;
|
||
}
|
||
|
||
// Set last used skip permissions setting and visibility
|
||
if (lastSettings.skipPermissions !== undefined) {
|
||
skipPermissionsCheckbox.checked = lastSettings.skipPermissions;
|
||
}
|
||
|
||
// Show/hide skip permissions based on coding agent
|
||
if (lastSettings.codingAgent === "codex") {
|
||
skipPermissionsGroup?.classList.add("hidden");
|
||
} else {
|
||
skipPermissionsGroup?.classList.remove("hidden");
|
||
}
|
||
});
|
||
|
||
// Browse directory
|
||
browseDirBtn?.addEventListener("click", async () => {
|
||
const dir = await ipcRenderer.invoke("select-directory");
|
||
if (dir) {
|
||
selectedDirectory = dir;
|
||
projectDirInput.value = dir;
|
||
|
||
// Load git branches
|
||
const branches = await ipcRenderer.invoke("get-branches", dir);
|
||
parentBranchSelect.innerHTML = "";
|
||
|
||
if (branches.length === 0) {
|
||
parentBranchSelect.innerHTML = '<option value="">No git repository found</option>';
|
||
} else {
|
||
branches.forEach((branch: string) => {
|
||
const option = document.createElement("option");
|
||
option.value = branch;
|
||
option.textContent = branch;
|
||
parentBranchSelect.appendChild(option);
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Cancel button
|
||
cancelBtn?.addEventListener("click", () => {
|
||
modal?.classList.add("hidden");
|
||
projectDirInput.value = "";
|
||
selectedDirectory = "";
|
||
parentBranchSelect.innerHTML = '<option value="">Loading branches...</option>';
|
||
});
|
||
|
||
// Create session button
|
||
createBtn?.addEventListener("click", () => {
|
||
if (!selectedDirectory) {
|
||
alert("Please select a project directory");
|
||
return;
|
||
}
|
||
|
||
const config: SessionConfig = {
|
||
projectDir: selectedDirectory,
|
||
parentBranch: parentBranchSelect.value,
|
||
codingAgent: codingAgentSelect.value,
|
||
skipPermissions: codingAgentSelect.value === "claude" ? skipPermissionsCheckbox.checked : false,
|
||
};
|
||
|
||
// Save settings for next time
|
||
ipcRenderer.send("save-settings", config);
|
||
|
||
// Create the session
|
||
ipcRenderer.send("create-session", config);
|
||
modal?.classList.add("hidden");
|
||
|
||
// Reset form
|
||
projectDirInput.value = "";
|
||
selectedDirectory = "";
|
||
parentBranchSelect.innerHTML = '<option value="">Loading branches...</option>';
|
||
codingAgentSelect.value = "claude";
|
||
});
|
||
|
||
// MCP Server management functions
|
||
async function loadMcpServers() {
|
||
const addMcpServerBtn = document.getElementById("add-mcp-server");
|
||
|
||
// Show loading spinner
|
||
if (addMcpServerBtn) {
|
||
addMcpServerBtn.innerHTML = '<span class="loading-spinner"></span>';
|
||
addMcpServerBtn.classList.add("pointer-events-none");
|
||
}
|
||
|
||
try {
|
||
const servers = await ipcRenderer.invoke("list-mcp-servers");
|
||
mcpServers = servers;
|
||
renderMcpServers();
|
||
} catch (error) {
|
||
console.error("Failed to load MCP servers:", error);
|
||
} finally {
|
||
// Restore button
|
||
if (addMcpServerBtn) {
|
||
addMcpServerBtn.innerHTML = '+';
|
||
addMcpServerBtn.classList.remove("pointer-events-none");
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderMcpServers() {
|
||
const list = document.getElementById("mcp-server-list");
|
||
if (!list) return;
|
||
|
||
list.innerHTML = "";
|
||
|
||
mcpServers.forEach(server => {
|
||
const item = document.createElement("div");
|
||
item.className = "session-list-item";
|
||
const indicatorClass = server.connected ? "active" : "disconnected";
|
||
item.innerHTML = `
|
||
<div class="flex items-center space-x-2 flex-1">
|
||
<span class="session-indicator ${indicatorClass}"></span>
|
||
<span class="truncate">${server.name}</span>
|
||
</div>
|
||
<button class="session-delete-btn mcp-remove-btn" data-name="${server.name}" title="Remove server">×</button>
|
||
`;
|
||
|
||
// Click to show details
|
||
item.addEventListener("click", async (e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (!target.classList.contains("mcp-remove-btn")) {
|
||
await showMcpServerDetails(server.name);
|
||
}
|
||
});
|
||
|
||
const removeBtn = item.querySelector(".mcp-remove-btn");
|
||
removeBtn?.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
if (confirm(`Remove MCP server "${server.name}"?`)) {
|
||
try {
|
||
await ipcRenderer.invoke("remove-mcp-server", server.name);
|
||
await loadMcpServers();
|
||
} catch (error) {
|
||
alert(`Failed to remove server: ${error}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
list.appendChild(item);
|
||
});
|
||
}
|
||
|
||
async function showMcpServerDetails(name: string) {
|
||
const detailsModal = document.getElementById("mcp-details-modal");
|
||
const detailsTitle = document.getElementById("mcp-details-title");
|
||
const detailsContent = document.getElementById("mcp-details-content");
|
||
|
||
// Show modal immediately with loading state
|
||
if (detailsTitle) {
|
||
detailsTitle.textContent = name;
|
||
}
|
||
|
||
if (detailsContent) {
|
||
detailsContent.innerHTML = '<div class="flex items-center justify-center py-8"><span class="loading-spinner" style="width: 24px; height: 24px; border-width: 3px;"></span></div>';
|
||
}
|
||
|
||
detailsModal?.classList.remove("hidden");
|
||
|
||
try {
|
||
const details = await ipcRenderer.invoke("get-mcp-server-details", name);
|
||
|
||
if (detailsContent) {
|
||
let html = "";
|
||
if (details.scope) {
|
||
html += `<div><strong>Scope:</strong> ${details.scope}</div>`;
|
||
}
|
||
if (details.status) {
|
||
html += `<div><strong>Status:</strong> ${details.status}</div>`;
|
||
}
|
||
if (details.type) {
|
||
html += `<div><strong>Type:</strong> ${details.type}</div>`;
|
||
}
|
||
if (details.url) {
|
||
html += `<div><strong>URL:</strong> ${details.url}</div>`;
|
||
}
|
||
if (details.command) {
|
||
html += `<div><strong>Command:</strong> ${details.command}</div>`;
|
||
}
|
||
if (details.args) {
|
||
html += `<div><strong>Args:</strong> ${details.args}</div>`;
|
||
}
|
||
|
||
detailsContent.innerHTML = html;
|
||
}
|
||
|
||
// Store current server name for remove button
|
||
const removeMcpDetailsBtn = document.getElementById("remove-mcp-details") as HTMLButtonElement;
|
||
if (removeMcpDetailsBtn) {
|
||
removeMcpDetailsBtn.dataset.serverName = name;
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to get server details:", error);
|
||
if (detailsContent) {
|
||
detailsContent.innerHTML = `<div class="text-red-400">Failed to load server details</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// MCP Modal handling
|
||
const mcpModal = document.getElementById("mcp-modal");
|
||
const mcpNameInput = document.getElementById("mcp-name") as HTMLInputElement;
|
||
const mcpTypeSelect = document.getElementById("mcp-type") as HTMLSelectElement;
|
||
const mcpCommandInput = document.getElementById("mcp-command") as HTMLInputElement;
|
||
const mcpArgsInput = document.getElementById("mcp-args") as HTMLInputElement;
|
||
const mcpEnvInput = document.getElementById("mcp-env") as HTMLTextAreaElement;
|
||
const mcpUrlInput = document.getElementById("mcp-url") as HTMLInputElement;
|
||
const mcpHeadersInput = document.getElementById("mcp-headers") as HTMLTextAreaElement;
|
||
const mcpAlwaysAllowInput = document.getElementById("mcp-always-allow") as HTMLInputElement;
|
||
const localFields = document.getElementById("local-fields");
|
||
const remoteFields = document.getElementById("remote-fields");
|
||
const cancelMcpBtn = document.getElementById("cancel-mcp");
|
||
const addMcpBtn = document.getElementById("add-mcp") as HTMLButtonElement;
|
||
|
||
// Toggle fields based on server type
|
||
mcpTypeSelect?.addEventListener("change", () => {
|
||
if (mcpTypeSelect.value === "local") {
|
||
localFields!.style.display = "block";
|
||
remoteFields!.style.display = "none";
|
||
} else {
|
||
localFields!.style.display = "none";
|
||
remoteFields!.style.display = "block";
|
||
}
|
||
});
|
||
|
||
// Add MCP server button - opens modal
|
||
document.getElementById("add-mcp-server")?.addEventListener("click", () => {
|
||
mcpModal?.classList.remove("hidden");
|
||
mcpNameInput.value = "";
|
||
mcpTypeSelect.value = "local";
|
||
mcpCommandInput.value = "";
|
||
mcpArgsInput.value = "";
|
||
mcpEnvInput.value = "";
|
||
mcpUrlInput.value = "";
|
||
mcpHeadersInput.value = "";
|
||
mcpAlwaysAllowInput.value = "";
|
||
localFields!.style.display = "block";
|
||
remoteFields!.style.display = "none";
|
||
});
|
||
|
||
// Cancel MCP button
|
||
cancelMcpBtn?.addEventListener("click", () => {
|
||
mcpModal?.classList.add("hidden");
|
||
});
|
||
|
||
// Add MCP button
|
||
addMcpBtn?.addEventListener("click", async () => {
|
||
const name = mcpNameInput.value.trim();
|
||
const serverType = mcpTypeSelect.value;
|
||
|
||
if (!name) {
|
||
alert("Please enter a server name");
|
||
return;
|
||
}
|
||
|
||
const config: any = {};
|
||
|
||
if (serverType === "local") {
|
||
config.type = "stdio";
|
||
|
||
const command = mcpCommandInput.value.trim();
|
||
const argsInput = mcpArgsInput.value.trim();
|
||
|
||
if (!command) {
|
||
alert("Please enter a command");
|
||
return;
|
||
}
|
||
|
||
config.command = command;
|
||
if (argsInput) {
|
||
config.args = argsInput.split(" ").filter(a => a.trim());
|
||
}
|
||
|
||
// Parse environment variables if provided
|
||
const envInput = mcpEnvInput.value.trim();
|
||
if (envInput) {
|
||
try {
|
||
config.env = JSON.parse(envInput);
|
||
} catch (error) {
|
||
alert("Invalid JSON for environment variables");
|
||
return;
|
||
}
|
||
}
|
||
} else {
|
||
// Remote server
|
||
config.type = "sse";
|
||
|
||
const url = mcpUrlInput.value.trim();
|
||
|
||
if (!url) {
|
||
alert("Please enter a server URL");
|
||
return;
|
||
}
|
||
|
||
config.url = url;
|
||
|
||
// Parse headers if provided
|
||
const headersInput = mcpHeadersInput.value.trim();
|
||
if (headersInput) {
|
||
try {
|
||
config.headers = JSON.parse(headersInput);
|
||
} catch (error) {
|
||
alert("Invalid JSON for headers");
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Parse always allow tools
|
||
const alwaysAllowInput = mcpAlwaysAllowInput.value.trim();
|
||
if (alwaysAllowInput) {
|
||
config.alwaysAllow = alwaysAllowInput.split(",").map(t => t.trim()).filter(t => t);
|
||
}
|
||
|
||
// Show loading state
|
||
const originalText = addMcpBtn.textContent;
|
||
addMcpBtn.textContent = "Adding...";
|
||
addMcpBtn.disabled = true;
|
||
addMcpBtn.classList.add("opacity-50", "cursor-not-allowed");
|
||
|
||
try {
|
||
await ipcRenderer.invoke("add-mcp-server", name, config);
|
||
await loadMcpServers();
|
||
mcpModal?.classList.add("hidden");
|
||
} catch (error) {
|
||
console.error("Error adding server:", error);
|
||
alert(`Failed to add server: ${error}`);
|
||
} finally {
|
||
// Reset button state
|
||
addMcpBtn.textContent = originalText;
|
||
addMcpBtn.disabled = false;
|
||
addMcpBtn.classList.remove("opacity-50", "cursor-not-allowed");
|
||
}
|
||
});
|
||
|
||
// MCP Details Modal handling
|
||
const closeMcpDetailsBtn = document.getElementById("close-mcp-details");
|
||
const removeMcpDetailsBtn = document.getElementById("remove-mcp-details") as HTMLButtonElement;
|
||
const mcpDetailsModal = document.getElementById("mcp-details-modal");
|
||
|
||
closeMcpDetailsBtn?.addEventListener("click", () => {
|
||
mcpDetailsModal?.classList.add("hidden");
|
||
});
|
||
|
||
removeMcpDetailsBtn?.addEventListener("click", async () => {
|
||
const serverName = removeMcpDetailsBtn.dataset.serverName;
|
||
if (!serverName) return;
|
||
|
||
if (confirm(`Remove MCP server "${serverName}"?`)) {
|
||
try {
|
||
await ipcRenderer.invoke("remove-mcp-server", serverName);
|
||
mcpDetailsModal?.classList.add("hidden");
|
||
await loadMcpServers();
|
||
} catch (error) {
|
||
alert(`Failed to remove server: ${error}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Listen for MCP server updates from main process
|
||
ipcRenderer.on("mcp-servers-updated", (_event, servers: McpServer[]) => {
|
||
mcpServers = servers;
|
||
renderMcpServers();
|
||
});
|
||
|
||
// Load MCP servers on startup
|
||
loadMcpServers();
|