Compare commits

..

18 Commits

Author SHA1 Message Date
simosmik
09dcea05fb fix: precise Claude SDK denial message detection in deriveToolStatus 2026-04-20 15:52:09 +00:00
simosmik
3969135bd4 fix: iOS scrolling main chat area 2026-04-20 15:47:19 +00:00
simosmik
25820ed995 fix: small mobile respnosive fixes 2026-04-20 15:41:37 +00:00
simosmik
fc3504eaed fix: migrate PlanDisplay raw params from native details to Collapsible primitive 2026-04-20 15:36:02 +00:00
simosmik
ec0ff974cb refactor: queue primitive, tool status badges, and tool display cleanup
- Add Queue/QueueItem/QueueItemIndicator/QueueItemContent primitive
- Rewrite TodoList using Queue (clean list, no bordered cards, no priority badges)
- Add ToolStatusBadge component (Running/Completed/Error/Denied)
- Migrate CollapsibleSection from native <details> to Collapsible primitive
- Add badge prop threading through CollapsibleDisplay and CollapsibleSection
- Add status badges to OneLineDisplay and CollapsibleDisplay via ToolRenderer
- Update SubagentContainer: theme tokens + Collapsible for tool history
- Replace hardcoded gray-* colors with theme tokens throughout tool displays
2026-04-20 15:30:16 +00:00
simosmik
c471b5d3fa fix: small mobile respnosive fixes 2026-04-20 15:05:08 +00:00
simosmik
5758bee8a0 refactor: chat composer new design 2026-04-20 14:47:49 +00:00
simosmik
7763e60fb3 refactor: add primitives, plan mode display, and new session model selector 2026-04-20 12:47:55 +00:00
viper151
25b00b58de chore(release): v1.29.5 2026-04-16 11:02:31 +00:00
simosmik
6a13e1773b fix: update node-pty to latest version 2026-04-16 10:52:55 +00:00
viper151
6102b74455 chore(release): v1.29.4 2026-04-16 10:33:45 +00:00
Simos Mikelatos
9ef1ab533d Refactor CLI authentication module location (#660)
* refactor: move cli-auth.js to the providers folder

* fix: expired oauth token returns no error message
2026-04-16 12:32:25 +02:00
simosmik
e9c7a5041c feat: deleting from sidebar will now ask whether to remove all data as well 2026-04-16 09:05:56 +00:00
simosmik
289520814c refactor: remove the sqlite3 dependency 2026-04-16 08:37:59 +00:00
simosmik
09486016e6 chore: upgrade commit lint to 20.5.0 2026-04-16 08:08:36 +00:00
simosmik
4c106a5083 fix: pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set
Closes #468
2026-04-16 07:58:32 +00:00
Simos Mikelatos
63e996bb77 refactor(server): extract URL detection and color utils from index.js (#657)
No behavioral changes — 1:1 code move with imports replacing inline definitions.
2026-04-16 09:46:09 +02:00
viper151
fbad3a90f8 chore(release): v1.29.3 2026-04-15 12:02:27 +00:00
127 changed files with 8475 additions and 7627 deletions

View File

@@ -3,6 +3,41 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.29.5](https://github.com/siteboon/claudecodeui/compare/v1.29.4...v1.29.5) (2026-04-16)
### Bug Fixes
* update node-pty to latest version ([6a13e17](https://github.com/siteboon/claudecodeui/commit/6a13e1773b145049ade512aa6e5cac21c2e5c4de))
## [1.29.4](https://github.com/siteboon/claudecodeui/compare/v1.29.3...v1.29.4) (2026-04-16)
### New Features
* deleting from sidebar will now ask whether to remove all data as well ([e9c7a50](https://github.com/siteboon/claudecodeui/commit/e9c7a5041c31a6f7b2032f06abe19c52d3d4cd8c))
### Bug Fixes
* pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set ([4c106a5](https://github.com/siteboon/claudecodeui/commit/4c106a5083d90989bbeedaefdbb68f5b3fa6fd58)), closes [#468](https://github.com/siteboon/claudecodeui/issues/468)
### Refactoring
* remove the sqlite3 dependency ([2895208](https://github.com/siteboon/claudecodeui/commit/289520814cf3ca36403056739ef22021f78c6033))
* **server:** extract URL detection and color utils from index.js ([#657](https://github.com/siteboon/claudecodeui/issues/657)) ([63e996b](https://github.com/siteboon/claudecodeui/commit/63e996bb77cfa97b1f55f6bdccc50161a75a3eee))
### Maintenance
* upgrade commit lint to 20.5.0 ([0948601](https://github.com/siteboon/claudecodeui/commit/09486016e67d97358c228ebc6eb4502ccb0012e4))
## [1.29.3](https://github.com/siteboon/claudecodeui/compare/v1.29.2...v1.29.3) (2026-04-15)
### Bug Fixes
* **version-upgrade-modal:** implement reload countdown and update UI messages ([#655](https://github.com/siteboon/claudecodeui/issues/655)) ([6413042](https://github.com/siteboon/claudecodeui/commit/641304242d7705b54aab65faa4a7673438c92c60))
### Maintenance
* remove unused route (migrated to providers already) ([31f28a2](https://github.com/siteboon/claudecodeui/commit/31f28a2c183f6ead50941027632d7ab64b7bb2d4))
## [1.29.2](https://github.com/siteboon/claudecodeui/compare/v1.29.1...v1.29.2) (2026-04-14)
### Bug Fixes

View File

@@ -148,27 +148,9 @@ export default tseslint.config(
],
"boundaries/elements": [
{
type: "backend-shared-type-contract", // shared backend type/interface contracts that modules may consume without creating runtime coupling
pattern: [
"server/shared/types.{js,ts}",
"server/shared/interfaces.{js,ts}",
], // keep backend modules on explicit shared contract files for erased imports only
mode: "file", // treat each shared contract file itself as the boundary element instead of the whole folder
},
{
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
pattern: ["server/shared/utils.{js,ts}"], // classify the shared utils file so modules can depend on it explicitly
mode: "file",
},
{
type: "backend-legacy-runtime", // legacy runtime persistence modules used while providers migrate into server/modules
pattern: [
"server/projects.js",
"server/sessionManager.js",
"server/database/*.{js,ts}",
"server/utils/runtime-paths.js",
], // provider history loading still resolves session data through these legacy runtime/database files
mode: "file",
type: "backend-shared-types", // shared backend type contract that modules may consume without creating runtime coupling
pattern: ["server/shared/types.{js,ts}"], // support the current shared types path
mode: "file", // treat the types file itself as the boundary element instead of the whole folder
},
{
type: "backend-module", // logical element name used by boundaries rules below
@@ -214,13 +196,13 @@ export default tseslint.config(
checkInternals: false, // do not apply these cross-module rules to imports inside the same module
rules: [
{
from: { type: "backend-module" }, // modules may depend on shared type/interface contracts only as erased type-only imports
to: { type: "backend-shared-type-contract" },
from: { type: "backend-module" }, // modules may depend on the shared types contract only as erased type-only imports
to: { type: "backend-shared-types" },
disallow: {
dependency: { kind: ["value", "typeof"] },
}, // block runtime imports so shared contracts stay compile-time only instead of becoming hidden shared modules
}, // block runtime imports so shared types stay a compile-time contract instead of a hidden shared module
message:
"Backend modules may only use `import type` when importing from server/shared/types.ts or server/shared/interfaces.ts.",
"Backend modules may only use `import type` when importing from server/shared/types.ts (or server/types.ts).",
},
{
to: { type: "backend-module" }, // when importing anything that belongs to another backend module

1149
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.29.2",
"version": "1.29.5",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",
@@ -104,7 +104,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.1",
"node-fetch": "^2.7.0",
"node-pty": "^1.1.0-beta34",
"node-pty": "^1.2.0-beta.12",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
@@ -117,19 +117,15 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2"
},
"devDependencies": {
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@eslint/js": "^9.39.3",
"@release-it/conventional-changelog": "^10.0.5",
"@types/cross-spawn": "^6.0.6",
"@types/express": "^5.0.6",
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",

View File

@@ -24,15 +24,16 @@ import {
notifyRunStopped,
notifyUserIfEnabled
} from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { createNormalizedMessage } from './shared/utils.js';
import { claudeAdapter } from './providers/claude/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
@@ -148,6 +149,10 @@ function mapCliOptionsToSDK(options = {}) {
const sdkOptions = {};
if (process.env.CLAUDE_CLI_PATH) {
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH;
}
// Map working directory
if (cwd) {
sdkOptions.cwd = cwd;
@@ -649,7 +654,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
const sid = capturedSessionId || sessionId || null;
// Use adapter to normalize SDK events into NormalizedMessage[]
const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
const normalized = claudeAdapter.normalizeMessage(transformedMessage, sid);
for (const msg of normalized) {
// Preserve parentToolUseId from SDK wrapper for subagent tool grouping
if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
@@ -701,8 +706,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
// Check if Claude CLI is installed for a clearer error message
const installed = getStatusChecker('claude')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message;
// Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
notifyRunFailed({
userId: ws?.userId || null,
provider: 'claude',
@@ -710,8 +721,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionName: sessionSummary,
error
});
throw error;
}
}

View File

@@ -1,8 +1,9 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { createNormalizedMessage } from './shared/utils.js';
import { cursorAdapter } from './providers/cursor/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -189,7 +190,7 @@ async function spawnCursor(command, options = {}, ws) {
case 'assistant':
// Accumulate assistant message chunks
if (response.message && response.message.content && response.message.content.length > 0) {
const normalized = sessionsService.normalizeMessage('cursor', response, capturedSessionId || sessionId || null);
const normalized = cursorAdapter.normalizeMessage(response, capturedSessionId || sessionId || null);
for (const msg of normalized) ws.send(msg);
}
break;
@@ -219,7 +220,7 @@ async function spawnCursor(command, options = {}, ws) {
}
// If not JSON, send as stream delta via adapter
const normalized = sessionsService.normalizeMessage('cursor', line, capturedSessionId || sessionId || null);
const normalized = cursorAdapter.normalizeMessage(line, capturedSessionId || sessionId || null);
for (const msg of normalized) ws.send(msg);
}
};
@@ -294,7 +295,13 @@ async function spawnCursor(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
// Check if Cursor CLI is installed for a clearer error message
const installed = getStatusChecker('cursor')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Cursor CLI is not installed. Please install it from https://cursor.com'
: error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
notifyTerminalState({ error });
settleOnce(() => reject(error));

View File

@@ -9,7 +9,8 @@ import os from 'os';
import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
let activeGeminiProcesses = new Map(); // Track active processes by session ID
@@ -380,6 +381,15 @@ async function spawnGemini(command, options = {}, ws) {
notifyTerminalState({ code });
resolve();
} else {
// code 127 = shell "command not found" — check installation
if (code === 127) {
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
if (!installed) {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
}
}
notifyTerminalState({
code,
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
@@ -394,8 +404,14 @@ async function spawnGemini(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
// Check if Gemini CLI is installed for a clearer error message
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'
: error.message;
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' }));
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
notifyTerminalState({ error });
reject(error);

View File

@@ -1,5 +1,5 @@
// Gemini Response Handler - JSON Stream processing
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { geminiAdapter } from './providers/gemini/adapter.js';
class GeminiResponseHandler {
constructor(ws, options = {}) {
@@ -56,7 +56,7 @@ class GeminiResponseHandler {
}
// Normalize via adapter and send all resulting messages
const normalized = sessionsService.normalizeMessage('gemini', event, sid);
const normalized = geminiAdapter.normalizeMessage(event, sid);
for (const msg of normalized) {
this.ws.send(msg);
}

View File

@@ -5,34 +5,13 @@ import fs from 'fs';
import path from 'path';
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
import { AppError, createNormalizedMessage } from '@/shared/utils.js';
const __dirname = getModuleDir(import.meta.url);
// The server source runs from /server, while the compiled output runs from /dist-server/server.
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
import { c } from './utils/colors.js';
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
@@ -44,9 +23,10 @@ import cors from 'cors';
import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process';
import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';
import { getProjects, getSessions, renameProject, deleteSession, deleteProject, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
import { getProjects, getSessions, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
@@ -54,6 +34,7 @@ import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGemini
import sessionManager from './sessionManager.js';
import gitRoutes from './routes/git.js';
import authRoutes from './routes/auth.js';
import mcpRoutes from './routes/mcp.js';
import cursorRoutes from './routes/cursor.js';
import taskmasterRoutes from './routes/taskmaster.js';
import mcpUtilsRoutes from './routes/mcp-utils.js';
@@ -61,12 +42,13 @@ import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js';
import messagesRoutes from './routes/messages.js';
import providerRoutes from './modules/providers/provider.routes.js';
import { createNormalizedMessage } from './providers/types.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
@@ -226,68 +208,7 @@ const server = http.createServer(app);
const ptySessionsMap = new Map();
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
function stripAnsiSequences(value = '') {
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
}
function normalizeDetectedUrl(url) {
if (!url || typeof url !== 'string') return null;
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
if (!cleaned) return null;
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function extractUrlsFromText(value = '') {
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
// Handle wrapped terminal URLs split across lines by terminal width.
const wrappedMatches = [];
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
if (!startMatch) continue;
let combined = startMatch[0];
let j = i + 1;
while (j < lines.length) {
const continuation = lines[j].trim();
if (!continuation) break;
if (!continuationRegex.test(continuation)) break;
combined += continuation;
j++;
}
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
}
return Array.from(new Set([...directMatches, ...wrappedMatches]));
}
function shouldAutoOpenUrlFromOutput(value = '') {
const normalized = value.toLowerCase();
return (
normalized.includes('browser didn\'t open') ||
normalized.includes('open this url') ||
normalized.includes('continue in your browser') ||
normalized.includes('press enter to open') ||
normalized.includes('open_url:')
);
}
import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
// Single WebSocket server that handles both paths
const wss = new WebSocketServer({
@@ -365,6 +286,9 @@ app.use('/api/projects', authenticateToken, projectsRoutes);
// Git API Routes (protected)
app.use('/api/git', authenticateToken, gitRoutes);
// MCP API Routes (protected)
app.use('/api/mcp', authenticateToken, mcpRoutes);
// Cursor API Routes (protected)
app.use('/api/cursor', authenticateToken, cursorRoutes);
@@ -380,6 +304,9 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
// Settings API Routes (protected)
app.use('/api/settings', authenticateToken, settingsRoutes);
// CLI Authentication API Routes (protected)
app.use('/api/cli', authenticateToken, cliAuthRoutes);
// User API Routes (protected)
app.use('/api/user', authenticateToken, userRoutes);
@@ -395,9 +322,6 @@ app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Unified session messages route (protected)
app.use('/api/sessions', authenticateToken, messagesRoutes);
// Unified provider MCP routes (protected)
app.use('/api/providers', authenticateToken, providerRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
@@ -570,18 +494,38 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
}
});
// Delete project endpoint (force=true to delete with sessions)
// Delete project endpoint
// force=true to allow removal even when sessions exist
// deleteData=true to also delete session/memory files on disk (destructive)
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const force = req.query.force === 'true';
await deleteProject(projectName, force);
const deleteData = req.query.deleteData === 'true';
await deleteProject(projectName, force, deleteData);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create project endpoint
app.post('/api/projects/create', authenticateToken, async (req, res) => {
try {
const { path: projectPath } = req.body;
if (!projectPath || !projectPath.trim()) {
return res.status(400).json({ error: 'Project path is required' });
}
const project = await addProjectManually(projectPath.trim());
res.json({ success: true, project });
} catch (error) {
console.error('Error creating project:', error);
res.status(500).json({ error: error.message });
}
});
// Search conversations content (SSE streaming)
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
@@ -1434,7 +1378,7 @@ wss.on('connection', (ws, request) => {
/**
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
*
* Provider files use `createNormalizedMessage()` from `shared/utils.js` and
* Provider files use `createNormalizedMessage()` from `providers/types.js` and
* adapter `normalizeMessage()` to produce unified NormalizedMessage events.
* The writer simply serialises and sends.
*/
@@ -2269,30 +2213,6 @@ app.get('*', (req, res) => {
}
});
// global error middleware must be last
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
error: {
code: err.code,
message: err.message,
details: err.details,
},
});
}
console.error(err);
return res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'Internal server error',
},
});
});
// Helper function to convert permissions to rwx format
function permToRwx(perm) {
const r = perm & 4 ? 'r' : '-';

View File

@@ -1,123 +0,0 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type ClaudeCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class ClaudeProviderAuth implements IProviderAuth {
/**
* Checks whether the Claude Code CLI is available on this host.
*/
private checkInstalled(): boolean {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Claude installation and credential status using Claude Code's auth priority.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'claude',
authenticated: false,
email: null,
method: null,
error: 'Claude Code CLI is not installed',
};
}
const credentials = await this.checkCredentials();
return {
installed,
provider: 'claude',
authenticated: credentials.authenticated,
email: credentials.authenticated ? credentials.email || 'Authenticated' : credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Reads Claude settings env values that the CLI can use even when the server process env is empty.
*/
private async loadSettingsEnv(): Promise<Record<string, unknown>> {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await readFile(settingsPath, 'utf8');
const settings = readObjectRecord(JSON.parse(content));
return readObjectRecord(settings?.env) ?? {};
} catch {
return {};
}
}
/**
* Checks Claude credentials in the same priority order used by Claude Code.
*/
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
if (process.env.ANTHROPIC_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const settingsEnv = await this.loadSettingsEnv();
if (readOptionalString(settingsEnv.ANTHROPIC_API_KEY)) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
if (readOptionalString(settingsEnv.ANTHROPIC_AUTH_TOKEN)) {
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
}
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await readFile(credPath, 'utf8');
const creds = readObjectRecord(JSON.parse(content)) ?? {};
const oauth = readObjectRecord(creds.claudeAiOauth);
const accessToken = readOptionalString(oauth?.accessToken);
if (accessToken) {
const expiresAt = typeof oauth?.expiresAt === 'number' ? oauth.expiresAt : undefined;
const email = readOptionalString(creds.email) ?? readOptionalString(creds.user) ?? null;
if (!expiresAt || Date.now() < expiresAt) {
return {
authenticated: true,
email,
method: 'credentials_file',
};
}
return {
authenticated: false,
email,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login',
};
}
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}
}

View File

@@ -1,135 +0,0 @@
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readJsonConfig,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
writeJsonConfig,
} from '@/shared/utils.js';
export class ClaudeMcpProvider extends McpProvider {
constructor() {
super('claude', ['user', 'local', 'project'], ['stdio', 'http', 'sse']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
if (scope === 'project') {
const filePath = path.join(workspacePath, '.mcp.json');
const config = await readJsonConfig(filePath);
return readObjectRecord(config.mcpServers) ?? {};
}
const filePath = path.join(os.homedir(), '.claude.json');
const config = await readJsonConfig(filePath);
if (scope === 'user') {
return readObjectRecord(config.mcpServers) ?? {};
}
const projects = readObjectRecord(config.projects) ?? {};
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
return readObjectRecord(projectConfig.mcpServers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
if (scope === 'project') {
const filePath = path.join(workspacePath, '.mcp.json');
const config = await readJsonConfig(filePath);
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
return;
}
const filePath = path.join(os.homedir(), '.claude.json');
const config = await readJsonConfig(filePath);
if (scope === 'user') {
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
return;
}
const projects = readObjectRecord(config.projects) ?? {};
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
projectConfig.mcpServers = servers;
projects[workspacePath] = projectConfig;
config.projects = projects;
await writeJsonConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
type: 'stdio',
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: input.transport,
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'claude',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
};
}
if (typeof config.url === 'string') {
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
return {
provider: 'claude',
name,
scope,
transport,
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -1,321 +0,0 @@
import { getSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'claude';
type RawProviderMessage = Record<string, any>;
type ClaudeToolResult = {
content: unknown;
isError: boolean;
subagentTools?: unknown;
toolUseResult?: unknown;
};
type ClaudeHistoryResult =
| RawProviderMessage[]
| {
messages?: RawProviderMessage[];
total?: number;
hasMore?: boolean;
};
const loadClaudeSessionMessages = getSessionMessages as unknown as (
projectName: string,
sessionId: string,
limit: number | null,
offset: number,
) => Promise<ClaudeHistoryResult>;
/**
* Claude writes internal command and system reminder entries into history.
* Those are useful for the CLI but should not appear in the user-facing chat.
*/
const INTERNAL_CONTENT_PREFIXES = [
'<command-name>',
'<command-message>',
'<command-args>',
'<local-command-stdout>',
'<system-reminder>',
'Caveat:',
'This session is being continued from a previous',
'[Request interrupted',
] as const;
function isInternalContent(content: string): boolean {
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
}
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class ClaudeProvider extends AbstractProvider {
readonly mcp = new ClaudeMcpProvider();
readonly auth: IProviderAuth = new ClaudeProviderAuth();
constructor() {
super('claude');
}
/**
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
* message shape consumed by REST and WebSocket clients.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (!raw) {
return [];
}
if (raw.type === 'content_block_delta' && raw.delta?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
}
if (raw.type === 'content_block_stop') {
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
}
const messages: NormalizedMessage[] = [];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('claude');
if (raw.message?.role === 'user' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: `${baseId}_tr_${part.tool_use_id}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id,
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
}));
} else if (part.type === 'text') {
const text = part.text || '';
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
}
if (messages.length === 0) {
const textParts = raw.message.content
.filter((part: RawProviderMessage) => part.type === 'text')
.map((part: RawProviderMessage) => part.text)
.filter(Boolean)
.join('\n');
if (textParts && !isInternalContent(textParts)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: textParts,
}));
}
}
} else if (typeof raw.message.content === 'string') {
const text = raw.message.content;
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
return messages;
}
if (raw.type === 'thinking' && raw.message?.content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message.content,
}));
return messages;
}
if (raw.type === 'tool_use' && raw.toolName) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName,
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
}));
return messages;
}
if (raw.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: false,
}));
return messages;
}
if (raw.message?.role === 'assistant' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
let partIndex = 0;
for (const part of raw.message.content) {
if (part.type === 'text' && part.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: part.text,
}));
} else if (part.type === 'tool_use') {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id,
}));
} else if (part.type === 'thinking' && part.thinking) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.thinking,
}));
}
partIndex++;
}
} else if (typeof raw.message.content === 'string') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message.content,
}));
}
return messages;
}
return messages;
}
/**
* Loads Claude JSONL history for a project/session and returns normalized
* messages, preserving the existing pagination behavior from projects.js.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectName, limit = null, offset = 0 } = options;
if (!projectName) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
let result: ClaudeHistoryResult;
try {
result = await loadClaudeSessionMessages(projectName, sessionId, limit, offset);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const toolResultMap = new Map<string, ClaudeToolResult>();
for (const raw of rawMessages) {
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result' && part.tool_use_id) {
toolResultMap.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
});
}
}
}
}
const normalized: NormalizedMessage[] = [];
for (const raw of rawMessages) {
normalized.push(...this.normalizeMessage(raw, sessionId));
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (!toolResult) {
continue;
}
msg.toolResult = {
content: typeof toolResult.content === 'string'
? toolResult.content
: JSON.stringify(toolResult.content),
isError: toolResult.isError,
toolUseResult: toolResult.toolUseResult,
};
msg.subagentTools = toolResult.subagentTools;
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
}
}

View File

@@ -1,100 +0,0 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type CodexCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class CodexProviderAuth implements IProviderAuth {
/**
* Checks whether Codex is available to the server runtime.
*/
private checkInstalled(): boolean {
try {
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Codex SDK availability and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
const credentials = await this.checkCredentials();
return {
installed,
provider: 'codex',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Reads Codex auth.json and checks OAuth tokens or an API key fallback.
*/
private async checkCredentials(): Promise<CodexCredentialsStatus> {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await readFile(authPath, 'utf8');
const auth = readObjectRecord(JSON.parse(content)) ?? {};
const tokens = readObjectRecord(auth.tokens) ?? {};
const idToken = readOptionalString(tokens.id_token);
const accessToken = readOptionalString(tokens.access_token);
if (idToken || accessToken) {
return {
authenticated: true,
email: idToken ? this.readEmailFromIdToken(idToken) : 'Authenticated',
method: 'credentials_file',
};
}
if (readOptionalString(auth.OPENAI_API_KEY)) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
return { authenticated: false, email: null, method: null, error: 'No valid tokens found' };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return {
authenticated: false,
email: null,
method: null,
error: code === 'ENOENT' ? 'Codex not configured' : error instanceof Error ? error.message : 'Failed to read Codex auth',
};
}
}
/**
* Extracts the user email from a Codex id_token when a readable JWT payload exists.
*/
private readEmailFromIdToken(idToken: string): string {
try {
const parts = idToken.split('.');
if (parts.length >= 2) {
const payload = readObjectRecord(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')));
return readOptionalString(payload?.email) ?? readOptionalString(payload?.user) ?? 'Authenticated';
}
} catch {
// Fall back to a generic authenticated marker if the token payload is not readable.
}
return 'Authenticated';
}
}

View File

@@ -1,135 +0,0 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import TOML from '@iarna/toml';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
} from '@/shared/utils.js';
const readTomlConfig = async (filePath: string): Promise<Record<string, unknown>> => {
try {
const content = await readFile(filePath, 'utf8');
const parsed = TOML.parse(content) as Record<string, unknown>;
return readObjectRecord(parsed) ?? {};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {};
}
throw error;
}
};
const writeTomlConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
await mkdir(path.dirname(filePath), { recursive: true });
const toml = TOML.stringify(data as never);
await writeFile(filePath, toml, 'utf8');
};
export class CodexMcpProvider extends McpProvider {
constructor() {
super('codex', ['user', 'project'], ['stdio', 'http']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.codex', 'config.toml')
: path.join(workspacePath, '.codex', 'config.toml');
const config = await readTomlConfig(filePath);
return readObjectRecord(config.mcp_servers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.codex', 'config.toml')
: path.join(workspacePath, '.codex', 'config.toml');
const config = await readTomlConfig(filePath);
config.mcp_servers = servers;
await writeTomlConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
env_vars: input.envVars ?? [],
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
url: input.url,
bearer_token_env_var: input.bearerTokenEnvVar,
http_headers: input.headers ?? {},
env_http_headers: input.envHttpHeaders ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'codex',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
envVars: readStringArray(config.env_vars),
};
}
if (typeof config.url === 'string') {
return {
provider: 'codex',
name,
scope,
transport: 'http',
url: config.url,
headers: readStringRecord(config.http_headers),
bearerTokenEnvVar: readOptionalString(config.bearer_token_env_var),
envHttpHeaders: readStringRecord(config.env_http_headers),
};
}
return null;
}
}

View File

@@ -1,335 +0,0 @@
import { getCodexSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'codex';
type RawProviderMessage = Record<string, any>;
type CodexHistoryResult =
| RawProviderMessage[]
| {
messages?: RawProviderMessage[];
total?: number;
hasMore?: boolean;
tokenUsage?: unknown;
};
const loadCodexSessionMessages = getCodexSessionMessages as unknown as (
sessionId: string,
limit: number | null,
offset: number,
) => Promise<CodexHistoryResult>;
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class CodexProvider extends AbstractProvider {
readonly mcp = new CodexMcpProvider();
readonly auth: IProviderAuth = new CodexProviderAuth();
constructor() {
super('codex');
}
/**
* Normalizes a persisted Codex JSONL entry.
*
* Live Codex SDK events are transformed before they reach normalizeMessage(),
* while history entries already use a compact message/tool shape from projects.js.
*/
private normalizeHistoryEntry(raw: RawProviderMessage, sessionId: string | null): NormalizedMessage[] {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
if (raw.message?.role === 'user') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
: String(raw.message.content || '');
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content,
})];
}
if (raw.message?.role === 'assistant') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
: '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content,
})];
}
if (raw.type === 'thinking' || raw.isReasoning) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
}
if (raw.type === 'tool_use' || raw.toolName) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName || 'Unknown',
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: Boolean(raw.isError),
})];
}
return [];
}
/**
* Normalizes either a Codex history entry or a transformed live SDK event.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (!raw) {
return [];
}
if (raw.message?.role) {
return this.normalizeHistoryEntry(raw, sessionId);
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
if (raw.type === 'item') {
switch (raw.itemType) {
case 'agent_message':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message?.content || '',
})];
case 'reasoning':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
case 'command_execution':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'Bash',
toolInput: { command: raw.command },
toolId: baseId,
output: raw.output,
exitCode: raw.exitCode,
status: raw.status,
})];
case 'file_change':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'FileChanges',
toolInput: raw.changes,
toolId: baseId,
status: raw.status,
})];
case 'mcp_tool_call':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool || 'MCP',
toolInput: raw.arguments,
toolId: baseId,
server: raw.server,
result: raw.result,
error: raw.error,
status: raw.status,
})];
case 'web_search':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'WebSearch',
toolInput: { query: raw.query },
toolId: baseId,
})];
case 'todo_list':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'TodoList',
toolInput: { items: raw.items },
toolId: baseId,
})];
case 'error':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.message?.content || 'Unknown error',
})];
default:
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.itemType || 'Unknown',
toolInput: raw.item || raw,
toolId: baseId,
})];
}
}
if (raw.type === 'turn_complete') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'complete',
})];
}
if (raw.type === 'turn_failed') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error?.message || 'Turn failed',
})];
}
return [];
}
/**
* Loads Codex JSONL history and keeps token usage metadata when projects.js
* provides it.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
let result: CodexHistoryResult;
try {
result = await loadCodexSessionMessages(sessionId, limit, offset);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
const normalized: NormalizedMessage[] = [];
for (const raw of rawMessages) {
normalized.push(...this.normalizeHistoryEntry(raw, sessionId));
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
tokenUsage,
};
}
}

View File

@@ -1,143 +0,0 @@
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
type CursorLoginStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class CursorProviderAuth implements IProviderAuth {
/**
* Checks whether the cursor-agent CLI is available on this host.
*/
private checkInstalled(): boolean {
try {
spawn.sync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Cursor CLI installation and login status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'cursor',
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI is not installed',
};
}
const login = await this.checkCursorLogin();
return {
installed,
provider: 'cursor',
authenticated: login.authenticated,
email: login.email,
method: login.method,
error: login.authenticated ? undefined : login.error || 'Not logged in',
};
}
/**
* Runs cursor-agent status and parses the login marker from stdout.
*/
private checkCursorLogin(): Promise<CursorLoginStatus> {
return new Promise((resolve) => {
let processCompleted = false;
let childProcess: ReturnType<typeof spawn> | undefined;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
childProcess?.kill();
resolve({
authenticated: false,
email: null,
method: null,
error: 'Command timeout',
});
}
}, 5000);
try {
childProcess = spawn('cursor-agent', ['status']);
} catch {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI not found or not installed',
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
childProcess.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) {
return;
}
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch?.[1]) {
resolve({ authenticated: true, email: emailMatch[1], method: 'cli' });
return;
}
if (stdout.includes('Logged in')) {
resolve({ authenticated: true, email: 'Logged in', method: 'cli' });
return;
}
resolve({ authenticated: false, email: null, method: null, error: 'Not logged in' });
return;
}
resolve({ authenticated: false, email: null, method: null, error: stderr || 'Not logged in' });
});
childProcess.on('error', () => {
if (processCompleted) {
return;
}
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI not found or not installed',
});
});
});
}
}

View File

@@ -1,108 +0,0 @@
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readJsonConfig,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
writeJsonConfig,
} from '@/shared/utils.js';
export class CursorMcpProvider extends McpProvider {
constructor() {
super('cursor', ['user', 'project'], ['stdio', 'http', 'sse']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.cursor', 'mcp.json')
: path.join(workspacePath, '.cursor', 'mcp.json');
const config = await readJsonConfig(filePath);
return readObjectRecord(config.mcpServers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.cursor', 'mcp.json')
: path.join(workspacePath, '.cursor', 'mcp.json');
const config = await readJsonConfig(filePath);
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'cursor',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
};
}
if (typeof config.url === 'string') {
return {
provider: 'cursor',
name,
scope,
transport: 'http',
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -1,403 +0,0 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'cursor';
type RawProviderMessage = Record<string, any>;
type CursorDbBlob = {
rowid: number;
id: string;
data?: Buffer;
};
type CursorJsonBlob = CursorDbBlob & {
parsed: RawProviderMessage;
};
type CursorMessageBlob = {
id: string;
sequence: number;
rowid: number;
content: RawProviderMessage;
};
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class CursorProvider extends AbstractProvider {
readonly mcp = new CursorMcpProvider();
readonly auth: IProviderAuth = new CursorProviderAuth();
constructor() {
super('cursor');
}
/**
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
* order. Cursor history is stored as content-addressed blobs rather than JSONL.
*/
private async loadCursorBlobs(sessionId: string, projectPath: string): Promise<CursorMessageBlob[]> {
const sqlite3Module = await import('sqlite3');
const sqlite3 = sqlite3Module.default;
const { open } = await import('sqlite');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY,
});
try {
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs') as CursorDbBlob[];
const blobMap = new Map<string, CursorDbBlob>();
const parentRefs = new Map<string, string[]>();
const childRefs = new Map<string, string[]>();
const jsonBlobs: CursorJsonBlob[] = [];
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
if (blob.data && blob.data[0] === 0x7B) {
try {
const parsed = JSON.parse(blob.data.toString('utf8')) as RawProviderMessage;
jsonBlobs.push({ ...blob, parsed });
} catch {
// Cursor can include binary or partial blobs; only JSON blobs become messages.
}
} else if (blob.data) {
const parents: string[] = [];
let i = 0;
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
for (const parentId of parents) {
if (!childRefs.has(parentId)) {
childRefs.set(parentId, []);
}
childRefs.get(parentId)?.push(blob.id);
}
}
}
}
const visited = new Set<string>();
const sorted: CursorDbBlob[] = [];
const visit = (nodeId: string): void => {
if (visited.has(nodeId)) {
return;
}
visited.add(nodeId);
for (const parentId of parentRefs.get(nodeId) || []) {
visit(parentId);
}
const blob = blobMap.get(nodeId);
if (blob) {
sorted.push(blob);
}
};
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) {
visit(blob.id);
}
}
for (const blob of allBlobs) {
visit(blob.id);
}
const messageOrder = new Map<string, number>();
let orderIndex = 0;
for (const blob of sorted) {
if (blob.data && blob.data[0] !== 0x7B) {
for (const jsonBlob of jsonBlobs) {
try {
const idBytes = Buffer.from(jsonBlob.id, 'hex');
if (blob.data.includes(idBytes) && !messageOrder.has(jsonBlob.id)) {
messageOrder.set(jsonBlob.id, orderIndex++);
}
} catch {
// Ignore malformed blob ids that cannot be decoded as hex.
}
}
}
}
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const aOrder = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const bOrder = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return aOrder !== bOrder ? aOrder - bOrder : a.rowid - b.rowid;
});
const messages: CursorMessageBlob[] = [];
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
const blob = sortedJsonBlobs[idx];
const parsed = blob.parsed;
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') {
continue;
}
messages.push({
id: blob.id,
sequence: idx + 1,
rowid: blob.rowid,
content: parsed,
});
}
return messages;
} finally {
await db.close();
}
}
/**
* Normalizes live Cursor CLI NDJSON events. Persisted Cursor history is
* normalized from SQLite blobs in fetchHistory().
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (raw?.type === 'assistant' && raw.message?.content?.[0]?.text) {
return [createNormalizedMessage({
kind: 'stream_delta',
content: raw.message.content[0].text,
sessionId,
provider: PROVIDER,
})];
}
if (typeof rawMessage === 'string' && rawMessage.trim()) {
return [createNormalizedMessage({
kind: 'stream_delta',
content: rawMessage,
sessionId,
provider: PROVIDER,
})];
}
return [];
}
/**
* Fetches and paginates Cursor session history from its project-scoped store.db.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectPath = '', limit = null, offset = 0 } = options;
try {
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
if (limit !== null && limit > 0) {
const start = offset;
const page = allNormalized.slice(start, start + limit);
return {
messages: page,
total: allNormalized.length,
hasMore: start + limit < allNormalized.length,
offset,
limit,
};
}
return {
messages: allNormalized,
total: allNormalized.length,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CursorProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
}
/**
* Converts Cursor SQLite message blobs into normalized messages and attaches
* matching tool results to their tool_use entries.
*/
private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
const messages: NormalizedMessage[] = [];
const toolUseMap = new Map<string, NormalizedMessage>();
const baseTime = Date.now();
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
const content = blob.content;
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
const baseId = blob.id || generateMessageId('cursor');
try {
if (!content?.role || !content?.content) {
if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') {
continue;
}
const role = content.message.role === 'user' ? 'user' : 'assistant';
let text = '';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
if (text?.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
}
continue;
}
if (content.role === 'system') {
continue;
}
if (content.role === 'tool') {
const toolItems = Array.isArray(content.content) ? content.content : [];
for (const item of toolItems) {
if (item?.type !== 'tool-result') {
continue;
}
const toolCallId = item.toolCallId || content.id;
messages.push(createNormalizedMessage({
id: `${baseId}_tr`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: toolCallId,
content: item.result || '',
isError: false,
}));
}
continue;
}
const role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
const part = content.content[partIdx];
if (part?.type === 'text' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: part.text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
} else if (part?.type === 'reasoning' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.text,
}));
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
const rawToolName = part.toolName || part.name || 'Unknown Tool';
const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName;
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
const message = createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: part.args || part.input,
toolId,
});
messages.push(message);
toolUseMap.set(toolId, message);
}
}
} else if (typeof content.content === 'string' && content.content.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: content.content,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
} catch (error) {
console.warn('Error normalizing cursor blob:', error);
}
}
for (const msg of messages) {
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
const toolUse = toolUseMap.get(msg.toolId);
if (toolUse) {
toolUse.toolResult = {
content: msg.content,
isError: msg.isError,
};
}
}
}
messages.sort((a, b) => {
if (a.sequence !== undefined && b.sequence !== undefined) {
return a.sequence - b.sequence;
}
if (a.rowid !== undefined && b.rowid !== undefined) {
return a.rowid - b.rowid;
}
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
return messages;
}
}

View File

@@ -1,151 +0,0 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type GeminiCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class GeminiProviderAuth implements IProviderAuth {
/**
* Checks whether the Gemini CLI is available on this host.
*/
private checkInstalled(): boolean {
const cliPath = process.env.GEMINI_PATH || 'gemini';
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Gemini CLI installation and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'gemini',
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI is not installed',
};
}
const credentials = await this.checkCredentials();
return {
installed,
provider: 'gemini',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Checks Gemini credentials from API key env vars or local OAuth credential files.
*/
private async checkCredentials(): Promise<GeminiCredentialsStatus> {
if (process.env.GEMINI_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await readFile(credsPath, 'utf8');
const creds = readObjectRecord(JSON.parse(content)) ?? {};
const accessToken = readOptionalString(creds.access_token);
if (!accessToken) {
return {
authenticated: false,
email: null,
method: null,
error: 'No valid tokens found in oauth_creds',
};
}
const refreshToken = readOptionalString(creds.refresh_token);
const tokenInfo = await this.getTokenInfoEmail(accessToken);
if (tokenInfo.valid) {
return {
authenticated: true,
email: tokenInfo.email || 'OAuth Session',
method: 'credentials_file',
};
}
if (!refreshToken) {
return {
authenticated: false,
email: null,
method: 'credentials_file',
error: 'Access token invalid and no refresh token found',
};
}
return {
authenticated: true,
email: await this.getActiveAccountEmail() || 'OAuth Session',
method: 'credentials_file',
};
} catch {
return {
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI not configured',
};
}
}
/**
* Validates a Gemini OAuth access token and returns an email when Google reports one.
*/
private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> {
try {
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
if (!tokenRes.ok) {
return { valid: false, email: null };
}
const tokenInfo = readObjectRecord(await tokenRes.json());
return {
valid: true,
email: readOptionalString(tokenInfo?.email) ?? null,
};
} catch {
return { valid: false, email: null };
}
}
/**
* Reads Gemini's active local Google account as an offline fallback for display.
*/
private async getActiveAccountEmail(): Promise<string | null> {
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await readFile(accPath, 'utf8');
const accounts = readObjectRecord(JSON.parse(accContent));
return readOptionalString(accounts?.active) ?? null;
} catch {
return null;
}
}
}

View File

@@ -1,110 +0,0 @@
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readJsonConfig,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
writeJsonConfig,
} from '@/shared/utils.js';
export class GeminiMcpProvider extends McpProvider {
constructor() {
super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.gemini', 'settings.json')
: path.join(workspacePath, '.gemini', 'settings.json');
const config = await readJsonConfig(filePath);
return readObjectRecord(config.mcpServers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.gemini', 'settings.json')
: path.join(workspacePath, '.gemini', 'settings.json');
const config = await readJsonConfig(filePath);
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: input.transport,
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'gemini',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
};
}
if (typeof config.url === 'string') {
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
return {
provider: 'gemini',
name,
scope,
transport,
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -1,235 +0,0 @@
import sessionManager from '@/sessionManager.js';
import { getGeminiCliSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'gemini';
type RawProviderMessage = Record<string, any>;
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class GeminiProvider extends AbstractProvider {
readonly mcp = new GeminiMcpProvider();
readonly auth: IProviderAuth = new GeminiProviderAuth();
constructor() {
super('gemini');
}
/**
* Normalizes live Gemini stream-json events into the shared message shape.
*
* Gemini history uses a different session file shape, so fetchHistory handles
* that separately after loading raw persisted messages.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (!raw) {
return [];
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'message' && raw.role === 'assistant') {
const content = raw.content || '';
const messages: NormalizedMessage[] = [];
if (content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_delta',
content,
}));
}
if (raw.delta !== true) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
}));
}
return messages;
}
if (raw.type === 'tool_use') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool_name,
toolInput: raw.parameters || {},
toolId: raw.tool_id || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.tool_id || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: raw.status === 'error',
})];
}
if (raw.type === 'result') {
const messages = [createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
})];
if (raw.stats?.total_tokens) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'status',
text: 'Complete',
tokens: raw.stats.total_tokens,
canInterrupt: false,
}));
}
return messages;
}
if (raw.type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error || raw.message || 'Unknown Gemini streaming error',
})];
}
return [];
}
/**
* Loads Gemini history from the in-memory session manager first, then falls
* back to Gemini CLI session files on disk.
*/
async fetchHistory(
sessionId: string,
_options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
let rawMessages: RawProviderMessage[];
try {
rawMessages = sessionManager.getSessionMessages(sessionId) as RawProviderMessage[];
if (rawMessages.length === 0) {
rawMessages = await getGeminiCliSessionMessages(sessionId) as RawProviderMessage[];
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const normalized: NormalizedMessage[] = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) {
continue;
}
const normalizedRole = role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx];
if (part.type === 'text' && part.text) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
}));
}
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
return {
messages: normalized,
total: normalized.length,
hasMore: false,
offset: 0,
limit: null,
};
}
}

View File

@@ -1,36 +0,0 @@
import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js';
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
const providers: Record<LLMProvider, IProvider> = {
claude: new ClaudeProvider(),
codex: new CodexProvider(),
cursor: new CursorProvider(),
gemini: new GeminiProvider(),
};
/**
* Central registry for resolving concrete provider implementations by id.
*/
export const providerRegistry = {
listProviders(): IProvider[] {
return Object.values(providers);
},
resolveProvider(provider: string): IProvider {
const key = provider as LLMProvider;
const resolvedProvider = providers[key];
if (!resolvedProvider) {
throw new AppError(`Unsupported provider "${provider}".`, {
code: 'UNSUPPORTED_PROVIDER',
statusCode: 400,
});
}
return resolvedProvider;
},
};

View File

@@ -1,217 +0,0 @@
import express, { type Request, type Response } from 'express';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
const router = express.Router();
const readPathParam = (value: unknown, name: string): string => {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0];
}
throw new AppError(`${name} path parameter is invalid.`, {
code: 'INVALID_PATH_PARAMETER',
statusCode: 400,
});
};
const normalizeProviderParam = (value: unknown): string =>
readPathParam(value, 'provider').trim().toLowerCase();
const readOptionalQueryString = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
};
const parseMcpScope = (value: unknown): McpScope | undefined => {
if (value === undefined) {
return undefined;
}
const normalized = readOptionalQueryString(value);
if (!normalized) {
return undefined;
}
if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
return normalized;
}
throw new AppError(`Unsupported MCP scope "${normalized}".`, {
code: 'INVALID_MCP_SCOPE',
statusCode: 400,
});
};
const parseMcpTransport = (value: unknown): McpTransport => {
const normalized = readOptionalQueryString(value);
if (!normalized) {
throw new AppError('transport is required.', {
code: 'MCP_TRANSPORT_REQUIRED',
statusCode: 400,
});
}
if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
return normalized;
}
throw new AppError(`Unsupported MCP transport "${normalized}".`, {
code: 'INVALID_MCP_TRANSPORT',
statusCode: 400,
});
};
const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const name = readOptionalQueryString(body.name);
if (!name) {
throw new AppError('name is required.', {
code: 'MCP_NAME_REQUIRED',
statusCode: 400,
});
}
const transport = parseMcpTransport(body.transport);
const scope = parseMcpScope(body.scope);
const workspacePath = readOptionalQueryString(body.workspacePath);
return {
name,
transport,
scope,
workspacePath,
command: readOptionalQueryString(body.command),
args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
env: typeof body.env === 'object' && body.env !== null
? Object.fromEntries(
Object.entries(body.env as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
cwd: readOptionalQueryString(body.cwd),
url: readOptionalQueryString(body.url),
headers: typeof body.headers === 'object' && body.headers !== null
? Object.fromEntries(
Object.entries(body.headers as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
envVars: Array.isArray(body.envVars)
? body.envVars.filter((entry): entry is string => typeof entry === 'string')
: undefined,
bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
? Object.fromEntries(
Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
};
};
const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value);
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
return normalized;
}
throw new AppError(`Unsupported provider "${normalized}".`, {
code: 'UNSUPPORTED_PROVIDER',
statusCode: 400,
});
};
router.get(
'/:provider/auth/status',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const status = await providerAuthService.getProviderAuthStatus(provider);
res.json(status);
}),
);
router.get(
'/:provider/mcp/servers',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
const scope = parseMcpScope(req.query.scope);
if (scope) {
const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath });
res.json(createApiSuccessResponse({ provider, scope, servers }));
return;
}
const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath });
res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
}),
);
router.post(
'/:provider/mcp/servers',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const payload = parseMcpUpsertPayload(req.body);
const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
res.status(201).json(createApiSuccessResponse({ server }));
}),
);
router.delete(
'/:provider/mcp/servers/:name',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const scope = parseMcpScope(req.query.scope);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
const result = await providerMcpService.removeProviderMcpServer(provider, {
name: readPathParam(req.params.name, 'name'),
scope,
workspacePath,
});
res.json(createApiSuccessResponse(result));
}),
);
router.post(
'/mcp/servers/global',
asyncHandler(async (req: Request, res: Response) => {
const payload = parseMcpUpsertPayload(req.body);
if (payload.scope === 'local') {
throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
code: 'INVALID_GLOBAL_MCP_SCOPE',
statusCode: 400,
});
}
const results = await providerMcpService.addMcpServerToAllProviders({
...payload,
scope: payload.scope === 'user' ? 'user' : 'project',
});
res.status(201).json(createApiSuccessResponse({ results }));
}),
);
export default router;

View File

@@ -1,94 +0,0 @@
import os from 'node:os';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
/** Cursor MCP is not supported on Windows hosts (no Cursor CLI integration). */
function includeProviderInGlobalMcp(providerId: LLMProvider): boolean {
if (providerId === 'cursor' && os.platform() === 'win32') {
return false;
}
return true;
}
export const providerMcpService = {
/**
* Lists MCP servers for one provider grouped by supported scopes.
*/
async listProviderMcpServers(
providerName: string,
options?: { workspacePath?: string },
): Promise<Record<McpScope, ProviderMcpServer[]>> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.listServers(options);
},
/**
* Lists MCP servers for one provider scope.
*/
async listProviderMcpServersForScope(
providerName: string,
scope: McpScope,
options?: { workspacePath?: string },
): Promise<ProviderMcpServer[]> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.listServersForScope(scope, options);
},
/**
* Adds or updates one provider MCP server.
*/
async upsertProviderMcpServer(
providerName: string,
input: UpsertProviderMcpServerInput,
): Promise<ProviderMcpServer> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.upsertServer(input);
},
/**
* Removes one provider MCP server.
*/
async removeProviderMcpServer(
providerName: string,
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.removeServer(input);
},
/**
* Adds one HTTP/stdio MCP server to every provider.
*/
async addMcpServerToAllProviders(
input: Omit<UpsertProviderMcpServerInput, 'scope'> & { scope?: Exclude<McpScope, 'local'> },
): Promise<Array<{ provider: LLMProvider; created: boolean; error?: string }>> {
if (input.transport !== 'stdio' && input.transport !== 'http') {
throw new AppError('Global MCP add supports only "stdio" and "http".', {
code: 'INVALID_GLOBAL_MCP_TRANSPORT',
statusCode: 400,
});
}
const scope = input.scope ?? 'project';
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
const providers = providerRegistry.listProviders().filter((p) => includeProviderInGlobalMcp(p.id));
for (const provider of providers) {
try {
await provider.mcp.upsertServer({ ...input, scope });
results.push({ provider: provider.id, created: true });
} catch (error) {
results.push({
provider: provider.id,
created: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return results;
},
};

View File

@@ -1,12 +0,0 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
export const providerAuthService = {
/**
* Resolves a provider and returns its installation/authentication status.
*/
async getProviderAuthStatus(providerName: string): Promise<ProviderAuthStatus> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.auth.getStatus();
},
};

View File

@@ -1,45 +0,0 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type {
FetchHistoryOptions,
FetchHistoryResult,
LLMProvider,
NormalizedMessage,
} from '@/shared/types.js';
/**
* Application service for provider-backed session message operations.
*
* Callers pass a provider id and this service resolves the concrete provider
* class, keeping normalization/history call sites decoupled from implementation
* file layout.
*/
export const sessionsService = {
/**
* Lists provider ids that can load session history and normalize live messages.
*/
listProviderIds(): LLMProvider[] {
return providerRegistry.listProviders().map((provider) => provider.id);
},
/**
* Normalizes one provider-native event into frontend session message events.
*/
normalizeMessage(
providerName: string,
raw: unknown,
sessionId: string | null,
): NormalizedMessage[] {
return providerRegistry.resolveProvider(providerName).normalizeMessage(raw, sessionId);
},
/**
* Fetches normalized persisted session history for one provider/session pair.
*/
fetchHistory(
providerName: string,
sessionId: string,
options?: FetchHistoryOptions,
): Promise<FetchHistoryResult> {
return providerRegistry.resolveProvider(providerName).fetchHistory(sessionId, options);
},
};

View File

@@ -1,31 +0,0 @@
import type { IProvider, IProviderAuth, IProviderMcp } from '@/shared/interfaces.js';
import type {
FetchHistoryOptions,
FetchHistoryResult,
LLMProvider,
NormalizedMessage,
} from '@/shared/types.js';
/**
* Shared provider base.
*
* Concrete providers must expose auth/MCP handlers and implement message
* normalization/history loading because those behaviors depend on native
* SDK/CLI formats.
*/
export abstract class AbstractProvider implements IProvider {
readonly id: LLMProvider;
abstract readonly mcp: IProviderMcp;
abstract readonly auth: IProviderAuth;
protected constructor(id: LLMProvider) {
this.id = id;
}
abstract normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
abstract fetchHistory(
sessionId: string,
options?: FetchHistoryOptions,
): Promise<FetchHistoryResult>;
}

View File

@@ -1,151 +0,0 @@
import path from 'node:path';
import type { IProviderMcp } from '@/shared/interfaces.js';
import type { LLMProvider, McpScope, McpTransport, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
const resolveWorkspacePath = (workspacePath?: string): string =>
path.resolve(workspacePath ?? process.cwd());
const normalizeServerName = (name: string): string => {
const normalized = name.trim();
if (!normalized) {
throw new AppError('MCP server name is required.', {
code: 'MCP_SERVER_NAME_REQUIRED',
statusCode: 400,
});
}
return normalized;
};
/**
* Shared MCP provider for provider-specific config readers/writers.
*/
export abstract class McpProvider implements IProviderMcp {
protected readonly provider: LLMProvider;
protected readonly supportedScopes: McpScope[];
protected readonly supportedTransports: McpTransport[];
protected constructor(
provider: LLMProvider,
supportedScopes: McpScope[],
supportedTransports: McpTransport[],
) {
this.provider = provider;
this.supportedScopes = supportedScopes;
this.supportedTransports = supportedTransports;
}
async listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>> {
const grouped: Record<McpScope, ProviderMcpServer[]> = {
user: [],
local: [],
project: [],
};
for (const scope of this.supportedScopes) {
grouped[scope] = await this.listServersForScope(scope, options);
}
return grouped;
}
async listServersForScope(
scope: McpScope,
options?: { workspacePath?: string },
): Promise<ProviderMcpServer[]> {
if (!this.supportedScopes.includes(scope)) {
return [];
}
const workspacePath = resolveWorkspacePath(options?.workspacePath);
const scopedServers = await this.readScopedServers(scope, workspacePath);
return Object.entries(scopedServers)
.map(([name, rawConfig]) => this.normalizeServerConfig(scope, name, rawConfig))
.filter((entry): entry is ProviderMcpServer => entry !== null);
}
async upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer> {
const scope = input.scope ?? 'project';
this.assertScopeAndTransport(scope, input.transport);
const workspacePath = resolveWorkspacePath(input.workspacePath);
const normalizedName = normalizeServerName(input.name);
const scopedServers = await this.readScopedServers(scope, workspacePath);
scopedServers[normalizedName] = this.buildServerConfig(input);
await this.writeScopedServers(scope, workspacePath, scopedServers);
return {
provider: this.provider,
name: normalizedName,
scope,
transport: input.transport,
command: input.command,
args: input.args,
env: input.env,
cwd: input.cwd,
url: input.url,
headers: input.headers,
envVars: input.envVars,
bearerTokenEnvVar: input.bearerTokenEnvVar,
envHttpHeaders: input.envHttpHeaders,
};
}
async removeServer(
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
const scope = input.scope ?? 'project';
this.assertScope(scope);
const workspacePath = resolveWorkspacePath(input.workspacePath);
const normalizedName = normalizeServerName(input.name);
const scopedServers = await this.readScopedServers(scope, workspacePath);
const removed = Object.prototype.hasOwnProperty.call(scopedServers, normalizedName);
if (removed) {
delete scopedServers[normalizedName];
await this.writeScopedServers(scope, workspacePath, scopedServers);
}
return { removed, provider: this.provider, name: normalizedName, scope };
}
protected abstract readScopedServers(
scope: McpScope,
workspacePath: string,
): Promise<Record<string, unknown>>;
protected abstract writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void>;
protected abstract buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown>;
protected abstract normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null;
protected assertScope(scope: McpScope): void {
if (!this.supportedScopes.includes(scope)) {
throw new AppError(`Provider "${this.provider}" does not support "${scope}" MCP scope.`, {
code: 'MCP_SCOPE_NOT_SUPPORTED',
statusCode: 400,
});
}
}
protected assertScopeAndTransport(scope: McpScope, transport: McpTransport): void {
this.assertScope(scope);
if (!this.supportedTransports.includes(transport)) {
throw new AppError(`Provider "${this.provider}" does not support "${transport}" MCP transport.`, {
code: 'MCP_TRANSPORT_NOT_SUPPORTED',
statusCode: 400,
});
}
}
}

View File

@@ -1,293 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import TOML from '@iarna/toml';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { AppError } from '@/shared/utils.js';
const patchHomeDir = (nextHomeDir: string) => {
const original = os.homedir;
(os as any).homedir = () => nextHomeDir;
return () => {
(os as any).homedir = original;
};
};
const readJson = async (filePath: string): Promise<Record<string, unknown>> => {
const content = await fs.readFile(filePath, 'utf8');
return JSON.parse(content) as Record<string, unknown>;
};
/**
* This test covers Claude MCP support for all scopes (user/local/project) and all transports (stdio/http/sse),
* including add, update/list, and remove operations.
*/
test('providerMcpService handles claude MCP scopes/transports with file-backed persistence', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-claude-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-user-stdio',
scope: 'user',
transport: 'stdio',
command: 'npx',
args: ['-y', 'my-server'],
env: { API_KEY: 'secret' },
});
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-local-http',
scope: 'local',
transport: 'http',
url: 'https://example.com/mcp',
headers: { Authorization: 'Bearer token' },
workspacePath,
});
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-project-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
headers: { 'X-API-Key': 'abc' },
workspacePath,
});
const grouped = await providerMcpService.listProviderMcpServers('claude', { workspacePath });
assert.ok(grouped.user.some((server) => server.name === 'claude-user-stdio' && server.transport === 'stdio'));
assert.ok(grouped.local.some((server) => server.name === 'claude-local-http' && server.transport === 'http'));
assert.ok(grouped.project.some((server) => server.name === 'claude-project-sse' && server.transport === 'sse'));
// update behavior is the same upsert route with same name
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-project-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse-updated',
headers: { 'X-API-Key': 'updated' },
workspacePath,
});
const projectConfig = await readJson(path.join(workspacePath, '.mcp.json'));
const projectServers = projectConfig.mcpServers as Record<string, unknown>;
const projectServer = projectServers['claude-project-sse'] as Record<string, unknown>;
assert.equal(projectServer.url, 'https://example.com/sse-updated');
const removeResult = await providerMcpService.removeProviderMcpServer('claude', {
name: 'claude-local-http',
scope: 'local',
workspacePath,
});
assert.equal(removeResult.removed, true);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers Codex MCP support for user/project scopes, stdio/http formats,
* and validation for unsupported scope/transport combinations.
*/
test('providerMcpService handles codex MCP TOML config and capability validation', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-codex-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-user-stdio',
scope: 'user',
transport: 'stdio',
command: 'python',
args: ['server.py'],
env: { API_KEY: 'x' },
envVars: ['API_KEY'],
cwd: '/tmp',
});
await providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-project-http',
scope: 'project',
transport: 'http',
url: 'https://codex.example.com/mcp',
headers: { 'X-Custom-Header': 'value' },
envHttpHeaders: { 'X-API-Key': 'MY_API_KEY_ENV' },
bearerTokenEnvVar: 'MY_API_TOKEN',
workspacePath,
});
const userTomlPath = path.join(tempRoot, '.codex', 'config.toml');
const userConfig = TOML.parse(await fs.readFile(userTomlPath, 'utf8')) as Record<string, unknown>;
const userServers = userConfig.mcp_servers as Record<string, unknown>;
const userStdio = userServers['codex-user-stdio'] as Record<string, unknown>;
assert.equal(userStdio.command, 'python');
const projectTomlPath = path.join(workspacePath, '.codex', 'config.toml');
const projectConfig = TOML.parse(await fs.readFile(projectTomlPath, 'utf8')) as Record<string, unknown>;
const projectServers = projectConfig.mcp_servers as Record<string, unknown>;
const projectHttp = projectServers['codex-project-http'] as Record<string, unknown>;
assert.equal(projectHttp.url, 'https://codex.example.com/mcp');
await assert.rejects(
providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-local',
scope: 'local',
transport: 'stdio',
command: 'node',
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
error.statusCode === 400,
);
await assert.rejects(
providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
workspacePath,
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
error.statusCode === 400,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
*/
test('providerMcpService handles gemini and cursor MCP JSON config formats', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-gc-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('gemini', {
name: 'gemini-stdio',
scope: 'user',
transport: 'stdio',
command: 'node',
args: ['server.js'],
env: { TOKEN: '$TOKEN' },
cwd: './server',
});
await providerMcpService.upsertProviderMcpServer('gemini', {
name: 'gemini-http',
scope: 'project',
transport: 'http',
url: 'https://gemini.example.com/mcp',
headers: { Authorization: 'Bearer token' },
workspacePath,
});
await providerMcpService.upsertProviderMcpServer('cursor', {
name: 'cursor-stdio',
scope: 'project',
transport: 'stdio',
command: 'npx',
args: ['-y', 'mcp-server'],
env: { API_KEY: 'value' },
workspacePath,
});
await providerMcpService.upsertProviderMcpServer('cursor', {
name: 'cursor-http',
scope: 'user',
transport: 'http',
url: 'http://localhost:3333/mcp',
headers: { API_KEY: 'value' },
});
const geminiUserConfig = await readJson(path.join(tempRoot, '.gemini', 'settings.json'));
const geminiUserServer = (geminiUserConfig.mcpServers as Record<string, unknown>)['gemini-stdio'] as Record<string, unknown>;
assert.equal(geminiUserServer.command, 'node');
assert.equal(geminiUserServer.type, undefined);
const geminiProjectConfig = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
const geminiProjectServer = (geminiProjectConfig.mcpServers as Record<string, unknown>)['gemini-http'] as Record<string, unknown>;
assert.equal(geminiProjectServer.type, 'http');
const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json'));
const cursorHttpServer = (cursorUserConfig.mcpServers as Record<string, unknown>)['cursor-http'] as Record<string, unknown>;
assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp');
assert.equal(cursorHttpServer.type, undefined);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers the global MCP adder requirement: only http/stdio are allowed and
* one payload is written to all providers.
*/
test('providerMcpService global adder writes to all providers and rejects unsupported transports', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-global-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
const globalResult = await providerMcpService.addMcpServerToAllProviders({
name: 'global-http',
scope: 'project',
transport: 'http',
url: 'https://global.example.com/mcp',
workspacePath,
});
const expectCursorGlobal = process.platform !== 'win32';
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
assert.ok((claudeProject.mcpServers as Record<string, unknown>)['global-http']);
const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record<string, unknown>;
assert.ok((codexProject.mcp_servers as Record<string, unknown>)['global-http']);
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
if (expectCursorGlobal) {
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
}
await assert.rejects(
providerMcpService.addMcpServerToAllProviders({
name: 'global-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
workspacePath,
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_GLOBAL_MCP_TRANSPORT' &&
error.statusCode === 400,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -15,8 +15,9 @@
import { Codex } from '@openai/codex-sdk';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { createNormalizedMessage } from './shared/utils.js';
import { codexAdapter } from './providers/codex/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
// Track active sessions
const activeCodexSessions = new Map();
@@ -264,7 +265,7 @@ export async function queryCodex(command, options = {}, ws) {
const transformed = transformCodexEvent(event);
// Normalize the transformed event into NormalizedMessage(s) via adapter
const normalizedMsgs = sessionsService.normalizeMessage('codex', transformed, currentSessionId);
const normalizedMsgs = codexAdapter.normalizeMessage(transformed, currentSessionId);
for (const msg of normalizedMsgs) {
sendMessage(ws, msg);
}
@@ -308,7 +309,14 @@ export async function queryCodex(command, options = {}, ws) {
if (!wasAborted) {
console.error('[Codex] Error:', error);
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' }));
// Check if Codex SDK is available for a clearer error message
const installed = getStatusChecker('codex')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Codex CLI is not configured. Please set up authentication first.'
: error.message;
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: currentSessionId, provider: 'codex' }));
if (!terminalFailure) {
notifyRunFailed({
userId: ws?.userId || null,

View File

@@ -62,8 +62,7 @@ import fsSync from 'fs';
import path from 'path';
import readline from 'readline';
import crypto from 'crypto';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import Database from 'better-sqlite3';
import os from 'os';
import sessionManager from './sessionManager.js';
import { applyCustomSessionNames } from './database/db.js';
@@ -1164,8 +1163,9 @@ async function isProjectEmpty(projectName) {
}
}
// Delete a project (force=true to delete even with sessions)
async function deleteProject(projectName, force = false) {
// Remove a project from the UI.
// When deleteData=true, also delete session/memory files on disk (destructive).
async function deleteProject(projectName, force = false, deleteData = false) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
@@ -1175,48 +1175,50 @@ async function deleteProject(projectName, force = false) {
}
const config = await loadProjectConfig();
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
// Fallback to extractProjectDirectory if projectPath is not in config
if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
}
// Destructive path: delete underlying data when explicitly requested
if (deleteData) {
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
}
// Remove the project directory (includes all Claude sessions)
await fs.rm(projectDir, { recursive: true, force: true });
// Remove the Claude project directory (session logs, memory, subagent data)
await fs.rm(projectDir, { recursive: true, force: true });
// Delete all Codex sessions associated with this project
if (projectPath) {
try {
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
for (const session of codexSessions) {
try {
await deleteCodexSession(session.id);
} catch (err) {
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
// Delete Codex sessions associated with this project
if (projectPath) {
try {
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
for (const session of codexSessions) {
try {
await deleteCodexSession(session.id);
} catch (err) {
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
}
}
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
}
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
}
// Delete Cursor sessions directory if it exists
try {
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
await fs.rm(cursorProjectDir, { recursive: true, force: true });
} catch (err) {
// Cursor dir may not exist, ignore
// Delete Cursor sessions directory if it exists
try {
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
await fs.rm(cursorProjectDir, { recursive: true, force: true });
} catch (err) {
// Cursor dir may not exist, ignore
}
}
}
// Remove from project config
// Always remove from project config
delete config[projectName];
await saveProjectConfig(config);
return true;
} catch (error) {
console.error(`Error deleting project ${projectName}:`, error);
console.error(`Error removing project ${projectName}:`, error);
throw error;
}
}
@@ -1305,16 +1307,10 @@ async function getCursorSessions(projectPath) {
} catch (_) { }
// Open SQLite database
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
// Get metadata from meta table
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
const metaRows = db.prepare('SELECT key, value FROM meta').all();
// Parse metadata
let metadata = {};
@@ -1336,11 +1332,9 @@ async function getCursorSessions(projectPath) {
}
// Get message count
const messageCountResult = await db.get(`
SELECT COUNT(*) as count FROM blobs
`);
const messageCountResult = db.prepare('SELECT COUNT(*) as count FROM blobs').get();
await db.close();
db.close();
// Extract session info
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';

View File

@@ -0,0 +1,278 @@
/**
* Claude provider adapter.
*
* Normalizes Claude SDK session history into NormalizedMessage format.
* @module adapters/claude
*/
import { getSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js';
import { isInternalContent } from '../utils.js';
const PROVIDER = 'claude';
/**
* Normalize a raw JSONL message or realtime SDK event into NormalizedMessage(s).
* Handles both history entries (JSONL `{ message: { role, content } }`) and
* realtime streaming events (`content_block_delta`, `content_block_stop`, etc.).
* @param {object} raw - A single entry from JSONL or a live SDK event
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
// ── Streaming events (realtime) ──────────────────────────────────────────
if (raw.type === 'content_block_delta' && raw.delta?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
}
if (raw.type === 'content_block_stop') {
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
}
// ── History / full-message events ────────────────────────────────────────
const messages = [];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('claude');
// User message
if (raw.message?.role === 'user' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
// Handle tool_result parts
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: `${baseId}_tr_${part.tool_use_id}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id,
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
}));
} else if (part.type === 'text') {
// Regular text parts from user
const text = part.text || '';
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
}
// If no text parts were found, check if it's a pure user message
if (messages.length === 0) {
const textParts = raw.message.content
.filter(p => p.type === 'text')
.map(p => p.text)
.filter(Boolean)
.join('\n');
if (textParts && !isInternalContent(textParts)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: textParts,
}));
}
}
} else if (typeof raw.message.content === 'string') {
const text = raw.message.content;
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
return messages;
}
// Thinking message
if (raw.type === 'thinking' && raw.message?.content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message.content,
}));
return messages;
}
// Tool use result (codex-style in Claude)
if (raw.type === 'tool_use' && raw.toolName) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName,
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
}));
return messages;
}
if (raw.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: false,
}));
return messages;
}
// Assistant message
if (raw.message?.role === 'assistant' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
let partIndex = 0;
for (const part of raw.message.content) {
if (part.type === 'text' && part.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: part.text,
}));
} else if (part.type === 'tool_use') {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id,
}));
} else if (part.type === 'thinking' && part.thinking) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.thinking,
}));
}
partIndex++;
}
} else if (typeof raw.message.content === 'string') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message.content,
}));
}
return messages;
}
return messages;
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const claudeAdapter = {
normalizeMessage,
/**
* Fetch session history from JSONL files, returning normalized messages.
*/
async fetchHistory(sessionId, opts = {}) {
const { projectName, limit = null, offset = 0 } = opts;
if (!projectName) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
let result;
try {
result = await getSessionMessages(projectName, sessionId, limit, offset);
} catch (error) {
console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
// getSessionMessages returns either an array (no limit) or { messages, total, hasMore }
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
// First pass: collect tool results for attachment to tool_use messages
const toolResultMap = new Map();
for (const raw of rawMessages) {
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
toolResultMap.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
timestamp: raw.timestamp,
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
});
}
}
}
}
// Second pass: normalize all messages
const normalized = [];
for (const raw of rawMessages) {
const entries = normalizeMessage(raw, sessionId);
normalized.push(...entries);
}
// Attach tool results to their corresponding tool_use messages
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = {
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
isError: tr.isError,
toolUseResult: tr.toolUseResult,
};
msg.subagentTools = tr.subagentTools;
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
},
};

View File

@@ -0,0 +1,136 @@
/**
* Claude Provider Status
*
* Checks whether Claude Code CLI is installed and whether the user
* has valid authentication credentials.
*
* @module providers/claude/status
*/
import { execFileSync } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Claude Code CLI is installed and available.
* Uses CLAUDE_CLI_PATH env var if set, otherwise looks for 'claude' in PATH.
* @returns {boolean}
*/
export function checkInstalled() {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
try {
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
method: null,
error: 'Claude Code CLI is not installed'
};
}
const credentialsResult = await checkCredentials();
if (credentialsResult.authenticated) {
return {
installed,
authenticated: true,
email: credentialsResult.email || 'Authenticated',
method: credentialsResult.method || null,
error: null
};
}
return {
installed,
authenticated: false,
email: credentialsResult.email || null,
method: credentialsResult.method || null,
error: credentialsResult.error || 'Not authenticated'
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function loadSettingsEnv() {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
if (settings?.env && typeof settings.env === 'object') {
return settings.env;
}
} catch {
// Ignore missing or malformed settings.
}
return {};
}
/**
* Checks Claude authentication credentials.
*
* Priority 1: ANTHROPIC_API_KEY environment variable
* Priority 1b: ~/.claude/settings.json env values
* Priority 2: ~/.claude/.credentials.json OAuth tokens
*/
async function checkCredentials() {
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const settingsEnv = await loadSettingsEnv();
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
}
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8');
const creds = JSON.parse(content);
const oauth = creds.claudeAiOauth;
if (oauth && oauth.accessToken) {
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
if (!isExpired) {
return {
authenticated: true,
email: creds.email || creds.user || null,
method: 'credentials_file'
};
}
return {
authenticated: false,
email: creds.email || creds.user || null,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login'
};
}
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}

View File

@@ -0,0 +1,248 @@
/**
* Codex (OpenAI) provider adapter.
*
* Normalizes Codex SDK session history into NormalizedMessage format.
* @module adapters/codex
*/
import { getCodexSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'codex';
/**
* Normalize a raw Codex JSONL message into NormalizedMessage(s).
* @param {object} raw - A single parsed message from Codex JSONL
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
function normalizeCodexHistoryEntry(raw, sessionId) {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
// User message
if (raw.message?.role === 'user') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
: String(raw.message.content || '');
if (!content.trim()) return [];
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content,
})];
}
// Assistant message
if (raw.message?.role === 'assistant') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
: '';
if (!content.trim()) return [];
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content,
})];
}
// Thinking/reasoning
if (raw.type === 'thinking' || raw.isReasoning) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
}
// Tool use
if (raw.type === 'tool_use' || raw.toolName) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName || 'Unknown',
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
})];
}
// Tool result
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: Boolean(raw.isError),
})];
}
return [];
}
/**
* Normalize a raw Codex event (history JSONL or transformed SDK event) into NormalizedMessage(s).
* @param {object} raw - A history entry (has raw.message.role) or transformed SDK event (has raw.type)
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
// History format: has message.role
if (raw.message?.role) {
return normalizeCodexHistoryEntry(raw, sessionId);
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
// SDK event format (output of transformCodexEvent)
if (raw.type === 'item') {
switch (raw.itemType) {
case 'agent_message':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'text', role: 'assistant', content: raw.message?.content || '',
})];
case 'reasoning':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'thinking', content: raw.message?.content || '',
})];
case 'command_execution':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'Bash', toolInput: { command: raw.command },
toolId: baseId,
output: raw.output, exitCode: raw.exitCode, status: raw.status,
})];
case 'file_change':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'FileChanges', toolInput: raw.changes,
toolId: baseId, status: raw.status,
})];
case 'mcp_tool_call':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: raw.tool || 'MCP', toolInput: raw.arguments,
toolId: baseId, server: raw.server, result: raw.result,
error: raw.error, status: raw.status,
})];
case 'web_search':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'WebSearch', toolInput: { query: raw.query },
toolId: baseId,
})];
case 'todo_list':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'TodoList', toolInput: { items: raw.items },
toolId: baseId,
})];
case 'error':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'error', content: raw.message?.content || 'Unknown error',
})];
default:
// Unknown item type — pass through as generic tool_use
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: raw.itemType || 'Unknown',
toolInput: raw.item || raw, toolId: baseId,
})];
}
}
if (raw.type === 'turn_complete') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'complete',
})];
}
if (raw.type === 'turn_failed') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'error', content: raw.error?.message || 'Turn failed',
})];
}
return [];
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const codexAdapter = {
normalizeMessage,
/**
* Fetch session history from Codex JSONL files.
*/
async fetchHistory(sessionId, opts = {}) {
const { limit = null, offset = 0 } = opts;
let result;
try {
result = await getCodexSessionMessages(sessionId, limit, offset);
} catch (error) {
console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const tokenUsage = result.tokenUsage || null;
const normalized = [];
for (const raw of rawMessages) {
const entries = normalizeCodexHistoryEntry(raw, sessionId);
normalized.push(...entries);
}
// Attach tool results to tool_use messages
const toolResultMap = new Map();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = { content: tr.content, isError: tr.isError };
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
tokenUsage,
};
},
};

View File

@@ -0,0 +1,78 @@
/**
* Codex Provider Status
*
* Checks whether the user has valid Codex authentication credentials.
* Codex uses an SDK that makes direct API calls (no external binary),
* so installation check always returns true if the server is running.
*
* @module providers/codex/status
*/
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Codex is installed.
* Codex SDK is bundled with this application — no external binary needed.
* @returns {boolean}
*/
export function checkInstalled() {
return true;
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
const result = await checkCredentials();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function checkCredentials() {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await fs.readFile(authPath, 'utf8');
const auth = JSON.parse(content);
const tokens = auth.tokens || {};
if (tokens.id_token || tokens.access_token) {
let email = 'Authenticated';
if (tokens.id_token) {
try {
const parts = tokens.id_token.split('.');
if (parts.length >= 2) {
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
email = payload.email || payload.user || 'Authenticated';
}
} catch {
email = 'Authenticated';
}
}
return { authenticated: true, email };
}
if (auth.OPENAI_API_KEY) {
return { authenticated: true, email: 'API Key Auth' };
}
return { authenticated: false, email: null, error: 'No valid tokens found' };
} catch (error) {
if (error.code === 'ENOENT') {
return { authenticated: false, email: null, error: 'Codex not configured' };
}
return { authenticated: false, email: null, error: error.message };
}
}

View File

@@ -0,0 +1,348 @@
/**
* Cursor provider adapter.
*
* Normalizes Cursor CLI session history into NormalizedMessage format.
* @module adapters/cursor
*/
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'cursor';
/**
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
* and return sorted message blobs in chronological order.
* @param {string} sessionId
* @param {string} projectPath - Absolute project path (used to compute cwdId hash)
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
*/
async function loadCursorBlobs(sessionId, projectPath) {
// Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
const { default: Database } = await import('better-sqlite3');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
try {
const allBlobs = db.prepare('SELECT rowid, id, data FROM blobs').all();
const blobMap = new Map();
const parentRefs = new Map();
const childRefs = new Map();
const jsonBlobs = [];
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
if (blob.data && blob.data[0] === 0x7B) {
try {
const parsed = JSON.parse(blob.data.toString('utf8'));
jsonBlobs.push({ ...blob, parsed });
} catch {
// skip unparseable blobs
}
} else if (blob.data) {
const parents = [];
let i = 0;
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
for (const parentId of parents) {
if (!childRefs.has(parentId)) childRefs.set(parentId, []);
childRefs.get(parentId).push(blob.id);
}
}
}
}
// Topological sort (DFS)
const visited = new Set();
const sorted = [];
function visit(nodeId) {
if (visited.has(nodeId)) return;
visited.add(nodeId);
for (const pid of (parentRefs.get(nodeId) || [])) visit(pid);
const b = blobMap.get(nodeId);
if (b) sorted.push(b);
}
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) visit(blob.id);
}
for (const blob of allBlobs) visit(blob.id);
// Order JSON blobs by DAG appearance
const messageOrder = new Map();
let orderIndex = 0;
for (const blob of sorted) {
if (blob.data && blob.data[0] !== 0x7B) {
for (const jb of jsonBlobs) {
try {
const idBytes = Buffer.from(jb.id, 'hex');
if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) {
messageOrder.set(jb.id, orderIndex++);
}
} catch { /* skip */ }
}
}
}
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return oa !== ob ? oa - ob : a.rowid - b.rowid;
});
const messages = [];
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
const blob = sortedJsonBlobs[idx];
const parsed = blob.parsed;
if (!parsed) continue;
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') continue;
messages.push({
id: blob.id,
sequence: idx + 1,
rowid: blob.rowid,
content: parsed,
});
}
return messages;
} finally {
db.close();
}
}
/**
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
* @param {object|string} raw - A parsed NDJSON event or a raw text line
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
// Structured assistant message with content array
if (raw && typeof raw === 'object' && raw.type === 'assistant' && raw.message?.content?.[0]?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.message.content[0].text, sessionId, provider: PROVIDER })];
}
// Plain string line (non-JSON output)
if (typeof raw === 'string' && raw.trim()) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw, sessionId, provider: PROVIDER })];
}
return [];
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const cursorAdapter = {
normalizeMessage,
/**
* Fetch session history for Cursor from SQLite store.db.
*/
async fetchHistory(sessionId, opts = {}) {
const { projectPath = '', limit = null, offset = 0 } = opts;
try {
const blobs = await loadCursorBlobs(sessionId, projectPath);
const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId);
// Apply pagination
if (limit !== null && limit > 0) {
const start = offset;
const page = allNormalized.slice(start, start + limit);
return {
messages: page,
total: allNormalized.length,
hasMore: start + limit < allNormalized.length,
offset,
limit,
};
}
return {
messages: allNormalized,
total: allNormalized.length,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
// DB doesn't exist or is unreadable — return empty
console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
},
/**
* Normalize raw Cursor blob messages into NormalizedMessage[].
* @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
normalizeCursorBlobs(blobs, sessionId) {
const messages = [];
const toolUseMap = new Map();
// Use a fixed base timestamp so messages have stable, monotonically-increasing
// timestamps based on their sequence number rather than wall-clock time.
const baseTime = Date.now();
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
const content = blob.content;
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
const baseId = blob.id || generateMessageId('cursor');
try {
if (!content?.role || !content?.content) {
// Try nested message format
if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') continue;
const role = content.message.role === 'user' ? 'user' : 'assistant';
let text = '';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map(p => typeof p === 'string' ? p : p?.text || '')
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
if (text?.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
}
continue;
}
if (content.role === 'system') continue;
// Tool results
if (content.role === 'tool') {
const toolItems = Array.isArray(content.content) ? content.content : [];
for (const item of toolItems) {
if (item?.type !== 'tool-result') continue;
const toolCallId = item.toolCallId || content.id;
messages.push(createNormalizedMessage({
id: `${baseId}_tr`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: toolCallId,
content: item.result || '',
isError: false,
}));
}
continue;
}
const role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
const part = content.content[partIdx];
if (part?.type === 'text' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: part.text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
} else if (part?.type === 'reasoning' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.text,
}));
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'
? 'Edit' : (part.toolName || part.name || 'Unknown Tool');
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: part.args || part.input,
toolId,
}));
toolUseMap.set(toolId, messages[messages.length - 1]);
}
}
} else if (typeof content.content === 'string' && content.content.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: content.content,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
} catch (error) {
console.warn('Error normalizing cursor blob:', error);
}
}
// Attach tool results to tool_use messages
for (const msg of messages) {
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
const toolUse = toolUseMap.get(msg.toolId);
toolUse.toolResult = {
content: msg.content,
isError: msg.isError,
};
}
}
// Sort by sequence/rowid
messages.sort((a, b) => {
if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;
if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
return messages;
},
};

View File

@@ -0,0 +1,128 @@
/**
* Cursor Provider Status
*
* Checks whether cursor-agent CLI is installed and whether the user
* is logged in.
*
* @module providers/cursor/status
*/
import { execFileSync, spawn } from 'child_process';
/**
* Check if cursor-agent CLI is installed.
* @returns {boolean}
*/
export function checkInstalled() {
try {
execFileSync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
error: 'Cursor CLI is not installed'
};
}
const result = await checkCursorLogin();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
function checkCursorLogin() {
return new Promise((resolve) => {
let processCompleted = false;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
if (childProcess) {
childProcess.kill();
}
resolve({
authenticated: false,
email: null,
error: 'Command timeout'
});
}
}, 5000);
let childProcess;
try {
childProcess = spawn('cursor-agent', ['status']);
} catch {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch) {
resolve({ authenticated: true, email: emailMatch[1] });
} else if (stdout.includes('Logged in')) {
resolve({ authenticated: true, email: 'Logged in' });
} else {
resolve({ authenticated: false, email: null, error: 'Not logged in' });
}
} else {
resolve({ authenticated: false, email: null, error: stderr || 'Not logged in' });
}
});
childProcess.on('error', () => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
});
});
}

View File

@@ -0,0 +1,186 @@
/**
* Gemini provider adapter.
*
* Normalizes Gemini CLI session history into NormalizedMessage format.
* @module adapters/gemini
*/
import sessionManager from '../../sessionManager.js';
import { getGeminiCliSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'gemini';
/**
* Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s).
* Handles: message (delta/final), tool_use, tool_result, result, error.
* @param {object} raw - A parsed NDJSON event
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'message' && raw.role === 'assistant') {
const content = raw.content || '';
const msgs = [];
if (content) {
msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content }));
}
// If not a delta, also send stream_end
if (raw.delta !== true) {
msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' }));
}
return msgs;
}
if (raw.type === 'tool_use') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: raw.tool_name, toolInput: raw.parameters || {},
toolId: raw.tool_id || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_result', toolId: raw.tool_id || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: raw.status === 'error',
})];
}
if (raw.type === 'result') {
const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
if (raw.stats?.total_tokens) {
msgs.push(createNormalizedMessage({
sessionId, timestamp: ts, provider: PROVIDER,
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
}));
}
return msgs;
}
if (raw.type === 'error') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'error', content: raw.error || raw.message || 'Unknown Gemini streaming error',
})];
}
return [];
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const geminiAdapter = {
normalizeMessage,
/**
* Fetch session history for Gemini.
* First tries in-memory session manager, then falls back to CLI sessions on disk.
*/
async fetchHistory(sessionId, opts = {}) {
let rawMessages;
try {
rawMessages = sessionManager.getSessionMessages(sessionId);
// Fallback to Gemini CLI sessions on disk
if (rawMessages.length === 0) {
rawMessages = await getGeminiCliSessionMessages(sessionId);
}
} catch (error) {
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const normalized = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) continue;
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx];
if (part.type === 'text' && part.text) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
}));
}
}
// Attach tool results to tool_use messages
const toolResultMap = new Map();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = { content: tr.content, isError: tr.isError };
}
}
return {
messages: normalized,
total: normalized.length,
hasMore: false,
offset: 0,
limit: null,
};
},
};

View File

@@ -0,0 +1,111 @@
/**
* Gemini Provider Status
*
* Checks whether Gemini CLI is installed and whether the user
* has valid authentication credentials.
*
* @module providers/gemini/status
*/
import { execFileSync } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Gemini CLI is installed.
* Uses GEMINI_PATH env var if set, otherwise looks for 'gemini' in PATH.
* @returns {boolean}
*/
export function checkInstalled() {
const cliPath = process.env.GEMINI_PATH || 'gemini';
try {
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
error: 'Gemini CLI is not installed'
};
}
const result = await checkCredentials();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function checkCredentials() {
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth' };
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await fs.readFile(credsPath, 'utf8');
const creds = JSON.parse(content);
if (creds.access_token) {
let email = 'OAuth Session';
try {
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
if (tokenRes.ok) {
const tokenInfo = await tokenRes.json();
if (tokenInfo.email) {
email = tokenInfo.email;
}
} else if (!creds.refresh_token) {
return {
authenticated: false,
email: null,
error: 'Access token invalid and no refresh token found'
};
} else {
// Token might be expired but we have a refresh token, so CLI will refresh it
email = await getActiveAccountEmail() || email;
}
} catch {
// Network error, fallback to checking local accounts file
email = await getActiveAccountEmail() || email;
}
return { authenticated: true, email };
}
return { authenticated: false, email: null, error: 'No valid tokens found in oauth_creds' };
} catch {
return { authenticated: false, email: null, error: 'Gemini CLI not configured' };
}
}
async function getActiveAccountEmail() {
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
return accounts.active || null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,67 @@
/**
* Provider Registry
*
* Centralizes provider adapter and status checker lookup. All code that needs
* a provider adapter or status checker should go through this registry instead
* of importing individual modules directly.
*
* @module providers/registry
*/
import { claudeAdapter } from './claude/adapter.js';
import { cursorAdapter } from './cursor/adapter.js';
import { codexAdapter } from './codex/adapter.js';
import { geminiAdapter } from './gemini/adapter.js';
import * as claudeStatus from './claude/status.js';
import * as cursorStatus from './cursor/status.js';
import * as codexStatus from './codex/status.js';
import * as geminiStatus from './gemini/status.js';
/**
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
* @typedef {import('./types.js').SessionProvider} SessionProvider
*/
/** @type {Map<string, ProviderAdapter>} */
const providers = new Map();
/** @type {Map<string, { checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> }>} */
const statusCheckers = new Map();
// Register built-in providers
providers.set('claude', claudeAdapter);
providers.set('cursor', cursorAdapter);
providers.set('codex', codexAdapter);
providers.set('gemini', geminiAdapter);
statusCheckers.set('claude', claudeStatus);
statusCheckers.set('cursor', cursorStatus);
statusCheckers.set('codex', codexStatus);
statusCheckers.set('gemini', geminiStatus);
/**
* Get a provider adapter by name.
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
* @returns {ProviderAdapter | undefined}
*/
export function getProvider(name) {
return providers.get(name);
}
/**
* Get a provider status checker by name.
* @param {string} name - Provider name
* @returns {{ checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> } | undefined}
*/
export function getStatusChecker(name) {
return statusCheckers.get(name);
}
/**
* Get all registered provider names.
* @returns {string[]}
*/
export function getAllProviders() {
return Array.from(providers.keys());
}

132
server/providers/types.js Normal file
View File

@@ -0,0 +1,132 @@
/**
* Provider Types & Interface
*
* Defines the normalized message format and the provider adapter interface.
* All providers normalize their native formats into NormalizedMessage
* before sending over REST or WebSocket.
*
* @module providers/types
*/
// ─── Session Provider ────────────────────────────────────────────────────────
/**
* @typedef {'claude' | 'cursor' | 'codex' | 'gemini'} SessionProvider
*/
// ─── Message Kind ────────────────────────────────────────────────────────────
/**
* @typedef {'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end'
* | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled'
* | 'session_created' | 'interactive_prompt' | 'task_notification'} MessageKind
*/
// ─── NormalizedMessage ───────────────────────────────────────────────────────
/**
* @typedef {Object} NormalizedMessage
* @property {string} id - Unique message id (for dedup between server + realtime)
* @property {string} sessionId
* @property {string} timestamp - ISO 8601
* @property {SessionProvider} provider
* @property {MessageKind} kind
*
* Additional fields depending on kind:
* - text: role ('user'|'assistant'), content, images?
* - tool_use: toolName, toolInput, toolId
* - tool_result: toolId, content, isError
* - thinking: content
* - stream_delta: content
* - stream_end: (no extra fields)
* - error: content
* - complete: (no extra fields)
* - status: text, tokens?, canInterrupt?
* - permission_request: requestId, toolName, input, context?
* - permission_cancelled: requestId
* - session_created: newSessionId
* - interactive_prompt: content
* - task_notification: status, summary
*/
// ─── Fetch History ───────────────────────────────────────────────────────────
/**
* @typedef {Object} FetchHistoryOptions
* @property {string} [projectName] - Project name (required for Claude)
* @property {string} [projectPath] - Absolute project path (required for Cursor cwdId hash)
* @property {number|null} [limit] - Page size (null = all messages)
* @property {number} [offset] - Pagination offset (default: 0)
*/
/**
* @typedef {Object} FetchHistoryResult
* @property {NormalizedMessage[]} messages - Normalized messages
* @property {number} total - Total number of messages in the session
* @property {boolean} hasMore - Whether more messages exist before the current page
* @property {number} offset - Current offset
* @property {number|null} limit - Page size used
* @property {object} [tokenUsage] - Token usage data (provider-specific)
*/
// ─── Provider Status ────────────────────────────────────────────────────────
/**
* Result of a provider status check (installation + authentication).
*
* @typedef {Object} ProviderStatus
* @property {boolean} installed - Whether the provider's CLI/SDK is available
* @property {boolean} authenticated - Whether valid credentials exist
* @property {string|null} email - User email or auth method identifier
* @property {string|null} [method] - Auth method (e.g. 'api_key', 'credentials_file')
* @property {string|null} [error] - Error message if not installed or not authenticated
*/
// ─── Provider Adapter Interface ──────────────────────────────────────────────
/**
* Every provider adapter MUST implement this interface.
*
* @typedef {Object} ProviderAdapter
*
* @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise<FetchHistoryResult>} fetchHistory
* Read persisted session messages from disk/database and return them as NormalizedMessage[].
* The backend calls this from the unified GET /api/sessions/:id/messages endpoint.
*
* Provider implementations:
* - Claude: reads ~/.claude/projects/{projectName}/*.jsonl
* - Cursor: reads from SQLite store.db (via normalizeCursorBlobs helper)
* - Codex: reads ~/.codex/sessions/*.jsonl
* - Gemini: reads from in-memory sessionManager or ~/.gemini/tmp/ JSON files
*
* @property {(raw: any, sessionId: string) => NormalizedMessage[]} normalizeMessage
* Normalize a provider-specific event (JSONL entry or live SDK event) into NormalizedMessage[].
* Used by provider files to convert both history and realtime events.
*/
// ─── Runtime Helpers ─────────────────────────────────────────────────────────
/**
* Generate a unique message ID.
* Uses crypto.randomUUID() to avoid collisions across server restarts and workers.
* @param {string} [prefix='msg'] - Optional prefix
* @returns {string}
*/
export function generateMessageId(prefix = 'msg') {
return `${prefix}_${crypto.randomUUID()}`;
}
/**
* Create a NormalizedMessage with common fields pre-filled.
* @param {Partial<NormalizedMessage> & {kind: MessageKind, provider: SessionProvider}} fields
* @returns {NormalizedMessage}
*/
export function createNormalizedMessage(fields) {
return {
...fields,
id: fields.id || generateMessageId(fields.kind),
sessionId: fields.sessionId || '',
timestamp: fields.timestamp || new Date().toISOString(),
provider: fields.provider,
};
}

29
server/providers/utils.js Normal file
View File

@@ -0,0 +1,29 @@
/**
* Shared provider utilities.
*
* @module providers/utils
*/
/**
* Prefixes that indicate internal/system content which should be hidden from the UI.
* @type {readonly string[]}
*/
export const INTERNAL_CONTENT_PREFIXES = Object.freeze([
'<command-name>',
'<command-message>',
'<command-args>',
'<local-command-stdout>',
'<system-reminder>',
'Caveat:',
'This session is being continued from a previous',
'[Request interrupted',
]);
/**
* Check if user text content is internal/system that should be skipped.
* @param {string} content
* @returns {boolean}
*/
export function isInternalContent(content) {
return INTERNAL_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix));
}

27
server/routes/cli-auth.js Normal file
View File

@@ -0,0 +1,27 @@
/**
* CLI Auth Routes
*
* Thin router that delegates to per-provider status checkers
* registered in the provider registry.
*
* @module routes/cli-auth
*/
import express from 'express';
import { getAllProviders, getStatusChecker } from '../providers/registry.js';
const router = express.Router();
for (const provider of getAllProviders()) {
router.get(`/${provider}/status`, async (req, res) => {
try {
const checker = getStatusChecker(provider);
res.json(await checker.checkStatus());
} catch (error) {
console.error(`Error checking ${provider} status:`, error);
res.status(500).json({ authenticated: false, error: error.message });
}
});
}
export default router;

View File

@@ -1,9 +1,73 @@
import express from 'express';
import { deleteCodexSession } from '../projects.js';
import { sessionNamesDb } from '../database/db.js';
import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import TOML from '@iarna/toml';
import { getCodexSessions, deleteCodexSession } from '../projects.js';
import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
const router = express.Router();
function createCliResponder(res) {
let responded = false;
return (status, payload) => {
if (responded || res.headersSent) {
return;
}
responded = true;
res.status(status).json(payload);
};
}
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
const content = await fs.readFile(configPath, 'utf8');
const config = TOML.parse(content);
res.json({
success: true,
config: {
model: config.model || null,
mcpServers: config.mcp_servers || {},
approvalMode: config.approval_mode || 'suggest'
}
});
} catch (error) {
if (error.code === 'ENOENT') {
res.json({
success: true,
config: {
model: null,
mcpServers: {},
approvalMode: 'suggest'
}
});
} else {
console.error('Error reading Codex config:', error);
res.status(500).json({ success: false, error: error.message });
}
}
});
router.get('/sessions', async (req, res) => {
try {
const { projectPath } = req.query;
if (!projectPath) {
return res.status(400).json({ success: false, error: 'projectPath query parameter required' });
}
const sessions = await getCodexSessions(projectPath);
applyCustomSessionNames(sessions, 'codex');
res.json({ success: true, sessions });
} catch (error) {
console.error('Error fetching Codex sessions:', error);
res.status(500).json({ success: false, error: error.message });
}
});
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
@@ -16,4 +80,250 @@ router.delete('/sessions/:sessionId', async (req, res) => {
}
});
// MCP Server Management Routes
router.get('/mcp/cli/list', async (req, res) => {
try {
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} else {
respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
}
});
router.post('/mcp/cli/add', async (req, res) => {
try {
const { name, command, args = [], env = {} } = req.body;
if (!name || !command) {
return res.status(400).json({ error: 'name and command are required' });
}
// Build: codex mcp add <name> [-e KEY=VAL]... -- <command> [args...]
let cliArgs = ['mcp', 'add', name];
Object.entries(env).forEach(([key, value]) => {
cliArgs.push('-e', `${key}=${value}`);
});
cliArgs.push('--', command);
if (args && args.length > 0) {
cliArgs.push(...args);
}
const respond = createCliResponder(res);
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else {
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
}
});
router.delete('/mcp/cli/remove/:name', async (req, res) => {
try {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else {
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
}
});
router.get('/mcp/cli/get/:name', async (req, res) => {
try {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} else {
respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
}
});
router.get('/mcp/config/read', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
let configData = null;
try {
const fileContent = await fs.readFile(configPath, 'utf8');
configData = TOML.parse(fileContent);
} catch (error) {
// Config file doesn't exist
}
if (!configData) {
return res.json({ success: true, configPath, servers: [] }); }
const servers = [];
if (configData.mcp_servers && typeof configData.mcp_servers === 'object') {
for (const [name, config] of Object.entries(configData.mcp_servers)) {
servers.push({
id: name,
name: name,
type: 'stdio',
scope: 'user',
config: {
command: config.command || '',
args: config.args || [],
env: config.env || {}
},
raw: config
});
}
}
res.json({ success: true, configPath, servers });
} catch (error) {
res.status(500).json({ error: 'Failed to read Codex configuration', details: error.message });
}
});
function parseCodexListOutput(output) {
const servers = [];
const lines = output.split('\n').filter(line => line.trim());
for (const line of lines) {
if (line.includes(':')) {
const colonIndex = line.indexOf(':');
const name = line.substring(0, colonIndex).trim();
if (!name) continue;
const rest = line.substring(colonIndex + 1).trim();
let description = rest;
let status = 'unknown';
if (rest.includes('✓') || rest.includes('✗')) {
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
if (statusMatch) {
description = statusMatch[1].trim();
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
}
}
servers.push({ name, type: 'stdio', status, description });
}
}
return servers;
}
function parseCodexGetOutput(output) {
try {
const jsonMatch = output.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
const server = { raw_output: output };
const lines = output.split('\n');
for (const line of lines) {
if (line.includes('Name:')) server.name = line.split(':')[1]?.trim();
else if (line.includes('Type:')) server.type = line.split(':')[1]?.trim();
else if (line.includes('Command:')) server.command = line.split(':')[1]?.trim();
}
return server;
} catch (error) {
return { raw_output: output, parse_error: error.message };
}
}
export default router;

View File

@@ -451,6 +451,55 @@ router.post('/list', async (req, res) => {
}
});
/**
* POST /api/commands/load
* Load a specific command file and return its content and metadata
*/
router.post('/load', async (req, res) => {
try {
const { commandPath } = req.body;
if (!commandPath) {
return res.status(400).json({
error: 'Command path is required'
});
}
// Security: Prevent path traversal
const resolvedPath = path.resolve(commandPath);
if (!resolvedPath.startsWith(path.resolve(os.homedir())) &&
!resolvedPath.includes('.claude/commands')) {
return res.status(403).json({
error: 'Access denied',
message: 'Command must be in .claude/commands directory'
});
}
// Read and parse the command file
const content = await fs.readFile(commandPath, 'utf8');
const { data: metadata, content: commandContent } = parseFrontmatter(content);
res.json({
path: commandPath,
metadata,
content: commandContent
});
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({
error: 'Command not found',
message: `Command file not found: ${req.body.commandPath}`
});
}
console.error('Error loading command:', error);
res.status(500).json({
error: 'Failed to load command',
message: error.message
});
}
});
/**
* POST /api/commands/execute
* Execute a command with argument replacement

View File

@@ -2,7 +2,10 @@ import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import Database from 'better-sqlite3';
import crypto from 'crypto';
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
import { applyCustomSessionNames } from '../database/db.js';
const router = express.Router();
@@ -10,20 +13,20 @@ const router = express.Router();
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
try {
const configContent = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configContent);
res.json({
success: true,
config,
path: configPath,
config: config,
path: configPath
});
} catch (error) {
// Config doesn't exist or is invalid
console.log('Cursor config not found or invalid:', error.message);
// Return default config
res.json({
success: true,
@@ -31,23 +34,531 @@ router.get('/config', async (req, res) => {
version: 1,
model: {
modelId: CURSOR_MODELS.DEFAULT,
displayName: 'GPT-5',
displayName: "GPT-5"
},
permissions: {
allow: [],
deny: [],
},
deny: []
}
},
isDefault: true,
isDefault: true
});
}
} catch (error) {
console.error('Error reading Cursor config:', error);
res.status(500).json({
error: 'Failed to read Cursor configuration',
details: error.message,
res.status(500).json({
error: 'Failed to read Cursor configuration',
details: error.message
});
}
});
export default router;
// POST /api/cursor/config - Update Cursor CLI configuration
router.post('/config', async (req, res) => {
try {
const { permissions, model } = req.body;
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
// Read existing config or create default
let config = {
version: 1,
editor: {
vimMode: false
},
hasChangedDefaultModel: false,
privacyCache: {
ghostMode: false,
privacyMode: 3,
updatedAt: Date.now()
}
};
try {
const existing = await fs.readFile(configPath, 'utf8');
config = JSON.parse(existing);
} catch (error) {
// Config doesn't exist, use defaults
console.log('Creating new Cursor config');
}
// Update permissions if provided
if (permissions) {
config.permissions = {
allow: permissions.allow || [],
deny: permissions.deny || []
};
}
// Update model if provided
if (model) {
config.model = model;
config.hasChangedDefaultModel = true;
}
// Ensure directory exists
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
// Write updated config
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
res.json({
success: true,
config: config,
message: 'Cursor configuration updated successfully'
});
} catch (error) {
console.error('Error updating Cursor config:', error);
res.status(500).json({
error: 'Failed to update Cursor configuration',
details: error.message
});
}
});
// GET /api/cursor/mcp - Read Cursor MCP servers configuration
router.get('/mcp', async (req, res) => {
try {
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
try {
const mcpContent = await fs.readFile(mcpPath, 'utf8');
const mcpConfig = JSON.parse(mcpContent);
// Convert to UI-friendly format
const servers = [];
if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') {
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
const server = {
id: name,
name: name,
type: 'stdio',
scope: 'cursor',
config: {},
raw: config
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
res.json({
success: true,
servers: servers,
path: mcpPath
});
} catch (error) {
// MCP config doesn't exist
console.log('Cursor MCP config not found:', error.message);
res.json({
success: true,
servers: [],
isDefault: true
});
}
} catch (error) {
console.error('Error reading Cursor MCP config:', error);
res.status(500).json({
error: 'Failed to read Cursor MCP configuration',
details: error.message
});
}
});
// POST /api/cursor/mcp/add - Add MCP server to Cursor configuration
router.post('/mcp/add', async (req, res) => {
try {
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(` Adding MCP server to Cursor config: ${name}`);
// Read existing config or create new
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
if (!mcpConfig.mcpServers) {
mcpConfig.mcpServers = {};
}
} catch (error) {
console.log('Creating new Cursor MCP config');
}
// Build server config based on type
let serverConfig = {};
if (type === 'stdio') {
serverConfig = {
command: command,
args: args,
env: env
};
} else if (type === 'http' || type === 'sse') {
serverConfig = {
url: url,
transport: type,
headers: headers
};
}
// Add server to config
mcpConfig.mcpServers[name] = serverConfig;
// Ensure directory exists
const mcpDir = path.dirname(mcpPath);
await fs.mkdir(mcpDir, { recursive: true });
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" added to Cursor configuration`,
config: mcpConfig
});
} catch (error) {
console.error('Error adding MCP server to Cursor:', error);
res.status(500).json({
error: 'Failed to add MCP server',
details: error.message
});
}
});
// DELETE /api/cursor/mcp/:name - Remove MCP server from Cursor configuration
router.delete('/mcp/:name', async (req, res) => {
try {
const { name } = req.params;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(`🗑️ Removing MCP server from Cursor config: ${name}`);
// Read existing config
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
} catch (error) {
return res.status(404).json({
error: 'Cursor MCP configuration not found'
});
}
// Check if server exists
if (!mcpConfig.mcpServers || !mcpConfig.mcpServers[name]) {
return res.status(404).json({
error: `MCP server "${name}" not found in Cursor configuration`
});
}
// Remove server from config
delete mcpConfig.mcpServers[name];
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" removed from Cursor configuration`,
config: mcpConfig
});
} catch (error) {
console.error('Error removing MCP server from Cursor:', error);
res.status(500).json({
error: 'Failed to remove MCP server',
details: error.message
});
}
});
// POST /api/cursor/mcp/add-json - Add MCP server using JSON format
router.post('/mcp/add-json', async (req, res) => {
try {
const { name, jsonConfig } = req.body;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(` Adding MCP server to Cursor config via JSON: ${name}`);
// Validate and parse JSON config
let parsedConfig;
try {
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
} catch (parseError) {
return res.status(400).json({
error: 'Invalid JSON configuration',
details: parseError.message
});
}
// Read existing config or create new
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
if (!mcpConfig.mcpServers) {
mcpConfig.mcpServers = {};
}
} catch (error) {
console.log('Creating new Cursor MCP config');
}
// Add server to config
mcpConfig.mcpServers[name] = parsedConfig;
// Ensure directory exists
const mcpDir = path.dirname(mcpPath);
await fs.mkdir(mcpDir, { recursive: true });
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" added to Cursor configuration via JSON`,
config: mcpConfig
});
} catch (error) {
console.error('Error adding MCP server to Cursor via JSON:', error);
res.status(500).json({
error: 'Failed to add MCP server',
details: error.message
});
}
});
// GET /api/cursor/sessions - Get Cursor sessions from SQLite database
router.get('/sessions', async (req, res) => {
try {
const { projectPath } = req.query;
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
// Check if the directory exists
try {
await fs.access(cursorChatsPath);
} catch (error) {
// No sessions for this project
return res.json({
success: true,
sessions: [],
cwdId: cwdId,
path: cursorChatsPath
});
}
// List all session directories
const sessionDirs = await fs.readdir(cursorChatsPath);
const sessions = [];
for (const sessionId of sessionDirs) {
const sessionPath = path.join(cursorChatsPath, sessionId);
const storeDbPath = path.join(sessionPath, 'store.db');
let dbStatMtimeMs = null;
try {
// Check if store.db exists
await fs.access(storeDbPath);
// Capture store.db mtime as a reliable fallback timestamp (last activity)
try {
const stat = await fs.stat(storeDbPath);
dbStatMtimeMs = stat.mtimeMs;
} catch (_) {}
// Open SQLite database
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
// Get metadata from meta table
const metaRows = db.prepare('SELECT key, value FROM meta').all();
let sessionData = {
id: sessionId,
name: 'Untitled Session',
createdAt: null,
mode: null,
projectPath: projectPath,
lastMessage: null,
messageCount: 0
};
// Parse meta table entries
for (const row of metaRows) {
if (row.value) {
try {
// Try to decode as hex-encoded JSON
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
if (hexMatch) {
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
const data = JSON.parse(jsonStr);
if (row.key === 'agent') {
sessionData.name = data.name || sessionData.name;
// Normalize createdAt to ISO string in milliseconds
let createdAt = data.createdAt;
if (typeof createdAt === 'number') {
if (createdAt < 1e12) {
createdAt = createdAt * 1000; // seconds -> ms
}
sessionData.createdAt = new Date(createdAt).toISOString();
} else if (typeof createdAt === 'string') {
const n = Number(createdAt);
if (!Number.isNaN(n)) {
const ms = n < 1e12 ? n * 1000 : n;
sessionData.createdAt = new Date(ms).toISOString();
} else {
// Assume it's already an ISO/date string
const d = new Date(createdAt);
sessionData.createdAt = isNaN(d.getTime()) ? null : d.toISOString();
}
} else {
sessionData.createdAt = sessionData.createdAt || null;
}
sessionData.mode = data.mode;
sessionData.agentId = data.agentId;
sessionData.latestRootBlobId = data.latestRootBlobId;
}
} else {
// If not hex, use raw value for simple keys
if (row.key === 'name') {
sessionData.name = row.value.toString();
}
}
} catch (e) {
console.log(`Could not parse meta value for key ${row.key}:`, e.message);
}
}
}
// Get message count from JSON blobs only (actual messages, not DAG structure)
try {
const blobCount = db.prepare(`SELECT COUNT(*) as count FROM blobs WHERE substr(data, 1, 1) = X'7B'`).get();
sessionData.messageCount = blobCount.count;
// Get the most recent JSON blob for preview (actual message, not DAG structure)
const lastBlob = db.prepare(`SELECT data FROM blobs WHERE substr(data, 1, 1) = X'7B' ORDER BY rowid DESC LIMIT 1`).get();
if (lastBlob && lastBlob.data) {
try {
// Try to extract readable preview from blob (may contain binary with embedded JSON)
const raw = lastBlob.data.toString('utf8');
let preview = '';
// Attempt direct JSON parse
try {
const parsed = JSON.parse(raw);
if (parsed?.content) {
if (Array.isArray(parsed.content)) {
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
preview = firstText;
} else if (typeof parsed.content === 'string') {
preview = parsed.content;
}
}
} catch (_) {}
if (!preview) {
// Strip non-printable and try to find JSON chunk
const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
const s = cleaned;
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start !== -1 && end > start) {
const jsonStr = s.slice(start, end + 1);
try {
const parsed = JSON.parse(jsonStr);
if (parsed?.content) {
if (Array.isArray(parsed.content)) {
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
preview = firstText;
} else if (typeof parsed.content === 'string') {
preview = parsed.content;
}
}
} catch (_) {
preview = s;
}
} else {
preview = s;
}
}
if (preview && preview.length > 0) {
sessionData.lastMessage = preview.substring(0, 100) + (preview.length > 100 ? '...' : '');
}
} catch (e) {
console.log('Could not parse blob data:', e.message);
}
}
} catch (e) {
console.log('Could not read blobs:', e.message);
}
db.close();
// Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime
if (!sessionData.createdAt) {
if (dbStatMtimeMs && Number.isFinite(dbStatMtimeMs)) {
sessionData.createdAt = new Date(dbStatMtimeMs).toISOString();
}
}
sessions.push(sessionData);
} catch (error) {
console.log(`Could not read session ${sessionId}:`, error.message);
}
}
// Fallback: ensure createdAt is a valid ISO string (use session directory mtime as last resort)
for (const s of sessions) {
if (!s.createdAt) {
try {
const sessionDir = path.join(cursorChatsPath, s.id);
const st = await fs.stat(sessionDir);
s.createdAt = new Date(st.mtimeMs).toISOString();
} catch {
s.createdAt = new Date().toISOString();
}
}
}
// Sort sessions by creation date (newest first)
sessions.sort((a, b) => {
if (!a.createdAt) return 1;
if (!b.createdAt) return -1;
return new Date(b.createdAt) - new Date(a.createdAt);
});
applyCustomSessionNames(sessions, 'cursor');
res.json({
success: true,
sessions: sessions,
cwdId: cwdId,
path: cursorChatsPath
});
} catch (error) {
console.error('Error reading Cursor sessions:', error);
res.status(500).json({
error: 'Failed to read Cursor sessions',
details: error.message
});
}
});
export default router;

View File

@@ -7,7 +7,7 @@
*/
import express from 'express';
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
import { detectTaskMasterMCPServer, getAllMCPServers } from '../utils/mcp-detector.js';
const router = express.Router();
@@ -28,4 +28,21 @@ router.get('/taskmaster-server', async (req, res) => {
}
});
export default router;
/**
* GET /api/mcp-utils/all-servers
* Get all configured MCP servers
*/
router.get('/all-servers', async (req, res) => {
try {
const result = await getAllMCPServers();
res.json(result);
} catch (error) {
console.error('MCP servers detection error:', error);
res.status(500).json({
error: 'Failed to get MCP servers',
message: error.message
});
}
});
export default router;

552
server/routes/mcp.js Normal file
View File

@@ -0,0 +1,552 @@
import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { spawn } from 'child_process';
const router = express.Router();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Claude CLI command routes
// GET /api/mcp/cli/list - List MCP servers using Claude CLI
router.get('/cli/list', async (req, res) => {
try {
console.log('📋 Listing MCP servers using Claude CLI');
const { spawn } = await import('child_process');
const { promisify } = await import('util');
const exec = promisify(spawn);
const process = spawn('claude', ['mcp', 'list'], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, servers: parseClaudeListOutput(stdout) });
} else {
console.error('Claude CLI error:', stderr);
res.status(500).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error listing MCP servers via CLI:', error);
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
}
});
// POST /api/mcp/cli/add - Add MCP server using Claude CLI
router.post('/cli/add', async (req, res) => {
try {
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {}, scope = 'user', projectPath } = req.body;
console.log(` Adding MCP server using Claude CLI (${scope} scope):`, name);
const { spawn } = await import('child_process');
let cliArgs = ['mcp', 'add'];
// Add scope flag
cliArgs.push('--scope', scope);
if (type === 'http') {
cliArgs.push('--transport', 'http', name, url);
// Add headers if provided
Object.entries(headers).forEach(([key, value]) => {
cliArgs.push('--header', `${key}: ${value}`);
});
} else if (type === 'sse') {
cliArgs.push('--transport', 'sse', name, url);
// Add headers if provided
Object.entries(headers).forEach(([key, value]) => {
cliArgs.push('--header', `${key}: ${value}`);
});
} else {
// stdio (default): claude mcp add --scope user <name> <command> [args...]
cliArgs.push(name);
// Add environment variables
Object.entries(env).forEach(([key, value]) => {
cliArgs.push('-e', `${key}=${value}`);
});
cliArgs.push(command);
if (args && args.length > 0) {
cliArgs.push(...args);
}
}
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
// For local scope, we need to run the command in the project directory
const spawnOptions = {
stdio: ['pipe', 'pipe', 'pipe']
};
if (scope === 'local' && projectPath) {
spawnOptions.cwd = projectPath;
console.log('📁 Running in project directory:', projectPath);
}
const process = spawn('claude', cliArgs, spawnOptions);
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else {
console.error('Claude CLI error:', stderr);
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error adding MCP server via CLI:', error);
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
}
});
// POST /api/mcp/cli/add-json - Add MCP server using JSON format
router.post('/cli/add-json', async (req, res) => {
try {
const { name, jsonConfig, scope = 'user', projectPath } = req.body;
console.log(' Adding MCP server using JSON format:', name);
// Validate and parse JSON config
let parsedConfig;
try {
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
} catch (parseError) {
return res.status(400).json({
error: 'Invalid JSON configuration',
details: parseError.message
});
}
// Validate required fields
if (!parsedConfig.type) {
return res.status(400).json({
error: 'Invalid configuration',
details: 'Missing required field: type'
});
}
if (parsedConfig.type === 'stdio' && !parsedConfig.command) {
return res.status(400).json({
error: 'Invalid configuration',
details: 'stdio type requires a command field'
});
}
if ((parsedConfig.type === 'http' || parsedConfig.type === 'sse') && !parsedConfig.url) {
return res.status(400).json({
error: 'Invalid configuration',
details: `${parsedConfig.type} type requires a url field`
});
}
const { spawn } = await import('child_process');
// Build the command: claude mcp add-json --scope <scope> <name> '<json>'
const cliArgs = ['mcp', 'add-json', '--scope', scope, name];
// Add the JSON config as a properly formatted string
const jsonString = JSON.stringify(parsedConfig);
cliArgs.push(jsonString);
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4], jsonString);
// For local scope, we need to run the command in the project directory
const spawnOptions = {
stdio: ['pipe', 'pipe', 'pipe']
};
if (scope === 'local' && projectPath) {
spawnOptions.cwd = projectPath;
console.log('📁 Running in project directory:', projectPath);
}
const process = spawn('claude', cliArgs, spawnOptions);
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully via JSON` });
} else {
console.error('Claude CLI error:', stderr);
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error adding MCP server via JSON:', error);
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
}
});
// DELETE /api/mcp/cli/remove/:name - Remove MCP server using Claude CLI
router.delete('/cli/remove/:name', async (req, res) => {
try {
const { name } = req.params;
const { scope } = req.query; // Get scope from query params
// Handle the ID format (remove scope prefix if present)
let actualName = name;
let actualScope = scope;
// If the name includes a scope prefix like "local:test", extract it
if (name.includes(':')) {
const [prefix, serverName] = name.split(':');
actualName = serverName;
actualScope = actualScope || prefix; // Use prefix as scope if not provided in query
}
console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope);
const { spawn } = await import('child_process');
// Build command args based on scope
let cliArgs = ['mcp', 'remove'];
// Add scope flag if it's local scope
if (actualScope === 'local') {
cliArgs.push('--scope', 'local');
} else if (actualScope === 'user' || !actualScope) {
// User scope is default, but we can be explicit
cliArgs.push('--scope', 'user');
}
cliArgs.push(actualName);
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
const process = spawn('claude', cliArgs, {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else {
console.error('Claude CLI error:', stderr);
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error removing MCP server via CLI:', error);
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
}
});
// GET /api/mcp/cli/get/:name - Get MCP server details using Claude CLI
router.get('/cli/get/:name', async (req, res) => {
try {
const { name } = req.params;
console.log('📄 Getting MCP server details using Claude CLI:', name);
const { spawn } = await import('child_process');
const process = spawn('claude', ['mcp', 'get', name], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, server: parseClaudeGetOutput(stdout) });
} else {
console.error('Claude CLI error:', stderr);
res.status(404).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error getting MCP server details via CLI:', error);
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
}
});
// GET /api/mcp/config/read - Read MCP servers directly from Claude config files
router.get('/config/read', async (req, res) => {
try {
console.log('📖 Reading MCP servers from Claude config files');
const homeDir = os.homedir();
const configPaths = [
path.join(homeDir, '.claude.json'),
path.join(homeDir, '.claude', 'settings.json')
];
let configData = null;
let configPath = null;
// Try to read from either config file
for (const filepath of configPaths) {
try {
const fileContent = await fs.readFile(filepath, 'utf8');
configData = JSON.parse(fileContent);
configPath = filepath;
console.log(`✅ Found Claude config at: ${filepath}`);
break;
} catch (error) {
// File doesn't exist or is not valid JSON, try next
console.log(` Config not found or invalid at: ${filepath}`);
}
}
if (!configData) {
return res.json({
success: false,
message: 'No Claude configuration file found',
servers: []
});
}
// Extract MCP servers from the config
const servers = [];
// Check for user-scoped MCP servers (at root level)
if (configData.mcpServers && typeof configData.mcpServers === 'object' && Object.keys(configData.mcpServers).length > 0) {
console.log('🔍 Found user-scoped MCP servers:', Object.keys(configData.mcpServers));
for (const [name, config] of Object.entries(configData.mcpServers)) {
const server = {
id: name,
name: name,
type: 'stdio', // Default type
scope: 'user', // User scope - available across all projects
config: {},
raw: config // Include raw config for full details
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
// Check for local-scoped MCP servers (project-specific)
const currentProjectPath = process.cwd();
// Check under 'projects' key
if (configData.projects && configData.projects[currentProjectPath]) {
const projectConfig = configData.projects[currentProjectPath];
if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object' && Object.keys(projectConfig.mcpServers).length > 0) {
console.log(`🔍 Found local-scoped MCP servers for ${currentProjectPath}:`, Object.keys(projectConfig.mcpServers));
for (const [name, config] of Object.entries(projectConfig.mcpServers)) {
const server = {
id: `local:${name}`, // Prefix with scope for uniqueness
name: name, // Keep original name
type: 'stdio', // Default type
scope: 'local', // Local scope - only for this project
projectPath: currentProjectPath,
config: {},
raw: config // Include raw config for full details
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
}
console.log(`📋 Found ${servers.length} MCP servers in config`);
res.json({
success: true,
configPath: configPath,
servers: servers
});
} catch (error) {
console.error('Error reading Claude config:', error);
res.status(500).json({
error: 'Failed to read Claude configuration',
details: error.message
});
}
});
// Helper functions to parse Claude CLI output
function parseClaudeListOutput(output) {
const servers = [];
const lines = output.split('\n').filter(line => line.trim());
for (const line of lines) {
// Skip the header line
if (line.includes('Checking MCP server health')) continue;
// Parse lines like "test: test test - ✗ Failed to connect"
// or "server-name: command or description - ✓ Connected"
if (line.includes(':')) {
const colonIndex = line.indexOf(':');
const name = line.substring(0, colonIndex).trim();
// Skip empty names
if (!name) continue;
// Extract the rest after the name
const rest = line.substring(colonIndex + 1).trim();
// Try to extract description and status
let description = rest;
let status = 'unknown';
let type = 'stdio'; // default type
// Check for status indicators
if (rest.includes('✓') || rest.includes('✗')) {
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
if (statusMatch) {
description = statusMatch[1].trim();
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
}
}
// Try to determine type from description
if (description.startsWith('http://') || description.startsWith('https://')) {
type = 'http';
}
servers.push({
name,
type,
status: status || 'active',
description
});
}
}
console.log('🔍 Parsed Claude CLI servers:', servers);
return servers;
}
function parseClaudeGetOutput(output) {
// Parse the output from 'claude mcp get <name>' command
// This is a simple parser - might need adjustment based on actual output format
try {
// Try to extract JSON if present
const jsonMatch = output.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
// Otherwise, parse as text
const server = { raw_output: output };
const lines = output.split('\n');
for (const line of lines) {
if (line.includes('Name:')) {
server.name = line.split(':')[1]?.trim();
} else if (line.includes('Type:')) {
server.type = line.split(':')[1]?.trim();
} else if (line.includes('Command:')) {
server.command = line.split(':')[1]?.trim();
} else if (line.includes('URL:')) {
server.url = line.split(':')[1]?.trim();
}
}
return server;
} catch (error) {
return { raw_output: output, parse_error: error.message };
}
}
export default router;

View File

@@ -10,7 +10,7 @@
*/
import express from 'express';
import { sessionsService } from '../modules/providers/services/sessions.service.js';
import { getProvider, getAllProviders } from '../providers/registry.js';
const router = express.Router();
@@ -29,7 +29,7 @@ const router = express.Router();
router.get('/:sessionId/messages', async (req, res) => {
try {
const { sessionId } = req.params;
const provider = String(req.query.provider || 'claude').trim().toLowerCase();
const provider = req.query.provider || 'claude';
const projectName = req.query.projectName || '';
const projectPath = req.query.projectPath || '';
const limitParam = req.query.limit;
@@ -38,13 +38,13 @@ router.get('/:sessionId/messages', async (req, res) => {
: null;
const offset = parseInt(req.query.offset || '0', 10);
const availableProviders = sessionsService.listProviderIds();
if (!availableProviders.includes(provider)) {
const available = availableProviders.join(', ');
const adapter = getProvider(provider);
if (!adapter) {
const available = getAllProviders().join(', ');
return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` });
}
const result = await sessionsService.fetchHistory(provider, sessionId, {
const result = await adapter.fetchHistory(sessionId, {
projectName,
projectPath,
limit,

View File

@@ -273,14 +273,4 @@ router.post('/push/unsubscribe', async (req, res) => {
}
});
// Host OS for UI (e.g. hide Cursor agent when the backend runs on Windows).
router.get('/server-env', async (req, res) => {
try {
res.json({ platform: process.platform });
} catch (error) {
console.error('Error reading server environment:', error);
res.status(500).json({ error: 'Failed to read server environment' });
}
});
export default router;

View File

@@ -13,10 +13,16 @@ import fs from 'fs';
import path from 'path';
import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import os from 'os';
import { extractProjectDirectory } from '../projects.js';
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = express.Router();
/**
@@ -94,6 +100,140 @@ async function checkTaskMasterInstallation() {
});
}
/**
* Detect .taskmaster folder presence in a given project directory
* @param {string} projectPath - Absolute path to project directory
* @returns {Promise<Object>} Detection result with status and metadata
*/
async function detectTaskMasterFolder(projectPath) {
try {
const taskMasterPath = path.join(projectPath, '.taskmaster');
// Check if .taskmaster directory exists
try {
const stats = await fsPromises.stat(taskMasterPath);
if (!stats.isDirectory()) {
return {
hasTaskmaster: false,
reason: '.taskmaster exists but is not a directory'
};
}
} catch (error) {
if (error.code === 'ENOENT') {
return {
hasTaskmaster: false,
reason: '.taskmaster directory not found'
};
}
throw error;
}
// Check for key TaskMaster files
const keyFiles = [
'tasks/tasks.json',
'config.json'
];
const fileStatus = {};
let hasEssentialFiles = true;
for (const file of keyFiles) {
const filePath = path.join(taskMasterPath, file);
try {
await fsPromises.access(filePath, fs.constants.R_OK);
fileStatus[file] = true;
} catch (error) {
fileStatus[file] = false;
if (file === 'tasks/tasks.json') {
hasEssentialFiles = false;
}
}
}
// Parse tasks.json if it exists for metadata
let taskMetadata = null;
if (fileStatus['tasks/tasks.json']) {
try {
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
const tasksContent = await fsPromises.readFile(tasksPath, 'utf8');
const tasksData = JSON.parse(tasksContent);
// Handle both tagged and legacy formats
let tasks = [];
if (tasksData.tasks) {
// Legacy format
tasks = tasksData.tasks;
} else {
// Tagged format - get tasks from all tags
Object.values(tasksData).forEach(tagData => {
if (tagData.tasks) {
tasks = tasks.concat(tagData.tasks);
}
});
}
// Calculate task statistics
const stats = tasks.reduce((acc, task) => {
acc.total++;
acc[task.status] = (acc[task.status] || 0) + 1;
// Count subtasks
if (task.subtasks) {
task.subtasks.forEach(subtask => {
acc.subtotalTasks++;
acc.subtasks = acc.subtasks || {};
acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;
});
}
return acc;
}, {
total: 0,
subtotalTasks: 0,
pending: 0,
'in-progress': 0,
done: 0,
review: 0,
deferred: 0,
cancelled: 0,
subtasks: {}
});
taskMetadata = {
taskCount: stats.total,
subtaskCount: stats.subtotalTasks,
completed: stats.done || 0,
pending: stats.pending || 0,
inProgress: stats['in-progress'] || 0,
review: stats.review || 0,
completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
lastModified: (await fsPromises.stat(tasksPath)).mtime.toISOString()
};
} catch (parseError) {
console.warn('Failed to parse tasks.json:', parseError.message);
taskMetadata = { error: 'Failed to parse tasks.json' };
}
}
return {
hasTaskmaster: true,
hasEssentialFiles,
files: fileStatus,
metadata: taskMetadata,
path: taskMasterPath
};
} catch (error) {
console.error('Error detecting TaskMaster folder:', error);
return {
hasTaskmaster: false,
reason: `Error checking directory: ${error.message}`
};
}
}
// MCP detection is now handled by the centralized utility
// API Routes
/**
@@ -131,6 +271,298 @@ router.get('/installation-status', async (req, res) => {
}
});
/**
* GET /api/taskmaster/detect/:projectName
* Detect TaskMaster configuration for a specific project
*/
router.get('/detect/:projectName', async (req, res) => {
try {
const { projectName } = req.params;
// Use the existing extractProjectDirectory function to get actual project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
console.error('Error extracting project directory:', error);
return res.status(404).json({
error: 'Project path not found',
projectName,
message: error.message
});
}
// Verify the project path exists
try {
await fsPromises.access(projectPath, fs.constants.R_OK);
} catch (error) {
return res.status(404).json({
error: 'Project path not accessible',
projectPath,
projectName,
message: error.message
});
}
// Run detection in parallel
const [taskMasterResult, mcpResult] = await Promise.all([
detectTaskMasterFolder(projectPath),
detectTaskMasterMCPServer()
]);
// Determine overall status
let status = 'not-configured';
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
status = 'fully-configured';
} else {
status = 'taskmaster-only';
}
} else if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
status = 'mcp-only';
}
const responseData = {
projectName,
projectPath,
status,
taskmaster: taskMasterResult,
mcp: mcpResult,
timestamp: new Date().toISOString()
};
res.json(responseData);
} catch (error) {
console.error('TaskMaster detection error:', error);
res.status(500).json({
error: 'Failed to detect TaskMaster configuration',
message: error.message
});
}
});
/**
* GET /api/taskmaster/detect-all
* Detect TaskMaster configuration for all known projects
* This endpoint works with the existing projects system
*/
router.get('/detect-all', async (req, res) => {
try {
// Import getProjects from the projects module
const { getProjects } = await import('../projects.js');
const projects = await getProjects();
// Run detection for all projects in parallel
const detectionPromises = projects.map(async (project) => {
try {
// Use the project's fullPath if available, otherwise extract the directory
let projectPath;
if (project.fullPath) {
projectPath = project.fullPath;
} else {
try {
projectPath = await extractProjectDirectory(project.name);
} catch (error) {
throw new Error(`Failed to extract project directory: ${error.message}`);
}
}
const [taskMasterResult, mcpResult] = await Promise.all([
detectTaskMasterFolder(projectPath),
detectTaskMasterMCPServer()
]);
// Determine status
let status = 'not-configured';
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
status = 'fully-configured';
} else {
status = 'taskmaster-only';
}
} else if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
status = 'mcp-only';
}
return {
projectName: project.name,
displayName: project.displayName,
projectPath,
status,
taskmaster: taskMasterResult,
mcp: mcpResult
};
} catch (error) {
return {
projectName: project.name,
displayName: project.displayName,
status: 'error',
error: error.message
};
}
});
const results = await Promise.all(detectionPromises);
res.json({
projects: results,
summary: {
total: results.length,
fullyConfigured: results.filter(p => p.status === 'fully-configured').length,
taskmasterOnly: results.filter(p => p.status === 'taskmaster-only').length,
mcpOnly: results.filter(p => p.status === 'mcp-only').length,
notConfigured: results.filter(p => p.status === 'not-configured').length,
errors: results.filter(p => p.status === 'error').length
},
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Bulk TaskMaster detection error:', error);
res.status(500).json({
error: 'Failed to detect TaskMaster configuration for projects',
message: error.message
});
}
});
/**
* POST /api/taskmaster/initialize/:projectName
* Initialize TaskMaster in a project (placeholder for future CLI integration)
*/
router.post('/initialize/:projectName', async (req, res) => {
try {
const { projectName } = req.params;
const { rules } = req.body; // Optional rule profiles
// This will be implemented in a later subtask with CLI integration
res.status(501).json({
error: 'TaskMaster initialization not yet implemented',
message: 'This endpoint will execute task-master init via CLI in a future update',
projectName,
rules
});
} catch (error) {
console.error('TaskMaster initialization error:', error);
res.status(500).json({
error: 'Failed to initialize TaskMaster',
message: error.message
});
}
});
/**
* GET /api/taskmaster/next/:projectName
* Get the next recommended task using task-master CLI
*/
router.get('/next/:projectName', async (req, res) => {
try {
const { projectName } = req.params;
// Get project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({
error: 'Project not found',
message: `Project "${projectName}" does not exist`
});
}
// Try to execute task-master next command
try {
const { spawn } = await import('child_process');
const nextTaskCommand = spawn('task-master', ['next'], {
cwd: projectPath,
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
nextTaskCommand.stdout.on('data', (data) => {
stdout += data.toString();
});
nextTaskCommand.stderr.on('data', (data) => {
stderr += data.toString();
});
await new Promise((resolve, reject) => {
nextTaskCommand.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`task-master next failed with code ${code}: ${stderr}`));
}
});
nextTaskCommand.on('error', (error) => {
reject(error);
});
});
// Parse the output - task-master next usually returns JSON
let nextTaskData = null;
if (stdout.trim()) {
try {
nextTaskData = JSON.parse(stdout);
} catch (parseError) {
// If not JSON, treat as plain text
nextTaskData = { message: stdout.trim() };
}
}
res.json({
projectName,
projectPath,
nextTask: nextTaskData,
timestamp: new Date().toISOString()
});
} catch (cliError) {
console.warn('Failed to execute task-master CLI:', cliError.message);
// Fallback to loading tasks and finding next one locally
// Use localhost to bypass proxy for internal server-to-server calls
const tasksResponse = await fetch(`http://localhost:${process.env.SERVER_PORT || process.env.PORT || '3001'}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
headers: {
'Authorization': req.headers.authorization
}
});
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
const nextTask = tasksData.tasks?.find(task =>
task.status === 'pending' || task.status === 'in-progress'
) || null;
res.json({
projectName,
projectPath,
nextTask,
fallback: true,
message: 'Used fallback method (CLI not available)',
timestamp: new Date().toISOString()
});
} else {
throw new Error('Failed to load tasks via fallback method');
}
}
} catch (error) {
console.error('TaskMaster next task error:', error);
res.status(500).json({
error: 'Failed to get next task',
message: error.message
});
}
});
/**
* GET /api/taskmaster/tasks/:projectName
* Load actual tasks from .taskmaster/tasks/tasks.json
@@ -472,6 +904,66 @@ router.get('/prd/:projectName/:fileName', async (req, res) => {
}
});
/**
* DELETE /api/taskmaster/prd/:projectName/:fileName
* Delete a specific PRD file
*/
router.delete('/prd/:projectName/:fileName', async (req, res) => {
try {
const { projectName, fileName } = req.params;
// Get project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({
error: 'Project not found',
message: `Project "${projectName}" does not exist`
});
}
const filePath = path.join(projectPath, '.taskmaster', 'docs', fileName);
// Check if file exists
try {
await fsPromises.access(filePath, fs.constants.F_OK);
} catch (error) {
return res.status(404).json({
error: 'PRD file not found',
message: `File "${fileName}" does not exist`
});
}
// Delete the file
try {
await fsPromises.unlink(filePath);
res.json({
projectName,
projectPath,
fileName,
message: 'PRD file deleted successfully',
timestamp: new Date().toISOString()
});
} catch (deleteError) {
console.error('Failed to delete PRD file:', deleteError);
return res.status(500).json({
error: 'Failed to delete PRD file',
message: deleteError.message
});
}
} catch (error) {
console.error('PRD delete error:', error);
res.status(500).json({
error: 'Failed to delete PRD file',
message: error.message
});
}
});
/**
* POST /api/taskmaster/init/:projectName
* Initialize TaskMaster in a project

View File

@@ -1,48 +0,0 @@
import type {
FetchHistoryOptions,
FetchHistoryResult,
LLMProvider,
McpScope,
NormalizedMessage,
ProviderAuthStatus,
ProviderMcpServer,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
/**
* Main provider contract for CLI and SDK integrations.
*
* Each concrete provider owns its MCP/auth handlers plus the provider-specific
* logic for converting native events/history into the app's normalized shape.
*/
export interface IProvider {
readonly id: LLMProvider;
readonly mcp: IProviderMcp;
readonly auth: IProviderAuth;
normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise<FetchHistoryResult>;
}
/**
* Auth contract for one provider.
*/
export interface IProviderAuth {
/**
* Checks whether the provider is installed and has usable credentials.
*/
getStatus(): Promise<ProviderAuthStatus>;
}
/**
* MCP contract for one provider.
*/
export interface IProviderMcp {
listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>>;
listServersForScope(scope: McpScope, options?: { workspacePath?: string }): Promise<ProviderMcpServer[]>;
upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer>;
removeServer(
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }>;
}

View File

@@ -1,179 +0,0 @@
// -------------- HTTP API response shapes for the server, shared across modules --------------
export type ApiSuccessShape<TData = unknown> = {
success: true;
data: TData;
};
export type ApiErrorShape = {
success: false;
error: {
code: string;
message: string;
details?: unknown;
};
};
// ---------------------------------------------------------------------------------------------
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor';
// ---------------------------------------------------------------------------------------------
export type MessageKind =
| 'text'
| 'tool_use'
| 'tool_result'
| 'thinking'
| 'stream_delta'
| 'stream_end'
| 'error'
| 'complete'
| 'status'
| 'permission_request'
| 'permission_cancelled'
| 'session_created'
| 'interactive_prompt'
| 'task_notification';
/**
* Provider-neutral message event emitted over REST and realtime transports.
*
* Providers all produce their own native SDK/CLI event shapes, so this type keeps
* the common envelope strict while allowing provider-specific details to ride
* along as optional properties.
*/
export type NormalizedMessage = {
id: string;
sessionId: string;
timestamp: string;
provider: LLMProvider;
kind: MessageKind;
role?: 'user' | 'assistant';
content?: string;
images?: unknown;
toolName?: string;
toolInput?: unknown;
toolId?: string;
toolResult?: {
content?: string;
isError?: boolean;
toolUseResult?: unknown;
};
isError?: boolean;
text?: string;
tokens?: number;
canInterrupt?: boolean;
requestId?: string;
input?: unknown;
context?: unknown;
reason?: string;
newSessionId?: string;
status?: string;
summary?: string;
tokenBudget?: unknown;
subagentTools?: unknown;
toolUseResult?: unknown;
sequence?: number;
rowid?: number;
[key: string]: unknown;
};
/**
* Pagination and provider lookup options for reading persisted session history.
*/
export type FetchHistoryOptions = {
/** Claude project folder name. Required by Claude history lookup. */
projectName?: string;
/** Absolute workspace path. Required by Cursor to compute its chat hash. */
projectPath?: string;
/** Page size. `null` means all messages. */
limit?: number | null;
/** Pagination offset from the newest messages. */
offset?: number;
};
/**
* Provider-neutral history result returned by the unified messages endpoint.
*/
export type FetchHistoryResult = {
messages: NormalizedMessage[];
total: number;
hasMore: boolean;
offset: number;
limit: number | null;
tokenUsage?: unknown;
};
// ---------------------------------------------------------------------------------------------
export type AppErrorOptions = {
code?: string;
statusCode?: number;
details?: unknown;
};
// -------------------- MCP related shared types --------------------
export type McpScope = 'user' | 'local' | 'project';
export type McpTransport = 'stdio' | 'http' | 'sse';
/**
* Provider MCP server descriptor normalized for frontend consumption.
*/
export type ProviderMcpServer = {
provider: LLMProvider;
name: string;
scope: McpScope;
transport: McpTransport;
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
envVars?: string[];
bearerTokenEnvVar?: string;
envHttpHeaders?: Record<string, string>;
};
/**
* Shared payload shape for MCP server create/update operations.
*/
export type UpsertProviderMcpServerInput = {
name: string;
scope?: McpScope;
transport: McpTransport;
workspacePath?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
envVars?: string[];
bearerTokenEnvVar?: string;
envHttpHeaders?: Record<string, string>;
};
// ---------------------------------------------------------------------------------------------
// -------------------- Provider auth status types --------------------
/**
* Result of a provider status check (installation + authentication).
*
* installed - Whether the provider's CLI/SDK is available
* provider - Provider id the status belongs to
* authenticated - Whether valid credentials exist
* email - User email or auth method identifier
* method - Auth method (e.g. 'api_key', 'credentials_file')
* [error] - Error message if not installed or not authenticated
*/
export type ProviderAuthStatus = {
installed: boolean;
provider: LLMProvider;
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};

View File

@@ -1,208 +0,0 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import type {
ApiErrorShape,
ApiSuccessShape,
AppErrorOptions,
NormalizedMessage,
} from '@/shared/types.js';
type NormalizedMessageInput =
{
kind: NormalizedMessage['kind'];
provider: NormalizedMessage['provider'];
id?: string | null;
sessionId?: string | null;
timestamp?: string | null;
} & Record<string, unknown>;
export function createApiSuccessResponse<TData>(
data: TData,
): ApiSuccessShape<TData> {
return {
success: true,
data,
};
}
export function createApiErrorResponse(
code: string,
message: string,
details?: unknown
): ApiErrorShape {
return {
success: false,
error: {
code,
message,
details,
}
};
}
export function asyncHandler(
handler: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
): RequestHandler {
return (req, res, next) => {
void Promise.resolve(handler(req, res, next)).catch(next);
};
}
// --------- Global app error class for consistent error handling across the server ---------
export class AppError extends Error {
readonly code: string;
readonly statusCode: number;
readonly details?: unknown;
constructor(message: string, options: AppErrorOptions = {}) {
super(message);
this.name = 'AppError';
this.code = options.code ?? 'INTERNAL_ERROR';
this.statusCode = options.statusCode ?? 500;
this.details = options.details;
}
}
// -------------------------------------------------------------------------------------------
// ------------------------ Normalized provider message helpers ------------------------
/**
* Generates a stable unique id for normalized provider messages.
*/
export function generateMessageId(prefix = 'msg'): string {
return `${prefix}_${randomUUID()}`;
}
/**
* Creates a normalized provider message and fills the shared envelope fields.
*
* Provider adapters and live SDK handlers pass through provider-specific fields,
* while this helper guarantees every emitted event has an id, session id,
* timestamp, and provider marker.
*/
export function createNormalizedMessage(fields: NormalizedMessageInput): NormalizedMessage {
return {
...fields,
id: fields.id || generateMessageId(fields.kind),
sessionId: fields.sessionId || '',
timestamp: fields.timestamp || new Date().toISOString(),
provider: fields.provider,
};
}
// -------------------------------------------------------------------------------------------
// ------------------------ The following are mainly for provider MCP runtimes ------------------------
/**
* Safely narrows an unknown value to a plain object record.
*
* This deliberately rejects arrays, `null`, and primitive values so callers can
* treat the returned value as a JSON-style object map without repeating the same
* defensive shape checks at every config read site.
*/
export const readObjectRecord = (value: unknown): Record<string, unknown> | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
};
/**
* Reads an optional string from unknown input and normalizes empty or whitespace-only
* values to `undefined`.
*
* This is useful when parsing config files where a field may be missing, present
* with the wrong type, or present as an empty string that should be treated as
* "not configured".
*/
export const readOptionalString = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
};
/**
* Reads an optional string array from unknown input.
*
* Non-array values are ignored, and any array entries that are not strings are
* filtered out. This lets provider config readers consume loosely shaped JSON/TOML
* data without failing on incidental invalid members.
*/
export const readStringArray = (value: unknown): string[] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
return value.filter((entry): entry is string => typeof entry === 'string');
};
/**
* Reads an optional string-to-string map from unknown input.
*
* The function first ensures the source value is a plain object, then keeps only
* keys whose values are strings. If no valid entries remain, it returns `undefined`
* so callers can distinguish "no usable map" from an empty object that was
* intentionally authored downstream.
*/
export const readStringRecord = (value: unknown): Record<string, string> | undefined => {
const record = readObjectRecord(value);
if (!record) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [key, entry] of Object.entries(record)) {
if (typeof entry === 'string') {
normalized[key] = entry;
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
};
/**
* Reads a JSON config file and guarantees a plain object result.
*
* Missing files are treated as an empty config object so provider-specific MCP
* readers can operate against first-run environments without special-case file
* existence checks. If the file exists but contains invalid JSON, the parse error
* is preserved and rethrown.
*/
export const readJsonConfig = async (filePath: string): Promise<Record<string, unknown>> => {
try {
const content = await readFile(filePath, 'utf8');
const parsed = JSON.parse(content) as Record<string, unknown>;
return readObjectRecord(parsed) ?? {};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {};
}
throw error;
}
};
/**
* Writes a JSON config file with stable, human-readable formatting.
*
* The parent directory is created automatically so callers can persist config into
* provider-specific folders without pre-creating the directory tree. Output always
* ends with a trailing newline to keep the file diff-friendly.
*/
export const writeJsonConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
};
// -------------------------------------------------------------------------------------------

21
server/utils/colors.js Normal file
View File

@@ -0,0 +1,21 @@
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
export { colors, c };

View File

@@ -145,3 +145,54 @@ export async function detectTaskMasterMCPServer() {
}
}
/**
* Get all configured MCP servers (not just TaskMaster)
* @returns {Promise<Object>} All MCP servers configuration
*/
export async function getAllMCPServers() {
try {
const homeDir = os.homedir();
const configPaths = [
path.join(homeDir, '.claude.json'),
path.join(homeDir, '.claude', 'settings.json')
];
let configData = null;
let configPath = null;
// Try to read from either config file
for (const filepath of configPaths) {
try {
const fileContent = await fsPromises.readFile(filepath, 'utf8');
configData = JSON.parse(fileContent);
configPath = filepath;
break;
} catch (error) {
continue;
}
}
if (!configData) {
return {
hasConfig: false,
servers: {},
projectServers: {}
};
}
return {
hasConfig: true,
configPath,
servers: configData.mcpServers || {},
projectServers: configData.projects || {}
};
} catch (error) {
console.error('Error getting all MCP servers:', error);
return {
hasConfig: false,
error: error.message,
servers: {},
projectServers: {}
};
}
}

View File

@@ -0,0 +1,71 @@
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
function stripAnsiSequences(value = '') {
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
}
function normalizeDetectedUrl(url) {
if (!url || typeof url !== 'string') return null;
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
if (!cleaned) return null;
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function extractUrlsFromText(value = '') {
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
// Handle wrapped terminal URLs split across lines by terminal width.
const wrappedMatches = [];
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
if (!startMatch) continue;
let combined = startMatch[0];
let j = i + 1;
while (j < lines.length) {
const continuation = lines[j].trim();
if (!continuation) break;
if (!continuationRegex.test(continuation)) break;
combined += continuation;
j++;
}
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
}
return Array.from(new Set([...directMatches, ...wrappedMatches]));
}
function shouldAutoOpenUrlFromOutput(value = '') {
const normalized = value.toLowerCase();
return (
normalized.includes('browser didn\'t open') ||
normalized.includes('open this url') ||
normalized.includes('continue in your browser') ||
normalized.includes('press enter to open') ||
normalized.includes('open_url:')
);
}
export {
ANSI_ESCAPE_SEQUENCE_REGEX,
TRAILING_URL_PUNCTUATION_REGEX,
stripAnsiSequences,
normalizeDetectedUrl,
extractUrlsFromText,
shouldAutoOpenUrlFromOutput
};

View File

@@ -122,8 +122,28 @@ export default function AppContent() {
}
}, [isConnected, selectedSession?.id, sendMessage]);
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
// so inset-0 adjusts automatically. On iOS the layout viewport stays full-height and
// the keyboard overlays it — we use the Visual Viewport API to track keyboard height
// and apply it as a CSS variable that shifts the container's bottom edge up.
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
// Only resize matters — keyboard open/close changes vv.height.
// Do NOT listen to scroll: on iOS Safari, scrolling content changes
// vv.offsetTop which would make --keyboard-height fluctuate during
// normal scrolling, causing the container to bounce up and down.
const kb = Math.max(0, window.innerHeight - vv.height);
document.documentElement.style.setProperty('--keyboard-height', `${kb}px`);
};
vv.addEventListener('resize', update);
return () => vv.removeEventListener('resize', update);
}, []);
return (
<div className="fixed inset-0 flex bg-background">
<div className="fixed inset-0 flex bg-background" style={{ bottom: 'var(--keyboard-height, 0px)' }}>
{!isMobile ? (
<div className="h-full flex-shrink-0 border-r border-border/50">
<Sidebar {...sidebarSharedProps} />

View File

@@ -737,7 +737,7 @@ export function useChatComposerState({
}
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(expanded);
@@ -824,7 +824,7 @@ export function useChatComposerState({
(event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
target.style.height = `${Math.max(22, target.scrollHeight)}px`;
setCursorPosition(target.selectionStart);
syncInputOverlayScroll(target);

View File

@@ -1,8 +1,13 @@
import React, { memo, useMemo, useCallback } from 'react';
import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { PlanDisplay } from './components/PlanDisplay';
import { ToolStatusBadge } from './components/ToolStatusBadge';
import type { ToolStatus } from './components/ToolStatusBadge';
type DiffLine = {
type: string;
@@ -36,12 +41,32 @@ function getToolCategory(toolName: string): string {
if (toolName === 'Bash') return 'bash';
if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo';
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
if (toolName === 'Task') return 'agent'; // Subagent task
if (toolName === 'Task') return 'agent';
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
if (toolName === 'AskUserQuestion') return 'question';
return 'default';
}
// Exact denial messages from server/claude-sdk.js — other providers can't reliably signal denial
const CLAUDE_DENIAL_MESSAGES = [
'user denied tool use',
'tool disallowed by settings',
'permission request timed out',
'permission request cancelled',
];
function deriveToolStatus(toolResult: any): ToolStatus {
if (!toolResult) return 'running';
if (toolResult.isError) {
const content = String(toolResult.content || '').toLowerCase().trim();
if (CLAUDE_DENIAL_MESSAGES.some((msg) => content.includes(msg))) {
return 'denied';
}
return 'error';
}
return 'completed';
}
/**
* Main tool renderer router
* Routes to OneLineDisplay or CollapsibleDisplay based on tool config
@@ -73,6 +98,12 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
}
}, [mode, toolInput, toolResult]);
// Only derive and show status badge on input renders
const toolStatus = useMemo(
() => mode === 'input' ? deriveToolStatus(toolResult) : undefined,
[mode, toolResult],
);
const handleAction = useCallback(() => {
if (displayConfig?.action === 'open-file' && onFileOpen) {
const value = displayConfig.getValue?.(parsedData) || '';
@@ -82,9 +113,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
// Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks)
if (isSubagentContainer && subagentState) {
if (mode === 'result') {
return null;
}
if (mode === 'result') return null;
return (
<SubagentContainer
toolInput={toolInput}
@@ -115,6 +144,34 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
wrapText={displayConfig.wrapText}
colorScheme={displayConfig.colorScheme}
resultId={mode === 'input' ? `tool-result-${toolId}` : undefined}
status={toolStatus !== 'completed' ? toolStatus : undefined}
/>
);
}
if (displayConfig.type === 'plan') {
const title = typeof displayConfig.title === 'function'
? displayConfig.title(parsedData)
: displayConfig.title || 'Plan';
const contentProps = displayConfig.getContentProps?.(parsedData, {
selectedProject,
createDiff,
onFileOpen
}) || {};
const isStreaming = mode === 'input' && !toolResult;
return (
<PlanDisplay
title={title}
content={contentProps.content || ''}
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools}
isStreaming={isStreaming}
showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput}
toolName={toolName}
toolId={toolId}
/>
);
}
@@ -134,7 +191,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
onFileOpen
}) || {};
// Build the content component based on contentType
let contentComponent: React.ReactNode = null;
switch (displayConfig.contentType) {
@@ -211,7 +267,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
}
}
// For edit tools, make the title (filename) clickable to open the file
const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen
? () => onFileOpen(contentProps.filePath, {
old_string: contentProps.oldContent,
@@ -219,6 +274,8 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
})
: undefined;
const badgeElement = toolStatus && toolStatus !== 'completed' ? <ToolStatusBadge status={toolStatus} /> : undefined;
return (
<CollapsibleDisplay
toolName={toolName}
@@ -226,6 +283,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
title={title}
defaultOpen={defaultOpen}
onTitleClick={handleTitleClick}
badge={badgeElement}
showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput}
toolCategory={getToolCategory(toolName)}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
import { CollapsibleSection } from './CollapsibleSection';
interface CollapsibleDisplayProps {
@@ -7,6 +8,7 @@ interface CollapsibleDisplayProps {
title: string;
defaultOpen?: boolean;
action?: React.ReactNode;
badge?: React.ReactNode;
onTitleClick?: () => void;
children: React.ReactNode;
showRawParameters?: boolean;
@@ -17,14 +19,14 @@ interface CollapsibleDisplayProps {
const borderColorMap: Record<string, string> = {
edit: 'border-l-amber-500 dark:border-l-amber-400',
search: 'border-l-gray-400 dark:border-l-gray-500',
search: 'border-l-muted-foreground/40',
bash: 'border-l-green-500 dark:border-l-green-400',
todo: 'border-l-violet-500 dark:border-l-violet-400',
task: 'border-l-violet-500 dark:border-l-violet-400',
agent: 'border-l-purple-500 dark:border-l-purple-400',
plan: 'border-l-indigo-500 dark:border-l-indigo-400',
question: 'border-l-blue-500 dark:border-l-blue-400',
default: 'border-l-gray-300 dark:border-l-gray-600',
default: 'border-l-border',
};
export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
@@ -32,14 +34,14 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
title,
defaultOpen = false,
action,
badge,
onTitleClick,
children,
showRawParameters = false,
rawContent,
className = '',
toolCategory
toolCategory,
}) => {
// Fall back to default styling for unknown/new categories so className never includes "undefined".
const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;
return (
@@ -49,15 +51,16 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
toolName={toolName}
open={defaultOpen}
action={action}
badge={badge}
onTitleClick={onTitleClick}
>
{children}
{showRawParameters && rawContent && (
<details className="group/raw relative mt-2">
<summary className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<Collapsible className="mt-2">
<CollapsibleTrigger className="flex items-center gap-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground">
<svg
className="h-2.5 w-2.5 transition-transform duration-150 group-open/raw:rotate-90"
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -65,11 +68,13 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
raw params
</summary>
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/40 bg-gray-50 p-2 font-mono text-[11px] text-gray-600 dark:border-gray-700/40 dark:bg-gray-900/50 dark:text-gray-400">
{rawContent}
</pre>
</details>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-border/40 bg-muted p-2 font-mono text-[11px] text-muted-foreground">
{rawContent}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</CollapsibleSection>
</div>

View File

@@ -1,10 +1,13 @@
import React from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils';
interface CollapsibleSectionProps {
title: string;
toolName?: string;
open?: boolean;
action?: React.ReactNode;
badge?: React.ReactNode;
onTitleClick?: () => void;
children: React.ReactNode;
className?: string;
@@ -18,44 +21,68 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
toolName,
open = false,
action,
badge,
onTitleClick,
children,
className = ''
className = '',
}) => {
return (
<details className={`group/details relative ${className}`} open={open}>
<summary className="flex cursor-pointer select-none items-center gap-1.5 py-0.5 text-xs group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:-mx-1 group-open/details:bg-background group-open/details:px-1">
<svg
className="h-3 w-3 flex-shrink-0 text-gray-400 transition-transform duration-150 group-open/details:rotate-90 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{toolName && (
<span className="flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">{toolName}</span>
)}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-gray-300 dark:text-gray-600">/</span>
)}
{onTitleClick ? (
<Collapsible defaultOpen={open} className={cn('group/section', className)}>
{/* When there's a clickable title (Edit/Write), only the chevron toggles collapse */}
{onTitleClick ? (
<div className="flex cursor-default select-none items-center gap-1.5 py-0.5 text-xs group-data-[state=open]/section:sticky group-data-[state=open]/section:top-0 group-data-[state=open]/section:z-10 group-data-[state=open]/section:-mx-1 group-data-[state=open]/section:bg-background group-data-[state=open]/section:px-1">
<CollapsibleTrigger className="flex flex-shrink-0 items-center p-0.5 text-muted-foreground hover:text-foreground">
<svg
className="h-3 w-3 transition-transform duration-150 group-data-[state=open]/section:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</CollapsibleTrigger>
{toolName && (
<span className="flex-shrink-0 font-medium text-muted-foreground">{toolName}</span>
)}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground/40">/</span>
)}
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }}
className="flex-1 truncate text-left font-mono text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
onClick={onTitleClick}
className="flex-1 truncate text-left font-mono text-primary transition-colors hover:text-primary/80 hover:underline"
>
{title}
</button>
) : (
<span className="flex-1 truncate text-gray-600 dark:text-gray-400">
{title}
</span>
)}
{action && <span className="ml-1 flex-shrink-0">{action}</span>}
</summary>
<div className="mt-1.5 pl-[18px]">
{children}
</div>
</details>
{badge && <span className="ml-auto flex-shrink-0">{badge}</span>}
{action && <span className="ml-1 flex-shrink-0">{action}</span>}
</div>
) : (
<CollapsibleTrigger className="flex w-full select-none items-center gap-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground group-data-[state=open]/section:sticky group-data-[state=open]/section:top-0 group-data-[state=open]/section:z-10 group-data-[state=open]/section:-mx-1 group-data-[state=open]/section:bg-background group-data-[state=open]/section:px-1">
<svg
className="h-3 w-3 flex-shrink-0 transition-transform duration-150 group-data-[state=open]/section:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{toolName && (
<span className="flex-shrink-0 font-medium">{toolName}</span>
)}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground/40">/</span>
)}
<span className="flex-1 truncate text-left">{title}</span>
{badge && <span className="ml-auto flex-shrink-0">{badge}</span>}
{action && <span className="ml-1 flex-shrink-0">{action}</span>}
</CollapsibleTrigger>
)}
<CollapsibleContent>
<div className="mt-1.5 pl-[18px]">
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@@ -1,114 +1,21 @@
import { memo, useMemo } from 'react';
import { CheckCircle2, Circle, Clock, type LucideIcon } from 'lucide-react';
import { Badge } from '../../../../../shared/view/ui';
type TodoStatus = 'completed' | 'in_progress' | 'pending';
type TodoPriority = 'high' | 'medium' | 'low';
import { Queue, QueueItem, QueueItemIndicator, QueueItemContent } from '../../../../../shared/view/ui';
import type { QueueItemStatus } from '../../../../../shared/view/ui';
export type TodoItem = {
id?: string;
content: string;
status: string;
priority?: string;
activeForm?: string;
};
type NormalizedTodoItem = {
id?: string;
content: string;
status: TodoStatus;
priority: TodoPriority;
};
type StatusConfig = {
icon: LucideIcon;
iconClassName: string;
badgeClassName: string;
textClassName: string;
};
// Centralized visual config keeps rendering logic compact and easier to scan.
const STATUS_CONFIG: Record<TodoStatus, StatusConfig> = {
completed: {
icon: CheckCircle2,
iconClassName: 'w-3.5 h-3.5 text-green-500 dark:text-green-400',
badgeClassName:
'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800',
textClassName: 'line-through text-gray-500 dark:text-gray-400',
},
in_progress: {
icon: Clock,
iconClassName: 'w-3.5 h-3.5 text-blue-500 dark:text-blue-400',
badgeClassName:
'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800',
textClassName: 'text-gray-900 dark:text-gray-100',
},
pending: {
icon: Circle,
iconClassName: 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500',
badgeClassName:
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
textClassName: 'text-gray-900 dark:text-gray-100',
},
};
const PRIORITY_BADGE_CLASS: Record<TodoPriority, string> = {
high: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800',
medium:
'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800',
low: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
};
// Incoming tool payloads can vary; normalize to supported UI states.
const normalizeStatus = (status: string): TodoStatus => {
if (status === 'completed' || status === 'in_progress') {
return status;
}
const normalizeStatus = (status: string): QueueItemStatus => {
if (status === 'completed') return 'completed';
if (status === 'in_progress') return 'in_progress';
return 'pending';
};
const normalizePriority = (priority?: string): TodoPriority => {
if (priority === 'high' || priority === 'medium') {
return priority;
}
return 'low';
};
const TodoRow = memo(
({ todo }: { todo: NormalizedTodoItem }) => {
const statusConfig = STATUS_CONFIG[todo.status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex items-start gap-2 rounded border border-gray-200 bg-white p-2 transition-colors dark:border-gray-700 dark:bg-gray-800">
<div className="mt-0.5 flex-shrink-0">
<StatusIcon className={statusConfig.iconClassName} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-start justify-between gap-2">
<p className={`text-xs font-medium ${statusConfig.textClassName}`}>
{todo.content}
</p>
<div className="flex flex-shrink-0 gap-1">
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${PRIORITY_BADGE_CLASS[todo.priority]}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${statusConfig.badgeClassName}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
);
}
);
const TodoList = memo(
({
todos,
@@ -117,36 +24,33 @@ const TodoList = memo(
todos: TodoItem[];
isResult?: boolean;
}) => {
// Memoize normalization to avoid recomputing list metadata on every render.
const normalizedTodos = useMemo<NormalizedTodoItem[]>(
() =>
todos.map((todo) => ({
id: todo.id,
content: todo.content,
status: normalizeStatus(todo.status),
priority: normalizePriority(todo.priority),
})),
[todos]
const normalized = useMemo(
() => todos.map((todo) => ({ ...todo, queueStatus: normalizeStatus(todo.status) })),
[todos],
);
if (normalizedTodos.length === 0) {
return null;
}
if (normalized.length === 0) return null;
return (
<div className="space-y-1.5">
<div>
{isResult && (
<div className="mb-1.5 text-xs font-medium text-gray-600 dark:text-gray-400">
Todo List ({normalizedTodos.length}{' '}
{normalizedTodos.length === 1 ? 'item' : 'items'})
<div className="mb-1.5 text-xs font-medium text-muted-foreground">
Todo List ({normalized.length} {normalized.length === 1 ? 'item' : 'items'})
</div>
)}
{normalizedTodos.map((todo, index) => (
<TodoRow key={todo.id ?? `${todo.content}-${index}`} todo={todo} />
))}
<Queue>
{normalized.map((todo, index) => (
<QueueItem key={todo.id ?? `${todo.content}-${index}`} status={todo.queueStatus}>
<QueueItemIndicator />
<QueueItemContent>{todo.content}</QueueItemContent>
</QueueItem>
))}
</Queue>
</div>
);
}
},
);
TodoList.displayName = 'TodoList';
export default TodoList;

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { ToolStatusBadge } from './ToolStatusBadge';
import type { ToolStatus } from './ToolStatusBadge';
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
@@ -23,6 +25,7 @@ interface OneLineDisplayProps {
resultId?: string;
toolResult?: any;
toolId?: string;
status?: ToolStatus;
}
/**
@@ -40,14 +43,15 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
style,
wrapText = false,
colorScheme = {
primary: 'text-gray-700 dark:text-gray-300',
secondary: 'text-gray-500 dark:text-gray-400',
primary: 'text-foreground',
secondary: 'text-muted-foreground',
background: '',
border: 'border-gray-300 dark:border-gray-600',
icon: 'text-gray-500 dark:text-gray-400'
border: 'border-border',
icon: 'text-muted-foreground',
},
toolResult,
toolId
toolId,
status,
}) => {
const [copied, setCopied] = useState(false);
const isTerminal = style === 'terminal';
@@ -55,9 +59,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const handleAction = async () => {
if (action === 'copy' && value) {
const didCopy = await copyTextToClipboard(value);
if (!didCopy) {
return;
}
if (!didCopy) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else if (onAction) {
@@ -68,7 +70,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const renderCopyButton = () => (
<button
onClick={handleAction}
className="ml-1 flex-shrink-0 text-gray-400 opacity-0 transition-all hover:text-gray-600 group-hover:opacity-100 dark:hover:text-gray-200"
className="ml-1 flex-shrink-0 text-muted-foreground/40 opacity-0 transition-all hover:text-muted-foreground group-hover:opacity-100"
title="Copy to clipboard"
aria-label="Copy to clipboard"
>
@@ -84,7 +86,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
</button>
);
// Terminal style: dark pill only around the command
// Terminal style: dark pill around the command
if (isTerminal) {
return (
<div className="group my-1">
@@ -100,12 +102,13 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
<span className="select-none text-green-600 dark:text-green-500">$ </span>{value}
</code>
</div>
{status && <ToolStatusBadge status={status} className="mt-0.5" />}
{action === 'copy' && renderCopyButton()}
</div>
</div>
{secondary && (
<div className="ml-7 mt-1">
<span className="text-[11px] italic text-gray-400 dark:text-gray-500">
<span className="text-[11px] italic text-muted-foreground/60">
{secondary}
</span>
</div>
@@ -114,20 +117,21 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
);
}
// File open style - show filename only, full path on hover
// File open style
if (action === 'open-file') {
const displayName = value.split('/').pop() || value;
return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
<span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
<span className="text-[10px] text-muted-foreground/40">/</span>
<button
onClick={handleAction}
className="truncate font-mono text-xs text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
className="truncate font-mono text-xs text-primary transition-colors hover:text-primary/80 hover:underline"
title={value}
>
{displayName}
</button>
{status && <ToolStatusBadge status={status} className="ml-auto" />}
</div>
);
}
@@ -136,20 +140,21 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
if (action === 'jump-to-results') {
return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
<span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
<span className="text-[10px] text-muted-foreground/40">/</span>
<span className={`min-w-0 flex-1 truncate font-mono text-xs ${colorScheme.primary}`}>
{value}
</span>
{secondary && (
<span className="flex-shrink-0 text-[11px] italic text-gray-400 dark:text-gray-500">
<span className="flex-shrink-0 text-[11px] italic text-muted-foreground/60">
{secondary}
</span>
)}
{status && <ToolStatusBadge status={status} />}
{toolResult && (
<a
href={`#tool-result-${toolId}`}
className="flex flex-shrink-0 items-center gap-0.5 text-[11px] text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
className="flex flex-shrink-0 items-center gap-0.5 text-[11px] text-primary transition-colors hover:text-primary/80"
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@@ -167,10 +172,10 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
<span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span>
)}
{!icon && (label || toolName) && (
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
<span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
)}
{(icon || label || toolName) && (
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
<span className="text-[10px] text-muted-foreground/40">/</span>
)}
<span className={`font-mono text-xs ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} min-w-0 flex-1 ${colorScheme.primary}`}>
{value}
@@ -180,6 +185,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
{secondary}
</span>
)}
{status && <ToolStatusBadge status={status} />}
{action === 'copy' && renderCopyButton()}
</div>
);

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { ChevronsUpDown, FileText } from 'lucide-react';
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
Button,
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
Shimmer,
} from '../../../../shared/view/ui';
import { usePermission } from '../../../../contexts/PermissionContext';
import { MarkdownContent } from './ContentRenderers';
interface PlanDisplayProps {
title: string;
content: string;
defaultOpen?: boolean;
isStreaming?: boolean;
showRawParameters?: boolean;
rawContent?: string;
toolName: string;
toolId?: string;
}
export const PlanDisplay: React.FC<PlanDisplayProps> = ({
title,
content,
defaultOpen = false,
isStreaming = false,
showRawParameters = false,
rawContent,
toolName: _toolName,
}) => {
const permissionCtx = usePermission();
const pendingRequest = permissionCtx?.pendingPermissionRequests.find(
(r) => r.toolName === 'ExitPlanMode' || r.toolName === 'exit_plan_mode'
);
const handleBuild = () => {
if (pendingRequest && permissionCtx) {
permissionCtx.handlePermissionDecision(pendingRequest.requestId, { allow: true });
}
};
const handleRevise = () => {
if (pendingRequest && permissionCtx) {
permissionCtx.handlePermissionDecision(pendingRequest.requestId, {
allow: false,
message: 'User asked to revise the plan',
});
}
};
return (
<Collapsible defaultOpen={defaultOpen}>
<Card className="my-1 flex flex-col shadow-none">
{/* Header — always visible */}
<CardHeader className="flex flex-row items-start justify-between space-y-0 px-4 pb-0 pt-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<CardTitle className="text-sm font-semibold">
{isStreaming ? <Shimmer>{title}</Shimmer> : title}
</CardTitle>
</div>
<CollapsibleTrigger className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">Toggle plan</span>
</CollapsibleTrigger>
</CardHeader>
{/* Collapsible content */}
<CollapsibleContent>
<CardContent className="px-4 pb-4 pt-3">
{content ? (
<MarkdownContent
content={content}
className="prose prose-sm max-w-none dark:prose-invert"
/>
) : isStreaming ? (
<div className="py-2">
<Shimmer>Generating plan...</Shimmer>
</div>
) : null}
{showRawParameters && rawContent && (
<Collapsible className="mt-3">
<CollapsibleTrigger className="flex items-center gap-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground">
<svg
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
raw params
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-border/40 bg-muted p-2 font-mono text-[11px] text-muted-foreground">
{rawContent}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</CollapsibleContent>
{/* Footer — always visible when permission is pending */}
{pendingRequest && (
<CardFooter className="justify-end gap-2 border-t border-border/40 px-4 pb-3 pt-3">
<Button
variant="ghost"
size="sm"
onClick={handleRevise}
className="text-muted-foreground"
>
Revise
</Button>
<Button size="sm" onClick={handleBuild}>
Build{' '}
<kbd className="ml-1 rounded bg-primary-foreground/20 px-1 py-0.5 font-mono text-[10px]">
</kbd>
</Button>
</CardFooter>
)}
</Card>
</Collapsible>
);
};

View File

@@ -1,6 +1,7 @@
import React from 'react';
import type { SubagentChildTool } from '../../types/types';
import { CollapsibleSection } from './CollapsibleSection';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
interface SubagentContainerProps {
toolInput: unknown;
@@ -65,21 +66,21 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
>
{/* Prompt/request to the subagent */}
{prompt && (
<div className="mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-gray-600 dark:text-gray-400">
<div className="mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-muted-foreground">
{prompt}
</div>
)}
{/* Current tool indicator (while running) */}
{currentTool && !isComplete && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-purple-500 dark:bg-purple-400" />
<span className="text-gray-400 dark:text-gray-500">Currently:</span>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span>
<span className="text-muted-foreground/60">Currently:</span>
<span className="font-medium text-foreground">{currentTool.toolName}</span>
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
<>
<span className="text-gray-300 dark:text-gray-600">/</span>
<span className="truncate font-mono text-gray-500 dark:text-gray-400">
<span className="text-muted-foreground/40">/</span>
<span className="truncate font-mono text-muted-foreground">
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
</span>
</>
@@ -99,10 +100,10 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
{/* Tool history (collapsed) */}
{childTools.length > 0 && (
<details className="group/history mt-2">
<summary className="flex cursor-pointer items-center gap-1 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<Collapsible className="mt-2">
<CollapsibleTrigger className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<svg
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 group-open/history:rotate-90"
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -110,29 +111,31 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>View tool history ({childTools.length})</span>
</summary>
<div className="mt-1 space-y-0.5 border-l border-gray-200 pl-3 dark:border-gray-700">
{childTools.map((child, index) => (
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<span className="w-4 flex-shrink-0 text-right text-gray-400 dark:text-gray-500">{index + 1}.</span>
<span className="font-medium">{child.toolName}</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && (
<span className="truncate font-mono text-gray-400 dark:text-gray-500">
{getCompactToolDisplay(child.toolName, child.toolInput)}
</span>
)}
{child.toolResult?.isError && (
<span className="flex-shrink-0 text-red-500">(error)</span>
)}
</div>
))}
</div>
</details>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 space-y-0.5 border-l border-border pl-3">
{childTools.map((child, index) => (
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="w-4 flex-shrink-0 text-right text-muted-foreground/60">{index + 1}.</span>
<span className="font-medium text-foreground">{child.toolName}</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && (
<span className="truncate font-mono text-muted-foreground/70">
{getCompactToolDisplay(child.toolName, child.toolInput)}
</span>
)}
{child.toolResult?.isError && (
<span className="flex-shrink-0 text-red-500">(error)</span>
)}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Final result */}
{isComplete && toolResult && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
<div className="mt-2 text-xs text-muted-foreground">
{(() => {
let content = toolResult.content;

View File

@@ -0,0 +1,42 @@
import { cn } from '../../../../lib/utils';
export type ToolStatus = 'running' | 'completed' | 'error' | 'denied';
const STATUS_CONFIG: Record<ToolStatus, { label: string; className: string }> = {
running: {
label: 'Running',
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
},
completed: {
label: 'Completed',
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
},
error: {
label: 'Error',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
},
denied: {
label: 'Denied',
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
},
};
interface ToolStatusBadgeProps {
status: ToolStatus;
className?: string;
}
export function ToolStatusBadge({ status, className }: ToolStatusBadgeProps) {
const config = STATUS_CONFIG[status];
return (
<span
className={cn(
'inline-flex items-center rounded px-1.5 py-px text-[10px] font-medium',
config.className,
className,
)}
>
{config.label}
</span>
);
}

View File

@@ -5,3 +5,5 @@ export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer';
export * from './ContentRenderers';
export * from './InteractiveRenderers';
export { ToolStatusBadge } from './ToolStatusBadge';
export type { ToolStatus } from './ToolStatusBadge';

View File

@@ -5,7 +5,7 @@
export interface ToolDisplayConfig {
input: {
type: 'one-line' | 'collapsible' | 'hidden';
type: 'one-line' | 'collapsible' | 'plan' | 'hidden';
// One-line config
icon?: string;
label?: string;
@@ -31,7 +31,7 @@ export interface ToolDisplayConfig {
result?: {
hidden?: boolean;
hideOnSuccess?: boolean;
type?: 'one-line' | 'collapsible' | 'special';
type?: 'one-line' | 'collapsible' | 'plan' | 'special';
title?: string | ((result: any) => string);
defaultOpen?: boolean;
// Special result handlers
@@ -494,7 +494,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
exit_plan_mode: {
input: {
type: 'collapsible',
type: 'plan',
title: 'Implementation plan',
defaultOpen: true,
contentType: 'markdown',
@@ -503,29 +503,14 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
})
},
result: {
type: 'collapsible',
contentType: 'markdown',
getContentProps: (result) => {
try {
let parsed = result.content;
if (typeof parsed === 'string') {
parsed = JSON.parse(parsed);
}
return {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
};
} catch (e) {
console.warn('Failed to parse plan content:', e);
return { content: '' };
}
}
hidden: true
}
},
// Also register as ExitPlanMode (the actual tool name used by Claude)
ExitPlanMode: {
input: {
type: 'collapsible',
type: 'plan',
title: 'Implementation plan',
defaultOpen: true,
contentType: 'markdown',
@@ -534,22 +519,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
})
},
result: {
type: 'collapsible',
contentType: 'markdown',
getContentProps: (result) => {
try {
let parsed = result.content;
if (typeof parsed === 'string') {
parsed = JSON.parse(parsed);
}
return {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
};
} catch (e) {
console.warn('Failed to parse plan content:', e);
return { content: '' };
}
}
hidden: true
}
},

View File

@@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import PermissionContext from '../../../contexts/PermissionContext';
import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps, Provider } from '../types/types';
import type { LLMProvider } from '../../../types/app';
@@ -9,6 +11,7 @@ import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState';
import { useSessionStore } from '../../../stores/useSessionStore';
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
@@ -267,6 +270,11 @@ function ChatInterface({
};
}, [resetStreamingState]);
const permissionContextValue = useMemo(() => ({
pendingPermissionRequests,
handlePermissionDecision,
}), [pendingPermissionRequests, handlePermissionDecision]);
if (!selectedProject) {
const selectedProviderLabel =
provider === 'cursor'
@@ -292,7 +300,7 @@ function ChatInterface({
}
return (
<>
<PermissionContext.Provider value={permissionContextValue}>
<div className="flex h-full flex-col">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
@@ -393,7 +401,6 @@ function ChatInterface({
onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput}
onInputFocusChange={handleInputFocusChange}
isInputFocused={isInputFocused}
placeholder={t('input.placeholder', {
provider:
provider === 'cursor'
@@ -410,7 +417,7 @@ function ChatInterface({
</div>
<QuickSettingsPanel />
</>
</PermissionContext.Provider>
);
}

View File

@@ -11,12 +11,24 @@ import type {
SetStateAction,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie';
import {
PromptInput,
PromptInputHeader,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
PromptInputButton,
PromptInputSubmit,
} from '../../../../shared/view/ui';
interface MentionableFile {
name: string;
@@ -86,7 +98,6 @@ interface ChatComposerProps {
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
onInputFocusChange?: (focused: boolean) => void;
isInputFocused?: boolean;
placeholder: string;
isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean;
@@ -142,7 +153,6 @@ export default function ChatComposer({
onTextareaScrollSync,
onTextareaInput,
onInputFocusChange,
isInputFocused,
placeholder,
isTextareaExpanded,
sendByCtrlEnter,
@@ -160,81 +170,43 @@ export default function ChatComposer({
(r) => r.toolName === 'AskUserQuestion'
);
// On mobile, when input is focused, float the input box at the bottom
const mobileFloatingClass = isInputFocused
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
: '';
// Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0;
return (
<div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}>
{!hasQuestionPanel && (
<div className="flex-1">
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
{!hasPendingPermissions && (
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
/>
)}
{pendingPermissionRequests.length > 0 && (
<div className="mx-auto mb-3 max-w-4xl">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
/>
</div>
)}
<div className="mx-auto mb-3 max-w-4xl">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
/>
{!hasQuestionPanel && <ChatInputControls
permissionMode={permissionMode}
onModeSwitch={onModeSwitch}
provider={provider}
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={onToggleCommandMenu}
hasInput={hasInput}
onClearInput={onClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={hasMessages}
onScrollToBottom={onScrollToBottom}
/>}
</div>
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative mx-auto max-w-4xl">
{isDragActive && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
<div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
<svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
{!hasQuestionPanel && <div className="relative mx-auto max-w-4xl">
{isUserScrolledUp && hasMessages && (
<div className="absolute -top-10 left-0 right-0 z-10 flex justify-center">
<button
type="button"
onClick={onScrollToBottom}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<ArrowDownIcon className="h-4 w-4" />
</button>
</div>
)}
{attachedImages.length > 0 && (
<div className="mb-2 rounded-xl bg-muted/40 p-2">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => onRemoveImage(index)}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
)}
{showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
{filteredFiles.map((file, index) => (
@@ -272,21 +244,56 @@ export default function ChatComposer({
frequentCommands={frequentCommands}
/>
<div
<PromptInput
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
status={isLoading ? 'streaming' : 'ready'}
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
{...getRootProps()}
className={`relative overflow-hidden rounded-2xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15 ${
isTextareaExpanded ? 'chat-input-expanded' : ''
}`}
>
<input {...getInputProps()} />
<div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl">
<div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words py-1.5 pl-12 pr-20 text-base leading-6 text-transparent sm:py-4 sm:pr-40">
{renderInputWithMentions(input)}
{isDragActive && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
<div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
<svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
</div>
</div>
)}
<div className="relative z-10">
<textarea
{attachedImages.length > 0 && (
<PromptInputHeader>
<div className="rounded-xl bg-muted/40 p-2">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => onRemoveImage(index)}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
</PromptInputHeader>
)}
<input {...getInputProps()} />
<PromptInputBody>
<div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl">
<div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words px-4 py-2 text-sm leading-6 text-transparent">
{renderInputWithMentions(input)}
</div>
</div>
<PromptInputTextarea
ref={textareaRef}
value={input}
onChange={onInputChange}
@@ -298,54 +305,110 @@ export default function ChatComposer({
onBlur={() => onInputFocusChange?.(false)}
onInput={onTextareaInput}
placeholder={placeholder}
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
style={{ height: '50px' }}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputButton
tooltip={{ content: t('input.attachImages') }}
onClick={openImagePicker}
>
<ImageIcon />
</PromptInputButton>
<button
type="button"
onClick={openImagePicker}
className="absolute left-2 top-1/2 -translate-y-1/2 transform rounded-xl p-2 transition-colors hover:bg-accent/60"
title={t('input.attachImages')}
onClick={onModeSwitch}
className={`rounded-lg border px-1.5 py-1 text-xs font-medium transition-all duration-200 sm:px-2.5 ${
permissionMode === 'default'
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits'
? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
: permissionMode === 'bypassPermissions'
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
}`}
title={t('input.clickToChangeMode')}
>
<svg className="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
<div className="flex items-center gap-1.5">
<div
className={`h-2 w-2 rounded-full sm:h-1.5 sm:w-1.5 ${
permissionMode === 'default'
? 'bg-muted-foreground'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
}`}
/>
</svg>
<span className="hidden sm:inline">
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>
</div>
</button>
<button
type="submit"
disabled={!input.trim() || isLoading}
onMouseDown={(event) => {
event.preventDefault();
onSubmit(event);
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event);
}}
className="absolute right-2 top-1/2 flex h-10 w-10 -translate-y-1/2 transform items-center justify-center rounded-xl bg-primary transition-all duration-200 hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground sm:h-11 sm:w-11"
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<PromptInputButton
tooltip={{ content: t('input.showAllCommands') }}
onClick={onToggleCommandMenu}
className="relative"
>
<svg className="h-4 w-4 rotate-90 transform text-primary-foreground sm:h-[18px] sm:w-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
<MessageSquareIcon />
{slashCommandsCount > 0 && (
<span
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground"
>
{slashCommandsCount}
</span>
)}
</PromptInputButton>
{hasInput && (
<PromptInputButton
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
onClick={onClearInput}
className="hidden sm:inline-flex"
>
<XIcon />
</PromptInputButton>
)}
</PromptInputTools>
<div className="flex items-center gap-2">
<div
className={`pointer-events-none absolute bottom-1 left-12 right-14 hidden text-xs text-muted-foreground/50 transition-opacity duration-200 sm:right-40 sm:block ${
className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${
input.trim() ? 'opacity-0' : 'opacity-100'
}`}
>
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
</div>
<PromptInputSubmit
disabled={!input.trim() || isLoading}
className="h-10 w-10 sm:h-10 sm:w-10"
onMouseDown={(event) => {
event.preventDefault();
onSubmit(event as unknown as MouseEvent<HTMLButtonElement>);
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event as unknown as TouchEvent<HTMLButtonElement>);
}}
/>
</div>
</div>
</form>}
</PromptInputFooter>
</PromptInput>
</div>}
</div>
);
}

View File

@@ -1,137 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { PermissionMode, Provider } from '../../types/types';
import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie';
interface ChatInputControlsProps {
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
provider: Provider | string;
thinkingMode: string;
setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
}
export default function ChatInputControls({
permissionMode,
onModeSwitch,
provider,
thinkingMode,
setThinkingMode,
tokenBudget,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
}: ChatInputControlsProps) {
const { t } = useTranslation('chat');
return (
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3">
<button
type="button"
onClick={onModeSwitch}
className={`rounded-lg border px-2.5 py-1 text-sm font-medium transition-all duration-200 sm:px-3 sm:py-1.5 ${
permissionMode === 'default'
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits'
? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
: permissionMode === 'bypassPermissions'
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
}`}
title={t('input.clickToChangeMode')}
>
<div className="flex items-center gap-1.5">
<div
className={`h-1.5 w-1.5 rounded-full ${
permissionMode === 'default'
? 'bg-muted-foreground'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
}`}
/>
<span>
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>
</div>
</button>
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<button
type="button"
onClick={onToggleCommandMenu}
className="relative flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground sm:h-8 sm:w-8"
title={t('input.showAllCommands')}
>
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
{slashCommandsCount > 0 && (
<span
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground sm:h-5 sm:w-5"
>
{slashCommandsCount}
</span>
)}
</button>
{hasInput && (
<button
type="button"
onClick={onClearInput}
className="group flex h-7 w-7 items-center justify-center rounded-lg border border-border/50 bg-card shadow-sm transition-all duration-200 hover:bg-accent/60 sm:h-8 sm:w-8"
title={t('input.clearInput', { defaultValue: 'Clear input' })}
>
<svg
className="h-3.5 w-3.5 text-muted-foreground transition-colors group-hover:text-foreground sm:h-4 sm:w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{isUserScrolledUp && hasMessages && (
<button
onClick={onScrollToBottom}
className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all duration-200 hover:scale-105 hover:bg-primary/90 sm:h-8 sm:w-8"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl';
@@ -68,7 +69,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;
const shouldShowAssistantCopyControl = message.type === 'assistant' &&
assistantCopyContent.trim().length > 0 &&
!isCommandOrFileEditToolResponse;
!isCommandOrFileEditToolResponse &&
!message.isThinking;
useEffect(() => {
@@ -378,36 +380,30 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</div>
</div>
) : message.isThinking ? (
/* Thinking messages - collapsible by default */
<div className="text-sm text-gray-700 dark:text-gray-300">
<details className="group">
<summary className="flex cursor-pointer items-center gap-2 font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg className="h-3 w-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>{t('thinking.emoji')}</span>
</summary>
<div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-400">
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{message.content}
</Markdown>
/* Thinking messages — Reasoning component (ai-elements pattern) */
<Reasoning defaultOpen={false}>
<ReasoningTrigger />
<ReasoningContent>
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{message.content}
</Markdown>
<div className="mt-3 flex items-center text-[11px]">
<MessageCopyControl content={String(message.content || '')} messageType="assistant" />
</div>
</details>
</div>
</ReasoningContent>
</Reasoning>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
{/* Thinking accordion for reasoning */}
{/* Reasoning accordion */}
{showThinking && message.reasoning && (
<details className="mb-3">
<summary className="cursor-pointer font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
{t('thinking.emoji')}
</summary>
<div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
<Reasoning className="mb-3" defaultOpen={false}>
<ReasoningTrigger />
<ReasoningContent>
<div className="whitespace-pre-wrap">
{message.reasoning}
</div>
</div>
</details>
</ReasoningContent>
</Reasoning>
)}
{(() => {

View File

@@ -1,9 +1,18 @@
import React from 'react';
import { ShieldAlertIcon } from 'lucide-react';
import type { PendingPermissionRequest } from '../../types/types';
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
import { getClaudeSettings } from '../../utils/chatStorage';
import { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry';
import { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers';
import {
Confirmation,
ConfirmationTitle,
ConfirmationRequest,
ConfirmationActions,
ConfirmationAction,
} from '../../../../shared/view/ui';
registerPermissionPanel('AskUserQuestion', AskUserQuestionPanel);
@@ -21,13 +30,18 @@ export default function PermissionRequestsBanner({
handlePermissionDecision,
handleGrantToolPermission,
}: PermissionRequestsBannerProps) {
if (!pendingPermissionRequests.length) {
// Filter out plan tool requests — they are handled inline by PlanDisplay
const filteredRequests = pendingPermissionRequests.filter(
(r) => r.toolName !== 'ExitPlanMode' && r.toolName !== 'exit_plan_mode'
);
if (!filteredRequests.length) {
return null;
}
return (
<div className="mb-3 space-y-2">
{pendingPermissionRequests.map((request) => {
{filteredRequests.map((request) => {
const CustomPanel = getPermissionPanel(request.toolName);
if (CustomPanel) {
return (
@@ -54,69 +68,62 @@ export default function PermissionRequestsBanner({
: [request.requestId];
return (
<div
key={request.requestId}
className="rounded-lg border border-amber-200 bg-amber-50 p-3 shadow-sm dark:border-amber-800 dark:bg-amber-900/20"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">Permission required</div>
<div className="text-xs text-amber-800 dark:text-amber-200">
Tool: <span className="font-mono">{request.toolName}</span>
<Confirmation key={request.requestId} approval="pending">
<ConfirmationTitle className="flex items-start gap-3">
<ShieldAlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<ConfirmationRequest>
<div>
<span className="font-medium text-foreground">Permission required</span>
<span className="ml-2 text-muted-foreground">
Tool: <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{request.toolName}</code>
</span>
</div>
</div>
{permissionEntry && (
<div className="text-xs text-amber-700 dark:text-amber-300">
Allow rule: <span className="font-mono">{permissionEntry}</span>
</div>
)}
</div>
{permissionEntry && (
<div className="mt-1 text-xs text-muted-foreground">
Allow rule: <code className="rounded bg-muted px-1 py-0.5 text-xs">{permissionEntry}</code>
</div>
)}
</ConfirmationRequest>
</ConfirmationTitle>
{rawInput && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-amber-800 hover:text-amber-900 dark:text-amber-200 dark:hover:text-amber-100">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
View tool input
</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border border-amber-200/60 bg-white/80 p-2 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-gray-900/60 dark:text-amber-100">
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border bg-muted/50 p-2 text-xs text-muted-foreground">
{rawInput}
</pre>
</details>
)}
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
className="inline-flex items-center gap-2 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-amber-700"
<ConfirmationActions>
<ConfirmationAction
variant="outline"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
>
Allow once
</button>
<button
type="button"
Deny
</ConfirmationAction>
<ConfirmationAction
variant="outline"
onClick={() => {
if (permissionEntry && !alreadyAllowed) {
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
}
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
}}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
permissionEntry
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
: 'cursor-not-allowed border-gray-300 text-gray-400'
}`}
disabled={!permissionEntry}
>
{rememberLabel}
</button>
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
className="inline-flex items-center gap-2 rounded-md border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30"
</ConfirmationAction>
<ConfirmationAction
variant="default"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
>
Deny
</button>
</div>
</div>
Allow once
</ConfirmationAction>
</ConfirmationActions>
</Confirmation>
);
})}
</div>

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import {
CLAUDE_MODELS,
@@ -11,6 +11,19 @@ import {
} from "../../../../../shared/modelConstants";
import type { ProjectSession, LLMProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
Card,
} from "../../../../shared/view/ui";
type ProviderSelectionEmptyStateProps = {
selectedSession: ProjectSession | null;
@@ -32,48 +45,17 @@ type ProviderSelectionEmptyStateProps = {
setInput: React.Dispatch<React.SetStateAction<string>>;
};
type ProviderDef = {
interface ProviderGroup {
id: LLMProvider;
name: string;
infoKey: string;
accent: string;
ring: string;
check: string;
};
models: { value: string; label: string }[];
}
const PROVIDERS: ProviderDef[] = [
{
id: "claude",
name: "Claude Code",
infoKey: "providerSelection.providerInfo.anthropic",
accent: "border-primary",
ring: "ring-primary/15",
check: "bg-primary text-primary-foreground",
},
{
id: "cursor",
name: "Cursor",
infoKey: "providerSelection.providerInfo.cursorEditor",
accent: "border-violet-500 dark:border-violet-400",
ring: "ring-violet-500/15",
check: "bg-violet-500 text-white",
},
{
id: "codex",
name: "Codex",
infoKey: "providerSelection.providerInfo.openai",
accent: "border-emerald-600 dark:border-emerald-400",
ring: "ring-emerald-600/15",
check: "bg-emerald-600 dark:bg-emerald-500 text-white",
},
{
id: "gemini",
name: "Gemini",
infoKey: "providerSelection.providerInfo.google",
accent: "border-blue-500 dark:border-blue-400",
ring: "ring-blue-500/15",
check: "bg-blue-500 text-white",
},
const PROVIDER_GROUPS: ProviderGroup[] = [
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS.OPTIONS },
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS.OPTIONS },
{ id: "codex", name: "OpenAI", models: CODEX_MODELS.OPTIONS },
{ id: "gemini", name: "Google", models: GEMINI_MODELS.OPTIONS },
];
function getModelConfig(p: LLMProvider) {
@@ -83,7 +65,7 @@ function getModelConfig(p: LLMProvider) {
return CURSOR_MODELS;
}
function getModelValue(
function getCurrentModel(
p: LLMProvider,
c: string,
cu: string,
@@ -96,6 +78,13 @@ function getModelValue(
return cu;
}
function getProviderDisplayName(p: LLMProvider) {
if (p === "claude") return "Claude";
if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex";
return "Gemini";
}
export default function ProviderSelectionEmptyState({
selectedSession,
currentSessionId,
@@ -116,51 +105,12 @@ export default function ProviderSelectionEmptyState({
setInput,
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation("chat");
const { isWindowsServer } = useServerPlatform();
const visibleProviders = useMemo(
() =>
isWindowsServer
? PROVIDERS.filter((p) => p.id !== "cursor")
: PROVIDERS,
[isWindowsServer],
);
useEffect(() => {
if (isWindowsServer && provider === "cursor") {
setProvider("claude");
localStorage.setItem("selected-provider", "claude");
}
}, [isWindowsServer, provider, setProvider]);
const [dialogOpen, setDialogOpen] = useState(false);
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
defaultValue: "Start the next task",
});
const selectProvider = (next: LLMProvider) => {
setProvider(next);
localStorage.setItem("selected-provider", next);
setTimeout(() => textareaRef.current?.focus(), 100);
};
const handleModelChange = (value: string) => {
if (provider === "claude") {
setClaudeModel(value);
localStorage.setItem("claude-model", value);
} else if (provider === "codex") {
setCodexModel(value);
localStorage.setItem("codex-model", value);
} else if (provider === "gemini") {
setGeminiModel(value);
localStorage.setItem("gemini-model", value);
} else {
setCursorModel(value);
localStorage.setItem("cursor-model", value);
}
};
const modelConfig = getModelConfig(provider);
const currentModel = getModelValue(
const currentModel = getCurrentModel(
provider,
claudeModel,
cursorModel,
@@ -168,7 +118,42 @@ export default function ProviderSelectionEmptyState({
geminiModel,
);
/* ── New session — provider picker ── */
const currentModelLabel = useMemo(() => {
const config = getModelConfig(provider);
const found = config.OPTIONS.find(
(o: { value: string; label: string }) => o.value === currentModel,
);
return found?.label || currentModel;
}, [provider, currentModel]);
const handleModelSelect = useCallback(
(providerId: LLMProvider, modelValue: string) => {
// Set provider
setProvider(providerId);
localStorage.setItem("selected-provider", providerId);
// Set model for the correct provider
if (providerId === "claude") {
setClaudeModel(modelValue);
localStorage.setItem("claude-model", modelValue);
} else if (providerId === "codex") {
setCodexModel(modelValue);
localStorage.setItem("codex-model", modelValue);
} else if (providerId === "gemini") {
setGeminiModel(modelValue);
localStorage.setItem("gemini-model", modelValue);
} else {
setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue);
}
setDialogOpen(false);
setTimeout(() => textareaRef.current?.focus(), 100);
},
[setProvider, setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, textareaRef],
);
/* ── New session — provider + model picker ── */
if (!selectedSession && !currentSessionId) {
return (
<div className="flex h-full items-center justify-center px-4">
@@ -183,98 +168,100 @@ export default function ProviderSelectionEmptyState({
</p>
</div>
{/* Provider cards — horizontal row, equal width */}
<div
className={`mb-6 grid grid-cols-2 gap-2 sm:gap-2.5 ${visibleProviders.length >= 4 ? "sm:grid-cols-4" : "sm:grid-cols-3"}`}
>
{visibleProviders.map((p) => {
const active = provider === p.id;
return (
<button
key={p.id}
onClick={() => selectProvider(p.id)}
className={`
relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2
pb-4 pt-5 transition-all duration-150
active:scale-[0.97]
${
active
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
: "border-border bg-card/60 hover:border-border/80 hover:bg-card"
}
`}
>
{/* Model selector trigger — hero card style */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Card
className="group mx-auto max-w-sm cursor-pointer border-border/60 transition-all duration-150 hover:border-border hover:shadow-md active:scale-[0.99]"
role="button"
tabIndex={0}
>
<div className="flex items-center gap-3 p-4">
<SessionProviderLogo
provider={p.id}
className={`h-9 w-9 transition-transform duration-150 ${active ? "scale-110" : ""}`}
provider={provider}
className="h-8 w-8 shrink-0"
/>
<div className="text-center">
<p className="text-[13px] font-semibold leading-none text-foreground">
{p.name}
</p>
<p className="mt-1 text-[10px] leading-tight text-muted-foreground">
{t(p.infoKey)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-foreground">
{getProviderDisplayName(provider)}
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="truncate text-sm text-foreground">
{currentModelLabel}
</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("providerSelection.clickToChange", {
defaultValue: "Click to change model",
})}
</p>
</div>
{/* Check badge */}
{active && (
<div
className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-y-0.5" />
</div>
</Card>
</DialogTrigger>
<DialogContent className="max-w-md overflow-hidden p-0">
<DialogTitle>Model Selector</DialogTitle>
<Command>
<CommandInput placeholder={t("providerSelection.searchModels", { defaultValue: "Search models..." })} />
<CommandList className="max-h-[350px]">
<CommandEmpty>
{t("providerSelection.noModelsFound", { defaultValue: "No models found." })}
</CommandEmpty>
{PROVIDER_GROUPS.map((group) => (
<CommandGroup
key={group.id}
heading={
<span className="flex items-center gap-1.5">
<SessionProviderLogo provider={group.id} className="h-3.5 w-3.5 shrink-0" />
{group.name}
</span>
}
>
<Check className="h-2.5 w-2.5" strokeWidth={3} />
</div>
)}
</button>
);
})}
</div>
{group.models.map((model) => {
const isSelected =
provider === group.id && currentModel === model.value;
return (
<CommandItem
key={`${group.id}-${model.value}`}
value={`${group.name} ${model.label}`}
onSelect={() => handleModelSelect(group.id, model.value)}
>
<span className="flex-1 truncate">{model.label}</span>
{isSelected && (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
)}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogContent>
</Dialog>
{/* Model picker — appears after provider is chosen */}
<div
className={`transition-all duration-200 ${provider ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0"}`}
>
<div className="mb-5 flex items-center justify-center gap-2">
<span className="text-sm text-muted-foreground">
{t("providerSelection.selectModel")}
</span>
<div className="relative">
<select
value={currentModel}
onChange={(e) => handleModelChange(e.target.value)}
tabIndex={-1}
className="cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{modelConfig.OPTIONS.map(
({ value, label }: { value: string; label: string }) => (
<option key={value + label} value={value}>
{label}
</option>
),
)}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
<p className="text-center text-sm text-muted-foreground/70">
{/* Ready prompt */}
<p className="mt-4 text-center text-sm text-muted-foreground/70">
{
{
{
claude: t("providerSelection.readyPrompt.claude", {
model: claudeModel,
}),
cursor: t("providerSelection.readyPrompt.cursor", {
model: cursorModel,
}),
codex: t("providerSelection.readyPrompt.codex", {
model: codexModel,
}),
gemini: t("providerSelection.readyPrompt.gemini", {
model: geminiModel,
}),
}[provider]
}
</p>
</div>
claude: t("providerSelection.readyPrompt.claude", {
model: claudeModel,
}),
cursor: t("providerSelection.readyPrompt.cursor", {
model: cursorModel,
}),
codex: t("providerSelection.readyPrompt.codex", {
model: codexModel,
}),
gemini: t("providerSelection.readyPrompt.gemini", {
model: geminiModel,
}),
}[provider]
}
</p>
{/* Task banner */}
{provider && tasksEnabled && isTaskMasterInstalled && (

View File

@@ -1,58 +0,0 @@
import type { McpFormState, McpProvider, McpScope, McpTransport } from './types';
export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
claude: 'Claude',
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
};
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
claude: ['user', 'project', 'local'],
cursor: ['user', 'project'],
codex: ['user', 'project'],
gemini: ['user', 'project'],
};
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
claude: ['stdio', 'http', 'sse'],
cursor: ['stdio', 'http'],
codex: ['stdio', 'http'],
gemini: ['stdio', 'http', 'sse'],
};
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
claude: 'bg-purple-600 text-white hover:bg-purple-700',
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
};
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
claude: false,
cursor: false,
codex: true,
gemini: true,
};
export const DEFAULT_MCP_FORM: McpFormState = {
name: '',
scope: 'user',
workspacePath: '',
transport: 'stdio',
command: '',
args: [],
env: {},
cwd: '',
url: '',
headers: {},
envVars: [],
bearerTokenEnvVar: '',
envHttpHeaders: {},
importMode: 'form',
jsonInput: '',
};

View File

@@ -1,248 +0,0 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DEFAULT_MCP_FORM, MCP_SUPPORTED_SCOPES, MCP_SUPPORTED_TRANSPORTS } from '../constants';
import type { McpFormState, McpProject, McpProvider, McpScope, McpTransport, ProviderMcpServer } from '../types';
import {
formatKeyValueLines,
getErrorMessage,
getProjectPath,
isMcpTransport,
parseKeyValueLines,
parseListLines,
} from '../utils/mcpFormatting';
type UseMcpServerFormArgs = {
provider: McpProvider;
isOpen: boolean;
editingServer: ProviderMcpServer | null;
currentProjects: McpProject[];
supportedScopes?: McpScope[];
supportedTransports?: McpTransport[];
unsupportedTransportMessage?: (transport: McpTransport) => string;
onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise<void>;
};
type MultilineFieldText = {
args: string;
env: string;
headers: string;
envVars: string;
envHttpHeaders: string;
};
const cloneDefaultForm = (
provider: McpProvider,
supportedScopes = MCP_SUPPORTED_SCOPES[provider],
supportedTransports = MCP_SUPPORTED_TRANSPORTS[provider],
): McpFormState => ({
...DEFAULT_MCP_FORM,
scope: supportedScopes[0],
transport: supportedTransports[0],
args: [],
env: {},
headers: {},
envVars: [],
envHttpHeaders: {},
});
const createFormStateFromServer = (
provider: McpProvider,
server: ProviderMcpServer,
supportedScopes?: McpScope[],
supportedTransports?: McpTransport[],
): McpFormState => ({
...cloneDefaultForm(provider, supportedScopes, supportedTransports),
name: server.name,
scope: server.scope,
workspacePath: server.workspacePath || '',
transport: server.transport,
command: server.command || '',
args: server.args || [],
env: server.env || {},
cwd: server.cwd || '',
url: server.url || '',
headers: server.headers || {},
envVars: server.envVars || [],
bearerTokenEnvVar: server.bearerTokenEnvVar || '',
envHttpHeaders: server.envHttpHeaders || {},
});
const createMultilineTextFromForm = (formData: McpFormState): MultilineFieldText => ({
args: formData.args.join('\n'),
env: formatKeyValueLines(formData.env),
headers: formatKeyValueLines(formData.headers),
envVars: formData.envVars.join('\n'),
envHttpHeaders: formatKeyValueLines(formData.envHttpHeaders),
});
const normalizeScope = (supportedScopes: McpScope[], value: McpScope): McpScope => (
supportedScopes.includes(value) ? value : supportedScopes[0]
);
const normalizeTransport = (supportedTransports: McpTransport[], value: McpTransport): McpTransport => (
supportedTransports.includes(value) ? value : supportedTransports[0]
);
export function useMcpServerForm({
provider,
isOpen,
editingServer,
currentProjects,
supportedScopes = MCP_SUPPORTED_SCOPES[provider],
supportedTransports = MCP_SUPPORTED_TRANSPORTS[provider],
unsupportedTransportMessage,
onSubmit,
}: UseMcpServerFormArgs) {
const { t } = useTranslation('settings');
const [formData, setFormData] = useState<McpFormState>(() => (
cloneDefaultForm(provider, supportedScopes, supportedTransports)
));
const [multilineText, setMultilineText] = useState<MultilineFieldText>(() => (
createMultilineTextFromForm(cloneDefaultForm(provider, supportedScopes, supportedTransports))
));
const [jsonValidationError, setJsonValidationError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditing = Boolean(editingServer);
useEffect(() => {
if (!isOpen) {
return;
}
setJsonValidationError('');
if (editingServer) {
const nextFormData = createFormStateFromServer(provider, editingServer, supportedScopes, supportedTransports);
setFormData(nextFormData);
setMultilineText(createMultilineTextFromForm(nextFormData));
return;
}
const nextFormData = cloneDefaultForm(provider, supportedScopes, supportedTransports);
setFormData(nextFormData);
setMultilineText(createMultilineTextFromForm(nextFormData));
}, [editingServer, isOpen, provider, supportedScopes, supportedTransports]);
const projectOptions = useMemo(() => (
currentProjects
.map((project) => ({
value: getProjectPath(project),
label: project.displayName || project.name,
}))
.filter((project) => project.value)
), [currentProjects]);
const updateForm = <K extends keyof McpFormState>(key: K, value: McpFormState[K]) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const updateScope = (scope: McpScope) => {
setFormData((prev) => ({
...prev,
scope: normalizeScope(supportedScopes, scope),
workspacePath: scope === 'user' ? '' : prev.workspacePath,
}));
};
const updateTransport = (transport: McpTransport) => {
setFormData((prev) => ({ ...prev, transport: normalizeTransport(supportedTransports, transport) }));
};
const validateJsonInput = (value: string) => {
if (!value.trim()) {
setJsonValidationError('');
return;
}
try {
const parsed = JSON.parse(value) as { type?: unknown; transport?: unknown; command?: unknown; url?: unknown };
const transportInput = parsed.transport || parsed.type;
if (!isMcpTransport(transportInput)) {
setJsonValidationError(t('mcpForm.validation.missingType'));
} else if (!supportedTransports.includes(transportInput)) {
setJsonValidationError(
unsupportedTransportMessage?.(transportInput) ?? `${provider} does not support ${transportInput} MCP servers`,
);
} else if (transportInput === 'stdio' && !parsed.command) {
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
} else if ((transportInput === 'http' || transportInput === 'sse') && !parsed.url) {
setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: transportInput }));
} else {
setJsonValidationError('');
}
} catch {
setJsonValidationError(t('mcpForm.validation.invalidJson'));
}
};
const updateJsonInput = (value: string) => {
setFormData((prev) => ({ ...prev, jsonInput: value }));
validateJsonInput(value);
};
const updateMultilineText = <K extends keyof MultilineFieldText>(key: K, value: MultilineFieldText[K]) => {
setMultilineText((prev) => ({ ...prev, [key]: value }));
};
const createSubmitFormData = (): McpFormState => ({
...formData,
args: parseListLines(multilineText.args),
env: parseKeyValueLines(multilineText.env),
headers: parseKeyValueLines(multilineText.headers),
envVars: parseListLines(multilineText.envVars),
envHttpHeaders: parseKeyValueLines(multilineText.envHttpHeaders),
});
const canSubmit = useMemo(() => {
if (!formData.name.trim()) {
return false;
}
if (formData.scope !== 'user' && !formData.workspacePath.trim()) {
return false;
}
if (formData.importMode === 'json') {
return Boolean(formData.jsonInput.trim()) && !jsonValidationError;
}
if (formData.transport === 'stdio') {
return Boolean(formData.command.trim());
}
return Boolean(formData.url.trim());
}, [formData, jsonValidationError]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
try {
// Textareas keep raw strings while editing so users can create blank
// lines or partial KEY=value entries without the form rewriting them.
await onSubmit(createSubmitFormData(), editingServer);
} catch (error) {
alert(`Error: ${getErrorMessage(error)}`);
} finally {
setIsSubmitting(false);
}
};
return {
formData,
setFormData,
multilineText,
projectOptions,
isEditing,
isSubmitting,
jsonValidationError,
canSubmit,
updateForm,
updateScope,
updateTransport,
updateJsonInput,
updateMultilineText,
handleSubmit,
};
}

View File

@@ -1,535 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { MCP_GLOBAL_SUPPORTED_TRANSPORTS, MCP_PROVIDER_NAMES, MCP_SUPPORTED_SCOPES } from '../constants';
import type {
ApiResponse,
GlobalMcpServerResult,
McpFormState,
McpProject,
McpProvider,
McpScope,
McpTransport,
ProviderMcpServer,
UpsertProviderMcpServerPayload,
} from '../types';
import {
createMcpPayloadFromForm,
getErrorMessage,
getProjectPath,
isMcpScope,
isMcpTransport,
} from '../utils/mcpFormatting';
type ProviderMcpServerResponse = {
provider: McpProvider;
scope: McpScope;
servers: Array<Partial<ProviderMcpServer>>;
};
type GlobalMcpServerResponse = {
results: GlobalMcpServerResult[];
};
type ProjectTarget = {
name: string;
displayName: string;
path: string;
};
type McpServersCacheEntry = {
servers: ProviderMcpServer[];
updatedAt: number;
};
type ScopedProjectRequest = {
scope: McpScope;
project: ProjectTarget;
};
const MCP_CACHE_TTL_MS = 30_000;
const mcpServersCache = new Map<string, McpServersCacheEntry>();
// Settings users often switch between provider tabs repeatedly. A short module
// cache prevents those tab switches from refetching every project config file.
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const getApiErrorMessage = (payload: unknown, fallback: string): string => {
if (!payload || typeof payload !== 'object') {
return fallback;
}
const record = payload as Record<string, unknown>;
const error = record.error;
if (error && typeof error === 'object') {
const message = (error as Record<string, unknown>).message;
if (typeof message === 'string' && message.trim()) {
return message;
}
}
if (typeof error === 'string' && error.trim()) {
return error;
}
const details = record.details;
if (typeof details === 'string' && details.trim()) {
return details;
}
return fallback;
};
const normalizeTransport = (value: unknown, fallback: McpTransport = 'stdio'): McpTransport => (
isMcpTransport(value) ? value : fallback
);
const normalizeScope = (value: unknown, fallback: McpScope): McpScope => (
isMcpScope(value) ? value : fallback
);
const normalizeServer = (
provider: McpProvider,
scope: McpScope,
server: Partial<ProviderMcpServer>,
project?: ProjectTarget,
): ProviderMcpServer => {
const transport = normalizeTransport(server.transport, server.url ? 'http' : 'stdio');
return {
provider,
name: String(server.name ?? ''),
scope: normalizeScope(server.scope, scope),
transport,
command: server.command,
args: server.args ?? [],
env: server.env ?? {},
cwd: server.cwd,
url: server.url,
headers: server.headers ?? {},
envVars: server.envVars ?? [],
bearerTokenEnvVar: server.bearerTokenEnvVar,
envHttpHeaders: server.envHttpHeaders ?? {},
workspacePath: project?.path || server.workspacePath,
projectName: project?.name || server.projectName,
projectDisplayName: project?.displayName || server.projectDisplayName,
};
};
const createProjectTargets = (projects: McpProject[]): ProjectTarget[] => {
const seen = new Set<string>();
return projects.reduce<ProjectTarget[]>((acc, project) => {
const projectPath = getProjectPath(project);
if (!projectPath || seen.has(projectPath)) {
return acc;
}
seen.add(projectPath);
acc.push({
name: project.name,
displayName: project.displayName || project.name,
path: projectPath,
});
return acc;
}, []);
};
const fetchProviderScopeServers = async (
provider: McpProvider,
scope: McpScope,
project?: ProjectTarget,
): Promise<ProviderMcpServer[]> => {
const params = new URLSearchParams({ scope });
if (project?.path) {
params.set('workspacePath', project.path);
}
const response = await authenticatedFetch(`/api/providers/${provider}/mcp/servers?${params.toString()}`);
const data = await toResponseJson<ApiResponse<ProviderMcpServerResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, `Failed to load ${provider} MCP servers`));
}
return (data.data.servers || []).map((server) => normalizeServer(provider, scope, server, project));
};
const deleteProviderServer = async (
provider: McpProvider,
server: ProviderMcpServer,
): Promise<void> => {
const params = new URLSearchParams({ scope: server.scope });
if (server.workspacePath) {
params.set('workspacePath', server.workspacePath);
}
const response = await authenticatedFetch(
`/api/providers/${provider}/mcp/servers/${encodeURIComponent(server.name)}?${params.toString()}`,
{ method: 'DELETE' },
);
const data = await toResponseJson<ApiResponse<{ removed: boolean }>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to delete MCP server'));
}
};
const saveProviderServer = async (
provider: McpProvider,
payload: UpsertProviderMcpServerPayload,
): Promise<void> => {
const response = await authenticatedFetch(`/api/providers/${provider}/mcp/servers`, {
method: 'POST',
body: JSON.stringify(payload),
});
const data = await toResponseJson<ApiResponse<{ server: ProviderMcpServer }>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to save MCP server'));
}
};
const saveGlobalServer = async (
payload: UpsertProviderMcpServerPayload,
): Promise<GlobalMcpServerResult[]> => {
const response = await authenticatedFetch('/api/providers/mcp/servers/global', {
method: 'POST',
body: JSON.stringify(payload),
});
const data = await toResponseJson<ApiResponse<GlobalMcpServerResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to save MCP server to all providers'));
}
return data.data.results || [];
};
const didServerIdentityChange = (
editingServer: ProviderMcpServer,
payload: UpsertProviderMcpServerPayload,
): boolean => (
editingServer.name !== payload.name
|| editingServer.scope !== payload.scope
|| (editingServer.workspacePath || '') !== (payload.workspacePath || '')
);
const getServerIdentity = (server: ProviderMcpServer): string => (
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
);
const getCacheKey = (provider: McpProvider, projects: ProjectTarget[]): string => {
const projectKey = projects.map((project) => project.path).sort().join('|');
return `${provider}:${projectKey}`;
};
const formatGlobalAddFailures = (failures: GlobalMcpServerResult[]): string => (
failures
.map((failure) => `${MCP_PROVIDER_NAMES[failure.provider]}: ${failure.error || 'Unknown error'}`)
.join('; ')
);
const sortServers = (servers: ProviderMcpServer[]): ProviderMcpServer[] => {
const scopeOrder: Record<McpScope, number> = {
user: 0,
project: 1,
local: 2,
};
return [...servers].sort((left, right) => {
const scopeDelta = scopeOrder[left.scope] - scopeOrder[right.scope];
if (scopeDelta !== 0) {
return scopeDelta;
}
const projectDelta = (left.projectDisplayName || '').localeCompare(right.projectDisplayName || '');
if (projectDelta !== 0) {
return projectDelta;
}
return left.name.localeCompare(right.name);
});
};
const mergeServers = (
existingServers: ProviderMcpServer[],
incomingServers: ProviderMcpServer[],
): ProviderMcpServer[] => {
const serversById = new Map<string, ProviderMcpServer>();
existingServers.forEach((server) => {
serversById.set(getServerIdentity(server), server);
});
incomingServers.forEach((server) => {
serversById.set(getServerIdentity(server), server);
});
return sortServers([...serversById.values()]);
};
const replaceScopedServers = (
existingServers: ProviderMcpServer[],
incomingServers: ProviderMcpServer[],
scope: McpScope,
workspacePath?: string,
): ProviderMcpServer[] => {
const remainingServers = existingServers.filter((server) => (
server.scope !== scope || (server.workspacePath || '') !== (workspacePath || '')
));
return mergeServers(remainingServers, incomingServers);
};
type UseMcpServersArgs = {
selectedProvider: McpProvider;
currentProjects: McpProject[];
};
export function useMcpServers({ selectedProvider, currentProjects }: UseMcpServersArgs) {
const [servers, setServers] = useState<ProviderMcpServer[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
const [isFormOpen, setIsFormOpen] = useState(false);
const [isGlobalFormOpen, setIsGlobalFormOpen] = useState(false);
const [editingServer, setEditingServer] = useState<ProviderMcpServer | null>(null);
const activeLoadIdRef = useRef(0);
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
const cacheKey = useMemo(() => getCacheKey(selectedProvider, projectTargets), [projectTargets, selectedProvider]);
const refreshServers = useCallback(async (options: { force?: boolean } = {}) => {
const loadId = activeLoadIdRef.current + 1;
activeLoadIdRef.current = loadId;
const cachedEntry = mcpServersCache.get(cacheKey);
const canUseCache = !options.force && cachedEntry && Date.now() - cachedEntry.updatedAt < MCP_CACHE_TTL_MS;
if (canUseCache) {
setServers(cachedEntry.servers);
setIsLoading(false);
setIsLoadingProjectScopes(false);
setLoadError(null);
return;
}
if (cachedEntry && !options.force) {
setServers(cachedEntry.servers);
} else {
setServers([]);
}
setIsLoading(!cachedEntry);
setIsLoadingProjectScopes(false);
setLoadError(null);
const supportedScopes = MCP_SUPPORTED_SCOPES[selectedProvider];
let nextServers: ProviderMcpServer[] = cachedEntry && !options.force ? cachedEntry.servers : [];
let firstError: string | null = null;
// Load the global/user scope first so the visible list can paint quickly.
// Project and local scopes can involve many project config files, so they
// are appended below as background requests instead of blocking this render.
if (supportedScopes.includes('user')) {
try {
const userServers = await fetchProviderScopeServers(selectedProvider, 'user');
if (activeLoadIdRef.current !== loadId) {
return;
}
nextServers = replaceScopedServers(nextServers, userServers, 'user');
setServers(sortServers(nextServers));
} catch (error) {
firstError = getErrorMessage(error);
}
}
if (activeLoadIdRef.current !== loadId) {
return;
}
setIsLoading(false);
const projectScopeRequests: ScopedProjectRequest[] = [];
projectTargets.forEach((project) => {
if (supportedScopes.includes('project')) {
projectScopeRequests.push({ scope: 'project', project });
}
if (supportedScopes.includes('local')) {
projectScopeRequests.push({ scope: 'local', project });
}
});
if (projectScopeRequests.length === 0) {
const finalServers = sortServers(nextServers);
mcpServersCache.set(cacheKey, { servers: finalServers, updatedAt: Date.now() });
setLoadError(firstError);
return;
}
setIsLoadingProjectScopes(true);
// Update the UI as each project scope resolves. This avoids waiting for the
// slowest project before showing servers from faster config files.
await Promise.all(projectScopeRequests.map(async ({ scope, project }) => {
try {
const scopedServers = await fetchProviderScopeServers(selectedProvider, scope, project);
if (activeLoadIdRef.current !== loadId) {
return;
}
nextServers = replaceScopedServers(nextServers, scopedServers, scope, project.path);
setServers(nextServers);
} catch (error) {
firstError = firstError || getErrorMessage(error);
}
}));
if (activeLoadIdRef.current !== loadId) {
return;
}
const finalServers = sortServers(nextServers);
mcpServersCache.set(cacheKey, { servers: finalServers, updatedAt: Date.now() });
setServers(finalServers);
setLoadError(firstError);
setIsLoadingProjectScopes(false);
}, [cacheKey, projectTargets, selectedProvider]);
const openForm = useCallback((server?: ProviderMcpServer) => {
setEditingServer(server || null);
setIsFormOpen(true);
}, []);
const closeForm = useCallback(() => {
setIsFormOpen(false);
setEditingServer(null);
}, []);
const openGlobalForm = useCallback(() => {
setIsGlobalFormOpen(true);
}, []);
const closeGlobalForm = useCallback(() => {
setIsGlobalFormOpen(false);
}, []);
const submitForm = useCallback(
async (formData: McpFormState, serverBeingEdited: ProviderMcpServer | null) => {
const payload = createMcpPayloadFromForm(selectedProvider, formData);
if (payload.scope !== 'user' && !payload.workspacePath) {
throw new Error('Select a project for project-scoped MCP servers');
}
await saveProviderServer(selectedProvider, payload);
if (serverBeingEdited && didServerIdentityChange(serverBeingEdited, payload)) {
await deleteProviderServer(selectedProvider, serverBeingEdited);
}
mcpServersCache.delete(cacheKey);
await refreshServers({ force: true });
setSaveStatus('success');
closeForm();
},
[cacheKey, closeForm, refreshServers, selectedProvider],
);
const submitGlobalForm = useCallback(
async (formData: McpFormState) => {
const payload = createMcpPayloadFromForm(selectedProvider, formData, {
supportedTransports: MCP_GLOBAL_SUPPORTED_TRANSPORTS,
supportsWorkingDirectory: false,
includeProviderSpecificFields: false,
unsupportedTransportMessage: (transport) =>
`Add MCP Server supports only stdio and http across all providers, not ${transport}.`,
});
if (payload.scope === 'local') {
throw new Error('Add MCP Server supports only user or project scope across all providers.');
}
if (payload.scope !== 'user' && !payload.workspacePath) {
throw new Error('Select a project for project-scoped MCP servers');
}
// The global endpoint updates every provider, so clear every provider
// cache entry instead of only the currently visible provider tab.
const results = await saveGlobalServer(payload);
mcpServersCache.clear();
await refreshServers({ force: true });
const failures = results.filter((result) => !result.created);
if (failures.length > 0) {
setSaveStatus('error');
throw new Error(`Failed to add MCP server to all providers. ${formatGlobalAddFailures(failures)}`);
}
setSaveStatus('success');
closeGlobalForm();
},
[closeGlobalForm, refreshServers, selectedProvider],
);
const deleteServer = useCallback(
async (server: ProviderMcpServer) => {
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
return;
}
setDeleteError(null);
try {
await deleteProviderServer(selectedProvider, server);
mcpServersCache.delete(cacheKey);
await refreshServers({ force: true });
setSaveStatus('success');
} catch (error) {
setDeleteError(getErrorMessage(error));
setSaveStatus('error');
}
},
[cacheKey, refreshServers, selectedProvider],
);
useEffect(() => {
void refreshServers();
}, [refreshServers]);
useEffect(() => {
setIsFormOpen(false);
setIsGlobalFormOpen(false);
setEditingServer(null);
setDeleteError(null);
setSaveStatus(null);
}, [selectedProvider]);
useEffect(() => {
if (saveStatus === null) {
return;
}
const timer = window.setTimeout(() => setSaveStatus(null), 2000);
return () => window.clearTimeout(timer);
}, [saveStatus]);
return {
servers,
isLoading,
isLoadingProjectScopes,
loadError,
deleteError,
saveStatus,
isFormOpen,
isGlobalFormOpen,
editingServer,
openForm,
openGlobalForm,
closeForm,
closeGlobalForm,
submitForm,
submitGlobalForm,
deleteServer,
refreshServers,
};
}

View File

@@ -1 +0,0 @@
export { default as McpServers } from './view/McpServers';

View File

@@ -1,90 +0,0 @@
import type { LLMProvider } from '../../types/app';
export type McpProvider = LLMProvider;
export type McpScope = 'user' | 'local' | 'project';
export type McpTransport = 'stdio' | 'http' | 'sse';
export type McpImportMode = 'form' | 'json';
export type McpFormMode = 'provider' | 'global';
export type KeyValueMap = Record<string, string>;
export type McpProject = {
name: string;
displayName?: string;
fullPath?: string;
path?: string;
};
export type ProviderMcpServer = {
provider: McpProvider;
name: string;
scope: McpScope;
transport: McpTransport;
command?: string;
args?: string[];
env?: KeyValueMap;
cwd?: string;
url?: string;
headers?: KeyValueMap;
envVars?: string[];
bearerTokenEnvVar?: string;
envHttpHeaders?: KeyValueMap;
workspacePath?: string;
projectName?: string;
projectDisplayName?: string;
};
export type McpFormState = {
name: string;
scope: McpScope;
workspacePath: string;
transport: McpTransport;
command: string;
args: string[];
env: KeyValueMap;
cwd: string;
url: string;
headers: KeyValueMap;
envVars: string[];
bearerTokenEnvVar: string;
envHttpHeaders: KeyValueMap;
importMode: McpImportMode;
jsonInput: string;
};
export type UpsertProviderMcpServerPayload = {
name: string;
scope: McpScope;
transport: McpTransport;
workspacePath?: string;
command?: string;
args?: string[];
env?: KeyValueMap;
cwd?: string;
url?: string;
headers?: KeyValueMap;
envVars?: string[];
bearerTokenEnvVar?: string;
envHttpHeaders?: KeyValueMap;
};
export type GlobalMcpServerResult = {
provider: McpProvider;
created: boolean;
error?: string;
};
export type ApiSuccessResponse<T> = {
success: true;
data: T;
};
export type ApiErrorResponse = {
success: false;
error?: {
code?: string;
message?: string;
details?: unknown;
};
};
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

View File

@@ -1,184 +0,0 @@
import { MCP_SUPPORTED_TRANSPORTS, MCP_SUPPORTS_WORKING_DIRECTORY } from '../constants';
import type {
KeyValueMap,
McpFormState,
McpProvider,
McpScope,
McpTransport,
UpsertProviderMcpServerPayload,
} from '../types';
type CreateMcpPayloadOptions = {
supportedTransports?: McpTransport[];
supportsWorkingDirectory?: boolean;
includeProviderSpecificFields?: boolean;
unsupportedTransportMessage?: (transport: McpTransport) => string;
};
const isRecord = (value: unknown): value is Record<string, unknown> => (
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
);
const readString = (value: unknown): string | undefined => (
typeof value === 'string' && value.trim() ? value.trim() : undefined
);
const readStringArray = (value: unknown): string[] | undefined => (
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : undefined
);
const readStringRecord = (value: unknown): KeyValueMap | undefined => {
if (!isRecord(value)) {
return undefined;
}
const normalized: KeyValueMap = {};
Object.entries(value).forEach(([key, entry]) => {
if (typeof entry === 'string') {
normalized[key] = entry;
}
});
return Object.keys(normalized).length > 0 ? normalized : undefined;
};
export const formatKeyValueLines = (value: KeyValueMap): string => (
Object.entries(value).map(([key, entry]) => `${key}=${entry}`).join('\n')
);
export const parseKeyValueLines = (value: string): KeyValueMap => {
const normalized: KeyValueMap = {};
value.split('\n').forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key?.trim()) {
normalized[key.trim()] = valueParts.join('=').trim();
}
});
return normalized;
};
export const parseListLines = (value: string): string[] => (
value.split('\n').map((entry) => entry.trim()).filter(Boolean)
);
export const maskSecret = (value: unknown): string => {
const normalizedValue = String(value ?? '');
if (normalizedValue.length <= 4) {
return '****';
}
return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
};
export const isMcpScope = (value: unknown): value is McpScope => (
value === 'user' || value === 'local' || value === 'project'
);
export const isMcpTransport = (value: unknown): value is McpTransport => (
value === 'stdio' || value === 'http' || value === 'sse'
);
export const getProjectPath = (project: { fullPath?: string; path?: string }): string => (
project.fullPath || project.path || ''
);
export const getErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : 'Unknown error'
);
const assertSupportedTransport = (
provider: McpProvider,
transport: McpTransport,
options?: CreateMcpPayloadOptions,
) => {
const supportedTransports = options?.supportedTransports ?? MCP_SUPPORTED_TRANSPORTS[provider];
if (supportedTransports.includes(transport)) {
return;
}
throw new Error(
options?.unsupportedTransportMessage?.(transport) ?? `${provider} does not support ${transport} MCP servers`,
);
};
export const parseJsonMcpPayload = (
provider: McpProvider,
formData: McpFormState,
options?: CreateMcpPayloadOptions,
): UpsertProviderMcpServerPayload => {
const parsed = JSON.parse(formData.jsonInput) as unknown;
if (!isRecord(parsed)) {
throw new Error('JSON configuration must be an object');
}
const transportInput = readString(parsed.transport) ?? readString(parsed.type);
const transport = isMcpTransport(transportInput) ? transportInput : undefined;
if (!transport) {
throw new Error('Missing required field: type');
}
assertSupportedTransport(provider, transport, options);
if (transport === 'stdio' && !readString(parsed.command)) {
throw new Error('stdio type requires a command field');
}
if ((transport === 'http' || transport === 'sse') && !readString(parsed.url)) {
throw new Error(`${transport} type requires a url field`);
}
return {
name: formData.name.trim(),
scope: formData.scope,
workspacePath: formData.scope === 'user' ? undefined : formData.workspacePath,
transport,
command: readString(parsed.command),
args: readStringArray(parsed.args) ?? [],
env: readStringRecord(parsed.env) ?? {},
cwd: (options?.supportsWorkingDirectory ?? MCP_SUPPORTS_WORKING_DIRECTORY[provider])
? readString(parsed.cwd)
: undefined,
url: readString(parsed.url),
headers: readStringRecord(parsed.headers ?? parsed.http_headers) ?? {},
envVars: (options?.includeProviderSpecificFields ?? provider === 'codex')
? readStringArray(parsed.envVars ?? parsed.env_vars) ?? []
: undefined,
bearerTokenEnvVar: (options?.includeProviderSpecificFields ?? provider === 'codex')
? readString(parsed.bearerTokenEnvVar ?? parsed.bearer_token_env_var)
: undefined,
envHttpHeaders: (options?.includeProviderSpecificFields ?? provider === 'codex')
? readStringRecord(parsed.envHttpHeaders ?? parsed.env_http_headers) ?? {}
: undefined,
};
};
export const createMcpPayloadFromForm = (
provider: McpProvider,
formData: McpFormState,
options?: CreateMcpPayloadOptions,
): UpsertProviderMcpServerPayload => {
if (formData.importMode === 'json') {
return parseJsonMcpPayload(provider, formData, options);
}
assertSupportedTransport(provider, formData.transport, options);
const supportsWorkingDirectory = options?.supportsWorkingDirectory ?? MCP_SUPPORTS_WORKING_DIRECTORY[provider];
const includeProviderSpecificFields = options?.includeProviderSpecificFields ?? provider === 'codex';
return {
name: formData.name.trim(),
scope: formData.scope,
workspacePath: formData.scope === 'user' ? undefined : formData.workspacePath,
transport: formData.transport,
command: formData.transport === 'stdio' ? formData.command.trim() : undefined,
args: formData.transport === 'stdio' ? formData.args : undefined,
env: formData.env,
cwd: supportsWorkingDirectory ? formData.cwd.trim() || undefined : undefined,
url: formData.transport !== 'stdio' ? formData.url.trim() : undefined,
headers: formData.transport !== 'stdio' ? formData.headers : undefined,
envVars: includeProviderSpecificFields ? formData.envVars : undefined,
bearerTokenEnvVar: includeProviderSpecificFields ? formData.bearerTokenEnvVar.trim() || undefined : undefined,
envHttpHeaders: includeProviderSpecificFields ? formData.envHttpHeaders : undefined,
};
};

View File

@@ -1,281 +0,0 @@
import { Edit3, ExternalLink, Globe, Lock, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types';
import { IS_PLATFORM } from '../../../constants/config';
import { Badge, Button } from '../../../shared/view/ui';
import {
MCP_GLOBAL_SUPPORTED_SCOPES,
MCP_GLOBAL_SUPPORTED_TRANSPORTS,
MCP_PROVIDER_BUTTON_CLASSES,
MCP_PROVIDER_NAMES,
} from '../constants';
import { useMcpServers } from '../hooks/useMcpServers';
import { maskSecret } from '../utils/mcpFormatting';
import McpServerFormModal from './modals/McpServerFormModal';
type McpServersProps = {
selectedProvider: McpProvider;
currentProjects: McpProject[];
};
const getTransportIcon = (transport: string | undefined) => {
if (transport === 'stdio') {
return <Terminal className="h-4 w-4" />;
}
if (transport === 'sse') {
return <Zap className="h-4 w-4" />;
}
if (transport === 'http') {
return <Globe className="h-4 w-4" />;
}
return <Server className="h-4 w-4" />;
};
const getScopeLabel = (scope: McpScope): string => {
if (scope === 'user') {
return 'user';
}
if (scope === 'local') {
return 'local';
}
return 'project';
};
const getServerKey = (server: ProviderMcpServer): string => (
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
);
function ConfigLine({ label, children }: { label: string; children: string }) {
if (!children) {
return null;
}
return (
<div>
{label}:{' '}
<code className="rounded bg-muted px-1 text-xs">{children}</code>
</div>
);
}
function TeamMcpFeatureCard() {
return (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground">
<Users className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground">Team MCP Configs</h4>
<Lock className="h-3 w-3 text-muted-foreground/60" />
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Share MCP server configurations across your team. Everyone stays in sync automatically.
</p>
<a
href="https://cloudcli.ai"
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
>
Available with CloudCLI Pro
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
);
}
export default function McpServers({ selectedProvider, currentProjects }: McpServersProps) {
const { t } = useTranslation('settings');
const {
servers,
isLoading,
isLoadingProjectScopes,
loadError,
deleteError,
saveStatus,
isFormOpen,
isGlobalFormOpen,
editingServer,
openForm,
openGlobalForm,
closeForm,
closeGlobalForm,
submitForm,
submitGlobalForm,
deleteServer,
} = useMcpServers({ selectedProvider, currentProjects });
const providerName = MCP_PROVIDER_NAMES[selectedProvider];
const description = t(`mcpServers.description.${selectedProvider}`, {
defaultValue: `Model Context Protocol servers provide additional tools and data sources to ${providerName}`,
});
const globalButtonLabel = 'Add Global MCP Server';
const providerButtonLabel = `Add ${providerName} MCP Server`;
const globalAddDescription = 'Add Global MCP Server writes one common stdio or HTTP server to Claude, Cursor, Codex, and Gemini.';
const providerAddDescription = `${providerButtonLabel} only changes ${providerName}.`;
const globalModalDescription = 'Adds this MCP server to every provider: Claude, Cursor, Codex, and Gemini. '
+ 'Only stdio and HTTP transports are supported because the same config must work across all providers.';
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{description}</p>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={openGlobalForm}
className={MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}
size="sm"
title={globalAddDescription}
>
<Plus className="mr-2 h-4 w-4" />
{globalButtonLabel}
</Button>
<Button
onClick={() => openForm()}
variant="outline"
size="sm"
title={providerAddDescription}
>
<Plus className="mr-2 h-4 w-4" />
{providerButtonLabel}
</Button>
</div>
<div className="min-h-4">
{saveStatus === 'success' && (
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>
)}
{isLoadingProjectScopes && (
<span className="animate-in fade-in text-xs text-muted-foreground">Refreshing project scopes...</span>
)}
</div>
</div>
{(loadError || deleteError) && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{deleteError || loadError}
</div>
)}
<div className="space-y-2">
{isLoading && servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
)}
{servers.map((server) => (
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
{getTransportIcon(server.transport)}
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">
{server.transport || 'stdio'}
</Badge>
<Badge variant="outline" className="text-xs">
{getScopeLabel(server.scope)}
</Badge>
{server.projectDisplayName && (
<Badge variant="outline" className="max-w-full truncate text-xs">
{server.projectDisplayName}
</Badge>
)}
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
{server.env && Object.keys(server.env).length > 0 && (
<ConfigLine label={t('mcpServers.config.environment')}>
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
</ConfigLine>
)}
{server.envVars && server.envVars.length > 0 && (
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => openForm(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => deleteServer(server)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
{selectedProvider === 'codex' && (
<div className="rounded-lg border border-border bg-muted/50 p-4">
<h4 className="mb-2 font-medium text-foreground">{t('mcpServers.help.title')}</h4>
<p className="text-sm text-muted-foreground">{t('mcpServers.help.description')}</p>
</div>
)}
{selectedProvider === 'claude' && !IS_PLATFORM && <TeamMcpFeatureCard />}
<McpServerFormModal
provider={selectedProvider}
isOpen={isFormOpen}
editingServer={editingServer}
currentProjects={currentProjects}
title={editingServer ? undefined : providerButtonLabel}
submitLabel={providerButtonLabel}
onClose={closeForm}
onSubmit={submitForm}
/>
<McpServerFormModal
provider={selectedProvider}
mode="global"
isOpen={isGlobalFormOpen}
editingServer={null}
currentProjects={currentProjects}
title={globalButtonLabel}
description={globalModalDescription}
submitLabel={globalButtonLabel}
supportedScopes={MCP_GLOBAL_SUPPORTED_SCOPES}
supportedTransports={MCP_GLOBAL_SUPPORTED_TRANSPORTS}
onClose={closeGlobalForm}
onSubmit={(formData) => submitGlobalForm(formData)}
/>
</div>
);
}

View File

@@ -1,434 +0,0 @@
import { FolderOpen, Globe, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../../shared/view/ui';
import {
MCP_PROVIDER_NAMES,
MCP_SUPPORTED_SCOPES,
MCP_SUPPORTED_TRANSPORTS,
MCP_SUPPORTS_WORKING_DIRECTORY,
} from '../../constants';
import { useMcpServerForm } from '../../hooks/useMcpServerForm';
import type {
McpFormMode,
McpFormState,
McpProject,
McpProvider,
McpScope,
McpTransport,
ProviderMcpServer,
} from '../../types';
type McpServerFormModalProps = {
provider: McpProvider;
mode?: McpFormMode;
isOpen: boolean;
editingServer: ProviderMcpServer | null;
currentProjects: McpProject[];
title?: string;
description?: string;
submitLabel?: string;
supportedScopes?: McpScope[];
supportedTransports?: McpTransport[];
onClose: () => void;
onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise<void>;
};
const getScopeLabel = (scope: McpScope, mode: McpFormMode): string => {
if (scope === 'user') {
return mode === 'global' ? 'User (All Providers)' : 'User (Global)';
}
if (scope === 'local') {
return 'Claude Local';
}
return mode === 'global' ? 'Project (All Providers)' : 'Project';
};
const getScopeDescription = (scope: McpScope, mode: McpFormMode): string => {
if (scope === 'user') {
return mode === 'global'
? 'Writes to each provider user config and is available across projects on this machine'
: 'Available across all projects on your machine';
}
if (scope === 'local') {
return 'Stored in Claude user settings for the selected project';
}
return mode === 'global'
? 'Writes to the selected project workspace for every provider'
: 'Stored in the selected project workspace';
};
export default function McpServerFormModal({
provider,
mode = 'provider',
isOpen,
editingServer,
currentProjects,
title,
description,
submitLabel,
supportedScopes,
supportedTransports,
onClose,
onSubmit,
}: McpServerFormModalProps) {
const { t } = useTranslation('settings');
const isGlobalMode = mode === 'global';
const availableScopes = supportedScopes ?? MCP_SUPPORTED_SCOPES[provider];
const availableTransports = supportedTransports ?? MCP_SUPPORTED_TRANSPORTS[provider];
const {
formData,
multilineText,
projectOptions,
isEditing,
isSubmitting,
jsonValidationError,
canSubmit,
updateForm,
updateScope,
updateTransport,
updateJsonInput,
updateMultilineText,
handleSubmit,
} = useMcpServerForm({
provider,
isOpen,
editingServer,
currentProjects,
supportedScopes: availableScopes,
supportedTransports: availableTransports,
unsupportedTransportMessage: isGlobalMode
? (transport) => `Add MCP Server supports only stdio and http across all providers, not ${transport}.`
: undefined,
onSubmit,
});
if (!isOpen) {
return null;
}
const providerName = MCP_PROVIDER_NAMES[provider];
const modalTitle = title ?? (isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add'));
const addButtonLabel = submitLabel ?? `${t('mcpForm.actions.addServer')} to ${providerName}`;
const showProjectSelector = formData.scope !== 'user';
const supportsHttpHeaders = formData.transport === 'http' || formData.transport === 'sse';
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
<div className="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-4">
{description && (
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{description}
</div>
)}
{!isEditing && (
<div className="mb-4 flex gap-2">
<button
type="button"
onClick={() => updateForm('importMode', 'form')}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
formData.importMode === 'form'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{t('mcpForm.importMode.form')}
</button>
<button
type="button"
onClick={() => updateForm('importMode', 'json')}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
formData.importMode === 'json'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{t('mcpForm.importMode.json')}
</button>
</div>
)}
{isEditing && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/50">
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.scope.label')}
</label>
<div className="flex items-center gap-2">
{formData.scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
<span className="text-sm">{getScopeLabel(formData.scope, mode)}</span>
{formData.workspacePath && (
<span className="truncate text-xs text-muted-foreground">- {formData.workspacePath}</span>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">{t('mcpForm.scope.cannotChange')}</p>
</div>
)}
{!isEditing && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.scope.label')} *
</label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{availableScopes.map((scope) => (
<button
key={scope}
type="button"
onClick={() => updateScope(scope)}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
formData.scope === scope
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-center gap-2">
{scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
<span>{getScopeLabel(scope, mode)}</span>
</div>
</button>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">{getScopeDescription(formData.scope, mode)}</p>
</div>
{showProjectSelector && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.selectProject')} *
</label>
<select
value={formData.workspacePath}
onChange={(event) => updateForm('workspacePath', event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
required
>
<option value="">{t('mcpForm.fields.selectProject')}</option>
{projectOptions.map((project) => (
<option key={project.value} value={project.value}>
{project.label}
</option>
))}
</select>
{formData.workspacePath && (
<p className="mt-1 truncate text-xs text-muted-foreground">
{t('mcpForm.projectPath', { path: formData.workspacePath })}
</p>
)}
</div>
)}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className={formData.importMode === 'json' ? 'md:col-span-2' : ''}>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.serverName')} *
</label>
<Input
value={formData.name}
onChange={(event) => updateForm('name', event.target.value)}
placeholder={t('mcpForm.placeholders.serverName')}
required
/>
</div>
{formData.importMode === 'form' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.transportType')} *
</label>
<select
value={formData.transport}
onChange={(event) => updateTransport(event.target.value as McpFormState['transport'])}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
{availableTransports.map((transport) => (
<option key={transport} value={transport}>
{transport === 'sse' ? 'SSE' : transport.toUpperCase()}
</option>
))}
</select>
</div>
)}
</div>
{formData.importMode === 'json' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.jsonConfig')} *
</label>
<textarea
value={formData.jsonInput}
onChange={(event) => updateJsonInput(event.target.value)}
className={`w-full border px-3 py-2 ${
jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
} rounded-lg bg-gray-50 font-mono text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-100`}
rows={8}
placeholder={'{\n "type": "stdio",\n "command": "npx",\n "args": ["@upstash/context7-mcp"]\n}'}
required
/>
{jsonValidationError && (
<p className="mt-1 text-xs text-red-500">{jsonValidationError}</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
{t('mcpForm.validation.jsonHelp')}
<br />
- stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
<br />
- http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
</p>
</div>
)}
{formData.importMode === 'form' && formData.transport === 'stdio' && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.command')} *
</label>
<Input
value={formData.command}
onChange={(event) => updateForm('command', event.target.value)}
placeholder="npx @my-org/mcp-server"
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.arguments')}
</label>
<textarea
value={multilineText.args}
onChange={(event) => updateMultilineText('args', event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="--port&#10;3000"
/>
</div>
{supportsWorkingDirectory && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
Working Directory
</label>
<Input
value={formData.cwd}
onChange={(event) => updateForm('cwd', event.target.value)}
placeholder="."
/>
</div>
)}
</div>
)}
{formData.importMode === 'form' && formData.transport !== 'stdio' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.url')} *
</label>
<Input
value={formData.url}
onChange={(event) => updateForm('url', event.target.value)}
placeholder="https://api.example.com/mcp"
type="url"
required
/>
</div>
)}
{formData.importMode === 'form' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.envVars')}
</label>
<textarea
value={multilineText.env}
onChange={(event) => updateMultilineText('env', event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="API_KEY=your-key&#10;DEBUG=true"
/>
</div>
)}
{formData.importMode === 'form' && supportsHttpHeaders && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.headers')}
</label>
<textarea
value={multilineText.headers}
onChange={(event) => updateMultilineText('headers', event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="Authorization=Bearer token&#10;X-API-Key=your-key"
/>
</div>
)}
{showCodexOnlyFields && formData.importMode === 'form' && formData.transport === 'stdio' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
Environment Variable Names
</label>
<textarea
value={multilineText.envVars}
onChange={(event) => updateMultilineText('envVars', event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="GITHUB_TOKEN&#10;API_KEY"
/>
</div>
)}
{showCodexOnlyFields && formData.importMode === 'form' && formData.transport === 'http' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
Bearer Token Environment Variable
</label>
<Input
value={formData.bearerTokenEnvVar}
onChange={(event) => updateForm('bearerTokenEnvVar', event.target.value)}
placeholder="MCP_TOKEN"
/>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{t('mcpForm.actions.cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting || !canSubmit}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
>
{isSubmitting
? t('mcpForm.actions.saving')
: isEditing
? t('mcpForm.actions.updateServer')
: addButtonLabel}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -2,8 +2,8 @@ import { useCallback, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { LLMProvider } from '../../../types/app';
import {
CLI_AUTH_STATUS_ENDPOINTS,
CLI_PROVIDERS,
PROVIDER_AUTH_STATUS_ENDPOINTS,
createInitialProviderAuthStatusMap,
} from '../types';
import type {
@@ -69,7 +69,7 @@ export function useProviderAuthStatus(
setProviderLoading(provider);
try {
const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]);
const response = await authenticatedFetch(CLI_AUTH_STATUS_ENDPOINTS[provider]);
if (!response.ok) {
setProviderStatus(provider, {

View File

@@ -12,11 +12,11 @@ export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/providers/claude/auth/status',
cursor: '/api/providers/cursor/auth/status',
codex: '/api/providers/codex/auth/status',
gemini: '/api/providers/gemini/auth/status',
export const CLI_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/cli/claude/status',
cursor: '/api/cli/cursor/status',
codex: '/api/cli/codex/status',
gemini: '/api/cli/gemini/status',
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({

View File

@@ -1,8 +1,12 @@
import type {
AgentCategory,
AgentProvider,
ClaudeMcpFormState,
CodexMcpFormState,
CodeEditorSettingsState,
CursorPermissionsState,
McpToolsResult,
McpTestResult,
ProjectSortOrder,
SettingsMainTab,
} from '../types/types';
@@ -16,7 +20,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
'notifications',
];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
@@ -29,6 +33,47 @@ export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
fontSize: '14',
};
export const DEFAULT_MCP_TEST_RESULT: McpTestResult = {
success: false,
message: '',
details: [],
loading: false,
};
export const DEFAULT_MCP_TOOLS_RESULT: McpToolsResult = {
success: false,
tools: [],
resources: [],
prompts: [],
};
export const DEFAULT_CLAUDE_MCP_FORM: ClaudeMcpFormState = {
name: '',
type: 'stdio',
scope: 'user',
projectPath: '',
config: {
command: '',
args: [],
env: {},
url: '',
headers: {},
timeout: 30000,
},
importMode: 'form',
jsonInput: '',
};
export const DEFAULT_CODEX_MCP_FORM: CodexMcpFormState = {
name: '',
type: 'stdio',
config: {
command: '',
args: [],
env: {},
},
};
export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
allowedCommands: [],
disallowedCommands: [],

View File

@@ -8,11 +8,16 @@ import {
} from '../constants/constants';
import type {
AgentProvider,
ClaudeMcpFormState,
ClaudePermissionsState,
CodeEditorSettingsState,
CodexMcpFormState,
CodexPermissionMode,
CursorPermissionsState,
GeminiPermissionMode,
McpServer,
McpToolsResult,
McpTestResult,
NotificationPreferencesState,
ProjectSortOrder,
SettingsMainTab,
@@ -28,6 +33,41 @@ type UseSettingsControllerArgs = {
initialTab: string;
};
type JsonResult = {
success?: boolean;
error?: string;
};
type McpReadResponse = {
success?: boolean;
servers?: McpServer[];
};
type McpCliServer = {
name: string;
type?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
};
type McpCliReadResponse = {
success?: boolean;
servers?: McpCliServer[];
};
type McpTestResponse = {
testResult?: McpTestResult;
error?: string;
};
type McpToolsResponse = {
toolsResult?: McpToolsResult;
error?: string;
};
type ClaudeSettingsStorage = {
allowedTools?: string[];
disallowedTools?: string[];
@@ -63,6 +103,10 @@ const normalizeMainTab = (tab: string): SettingsMainTab => {
return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents';
};
const getErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : 'Unknown error'
);
const parseJson = <T>(value: string | null, fallback: T): T => {
if (!value) {
return fallback;
@@ -91,6 +135,25 @@ const readCodeEditorSettings = (): CodeEditorSettingsState => ({
fontSize: localStorage.getItem('codeEditorFontSize') ?? DEFAULT_CODE_EDITOR_SETTINGS.fontSize,
});
const mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] => (
servers.map((server) => ({
id: server.name,
name: server.name,
type: server.type || 'stdio',
scope: 'user',
config: {
command: server.command || '',
args: server.args || [],
env: server.env || {},
url: server.url || '',
headers: server.headers || {},
timeout: 30000,
},
created: new Date().toISOString(),
updated: new Date().toISOString(),
}))
);
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
@@ -121,6 +184,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
const [codeEditorSettings, setCodeEditorSettings] = useState<CodeEditorSettingsState>(() => (
readCodeEditorSettings()
@@ -138,6 +202,18 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
const [cursorMcpServers, setCursorMcpServers] = useState<McpServer[]>([]);
const [codexMcpServers, setCodexMcpServers] = useState<McpServer[]>([]);
const [mcpTestResults, setMcpTestResults] = useState<Record<string, McpTestResult>>({});
const [mcpServerTools, setMcpServerTools] = useState<Record<string, McpToolsResult>>({});
const [mcpToolsLoading, setMcpToolsLoading] = useState<Record<string, boolean>>({});
const [showMcpForm, setShowMcpForm] = useState(false);
const [editingMcpServer, setEditingMcpServer] = useState<McpServer | null>(null);
const [showCodexMcpForm, setShowCodexMcpForm] = useState(false);
const [editingCodexMcpServer, setEditingCodexMcpServer] = useState<McpServer | null>(null);
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
const {
@@ -146,6 +222,361 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
refreshProviderAuthStatuses,
} = useProviderAuthStatus();
const fetchCursorMcpServers = useCallback(async () => {
try {
const response = await authenticatedFetch('/api/cursor/mcp');
if (!response.ok) {
console.error('Failed to fetch Cursor MCP servers');
return;
}
const data = await toResponseJson<{ servers?: McpServer[] }>(response);
setCursorMcpServers(data.servers || []);
} catch (error) {
console.error('Error fetching Cursor MCP servers:', error);
}
}, []);
const fetchCodexMcpServers = useCallback(async () => {
try {
const configResponse = await authenticatedFetch('/api/codex/mcp/config/read');
if (configResponse.ok) {
const configData = await toResponseJson<McpReadResponse>(configResponse);
if (configData.success && configData.servers) {
setCodexMcpServers(configData.servers);
return;
}
}
const cliResponse = await authenticatedFetch('/api/codex/mcp/cli/list');
if (!cliResponse.ok) {
return;
}
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
if (!cliData.success || !cliData.servers) {
return;
}
setCodexMcpServers(mapCliServersToMcpServers(cliData.servers));
} catch (error) {
console.error('Error fetching Codex MCP servers:', error);
}
}, []);
const fetchMcpServers = useCallback(async () => {
try {
const configResponse = await authenticatedFetch('/api/mcp/config/read');
if (configResponse.ok) {
const configData = await toResponseJson<McpReadResponse>(configResponse);
if (configData.success && configData.servers) {
setMcpServers(configData.servers);
return;
}
}
const cliResponse = await authenticatedFetch('/api/mcp/cli/list');
if (cliResponse.ok) {
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
if (cliData.success && cliData.servers) {
setMcpServers(mapCliServersToMcpServers(cliData.servers));
return;
}
}
const fallbackResponse = await authenticatedFetch('/api/mcp/servers?scope=user');
if (!fallbackResponse.ok) {
console.error('Failed to fetch MCP servers');
return;
}
const fallbackData = await toResponseJson<{ servers?: McpServer[] }>(fallbackResponse);
setMcpServers(fallbackData.servers || []);
} catch (error) {
console.error('Error fetching MCP servers:', error);
}
}, []);
const deleteMcpServer = useCallback(async (serverId: string, scope = 'user') => {
const response = await authenticatedFetch(`/api/mcp/cli/remove/${serverId}?scope=${scope}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to delete server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to delete server via Claude CLI');
}
}, []);
const saveMcpServer = useCallback(
async (serverData: ClaudeMcpFormState, editingServer: McpServer | null) => {
const newServerScope = serverData.scope || 'user';
const response = await authenticatedFetch('/api/mcp/cli/add', {
method: 'POST',
body: JSON.stringify({
name: serverData.name,
type: serverData.type,
scope: newServerScope,
projectPath: serverData.projectPath,
command: serverData.config.command,
args: serverData.config.args || [],
url: serverData.config.url,
headers: serverData.config.headers || {},
env: serverData.config.env || {},
}),
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to save server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to save server via Claude CLI');
}
if (!editingServer?.id) {
return;
}
const previousServerScope = editingServer.scope || 'user';
const didServerIdentityChange =
editingServer.id !== serverData.name || previousServerScope !== newServerScope;
if (!didServerIdentityChange) {
return;
}
try {
await deleteMcpServer(editingServer.id, previousServerScope);
} catch (error) {
console.warn('Saved MCP server update but failed to remove the previous server entry.', {
previousServerId: editingServer.id,
previousServerScope,
error: getErrorMessage(error),
});
}
},
[deleteMcpServer],
);
const submitMcpForm = useCallback(
async (formData: ClaudeMcpFormState, editingServer: McpServer | null) => {
if (formData.importMode === 'json') {
const response = await authenticatedFetch('/api/mcp/cli/add-json', {
method: 'POST',
body: JSON.stringify({
name: formData.name,
jsonConfig: formData.jsonInput,
scope: formData.scope,
projectPath: formData.projectPath,
}),
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to add server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to add server via JSON');
}
} else {
await saveMcpServer(formData, editingServer);
}
await fetchMcpServers();
setSaveStatus('success');
setShowMcpForm(false);
setEditingMcpServer(null);
},
[fetchMcpServers, saveMcpServer],
);
const handleMcpDelete = useCallback(
async (serverId: string, scope = 'user') => {
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
return;
}
setDeleteError(null);
try {
await deleteMcpServer(serverId, scope);
await fetchMcpServers();
setDeleteError(null);
setSaveStatus('success');
} catch (error) {
setDeleteError(getErrorMessage(error));
setSaveStatus('error');
}
},
[deleteMcpServer, fetchMcpServers],
);
const testMcpServer = useCallback(async (serverId: string, scope = 'user') => {
const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/test?scope=${scope}`, {
method: 'POST',
});
if (!response.ok) {
const error = await toResponseJson<McpTestResponse>(response);
throw new Error(error.error || 'Failed to test server');
}
const data = await toResponseJson<McpTestResponse>(response);
return data.testResult || { success: false, message: 'No test result returned' };
}, []);
const discoverMcpTools = useCallback(async (serverId: string, scope = 'user') => {
const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/tools?scope=${scope}`, {
method: 'POST',
});
if (!response.ok) {
const error = await toResponseJson<McpToolsResponse>(response);
throw new Error(error.error || 'Failed to discover tools');
}
const data = await toResponseJson<McpToolsResponse>(response);
return data.toolsResult || { success: false, tools: [], resources: [], prompts: [] };
}, []);
const handleMcpTest = useCallback(
async (serverId: string, scope = 'user') => {
try {
setMcpTestResults((prev) => ({
...prev,
[serverId]: { success: false, message: 'Testing server...', details: [], loading: true },
}));
const result = await testMcpServer(serverId, scope);
setMcpTestResults((prev) => ({ ...prev, [serverId]: result }));
} catch (error) {
setMcpTestResults((prev) => ({
...prev,
[serverId]: {
success: false,
message: getErrorMessage(error),
details: [],
},
}));
}
},
[testMcpServer],
);
const handleMcpToolsDiscovery = useCallback(
async (serverId: string, scope = 'user') => {
try {
setMcpToolsLoading((prev) => ({ ...prev, [serverId]: true }));
const result = await discoverMcpTools(serverId, scope);
setMcpServerTools((prev) => ({ ...prev, [serverId]: result }));
} catch {
setMcpServerTools((prev) => ({
...prev,
[serverId]: { success: false, tools: [], resources: [], prompts: [] },
}));
} finally {
setMcpToolsLoading((prev) => ({ ...prev, [serverId]: false }));
}
},
[discoverMcpTools],
);
const deleteCodexMcpServer = useCallback(async (serverId: string) => {
const response = await authenticatedFetch(`/api/codex/mcp/cli/remove/${serverId}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to delete server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to delete Codex MCP server');
}
}, []);
const saveCodexMcpServer = useCallback(
async (serverData: CodexMcpFormState, editingServer: McpServer | null) => {
const response = await authenticatedFetch('/api/codex/mcp/cli/add', {
method: 'POST',
body: JSON.stringify({
name: serverData.name,
command: serverData.config.command,
args: serverData.config.args || [],
env: serverData.config.env || {},
}),
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to save server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to save Codex MCP server');
}
if (!editingServer?.name || editingServer.name === serverData.name) {
return;
}
try {
await deleteCodexMcpServer(editingServer.name);
} catch (error) {
console.warn('Saved Codex MCP server update but failed to remove the previous server entry.', {
previousServerName: editingServer.name,
error: getErrorMessage(error),
});
}
},
[deleteCodexMcpServer],
);
const submitCodexMcpForm = useCallback(
async (formData: CodexMcpFormState, editingServer: McpServer | null) => {
await saveCodexMcpServer(formData, editingServer);
await fetchCodexMcpServers();
setSaveStatus('success');
setShowCodexMcpForm(false);
setEditingCodexMcpServer(null);
},
[fetchCodexMcpServers, saveCodexMcpServer],
);
const handleCodexMcpDelete = useCallback(
async (serverName: string) => {
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
return;
}
setDeleteError(null);
try {
await deleteCodexMcpServer(serverName);
await fetchCodexMcpServers();
setDeleteError(null);
setSaveStatus('success');
} catch (error) {
setDeleteError(getErrorMessage(error));
setSaveStatus('error');
}
},
[deleteCodexMcpServer, fetchCodexMcpServers],
);
const loadSettings = useCallback(async () => {
try {
const savedClaudeSettings = parseJson<ClaudeSettingsStorage>(
@@ -197,6 +628,11 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
setNotificationPreferences(createDefaultNotificationPreferences());
}
await Promise.all([
fetchMcpServers(),
fetchCursorMcpServers(),
fetchCodexMcpServers(),
]);
} catch (error) {
console.error('Error loading settings:', error);
setClaudePermissions(createEmptyClaudePermissions());
@@ -205,7 +641,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
setCodexPermissionMode('default');
setProjectSortOrder('name');
}
}, []);
}, [fetchCodexMcpServers, fetchCursorMcpServers, fetchMcpServers]);
const openLoginForProvider = useCallback((provider: AgentProvider) => {
setLoginProvider(provider);
@@ -284,6 +720,26 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
[],
);
const openMcpForm = useCallback((server?: McpServer) => {
setEditingMcpServer(server || null);
setShowMcpForm(true);
}, []);
const closeMcpForm = useCallback(() => {
setShowMcpForm(false);
setEditingMcpServer(null);
}, []);
const openCodexMcpForm = useCallback((server?: McpServer) => {
setEditingCodexMcpServer(server || null);
setShowCodexMcpForm(true);
}, []);
const closeCodexMcpForm = useCallback(() => {
setShowCodexMcpForm(false);
setEditingCodexMcpServer(null);
}, []);
useEffect(() => {
if (!isOpen) {
return;
@@ -363,6 +819,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
isDarkMode,
toggleDarkMode,
saveStatus,
deleteError,
projectSortOrder,
setProjectSortOrder,
codeEditorSettings,
@@ -375,6 +832,26 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
setNotificationPreferences,
codexPermissionMode,
setCodexPermissionMode,
mcpServers,
cursorMcpServers,
codexMcpServers,
mcpTestResults,
mcpServerTools,
mcpToolsLoading,
showMcpForm,
editingMcpServer,
openMcpForm,
closeMcpForm,
submitMcpForm,
handleMcpDelete,
handleMcpTest,
handleMcpToolsDiscovery,
showCodexMcpForm,
editingCodexMcpServer,
openCodexMcpForm,
closeCodexMcpForm,
submitCodexMcpForm,
handleCodexMcpDelete,
providerAuthStatus,
geminiPermissionMode,
setGeminiPermissionMode,

View File

@@ -9,6 +9,9 @@ export type ProjectSortOrder = 'name' | 'date';
export type SaveStatus = 'success' | 'error' | null;
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
export type GeminiPermissionMode = 'default' | 'auto_edit' | 'yolo';
export type McpImportMode = 'form' | 'json';
export type McpScope = 'user' | 'local';
export type McpTransportType = 'stdio' | 'sse' | 'http';
export type SettingsProject = {
name: string;
@@ -19,6 +22,80 @@ export type SettingsProject = {
export type AuthStatus = ProviderAuthStatus;
export type KeyValueMap = Record<string, string>;
export type McpServerConfig = {
command?: string;
args?: string[];
env?: KeyValueMap;
url?: string;
headers?: KeyValueMap;
timeout?: number;
};
export type McpServer = {
id?: string;
name: string;
type?: string;
scope?: string;
projectPath?: string;
config?: McpServerConfig;
raw?: unknown;
created?: string;
updated?: string;
};
export type ClaudeMcpFormConfig = {
command: string;
args: string[];
env: KeyValueMap;
url: string;
headers: KeyValueMap;
timeout: number;
};
export type ClaudeMcpFormState = {
name: string;
type: McpTransportType;
scope: McpScope;
projectPath: string;
config: ClaudeMcpFormConfig;
importMode: McpImportMode;
jsonInput: string;
raw?: unknown;
};
export type CodexMcpFormConfig = {
command: string;
args: string[];
env: KeyValueMap;
};
export type CodexMcpFormState = {
name: string;
type: 'stdio';
config: CodexMcpFormConfig;
};
export type McpTestResult = {
success: boolean;
message: string;
details?: string[];
loading?: boolean;
};
export type McpTool = {
name: string;
[key: string]: unknown;
};
export type McpToolsResult = {
success?: boolean;
tools?: McpTool[];
resources?: unknown[];
prompts?: unknown[];
};
export type ClaudePermissionsState = {
allowedTools: string[];
disallowedTools: string[];

View File

@@ -2,6 +2,8 @@ import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
import { Button } from '../../../shared/view/ui';
import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal';
import CodexMcpFormModal from '../view/modals/CodexMcpFormModal';
import SettingsSidebar from '../view/SettingsSidebar';
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
@@ -21,6 +23,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
activeTab,
setActiveTab,
saveStatus,
deleteError,
projectSortOrder,
setProjectSortOrder,
codeEditorSettings,
@@ -33,6 +36,26 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
setCursorPermissions,
codexPermissionMode,
setCodexPermissionMode,
mcpServers,
cursorMcpServers,
codexMcpServers,
mcpTestResults,
mcpServerTools,
mcpToolsLoading,
showMcpForm,
editingMcpServer,
openMcpForm,
closeMcpForm,
submitMcpForm,
handleMcpDelete,
handleMcpTest,
handleMcpToolsDiscovery,
showCodexMcpForm,
editingCodexMcpServer,
openCodexMcpForm,
closeCodexMcpForm,
submitCodexMcpForm,
handleCodexMcpDelete,
providerAuthStatus,
geminiPermissionMode,
setGeminiPermissionMode,
@@ -133,7 +156,19 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
onCodexPermissionModeChange={setCodexPermissionMode}
geminiPermissionMode={geminiPermissionMode}
onGeminiPermissionModeChange={setGeminiPermissionMode}
projects={projects}
mcpServers={mcpServers}
cursorMcpServers={cursorMcpServers}
codexMcpServers={codexMcpServers}
mcpTestResults={mcpTestResults}
mcpServerTools={mcpServerTools}
mcpToolsLoading={mcpToolsLoading}
onOpenMcpForm={openMcpForm}
onDeleteMcpServer={handleMcpDelete}
onTestMcpServer={handleMcpTest}
onDiscoverMcpTools={handleMcpToolsDiscovery}
onOpenCodexMcpForm={openCodexMcpForm}
onDeleteCodexMcpServer={handleCodexMcpDelete}
deleteError={deleteError}
/>
)}
@@ -170,6 +205,20 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
isAuthenticated={isAuthenticated}
/>
<ClaudeMcpFormModal
isOpen={showMcpForm}
editingServer={editingMcpServer}
projects={projects}
onClose={closeMcpForm}
onSubmit={submitMcpForm}
/>
<CodexMcpFormModal
isOpen={showCodexMcpForm}
editingServer={editingCodexMcpServer}
onClose={closeCodexMcpForm}
onSubmit={submitCodexMcpForm}
/>
</div>
);
}

View File

@@ -0,0 +1,478 @@
import { FolderOpen, Globe, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../../shared/view/ui';
import { DEFAULT_CLAUDE_MCP_FORM } from '../../constants/constants';
import type { ClaudeMcpFormState, McpServer, McpScope, McpTransportType, SettingsProject } from '../../types/types';
type ClaudeMcpFormModalProps = {
isOpen: boolean;
editingServer: McpServer | null;
projects: SettingsProject[];
onClose: () => void;
onSubmit: (formData: ClaudeMcpFormState, editingServer: McpServer | null) => Promise<void>;
};
const getSafeTransportType = (value: unknown): McpTransportType => {
if (value === 'sse' || value === 'http') {
return value;
}
return 'stdio';
};
const getSafeScope = (value: unknown): McpScope => (value === 'local' ? 'local' : 'user');
const getErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : 'Unknown error'
);
const createFormStateFromServer = (server: McpServer): ClaudeMcpFormState => ({
name: server.name || '',
type: getSafeTransportType(server.type),
scope: getSafeScope(server.scope),
projectPath: server.projectPath || '',
config: {
command: server.config?.command || '',
args: server.config?.args || [],
env: server.config?.env || {},
url: server.config?.url || '',
headers: server.config?.headers || {},
timeout: server.config?.timeout || 30000,
},
importMode: 'form',
jsonInput: '',
raw: server.raw,
});
export default function ClaudeMcpFormModal({
isOpen,
editingServer,
projects,
onClose,
onSubmit,
}: ClaudeMcpFormModalProps) {
const { t } = useTranslation('settings');
const [formData, setFormData] = useState<ClaudeMcpFormState>(DEFAULT_CLAUDE_MCP_FORM);
const [jsonValidationError, setJsonValidationError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditing = Boolean(editingServer);
useEffect(() => {
if (!isOpen) {
return;
}
setJsonValidationError('');
if (editingServer) {
setFormData(createFormStateFromServer(editingServer));
return;
}
setFormData(DEFAULT_CLAUDE_MCP_FORM);
}, [editingServer, isOpen]);
const canSubmit = useMemo(() => {
if (!formData.name.trim()) {
return false;
}
if (formData.importMode === 'json') {
return Boolean(formData.jsonInput.trim()) && !jsonValidationError;
}
if (formData.scope === 'local' && !formData.projectPath.trim()) {
return false;
}
if (formData.type === 'stdio') {
return Boolean(formData.config.command.trim());
}
return Boolean(formData.config.url.trim());
}, [formData, jsonValidationError]);
if (!isOpen) {
return null;
}
const updateConfig = <K extends keyof ClaudeMcpFormState['config']>(
key: K,
value: ClaudeMcpFormState['config'][K],
) => {
setFormData((prev) => ({
...prev,
config: {
...prev.config,
[key]: value,
},
}));
};
const handleJsonValidation = (value: string) => {
if (!value.trim()) {
setJsonValidationError('');
return;
}
try {
const parsed = JSON.parse(value) as { type?: string; command?: string; url?: string };
if (!parsed.type) {
setJsonValidationError(t('mcpForm.validation.missingType'));
} else if (parsed.type === 'stdio' && !parsed.command) {
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
} else if ((parsed.type === 'http' || parsed.type === 'sse') && !parsed.url) {
setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: parsed.type }));
} else {
setJsonValidationError('');
}
} catch {
setJsonValidationError(t('mcpForm.validation.invalidJson'));
}
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
try {
await onSubmit(formData, editingServer);
} catch (error) {
alert(`Error: ${getErrorMessage(error)}`);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
<div className="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-medium text-foreground">
{isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
</h3>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-4">
{!isEditing && (
<div className="mb-4 flex gap-2">
<button
type="button"
onClick={() => setFormData((prev) => ({ ...prev, importMode: 'form' }))}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
formData.importMode === 'form'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{t('mcpForm.importMode.form')}
</button>
<button
type="button"
onClick={() => setFormData((prev) => ({ ...prev, importMode: 'json' }))}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
formData.importMode === 'json'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{t('mcpForm.importMode.json')}
</button>
</div>
)}
{formData.importMode === 'form' && isEditing && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/50">
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.scope.label')}
</label>
<div className="flex items-center gap-2">
{formData.scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
<span className="text-sm">
{formData.scope === 'user' ? t('mcpForm.scope.userGlobal') : t('mcpForm.scope.projectLocal')}
</span>
{formData.scope === 'local' && formData.projectPath && (
<span className="text-xs text-muted-foreground">- {formData.projectPath}</span>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">{t('mcpForm.scope.cannotChange')}</p>
</div>
)}
{formData.importMode === 'form' && !isEditing && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.scope.label')} *
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setFormData((prev) => ({ ...prev, scope: 'user', projectPath: '' }))}
className={`flex-1 rounded-lg px-4 py-2 font-medium transition-colors ${
formData.scope === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-center gap-2">
<Globe className="h-4 w-4" />
<span>{t('mcpForm.scope.userGlobal')}</span>
</div>
</button>
<button
type="button"
onClick={() => setFormData((prev) => ({ ...prev, scope: 'local' }))}
className={`flex-1 rounded-lg px-4 py-2 font-medium transition-colors ${
formData.scope === 'local'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-center gap-2">
<FolderOpen className="h-4 w-4" />
<span>{t('mcpForm.scope.projectLocal')}</span>
</div>
</button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{formData.scope === 'user'
? t('mcpForm.scope.userDescription')
: t('mcpForm.scope.projectDescription')}
</p>
</div>
{formData.scope === 'local' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.selectProject')} *
</label>
<select
value={formData.projectPath}
onChange={(event) => {
setFormData((prev) => ({ ...prev, projectPath: event.target.value }));
}}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
required
>
<option value="">{t('mcpForm.fields.selectProject')}...</option>
{projects.map((project) => (
<option key={project.name} value={project.path || project.fullPath}>
{project.displayName || project.name}
</option>
))}
</select>
{formData.projectPath && (
<p className="mt-1 text-xs text-muted-foreground">
{t('mcpForm.projectPath', { path: formData.projectPath })}
</p>
)}
</div>
)}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className={formData.importMode === 'json' ? 'md:col-span-2' : ''}>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.serverName')} *
</label>
<Input
value={formData.name}
onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('mcpForm.placeholders.serverName')}
required
/>
</div>
{formData.importMode === 'form' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.transportType')} *
</label>
<select
value={formData.type}
onChange={(event) => {
setFormData((prev) => ({
...prev,
type: getSafeTransportType(event.target.value),
}));
}}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
<option value="stdio">stdio</option>
<option value="sse">SSE</option>
<option value="http">HTTP</option>
</select>
</div>
)}
</div>
{isEditing && Boolean(formData.raw) && formData.importMode === 'form' && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<h4 className="mb-2 text-sm font-medium text-foreground">
{t('mcpForm.configDetails', {
configFile: editingServer?.scope === 'global' ? '~/.claude.json' : 'project config',
})}
</h4>
<pre className="overflow-x-auto rounded bg-gray-100 p-3 text-xs dark:bg-gray-800">
{JSON.stringify(formData.raw, null, 2)}
</pre>
</div>
)}
{formData.importMode === 'json' && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.jsonConfig')} *
</label>
<textarea
value={formData.jsonInput}
onChange={(event) => {
const value = event.target.value;
setFormData((prev) => ({ ...prev, jsonInput: value }));
handleJsonValidation(value);
}}
className={`w-full border px-3 py-2 ${
jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
} rounded-lg bg-gray-50 font-mono text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-100`}
rows={8}
placeholder={'{\n "type": "stdio",\n "command": "/path/to/server",\n "args": ["--api-key", "abc123"],\n "env": {\n "CACHE_DIR": "/tmp"\n }\n}'}
required
/>
{jsonValidationError && (
<p className="mt-1 text-xs text-red-500">{jsonValidationError}</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
{t('mcpForm.validation.jsonHelp')}
<br />
- stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
<br />
- http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
</p>
</div>
</div>
)}
{formData.importMode === 'form' && formData.type === 'stdio' && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.command')} *
</label>
<Input
value={formData.config.command}
onChange={(event) => updateConfig('command', event.target.value)}
placeholder="/path/to/mcp-server"
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.arguments')}
</label>
<textarea
value={formData.config.args.join('\n')}
onChange={(event) => {
const args = event.target.value.split('\n').filter((arg) => arg.trim());
updateConfig('args', args);
}}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="--api-key&#10;abc123"
/>
</div>
</div>
)}
{formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.url')} *
</label>
<Input
value={formData.config.url}
onChange={(event) => updateConfig('url', event.target.value)}
placeholder="https://api.example.com/mcp"
type="url"
required
/>
</div>
)}
{formData.importMode === 'form' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.envVars')}
</label>
<textarea
value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\n')}
onChange={(event) => {
const env: Record<string, string> = {};
event.target.value.split('\n').forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key && key.trim()) {
env[key.trim()] = valueParts.join('=').trim();
}
});
updateConfig('env', env);
}}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="API_KEY=your-key&#10;DEBUG=true"
/>
</div>
)}
{formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.headers')}
</label>
<textarea
value={Object.entries(formData.config.headers).map(([key, value]) => `${key}=${value}`).join('\n')}
onChange={(event) => {
const headers: Record<string, string> = {};
event.target.value.split('\n').forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key && key.trim()) {
headers[key.trim()] = valueParts.join('=').trim();
}
});
updateConfig('headers', headers);
}}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="Authorization=Bearer token&#10;X-API-Key=your-key"
/>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{t('mcpForm.actions.cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting || !canSubmit}
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-50"
>
{isSubmitting
? t('mcpForm.actions.saving')
: isEditing
? t('mcpForm.actions.updateServer')
: t('mcpForm.actions.addServer')}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../../shared/view/ui';
import { DEFAULT_CODEX_MCP_FORM } from '../../constants/constants';
import type { CodexMcpFormState, McpServer } from '../../types/types';
type CodexMcpFormModalProps = {
isOpen: boolean;
editingServer: McpServer | null;
onClose: () => void;
onSubmit: (formData: CodexMcpFormState, editingServer: McpServer | null) => Promise<void>;
};
const getErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : 'Unknown error'
);
const createFormStateFromServer = (server: McpServer): CodexMcpFormState => ({
name: server.name || '',
type: 'stdio',
config: {
command: server.config?.command || '',
args: server.config?.args || [],
env: server.config?.env || {},
},
});
export default function CodexMcpFormModal({
isOpen,
editingServer,
onClose,
onSubmit,
}: CodexMcpFormModalProps) {
const { t } = useTranslation('settings');
const [formData, setFormData] = useState<CodexMcpFormState>(DEFAULT_CODEX_MCP_FORM);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (!isOpen) {
return;
}
if (editingServer) {
setFormData(createFormStateFromServer(editingServer));
return;
}
setFormData(DEFAULT_CODEX_MCP_FORM);
}, [editingServer, isOpen]);
if (!isOpen) {
return null;
}
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
try {
await onSubmit(formData, editingServer);
} catch (error) {
alert(`Error: ${getErrorMessage(error)}`);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg border border-border bg-background">
<div className="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-medium text-foreground">
{editingServer ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
</h3>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.serverName')} *
</label>
<Input
value={formData.name}
onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('mcpForm.placeholders.serverName')}
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.command')} *
</label>
<Input
value={formData.config.command}
onChange={(event) => {
const command = event.target.value;
setFormData((prev) => ({
...prev,
config: { ...prev.config, command },
}));
}}
placeholder="npx @my-org/mcp-server"
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.arguments')}
</label>
<textarea
value={formData.config.args.join('\n')}
onChange={(event) => {
const args = event.target.value.split('\n').filter((arg) => arg.trim());
setFormData((prev) => ({
...prev,
config: { ...prev.config, args },
}));
}}
placeholder="--port&#10;3000"
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.envVars')}
</label>
<textarea
value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\n')}
onChange={(event) => {
const env: Record<string, string> = {};
event.target.value.split('\n').forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
env[key.trim()] = valueParts.join('=').trim();
}
});
setFormData((prev) => ({
...prev,
config: { ...prev.config, env },
}));
}}
placeholder="API_KEY=xxx&#10;DEBUG=true"
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{t('mcpForm.actions.cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting || !formData.name.trim() || !formData.config.command.trim()}
className="bg-green-600 text-white hover:bg-green-700"
>
{isSubmitting
? t('mcpForm.actions.saving')
: editingServer
? t('mcpForm.actions.updateServer')
: t('mcpForm.actions.addServer')}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,12 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useServerPlatform } from '../../../../../hooks/useServerPlatform';
import { useMemo, useState } from 'react';
import type { AgentCategory, AgentProvider } from '../../../types/types';
import type { AgentContext, AgentsSettingsTabProps } from './types';
import AgentCategoryContentSection from './sections/AgentCategoryContentSection';
import AgentCategoryTabsSection from './sections/AgentCategoryTabsSection';
import AgentSelectorSection from './sections/AgentSelectorSection';
import type { AgentContext, AgentsSettingsTabProps } from './types';
export default function AgentsSettingsTab({
providerAuthStatus,
@@ -19,26 +16,22 @@ export default function AgentsSettingsTab({
onCodexPermissionModeChange,
geminiPermissionMode,
onGeminiPermissionModeChange,
projects,
mcpServers,
cursorMcpServers,
codexMcpServers,
mcpTestResults,
mcpServerTools,
mcpToolsLoading,
deleteError,
onOpenMcpForm,
onDeleteMcpServer,
onTestMcpServer,
onDiscoverMcpTools,
onOpenCodexMcpForm,
onDeleteCodexMcpServer,
}: AgentsSettingsTabProps) {
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
const { isWindowsServer } = useServerPlatform();
const visibleAgents = useMemo<AgentProvider[]>(() => {
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
if (isWindowsServer) {
return all.filter((id) => id !== 'cursor');
}
return all;
}, [isWindowsServer]);
useEffect(() => {
if (isWindowsServer && selectedAgent === 'cursor') {
setSelectedAgent('claude');
}
}, [isWindowsServer, selectedAgent]);
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
claude: {
@@ -68,7 +61,6 @@ export default function AgentsSettingsTab({
return (
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
<AgentSelectorSection
agents={visibleAgents}
selectedAgent={selectedAgent}
onSelectAgent={setSelectedAgent}
agentContextById={agentContextById}
@@ -92,7 +84,19 @@ export default function AgentsSettingsTab({
onCodexPermissionModeChange={onCodexPermissionModeChange}
geminiPermissionMode={geminiPermissionMode}
onGeminiPermissionModeChange={onGeminiPermissionModeChange}
projects={projects}
mcpServers={mcpServers}
cursorMcpServers={cursorMcpServers}
codexMcpServers={codexMcpServers}
mcpTestResults={mcpTestResults}
mcpServerTools={mcpServerTools}
mcpToolsLoading={mcpToolsLoading}
deleteError={deleteError}
onOpenMcpForm={onOpenMcpForm}
onDeleteMcpServer={onDeleteMcpServer}
onTestMcpServer={onTestMcpServer}
onDiscoverMcpTools={onDiscoverMcpTools}
onOpenCodexMcpForm={onOpenCodexMcpForm}
onDeleteCodexMcpServer={onDeleteCodexMcpServer}
/>
</div>
</div>

View File

@@ -1,7 +1,6 @@
import type { AgentCategoryContentSectionProps } from '../types';
import { McpServers } from '../../../../../mcp';
import AccountContent from './content/AccountContent';
import McpServersContent from './content/McpServersContent';
import PermissionsContent from './content/PermissionsContent';
export default function AgentCategoryContentSection({
@@ -14,8 +13,23 @@ export default function AgentCategoryContentSection({
onCursorPermissionsChange,
codexPermissionMode,
onCodexPermissionModeChange,
projects,
mcpServers,
cursorMcpServers,
codexMcpServers,
mcpTestResults,
mcpServerTools,
mcpToolsLoading,
deleteError,
onOpenMcpForm,
onDeleteMcpServer,
onTestMcpServer,
onDiscoverMcpTools,
onOpenCodexMcpForm,
onDeleteCodexMcpServer,
}: AgentCategoryContentSectionProps) {
// Cursor MCP add/edit/delete was previously a placeholder and is intentionally preserved.
const noopCursorMcpAction = () => {};
return (
<div className="flex-1 overflow-y-auto p-3 md:p-4">
{selectedCategory === 'account' && (
@@ -70,10 +84,40 @@ export default function AgentCategoryContentSection({
/>
)}
{selectedCategory === 'mcp' && (
<McpServers
selectedProvider={selectedAgent}
currentProjects={projects}
{selectedCategory === 'mcp' && selectedAgent === 'claude' && (
<McpServersContent
agent="claude"
servers={mcpServers}
onAdd={() => onOpenMcpForm()}
onEdit={(server) => onOpenMcpForm(server)}
onDelete={onDeleteMcpServer}
onTest={onTestMcpServer}
onDiscoverTools={onDiscoverMcpTools}
testResults={mcpTestResults}
serverTools={mcpServerTools}
toolsLoading={mcpToolsLoading}
deleteError={deleteError}
/>
)}
{selectedCategory === 'mcp' && selectedAgent === 'cursor' && (
<McpServersContent
agent="cursor"
servers={cursorMcpServers}
onAdd={noopCursorMcpAction}
onEdit={noopCursorMcpAction}
onDelete={noopCursorMcpAction}
/>
)}
{selectedCategory === 'mcp' && selectedAgent === 'codex' && (
<McpServersContent
agent="codex"
servers={codexMcpServers}
onAdd={() => onOpenCodexMcpForm()}
onEdit={(server) => onOpenCodexMcpForm(server)}
onDelete={(serverId) => onDeleteCodexMcpServer(serverId)}
deleteError={deleteError}
/>
)}
</div>

View File

@@ -3,6 +3,8 @@ import SessionProviderLogo from '../../../../../llm-logo-provider/SessionProvide
import type { AgentProvider } from '../../../../types/types';
import type { AgentSelectorSectionProps } from '../types';
const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
const AGENT_NAMES: Record<AgentProvider, string> = {
claude: 'Claude',
cursor: 'Cursor',
@@ -11,7 +13,6 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
};
export default function AgentSelectorSection({
agents,
selectedAgent,
onSelectAgent,
agentContextById,
@@ -19,7 +20,7 @@ export default function AgentSelectorSection({
return (
<div className="flex-shrink-0 border-b border-border px-3 py-2 md:px-4 md:py-3">
<PillBar className="w-full md:w-auto">
{agents.map((agent) => {
{AGENT_PROVIDERS.map((agent) => {
const dotColor =
agent === 'claude' ? 'bg-blue-500' :
agent === 'cursor' ? 'bg-purple-500' :

View File

@@ -0,0 +1,391 @@
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge, Button } from '../../../../../../../shared/view/ui';
import { IS_PLATFORM } from '../../../../../../../constants/config';
import PremiumFeatureCard from '../../../../PremiumFeatureCard';
import type { McpServer, McpToolsResult, McpTestResult } from '../../../../../types/types';
const getTransportIcon = (type: string | undefined) => {
if (type === 'stdio') {
return <Terminal className="h-4 w-4" />;
}
if (type === 'sse') {
return <Zap className="h-4 w-4" />;
}
if (type === 'http') {
return <Globe className="h-4 w-4" />;
}
return <Server className="h-4 w-4" />;
};
const maskSecret = (value: unknown): string => {
const normalizedValue = String(value ?? '');
if (normalizedValue.length <= 4) {
return '****';
}
return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
};
type ClaudeMcpServersProps = {
agent: 'claude';
servers: McpServer[];
onAdd: () => void;
onEdit: (server: McpServer) => void;
onDelete: (serverId: string, scope?: string) => void;
onTest: (serverId: string, scope?: string) => void;
onDiscoverTools: (serverId: string, scope?: string) => void;
testResults: Record<string, McpTestResult>;
serverTools: Record<string, McpToolsResult>;
toolsLoading: Record<string, boolean>;
deleteError?: string | null;
};
function ClaudeMcpServers({
servers,
onAdd,
onEdit,
onDelete,
testResults,
serverTools,
deleteError,
}: Omit<ClaudeMcpServersProps, 'agent' | 'onTest' | 'onDiscoverTools' | 'toolsLoading'>) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('mcpServers.description.claude')}</p>
<div className="flex items-center justify-between">
<Button onClick={onAdd} className="bg-purple-600 text-white hover:bg-purple-700" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('mcpServers.addButton')}
</Button>
</div>
{deleteError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{deleteError}
</div>
)}
<div className="space-y-2">
{servers.map((server) => {
const serverId = server.id || server.name;
const testResult = testResults[serverId];
const toolsResult = serverTools[serverId];
return (
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
{getTransportIcon(server.type)}
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">
{server.type || 'stdio'}
</Badge>
<Badge variant="outline" className="text-xs">
{server.scope === 'local'
? t('mcpServers.scope.local')
: server.scope === 'user'
? t('mcpServers.scope.user')
: server.scope}
</Badge>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{server.type === 'stdio' && server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
<div>
{t('mcpServers.config.url')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.url}</code>
</div>
)}
{server.config?.args && server.config.args.length > 0 && (
<div>
{t('mcpServers.config.args')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
</div>
)}
</div>
{testResult && (
<div className={`mt-2 rounded p-2 text-xs ${
testResult.success
? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-200'
: 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200'
}`}
>
<div className="font-medium">{testResult.message}</div>
</div>
)}
{toolsResult && toolsResult.tools && toolsResult.tools.length > 0 && (
<div className="mt-2 rounded bg-blue-50 p-2 text-xs text-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<div className="font-medium">
{t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: toolsResult.tools.length })}
</div>
<div className="mt-1 flex flex-wrap gap-1">
{toolsResult.tools.slice(0, 5).map((tool, index) => (
<code key={`${tool.name}-${index}`} className="rounded bg-blue-100 px-1 dark:bg-blue-800">
{tool.name}
</code>
))}
{toolsResult.tools.length > 5 && (
<span className="text-xs opacity-75">
{t('mcpServers.tools.more', { count: toolsResult.tools.length - 5 })}
</span>
)}
</div>
</div>
)}
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => onDelete(serverId, server.scope)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
{servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
{!IS_PLATFORM && (
<PremiumFeatureCard
icon={<Users className="h-5 w-5" />}
title="Team MCP Configs"
description="Share MCP server configurations across your team. Everyone stays in sync automatically."
/>
)}
</div>
);
}
type CursorMcpServersProps = {
agent: 'cursor';
servers: McpServer[];
onAdd: () => void;
onEdit: (server: McpServer) => void;
onDelete: (serverId: string) => void;
};
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpServersProps, 'agent'>) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('mcpServers.description.cursor')}</p>
<div className="flex items-center justify-between">
<Button onClick={onAdd} className="bg-purple-600 text-white hover:bg-purple-700" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('mcpServers.addButton')}
</Button>
</div>
<div className="space-y-2">
{servers.map((server) => {
const serverId = server.id || server.name;
return (
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
<Terminal className="h-4 w-4" />
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">stdio</Badge>
</div>
<div className="text-sm text-muted-foreground">
{server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => onDelete(serverId)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
{servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
</div>
);
}
type CodexMcpServersProps = {
agent: 'codex';
servers: McpServer[];
onAdd: () => void;
onEdit: (server: McpServer) => void;
onDelete: (serverId: string) => void;
deleteError?: string | null;
};
function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit<CodexMcpServersProps, 'agent'>) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-muted-foreground" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('mcpServers.description.codex')}</p>
<div className="flex items-center justify-between">
<Button onClick={onAdd} className="bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('mcpServers.addButton')}
</Button>
</div>
{deleteError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{deleteError}
</div>
)}
<div className="space-y-2">
{servers.map((server) => (
<div key={server.name} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
<Terminal className="h-4 w-4" />
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">stdio</Badge>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
{server.config?.args && server.config.args.length > 0 && (
<div>
{t('mcpServers.config.args')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
</div>
)}
{server.config?.env && Object.keys(server.config.env).length > 0 && (
<div>
{t('mcpServers.config.environment')}:{' '}
<code className="rounded bg-muted px-1 text-xs">
{Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
</code>
</div>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => onDelete(server.name)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
{servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
<div className="rounded-lg border border-border bg-muted/50 p-4">
<h4 className="mb-2 font-medium text-foreground">{t('mcpServers.help.title')}</h4>
<p className="text-sm text-muted-foreground">{t('mcpServers.help.description')}</p>
</div>
</div>
);
}
type McpServersContentProps = ClaudeMcpServersProps | CursorMcpServersProps | CodexMcpServersProps;
export default function McpServersContent(props: McpServersContentProps) {
if (props.agent === 'claude') {
return <ClaudeMcpServers {...props} />;
}
if (props.agent === 'cursor') {
return <CursorMcpServers {...props} />;
}
return <CodexMcpServers {...props} />;
}

View File

@@ -6,7 +6,9 @@ import type {
CursorPermissionsState,
CodexPermissionMode,
GeminiPermissionMode,
SettingsProject,
McpServer,
McpToolsResult,
McpTestResult,
} from '../../../types/types';
export type AgentContext = {
@@ -28,7 +30,19 @@ export type AgentsSettingsTabProps = {
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
geminiPermissionMode: GeminiPermissionMode;
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
projects: SettingsProject[];
mcpServers: McpServer[];
cursorMcpServers: McpServer[];
codexMcpServers: McpServer[];
mcpTestResults: Record<string, McpTestResult>;
mcpServerTools: Record<string, McpToolsResult>;
mcpToolsLoading: Record<string, boolean>;
deleteError: string | null;
onOpenMcpForm: (server?: McpServer) => void;
onDeleteMcpServer: (serverId: string, scope?: string) => void;
onTestMcpServer: (serverId: string, scope?: string) => void;
onDiscoverMcpTools: (serverId: string, scope?: string) => void;
onOpenCodexMcpForm: (server?: McpServer) => void;
onDeleteCodexMcpServer: (serverId: string) => void;
};
export type AgentCategoryTabsSectionProps = {
@@ -37,7 +51,6 @@ export type AgentCategoryTabsSectionProps = {
};
export type AgentSelectorSectionProps = {
agents: AgentProvider[];
selectedAgent: AgentProvider;
onSelectAgent: (agent: AgentProvider) => void;
agentContextById: AgentContextByProvider;
@@ -55,5 +68,17 @@ export type AgentCategoryContentSectionProps = {
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
geminiPermissionMode: GeminiPermissionMode;
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
projects: SettingsProject[];
mcpServers: McpServer[];
cursorMcpServers: McpServer[];
codexMcpServers: McpServer[];
mcpTestResults: Record<string, McpTestResult>;
mcpServerTools: Record<string, McpToolsResult>;
mcpToolsLoading: Record<string, boolean>;
deleteError: string | null;
onOpenMcpForm: (server?: McpServer) => void;
onDeleteMcpServer: (serverId: string, scope?: string) => void;
onTestMcpServer: (serverId: string, scope?: string) => void;
onDiscoverMcpTools: (serverId: string, scope?: string) => void;
onOpenCodexMcpForm: (server?: McpServer) => void;
onDeleteCodexMcpServer: (serverId: string) => void;
};

View File

@@ -452,7 +452,7 @@ export function useSidebarController({
[getProjectSessions],
);
const confirmDeleteProject = useCallback(async () => {
const confirmDeleteProject = useCallback(async (deleteData = false) => {
if (!deleteConfirmation) {
return;
}
@@ -464,7 +464,7 @@ export function useSidebarController({
setDeletingProjects((prev) => new Set([...prev, project.name]));
try {
const response = await api.deleteProject(project.name, !isEmpty);
const response = await api.deleteProject(project.name, !isEmpty, deleteData);
if (response.ok) {
onProjectDelete?.(project.name);

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import ReactDOM from 'react-dom';
import { AlertTriangle, Trash2 } from 'lucide-react';
import { AlertTriangle, EyeOff, Trash2 } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui';
import Settings from '../../../settings/view/Settings';
@@ -22,7 +22,7 @@ type SidebarModalsProps = {
onProjectCreated: () => void;
deleteConfirmation: DeleteProjectConfirmation | null;
onCancelDeleteProject: () => void;
onConfirmDeleteProject: () => void;
onConfirmDeleteProject: (deleteData?: boolean) => void;
sessionDeleteConfirmation: SessionDeleteConfirmation | null;
onCancelDeleteSession: () => void;
onConfirmDeleteSession: () => void;
@@ -104,8 +104,8 @@ export default function SidebarModals({
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30">
<AlertTriangle className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
<div className="min-w-0 flex-1">
<h3 className="mb-2 text-lg font-semibold text-foreground">
@@ -119,32 +119,32 @@ export default function SidebarModals({
?
</p>
{deleteConfirmation.sessionCount > 0 && (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm font-medium text-red-700 dark:text-red-300">
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
</p>
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{t('deleteConfirmation.allConversationsDeleted')}
</p>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
</p>
)}
<p className="mt-3 text-xs text-muted-foreground">
{t('deleteConfirmation.cannotUndo')}
</p>
</div>
</div>
</div>
<div className="flex gap-3 border-t border-border bg-muted/30 p-4">
<Button variant="outline" className="flex-1" onClick={onCancelDeleteProject}>
{t('actions.cancel')}
<div className="flex flex-col gap-2 border-t border-border bg-muted/30 p-4">
<Button
variant="outline"
className="w-full justify-start"
onClick={() => onConfirmDeleteProject(false)}
>
<EyeOff className="mr-2 h-4 w-4" />
{t('deleteConfirmation.removeFromSidebar')}
</Button>
<Button
variant="destructive"
className="flex-1 bg-red-600 text-white hover:bg-red-700"
onClick={onConfirmDeleteProject}
className="w-full justify-start bg-red-600 text-white hover:bg-red-700"
onClick={() => onConfirmDeleteProject(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('actions.delete')}
{t('deleteConfirmation.deleteAllData')}
</Button>
<Button variant="ghost" className="w-full" onClick={onCancelDeleteProject}>
{t('actions.cancel')}
</Button>
</div>
</div>

View File

@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react';
import type { PendingPermissionRequest } from '../components/chat/types/types';
export interface PermissionContextValue {
pendingPermissionRequests: PendingPermissionRequest[];
handlePermissionDecision: (
requestIds: string | string[],
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void;
}
const PermissionContext = createContext<PermissionContextValue | null>(null);
export function usePermission(): PermissionContextValue | null {
return useContext(PermissionContext);
}
export default PermissionContext;

View File

@@ -1,40 +0,0 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../utils/api';
/**
* Node `process.platform` from the API host (e.g. win32, darwin, linux).
* Null until loaded or if the request fails.
*/
export function useServerPlatform(): {
serverPlatform: string | null;
isWindowsServer: boolean;
} {
const [serverPlatform, setServerPlatform] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const response = await authenticatedFetch('/api/settings/server-env');
if (!response.ok) {
return;
}
const body = (await response.json()) as { platform?: string };
if (!cancelled && typeof body.platform === 'string') {
setServerPlatform(body.platform);
}
} catch {
// Keep null: treat as unknown host.
}
})();
return () => {
cancelled = true;
};
}, []);
return {
serverPlatform,
isWindowsServer: serverPlatform === 'win32',
};
}

Some files were not shown because too many files have changed in this diff Show More