1828 lines
56 KiB
TypeScript
1828 lines
56 KiB
TypeScript
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<string, string>;
|
||
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<string, ThemeColors> = {
|
||
"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<string, Session>();
|
||
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<string, NodeJS.Timeout>();
|
||
|
||
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 = `
|
||
<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>
|
||
<div class="flex items-center space-x-1">
|
||
${hasActivePty ? `<button class="view-diff-btn" data-id="${sessionId}" title="View Diff">Diff</button>` : ''}
|
||
<button class="session-delete-btn" data-id="${sessionId}" title="Delete session">×</button>
|
||
</div>
|
||
`;
|
||
|
||
// 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 = `
|
||
<span class="unread-indicator"></span>
|
||
<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 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 = '<option value="">Loading branches...</option>';
|
||
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 = '<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;
|
||
}
|
||
|
||
// 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 = '<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);
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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 = '<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", () => {
|
||
// 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 = '<span class="loading-spinner"></span> 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 = '<span class="loading-spinner"></span>';
|
||
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 = `
|
||
<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}"?`)) {
|
||
// 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 = '<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 ipcAdapter.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.innerHTML;
|
||
addMcpBtn.innerHTML = '<span class="loading-spinner"></span> 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 = '<span class="loading-spinner"></span>';
|
||
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 = '<div class="diff-loading">Loading diff...</div>';
|
||
diffDisplay.innerHTML = '<div class="diff-empty">Loading...</div>';
|
||
|
||
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 = '<button id="toggle-diff-sidebar" class="diff-sidebar-toggle"><span class="toggle-expand">>></span><span class="toggle-collapse"><<</span></button><div class="diff-loading">No changes found</div>';
|
||
return;
|
||
}
|
||
|
||
fileList.innerHTML = '<button id="toggle-diff-sidebar" class="diff-sidebar-toggle"><span class="toggle-expand">>></span><span class="toggle-collapse"><<</span></button>';
|
||
|
||
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 = '<div class="diff-empty">Select a file to view changes</div>';
|
||
}
|
||
return;
|
||
}
|
||
|
||
const file = currentDiffFiles[selectedFileIndex];
|
||
|
||
try {
|
||
// Check if file is binary
|
||
if (file.binary) {
|
||
diffDisplay.innerHTML = '<div class="diff-empty">Binary file changed</div>';
|
||
return;
|
||
}
|
||
|
||
if (!pierreLib) {
|
||
diffDisplay.innerHTML = '<div class="diff-empty">Loading diff viewer...</div>';
|
||
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 = '<div class="diff-empty">Could not parse diff data</div>';
|
||
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 = `<div class="diff-empty">Error rendering diff: ${error}</div>`;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// 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 = `<div class="diff-loading">Error: ${error}</div>`;
|
||
}
|
||
if (diffDisplay) {
|
||
diffDisplay.innerHTML = `<div class="diff-empty">Failed to load diff</div>`;
|
||
}
|
||
});
|
||
|
||
// 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";
|
||
}
|
||
}
|
||
});
|