From d822a968184c423c626298a70d7e779977a4fb93 Mon Sep 17 00:00:00 2001 From: simosmik Date: Tue, 16 Dec 2025 17:22:33 +0000 Subject: [PATCH 01/17] feat(chat): add model selection for Claude and update to latest versinos of claude agent sdk and cursor cli --- server/claude-sdk.js | 3 +- src/components/ChatInterface.jsx | 92 ++++++++++++++++++++++---------- 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 9fea127..682fbde 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -76,8 +76,9 @@ function mapCliOptionsToSDK(options = {}) { } // Map model (default to sonnet) - // Map model (default to sonnet) + // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] sdkOptions.model = options.model || 'sonnet'; + console.log(`๐Ÿค– Using model: ${sdkOptions.model}`); // Map system prompt configuration sdkOptions.systemPrompt = { diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 0ced685..6ca26ef 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -1641,7 +1641,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => { // // This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations. function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) { - const { tasksEnabled } = useTasksSettings(); + const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); const [input, setInput] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; @@ -1705,6 +1705,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [cursorModel, setCursorModel] = useState(() => { return localStorage.getItem('cursor-model') || 'gpt-5'; }); + const [claudeModel, setClaudeModel] = useState(() => { + return localStorage.getItem('claude-model') || 'sonnet'; + }); // Load permission mode for the current session useEffect(() => { if (selectedSession?.id) { @@ -2036,7 +2039,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess projectName: selectedProject.name, sessionId: currentSessionId, provider, - model: provider === 'cursor' ? cursorModel : 'claude-sonnet-4.5', + model: provider === 'cursor' ? cursorModel : claudeModel, tokenUsage: tokenBudget }; @@ -3852,6 +3855,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess resume: !!currentSessionId, toolsSettings: toolsSettings, permissionMode: permissionMode, + model: claudeModel, images: uploadedImages // Pass images to backend } }); @@ -3872,7 +3876,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess if (selectedProject) { safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); } - }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); + }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); // Store handleSubmit in ref so handleCustomCommand can access it useEffect(() => { @@ -4271,40 +4275,72 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess - {/* Model Selection for Cursor - Always reserve space to prevent jumping */} -
+ {/* Model Selection - Always reserve space to prevent jumping */} +
- + {provider === 'claude' ? ( + + ) : ( + + )}

- {provider === 'claude' - ? 'Ready to use Claude AI. Start typing your message below.' + {provider === 'claude' + ? `Ready to use Claude with ${claudeModel}. Start typing your message below.` : provider === 'cursor' ? `Ready to use Cursor with ${cursorModel}. Start typing your message below.` : 'Select a provider above to begin' }

- {/* Show NextTaskBanner when provider is selected and ready */} - {provider && tasksEnabled && ( + {/* Show NextTaskBanner when provider is selected and ready, only if TaskMaster is installed */} + {provider && tasksEnabled && isTaskMasterInstalled && (
- setInput('Start the next task')} onShowAllTasks={onShowAllTasks} /> @@ -4319,10 +4355,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess Ask questions about your code, request changes, or get help with development tasks

- {/* Show NextTaskBanner for existing sessions too */} - {tasksEnabled && ( + {/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */} + {tasksEnabled && isTaskMasterInstalled && (
- setInput('Start the next task')} onShowAllTasks={onShowAllTasks} /> From 6bf36969910350e4292c3e36ffd77a0c8984cedf Mon Sep 17 00:00:00 2001 From: simosmik Date: Wed, 17 Dec 2025 12:18:40 +0000 Subject: [PATCH 02/17] fix: fixing claude and cursor login defaulting to the previously opened shell --- server/index.js | 26 ++++++++++++++++++++++++-- src/components/Onboarding.jsx | 1 + src/components/Settings.jsx | 1 + 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/server/index.js b/server/index.js index a094d9d..e1b23a4 100755 --- a/server/index.js +++ b/server/index.js @@ -827,9 +827,31 @@ function handleShellConnection(ws) { const initialCommand = data.initialCommand; const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell'; - ptySessionKey = `${projectPath}_${sessionId || 'default'}`; + // Login commands (Claude/Cursor auth) should never reuse cached sessions + const isLoginCommand = initialCommand && ( + initialCommand.includes('setup-token') || + initialCommand.includes('cursor-agent login') || + initialCommand.includes('auth login') + ); - const existingSession = ptySessionsMap.get(ptySessionKey); + // Include command hash in session key so different commands get separate sessions + const commandSuffix = isPlainShell && initialCommand + ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}` + : ''; + ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`; + + // Kill any existing login session before starting fresh + if (isLoginCommand) { + const oldSession = ptySessionsMap.get(ptySessionKey); + if (oldSession) { + console.log('๐Ÿงน Cleaning up existing login session:', ptySessionKey); + if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId); + if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill(); + ptySessionsMap.delete(ptySessionKey); + } + } + + const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey); if (existingSession) { console.log('โ™ป๏ธ Reconnecting to existing PTY session:', ptySessionKey); shellProcess = existingSession.pty; diff --git a/src/components/Onboarding.jsx b/src/components/Onboarding.jsx index 33d61e5..0943bbb 100644 --- a/src/components/Onboarding.jsx +++ b/src/components/Onboarding.jsx @@ -575,6 +575,7 @@ const Onboarding = ({ onComplete }) => { {/* Login Modal */} {showLoginModal && ( setShowLoginModal(false)} provider={loginProvider} diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index b3760ac..681fc22 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -2149,6 +2149,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) { {/* Login Modal */} setShowLoginModal(false)} provider={loginProvider} From 7a173071f1ee90fd17d7453420496305b0e1d59d Mon Sep 17 00:00:00 2001 From: simosmik Date: Wed, 17 Dec 2025 12:25:47 +0000 Subject: [PATCH 03/17] fix: added webfetch and websearch to plan mode tools --- server/claude-sdk.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 682fbde..458ff90 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -57,7 +57,7 @@ function mapCliOptionsToSDK(options = {}) { // Add plan mode default tools if (permissionMode === 'plan') { - const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite']; + const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; for (const tool of planModeTools) { if (!allowedTools.includes(tool)) { allowedTools.push(tool); From fbbf7465fbe0ee96a9944d610a81f8661bf0cb48 Mon Sep 17 00:00:00 2001 From: simosmik Date: Sat, 27 Dec 2025 22:30:32 +0000 Subject: [PATCH 04/17] feat: Introducing Codex to the Claude code UI project. Improve the Settings and Onboarding UX to accomodate more agents. --- README.md | 35 +- package-lock.json | 23 +- package.json | 2 + public/icons/codex-white.svg | 3 + public/icons/codex.svg | 3 + public/icons/cursor-white.svg | 12 + server/claude-sdk.js | 36 +- server/index.js | 125 +- server/openai-codex.js | 387 +++++ server/projects.js | 443 +++++- server/routes/agent.js | 15 +- server/routes/cli-auth.js | 83 ++ server/routes/codex.js | 310 ++++ src/App.jsx | 12 +- src/components/ChatInterface.jsx | 364 ++++- src/components/CodexLogo.jsx | 16 + src/components/CursorLogo.jsx | 9 +- src/components/LoginModal.jsx | 8 +- src/components/Onboarding.jsx | 335 +++-- src/components/Settings.jsx | 1257 +++++++---------- src/components/Sidebar.jsx | 115 +- src/components/settings/AccountContent.jsx | 124 ++ src/components/settings/AgentListItem.jsx | 104 ++ src/components/settings/McpServersContent.jsx | 314 ++++ .../settings/PermissionsContent.jsx | 611 ++++++++ src/utils/api.js | 26 +- 26 files changed, 3719 insertions(+), 1053 deletions(-) create mode 100644 public/icons/codex-white.svg create mode 100644 public/icons/codex.svg create mode 100644 public/icons/cursor-white.svg create mode 100644 server/openai-codex.js create mode 100644 server/routes/codex.js create mode 100644 src/components/CodexLogo.jsx create mode 100644 src/components/settings/AccountContent.jsx create mode 100644 src/components/settings/AgentListItem.jsx create mode 100644 src/components/settings/McpServersContent.jsx create mode 100644 src/components/settings/PermissionsContent.jsx diff --git a/README.md b/README.md index 1f81fd0..5d51128 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@
-A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), and [Cursor CLI](https://docs.cursor.com/en/cli/overview). You can use it locally or remotely to view your active projects and sessions in Claude Code or Cursor and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere. Supports models including **Claude Sonnet 4**, **Opus 4.1**, and **GPT-5** +A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) and [Codex](https://developers.openai.com/codex). You can use it locally or remotely to view your active projects and sessions in Claude Code, Cursor, or Codex and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere. ## Screenshots @@ -30,7 +30,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla

CLI Selection

CLI Selection
-Select between Claude Code and Cursor CLI +Select between Claude Code, Cursor CLI and Codex @@ -41,14 +41,14 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla ## Features -- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code from mobile -- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code or Cursor -- **Integrated Shell Terminal** - Direct access to Claude Code or Cursor CLI through built-in shell functionality +- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code, Cursor, or Codex from mobile +- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code, Cursor, or Codex +- **Integrated Shell Terminal** - Direct access to Claude Code, Cursor CLI, or Codex through built-in shell functionality - **File Explorer** - Interactive file tree with syntax highlighting and live editing - **Git Explorer** - View, stage and commit your changes. You can also switch branches - **Session Management** - Resume conversations, manage multiple sessions, and track history - **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation -- **Model Compatibility** - Works with Claude Sonnet 4, Opus 4.1, and GPT-5 +- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, and GPT-5.2 ## Quick Start @@ -57,7 +57,8 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla - [Node.js](https://nodejs.org/) v20 or higher - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or -- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured +- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or +- [Codex](https://developers.openai.com/codex) installed and configured ### One-click Operation (Recommended) @@ -218,15 +219,15 @@ After installing it you should be able to enable it from the Settings ### Core Features #### Project Management -The UI automatically discovers Claude Code projects from `~/.claude/projects/` and provides: -- **Visual Project Browser** - All available projects with metadata and session counts +It automatically discovers Claude Code, Cursor or Codex sessions when available and groups them together into projects +session counts - **Project Actions** - Rename, delete, and organize projects - **Smart Navigation** - Quick access to recent projects and sessions - **MCP support** - Add your own MCP servers through the UI #### Chat Interface -- **Use responsive chat or Claude Code/Cursor CLI** - You can either use the adapted chat interface or use the shell button to connect to your selected CLI. -- **Real-time Communication** - Stream responses from Claude with WebSocket connection +- **Use responsive chat or Claude Code/Cursor CLI/Codex CLI** - You can either use the adapted chat interface or use the shell button to connect to your selected CLI. +- **Real-time Communication** - Stream responses from your selected CLI (Claude Code/Cursor/Codex) with WebSocket connection - **Session Management** - Resume previous conversations or start fresh sessions - **Message History** - Complete conversation history with timestamps and metadata - **Multi-format Support** - Text, code blocks, and file references @@ -264,16 +265,16 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a ``` โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Frontend โ”‚ โ”‚ Backend โ”‚ โ”‚ Claude CLI โ”‚ +โ”‚ Frontend โ”‚ โ”‚ Backend โ”‚ โ”‚ Agent โ”‚ โ”‚ (React/Vite) โ”‚โ—„โ”€โ”€โ–บโ”‚ (Express/WS) โ”‚โ—„โ”€โ”€โ–บโ”‚ Integration โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` ### Backend (Node.js + Express) - **Express Server** - RESTful API with static file serving - **WebSocket Server** - Communication for chats and project refresh -- **CLI Integration (Claude Code / Cursor)** - Process spawning and management -- **Session Management** - JSONL parsing and conversation persistence +- **Agent Integration (Claude Code / Cursor CLI / Codex)** - Process spawning and management - **File System API** - Exposing file browser for projects ### Frontend (React + Vite) @@ -320,7 +321,7 @@ We welcome contributions! Please follow these guidelines: #### "No Claude projects found" **Problem**: The UI shows no projects or empty project list **Solutions**: -- Ensure [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) is properly installed +- Ensure [Claude Code](https://docs.anthropic.com/en/docs/claude-code) is properly installed - Run `claude` command in at least one project directory to initialize - Verify `~/.claude/projects/` directory exists and has proper permissions @@ -343,6 +344,8 @@ This project is open source and free to use, modify, and distribute under the GP ### Built With - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI +- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI +- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[React](https://react.dev/)** - User interface library - **[Vite](https://vitejs.dev/)** - Fast build tool and dev server - **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework @@ -361,5 +364,5 @@ This project is open source and free to use, modify, and distribute under the GP ---
- Made with care for the Claude Code community. + Made with care for the Claude Code, Cursor and Codex community.
diff --git a/package-lock.json b/package-lock.json index f1fddf2..e2c7931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ "@codemirror/lang-python": "^6.2.1", "@codemirror/merge": "^6.11.1", "@codemirror/theme-one-dark": "^6.1.2", + "@iarna/toml": "^2.2.5", "@octokit/rest": "^22.0.0", + "@openai/codex-sdk": "^0.75.0", "@replit/codemirror-minimap": "^0.5.2", "@tailwindcss/typography": "^0.5.16", "@uiw/react-codemirror": "^4.23.13", @@ -1062,6 +1064,12 @@ "license": "MIT", "optional": true }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "license": "ISC" + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -2417,6 +2425,15 @@ "@octokit/openapi-types": "^26.0.0" } }, + "node_modules/@openai/codex-sdk": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.75.0.tgz", + "integrity": "sha512-4X5kHPXLu16SmGUdsvSa9xRuVmRC8oQw62iH8dRyIDbyy2MNkh068NNoHWDoJErRLUc4X4Ed2ceiNs6Tbkswnw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@phun-ky/typeof": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@phun-ky/typeof/-/typeof-2.0.3.tgz", @@ -3748,9 +3765,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 74c62fc..eee477c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ "@codemirror/lang-python": "^6.2.1", "@codemirror/merge": "^6.11.1", "@codemirror/theme-one-dark": "^6.1.2", + "@iarna/toml": "^2.2.5", "@octokit/rest": "^22.0.0", + "@openai/codex-sdk": "^0.75.0", "@replit/codemirror-minimap": "^0.5.2", "@tailwindcss/typography": "^0.5.16", "@uiw/react-codemirror": "^4.23.13", diff --git a/public/icons/codex-white.svg b/public/icons/codex-white.svg new file mode 100644 index 0000000..3a3c8db --- /dev/null +++ b/public/icons/codex-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/codex.svg b/public/icons/codex.svg new file mode 100644 index 0000000..c6f3062 --- /dev/null +++ b/public/icons/codex.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/cursor-white.svg b/public/icons/cursor-white.svg new file mode 100644 index 0000000..10d50ca --- /dev/null +++ b/public/icons/cursor-white.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 458ff90..05b6df1 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -78,7 +78,7 @@ function mapCliOptionsToSDK(options = {}) { // Map model (default to sonnet) // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] sdkOptions.model = options.model || 'sonnet'; - console.log(`๐Ÿค– Using model: ${sdkOptions.model}`); + console.log(`Using model: ${sdkOptions.model}`); // Map system prompt configuration sdkOptions.systemPrompt = { @@ -184,7 +184,7 @@ function extractTokenBudget(resultMessage) { // This is the user's budget limit, not the model's context window const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000; - console.log(`๐Ÿ“Š Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`); + console.log(`Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`); return { used: totalUsed, @@ -240,7 +240,7 @@ async function handleImages(command, images, cwd) { modifiedCommand = command + imageNote; } - console.log(`๐Ÿ“ธ Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`); + console.log(`Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`); return { modifiedCommand, tempImagePaths, tempDir }; } catch (error) { console.error('Error processing images for SDK:', error); @@ -273,7 +273,7 @@ async function cleanupTempFiles(tempImagePaths, tempDir) { ); } - console.log(`๐Ÿงน Cleaned up ${tempImagePaths.length} temp image files`); + console.log(`Cleaned up ${tempImagePaths.length} temp image files`); } catch (error) { console.error('Error during temp file cleanup:', error); } @@ -293,7 +293,7 @@ async function loadMcpConfig(cwd) { await fs.access(claudeConfigPath); } catch (error) { // File doesn't exist, return null - console.log('๐Ÿ“ก No ~/.claude.json found, proceeding without MCP servers'); + console.log('No ~/.claude.json found, proceeding without MCP servers'); return null; } @@ -303,7 +303,7 @@ async function loadMcpConfig(cwd) { const configContent = await fs.readFile(claudeConfigPath, 'utf8'); claudeConfig = JSON.parse(configContent); } catch (error) { - console.error('โŒ Failed to parse ~/.claude.json:', error.message); + console.error('Failed to parse ~/.claude.json:', error.message); return null; } @@ -313,7 +313,7 @@ async function loadMcpConfig(cwd) { // Add global MCP servers if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') { mcpServers = { ...claudeConfig.mcpServers }; - console.log(`๐Ÿ“ก Loaded ${Object.keys(mcpServers).length} global MCP servers`); + console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`); } // Add/override with project-specific MCP servers @@ -321,20 +321,20 @@ async function loadMcpConfig(cwd) { const projectConfig = claudeConfig.claudeProjects[cwd]; if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') { mcpServers = { ...mcpServers, ...projectConfig.mcpServers }; - console.log(`๐Ÿ“ก Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`); + console.log(`Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`); } } // Return null if no servers found if (Object.keys(mcpServers).length === 0) { - console.log('๐Ÿ“ก No MCP servers configured'); + console.log('No MCP servers configured'); return null; } - console.log(`โœ… Total MCP servers loaded: ${Object.keys(mcpServers).length}`); + console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`); return mcpServers; } catch (error) { - console.error('โŒ Error loading MCP config:', error.message); + console.error('Error loading MCP config:', error.message); return null; } } @@ -381,7 +381,7 @@ async function queryClaudeSDK(command, options = {}, ws) { } // Process streaming messages - console.log('๐Ÿ”„ Starting async generator loop for session:', capturedSessionId || 'NEW'); + console.log('Starting async generator loop for session:', capturedSessionId || 'NEW'); for await (const message of queryInstance) { // Capture session ID from first message if (message.session_id && !capturedSessionId) { @@ -402,10 +402,10 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionId: capturedSessionId })); } else { - console.log('โš ๏ธ Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent); + console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent); } } else { - console.log('โš ๏ธ No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId); + console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId); } // Transform and send message to WebSocket @@ -419,7 +419,7 @@ async function queryClaudeSDK(command, options = {}, ws) { if (message.type === 'result') { const tokenBudget = extractTokenBudget(message); if (tokenBudget) { - console.log('๐Ÿ“Š Token budget from modelUsage:', tokenBudget); + console.log('Token budget from modelUsage:', tokenBudget); ws.send(JSON.stringify({ type: 'token-budget', data: tokenBudget @@ -437,14 +437,14 @@ async function queryClaudeSDK(command, options = {}, ws) { await cleanupTempFiles(tempImagePaths, tempDir); // Send completion event - console.log('โœ… Streaming complete, sending claude-complete event'); + console.log('Streaming complete, sending claude-complete event'); ws.send(JSON.stringify({ type: 'claude-complete', sessionId: capturedSessionId, exitCode: 0, isNewSession: !sessionId && !!command })); - console.log('๐Ÿ“ค claude-complete event sent'); + console.log('claude-complete event sent'); } catch (error) { console.error('SDK query error:', error); @@ -481,7 +481,7 @@ async function abortClaudeSDKSession(sessionId) { } try { - console.log(`๐Ÿ›‘ Aborting SDK session: ${sessionId}`); + console.log(`Aborting SDK session: ${sessionId}`); // Call interrupt() on the query instance await session.instance.interrupt(); diff --git a/server/index.js b/server/index.js index e1b23a4..b273037 100755 --- a/server/index.js +++ b/server/index.js @@ -60,6 +60,7 @@ import mime from 'mime-types'; import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; +import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; import gitRoutes from './routes/git.js'; import authRoutes from './routes/auth.js'; import mcpRoutes from './routes/mcp.js'; @@ -72,6 +73,7 @@ import agentRoutes from './routes/agent.js'; import projectsRoutes from './routes/projects.js'; import cliAuthRoutes from './routes/cli-auth.js'; import userRoutes from './routes/user.js'; +import codexRoutes from './routes/codex.js'; import { initializeDatabase } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; @@ -211,7 +213,17 @@ const wss = new WebSocketServer({ app.locals.wss = wss; app.use(cors()); -app.use(express.json({ limit: '50mb' })); +app.use(express.json({ + limit: '50mb', + type: (req) => { + // Skip multipart/form-data requests (for file uploads like images) + const contentType = req.headers['content-type'] || ''; + if (contentType.includes('multipart/form-data')) { + return false; + } + return contentType.includes('json'); + } +})); app.use(express.urlencoded({ limit: '50mb', extended: true })); // Public health check endpoint (no authentication required) @@ -258,6 +270,9 @@ app.use('/api/cli', authenticateToken, cliAuthRoutes); // User API Routes (protected) app.use('/api/user', authenticateToken, userRoutes); +// Codex API Routes (protected) +app.use('/api/codex', authenticateToken, codexRoutes); + // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); @@ -726,6 +741,12 @@ function handleChatConnection(ws) { console.log('๐Ÿ”„ Session:', data.options?.sessionId ? 'Resume' : 'New'); console.log('๐Ÿค– Model:', data.options?.model || 'default'); await spawnCursor(data.command, data.options, ws); + } else if (data.type === 'codex-command') { + console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]'); + console.log('๐Ÿ“ Project:', data.options?.projectPath || data.options?.cwd || 'Unknown'); + console.log('๐Ÿ”„ Session:', data.options?.sessionId ? 'Resume' : 'New'); + console.log('๐Ÿค– Model:', data.options?.model || 'default'); + await queryCodex(data.command, data.options, ws); } else if (data.type === 'cursor-resume') { // Backward compatibility: treat as cursor-command with resume and no prompt console.log('[DEBUG] Cursor resume session (compat):', data.sessionId); @@ -741,6 +762,8 @@ function handleChatConnection(ws) { if (provider === 'cursor') { success = abortCursorSession(data.sessionId); + } else if (provider === 'codex') { + success = abortCodexSession(data.sessionId); } else { // Use Claude Agents SDK success = await abortClaudeSDKSession(data.sessionId); @@ -769,6 +792,8 @@ function handleChatConnection(ws) { if (provider === 'cursor') { isActive = isCursorSessionActive(sessionId); + } else if (provider === 'codex') { + isActive = isCodexSessionActive(sessionId); } else { // Use Claude Agents SDK isActive = isClaudeSDKSessionActive(sessionId); @@ -784,7 +809,8 @@ function handleChatConnection(ws) { // Get all currently active sessions const activeSessions = { claude: getActiveClaudeSDKSessions(), - cursor: getActiveCursorSessions() + cursor: getActiveCursorSessions(), + codex: getActiveCodexSessions() }; ws.send(JSON.stringify({ type: 'active-sessions', @@ -1354,8 +1380,98 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { try { const { projectName, sessionId } = req.params; + const { provider = 'claude' } = req.query; const homeDir = os.homedir(); + // Allow only safe characters in sessionId + const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safeSessionId) { + return res.status(400).json({ error: 'Invalid sessionId' }); + } + + // Handle Cursor sessions - they use SQLite and don't have token usage info + if (provider === 'cursor') { + return res.json({ + used: 0, + total: 0, + breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 }, + unsupported: true, + message: 'Token usage tracking not available for Cursor sessions' + }); + } + + // Handle Codex sessions + if (provider === 'codex') { + const codexSessionsDir = path.join(homeDir, '.codex', 'sessions'); + + // Find the session file by searching for the session ID + const findSessionFile = async (dir) => { + try { + const entries = await fsPromises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = await findSessionFile(fullPath); + if (found) return found; + } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) { + return fullPath; + } + } + } catch (error) { + // Skip directories we can't read + } + return null; + }; + + const sessionFilePath = await findSessionFile(codexSessionsDir); + + if (!sessionFilePath) { + return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId }); + } + + // Read and parse the Codex JSONL file + let fileContent; + try { + fileContent = await fsPromises.readFile(sessionFilePath, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ error: 'Session file not found', path: sessionFilePath }); + } + throw error; + } + const lines = fileContent.trim().split('\n'); + let totalTokens = 0; + let contextWindow = 200000; // Default for Codex/OpenAI + + // Find the latest token_count event with info (scan from end) + for (let i = lines.length - 1; i >= 0; i--) { + try { + const entry = JSON.parse(lines[i]); + + // Codex stores token info in event_msg with type: "token_count" + if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) { + const tokenInfo = entry.payload.info; + if (tokenInfo.total_token_usage) { + totalTokens = tokenInfo.total_token_usage.total_tokens || 0; + } + if (tokenInfo.model_context_window) { + contextWindow = tokenInfo.model_context_window; + } + break; // Stop after finding the latest token count + } + } catch (parseError) { + // Skip lines that can't be parsed + continue; + } + } + + return res.json({ + used: totalTokens, + total: contextWindow + }); + } + + // Handle Claude sessions (default) // Extract actual project path let projectPath; try { @@ -1371,11 +1487,6 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-'); const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); - // Allow only safe characters in sessionId - const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, ''); - if (!safeSessionId) { - return res.status(400).json({ error: 'Invalid sessionId' }); - } const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); // Constrain to projectDir diff --git a/server/openai-codex.js b/server/openai-codex.js new file mode 100644 index 0000000..d6a5b43 --- /dev/null +++ b/server/openai-codex.js @@ -0,0 +1,387 @@ +/** + * OpenAI Codex SDK Integration + * ============================= + * + * This module provides integration with the OpenAI Codex SDK for non-interactive + * chat sessions. It mirrors the pattern used in claude-sdk.js for consistency. + * + * ## Usage + * + * - queryCodex(command, options, ws) - Execute a prompt with streaming via WebSocket + * - abortCodexSession(sessionId) - Cancel an active session + * - isCodexSessionActive(sessionId) - Check if a session is running + * - getActiveCodexSessions() - List all active sessions + */ + +import { Codex } from '@openai/codex-sdk'; + +// Track active sessions +const activeCodexSessions = new Map(); + +/** + * Transform Codex SDK event to WebSocket message format + * @param {object} event - SDK event + * @returns {object} - Transformed event for WebSocket + */ +function transformCodexEvent(event) { + // Map SDK event types to a consistent format + switch (event.type) { + case 'item.started': + case 'item.updated': + case 'item.completed': + const item = event.item; + if (!item) { + return { type: event.type, item: null }; + } + + // Transform based on item type + switch (item.type) { + case 'agent_message': + return { + type: 'item', + itemType: 'agent_message', + message: { + role: 'assistant', + content: item.text + } + }; + + case 'reasoning': + return { + type: 'item', + itemType: 'reasoning', + message: { + role: 'assistant', + content: item.text, + isReasoning: true + } + }; + + case 'command_execution': + return { + type: 'item', + itemType: 'command_execution', + command: item.command, + output: item.aggregated_output, + exitCode: item.exit_code, + status: item.status + }; + + case 'file_change': + return { + type: 'item', + itemType: 'file_change', + changes: item.changes, + status: item.status + }; + + case 'mcp_tool_call': + return { + type: 'item', + itemType: 'mcp_tool_call', + server: item.server, + tool: item.tool, + arguments: item.arguments, + result: item.result, + error: item.error, + status: item.status + }; + + case 'web_search': + return { + type: 'item', + itemType: 'web_search', + query: item.query + }; + + case 'todo_list': + return { + type: 'item', + itemType: 'todo_list', + items: item.items + }; + + case 'error': + return { + type: 'item', + itemType: 'error', + message: { + role: 'error', + content: item.message + } + }; + + default: + return { + type: 'item', + itemType: item.type, + item: item + }; + } + + case 'turn.started': + return { + type: 'turn_started' + }; + + case 'turn.completed': + return { + type: 'turn_complete', + usage: event.usage + }; + + case 'turn.failed': + return { + type: 'turn_failed', + error: event.error + }; + + case 'thread.started': + return { + type: 'thread_started', + threadId: event.id + }; + + case 'error': + return { + type: 'error', + message: event.message + }; + + default: + return { + type: event.type, + data: event + }; + } +} + +/** + * Map permission mode to Codex SDK options + * @param {string} permissionMode - 'default', 'acceptEdits', or 'bypassPermissions' + * @returns {object} - { sandboxMode, approvalPolicy } + */ +function mapPermissionModeToCodexOptions(permissionMode) { + switch (permissionMode) { + case 'acceptEdits': + return { + sandboxMode: 'workspace-write', + approvalPolicy: 'never' + }; + case 'bypassPermissions': + return { + sandboxMode: 'danger-full-access', + approvalPolicy: 'never' + }; + case 'default': + default: + return { + sandboxMode: 'workspace-write', + approvalPolicy: 'untrusted' + }; + } +} + +/** + * Execute a Codex query with streaming + * @param {string} command - The prompt to send + * @param {object} options - Options including cwd, sessionId, model, permissionMode + * @param {WebSocket|object} ws - WebSocket connection or response writer + */ +export async function queryCodex(command, options = {}, ws) { + const { + sessionId, + cwd, + projectPath, + model, + permissionMode = 'default' + } = options; + + const workingDirectory = cwd || projectPath || process.cwd(); + const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode); + + let codex; + let thread; + let currentSessionId = sessionId; + + try { + // Initialize Codex SDK + codex = new Codex(); + + // Thread options with sandbox and approval settings + const threadOptions = { + workingDirectory, + skipGitRepoCheck: true, + sandboxMode, + approvalPolicy + }; + + // Start or resume thread + if (sessionId) { + thread = codex.resumeThread(sessionId, threadOptions); + } else { + thread = codex.startThread(threadOptions); + } + + // Get the thread ID + currentSessionId = thread.id || sessionId || `codex-${Date.now()}`; + + // Track the session + activeCodexSessions.set(currentSessionId, { + thread, + codex, + status: 'running', + startedAt: new Date().toISOString() + }); + + // Send session created event + sendMessage(ws, { + type: 'session-created', + sessionId: currentSessionId, + provider: 'codex' + }); + + // Execute with streaming + const streamedTurn = await thread.runStreamed(command); + + for await (const event of streamedTurn.events) { + // Check if session was aborted + const session = activeCodexSessions.get(currentSessionId); + if (!session || session.status === 'aborted') { + break; + } + + if (event.type === 'item.started' || event.type === 'item.updated') { + continue; + } + + const transformed = transformCodexEvent(event); + + sendMessage(ws, { + type: 'codex-response', + data: transformed, + sessionId: currentSessionId + }); + + // Extract and send token usage if available (normalized to match Claude format) + if (event.type === 'turn.completed' && event.usage) { + const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0); + sendMessage(ws, { + type: 'token-budget', + data: { + used: totalTokens, + total: 200000 // Default context window for Codex models + } + }); + } + } + + // Send completion event + sendMessage(ws, { + type: 'codex-complete', + sessionId: currentSessionId + }); + + } catch (error) { + console.error('[Codex] Error:', error); + + sendMessage(ws, { + type: 'codex-error', + error: error.message, + sessionId: currentSessionId + }); + + } finally { + // Update session status + if (currentSessionId) { + const session = activeCodexSessions.get(currentSessionId); + if (session) { + session.status = 'completed'; + } + } + } +} + +/** + * Abort an active Codex session + * @param {string} sessionId - Session ID to abort + * @returns {boolean} - Whether abort was successful + */ +export function abortCodexSession(sessionId) { + const session = activeCodexSessions.get(sessionId); + + if (!session) { + return false; + } + + session.status = 'aborted'; + + // The SDK doesn't have a direct abort method, but marking status + // will cause the streaming loop to exit + + return true; +} + +/** + * Check if a session is active + * @param {string} sessionId - Session ID to check + * @returns {boolean} - Whether session is active + */ +export function isCodexSessionActive(sessionId) { + const session = activeCodexSessions.get(sessionId); + return session?.status === 'running'; +} + +/** + * Get all active sessions + * @returns {Array} - Array of active session info + */ +export function getActiveCodexSessions() { + const sessions = []; + + for (const [id, session] of activeCodexSessions.entries()) { + if (session.status === 'running') { + sessions.push({ + id, + status: session.status, + startedAt: session.startedAt + }); + } + } + + return sessions; +} + +/** + * Helper to send message via WebSocket or writer + * @param {WebSocket|object} ws - WebSocket or response writer + * @param {object} data - Data to send + */ +function sendMessage(ws, data) { + try { + if (typeof ws.send === 'function') { + // WebSocket + ws.send(JSON.stringify(data)); + } else if (typeof ws.write === 'function') { + // SSE writer (for agent API) + ws.write(`data: ${JSON.stringify(data)}\n\n`); + } + } catch (error) { + console.error('[Codex] Error sending message:', error); + } +} + +// Clean up old completed sessions periodically +setInterval(() => { + const now = Date.now(); + const maxAge = 30 * 60 * 1000; // 30 minutes + + for (const [id, session] of activeCodexSessions.entries()) { + if (session.status !== 'running') { + const startedAt = new Date(session.startedAt).getTime(); + if (now - startedAt > maxAge) { + activeCodexSessions.delete(id); + } + } + } +}, 5 * 60 * 1000); // Every 5 minutes diff --git a/server/projects.js b/server/projects.js index ff5aa25..db61d5b 100755 --- a/server/projects.js +++ b/server/projects.js @@ -425,7 +425,15 @@ async function getProjects() { console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message); project.cursorSessions = []; } - + + // Also fetch Codex sessions for this project + try { + project.codexSessions = await getCodexSessions(actualProjectDir); + } catch (e) { + console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message); + project.codexSessions = []; + } + // Add TaskMaster detection try { const taskMasterResult = await detectTaskMasterFolder(actualProjectDir); @@ -478,16 +486,24 @@ async function getProjects() { isCustomName: !!projectConfig.displayName, isManuallyAdded: true, sessions: [], - cursorSessions: [] + cursorSessions: [], + codexSessions: [] }; - + // Try to fetch Cursor sessions for manual projects too try { project.cursorSessions = await getCursorSessions(actualProjectDir); } catch (e) { console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message); } - + + // Try to fetch Codex sessions for manual projects too + try { + project.codexSessions = await getCodexSessions(actualProjectDir); + } catch (e) { + console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message); + } + // Add TaskMaster detection for manual projects try { const taskMasterResult = await detectTaskMasterFolder(actualProjectDir); @@ -1141,6 +1157,420 @@ async function getCursorSessions(projectPath) { } +// Fetch Codex sessions for a given project path +async function getCodexSessions(projectPath) { + try { + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + const sessions = []; + + // Check if the directory exists + try { + await fs.access(codexSessionsDir); + } catch (error) { + // No Codex sessions directory + return []; + } + + // Recursively find all .jsonl files in the sessions directory + const findJsonlFiles = async (dir) => { + const files = []; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await findJsonlFiles(fullPath)); + } else if (entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + } catch (error) { + // Skip directories we can't read + } + return files; + }; + + const jsonlFiles = await findJsonlFiles(codexSessionsDir); + + // Process each file to find sessions matching the project path + for (const filePath of jsonlFiles) { + try { + const sessionData = await parseCodexSessionFile(filePath); + + // Check if this session matches the project path + if (sessionData && sessionData.cwd === projectPath) { + sessions.push({ + id: sessionData.id, + summary: sessionData.summary || 'Codex Session', + messageCount: sessionData.messageCount || 0, + lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), + cwd: sessionData.cwd, + model: sessionData.model, + filePath: filePath, + provider: 'codex' + }); + } + } catch (error) { + console.warn(`Could not parse Codex session file ${filePath}:`, error.message); + } + } + + // Sort sessions by last activity (newest first) + sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + + // Return only the first 5 sessions for performance + return sessions.slice(0, 5); + + } catch (error) { + console.error('Error fetching Codex sessions:', error); + return []; + } +} + +// Parse a Codex session JSONL file to extract metadata +async function parseCodexSessionFile(filePath) { + try { + const fileStream = fsSync.createReadStream(filePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + + let sessionMeta = null; + let lastTimestamp = null; + let lastUserMessage = null; + let messageCount = 0; + + for await (const line of rl) { + if (line.trim()) { + try { + const entry = JSON.parse(line); + + // Track timestamp + if (entry.timestamp) { + lastTimestamp = entry.timestamp; + } + + // Extract session metadata + if (entry.type === 'session_meta' && entry.payload) { + sessionMeta = { + id: entry.payload.id, + cwd: entry.payload.cwd, + model: entry.payload.model || entry.payload.model_provider, + timestamp: entry.timestamp, + git: entry.payload.git + }; + } + + // Count messages and extract user messages for summary + if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') { + messageCount++; + if (entry.payload.text) { + lastUserMessage = entry.payload.text; + } + } + + if (entry.type === 'response_item' && entry.payload?.type === 'message') { + messageCount++; + } + + } catch (parseError) { + // Skip malformed lines + } + } + } + + if (sessionMeta) { + return { + ...sessionMeta, + timestamp: lastTimestamp || sessionMeta.timestamp, + summary: lastUserMessage ? + (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) : + 'Codex Session', + messageCount + }; + } + + return null; + + } catch (error) { + console.error('Error parsing Codex session file:', error); + return null; + } +} + +// Get messages for a specific Codex session +async function getCodexSessionMessages(sessionId, limit = null, offset = 0) { + try { + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + + // Find the session file by searching for the session ID + const findSessionFile = async (dir) => { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = await findSessionFile(fullPath); + if (found) return found; + } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) { + return fullPath; + } + } + } catch (error) { + // Skip directories we can't read + } + return null; + }; + + const sessionFilePath = await findSessionFile(codexSessionsDir); + + if (!sessionFilePath) { + console.warn(`Codex session file not found for session ${sessionId}`); + return { messages: [], total: 0, hasMore: false }; + } + + const messages = []; + let tokenUsage = null; + const fileStream = fsSync.createReadStream(sessionFilePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + + // Helper to extract text from Codex content array + const extractText = (content) => { + if (!Array.isArray(content)) return content; + return content + .map(item => { + if (item.type === 'input_text' || item.type === 'output_text') { + return item.text; + } + if (item.type === 'text') { + return item.text; + } + return ''; + }) + .filter(Boolean) + .join('\n'); + }; + + for await (const line of rl) { + if (line.trim()) { + try { + const entry = JSON.parse(line); + + // Extract token usage from token_count events (keep latest) + if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) { + const info = entry.payload.info; + if (info.total_token_usage) { + tokenUsage = { + used: info.total_token_usage.total_tokens || 0, + total: info.model_context_window || 200000 + }; + } + } + + // Extract messages from response_item + if (entry.type === 'response_item' && entry.payload?.type === 'message') { + const content = entry.payload.content; + const role = entry.payload.role || 'assistant'; + const textContent = extractText(content); + + // Skip system context messages (environment_context) + if (textContent?.includes('')) { + continue; + } + + // Only add if there's actual content + if (textContent?.trim()) { + messages.push({ + type: role === 'user' ? 'user' : 'assistant', + timestamp: entry.timestamp, + message: { + role: role, + content: textContent + } + }); + } + } + + if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') { + const summaryText = entry.payload.summary + ?.map(s => s.text) + .filter(Boolean) + .join('\n'); + if (summaryText?.trim()) { + messages.push({ + type: 'thinking', + timestamp: entry.timestamp, + message: { + role: 'assistant', + content: summaryText + } + }); + } + } + + if (entry.type === 'response_item' && entry.payload?.type === 'function_call') { + let toolName = entry.payload.name; + let toolInput = entry.payload.arguments; + + // Map Codex tool names to Claude equivalents + if (toolName === 'shell_command') { + toolName = 'Bash'; + try { + const args = JSON.parse(entry.payload.arguments); + toolInput = JSON.stringify({ command: args.command }); + } catch (e) { + // Keep original if parsing fails + } + } + + messages.push({ + type: 'tool_use', + timestamp: entry.timestamp, + toolName: toolName, + toolInput: toolInput, + toolCallId: entry.payload.call_id + }); + } + + if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') { + messages.push({ + type: 'tool_result', + timestamp: entry.timestamp, + toolCallId: entry.payload.call_id, + output: entry.payload.output + }); + } + + if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') { + const toolName = entry.payload.name || 'custom_tool'; + const input = entry.payload.input || ''; + + if (toolName === 'apply_patch') { + // Parse Codex patch format and convert to Claude Edit format + const fileMatch = input.match(/\*\*\* Update File: (.+)/); + const filePath = fileMatch ? fileMatch[1].trim() : 'unknown'; + + // Extract old and new content from patch + const lines = input.split('\n'); + const oldLines = []; + const newLines = []; + + for (const line of lines) { + if (line.startsWith('-') && !line.startsWith('---')) { + oldLines.push(line.substring(1)); + } else if (line.startsWith('+') && !line.startsWith('+++')) { + newLines.push(line.substring(1)); + } + } + + messages.push({ + type: 'tool_use', + timestamp: entry.timestamp, + toolName: 'Edit', + toolInput: JSON.stringify({ + file_path: filePath, + old_string: oldLines.join('\n'), + new_string: newLines.join('\n') + }), + toolCallId: entry.payload.call_id + }); + } else { + messages.push({ + type: 'tool_use', + timestamp: entry.timestamp, + toolName: toolName, + toolInput: input, + toolCallId: entry.payload.call_id + }); + } + } + + if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') { + messages.push({ + type: 'tool_result', + timestamp: entry.timestamp, + toolCallId: entry.payload.call_id, + output: entry.payload.output || '' + }); + } + + } catch (parseError) { + // Skip malformed lines + } + } + } + + // Sort by timestamp + messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0)); + + const total = messages.length; + + // Apply pagination if limit is specified + if (limit !== null) { + const startIndex = Math.max(0, total - offset - limit); + const endIndex = total - offset; + const paginatedMessages = messages.slice(startIndex, endIndex); + const hasMore = startIndex > 0; + + return { + messages: paginatedMessages, + total, + hasMore, + offset, + limit, + tokenUsage + }; + } + + return { messages, tokenUsage }; + + } catch (error) { + console.error(`Error reading Codex session messages for ${sessionId}:`, error); + return { messages: [], total: 0, hasMore: false }; + } +} + +async function deleteCodexSession(sessionId) { + try { + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + + const findJsonlFiles = async (dir) => { + const files = []; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await findJsonlFiles(fullPath)); + } else if (entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + } catch (error) {} + return files; + }; + + const jsonlFiles = await findJsonlFiles(codexSessionsDir); + + for (const filePath of jsonlFiles) { + const sessionData = await parseCodexSessionFile(filePath); + if (sessionData && sessionData.id === sessionId) { + await fs.unlink(filePath); + return true; + } + } + + throw new Error(`Codex session file not found for session ${sessionId}`); + } catch (error) { + console.error(`Error deleting Codex session ${sessionId}:`, error); + throw error; + } +} + export { getProjects, getSessions, @@ -1154,5 +1584,8 @@ export { loadProjectConfig, saveProjectConfig, extractProjectDirectory, - clearProjectDirectoryCache + clearProjectDirectoryCache, + getCodexSessions, + getCodexSessionMessages, + deleteCodexSession }; diff --git a/server/routes/agent.js b/server/routes/agent.js index d3e12bc..646b875 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -8,6 +8,7 @@ import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js'; import { addProjectManually } from '../projects.js'; import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; +import { queryCodex } from '../openai-codex.js'; import { Octokit } from '@octokit/rest'; const router = express.Router(); @@ -846,8 +847,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { return res.status(400).json({ error: 'message is required' }); } - if (!['claude', 'cursor'].includes(provider)) { - return res.status(400).json({ error: 'provider must be "claude" or "cursor"' }); + if (!['claude', 'cursor', 'codex'].includes(provider)) { + return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' }); } // Validate GitHub branch/PR creation requirements @@ -951,6 +952,16 @@ router.post('/', validateExternalApiKey, async (req, res) => { model: model || undefined, skipPermissions: true // Bypass permissions for Cursor }, writer); + } else if (provider === 'codex') { + console.log('๐Ÿค– Starting Codex SDK session'); + + await queryCodex(message.trim(), { + projectPath: finalProjectPath, + cwd: finalProjectPath, + sessionId: null, + model: model || 'gpt-5.2', + permissionMode: 'bypassPermissions' + }, writer); } // Handle GitHub branch and PR creation after successful agent completion diff --git a/server/routes/cli-auth.js b/server/routes/cli-auth.js index e88b0be..1de309d 100644 --- a/server/routes/cli-auth.js +++ b/server/routes/cli-auth.js @@ -54,6 +54,26 @@ router.get('/cursor/status', async (req, res) => { } }); +router.get('/codex/status', async (req, res) => { + try { + const result = await checkCodexCredentials(); + + res.json({ + authenticated: result.authenticated, + email: result.email, + error: result.error + }); + + } catch (error) { + console.error('Error checking Codex auth status:', error); + res.status(500).json({ + authenticated: false, + email: null, + error: error.message + }); + } +}); + async function checkClaudeCredentials() { try { const credPath = path.join(os.homedir(), '.claude', '.credentials.json'); @@ -177,4 +197,67 @@ function checkCursorStatus() { }); } +async function checkCodexCredentials() { + try { + const authPath = path.join(os.homedir(), '.codex', 'auth.json'); + const content = await fs.readFile(authPath, 'utf8'); + const auth = JSON.parse(content); + + // Tokens are nested under 'tokens' key + const tokens = auth.tokens || {}; + + // Check for valid tokens (id_token or access_token) + if (tokens.id_token || tokens.access_token) { + // Try to extract email from id_token JWT payload + let email = 'Authenticated'; + if (tokens.id_token) { + try { + // JWT is base64url encoded: header.payload.signature + const parts = tokens.id_token.split('.'); + if (parts.length >= 2) { + // Decode the payload (second part) + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + email = payload.email || payload.user || 'Authenticated'; + } + } catch { + // If JWT decoding fails, use fallback + email = 'Authenticated'; + } + } + + return { + authenticated: true, + email + }; + } + + // Also check for OPENAI_API_KEY as fallback auth method + 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 + }; + } +} + export default router; diff --git a/server/routes/codex.js b/server/routes/codex.js new file mode 100644 index 0000000..0699efb --- /dev/null +++ b/server/routes/codex.js @@ -0,0 +1,310 @@ +import express from 'express'; +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, getCodexSessionMessages, deleteCodexSession } from '../projects.js'; + +const router = express.Router(); + +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); + res.json({ success: true, sessions }); + } catch (error) { + console.error('Error fetching Codex sessions:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/sessions/:sessionId/messages', async (req, res) => { + try { + const { sessionId } = req.params; + const { limit, offset } = req.query; + + const result = await getCodexSessionMessages( + sessionId, + limit ? parseInt(limit, 10) : null, + offset ? parseInt(offset, 10) : 0 + ); + + res.json({ success: true, ...result }); + } catch (error) { + console.error('Error fetching Codex session messages:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.delete('/sessions/:sessionId', async (req, res) => { + try { + const { sessionId } = req.params; + await deleteCodexSession(sessionId); + res.json({ success: true }); + } catch (error) { + console.error(`Error deleting Codex session ${req.params.sessionId}:`, error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// MCP Server Management Routes + +router.get('/mcp/cli/list', async (req, res) => { + try { + 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) { + res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) }); + } else { + res.status(500).json({ error: 'Codex CLI command failed', details: stderr }); + } + }); + + proc.on('error', (error) => { + res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); + }); + } 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 [-e KEY=VAL]... -- [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 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) { + res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` }); + } else { + res.status(400).json({ error: 'Codex CLI command failed', details: stderr }); + } + }); + + proc.on('error', (error) => { + res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); + }); + } 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 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) { + res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` }); + } else { + res.status(400).json({ error: 'Codex CLI command failed', details: stderr }); + } + }); + + proc.on('error', (error) => { + res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); + }); + } 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 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) { + res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) }); + } else { + res.status(404).json({ error: 'Codex CLI command failed', details: stderr }); + } + }); + + proc.on('error', (error) => { + res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); + }); + } 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: false, message: 'No Codex configuration file found', 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; diff --git a/src/App.jsx b/src/App.jsx index 961e178..c701aa3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -55,7 +55,7 @@ function AppContent() { const [isLoadingProjects, setIsLoadingProjects] = useState(true); const [isInputFocused, setIsInputFocused] = useState(false); const [showSettings, setShowSettings] = useState(false); - const [settingsInitialTab, setSettingsInitialTab] = useState('tools'); + const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); const [showQuickSettings, setShowQuickSettings] = useState(false); const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false); const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false); @@ -230,14 +230,16 @@ function AppContent() { setSelectedProject(updatedSelectedProject); } - // Update selected session only if it was deleted - avoid unnecessary reloads if (selectedSession) { - const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id); + const allSessions = [ + ...(updatedSelectedProject.sessions || []), + ...(updatedSelectedProject.codexSessions || []), + ...(updatedSelectedProject.cursorSessions || []) + ]; + const updatedSelectedSession = allSessions.find(s => s.id === selectedSession.id); if (!updatedSelectedSession) { - // Session was deleted setSelectedSession(null); } - // Don't update if session still exists with same ID - prevents reload } } } diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 6ca26ef..ed87071 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -25,6 +25,7 @@ import { useDropzone } from 'react-dropzone'; import TodoList from './TodoList'; import ClaudeLogo from './ClaudeLogo.jsx'; import CursorLogo from './CursorLogo.jsx'; +import CodexLogo from './CodexLogo.jsx'; import NextTaskBanner from './NextTaskBanner.jsx'; import { useTasksSettings } from '../contexts/TasksSettingsContext'; @@ -442,13 +443,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? ( + ) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? ( + ) : ( )}
)}
- {message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude')} + {message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : 'Claude')}
)} @@ -1522,6 +1525,23 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile Read todo list
+ ) : message.isThinking ? ( + /* Thinking messages - collapsible by default */ +
+
+ + + + + ๐Ÿ’ญ Thinking... + +
+ + {message.content} + +
+
+
) : (
{/* Thinking accordion for reasoning */} @@ -1537,7 +1557,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
)} - + {(() => { const content = formatUsageLimitText(String(message.content || '')); @@ -1708,6 +1728,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [claudeModel, setClaudeModel] = useState(() => { return localStorage.getItem('claude-model') || 'sonnet'; }); + const [codexModel, setCodexModel] = useState(() => { + return localStorage.getItem('codex-model') || 'gpt-5.2'; + }); // Load permission mode for the current session useEffect(() => { if (selectedSession?.id) { @@ -2112,24 +2135,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess }, []); // Load session messages from API with pagination - const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false) => { + const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false, provider = 'claude') => { if (!projectName || !sessionId) return []; - + const isInitialLoad = !loadMore; if (isInitialLoad) { setIsLoadingSessionMessages(true); } else { setIsLoadingMoreMessages(true); } - + try { const currentOffset = loadMore ? messagesOffset : 0; - const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset); + const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset, provider); if (!response.ok) { throw new Error('Failed to load session messages'); } const data = await response.json(); - + + // Extract token usage if present (Codex includes it in messages response) + if (isInitialLoad && data.tokenUsage) { + setTokenBudget(data.tokenUsage); + } + // Handle paginated response if (data.hasMore !== undefined) { setHasMoreMessages(data.hasMore); @@ -2572,6 +2600,45 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } } + // Handle thinking messages (Codex reasoning) + else if (msg.type === 'thinking' && msg.message?.content) { + converted.push({ + type: 'assistant', + content: unescapeWithMathProtection(msg.message.content), + timestamp: msg.timestamp || new Date().toISOString(), + isThinking: true + }); + } + + // Handle tool_use messages (Codex function calls) + else if (msg.type === 'tool_use' && msg.toolName) { + converted.push({ + type: 'assistant', + content: '', + timestamp: msg.timestamp || new Date().toISOString(), + isToolUse: true, + toolName: msg.toolName, + toolInput: msg.toolInput || '', + toolCallId: msg.toolCallId + }); + } + + // Handle tool_result messages (Codex function outputs) + else if (msg.type === 'tool_result') { + // Find the matching tool_use by callId, or the last tool_use without a result + for (let i = converted.length - 1; i >= 0; i--) { + if (converted[i].isToolUse && !converted[i].toolResult) { + if (!msg.toolCallId || converted[i].toolCallId === msg.toolCallId) { + converted[i].toolResult = { + content: msg.output || '', + isError: false + }; + break; + } + } + } + } + // Handle assistant messages else if (msg.message?.role === 'assistant' && msg.message?.content) { if (Array.isArray(msg.message.content)) { @@ -2666,7 +2733,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const previousScrollTop = container.scrollTop; // Load more messages - const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true); + const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true, selectedSession.__provider || 'claude'); if (moreMessages.length > 0) { // Prepend new messages to the existing ones @@ -2757,7 +2824,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Only load messages from API if this is a user-initiated session change // For system-initiated changes, preserve existing messages and rely on WebSocket if (!isSystemSessionChange) { - const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false); + const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude'); setSessionMessages(messages); // convertedMessages will be automatically updated via useMemo // Scroll will be handled by the main scroll useEffect after messages are rendered @@ -2806,8 +2873,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess setSessionMessages([]); setChatMessages(converted); } else { - // Reload Claude messages from API/JSONL - const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false); + // Reload Claude/Codex messages from API/JSONL + const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude'); setSessionMessages(messages); // convertedMessages will be automatically updated via useMemo @@ -2893,7 +2960,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Filter messages by session ID to prevent cross-session interference // Skip filtering for global messages that apply to all sessions - const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete']; + const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete', 'codex-complete']; const isGlobalMessage = globalMessageTypes.includes(latestMessage.type); // For new sessions (currentSessionId is null), allow messages through @@ -2920,8 +2987,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess break; case 'token-budget': - // Token budget now fetched via API after message completion instead of WebSocket - // This case is kept for compatibility but does nothing + // Use token budget from WebSocket for active sessions + if (latestMessage.data) { + setTokenBudget(latestMessage.data); + } break; case 'claude-response': @@ -3314,23 +3383,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess setIsLoading(false); setCanAbortSession(false); setClaudeStatus(null); - - // Fetch updated token usage after message completes - if (selectedProject && selectedSession?.id) { - const fetchUpdatedTokenUsage = async () => { - try { - const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; - const response = await authenticatedFetch(url); - if (response.ok) { - const data = await response.json(); - setTokenBudget(data); - } - } catch (error) { - console.error('Failed to fetch updated token usage:', error); - } - }; - fetchUpdatedTokenUsage(); - } } // Always mark the completed session as inactive and not processing @@ -3358,7 +3410,154 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); } break; - + + case 'codex-response': + // Handle Codex SDK responses + const codexData = latestMessage.data; + if (codexData) { + // Handle item events + if (codexData.type === 'item') { + switch (codexData.itemType) { + case 'agent_message': + if (codexData.message?.content?.trim()) { + const content = decodeHtmlEntities(codexData.message.content); + setChatMessages(prev => [...prev, { + type: 'assistant', + content: content, + timestamp: new Date() + }]); + } + break; + + case 'reasoning': + if (codexData.message?.content?.trim()) { + const content = decodeHtmlEntities(codexData.message.content); + setChatMessages(prev => [...prev, { + type: 'assistant', + content: content, + timestamp: new Date(), + isThinking: true + }]); + } + break; + + case 'command_execution': + if (codexData.command) { + setChatMessages(prev => [...prev, { + type: 'assistant', + content: '', + timestamp: new Date(), + isToolUse: true, + toolName: 'Bash', + toolInput: codexData.command, + toolResult: codexData.output || null, + exitCode: codexData.exitCode + }]); + } + break; + + case 'file_change': + if (codexData.changes?.length > 0) { + const changesList = codexData.changes.map(c => `${c.kind}: ${c.path}`).join('\n'); + setChatMessages(prev => [...prev, { + type: 'assistant', + content: '', + timestamp: new Date(), + isToolUse: true, + toolName: 'FileChanges', + toolInput: changesList, + toolResult: `Status: ${codexData.status}` + }]); + } + break; + + case 'mcp_tool_call': + setChatMessages(prev => [...prev, { + type: 'assistant', + content: '', + timestamp: new Date(), + isToolUse: true, + toolName: `${codexData.server}:${codexData.tool}`, + toolInput: JSON.stringify(codexData.arguments, null, 2), + toolResult: codexData.result ? JSON.stringify(codexData.result, null, 2) : (codexData.error?.message || null) + }]); + break; + + case 'error': + if (codexData.message?.content) { + setChatMessages(prev => [...prev, { + type: 'error', + content: codexData.message.content, + timestamp: new Date() + }]); + } + break; + + default: + console.log('[Codex] Unhandled item type:', codexData.itemType, codexData); + } + } + + // Handle turn complete + if (codexData.type === 'turn_complete') { + // Turn completed, message stream done + setIsLoading(false); + } + + // Handle turn failed + if (codexData.type === 'turn_failed') { + setIsLoading(false); + setChatMessages(prev => [...prev, { + type: 'error', + content: codexData.error?.message || 'Turn failed', + timestamp: new Date() + }]); + } + } + break; + + case 'codex-complete': + // Handle Codex session completion + const codexCompletedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); + + if (codexCompletedSessionId === currentSessionId || !currentSessionId) { + setIsLoading(false); + setCanAbortSession(false); + setClaudeStatus(null); + } + + if (codexCompletedSessionId) { + if (onSessionInactive) { + onSessionInactive(codexCompletedSessionId); + } + if (onSessionNotProcessing) { + onSessionNotProcessing(codexCompletedSessionId); + } + } + + const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); + if (codexPendingSessionId && !currentSessionId) { + setCurrentSessionId(codexPendingSessionId); + sessionStorage.removeItem('pendingSessionId'); + console.log('Codex session complete, ID set to:', codexPendingSessionId); + } + + if (selectedProject) { + safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); + } + break; + + case 'codex-error': + // Handle Codex errors + setIsLoading(false); + setCanAbortSession(false); + setChatMessages(prev => [...prev, { + type: 'error', + content: latestMessage.error || 'An error occurred with Codex', + timestamp: new Date() + }]); + break; + case 'session-aborted': { // Get session ID from message or fall back to current session const abortedSessionId = latestMessage.sessionId || currentSessionId; @@ -3609,21 +3808,26 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, [input]); - // Load token usage when session changes (but don't poll to avoid conflicts with WebSocket) + // Load token usage when session changes for Claude sessions only + // (Codex token usage is included in messages response, Cursor doesn't support it) useEffect(() => { if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { - // Reset for new/empty sessions setTokenBudget(null); return; } - // Fetch token usage once when session loads + const sessionProvider = selectedSession.__provider || 'claude'; + + // Skip for Codex (included in messages) and Cursor (not supported) + if (sessionProvider !== 'claude') { + return; + } + + // Fetch token usage for Claude sessions const fetchInitialTokenUsage = async () => { try { const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; - const response = await authenticatedFetch(url); - if (response.ok) { const data = await response.json(); setTokenBudget(data); @@ -3636,7 +3840,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess }; fetchInitialTokenUsage(); - }, [selectedSession?.id, selectedProject?.path]); + }, [selectedSession?.id, selectedSession?.__provider, selectedProject?.path]); const handleTranscript = useCallback((text) => { if (text.trim()) { @@ -3808,7 +4012,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Get tools settings from localStorage based on provider const getToolsSettings = () => { try { - const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-settings'; + const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : provider === 'codex' ? 'codex-settings' : 'claude-settings'; const savedSettings = safeLocalStorage.getItem(settingsKey); if (savedSettings) { return JSON.parse(savedSettings); @@ -3843,6 +4047,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess toolsSettings: toolsSettings } }); + } else if (provider === 'codex') { + // Send Codex command + sendMessage({ + type: 'codex-command', + command: input, + sessionId: effectiveSessionId, + options: { + cwd: selectedProject.fullPath || selectedProject.path, + projectPath: selectedProject.fullPath || selectedProject.path, + sessionId: effectiveSessionId, + resume: !!effectiveSessionId, + model: codexModel, + permissionMode: permissionMode === 'plan' ? 'default' : permissionMode + } + }); } else { // Send Claude command (existing code) sendMessage({ @@ -3876,7 +4095,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess if (selectedProject) { safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); } - }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); + }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); // Store handleSubmit in ref so handleCustomCommand can access it useEffect(() => { @@ -3986,7 +4205,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Handle Tab key for mode switching (only when dropdowns are not showing) if (e.key === 'Tab' && !showFileDropdown && !showCommandMenu) { e.preventDefault(); - const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; + // Codex doesn't support plan mode + const modes = provider === 'codex' + ? ['default', 'acceptEdits', 'bypassPermissions'] + : ['default', 'acceptEdits', 'bypassPermissions', 'plan']; const currentIndex = modes.indexOf(permissionMode); const nextIndex = (currentIndex + 1) % modes.length; const newMode = modes[nextIndex]; @@ -4155,7 +4377,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess }; const handleModeSwitch = () => { - const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; + // Codex doesn't support plan mode + const modes = provider === 'codex' + ? ['default', 'acceptEdits', 'bypassPermissions'] + : ['default', 'acceptEdits', 'bypassPermissions', 'plan']; const currentIndex = modes.indexOf(permissionMode); const nextIndex = (currentIndex + 1) % modes.length; const newMode = modes[nextIndex]; @@ -4273,8 +4498,40 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess )} + + {/* Codex Button */} + - + {/* Model Selection - Always reserve space to prevent jumping */}
-
- + -
-
- -

- Authentication -

-
-
-
-
- {claudeAuthStatus.loading ? ( - - Checking authentication... - - ) : claudeAuthStatus.authenticated ? ( -
- - โœ“ Logged in - - {claudeAuthStatus.email && ( - - as {claudeAuthStatus.email} - - )} -
- ) : ( - - Not authenticated - + {/* Desktop: Sidebar - Agent List */} +
+
+ setSelectedAgent('claude')} + /> + setSelectedAgent('cursor')} + /> + setSelectedAgent('codex')} + /> +
+
+ + {/* Main Panel */} +
+ {/* Category Tabs */} +
+
+ + + +
+
+ + {/* Category Content */} +
+ {/* Account Category */} + {selectedCategory === 'account' && ( + + )} + + {/* Permissions Category */} + {selectedCategory === 'permissions' && selectedAgent === 'claude' && ( + + )} + + {selectedCategory === 'permissions' && selectedAgent === 'cursor' && ( + + )} + + {selectedCategory === 'permissions' && selectedAgent === 'codex' && ( + + )} + + {/* MCP Servers Category */} + {selectedCategory === 'mcp' && selectedAgent === 'claude' && ( + openMcpForm()} + onEdit={(server) => openMcpForm(server)} + onDelete={(serverId, scope) => handleMcpDelete(serverId, scope)} + onTest={(serverId, scope) => handleMcpTest(serverId, scope)} + onDiscoverTools={(serverId, scope) => handleMcpToolsDiscovery(serverId, scope)} + testResults={mcpTestResults} + serverTools={mcpServerTools} + toolsLoading={mcpToolsLoading} + /> + )} + + {selectedCategory === 'mcp' && selectedAgent === 'cursor' && ( + {/* TODO: Add cursor MCP form */}} + onEdit={(server) => {/* TODO: Edit cursor MCP form */}} + onDelete={(serverId) => {/* TODO: Delete cursor MCP */}} + /> + )} + + {selectedCategory === 'mcp' && selectedAgent === 'codex' && ( + openCodexMcpForm()} + onEdit={(server) => openCodexMcpForm(server)} + onDelete={(serverId) => handleCodexMcpDelete(serverId)} + /> )}
- -
-
-
- Claude CLI Login -
-
- {claudeAuthStatus.authenticated - ? 'Re-authenticate or switch accounts' - : 'Sign in to your Claude account to enable AI features'} -
-
- -
-
- - {/* Allowed Tools */} -
-
- -

- Allowed Tools -

-
-

- Tools that are automatically allowed without prompting for permission -

- -
- setNewAllowedTool(e.target.value)} - placeholder='e.g., "Bash(git log:*)" or "Write"' - onKeyPress={(e) => { - if (e.key === 'Enter') { - addAllowedTool(newAllowedTool); - } - }} - className="flex-1 h-10 touch-manipulation" - style={{ fontSize: '16px' }} - /> - -
- - {/* Common tools quick add */} -
-

- Quick add common tools: -

-
- {commonTools.map(tool => ( - - ))} -
-
- -
- {allowedTools.map(tool => ( -
- - {tool} - - -
- ))} - {allowedTools.length === 0 && ( -
- No allowed tools configured -
- )} -
-
- - {/* Disallowed Tools */} -
-
- -

- Disallowed Tools -

-
-

- Tools that are automatically blocked without prompting for permission -

- -
- setNewDisallowedTool(e.target.value)} - placeholder='e.g., "Bash(rm:*)" or "Write"' - onKeyPress={(e) => { - if (e.key === 'Enter') { - addDisallowedTool(newDisallowedTool); - } - }} - className="flex-1 h-10 touch-manipulation" - style={{ fontSize: '16px' }} - /> - -
- -
- {disallowedTools.map(tool => ( -
- - {tool} - - -
- ))} - {disallowedTools.length === 0 && ( -
- No disallowed tools configured -
- )} -
-
- - {/* Help Section */} -
-

- Tool Pattern Examples: -

-
    -
  • "Bash(git log:*)" - Allow all git log commands
  • -
  • "Bash(git diff:*)" - Allow all git diff commands
  • -
  • "Write" - Allow all Write tool usage
  • -
  • "Read" - Allow all Read tool usage
  • -
  • "Bash(rm:*)" - Block all rm commands (dangerous)
  • -
-
- - {/* MCP Server Management */} -
-
- -

- MCP Servers -

-
-
-

- Model Context Protocol servers provide additional tools and data sources to Claude -

-
- -
- -
- - {/* MCP Servers List */} -
- {mcpServers.map(server => ( -
-
-
-
- {getTransportIcon(server.type)} - {server.name} - - {server.type} - - - {server.scope === 'local' ? '๐Ÿ“ local' : server.scope === 'user' ? '๐Ÿ‘ค user' : server.scope} - - {server.projectPath && ( - - {server.projectPath.split('/').pop()} - - )} -
- -
- {server.type === 'stdio' && server.config.command && ( -
Command: {server.config.command}
- )} - {(server.type === 'sse' || server.type === 'http') && server.config.url && ( -
URL: {server.config.url}
- )} - {server.config.args && server.config.args.length > 0 && ( -
Args: {server.config.args.join(' ')}
- )} - {server.config.env && Object.keys(server.config.env).length > 0 && ( -
Environment: {Object.entries(server.config.env).map(([k, v]) => `${k}=${v}`).join(', ')}
- )} - {server.raw && ( -
- View full config -
-                                {JSON.stringify(server.raw, null, 2)}
-                              
-
- )} -
- - {/* Test Results */} - {mcpTestResults[server.id] && ( -
-
{mcpTestResults[server.id].message}
- {mcpTestResults[server.id].details && mcpTestResults[server.id].details.length > 0 && ( -
    - {mcpTestResults[server.id].details.map((detail, i) => ( -
  • โ€ข {detail}
  • - ))} -
- )} -
- )} - - {/* Tools Discovery Results */} - {mcpServerTools[server.id] && ( -
-
Available Tools & Resources
- - {mcpServerTools[server.id].tools && mcpServerTools[server.id].tools.length > 0 && ( -
-
Tools ({mcpServerTools[server.id].tools.length}):
-
    - {mcpServerTools[server.id].tools.map((tool, i) => ( -
  • - โ€ข -
    - {tool.name} - {tool.description && tool.description !== 'No description provided' && ( - - {tool.description} - )} -
    -
  • - ))} -
-
- )} - - {mcpServerTools[server.id].resources && mcpServerTools[server.id].resources.length > 0 && ( -
-
Resources ({mcpServerTools[server.id].resources.length}):
-
    - {mcpServerTools[server.id].resources.map((resource, i) => ( -
  • - โ€ข -
    - {resource.name} - {resource.description && resource.description !== 'No description provided' && ( - - {resource.description} - )} -
    -
  • - ))} -
-
- )} - - {mcpServerTools[server.id].prompts && mcpServerTools[server.id].prompts.length > 0 && ( -
-
Prompts ({mcpServerTools[server.id].prompts.length}):
-
    - {mcpServerTools[server.id].prompts.map((prompt, i) => ( -
  • - โ€ข -
    - {prompt.name} - {prompt.description && prompt.description !== 'No description provided' && ( - - {prompt.description} - )} -
    -
  • - ))} -
-
- )} - - {(!mcpServerTools[server.id].tools || mcpServerTools[server.id].tools.length === 0) && - (!mcpServerTools[server.id].resources || mcpServerTools[server.id].resources.length === 0) && - (!mcpServerTools[server.id].prompts || mcpServerTools[server.id].prompts.length === 0) && ( -
No tools, resources, or prompts discovered
- )} -
- )} -
- -
- - -
-
-
- ))} - {mcpServers.length === 0 && ( -
- No MCP servers configured -
- )} -
-
+ )} {/* MCP Server Form Modal */} {showMcpForm && ( @@ -1816,272 +1788,103 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
)} - - )} - - {/* Cursor Content */} - {toolsProvider === 'cursor' && ( -
- - {/* Skip Permissions for Cursor */} -
-
- + + {/* Codex MCP Server Form Modal */} + {showCodexMcpForm && ( +
+
+

- Cursor Permission Settings + {editingCodexMcpServer ? 'Edit MCP Server' : 'Add MCP Server'}

+
-
-
- -
-
- -

- Authentication -

-
-
-
-
- {cursorAuthStatus.loading ? ( - - Checking authentication... - - ) : cursorAuthStatus.authenticated ? ( -
- - โœ“ Logged in - - {cursorAuthStatus.email && ( - - as {cursorAuthStatus.email} - - )} -
- ) : ( - - Not authenticated - - )} -
- -
-
-
- Cursor CLI Login -
-
- {cursorAuthStatus.authenticated - ? 'Re-authenticate or switch accounts' - : 'Sign in to your Cursor account to enable AI features'} -
-
- -
-
-
- {/* Allowed Shell Commands */} -
-
- -

- Allowed Shell Commands -

-
-

- Shell commands that are automatically allowed without prompting for permission -

- -
- setNewCursorCommand(e.target.value)} - placeholder='e.g., "Shell(ls)" or "Shell(git status)"' - onKeyPress={(e) => { - if (e.key === 'Enter') { - if (newCursorCommand && !cursorAllowedCommands.includes(newCursorCommand)) { - setCursorAllowedCommands([...cursorAllowedCommands, newCursorCommand]); - setNewCursorCommand(''); - } - } - }} - className="flex-1 h-10 touch-manipulation" - style={{ fontSize: '16px' }} - /> - -
+
+ + setCodexMcpFormData(prev => ({ + ...prev, + config: { ...prev.config, command: e.target.value } + }))} + placeholder="npx @my-org/mcp-server" + required + /> +
- {/* Common commands quick add */} -
-

- Quick add common commands: -

-
- {commonCursorCommands.map(cmd => ( -