import {FitAddon} from "@xterm/addon-fit"; import {Terminal} from "xterm"; import {PersistedSession, SessionConfig, FileDiff, MergeStatus, MergeResult} from "./types"; import {isClaudeSessionReady} from "./terminal-utils"; import {ipcAdapter} from "./ipc-adapter"; 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"; } interface TerminalSettings { fontFamily: string; fontSize: number; theme: string; // Theme preset name cursorBlink: boolean; worktreeDir: string; } interface ThemeColors { background: string; foreground: string; cursor?: string; cursorAccent?: string; selection?: string; black?: string; red?: string; green?: string; yellow?: string; blue?: string; magenta?: string; cyan?: string; white?: string; brightBlack?: string; brightRed?: string; brightGreen?: string; brightYellow?: string; brightBlue?: string; brightMagenta?: string; brightCyan?: string; brightWhite?: string; } // Theme presets const THEME_PRESETS: Record = { "macos-light": { background: "#ffffff", foreground: "#000000", cursor: "#000000", selection: "#b4d5fe", black: "#000000", red: "#c23621", green: "#25bc24", yellow: "#adad27", blue: "#492ee1", magenta: "#d338d3", cyan: "#33bbc8", white: "#cbcccd", brightBlack: "#818383", brightRed: "#fc391f", brightGreen: "#31e722", brightYellow: "#eaec23", brightBlue: "#5833ff", brightMagenta: "#f935f8", brightCyan: "#14f0f0", brightWhite: "#e9ebeb", }, "macos-dark": { background: "#000000", foreground: "#ffffff", cursor: "#ffffff", selection: "#4d4d4d", black: "#000000", red: "#c23621", green: "#25bc24", yellow: "#adad27", blue: "#492ee1", magenta: "#d338d3", cyan: "#33bbc8", white: "#cbcccd", brightBlack: "#818383", brightRed: "#fc391f", brightGreen: "#31e722", brightYellow: "#eaec23", brightBlue: "#5833ff", brightMagenta: "#f935f8", brightCyan: "#14f0f0", brightWhite: "#e9ebeb", }, "solarized-dark": { background: "#002b36", foreground: "#839496", cursor: "#839496", selection: "#073642", black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900", blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5", brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75", brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4", brightCyan: "#93a1a1", brightWhite: "#fdf6e3", }, "dracula": { background: "#282a36", foreground: "#f8f8f2", cursor: "#f8f8f2", selection: "#44475a", black: "#21222c", red: "#ff5555", green: "#50fa7b", yellow: "#f1fa8c", blue: "#bd93f9", magenta: "#ff79c6", cyan: "#8be9fd", white: "#f8f8f2", brightBlack: "#6272a4", brightRed: "#ff6e6e", brightGreen: "#69ff94", brightYellow: "#ffffa5", brightBlue: "#d6acff", brightMagenta: "#ff92df", brightCyan: "#a4ffff", brightWhite: "#ffffff", }, "one-dark": { background: "#282c34", foreground: "#abb2bf", cursor: "#528bff", selection: "#3e4451", black: "#282c34", red: "#e06c75", green: "#98c379", yellow: "#e5c07b", blue: "#61afef", magenta: "#c678dd", cyan: "#56b6c2", white: "#abb2bf", brightBlack: "#5c6370", brightRed: "#e06c75", brightGreen: "#98c379", brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd", brightCyan: "#56b6c2", brightWhite: "#ffffff", }, "github-dark": { background: "#0d1117", foreground: "#c9d1d9", cursor: "#58a6ff", selection: "#163c61", black: "#484f58", red: "#ff7b72", green: "#3fb950", yellow: "#d29922", blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39c5cf", white: "#b1bac4", brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364", brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff", brightCyan: "#56d4dd", brightWhite: "#f0f6fc", }, }; // Detect system theme function getSystemTheme(): "light" | "dark" { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } // Get default worktree directory (different for Electron vs Browser) function getDefaultWorktreeDir(): string { try { // Try to use Node.js modules (Electron) if (typeof require !== 'undefined') { const path = require("path"); const os = require("os"); return path.join(os.homedir(), "worktrees"); } } catch (e) { // Fallback for browser } return "~/worktrees"; } // Default settings - macOS Terminal matching system theme const DEFAULT_SETTINGS: TerminalSettings = { fontFamily: "Menlo, Monaco, 'Courier New', monospace", fontSize: 11, theme: getSystemTheme() === "dark" ? "macos-dark" : "macos-light", cursorBlink: false, worktreeDir: getDefaultWorktreeDir(), }; const sessions = new Map(); let activeSessionId: string | null = null; let mcpServers: McpServer[] = []; let mcpPollerActive = false; let terminalSettings: TerminalSettings = { ...DEFAULT_SETTINGS }; // Track activity timers for each session const activityTimers = new Map(); function createTerminalUI(sessionId: string) { const themeColors = THEME_PRESETS[terminalSettings.theme] || THEME_PRESETS["macos-dark"]; const term = new Terminal({ cursorBlink: terminalSettings.cursorBlink, fontSize: terminalSettings.fontSize, fontFamily: terminalSettings.fontFamily, theme: themeColors, }); 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); term.onData((data) => { ipcAdapter.send("session-input", sessionId, data); }); // Listen for bell character to mark unread activity term.onBell(() => { if (activeSessionId !== sessionId) { markSessionAsUnread(sessionId); } }); const resizeHandler = () => { if (activeSessionId === sessionId) { const proposedDimensions = fitAddon.proposeDimensions(); if (proposedDimensions) { fitAddon.fit(); ipcAdapter.send("session-resize", sessionId, proposedDimensions.cols, proposedDimensions.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; // Force initial fit setTimeout(() => { if (session.fitAddon) { session.fitAddon.fit(); } }, 100); } 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"); const buttonContainer = sidebarItem?.querySelector(".flex.items-center.space-x-1"); if (indicator) { if (isActive) { indicator.classList.add("active"); // Add diff button if it doesn't exist if (buttonContainer && !buttonContainer.querySelector(".view-diff-btn")) { const diffBtn = document.createElement("button"); diffBtn.className = "view-diff-btn"; diffBtn.setAttribute("data-id", sessionId); diffBtn.setAttribute("title", "View Diff"); diffBtn.textContent = "Diff"; diffBtn.addEventListener("click", (e) => { e.stopPropagation(); openDiffViewer(sessionId); }); buttonContainer.insertBefore(diffBtn, buttonContainer.firstChild); } } else { indicator.classList.remove("active"); // Remove diff button when session becomes inactive const existingDiffBtn = buttonContainer?.querySelector(".view-diff-btn"); if (existingDiffBtn) { existingDiffBtn.remove(); } } } } 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}
${hasActivePty ? `` : ''}
`; // 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("view-diff-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); }); const viewDiffBtn = item.querySelector(".view-diff-btn"); viewDiffBtn?.addEventListener("click", (e) => { e.stopPropagation(); openDiffViewer(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 ipcAdapter.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 ipcAdapter.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 markSessionAsUnread(sessionId: string) { const session = sessions.get(sessionId); if (!session) return; // Add unread indicator to tab const tab = document.getElementById(`tab-${sessionId}`); if (tab) { tab.classList.add("unread"); } } function clearUnreadStatus(sessionId: string) { const session = sessions.get(sessionId); if (!session) return; // Remove unread indicator from tab const tab = document.getElementById(`tab-${sessionId}`); if (tab) { tab.classList.remove("unread"); } } function markSessionActivity(sessionId: string) { const session = sessions.get(sessionId); if (!session) return; // Add activity indicator to tab const tab = document.getElementById(`tab-${sessionId}`); if (tab) { tab.classList.add("activity"); tab.classList.remove("unread"); } // Clear any existing timer const existingTimer = activityTimers.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); } // Set a new timer to remove activity after 1 second of no output const timer = setTimeout(() => { clearActivityStatus(sessionId); }, 1000); activityTimers.set(sessionId, timer); } function clearActivityStatus(sessionId: string) { const session = sessions.get(sessionId); if (!session) return; // Remove activity indicator from tab, but keep unread if it's set const tab = document.getElementById(`tab-${sessionId}`); if (tab) { tab.classList.remove("activity"); // If there's no unread status, transition to unread after activity ends if (!tab.classList.contains("unread") && activeSessionId !== sessionId) { tab.classList.add("unread"); } } // Clear the timer activityTimers.delete(sessionId); } 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; // Show MCP section when a session is active const mcpSection = document.getElementById("mcp-section"); if (mcpSection) { mcpSection.style.display = "block"; } // Clear MCP servers from previous session and re-render mcpServers = []; renderMcpServers(); // Load MCP servers for this session loadMcpServers(); // Clear unread and activity status when switching to this session clearUnreadStatus(sessionId); clearActivityStatus(sessionId); // Focus and resize session.terminal.focus(); // Dispatch resize event to trigger terminal resize window.dispatchEvent(new Event("resize")); } } 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 ipcAdapter.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; // Hide MCP section when no sessions are active const mcpSection = document.getElementById("mcp-section"); if (mcpSection) { mcpSection.style.display = "none"; } } } } 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) ipcAdapter.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; // Hide MCP section when no sessions are active const mcpSection = document.getElementById("mcp-section"); if (mcpSection) { mcpSection.style.display = "none"; } } } } // Handle session output ipcAdapter.on("session-output", (_event, sessionId: string, data: string) => { const session = sessions.get(sessionId); if (session && session.terminal) { // Filter out [3J (clear scrollback) to prevent viewport resets during interactive menus // Keep [2J (clear screen) which is needed for the menu redraw const filteredData = data.replace(/\x1b\[3J/g, ''); session.terminal.write(filteredData); // Only mark as unread/activity if this is not the active session if (activeSessionId !== sessionId && session.hasActivePty) { // Show activity spinner while output is coming in markSessionActivity(sessionId); // Check if Claude session is ready for input if (isClaudeSessionReady(filteredData)) { // Clear activity timer and set unread const existingTimer = activityTimers.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); activityTimers.delete(sessionId); } const tab = document.getElementById(`tab-${sessionId}`); if (tab) { tab.classList.remove("activity"); tab.classList.add("unread"); } } } } }); // Handle session created ipcAdapter.on("session-created", (_event, sessionId: string, persistedSession: any) => { const session = addSession(persistedSession, true); activateSession(sessionId); // Reset button state and close modal const createBtn = document.getElementById("create-session") as HTMLButtonElement; const modal = document.getElementById("config-modal"); const projectDirInput = document.getElementById("project-dir") as HTMLInputElement; const parentBranchSelect = document.getElementById("parent-branch") as HTMLSelectElement; const branchNameInput = document.getElementById("branch-name") as HTMLInputElement; const setupCommandsTextarea = document.getElementById("setup-commands") as HTMLTextAreaElement; if (createBtn) { createBtn.disabled = false; createBtn.textContent = "Create Session"; createBtn.classList.remove("loading"); } modal?.classList.add("hidden"); // Reset form projectDirInput.value = ""; selectedDirectory = ""; parentBranchSelect.innerHTML = ''; if (branchNameInput) { branchNameInput.value = ""; } if (setupCommandsTextarea) { setupCommandsTextarea.value = ""; } }); // Handle session reopened ipcAdapter.on("session-reopened", (_event, sessionId: string) => { activateSession(sessionId); }); // Handle session deleted ipcAdapter.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 ipcAdapter.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") as HTMLButtonElement; 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 ipcAdapter.invoke("get-last-settings"); if (lastSettings.projectDir) { selectedDirectory = lastSettings.projectDir; projectDirInput.value = lastSettings.projectDir; // Load git branches for the last directory const branches = await ipcAdapter.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; } // Set last used setup commands const setupCommandsTextarea = document.getElementById("setup-commands") as HTMLTextAreaElement; if (lastSettings.setupCommands && setupCommandsTextarea) { setupCommandsTextarea.value = lastSettings.setupCommands.join("\n"); } // Show/hide skip permissions based on coding agent if (lastSettings.codingAgent === "codex" || lastSettings.codingAgent === "amp") { skipPermissionsGroup?.classList.add("hidden"); } else { skipPermissionsGroup?.classList.remove("hidden"); } }); // Detect if we're in web mode const isWebMode = typeof window !== "undefined" && (typeof (window as any).process === "undefined" || (window as any).process.type !== "renderer"); // In web mode, allow manual text input for project directory if (isWebMode && projectDirInput) { projectDirInput.removeAttribute("readonly"); projectDirInput.placeholder = "Enter absolute path to project (e.g., /home/user/project)"; // Load branches when user types a path projectDirInput.addEventListener("blur", async () => { const dir = projectDirInput.value.trim(); if (dir) { selectedDirectory = dir; // Try to load git branches const branches = await ipcAdapter.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); }); } } }); } // Browse directory browseDirBtn?.addEventListener("click", async () => { // In web mode, show alert that manual input is needed if (isWebMode) { alert("Directory picker is not available in web mode. Please type the absolute path to your project directory."); projectDirInput?.focus(); return; } const dir = await ipcAdapter.invoke("select-directory"); if (dir) { selectedDirectory = dir; projectDirInput.value = dir; // Load git branches const branches = await ipcAdapter.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", () => { // In web mode, use the input value directly if selectedDirectory isn't set const projectDir = selectedDirectory || projectDirInput.value.trim(); if (!projectDir) { alert("Please enter a project directory"); return; } if (!parentBranchSelect.value) { alert("Please select a parent branch"); return; } const setupCommandsTextarea = document.getElementById("setup-commands") as HTMLTextAreaElement; const setupCommandsText = setupCommandsTextarea?.value.trim(); const setupCommands = setupCommandsText ? setupCommandsText.split("\n").filter(cmd => cmd.trim()) : undefined; const branchNameInput = document.getElementById("branch-name") as HTMLInputElement; const branchName = branchNameInput?.value.trim() || undefined; const config: SessionConfig = { projectDir: projectDir, parentBranch: parentBranchSelect.value, branchName, codingAgent: codingAgentSelect.value, skipPermissions: codingAgentSelect.value === "claude" ? skipPermissionsCheckbox.checked : false, setupCommands, }; // Show loading state if (createBtn) { createBtn.disabled = true; createBtn.innerHTML = ' Creating...'; createBtn.classList.add("loading"); } // Save settings for next time ipcAdapter.send("save-settings", config); // Create the session ipcAdapter.send("create-session", config); }); // MCP Server management functions async function loadMcpServers() { // Only load MCP servers if there's an active session if (!activeSessionId) { return; } try { await ipcAdapter.invoke("list-mcp-servers", activeSessionId); // Results will come via mcp-servers-updated event } catch (error) { console.error("Failed to load MCP servers:", error); } } // Force immediate refresh of MCP servers after add/remove operations async function refreshMcpServers() { if (!activeSessionId) { return; } // Show loading state on add button const addMcpServerBtn = document.getElementById("add-mcp-server"); if (addMcpServerBtn) { addMcpServerBtn.innerHTML = ''; addMcpServerBtn.classList.add("pointer-events-none"); } try { // Trigger MCP list command await ipcAdapter.invoke("list-mcp-servers", activeSessionId); // Wait a bit for the poller to process and send results await new Promise(resolve => setTimeout(resolve, 500)); } catch (error) { console.error("Failed to refresh MCP servers:", error); } finally { // Restore add button will happen via mcp-servers-updated event } } 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}"?`)) { // Optimistically remove from UI mcpServers = mcpServers.filter(s => s.name !== server.name); renderMcpServers(); try { await ipcAdapter.invoke("remove-mcp-server", server.name); } catch (error) { alert(`Failed to remove server: ${error}`); // Refresh to restore correct state on error await refreshMcpServers(); } } }); 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 ipcAdapter.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.innerHTML; addMcpBtn.innerHTML = ' Adding...'; addMcpBtn.disabled = true; addMcpBtn.classList.add("opacity-50", "cursor-not-allowed"); try { await ipcAdapter.invoke("add-mcp-server", name, config); mcpModal?.classList.add("hidden"); // Force immediate refresh of MCP servers await refreshMcpServers(); } catch (error) { console.error("Error adding server:", error); alert(`Failed to add server: ${error}`); } finally { // Reset button state addMcpBtn.innerHTML = 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}"?`)) { // Close modal immediately mcpDetailsModal?.classList.add("hidden"); // Optimistically remove from UI mcpServers = mcpServers.filter(s => s.name !== serverName); renderMcpServers(); try { await ipcAdapter.invoke("remove-mcp-server", serverName); } catch (error) { alert(`Failed to remove server: ${error}`); // Refresh to restore correct state on error await refreshMcpServers(); } } }); // Listen for MCP polling started event ipcAdapter.on("mcp-polling-started", (_event, sessionId: string) => { if (sessionId === activeSessionId) { const addMcpServerBtn = document.getElementById("add-mcp-server"); if (addMcpServerBtn) { addMcpServerBtn.innerHTML = ''; addMcpServerBtn.classList.add("pointer-events-none"); } } }); // Listen for MCP server updates from main process ipcAdapter.on("mcp-servers-updated", (_event, sessionId: string, servers: McpServer[]) => { // Only update if this is for the active session if (sessionId === activeSessionId) { mcpServers = servers; renderMcpServers(); // Restore add button const addMcpServerBtn = document.getElementById("add-mcp-server"); if (addMcpServerBtn) { addMcpServerBtn.innerHTML = '+'; addMcpServerBtn.classList.remove("pointer-events-none"); } } }); // Settings Modal handling const settingsModal = document.getElementById("settings-modal"); const openSettingsBtn = document.getElementById("open-settings"); const cancelSettingsBtn = document.getElementById("cancel-settings"); const resetSettingsBtn = document.getElementById("reset-settings"); const saveSettingsBtn = document.getElementById("save-settings"); const settingsTheme = document.getElementById("settings-theme") as HTMLSelectElement; const settingsFontFamily = document.getElementById("settings-font-family") as HTMLSelectElement; const settingsFontSize = document.getElementById("settings-font-size") as HTMLInputElement; const settingsCursorBlink = document.getElementById("settings-cursor-blink") as HTMLInputElement; const settingsWorktreeDir = document.getElementById("settings-worktree-dir") as HTMLInputElement; const browseWorktreeDirBtn = document.getElementById("browse-worktree-dir"); // Load saved settings on startup async function loadSettings() { const savedSettings = await ipcAdapter.invoke("get-terminal-settings"); if (savedSettings) { terminalSettings = { ...DEFAULT_SETTINGS, ...savedSettings }; } } // Populate settings form function populateSettingsForm() { // Set theme settingsTheme.value = terminalSettings.theme; // Set font family - match against dropdown options const fontOptions = Array.from(settingsFontFamily.options); const matchingOption = fontOptions.find(opt => opt.value === terminalSettings.fontFamily); if (matchingOption) { settingsFontFamily.value = matchingOption.value; } else { // Default to first option (Menlo) if no match settingsFontFamily.selectedIndex = 0; } settingsFontSize.value = terminalSettings.fontSize.toString(); settingsCursorBlink.checked = terminalSettings.cursorBlink; settingsWorktreeDir.value = terminalSettings.worktreeDir; } // Apply settings to all existing terminals function applySettingsToAllTerminals() { const themeColors = THEME_PRESETS[terminalSettings.theme] || THEME_PRESETS["macos-dark"]; sessions.forEach((session) => { if (session.terminal) { session.terminal.options.fontFamily = terminalSettings.fontFamily; session.terminal.options.fontSize = terminalSettings.fontSize; session.terminal.options.cursorBlink = terminalSettings.cursorBlink; session.terminal.options.theme = themeColors; // Refresh terminal to apply changes if (session.fitAddon) { session.fitAddon.fit(); } } }); } // Open settings modal openSettingsBtn?.addEventListener("click", () => { populateSettingsForm(); settingsModal?.classList.remove("hidden"); }); // Cancel settings cancelSettingsBtn?.addEventListener("click", () => { settingsModal?.classList.add("hidden"); }); // Reset settings to default resetSettingsBtn?.addEventListener("click", () => { terminalSettings = { ...DEFAULT_SETTINGS }; populateSettingsForm(); }); // In web mode, allow manual text input for worktree directory if (isWebMode && settingsWorktreeDir) { settingsWorktreeDir.removeAttribute("readonly"); } // Browse worktree directory browseWorktreeDirBtn?.addEventListener("click", async () => { // In web mode, show alert that manual input is needed if (isWebMode) { alert("Directory picker is not available in web mode. Please type the absolute path to your worktrees directory."); settingsWorktreeDir?.focus(); return; } const dir = await ipcAdapter.invoke("select-directory"); if (dir) { settingsWorktreeDir.value = dir; } }); // Save settings saveSettingsBtn?.addEventListener("click", async () => { // Read values from form terminalSettings.theme = settingsTheme.value; terminalSettings.fontFamily = settingsFontFamily.value || DEFAULT_SETTINGS.fontFamily; terminalSettings.fontSize = parseInt(settingsFontSize.value) || DEFAULT_SETTINGS.fontSize; terminalSettings.cursorBlink = settingsCursorBlink.checked; terminalSettings.worktreeDir = settingsWorktreeDir.value || DEFAULT_SETTINGS.worktreeDir; // Save to electron-store await ipcAdapter.invoke("save-terminal-settings", terminalSettings); // Apply to all existing terminals applySettingsToAllTerminals(); // Close modal settingsModal?.classList.add("hidden"); }); // Load settings on startup loadSettings(); // Mobile menu handling const mobileMenuBtn = document.getElementById("mobile-menu-btn"); const mobileOverlay = document.getElementById("mobile-overlay"); const sidebar = document.getElementById("sidebar"); function toggleMobileMenu() { const isOpen = sidebar?.classList.contains("mobile-open"); if (isOpen) { sidebar?.classList.remove("mobile-open"); mobileOverlay?.classList.remove("active"); mobileMenuBtn?.classList.remove("active"); } else { sidebar?.classList.add("mobile-open"); mobileOverlay?.classList.add("active"); mobileMenuBtn?.classList.add("active"); } } function closeMobileMenu() { sidebar?.classList.remove("mobile-open"); mobileOverlay?.classList.remove("active"); mobileMenuBtn?.classList.remove("active"); } mobileMenuBtn?.addEventListener("click", toggleMobileMenu); mobileOverlay?.addEventListener("click", closeMobileMenu); // Close mobile menu when a session is selected document.getElementById("session-list")?.addEventListener("click", (e) => { const target = e.target as HTMLElement; if (target.closest(".session-list-item")) { closeMobileMenu(); } }); // Diff Viewer let currentDiffFiles: FileDiff[] = []; let selectedFileIndex: number = -1; let currentDiffSessionId: string = ''; let diffSidebarCollapsed: boolean = false; let pierreLib: any = null; let shikiLib: any = null; let highlighter: any = null; async function openDiffViewer(sessionId: string) { const modal = document.getElementById("diff-viewer-modal"); const fileList = document.getElementById("diff-file-list"); const diffDisplay = document.getElementById("diff-display"); const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement; if (!modal || !fileList || !diffDisplay) return; currentDiffSessionId = sessionId; // Auto-collapse sidebar on mobile const isMobile = window.innerWidth < 768; diffSidebarCollapsed = isMobile; if (diffSidebarCollapsed) { fileList.classList.add("collapsed"); } else { fileList.classList.remove("collapsed"); } // Show modal with loading state modal.classList.remove("hidden"); fileList.innerHTML = '
Loading diff...
'; diffDisplay.innerHTML = '
Loading...
'; if (mergeBtn) { mergeBtn.disabled = true; mergeBtn.textContent = "Merge to Parent"; } // Load libraries dynamically (only in browser) if (!pierreLib) { try { pierreLib = await import("@pierre/precision-diffs" as any); shikiLib = await import("shiki" as any); } catch (error) { console.error("Failed to load diff libraries:", error); } } // Initialize highlighter if needed if (!highlighter && shikiLib) { try { highlighter = await shikiLib.createHighlighter({ themes: ['github-dark'], langs: ['javascript', 'typescript', 'python', 'java', 'go', 'rust', 'html', 'css', 'json', 'markdown'] }); } catch (error) { console.error("Failed to initialize highlighter:", error); } } // Request diff and merge status from server ipcAdapter.send("get-session-diff", sessionId); ipcAdapter.send("check-merge-status", sessionId); } function renderDiffFileList() { const fileList = document.getElementById("diff-file-list"); if (!fileList) return; if (currentDiffFiles.length === 0) { fileList.innerHTML = '
No changes found
'; return; } fileList.innerHTML = ''; currentDiffFiles.forEach((file, index) => { const item = document.createElement("div"); item.className = `diff-file-item ${index === selectedFileIndex ? 'active' : ''}`; // Create file path div and set text content securely const pathDiv = document.createElement("div"); pathDiv.className = "diff-file-path"; pathDiv.textContent = file.path; // Create stats div const statsDiv = document.createElement("div"); statsDiv.className = "diff-file-stats"; // Create status badge const statusSpan = document.createElement("span"); statusSpan.className = `diff-file-status ${file.status}`; statusSpan.textContent = file.status; statsDiv.appendChild(statusSpan); // Add additions count if present if (file.additions > 0) { const additionsSpan = document.createElement("span"); additionsSpan.className = "diff-file-additions"; additionsSpan.textContent = `+${file.additions}`; statsDiv.appendChild(additionsSpan); } // Add deletions count if present if (file.deletions > 0) { const deletionsSpan = document.createElement("span"); deletionsSpan.className = "diff-file-deletions"; deletionsSpan.textContent = `-${file.deletions}`; statsDiv.appendChild(deletionsSpan); } item.appendChild(pathDiv); item.appendChild(statsDiv); item.addEventListener("click", () => { selectedFileIndex = index; // Auto-collapse sidebar on mobile when selecting a file const isMobile = window.innerWidth < 768; if (isMobile) { const fileListEl = document.getElementById("diff-file-list"); if (fileListEl) { diffSidebarCollapsed = true; fileListEl.classList.add("collapsed"); } } renderDiffFileList(); renderSelectedDiff(); }); fileList.appendChild(item); }); // Re-attach toggle button event listener document.getElementById("toggle-diff-sidebar")?.replaceWith(document.getElementById("toggle-diff-sidebar")!.cloneNode(true)); document.getElementById("toggle-diff-sidebar")?.addEventListener("click", () => { const fileListEl = document.getElementById("diff-file-list"); if (!fileListEl) return; diffSidebarCollapsed = !diffSidebarCollapsed; if (diffSidebarCollapsed) { fileListEl.classList.add("collapsed"); } else { fileListEl.classList.remove("collapsed"); } }); } async function renderSelectedDiff() { const diffDisplay = document.getElementById("diff-display"); if (!diffDisplay || selectedFileIndex < 0 || selectedFileIndex >= currentDiffFiles.length) { if (diffDisplay) { diffDisplay.innerHTML = '
Select a file to view changes
'; } return; } const file = currentDiffFiles[selectedFileIndex]; try { // Check if file is binary if (file.binary) { diffDisplay.innerHTML = '
Binary file changed
'; return; } if (!pierreLib) { diffDisplay.innerHTML = '
Loading diff viewer...
'; return; } // Use Pierre.js FileDiff to render the diff const diffContainer = document.createElement("div"); diffContainer.className = "diff-content"; // Create FileDiff instance const fileDiff = new pierreLib.FileDiff({ theme: { dark: 'github-dark', light: 'github-light' }, themeType: 'dark', }); // Parse the diff to get FileDiffMetadata const parsedPatches = pierreLib.parsePatchFiles(file.diff); if (!parsedPatches || parsedPatches.length === 0 || !parsedPatches[0].files || parsedPatches[0].files.length === 0) { diffDisplay.innerHTML = '
Could not parse diff data
'; return; } // Get the first file's diff metadata from the parsed patch const fileDiffMetadata = parsedPatches[0].files[0]; // Render using the parsed fileDiff metadata await fileDiff.render({ fileDiff: fileDiffMetadata, containerWrapper: diffContainer, }); diffDisplay.innerHTML = ''; diffDisplay.appendChild(diffContainer); } catch (error) { console.error("Error rendering diff:", error); diffDisplay.innerHTML = `
Error rendering diff: ${error}
`; } } // Close diff viewer document.getElementById("close-diff-viewer")?.addEventListener("click", () => { const modal = document.getElementById("diff-viewer-modal"); modal?.classList.add("hidden"); currentDiffFiles = []; selectedFileIndex = -1; currentDiffSessionId = ''; diffSidebarCollapsed = false; }); // Merge to parent button document.getElementById("merge-to-parent-btn")?.addEventListener("click", () => { if (!currentDiffSessionId) return; const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement; if (!mergeBtn || mergeBtn.disabled) return; if (!confirm("Merge this worktree branch into the parent branch? This cannot be undone.")) { return; } mergeBtn.disabled = true; mergeBtn.textContent = "Merging..."; ipcAdapter.send("merge-to-parent", currentDiffSessionId); }); // Handle diff data from server ipcAdapter.on("session-diff-data", (_event, sessionId: string, fileDiffs: FileDiff[]) => { currentDiffFiles = fileDiffs; selectedFileIndex = fileDiffs.length > 0 ? 0 : -1; renderDiffFileList(); renderSelectedDiff(); }); // Handle diff error from server ipcAdapter.on("session-diff-error", (_event, sessionId: string, error: string) => { const fileList = document.getElementById("diff-file-list"); const diffDisplay = document.getElementById("diff-display"); if (fileList) { fileList.innerHTML = `
Error: ${error}
`; } if (diffDisplay) { diffDisplay.innerHTML = `
Failed to load diff
`; } }); // Handle merge status from server ipcAdapter.on("merge-status-data", (_event, sessionId: string, status: MergeStatus) => { const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement; if (!mergeBtn) return; if (status.hasCommits) { mergeBtn.disabled = false; const commitText = status.commitsAhead === 1 ? '1 commit' : `${status.commitsAhead} commits`; mergeBtn.title = `Merge ${commitText} into parent branch`; if (status.hasUncommittedChanges) { mergeBtn.title += ' (has uncommitted changes)'; } } else { mergeBtn.disabled = true; mergeBtn.title = "No commits to merge"; } }); // Handle merge status error ipcAdapter.on("merge-status-error", (_event, sessionId: string, error: string) => { console.error("Merge status error:", error); const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement; if (mergeBtn) { mergeBtn.disabled = true; mergeBtn.title = `Error checking merge status: ${error}`; } }); // Handle merge result ipcAdapter.on("merge-result", (_event, sessionId: string, result: MergeResult) => { const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement; if (result.success) { alert(`✓ ${result.message}`); if (mergeBtn) { mergeBtn.disabled = true; mergeBtn.textContent = "Merged"; } // Refresh diff and status ipcAdapter.send("get-session-diff", sessionId); ipcAdapter.send("check-merge-status", sessionId); } else { alert(`✗ Merge failed: ${result.message}`); if (mergeBtn) { mergeBtn.disabled = false; mergeBtn.textContent = "Merge to Parent"; } } });