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; url?: string; type?: "stdio" | "sse"; } const sessions = new Map(); 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 = `
${name}
`; // 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 = ` ${name} `; 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 = ''; } 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 = ''; } 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 = ''; }); // 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 = ''; codingAgentSelect.value = "claude"; }); // MCP Server management functions async function loadMcpServers() { const addMcpServerBtn = document.getElementById("add-mcp-server"); // Show loading spinner if (addMcpServerBtn) { addMcpServerBtn.innerHTML = ''; 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 = `
${server.name}
`; // 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 = '
'; } detailsModal?.classList.remove("hidden"); try { const details = await ipcRenderer.invoke("get-mcp-server-details", name); if (detailsContent) { let html = ""; if (details.scope) { html += `
Scope: ${details.scope}
`; } if (details.status) { html += `
Status: ${details.status}
`; } if (details.type) { html += `
Type: ${details.type}
`; } if (details.url) { html += `
URL: ${details.url}
`; } if (details.command) { html += `
Command: ${details.command}
`; } if (details.args) { html += `
Args: ${details.args}
`; } 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 = `
Failed to load server details
`; } } } // 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();