mcp servers and a bunch of stuff

This commit is contained in:
Amrit Subramanian 2025-10-03 01:13:24 -04:00
parent 1c614c9d28
commit d01e715e68
5 changed files with 370 additions and 11 deletions

2
.gitignore vendored
View file

@ -139,3 +139,5 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
.fleetcode/

View file

@ -78,6 +78,13 @@
</select>
</div>
<div class="form-group">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="skip-permissions" class="form-checkbox" checked />
<span class="text-sm text-gray-300">Skip permissions (use --dangerously-skip-permissions)</span>
</label>
</div>
<div class="btn-group">
<button id="cancel-session" class="btn-secondary">Cancel</button>
<button id="create-session" class="btn-primary">Create Session</button>
@ -85,6 +92,22 @@
</div>
</div>
<!-- MCP Server Details Modal -->
<div id="mcp-details-modal" class="modal-overlay hidden">
<div class="modal">
<h2 class="modal-title" id="mcp-details-title">Server Details</h2>
<div id="mcp-details-content" class="space-y-3 text-sm text-gray-300">
<!-- Details will be populated here -->
</div>
<div class="btn-group">
<button id="close-mcp-details" class="btn-secondary">Close</button>
<button id="remove-mcp-details" class="btn-danger">Remove Server</button>
</div>
</div>
</div>
<!-- MCP Server Modal -->
<div id="mcp-modal" class="modal-overlay hidden">
<div class="modal">

83
main.ts
View file

@ -14,6 +14,7 @@ interface SessionConfig {
projectDir: string;
parentBranch: string;
codingAgent: string;
skipPermissions: boolean;
}
interface PersistedSession {
@ -120,6 +121,7 @@ ipcMain.handle("get-last-settings", () => {
projectDir: "",
parentBranch: "",
codingAgent: "claude",
skipPermissions: true,
});
});
@ -181,7 +183,8 @@ ipcMain.on("create-session", async (event, config: SessionConfig) => {
// Auto-run the selected coding agent
if (config.codingAgent === "claude") {
ptyProcess.write("claude\r");
const claudeCmd = config.skipPermissions ? "claude --dangerously-skip-permissions\r" : "claude\r";
ptyProcess.write(claudeCmd);
} else if (config.codingAgent === "codex") {
ptyProcess.write("codex\r");
}
@ -199,7 +202,8 @@ ipcMain.on("create-session", async (event, config: SessionConfig) => {
// Auto-run the selected coding agent
if (config.codingAgent === "claude") {
ptyProcess.write("claude\r");
const claudeCmd = config.skipPermissions ? "claude --dangerously-skip-permissions\r" : "claude\r";
ptyProcess.write(claudeCmd);
} else if (config.codingAgent === "codex") {
ptyProcess.write("codex\r");
}
@ -277,7 +281,8 @@ ipcMain.on("reopen-session", (event, sessionId: string) => {
terminalReady = true;
if (session.config.codingAgent === "claude") {
ptyProcess.write("claude\r");
const claudeCmd = session.config.skipPermissions ? "claude --dangerously-skip-permissions\r" : "claude\r";
ptyProcess.write(claudeCmd);
} else if (session.config.codingAgent === "codex") {
ptyProcess.write("codex\r");
}
@ -332,6 +337,17 @@ ipcMain.handle("get-all-sessions", () => {
return getPersistedSessions();
});
// Rename session
ipcMain.on("rename-session", (_event, sessionId: string, newName: string) => {
const sessions = getPersistedSessions();
const session = sessions.find(s => s.id === sessionId);
if (session) {
session.name = newName;
savePersistedSessions(sessions);
}
});
// MCP Server management functions
async function listMcpServers() {
try {
@ -355,12 +371,18 @@ async function listMcpServers() {
}
// Parse format: "name: url (type) - status" or just "name"
// Extract just the server name (before the colon)
// Extract server name (before the colon) and status
const colonIndex = line.indexOf(":");
const serverName = colonIndex > 0 ? line.substring(0, colonIndex).trim() : line.trim();
// Check if server is connected (✓ Connected or similar)
const isConnected = line.includes("✓") || line.includes("Connected");
if (serverName) {
servers.push({ name: serverName });
servers.push({
name: serverName,
connected: isConnected
});
}
}
@ -374,13 +396,45 @@ async function listMcpServers() {
async function addMcpServer(name: string, config: any) {
// Use add-json to support full configuration including env vars, headers, etc.
const jsonConfig = JSON.stringify(config);
await execAsync(`claude mcp add-json "${name}" '${jsonConfig}'`);
await execAsync(`claude mcp add-json --scope user "${name}" '${jsonConfig}'`);
}
async function removeMcpServer(name: string) {
await execAsync(`claude mcp remove "${name}"`);
}
async function getMcpServerDetails(name: string) {
try {
const { stdout } = await execAsync(`claude mcp get "${name}"`);
// Parse the output to extract details
const details: any = { name };
const lines = stdout.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.includes("Scope:")) {
details.scope = trimmed.replace("Scope:", "").trim();
} else if (trimmed.includes("Status:")) {
details.status = trimmed.replace("Status:", "").trim();
} else if (trimmed.includes("Type:")) {
details.type = trimmed.replace("Type:", "").trim();
} else if (trimmed.includes("URL:")) {
details.url = trimmed.replace("URL:", "").trim();
} else if (trimmed.includes("Command:")) {
details.command = trimmed.replace("Command:", "").trim();
} else if (trimmed.includes("Args:")) {
details.args = trimmed.replace("Args:", "").trim();
}
}
return details;
} catch (error) {
console.error("Error getting MCP server details:", error);
throw error;
}
}
ipcMain.handle("list-mcp-servers", async () => {
try {
return await listMcpServers();
@ -408,6 +462,15 @@ ipcMain.handle("remove-mcp-server", async (_event, name: string) => {
}
});
ipcMain.handle("get-mcp-server-details", async (_event, name: string) => {
try {
return await getMcpServerDetails(name);
} catch (error) {
console.error("Error getting MCP server details:", error);
throw error;
}
});
const createWindow = () => {
mainWindow = new BrowserWindow({
width: 1400,
@ -430,6 +493,14 @@ const createWindow = () => {
app.whenReady().then(() => {
createWindow();
// Refresh MCP server list every minute and broadcast to all windows
setInterval(async () => {
const servers = await listMcpServers();
BrowserWindow.getAllWindows().forEach(window => {
window.webContents.send("mcp-servers-updated", servers);
});
}, 60000); // 60000ms = 1 minute
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();

View file

@ -6,6 +6,7 @@ interface SessionConfig {
projectDir: string;
parentBranch: string;
codingAgent: string;
skipPermissions: boolean;
}
interface PersistedSession {
@ -30,6 +31,7 @@ interface Session {
interface McpServer {
name: string;
connected?: boolean;
command?: string;
args?: string[];
env?: Record<string, string>;
@ -153,16 +155,41 @@ function addToSidebar(sessionId: string, name: string, hasActivePty: boolean) {
item.id = `sidebar-${sessionId}`;
item.className = "session-list-item";
item.innerHTML = `
<div class="flex items-center space-x-2 flex-1">
<div class="flex items-center space-x-2 flex-1 session-name-container">
<span class="session-indicator ${hasActivePty ? 'active' : ''}"></span>
<span class="truncate">${name}</span>
<span class="truncate session-name-text" data-id="${sessionId}">${name}</span>
<input type="text" class="session-name-input hidden" data-id="${sessionId}" value="${name}" />
</div>
<button class="session-delete-btn" data-id="${sessionId}" title="Delete session">×</button>
`;
// Click on session name to edit
const nameText = item.querySelector(".session-name-text");
const nameInput = item.querySelector(".session-name-input") as HTMLInputElement;
nameText?.addEventListener("click", (e) => {
e.stopPropagation();
startEditingSessionName(sessionId);
});
// Handle input blur and enter key
nameInput?.addEventListener("blur", () => {
finishEditingSessionName(sessionId);
});
nameInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
finishEditingSessionName(sessionId);
} else if (e.key === "Escape") {
cancelEditingSessionName(sessionId);
}
});
item.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("session-delete-btn")) {
if (!target.classList.contains("session-delete-btn") &&
!target.classList.contains("session-name-text") &&
!target.classList.contains("session-name-input")) {
handleSessionClick(sessionId);
}
});
@ -176,6 +203,62 @@ function addToSidebar(sessionId: string, name: string, hasActivePty: boolean) {
list.appendChild(item);
}
function startEditingSessionName(sessionId: string) {
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
const nameText = sidebarItem?.querySelector(".session-name-text");
const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement;
if (nameText && nameInput) {
nameText.classList.add("hidden");
nameInput.classList.remove("hidden");
nameInput.focus();
nameInput.select();
}
}
function finishEditingSessionName(sessionId: string) {
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
const nameText = sidebarItem?.querySelector(".session-name-text");
const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement;
const session = sessions.get(sessionId);
if (nameText && nameInput && session) {
const newName = nameInput.value.trim();
if (newName && newName !== session.name) {
// Update session name
session.name = newName;
nameText.textContent = newName;
// Update tab name if exists
const tab = document.getElementById(`tab-${sessionId}`);
const tabName = tab?.querySelector(".tab-name");
if (tabName) {
tabName.textContent = newName;
}
// Save to backend
ipcRenderer.send("rename-session", sessionId, newName);
}
nameInput.classList.add("hidden");
nameText.classList.remove("hidden");
}
}
function cancelEditingSessionName(sessionId: string) {
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
const nameText = sidebarItem?.querySelector(".session-name-text");
const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement;
const session = sessions.get(sessionId);
if (nameText && nameInput && session) {
// Reset to original name
nameInput.value = session.name;
nameInput.classList.add("hidden");
nameText.classList.remove("hidden");
}
}
function handleSessionClick(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) return;
@ -371,12 +454,23 @@ const modal = document.getElementById("config-modal");
const projectDirInput = document.getElementById("project-dir") as HTMLInputElement;
const parentBranchSelect = document.getElementById("parent-branch") as HTMLSelectElement;
const codingAgentSelect = document.getElementById("coding-agent") as HTMLSelectElement;
const skipPermissionsCheckbox = document.getElementById("skip-permissions") as HTMLInputElement;
const skipPermissionsGroup = skipPermissionsCheckbox?.parentElement?.parentElement;
const browseDirBtn = document.getElementById("browse-dir");
const cancelBtn = document.getElementById("cancel-session");
const createBtn = document.getElementById("create-session");
let selectedDirectory = "";
// Toggle skip permissions checkbox visibility based on coding agent
codingAgentSelect?.addEventListener("change", () => {
if (codingAgentSelect.value === "claude") {
skipPermissionsGroup?.classList.remove("hidden");
} else {
skipPermissionsGroup?.classList.add("hidden");
}
});
// New session button - opens modal
document.getElementById("new-session")?.addEventListener("click", async () => {
modal?.classList.remove("hidden");
@ -411,6 +505,18 @@ document.getElementById("new-session")?.addEventListener("click", async () => {
if (lastSettings.codingAgent) {
codingAgentSelect.value = lastSettings.codingAgent;
}
// Set last used skip permissions setting and visibility
if (lastSettings.skipPermissions !== undefined) {
skipPermissionsCheckbox.checked = lastSettings.skipPermissions;
}
// Show/hide skip permissions based on coding agent
if (lastSettings.codingAgent === "codex") {
skipPermissionsGroup?.classList.add("hidden");
} else {
skipPermissionsGroup?.classList.remove("hidden");
}
});
// Browse directory
@ -456,6 +562,7 @@ createBtn?.addEventListener("click", () => {
projectDir: selectedDirectory,
parentBranch: parentBranchSelect.value,
codingAgent: codingAgentSelect.value,
skipPermissions: codingAgentSelect.value === "claude" ? skipPermissionsCheckbox.checked : false,
};
// Save settings for next time
@ -474,12 +581,26 @@ createBtn?.addEventListener("click", () => {
// MCP Server management functions
async function loadMcpServers() {
const addMcpServerBtn = document.getElementById("add-mcp-server");
// Show loading spinner
if (addMcpServerBtn) {
addMcpServerBtn.innerHTML = '<span class="loading-spinner"></span>';
addMcpServerBtn.classList.add("pointer-events-none");
}
try {
const servers = await ipcRenderer.invoke("list-mcp-servers");
mcpServers = servers;
renderMcpServers();
} catch (error) {
console.error("Failed to load MCP servers:", error);
} finally {
// Restore button
if (addMcpServerBtn) {
addMcpServerBtn.innerHTML = '+';
addMcpServerBtn.classList.remove("pointer-events-none");
}
}
}
@ -492,14 +613,23 @@ function renderMcpServers() {
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 active"></span>
<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();
@ -517,6 +647,62 @@ function renderMcpServers() {
});
}
async function showMcpServerDetails(name: string) {
const detailsModal = document.getElementById("mcp-details-modal");
const detailsTitle = document.getElementById("mcp-details-title");
const detailsContent = document.getElementById("mcp-details-content");
// Show modal immediately with loading state
if (detailsTitle) {
detailsTitle.textContent = name;
}
if (detailsContent) {
detailsContent.innerHTML = '<div class="flex items-center justify-center py-8"><span class="loading-spinner" style="width: 24px; height: 24px; border-width: 3px;"></span></div>';
}
detailsModal?.classList.remove("hidden");
try {
const details = await ipcRenderer.invoke("get-mcp-server-details", name);
if (detailsContent) {
let html = "";
if (details.scope) {
html += `<div><strong>Scope:</strong> ${details.scope}</div>`;
}
if (details.status) {
html += `<div><strong>Status:</strong> ${details.status}</div>`;
}
if (details.type) {
html += `<div><strong>Type:</strong> ${details.type}</div>`;
}
if (details.url) {
html += `<div><strong>URL:</strong> ${details.url}</div>`;
}
if (details.command) {
html += `<div><strong>Command:</strong> ${details.command}</div>`;
}
if (details.args) {
html += `<div><strong>Args:</strong> ${details.args}</div>`;
}
detailsContent.innerHTML = html;
}
// Store current server name for remove button
const removeMcpDetailsBtn = document.getElementById("remove-mcp-details") as HTMLButtonElement;
if (removeMcpDetailsBtn) {
removeMcpDetailsBtn.dataset.serverName = name;
}
} catch (error) {
console.error("Failed to get server details:", error);
if (detailsContent) {
detailsContent.innerHTML = `<div class="text-red-400">Failed to load server details</div>`;
}
}
}
// MCP Modal handling
const mcpModal = document.getElementById("mcp-modal");
const mcpNameInput = document.getElementById("mcp-name") as HTMLInputElement;
@ -530,7 +716,7 @@ const mcpAlwaysAllowInput = document.getElementById("mcp-always-allow") as HTMLI
const localFields = document.getElementById("local-fields");
const remoteFields = document.getElementById("remote-fields");
const cancelMcpBtn = document.getElementById("cancel-mcp");
const addMcpBtn = document.getElementById("add-mcp");
const addMcpBtn = document.getElementById("add-mcp") as HTMLButtonElement;
// Toggle fields based on server type
mcpTypeSelect?.addEventListener("change", () => {
@ -632,6 +818,12 @@ addMcpBtn?.addEventListener("click", async () => {
config.alwaysAllow = alwaysAllowInput.split(",").map(t => t.trim()).filter(t => t);
}
// Show loading state
const originalText = addMcpBtn.textContent;
addMcpBtn.textContent = "Adding...";
addMcpBtn.disabled = true;
addMcpBtn.classList.add("opacity-50", "cursor-not-allowed");
try {
await ipcRenderer.invoke("add-mcp-server", name, config);
await loadMcpServers();
@ -639,8 +831,43 @@ addMcpBtn?.addEventListener("click", async () => {
} catch (error) {
console.error("Error adding server:", error);
alert(`Failed to add server: ${error}`);
} finally {
// Reset button state
addMcpBtn.textContent = originalText;
addMcpBtn.disabled = false;
addMcpBtn.classList.remove("opacity-50", "cursor-not-allowed");
}
});
// MCP Details Modal handling
const closeMcpDetailsBtn = document.getElementById("close-mcp-details");
const removeMcpDetailsBtn = document.getElementById("remove-mcp-details") as HTMLButtonElement;
const mcpDetailsModal = document.getElementById("mcp-details-modal");
closeMcpDetailsBtn?.addEventListener("click", () => {
mcpDetailsModal?.classList.add("hidden");
});
removeMcpDetailsBtn?.addEventListener("click", async () => {
const serverName = removeMcpDetailsBtn.dataset.serverName;
if (!serverName) return;
if (confirm(`Remove MCP server "${serverName}"?`)) {
try {
await ipcRenderer.invoke("remove-mcp-server", serverName);
mcpDetailsModal?.classList.add("hidden");
await loadMcpServers();
} catch (error) {
alert(`Failed to remove server: ${error}`);
}
}
});
// Listen for MCP server updates from main process
ipcRenderer.on("mcp-servers-updated", (_event, servers: McpServer[]) => {
mcpServers = servers;
renderMcpServers();
});
// Load MCP servers on startup
loadMcpServers();

View file

@ -36,6 +36,17 @@
@apply text-gray-400 hover:text-white hover:bg-gray-700 text-sm w-5 h-5 flex items-center justify-center rounded transition-all;
}
.loading-spinner {
@apply inline-block w-3 h-3 border-2 border-gray-400 border-t-transparent rounded-full;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Session List Item */
.session-list-item {
@apply px-3 py-2 rounded cursor-pointer hover:bg-gray-700 text-gray-300 text-sm flex items-center justify-between;
@ -54,6 +65,10 @@
@apply bg-green-500;
}
.session-indicator.disconnected {
@apply bg-red-500;
}
.session-delete-btn {
@apply text-gray-500 hover:text-red-500;
opacity: 0;
@ -64,6 +79,19 @@
opacity: 1;
}
.session-name-text {
cursor: pointer;
}
.session-name-text:hover {
@apply text-gray-100;
}
.session-name-input {
@apply bg-gray-700 border border-gray-600 rounded px-1 py-0 text-gray-100 text-sm focus:outline-none focus:border-blue-500;
min-width: 80px;
}
/* Tabs */
.tabs-container {
@apply flex bg-gray-900 border-b border-gray-700 overflow-x-auto;
@ -134,10 +162,18 @@
@apply w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500;
}
.form-checkbox {
@apply w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500 focus:ring-2 cursor-pointer;
}
.btn-secondary {
@apply bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded text-sm font-medium transition;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded text-sm font-medium transition;
}
.btn-group {
@apply flex justify-end space-x-3 mt-6;
}