diff --git a/package-lock.json b/package-lock.json index f736ab5..f5be1b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -169,6 +169,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -541,6 +542,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz", "integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -555,6 +557,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz", "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", @@ -590,6 +593,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -611,6 +615,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -2033,7 +2038,8 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@lezer/css": { "version": "1.3.0", @@ -2051,6 +2057,7 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -2261,6 +2268,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.4.tgz", "integrity": "sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", @@ -3181,6 +3189,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3325,7 +3334,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/abbrev": { "version": "2.0.0", @@ -3408,9 +3418,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -3843,6 +3853,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -4750,9 +4761,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/data-uri-to-buffer": { @@ -4783,9 +4794,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5814,9 +5825,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "dev": true, "license": "MIT", "engines": { @@ -6439,6 +6450,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -9065,22 +9077,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/os-name": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/os-name/-/os-name-6.1.0.tgz", @@ -9371,6 +9367,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9752,6 +9749,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9764,6 +9762,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10197,6 +10196,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@nodeutils/defaults-deep": "1.1.0", "@octokit/rest": "22.0.0", @@ -11776,12 +11776,12 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -11932,6 +11932,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12175,6 +12176,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12322,6 +12324,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12650,6 +12653,7 @@ "integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -12743,6 +12747,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 6b07650..30fa2cd 100644 --- a/package.json +++ b/package.json @@ -119,4 +119,4 @@ "typescript": "^5.9.3", "vite": "^7.0.4" } -} +} \ No newline at end of file diff --git a/public/icons/gemini-ai-icon.svg b/public/icons/gemini-ai-icon.svg new file mode 100644 index 0000000..f1cf357 --- /dev/null +++ b/public/icons/gemini-ai-icon.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/server/gemini-cli.js b/server/gemini-cli.js new file mode 100644 index 0000000..0c6506a --- /dev/null +++ b/server/gemini-cli.js @@ -0,0 +1,455 @@ +import { spawn } from 'child_process'; +import crossSpawn from 'cross-spawn'; + +// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) +const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { getSessions, getSessionMessages } from './projects.js'; +import sessionManager from './sessionManager.js'; +import GeminiResponseHandler from './gemini-response-handler.js'; + +let activeGeminiProcesses = new Map(); // Track active processes by session ID + +async function spawnGemini(command, options = {}, ws) { + const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options; + let capturedSessionId = sessionId; // Track session ID throughout the process + let sessionCreatedSent = false; // Track if we've already sent session-created event + let assistantBlocks = []; // Accumulate the full response blocks including tools + + // Use tools settings passed from frontend, or defaults + const settings = toolsSettings || { + allowedTools: [], + disallowedTools: [], + skipPermissions: false + }; + + // Build Gemini CLI command - start with print/resume flags first + const args = []; + + // Add prompt flag with command if we have a command + if (command && command.trim()) { + args.push('--prompt', command); + } + + // If we have a sessionId, we want to resume + if (sessionId) { + const session = sessionManager.getSession(sessionId); + if (session && session.cliSessionId) { + args.push('--resume', session.cliSessionId); + } + } + + // Use cwd (actual project directory) instead of projectPath (Gemini's metadata directory) + // Clean the path by removing any non-printable characters + const cleanPath = (cwd || projectPath || process.cwd()).replace(/[^\x20-\x7E]/g, '').trim(); + const workingDir = cleanPath; + + // Handle images by saving them to temporary files and passing paths to Gemini + const tempImagePaths = []; + let tempDir = null; + if (images && images.length > 0) { + try { + // Create temp directory in the project directory so Gemini can access it + tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString()); + await fs.mkdir(tempDir, { recursive: true }); + + // Save each image to a temp file + for (const [index, image] of images.entries()) { + // Extract base64 data and mime type + const matches = image.data.match(/^data:([^;]+);base64,(.+)$/); + if (!matches) { + continue; + } + + const [, mimeType, base64Data] = matches; + const extension = mimeType.split('/')[1] || 'png'; + const filename = `image_${index}.${extension}`; + const filepath = path.join(tempDir, filename); + + // Write base64 data to file + await fs.writeFile(filepath, Buffer.from(base64Data, 'base64')); + tempImagePaths.push(filepath); + } + + // Include the full image paths in the prompt for Gemini to reference + // Gemini CLI can read images from file paths in the prompt + if (tempImagePaths.length > 0 && command && command.trim()) { + const imageNote = `\n\n[Images given: ${tempImagePaths.length} images are located at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`; + const modifiedCommand = command + imageNote; + + // Update the command in args + const promptIndex = args.indexOf('--prompt'); + if (promptIndex !== -1 && args[promptIndex + 1] === command) { + args[promptIndex + 1] = modifiedCommand; + } else if (promptIndex !== -1) { + // If we're using context, update the full prompt + args[promptIndex + 1] = args[promptIndex + 1] + imageNote; + } + } + } catch (error) { + console.error('Error processing images for Gemini:', error); + } + } + + // Add basic flags for Gemini + if (options.debug) { + args.push('--debug'); + } + + // Add MCP config flag only if MCP servers are configured + try { + const geminiConfigPath = path.join(os.homedir(), '.gemini.json'); + let hasMcpServers = false; + + try { + await fs.access(geminiConfigPath); + const geminiConfigRaw = await fs.readFile(geminiConfigPath, 'utf8'); + const geminiConfig = JSON.parse(geminiConfigRaw); + + // Check global MCP servers + if (geminiConfig.mcpServers && Object.keys(geminiConfig.mcpServers).length > 0) { + hasMcpServers = true; + } + + // Check project-specific MCP servers + if (!hasMcpServers && geminiConfig.geminiProjects) { + const currentProjectPath = process.cwd(); + const projectConfig = geminiConfig.geminiProjects[currentProjectPath]; + if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) { + hasMcpServers = true; + } + } + } catch (e) { + // Ignore if file doesn't exist or isn't parsable + } + + if (hasMcpServers) { + args.push('--mcp-config', geminiConfigPath); + } + } catch (error) { + // Ignore outer errors + } + + // Add model for all sessions (both new and resumed) + let modelToUse = options.model || 'gemini-2.5-flash'; + args.push('--model', modelToUse); + args.push('--output-format', 'stream-json'); + + // Handle approval modes and allowed tools + if (settings.skipPermissions || options.skipPermissions || permissionMode === 'yolo') { + args.push('--yolo'); + } else if (permissionMode === 'auto_edit') { + args.push('--approval-mode', 'auto_edit'); + } else if (permissionMode === 'plan') { + args.push('--approval-mode', 'plan'); + } + + if (settings.allowedTools && settings.allowedTools.length > 0) { + args.push('--allowed-tools', settings.allowedTools.join(',')); + } + + // Try to find gemini in PATH first, then fall back to environment variable + const geminiPath = process.env.GEMINI_PATH || 'gemini'; + console.log('Spawning Gemini CLI:', geminiPath, args.join(' ')); + console.log('Working directory:', workingDir); + + let spawnCmd = geminiPath; + let spawnArgs = args; + + // On non-Windows platforms, wrap the execution in a shell to avoid ENOEXEC + // which happens when the target is a script lacking a shebang. + if (os.platform() !== 'win32') { + spawnCmd = 'sh'; + // Use exec to replace the shell process, ensuring signals hit gemini directly + spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args]; + } + + return new Promise((resolve, reject) => { + const geminiProcess = spawnFunction(spawnCmd, spawnArgs, { + cwd: workingDir, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env } // Inherit all environment variables + }); + + // Attach temp file info to process for cleanup later + geminiProcess.tempImagePaths = tempImagePaths; + geminiProcess.tempDir = tempDir; + + // Store process reference for potential abort + const processKey = capturedSessionId || sessionId || Date.now().toString(); + activeGeminiProcesses.set(processKey, geminiProcess); + + // Store sessionId on the process object for debugging + geminiProcess.sessionId = processKey; + + // Close stdin to signal we're done sending input + geminiProcess.stdin.end(); + + // Add timeout handler + let hasReceivedOutput = false; + const timeoutMs = 120000; // 120 seconds for slower models + let timeout; + + const startTimeout = () => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey); + ws.send({ + type: 'gemini-error', + sessionId: socketSessionId, + error: `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds` + }); + try { + geminiProcess.kill('SIGTERM'); + } catch (e) { } + }, timeoutMs); + }; + + startTimeout(); + + // Save user message to session when starting + if (command && capturedSessionId) { + sessionManager.addMessage(capturedSessionId, 'user', command); + } + + // Create response handler for NDJSON buffering + let responseHandler; + if (ws) { + responseHandler = new GeminiResponseHandler(ws, { + onContentFragment: (content) => { + if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') { + assistantBlocks[assistantBlocks.length - 1].text += content; + } else { + assistantBlocks.push({ type: 'text', text: content }); + } + }, + onToolUse: (event) => { + assistantBlocks.push({ + type: 'tool_use', + id: event.tool_id, + name: event.tool_name, + input: event.parameters + }); + }, + onToolResult: (event) => { + if (capturedSessionId) { + if (assistantBlocks.length > 0) { + sessionManager.addMessage(capturedSessionId, 'assistant', [...assistantBlocks]); + assistantBlocks = []; + } + sessionManager.addMessage(capturedSessionId, 'user', [{ + type: 'tool_result', + tool_use_id: event.tool_id, + content: event.output === undefined ? null : event.output, + is_error: event.status === 'error' + }]); + } + }, + onInit: (event) => { + if (capturedSessionId) { + const sess = sessionManager.getSession(capturedSessionId); + if (sess && !sess.cliSessionId) { + sess.cliSessionId = event.session_id; + sessionManager.saveSession(capturedSessionId); + } + } + } + }); + } + + // Handle stdout + geminiProcess.stdout.on('data', (data) => { + const rawOutput = data.toString(); + hasReceivedOutput = true; + startTimeout(); // Re-arm the timeout + + // For new sessions, create a session ID FIRST + if (!sessionId && !sessionCreatedSent && !capturedSessionId) { + capturedSessionId = `gemini_${Date.now()}`; + sessionCreatedSent = true; + + // Create session in session manager + sessionManager.createSession(capturedSessionId, cwd || process.cwd()); + + // Save the user message now that we have a session ID + if (command) { + sessionManager.addMessage(capturedSessionId, 'user', command); + } + + // Update process key with captured session ID + if (processKey !== capturedSessionId) { + activeGeminiProcesses.delete(processKey); + activeGeminiProcesses.set(capturedSessionId, geminiProcess); + } + + ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId); + + ws.send({ + type: 'session-created', + sessionId: capturedSessionId + }); + + // Emit fake system init so the frontend immediately navigates and saves the session + ws.send({ + type: 'claude-response', + sessionId: capturedSessionId, + data: { + type: 'system', + subtype: 'init', + session_id: capturedSessionId + } + }); + } + + if (responseHandler) { + responseHandler.processData(rawOutput); + } else if (rawOutput) { + // Fallback to direct sending for raw CLI mode without WS + if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') { + assistantBlocks[assistantBlocks.length - 1].text += rawOutput; + } else { + assistantBlocks.push({ type: 'text', text: rawOutput }); + } + const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId); + ws.send({ + type: 'gemini-response', + sessionId: socketSessionId, + data: { + type: 'message', + content: rawOutput + } + }); + } + }); + + // Handle stderr + geminiProcess.stderr.on('data', (data) => { + const errorMsg = data.toString(); + + // Filter out deprecation warnings and "Loaded cached credentials" message + if (errorMsg.includes('[DEP0040]') || + errorMsg.includes('DeprecationWarning') || + errorMsg.includes('--trace-deprecation') || + errorMsg.includes('Loaded cached credentials')) { + return; + } + + const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId); + ws.send({ + type: 'gemini-error', + sessionId: socketSessionId, + error: errorMsg + }); + }); + + // Handle process completion + geminiProcess.on('close', async (code) => { + clearTimeout(timeout); + + // Flush any remaining buffered content + if (responseHandler) { + responseHandler.forceFlush(); + responseHandler.destroy(); + } + + // Clean up process reference + const finalSessionId = capturedSessionId || sessionId || processKey; + activeGeminiProcesses.delete(finalSessionId); + + // Save assistant response to session if we have one + if (finalSessionId && assistantBlocks.length > 0) { + sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks); + } + + ws.send({ + type: 'claude-complete', // Use claude-complete for compatibility with UI + sessionId: finalSessionId, + exitCode: code, + isNewSession: !sessionId && !!command // Flag to indicate this was a new session + }); + + // Clean up temporary image files if any + if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) { + for (const imagePath of geminiProcess.tempImagePaths) { + await fs.unlink(imagePath).catch(err => { }); + } + if (geminiProcess.tempDir) { + await fs.rm(geminiProcess.tempDir, { recursive: true, force: true }).catch(err => { }); + } + } + + if (code === 0) { + resolve(); + } else { + reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`)); + } + }); + + // Handle process errors + geminiProcess.on('error', (error) => { + // Clean up process reference on error + const finalSessionId = capturedSessionId || sessionId || processKey; + activeGeminiProcesses.delete(finalSessionId); + + const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; + ws.send({ + type: 'gemini-error', + sessionId: errorSessionId, + error: error.message + }); + + reject(error); + }); + + }); +} + +function abortGeminiSession(sessionId) { + let geminiProc = activeGeminiProcesses.get(sessionId); + let processKey = sessionId; + + if (!geminiProc) { + for (const [key, proc] of activeGeminiProcesses.entries()) { + if (proc.sessionId === sessionId) { + geminiProc = proc; + processKey = key; + break; + } + } + } + + if (geminiProc) { + try { + geminiProc.kill('SIGTERM'); + setTimeout(() => { + if (activeGeminiProcesses.has(processKey)) { + try { + geminiProc.kill('SIGKILL'); + } catch (e) { } + } + }, 2000); // Wait 2 seconds before force kill + + return true; + } catch (error) { + return false; + } + } + return false; +} + +function isGeminiSessionActive(sessionId) { + return activeGeminiProcesses.has(sessionId); +} + +function getActiveGeminiSessions() { + return Array.from(activeGeminiProcesses.keys()); +} + +export { + spawnGemini, + abortGeminiSession, + isGeminiSessionActive, + getActiveGeminiSessions +}; diff --git a/server/gemini-response-handler.js b/server/gemini-response-handler.js new file mode 100644 index 0000000..e655d9a --- /dev/null +++ b/server/gemini-response-handler.js @@ -0,0 +1,140 @@ +// Gemini Response Handler - JSON Stream processing +class GeminiResponseHandler { + constructor(ws, options = {}) { + this.ws = ws; + this.buffer = ''; + this.onContentFragment = options.onContentFragment || null; + this.onInit = options.onInit || null; + this.onToolUse = options.onToolUse || null; + this.onToolResult = options.onToolResult || null; + } + + // Process incoming raw data from Gemini stream-json + processData(data) { + this.buffer += data; + + // Split by newline + const lines = this.buffer.split('\n'); + + // Keep the last incomplete line in the buffer + this.buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const event = JSON.parse(line); + this.handleEvent(event); + } catch (err) { + // Not a JSON line, probably debug output or CLI warnings + // console.error('[Gemini Handler] Non-JSON line ignored:', line); + } + } + } + + handleEvent(event) { + const socketSessionId = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null; + + if (event.type === 'init') { + if (this.onInit) { + this.onInit(event); + } + return; + } + + if (event.type === 'message' && event.role === 'assistant') { + const content = event.content || ''; + + // Notify the parent CLI handler of accumulated text + if (this.onContentFragment && content) { + this.onContentFragment(content); + } + + let payload = { + type: 'gemini-response', + data: { + type: 'message', + content: content, + isPartial: event.delta === true + } + }; + if (socketSessionId) payload.sessionId = socketSessionId; + this.ws.send(payload); + } + else if (event.type === 'tool_use') { + if (this.onToolUse) { + this.onToolUse(event); + } + let payload = { + type: 'gemini-tool-use', + toolName: event.tool_name, + toolId: event.tool_id, + parameters: event.parameters || {} + }; + if (socketSessionId) payload.sessionId = socketSessionId; + this.ws.send(payload); + } + else if (event.type === 'tool_result') { + if (this.onToolResult) { + this.onToolResult(event); + } + let payload = { + type: 'gemini-tool-result', + toolId: event.tool_id, + status: event.status, + output: event.output || '' + }; + if (socketSessionId) payload.sessionId = socketSessionId; + this.ws.send(payload); + } + else if (event.type === 'result') { + // Send a finalize message string + let payload = { + type: 'gemini-response', + data: { + type: 'message', + content: '', + isPartial: false + } + }; + if (socketSessionId) payload.sessionId = socketSessionId; + this.ws.send(payload); + + if (event.stats && event.stats.total_tokens) { + let statsPayload = { + type: 'claude-status', + data: { + status: 'Complete', + tokens: event.stats.total_tokens + } + }; + if (socketSessionId) statsPayload.sessionId = socketSessionId; + this.ws.send(statsPayload); + } + } + else if (event.type === 'error') { + let payload = { + type: 'gemini-error', + error: event.error || event.message || 'Unknown Gemini streaming error' + }; + if (socketSessionId) payload.sessionId = socketSessionId; + this.ws.send(payload); + } + } + + forceFlush() { + // If the buffer has content, try to parse it one last time + if (this.buffer.trim()) { + try { + const event = JSON.parse(this.buffer); + this.handleEvent(event); + } catch (err) { } + } + } + + destroy() { + this.buffer = ''; + } +} + +export default GeminiResponseHandler; diff --git a/server/index.js b/server/index.js index d4902ec..1cfc91c 100755 --- a/server/index.js +++ b/server/index.js @@ -48,6 +48,8 @@ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSess import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; +import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js'; +import sessionManager from './sessionManager.js'; import gitRoutes from './routes/git.js'; import authRoutes from './routes/auth.js'; import mcpRoutes from './routes/mcp.js'; @@ -61,6 +63,7 @@ import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes import cliAuthRoutes from './routes/cli-auth.js'; import userRoutes from './routes/user.js'; import codexRoutes from './routes/codex.js'; +import geminiRoutes from './routes/gemini.js'; import { initializeDatabase } from './database/db.js'; import { configureWebPush } from './services/vapid-keys.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; @@ -70,7 +73,9 @@ import { IS_PLATFORM } from './constants/config.js'; const PROVIDER_WATCH_PATHS = [ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') }, { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') }, - { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') } + { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }, + { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'projects') }, + { provider: 'gemini_sessions', rootPath: path.join(os.homedir(), '.gemini', 'sessions') } ]; const WATCHER_IGNORED_PATTERNS = [ '**/node_modules/**', @@ -320,25 +325,25 @@ app.locals.wss = wss; app.use(cors()); 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; + 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'); } - return contentType.includes('json'); - } })); app.use(express.urlencoded({ limit: '50mb', extended: true })); // Public health check endpoint (no authentication required) app.get('/health', (req, res) => { - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - installMode - }); + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + installMode + }); }); // Optional API key validation (if configured) @@ -380,6 +385,9 @@ app.use('/api/user', authenticateToken, userRoutes); // Codex API Routes (protected) app.use('/api/codex', authenticateToken, codexRoutes); +// Gemini API Routes (protected) +app.use('/api/gemini', authenticateToken, geminiRoutes); + // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); @@ -389,17 +397,17 @@ app.use(express.static(path.join(__dirname, '../public'))); // Static files served after API routes // Add cache control: HTML files should not be cached, but assets can be cached app.use(express.static(path.join(__dirname, '../dist'), { - setHeaders: (res, filePath) => { - if (filePath.endsWith('.html')) { - // Prevent HTML caching to avoid service worker issues after builds - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); - } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) { - // Cache static assets for 1 year (they have hashed names) - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + setHeaders: (res, filePath) => { + if (filePath.endsWith('.html')) { + // Prevent HTML caching to avoid service worker issues after builds + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) { + // Cache static assets for 1 year (they have hashed names) + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } } - } })); // API Routes (protected) @@ -497,13 +505,13 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateT try { const { projectName, sessionId } = req.params; const { limit, offset } = req.query; - + // Parse limit and offset if provided const parsedLimit = limit ? parseInt(limit, 10) : null; const parsedOffset = offset ? parseInt(offset, 10) : 0; - + const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset); - + // Handle both old and new response formats if (Array.isArray(result)) { // Backward compatibility: no pagination parameters were provided @@ -586,13 +594,13 @@ const expandWorkspacePath = (inputPath) => { app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { try { const { path: dirPath } = req.query; - + console.log('[API] Browse filesystem request for path:', dirPath); console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT); // Default to home directory if no path provided const defaultRoot = WORKSPACES_ROOT; let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot; - + // Resolve and normalize the path targetPath = path.resolve(targetPath); @@ -602,22 +610,22 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { return res.status(403).json({ error: validation.error }); } const resolvedPath = validation.resolvedPath || targetPath; - + // Security check - ensure path is accessible try { await fs.promises.access(resolvedPath); const stats = await fs.promises.stat(resolvedPath); - + if (!stats.isDirectory()) { return res.status(400).json({ error: 'Path is not a directory' }); } } catch (err) { return res.status(404).json({ error: 'Directory not accessible' }); } - + // Use existing getFileTree function with shallow depth (only direct children) const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false - + // Filter only directories and format for suggestions const directories = fileTree .filter(item => item.type === 'directory') @@ -633,7 +641,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { if (!aHidden && bHidden) return -1; return a.name.localeCompare(b.name); }); - + // Add common directories if browsing home directory const suggestions = []; let resolvedWorkspaceRoot = defaultRoot; @@ -646,17 +654,17 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace']; const existingCommon = directories.filter(dir => commonDirs.includes(dir.name)); const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name)); - + suggestions.push(...existingCommon, ...otherDirs); } else { suggestions.push(...directories); } - + res.json({ path: resolvedPath, suggestions: suggestions }); - + } catch (error) { console.error('Error browsing filesystem:', error); res.status(500).json({ error: 'Failed to browse filesystem' }); @@ -900,27 +908,27 @@ wss.on('connection', (ws, request) => { * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface */ class WebSocketWriter { - constructor(ws, userId = null) { - this.ws = ws; - this.sessionId = null; - this.userId = userId; - this.isWebSocketWriter = true; // Marker for transport detection - } - - send(data) { - if (this.ws.readyState === 1) { // WebSocket.OPEN - // Providers send raw objects, we stringify for WebSocket - this.ws.send(JSON.stringify(data)); + constructor(ws, userId = null) { + this.ws = ws; + this.sessionId = null; + this.userId = userId; + this.isWebSocketWriter = true; // Marker for transport detection } - } - setSessionId(sessionId) { - this.sessionId = sessionId; - } + send(data) { + if (this.ws.readyState === 1) { // WebSocket.OPEN + // Providers send raw objects, we stringify for WebSocket + this.ws.send(JSON.stringify(data)); + } + } - getSessionId() { - return this.sessionId; - } + setSessionId(sessionId) { + this.sessionId = sessionId; + } + + getSessionId() { + return this.sessionId; + } } // Handle chat WebSocket connections @@ -956,6 +964,12 @@ function handleChatConnection(ws, request) { console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); console.log('🤖 Model:', data.options?.model || 'default'); await queryCodex(data.command, data.options, writer); + } else if (data.type === 'gemini-command') { + console.log('[DEBUG] Gemini 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 spawnGemini(data.command, data.options, writer); } 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); @@ -973,6 +987,8 @@ function handleChatConnection(ws, request) { success = abortCursorSession(data.sessionId); } else if (provider === 'codex') { success = abortCodexSession(data.sessionId); + } else if (provider === 'gemini') { + success = abortGeminiSession(data.sessionId); } else { // Use Claude Agents SDK success = await abortClaudeSDKSession(data.sessionId); @@ -1015,6 +1031,8 @@ function handleChatConnection(ws, request) { isActive = isCursorSessionActive(sessionId); } else if (provider === 'codex') { isActive = isCodexSessionActive(sessionId); + } else if (provider === 'gemini') { + isActive = isGeminiSessionActive(sessionId); } else { // Use Claude Agents SDK isActive = isClaudeSDKSessionActive(sessionId); @@ -1031,7 +1049,8 @@ function handleChatConnection(ws, request) { const activeSessions = { claude: getActiveClaudeSDKSessions(), cursor: getActiveCursorSessions(), - codex: getActiveCodexSessions() + codex: getActiveCodexSessions(), + gemini: getActiveGeminiSessions() }; writer.send({ type: 'active-sessions', @@ -1140,7 +1159,7 @@ function handleShellConnection(ws) { if (isPlainShell) { welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`; } else { - const providerName = provider === 'cursor' ? 'Cursor' : provider === 'codex' ? 'Codex' : 'Claude'; + const providerName = provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude')); welcomeMsg = hasSession ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` : `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`; @@ -1176,6 +1195,7 @@ function handleShellConnection(ws) { shellCommand = `cd "${projectPath}" && cursor-agent`; } } + } else if (provider === 'codex') { // Use codex command if (os.platform() === 'win32') { @@ -1193,6 +1213,37 @@ function handleShellConnection(ws) { shellCommand = `cd "${projectPath}" && codex`; } } + } else if (provider === 'gemini') { + // Use gemini command + const command = initialCommand || 'gemini'; + let resumeId = sessionId; + if (hasSession && sessionId) { + try { + // Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names. + // The UI only knows about its internal generated `sessionId` (e.g. gemini_1234). + // We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell. + const sess = sessionManager.getSession(sessionId); + if (sess && sess.cliSessionId) { + resumeId = sess.cliSessionId; + } + } catch (err) { + console.error('Failed to get Gemini CLI session ID:', err); + } + } + + if (os.platform() === 'win32') { + if (hasSession && resumeId) { + shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`; + } else { + shellCommand = `Set-Location -Path "${projectPath}"; ${command}`; + } + } else { + if (hasSession && resumeId) { + shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`; + } else { + shellCommand = `cd "${projectPath}" && ${command}`; + } + } } else { // Use claude command (default) or initialCommand if provided const command = initialCommand || 'claude'; @@ -1626,203 +1677,214 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r // Get token usage for a specific session 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(); + 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' }); - } + // 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 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'); + // Handle Gemini sessions - they are raw logs in our current setup + if (provider === 'gemini') { + return res.json({ + used: 0, + total: 0, + breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 }, + unsupported: true, + message: 'Token usage tracking not available for Gemini 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; + // 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 { + projectPath = await extractProjectDirectory(projectName); } catch (error) { - // Skip directories we can't read + console.error('Error extracting project directory:', error); + return res.status(500).json({ error: 'Failed to determine project path' }); } - return null; - }; - const sessionFilePath = await findSessionFile(codexSessionsDir); + // Construct the JSONL file path + // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl + // The encoding replaces /, spaces, ~, and _ with - + const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-'); + const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); - if (!sessionFilePath) { - return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId }); - } + const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); - // 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 }); + // Constrain to projectDir + const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + return res.status(400).json({ error: 'Invalid path' }); } - 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--) { + // Read and parse the JSONL file + let fileContent; 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; + fileContent = await fsPromises.readFile(jsonlPath, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ error: 'Session file not found', path: jsonlPath }); } - if (tokenInfo.model_context_window) { - contextWindow = tokenInfo.model_context_window; + throw error; // Re-throw other errors to be caught by outer try-catch + } + const lines = fileContent.trim().split('\n'); + + const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10); + const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000; + let inputTokens = 0; + let cacheCreationTokens = 0; + let cacheReadTokens = 0; + + // Find the latest assistant message with usage data (scan from end) + for (let i = lines.length - 1; i >= 0; i--) { + try { + const entry = JSON.parse(lines[i]); + + // Only count assistant messages which have usage data + if (entry.type === 'assistant' && entry.message?.usage) { + const usage = entry.message.usage; + + // Use token counts from latest assistant message only + inputTokens = usage.input_tokens || 0; + cacheCreationTokens = usage.cache_creation_input_tokens || 0; + cacheReadTokens = usage.cache_read_input_tokens || 0; + + break; // Stop after finding the latest assistant message + } + } catch (parseError) { + // Skip lines that can't be parsed + continue; } - 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 - }); - } + // Calculate total context usage (excluding output_tokens, as per ccusage) + const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens; - // Handle Claude sessions (default) - // Extract actual project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); + res.json({ + used: totalUsed, + total: contextWindow, + breakdown: { + input: inputTokens, + cacheCreation: cacheCreationTokens, + cacheRead: cacheReadTokens + } + }); } catch (error) { - console.error('Error extracting project directory:', error); - return res.status(500).json({ error: 'Failed to determine project path' }); + console.error('Error reading session token usage:', error); + res.status(500).json({ error: 'Failed to read session token usage' }); } - - // Construct the JSONL file path - // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl - // The encoding replaces /, spaces, ~, and _ with - - const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-'); - const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); - - const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); - - // Constrain to projectDir - const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); - if (rel.startsWith('..') || path.isAbsolute(rel)) { - return res.status(400).json({ error: 'Invalid path' }); - } - - // Read and parse the JSONL file - let fileContent; - try { - fileContent = await fsPromises.readFile(jsonlPath, 'utf8'); - } catch (error) { - if (error.code === 'ENOENT') { - return res.status(404).json({ error: 'Session file not found', path: jsonlPath }); - } - throw error; // Re-throw other errors to be caught by outer try-catch - } - const lines = fileContent.trim().split('\n'); - - const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10); - const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000; - let inputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; - - // Find the latest assistant message with usage data (scan from end) - for (let i = lines.length - 1; i >= 0; i--) { - try { - const entry = JSON.parse(lines[i]); - - // Only count assistant messages which have usage data - if (entry.type === 'assistant' && entry.message?.usage) { - const usage = entry.message.usage; - - // Use token counts from latest assistant message only - inputTokens = usage.input_tokens || 0; - cacheCreationTokens = usage.cache_creation_input_tokens || 0; - cacheReadTokens = usage.cache_read_input_tokens || 0; - - break; // Stop after finding the latest assistant message - } - } catch (parseError) { - // Skip lines that can't be parsed - continue; - } - } - - // Calculate total context usage (excluding output_tokens, as per ccusage) - const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens; - - res.json({ - used: totalUsed, - total: contextWindow, - breakdown: { - input: inputTokens, - cacheCreation: cacheCreationTokens, - cacheRead: cacheReadTokens - } - }); - } catch (error) { - console.error('Error reading session token usage:', error); - res.status(500).json({ error: 'Failed to read session token usage' }); - } }); // Serve React app for all other routes (excluding static files) app.get('*', (req, res) => { - // Skip requests for static assets (files with extensions) - if (path.extname(req.path)) { - return res.status(404).send('Not found'); - } + // Skip requests for static assets (files with extensions) + if (path.extname(req.path)) { + return res.status(404).send('Not found'); + } - // Only serve index.html for HTML routes, not for static assets - // Static assets should already be handled by express.static middleware above - const indexPath = path.join(__dirname, '../dist/index.html'); + // Only serve index.html for HTML routes, not for static assets + // Static assets should already be handled by express.static middleware above + const indexPath = path.join(__dirname, '../dist/index.html'); - // Check if dist/index.html exists (production build available) - if (fs.existsSync(indexPath)) { - // Set no-cache headers for HTML to prevent service worker issues - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); - res.sendFile(indexPath); - } else { - // In development, redirect to Vite dev server only if dist doesn't exist - res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`); - } + // Check if dist/index.html exists (production build available) + if (fs.existsSync(indexPath)) { + // Set no-cache headers for HTML to prevent service worker issues + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.sendFile(indexPath); + } else { + // In development, redirect to Vite dev server only if dist doesn't exist + res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`); + } }); // Helper function to convert permissions to rwx format diff --git a/server/projects.js b/server/projects.js index b736bbb..bbe92cb 100755 --- a/server/projects.js +++ b/server/projects.js @@ -65,133 +65,134 @@ import crypto from 'crypto'; import sqlite3 from 'sqlite3'; import { open } from 'sqlite'; import os from 'os'; +import sessionManager from './sessionManager.js'; // Import TaskMaster detection functions async function detectTaskMasterFolder(projectPath) { + try { + const taskMasterPath = path.join(projectPath, '.taskmaster'); + + // Check if .taskmaster directory exists try { - const taskMasterPath = path.join(projectPath, '.taskmaster'); - - // Check if .taskmaster directory exists - try { - const stats = await fs.stat(taskMasterPath); - if (!stats.isDirectory()) { - return { - hasTaskmaster: false, - reason: '.taskmaster exists but is not a directory' - }; - } - } catch (error) { - if (error.code === 'ENOENT') { - return { - hasTaskmaster: false, - reason: '.taskmaster directory not found' - }; - } - throw error; - } - - // Check for key TaskMaster files - const keyFiles = [ - 'tasks/tasks.json', - 'config.json' - ]; - - const fileStatus = {}; - let hasEssentialFiles = true; - - for (const file of keyFiles) { - const filePath = path.join(taskMasterPath, file); - try { - await fs.access(filePath); - fileStatus[file] = true; - } catch (error) { - fileStatus[file] = false; - if (file === 'tasks/tasks.json') { - hasEssentialFiles = false; - } - } - } - - // Parse tasks.json if it exists for metadata - let taskMetadata = null; - if (fileStatus['tasks/tasks.json']) { - try { - const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json'); - const tasksContent = await fs.readFile(tasksPath, 'utf8'); - const tasksData = JSON.parse(tasksContent); - - // Handle both tagged and legacy formats - let tasks = []; - if (tasksData.tasks) { - // Legacy format - tasks = tasksData.tasks; - } else { - // Tagged format - get tasks from all tags - Object.values(tasksData).forEach(tagData => { - if (tagData.tasks) { - tasks = tasks.concat(tagData.tasks); - } - }); - } - - // Calculate task statistics - const stats = tasks.reduce((acc, task) => { - acc.total++; - acc[task.status] = (acc[task.status] || 0) + 1; - - // Count subtasks - if (task.subtasks) { - task.subtasks.forEach(subtask => { - acc.subtotalTasks++; - acc.subtasks = acc.subtasks || {}; - acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1; - }); - } - - return acc; - }, { - total: 0, - subtotalTasks: 0, - pending: 0, - 'in-progress': 0, - done: 0, - review: 0, - deferred: 0, - cancelled: 0, - subtasks: {} - }); - - taskMetadata = { - taskCount: stats.total, - subtaskCount: stats.subtotalTasks, - completed: stats.done || 0, - pending: stats.pending || 0, - inProgress: stats['in-progress'] || 0, - review: stats.review || 0, - completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0, - lastModified: (await fs.stat(tasksPath)).mtime.toISOString() - }; - } catch (parseError) { - console.warn('Failed to parse tasks.json:', parseError.message); - taskMetadata = { error: 'Failed to parse tasks.json' }; - } - } - + const stats = await fs.stat(taskMasterPath); + if (!stats.isDirectory()) { return { - hasTaskmaster: true, - hasEssentialFiles, - files: fileStatus, - metadata: taskMetadata, - path: taskMasterPath + hasTaskmaster: false, + reason: '.taskmaster exists but is not a directory' }; - + } } catch (error) { - console.error('Error detecting TaskMaster folder:', error); + if (error.code === 'ENOENT') { return { - hasTaskmaster: false, - reason: `Error checking directory: ${error.message}` + hasTaskmaster: false, + reason: '.taskmaster directory not found' }; + } + throw error; } + + // Check for key TaskMaster files + const keyFiles = [ + 'tasks/tasks.json', + 'config.json' + ]; + + const fileStatus = {}; + let hasEssentialFiles = true; + + for (const file of keyFiles) { + const filePath = path.join(taskMasterPath, file); + try { + await fs.access(filePath); + fileStatus[file] = true; + } catch (error) { + fileStatus[file] = false; + if (file === 'tasks/tasks.json') { + hasEssentialFiles = false; + } + } + } + + // Parse tasks.json if it exists for metadata + let taskMetadata = null; + if (fileStatus['tasks/tasks.json']) { + try { + const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json'); + const tasksContent = await fs.readFile(tasksPath, 'utf8'); + const tasksData = JSON.parse(tasksContent); + + // Handle both tagged and legacy formats + let tasks = []; + if (tasksData.tasks) { + // Legacy format + tasks = tasksData.tasks; + } else { + // Tagged format - get tasks from all tags + Object.values(tasksData).forEach(tagData => { + if (tagData.tasks) { + tasks = tasks.concat(tagData.tasks); + } + }); + } + + // Calculate task statistics + const stats = tasks.reduce((acc, task) => { + acc.total++; + acc[task.status] = (acc[task.status] || 0) + 1; + + // Count subtasks + if (task.subtasks) { + task.subtasks.forEach(subtask => { + acc.subtotalTasks++; + acc.subtasks = acc.subtasks || {}; + acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1; + }); + } + + return acc; + }, { + total: 0, + subtotalTasks: 0, + pending: 0, + 'in-progress': 0, + done: 0, + review: 0, + deferred: 0, + cancelled: 0, + subtasks: {} + }); + + taskMetadata = { + taskCount: stats.total, + subtaskCount: stats.subtotalTasks, + completed: stats.done || 0, + pending: stats.pending || 0, + inProgress: stats['in-progress'] || 0, + review: stats.review || 0, + completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0, + lastModified: (await fs.stat(tasksPath)).mtime.toISOString() + }; + } catch (parseError) { + console.warn('Failed to parse tasks.json:', parseError.message); + taskMetadata = { error: 'Failed to parse tasks.json' }; + } + } + + return { + hasTaskmaster: true, + hasEssentialFiles, + files: fileStatus, + metadata: taskMetadata, + path: taskMasterPath + }; + + } catch (error) { + console.error('Error detecting TaskMaster folder:', error); + return { + hasTaskmaster: false, + reason: `Error checking directory: ${error.message}` + }; + } } // Cache for extracted project directories @@ -218,7 +219,7 @@ async function loadProjectConfig() { async function saveProjectConfig(config) { const claudeDir = path.join(os.homedir(), '.claude'); const configPath = path.join(claudeDir, 'project-config.json'); - + // Ensure the .claude directory exists try { await fs.mkdir(claudeDir, { recursive: true }); @@ -227,7 +228,7 @@ async function saveProjectConfig(config) { throw error; } } - + await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); } @@ -235,13 +236,13 @@ async function saveProjectConfig(config) { async function generateDisplayName(projectName, actualProjectDir = null) { // Use actual project directory if provided, otherwise decode from project name let projectPath = actualProjectDir || projectName.replace(/-/g, '/'); - + // Try to read package.json from the project path try { const packageJsonPath = path.join(projectPath, 'package.json'); const packageData = await fs.readFile(packageJsonPath, 'utf8'); const packageJson = JSON.parse(packageData); - + // Return the name from package.json if it exists if (packageJson.name) { return packageJson.name; @@ -249,14 +250,14 @@ async function generateDisplayName(projectName, actualProjectDir = null) { } catch (error) { // Fall back to path-based naming if package.json doesn't exist or can't be read } - + // If it starts with /, it's an absolute path if (projectPath.startsWith('/')) { const parts = projectPath.split('/').filter(Boolean); // Return only the last folder name return parts[parts.length - 1] || projectPath; } - + return projectPath; } @@ -281,14 +282,14 @@ async function extractProjectDirectory(projectName) { let latestTimestamp = 0; let latestCwd = null; let extractedPath; - + try { // Check if the project directory exists await fs.access(projectDir); - + const files = await fs.readdir(projectDir); const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); - + if (jsonlFiles.length === 0) { // Fall back to decoded project name if no sessions extractedPath = projectName.replace(/-/g, '/'); @@ -301,16 +302,16 @@ async function extractProjectDirectory(projectName) { input: fileStream, crlfDelay: Infinity }); - + for await (const line of rl) { if (line.trim()) { try { const entry = JSON.parse(line); - + if (entry.cwd) { // Count occurrences of each cwd cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1); - + // Track the most recent cwd const timestamp = new Date(entry.timestamp || 0).getTime(); if (timestamp > latestTimestamp) { @@ -324,7 +325,7 @@ async function extractProjectDirectory(projectName) { } } } - + // Determine the best cwd to use if (cwdCounts.size === 0) { // No cwd found, fall back to decoded project name @@ -336,7 +337,7 @@ async function extractProjectDirectory(projectName) { // Multiple cwd values - prefer the most recent one if it has reasonable usage const mostRecentCount = cwdCounts.get(latestCwd) || 0; const maxCount = Math.max(...cwdCounts.values()); - + // Use most recent if it has at least 25% of the max count if (mostRecentCount >= maxCount * 0.25) { extractedPath = latestCwd; @@ -349,19 +350,19 @@ async function extractProjectDirectory(projectName) { } } } - + // Fallback (shouldn't reach here) if (!extractedPath) { extractedPath = latestCwd || projectName.replace(/-/g, '/'); } } } - + // Cache the result projectDirectoryCache.set(projectName, extractedPath); - + return extractedPath; - + } catch (error) { // If the directory doesn't exist, just use the decoded project name if (error.code === 'ENOENT') { @@ -371,10 +372,10 @@ async function extractProjectDirectory(projectName) { // Fall back to decoded project name for other errors extractedPath = projectName.replace(/-/g, '/'); } - + // Cache the fallback result too projectDirectoryCache.set(projectName, extractedPath); - + return extractedPath; } } @@ -408,91 +409,100 @@ async function getProjects(progressCallback = null) { totalProjects = directories.length + manualProjectsCount; for (const entry of directories) { - processedProjects++; + processedProjects++; - // Emit progress - if (progressCallback) { - progressCallback({ - phase: 'loading', - current: processedProjects, - total: totalProjects, - currentProject: entry.name - }); + // Emit progress + if (progressCallback) { + progressCallback({ + phase: 'loading', + current: processedProjects, + total: totalProjects, + currentProject: entry.name + }); + } + + // Extract actual project directory from JSONL sessions + const actualProjectDir = await extractProjectDirectory(entry.name); + + // Get display name from config or generate one + const customName = config[entry.name]?.displayName; + const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir); + const fullPath = actualProjectDir; + + const project = { + name: entry.name, + path: actualProjectDir, + displayName: customName || autoDisplayName, + fullPath: fullPath, + isCustomName: !!customName, + sessions: [], + geminiSessions: [], + sessionMeta: { + hasMore: false, + total: 0 } + }; - // Extract actual project directory from JSONL sessions - const actualProjectDir = await extractProjectDirectory(entry.name); - - // Get display name from config or generate one - const customName = config[entry.name]?.displayName; - const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir); - const fullPath = actualProjectDir; - - const project = { - name: entry.name, - path: actualProjectDir, - displayName: customName || autoDisplayName, - fullPath: fullPath, - isCustomName: !!customName, - sessions: [], - sessionMeta: { - hasMore: false, - total: 0 - } + // Try to get sessions for this project (just first 5 for performance) + try { + const sessionResult = await getSessions(entry.name, 5, 0); + project.sessions = sessionResult.sessions || []; + project.sessionMeta = { + hasMore: sessionResult.hasMore, + total: sessionResult.total }; - - // Try to get sessions for this project (just first 5 for performance) - try { - const sessionResult = await getSessions(entry.name, 5, 0); - project.sessions = sessionResult.sessions || []; - project.sessionMeta = { - hasMore: sessionResult.hasMore, - total: sessionResult.total - }; - } catch (e) { - console.warn(`Could not load sessions for project ${entry.name}:`, e.message); - project.sessionMeta = { - hasMore: false, - total: 0 - }; - } - - // Also fetch Cursor sessions for this project - try { - project.cursorSessions = await getCursorSessions(actualProjectDir); - } catch (e) { - console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message); - project.cursorSessions = []; - } + } catch (e) { + console.warn(`Could not load sessions for project ${entry.name}:`, e.message); + project.sessionMeta = { + hasMore: false, + total: 0 + }; + } - // Also fetch Codex sessions for this project - try { - project.codexSessions = await getCodexSessions(actualProjectDir, { - indexRef: codexSessionsIndexRef, - }); - } catch (e) { - console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message); - project.codexSessions = []; - } + // Also fetch Cursor sessions for this project + try { + project.cursorSessions = await getCursorSessions(actualProjectDir); + } catch (e) { + console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message); + project.cursorSessions = []; + } - // Add TaskMaster detection - try { - const taskMasterResult = await detectTaskMasterFolder(actualProjectDir); - project.taskmaster = { - hasTaskmaster: taskMasterResult.hasTaskmaster, - hasEssentialFiles: taskMasterResult.hasEssentialFiles, - metadata: taskMasterResult.metadata, - status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured' - }; - } catch (e) { - console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message); - project.taskmaster = { - hasTaskmaster: false, - hasEssentialFiles: false, - metadata: null, - status: 'error' - }; - } + // Also fetch Codex sessions for this project + try { + project.codexSessions = await getCodexSessions(actualProjectDir, { + indexRef: codexSessionsIndexRef, + }); + } catch (e) { + console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message); + project.codexSessions = []; + } + + // Also fetch Gemini sessions for this project + try { + project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; + } catch (e) { + console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message); + project.geminiSessions = []; + } + + // Add TaskMaster detection + try { + const taskMasterResult = await detectTaskMasterFolder(actualProjectDir); + project.taskmaster = { + hasTaskmaster: taskMasterResult.hasTaskmaster, + hasEssentialFiles: taskMasterResult.hasEssentialFiles, + metadata: taskMasterResult.metadata, + status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured' + }; + } catch (e) { + console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message); + project.taskmaster = { + hasTaskmaster: false, + hasEssentialFiles: false, + metadata: null, + status: 'error' + }; + } projects.push(project); } @@ -506,7 +516,7 @@ async function getProjects(progressCallback = null) { .filter(([name, cfg]) => cfg.manuallyAdded) .length; } - + // Add manually configured projects that don't exist as folders yet for (const [projectName, projectConfig] of Object.entries(config)) { if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) { @@ -524,7 +534,7 @@ async function getProjects(progressCallback = null) { // Use the original path if available, otherwise extract from potential sessions let actualProjectDir = projectConfig.originalPath; - + if (!actualProjectDir) { try { actualProjectDir = await extractProjectDirectory(projectName); @@ -533,21 +543,22 @@ async function getProjects(progressCallback = null) { actualProjectDir = projectName.replace(/-/g, '/'); } } - + const project = { - name: projectName, - path: actualProjectDir, - displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir), - fullPath: actualProjectDir, - isCustomName: !!projectConfig.displayName, - isManuallyAdded: true, - sessions: [], - sessionMeta: { - hasMore: false, - total: 0 - }, - cursorSessions: [], - codexSessions: [] + name: projectName, + path: actualProjectDir, + displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir), + fullPath: actualProjectDir, + isCustomName: !!projectConfig.displayName, + isManuallyAdded: true, + sessions: [], + geminiSessions: [], + sessionMeta: { + hasMore: false, + total: 0 + }, + cursorSessions: [], + codexSessions: [] }; // Try to fetch Cursor sessions for manual projects too @@ -566,16 +577,23 @@ async function getProjects(progressCallback = null) { console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message); } + // Try to fetch Gemini sessions for manual projects too + try { + project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; + } catch (e) { + console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message); + } + // Add TaskMaster detection for manual projects try { const taskMasterResult = await detectTaskMasterFolder(actualProjectDir); - + // Determine TaskMaster status let taskMasterStatus = 'not-configured'; if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) { taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk } - + project.taskmaster = { status: taskMasterStatus, hasTaskmaster: taskMasterResult.hasTaskmaster, @@ -591,7 +609,7 @@ async function getProjects(progressCallback = null) { error: error.message }; } - + projects.push(project); } } @@ -616,11 +634,11 @@ async function getSessions(projectName, limit = 5, offset = 0) { // agent-*.jsonl files contain session start data at this point. This needs to be revisited // periodically to make sure only accurate data is there and no new functionality is added there const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')); - + if (jsonlFiles.length === 0) { return { sessions: [], hasMore: false, total: 0 }; } - + // Sort files by modification time (newest first) const filesWithStats = await Promise.all( jsonlFiles.map(async (file) => { @@ -630,37 +648,37 @@ async function getSessions(projectName, limit = 5, offset = 0) { }) ); filesWithStats.sort((a, b) => b.mtime - a.mtime); - + const allSessions = new Map(); const allEntries = []; const uuidToSessionMap = new Map(); - + // Collect all sessions and entries from all files for (const { file } of filesWithStats) { const jsonlFile = path.join(projectDir, file); const result = await parseJsonlSessions(jsonlFile); - + result.sessions.forEach(session => { if (!allSessions.has(session.id)) { allSessions.set(session.id, session); } }); - + allEntries.push(...result.entries); - + // Early exit optimization for large projects if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) { break; } } - + // Build UUID-to-session mapping for timeline detection allEntries.forEach(entry => { if (entry.uuid && entry.sessionId) { uuidToSessionMap.set(entry.uuid, entry.sessionId); } }); - + // Group sessions by first user message ID const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] } const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId @@ -722,7 +740,7 @@ async function getSessions(projectName, limit = 5, offset = 0) { const total = visibleSessions.length; const paginatedSessions = visibleSessions.slice(offset, offset + limit); const hasMore = offset + limit < total; - + return { sessions: paginatedSessions, hasMore, @@ -926,8 +944,8 @@ async function parseAgentTools(filePath) { if (tool) { tool.toolResult = { content: typeof part.content === 'string' ? part.content : - Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') : - JSON.stringify(part.content), + Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') : + JSON.stringify(part.content), isError: Boolean(part.is_error) }; } @@ -1015,7 +1033,6 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset = } } } - // Sort messages by timestamp const sortedMessages = messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0) @@ -1051,7 +1068,7 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset = // Rename a project's display name async function renameProject(projectName, newDisplayName) { const config = await loadProjectConfig(); - + if (!newDisplayName || newDisplayName.trim() === '') { // Remove custom name if empty, will fall back to auto-generated delete config[projectName]; @@ -1061,7 +1078,7 @@ async function renameProject(projectName, newDisplayName) { displayName: newDisplayName.trim() }; } - + await saveProjectConfig(config); return true; } @@ -1069,21 +1086,21 @@ async function renameProject(projectName, newDisplayName) { // Delete a session from a project async function deleteSession(projectName, sessionId) { const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); - + try { const files = await fs.readdir(projectDir); const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); - + if (jsonlFiles.length === 0) { throw new Error('No session files found for this project'); } - + // Check all JSONL files to find which one contains the session for (const file of jsonlFiles) { const jsonlFile = path.join(projectDir, file); const content = await fs.readFile(jsonlFile, 'utf8'); const lines = content.split('\n').filter(line => line.trim()); - + // Check if this file contains the session const hasSession = lines.some(line => { try { @@ -1093,7 +1110,7 @@ async function deleteSession(projectName, sessionId) { return false; } }); - + if (hasSession) { // Filter out all entries for this session const filteredLines = lines.filter(line => { @@ -1104,13 +1121,13 @@ async function deleteSession(projectName, sessionId) { return true; // Keep malformed lines } }); - + // Write back the filtered content await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : '')); return true; } } - + throw new Error(`Session ${sessionId} not found in any files`); } catch (error) { console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error); @@ -1220,10 +1237,10 @@ async function addProjectManually(projectPath, displayName = null) { if (displayName) { config[projectName].displayName = displayName; } - + await saveProjectConfig(config); - - + + return { name: projectName, path: absolutePath, @@ -1241,7 +1258,7 @@ async function getCursorSessions(projectPath) { // Calculate cwdID hash for the project path (Cursor uses MD5 hash) const cwdId = crypto.createHash('md5').update(projectPath).digest('hex'); const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId); - + // Check if the directory exists try { await fs.access(cursorChatsPath); @@ -1249,25 +1266,25 @@ async function getCursorSessions(projectPath) { // No sessions for this project return []; } - + // List all session directories const sessionDirs = await fs.readdir(cursorChatsPath); const sessions = []; - + for (const sessionId of sessionDirs) { const sessionPath = path.join(cursorChatsPath, sessionId); const storeDbPath = path.join(sessionPath, 'store.db'); - + try { // Check if store.db exists await fs.access(storeDbPath); - + // Capture store.db mtime as a reliable fallback timestamp let dbStatMtimeMs = null; try { const stat = await fs.stat(storeDbPath); dbStatMtimeMs = stat.mtimeMs; - } catch (_) {} + } catch (_) { } // Open SQLite database const db = await open({ @@ -1275,12 +1292,12 @@ async function getCursorSessions(projectPath) { driver: sqlite3.Database, mode: sqlite3.OPEN_READONLY }); - + // Get metadata from meta table const metaRows = await db.all(` SELECT key, value FROM meta `); - + // Parse metadata let metadata = {}; for (const row of metaRows) { @@ -1299,17 +1316,17 @@ async function getCursorSessions(projectPath) { } } } - + // Get message count const messageCountResult = await db.get(` SELECT COUNT(*) as count FROM blobs `); - + await db.close(); - + // Extract session info const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session'; - + // Determine timestamp - prefer createdAt from metadata, fall back to db file mtime let createdAt = null; if (metadata.createdAt) { @@ -1319,7 +1336,7 @@ async function getCursorSessions(projectPath) { } else { createdAt = new Date().toISOString(); } - + sessions.push({ id: sessionId, name: sessionName, @@ -1328,18 +1345,18 @@ async function getCursorSessions(projectPath) { messageCount: messageCountResult.count || 0, projectPath: projectPath }); - + } catch (error) { console.warn(`Could not read Cursor session ${sessionId}:`, error.message); } } - + // Sort sessions by creation time (newest first) sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - + // Return only the first 5 sessions for performance return sessions.slice(0, 5); - + } catch (error) { console.error('Error fetching Cursor sessions:', error); return []; @@ -1785,7 +1802,7 @@ async function deleteCodexSession(sessionId) { files.push(fullPath); } } - } catch (error) {} + } catch (error) { } return files; }; diff --git a/server/routes/agent.js b/server/routes/agent.js index 3ef2620..8bc88c9 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -9,6 +9,7 @@ import { addProjectManually } from '../projects.js'; import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; import { queryCodex } from '../openai-codex.js'; +import { spawnGemini } from '../gemini-cli.js'; import { Octokit } from '@octokit/rest'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; import { IS_PLATFORM } from '../constants/config.js'; @@ -629,7 +630,7 @@ class ResponseCollector { * - Source for auto-generated branch names (if createBranch=true and no branchName) * - Fallback for PR title if no commits are made * - * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' + * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' * Default: 'claude' * * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates. @@ -747,7 +748,7 @@ class ResponseCollector { * Input Validations (400 Bad Request): * - Either githubUrl OR projectPath must be provided (not neither) * - message must be non-empty string - * - provider must be 'claude' or 'cursor' + * - provider must be 'claude', 'cursor', 'codex', or 'gemini' * - createBranch/createPR requires githubUrl OR projectPath (not neither) * - branchName must pass Git naming rules (if provided) * @@ -855,8 +856,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { return res.status(400).json({ error: 'message is required' }); } - if (!['claude', 'cursor', 'codex'].includes(provider)) { - return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' }); + if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) { + return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' }); } // Validate GitHub branch/PR creation requirements @@ -971,6 +972,16 @@ router.post('/', validateExternalApiKey, async (req, res) => { model: model || CODEX_MODELS.DEFAULT, permissionMode: 'bypassPermissions' }, writer); + } else if (provider === 'gemini') { + console.log('✨ Starting Gemini CLI session'); + + await spawnGemini(message.trim(), { + projectPath: finalProjectPath, + cwd: finalProjectPath, + sessionId: null, + model: model, + skipPermissions: true // CLI mode bypasses permissions + }, 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 1de309d..9b3721b 100644 --- a/server/routes/cli-auth.js +++ b/server/routes/cli-auth.js @@ -74,6 +74,46 @@ router.get('/codex/status', async (req, res) => { } }); +router.get('/gemini/status', async (req, res) => { + try { + const result = await checkGeminiCredentials(); + + res.json({ + authenticated: result.authenticated, + email: result.email, + error: result.error + }); + + } catch (error) { + console.error('Error checking Gemini auth status:', error); + res.status(500).json({ + authenticated: false, + email: null, + error: error.message + }); + } +}); + +/** + * Checks Claude authentication credentials using two methods with priority order: + * + * Priority 1: ANTHROPIC_API_KEY environment variable + * Priority 2: ~/.claude/.credentials.json OAuth tokens + * + * The Claude Agent SDK prioritizes environment variables over authenticated subscriptions. + * This matching behavior ensures consistency with how the SDK authenticates. + * + * References: + * - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code + * "Claude Code prioritizes environment variable API keys over authenticated subscriptions" + * - https://platform.claude.com/docs/en/agent-sdk/overview + * SDK authentication documentation + * + * @returns {Promise} Authentication status with { authenticated, email, method } + * - authenticated: boolean indicating if valid credentials exist + * - email: user email or auth method identifier + * - method: 'api_key' for env var, 'credentials_file' for OAuth tokens + */ async function checkClaudeCredentials() { try { const credPath = path.join(os.homedir(), '.claude', '.credentials.json'); @@ -260,4 +300,78 @@ async function checkCodexCredentials() { } } +async function checkGeminiCredentials() { + if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) { + return { + authenticated: true, + email: 'API Key Auth' + }; + } + + try { + const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + const content = await fs.readFile(credsPath, 'utf8'); + const creds = JSON.parse(content); + + if (creds.access_token) { + let email = 'OAuth Session'; + + try { + // Validate token against Google API + const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`); + if (tokenRes.ok) { + const tokenInfo = await tokenRes.json(); + if (tokenInfo.email) { + email = tokenInfo.email; + } + } else if (!creds.refresh_token) { + // Token invalid and no refresh token available + return { + authenticated: false, + email: null, + error: 'Access token invalid and no refresh token found' + }; + } else { + // Token might be expired but we have a refresh token, so CLI will refresh it + try { + const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); + const accContent = await fs.readFile(accPath, 'utf8'); + const accounts = JSON.parse(accContent); + if (accounts.active) { + email = accounts.active; + } + } catch (e) { } + } + } catch (e) { + // Network error, fallback to checking local accounts file + try { + const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); + const accContent = await fs.readFile(accPath, 'utf8'); + const accounts = JSON.parse(accContent); + if (accounts.active) { + email = accounts.active; + } + } catch (err) { } + } + + return { + authenticated: true, + email: email + }; + } + + return { + authenticated: false, + email: null, + error: 'No valid tokens found in oauth_creds' + }; + } catch (error) { + return { + authenticated: false, + email: null, + error: 'Gemini CLI not configured' + }; + } +} + export default router; diff --git a/server/routes/gemini.js b/server/routes/gemini.js new file mode 100644 index 0000000..2a30f99 --- /dev/null +++ b/server/routes/gemini.js @@ -0,0 +1,46 @@ +import express from 'express'; +import sessionManager from '../sessionManager.js'; + +const router = express.Router(); + +router.get('/sessions/:sessionId/messages', async (req, res) => { + try { + const { sessionId } = req.params; + + if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) { + return res.status(400).json({ success: false, error: 'Invalid session ID format' }); + } + + const messages = sessionManager.getSessionMessages(sessionId); + + res.json({ + success: true, + messages: messages, + total: messages.length, + hasMore: false, + offset: 0, + limit: messages.length + }); + } catch (error) { + console.error('Error fetching Gemini session messages:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.delete('/sessions/:sessionId', async (req, res) => { + try { + const { sessionId } = req.params; + + if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) { + return res.status(400).json({ success: false, error: 'Invalid session ID format' }); + } + + await sessionManager.deleteSession(sessionId); + res.json({ success: true }); + } catch (error) { + console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +export default router; diff --git a/server/sessionManager.js b/server/sessionManager.js new file mode 100644 index 0000000..1bf33bd --- /dev/null +++ b/server/sessionManager.js @@ -0,0 +1,226 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +class SessionManager { + constructor() { + // Store sessions in memory with conversation history + this.sessions = new Map(); + this.maxSessions = 100; + this.sessionsDir = path.join(os.homedir(), '.gemini', 'sessions'); + this.ready = this.init(); + } + + async init() { + await this.initSessionsDir(); + await this.loadSessions(); + } + + async initSessionsDir() { + try { + await fs.mkdir(this.sessionsDir, { recursive: true }); + } catch (error) { + // console.error('Error creating sessions directory:', error); + } + } + + // Create a new session + createSession(sessionId, projectPath) { + const session = { + id: sessionId, + projectPath: projectPath, + messages: [], + createdAt: new Date(), + lastActivity: new Date() + }; + + // Evict oldest session from memory if we exceed limit + if (this.sessions.size >= this.maxSessions) { + const oldestKey = this.sessions.keys().next().value; + if (oldestKey) this.sessions.delete(oldestKey); + } + + this.sessions.set(sessionId, session); + this.saveSession(sessionId); + + return session; + } + + // Add a message to session + addMessage(sessionId, role, content) { + let session = this.sessions.get(sessionId); + + if (!session) { + // Create session if it doesn't exist + session = this.createSession(sessionId, ''); + } + + const message = { + role: role, // 'user' or 'assistant' + content: content, + timestamp: new Date() + }; + + session.messages.push(message); + session.lastActivity = new Date(); + + this.saveSession(sessionId); + + return session; + } + + // Get session by ID + getSession(sessionId) { + return this.sessions.get(sessionId); + } + + // Get all sessions for a project + getProjectSessions(projectPath) { + const sessions = []; + + for (const [id, session] of this.sessions) { + if (session.projectPath === projectPath) { + sessions.push({ + id: session.id, + summary: this.getSessionSummary(session), + messageCount: session.messages.length, + lastActivity: session.lastActivity + }); + } + } + + return sessions.sort((a, b) => + new Date(b.lastActivity) - new Date(a.lastActivity) + ); + } + + // Get session summary + getSessionSummary(session) { + if (session.messages.length === 0) { + return 'New Session'; + } + + // Find first user message + const firstUserMessage = session.messages.find(m => m.role === 'user'); + if (firstUserMessage) { + const content = firstUserMessage.content; + return content.length > 50 ? content.substring(0, 50) + '...' : content; + } + + return 'New Session'; + } + + // Build conversation context for Gemini + buildConversationContext(sessionId, maxMessages = 10) { + const session = this.sessions.get(sessionId); + + if (!session || session.messages.length === 0) { + return ''; + } + + // Get last N messages for context + const recentMessages = session.messages.slice(-maxMessages); + + let context = 'Here is the conversation history:\n\n'; + + for (const msg of recentMessages) { + if (msg.role === 'user') { + context += `User: ${msg.content}\n`; + } else { + context += `Assistant: ${msg.content}\n`; + } + } + + context += '\nBased on the conversation history above, please answer the following:\n'; + + return context; + } + + // Prevent path traversal + _safeFilePath(sessionId) { + const safeId = String(sessionId).replace(/[/\\]|\.\./g, ''); + return path.join(this.sessionsDir, `${safeId}.json`); + } + + // Save session to disk + async saveSession(sessionId) { + const session = this.sessions.get(sessionId); + if (!session) return; + + try { + const filePath = this._safeFilePath(sessionId); + await fs.writeFile(filePath, JSON.stringify(session, null, 2)); + } catch (error) { + // console.error('Error saving session:', error); + } + } + + // Load sessions from disk + async loadSessions() { + try { + const files = await fs.readdir(this.sessionsDir); + + for (const file of files) { + if (file.endsWith('.json')) { + try { + const filePath = path.join(this.sessionsDir, file); + const data = await fs.readFile(filePath, 'utf8'); + const session = JSON.parse(data); + + // Convert dates + session.createdAt = new Date(session.createdAt); + session.lastActivity = new Date(session.lastActivity); + session.messages.forEach(msg => { + msg.timestamp = new Date(msg.timestamp); + }); + + this.sessions.set(session.id, session); + } catch (error) { + // console.error(`Error loading session ${file}:`, error); + } + } + } + + // Enforce eviction after loading to prevent massive memory usage + while (this.sessions.size > this.maxSessions) { + const oldestKey = this.sessions.keys().next().value; + if (oldestKey) this.sessions.delete(oldestKey); + } + } catch (error) { + // console.error('Error loading sessions:', error); + } + } + + // Delete a session + async deleteSession(sessionId) { + this.sessions.delete(sessionId); + + try { + const filePath = this._safeFilePath(sessionId); + await fs.unlink(filePath); + } catch (error) { + // console.error('Error deleting session file:', error); + } + } + + // Get session messages for display + getSessionMessages(sessionId) { + const session = this.sessions.get(sessionId); + if (!session) return []; + + return session.messages.map(msg => ({ + type: 'message', + message: { + role: msg.role, + content: msg.content + }, + timestamp: msg.timestamp.toISOString() + })); + } +} + +// Singleton instance +const sessionManager = new SessionManager(); + +export const ready = sessionManager.ready; +export default sessionManager; \ No newline at end of file diff --git a/shared/modelConstants.js b/shared/modelConstants.js index 4022340..df6a10d 100644 --- a/shared/modelConstants.js +++ b/shared/modelConstants.js @@ -65,3 +65,22 @@ export const CODEX_MODELS = { DEFAULT: 'gpt-5.3-codex' }; + +/** + * Gemini Models + */ +export const GEMINI_MODELS = { + OPTIONS: [ + { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' }, + { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, + { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' }, + { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, + { value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' }, + { value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' } + ], + + DEFAULT: 'gemini-2.5-flash' +}; diff --git a/src/components/GeminiLogo.jsx b/src/components/GeminiLogo.jsx new file mode 100644 index 0000000..b9fcca4 --- /dev/null +++ b/src/components/GeminiLogo.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const GeminiLogo = ({className = 'w-5 h-5'}) => { + return ( + Gemini + ); +}; + +export default GeminiLogo; \ No newline at end of file diff --git a/src/components/GeminiStatus.jsx b/src/components/GeminiStatus.jsx new file mode 100644 index 0000000..b5a10e7 --- /dev/null +++ b/src/components/GeminiStatus.jsx @@ -0,0 +1,90 @@ +import React, { useState, useEffect } from 'react'; +import { cn } from '../lib/utils'; + +function GeminiStatus({ status, onAbort, isLoading }) { + const [elapsedTime, setElapsedTime] = useState(0); + const [animationPhase, setAnimationPhase] = useState(0); + + // Update elapsed time every second + useEffect(() => { + if (!isLoading) { + setElapsedTime(0); + return; + } + + const startTime = Date.now(); + const timer = setInterval(() => { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + setElapsedTime(elapsed); + }, 1000); + + return () => clearInterval(timer); + }, [isLoading]); + + // Animate the status indicator + useEffect(() => { + if (!isLoading) return; + + const timer = setInterval(() => { + setAnimationPhase(prev => (prev + 1) % 4); + }, 500); + + return () => clearInterval(timer); + }, [isLoading]); + + if (!isLoading) return null; + + // Clever action words that cycle + const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning']; + const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length; + + // Parse status data + const statusText = status?.text || actionWords[actionIndex]; + const canInterrupt = status?.can_interrupt !== false; + + // Animation characters + const spinners = ['✻', '✹', '✸', '✶']; + const currentSpinner = spinners[animationPhase]; + + return ( +
+
+
+
+ {/* Animated spinner */} + + {currentSpinner} + + + {/* Status text - first line */} +
+
+ {statusText}... + ({elapsedTime}s) +
+
+
+
+ + {/* Interrupt button */} + {canInterrupt && onAbort && ( + + )} +
+
+ ); +} + +export default GeminiStatus; \ No newline at end of file diff --git a/src/components/LoginModal.jsx b/src/components/LoginModal.jsx index f391c6d..9106140 100644 --- a/src/components/LoginModal.jsx +++ b/src/components/LoginModal.jsx @@ -1,14 +1,14 @@ -import { X } from 'lucide-react'; +import { X, ExternalLink, KeyRound } from 'lucide-react'; import StandaloneShell from './standalone-shell/view/StandaloneShell'; import { IS_PLATFORM } from '../constants/config'; /** - * Reusable login modal component for Claude, Cursor, and Codex CLI authentication + * Reusable login modal component for Claude, Cursor, Codex, and Gemini CLI authentication * * @param {Object} props * @param {boolean} props.isOpen - Whether the modal is visible * @param {Function} props.onClose - Callback when modal is closed - * @param {'claude'|'cursor'|'codex'} props.provider - Which CLI provider to authenticate with + * @param {'claude'|'cursor'|'codex'|'gemini'} props.provider - Which CLI provider to authenticate with * @param {Object} props.project - Project object containing name and path information * @param {Function} props.onComplete - Callback when login process completes (receives exitCode) * @param {string} props.customCommand - Optional custom command to override defaults @@ -36,6 +36,9 @@ function LoginModal({ return 'cursor-agent login'; case 'codex': return IS_PLATFORM ? 'codex login --device-auth' : 'codex login'; + case 'gemini': + // No explicit interactive login command for gemini CLI exists yet similar to Claude, so we'll just check status or instruct the user to configure `.gemini.json` + return 'gemini status'; default: return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions'; } @@ -49,6 +52,8 @@ function LoginModal({ return 'Cursor CLI Login'; case 'codex': return 'Codex CLI Login'; + case 'gemini': + return 'Gemini CLI Configuration'; default: return 'CLI Login'; } @@ -77,12 +82,68 @@ function LoginModal({
- + {provider === 'gemini' ? ( +
+
+ +
+ +

+ Setup Gemini API Access +

+ +

+ The Gemini CLI requires an API key to function. Unlike Claude, you'll need to configure this directly in your terminal first. +

+ +
+
    +
  1. +
    + 1 +
    +
    +

    Get your API Key

    + + Google AI Studio + +
    +
  2. +
  3. +
    + 2 +
    +
    +

    Run configuration

    +

    Open your terminal and run:

    + + gemini config set api_key YOUR_KEY + +
    +
  4. +
+
+ + +
+ ) : ( + + )}
diff --git a/src/components/Onboarding.jsx b/src/components/Onboarding.jsx index bc9088e..15fed04 100644 --- a/src/components/Onboarding.jsx +++ b/src/components/Onboarding.jsx @@ -37,6 +37,13 @@ const Onboarding = ({ onComplete }) => { error: null }); + const [geminiAuthStatus, setGeminiAuthStatus] = useState({ + authenticated: false, + email: null, + loading: true, + error: null + }); + const { user } = useAuth(); const prevActiveLoginProviderRef = useRef(undefined); @@ -69,22 +76,23 @@ const Onboarding = ({ onComplete }) => { checkClaudeAuthStatus(); checkCursorAuthStatus(); checkCodexAuthStatus(); + checkGeminiAuthStatus(); } }, [activeLoginProvider]); - const checkClaudeAuthStatus = async () => { + const checkProviderAuthStatus = async (provider, setter) => { try { - const response = await authenticatedFetch('/api/cli/claude/status'); + const response = await authenticatedFetch(`/api/cli/${provider}/status`); if (response.ok) { const data = await response.json(); - setClaudeAuthStatus({ + setter({ authenticated: data.authenticated, email: data.email, loading: false, error: data.error || null }); } else { - setClaudeAuthStatus({ + setter({ authenticated: false, email: null, loading: false, @@ -92,8 +100,8 @@ const Onboarding = ({ onComplete }) => { }); } } catch (error) { - console.error('Error checking Claude auth status:', error); - setClaudeAuthStatus({ + console.error(`Error checking ${provider} auth status:`, error); + setter({ authenticated: false, email: null, loading: false, @@ -102,69 +110,15 @@ const Onboarding = ({ onComplete }) => { } }; - const checkCursorAuthStatus = async () => { - try { - const response = await authenticatedFetch('/api/cli/cursor/status'); - if (response.ok) { - const data = await response.json(); - setCursorAuthStatus({ - authenticated: data.authenticated, - email: data.email, - loading: false, - error: data.error || null - }); - } else { - setCursorAuthStatus({ - authenticated: false, - email: null, - loading: false, - error: 'Failed to check authentication status' - }); - } - } catch (error) { - console.error('Error checking Cursor auth status:', error); - setCursorAuthStatus({ - authenticated: false, - email: null, - loading: false, - error: error.message - }); - } - }; - - const checkCodexAuthStatus = async () => { - try { - const response = await authenticatedFetch('/api/cli/codex/status'); - if (response.ok) { - const data = await response.json(); - setCodexAuthStatus({ - authenticated: data.authenticated, - email: data.email, - loading: false, - error: data.error || null - }); - } else { - setCodexAuthStatus({ - authenticated: false, - email: null, - loading: false, - error: 'Failed to check authentication status' - }); - } - } catch (error) { - console.error('Error checking Codex auth status:', error); - setCodexAuthStatus({ - authenticated: false, - email: null, - loading: false, - error: error.message - }); - } - }; + const checkClaudeAuthStatus = () => checkProviderAuthStatus('claude', setClaudeAuthStatus); + const checkCursorAuthStatus = () => checkProviderAuthStatus('cursor', setCursorAuthStatus); + const checkCodexAuthStatus = () => checkProviderAuthStatus('codex', setCodexAuthStatus); + const checkGeminiAuthStatus = () => checkProviderAuthStatus('gemini', setGeminiAuthStatus); const handleClaudeLogin = () => setActiveLoginProvider('claude'); const handleCursorLogin = () => setActiveLoginProvider('cursor'); const handleCodexLogin = () => setActiveLoginProvider('codex'); + const handleGeminiLogin = () => setActiveLoginProvider('gemini'); const handleLoginComplete = (exitCode) => { if (exitCode === 0) { @@ -174,6 +128,8 @@ const Onboarding = ({ onComplete }) => { checkCursorAuthStatus(); } else if (activeLoginProvider === 'codex') { checkCodexAuthStatus(); + } else if (activeLoginProvider === 'gemini') { + checkGeminiAuthStatus(); } } }; @@ -337,11 +293,10 @@ const Onboarding = ({ onComplete }) => { {/* Agent Cards Grid */}
{/* Claude */} -
+
@@ -354,7 +309,7 @@ const Onboarding = ({ onComplete }) => {
{claudeAuthStatus.loading ? 'Checking...' : - claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'} + claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
@@ -370,11 +325,10 @@ const Onboarding = ({ onComplete }) => {
{/* Cursor */} -
+
@@ -387,7 +341,7 @@ const Onboarding = ({ onComplete }) => {
{cursorAuthStatus.loading ? 'Checking...' : - cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'} + cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
@@ -403,11 +357,10 @@ const Onboarding = ({ onComplete }) => {
{/* Codex */} -
+
@@ -420,7 +373,7 @@ const Onboarding = ({ onComplete }) => {
{codexAuthStatus.loading ? 'Checking...' : - codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'} + codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
@@ -434,6 +387,38 @@ const Onboarding = ({ onComplete }) => { )}
+ + {/* Gemini */} +
+
+
+
+ +
+
+
+ Gemini + {geminiAuthStatus.authenticated && } +
+
+ {geminiAuthStatus.loading ? 'Checking...' : + geminiAuthStatus.authenticated ? geminiAuthStatus.email || 'Connected' : 'Not connected'} +
+
+
+ {!geminiAuthStatus.authenticated && !geminiAuthStatus.loading && ( + + )} +
+
@@ -452,7 +437,7 @@ const Onboarding = ({ onComplete }) => { case 0: return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail); case 1: - return true; + return true; default: return false; } @@ -468,11 +453,10 @@ const Onboarding = ({ onComplete }) => { {steps.map((step, index) => (
-
+ 'bg-background border-border text-muted-foreground' + }`}> {index < currentStep ? ( ) : typeof step.icon === 'function' ? ( @@ -482,9 +466,8 @@ const Onboarding = ({ onComplete }) => { )}
-

+

{step.title}

{step.required && ( @@ -493,9 +476,8 @@ const Onboarding = ({ onComplete }) => {
{index < steps.length - 1 && ( -
+
)} ))} diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index a779bcd..7f4da96 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -41,6 +41,7 @@ interface UseChatComposerStateArgs { cursorModel: string; claudeModel: string; codexModel: string; + geminiModel: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | null; @@ -93,6 +94,7 @@ export function useChatComposerState({ cursorModel, claudeModel, codexModel, + geminiModel, isLoading, canAbortSession, tokenBudget, @@ -289,7 +291,7 @@ export function useChatComposerState({ projectName: selectedProject.name, sessionId: currentSessionId, provider, - model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : claudeModel, + model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel, tokenUsage: tokenBudget, }; @@ -343,6 +345,7 @@ export function useChatComposerState({ codexModel, currentSessionId, cursorModel, + geminiModel, handleBuiltInCommand, handleCustomCommand, input, @@ -581,8 +584,10 @@ export function useChatComposerState({ provider === 'cursor' ? 'cursor-tools-settings' : provider === 'codex' - ? 'codex-settings' - : 'claude-settings'; + ? 'codex-settings' + : provider === 'gemini' + ? 'gemini-settings' + : 'claude-settings'; const savedSettings = safeLocalStorage.getItem(settingsKey); if (savedSettings) { return JSON.parse(savedSettings); @@ -630,6 +635,21 @@ export function useChatComposerState({ permissionMode: permissionMode === 'plan' ? 'default' : permissionMode, }, }); + } else if (provider === 'gemini') { + sendMessage({ + type: 'gemini-command', + command: messageContent, + sessionId: effectiveSessionId, + options: { + cwd: resolvedProjectPath, + projectPath: resolvedProjectPath, + sessionId: effectiveSessionId, + resume: Boolean(effectiveSessionId), + model: geminiModel, + permissionMode, + toolsSettings, + }, + }); } else { sendMessage({ type: 'claude-command', @@ -669,6 +689,7 @@ export function useChatComposerState({ currentSessionId, cursorModel, executeCommand, + geminiModel, isLoading, onSessionActive, onSessionProcessing, diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index e2d98e5..8bd1151 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { authenticatedFetch } from '../../../utils/api'; -import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants'; +import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants'; import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types'; import type { ProjectSession, SessionProvider } from '../../../types/app'; @@ -23,6 +23,9 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr const [codexModel, setCodexModel] = useState(() => { return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT; }); + const [geminiModel, setGeminiModel] = useState(() => { + return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT; + }); const lastProviderRef = useRef(provider); @@ -105,6 +108,8 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr setClaudeModel, codexModel, setCodexModel, + geminiModel, + setGeminiModel, permissionMode, setPermissionMode, pendingPermissionRequests, diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 6ba11cc..d563261 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -145,6 +145,7 @@ export function useChatRealtimeHandlers({ 'claude-error', 'cursor-error', 'codex-error', + 'gemini-error', ]); const isClaudeSystemInit = @@ -162,8 +163,8 @@ export function useChatRealtimeHandlers({ const systemInitSessionId = isClaudeSystemInit ? structuredMessageData?.session_id : isCursorSystemInit - ? rawStructuredData?.session_id - : null; + ? rawStructuredData?.session_id + : null; const activeViewSessionId = selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null; @@ -176,7 +177,8 @@ export function useChatRealtimeHandlers({ !pendingViewSessionRef.current.sessionId && (latestMessage.type === 'claude-error' || latestMessage.type === 'cursor-error' || - latestMessage.type === 'codex-error'); + latestMessage.type === 'codex-error' || + latestMessage.type === 'gemini-error'); const handleBackgroundLifecycle = (sessionId?: string) => { if (!sessionId) { @@ -225,12 +227,6 @@ export function useChatRealtimeHandlers({ if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { handleBackgroundLifecycle(latestMessage.sessionId); } - console.log( - 'Skipping message for different session:', - latestMessage.sessionId, - 'current:', - activeViewSessionId, - ); return; } } @@ -297,11 +293,6 @@ export function useChatRealtimeHandlers({ structuredMessageData.session_id !== currentSessionId && isSystemInitForView ) { - console.log('Claude CLI session duplication detected:', { - originalSession: currentSessionId, - newSession: structuredMessageData.session_id, - }); - setIsSystemSessionChange(true); onNavigateToSession?.(structuredMessageData.session_id); return; @@ -314,10 +305,6 @@ export function useChatRealtimeHandlers({ !currentSessionId && isSystemInitForView ) { - console.log('New session init detected:', { - newSession: structuredMessageData.session_id, - }); - setIsSystemSessionChange(true); onNavigateToSession?.(structuredMessageData.session_id); return; @@ -331,7 +318,6 @@ export function useChatRealtimeHandlers({ structuredMessageData.session_id === currentSessionId && isSystemInitForView ) { - console.log('System init message for current session, ignoring'); return; } @@ -583,17 +569,12 @@ export function useChatRealtimeHandlers({ } if (currentSessionId && cursorData.session_id !== currentSessionId) { - console.log('Cursor session switch detected:', { - originalSession: currentSessionId, - newSession: cursorData.session_id, - }); setIsSystemSessionChange(true); onNavigateToSession?.(cursorData.session_id); return; } if (!currentSessionId) { - console.log('Cursor new session init detected:', { newSession: cursorData.session_id }); setIsSystemSessionChange(true); onNavigateToSession?.(cursorData.session_id); return; @@ -612,9 +593,8 @@ export function useChatRealtimeHandlers({ ...previous, { type: 'assistant', - content: `Using tool: ${latestMessage.tool} ${ - latestMessage.input ? `with ${latestMessage.input}` : '' - }`, + content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : '' + }`, timestamp: new Date(), isToolUse: true, toolName: latestMessage.tool, @@ -897,7 +877,6 @@ export function useChatRealtimeHandlers({ onNavigateToSession?.(codexActualSessionId); } sessionStorage.removeItem('pendingSessionId'); - console.log('Codex session complete, ID set to:', codexPendingSessionId); } if (selectedProject) { @@ -919,6 +898,91 @@ export function useChatRealtimeHandlers({ ]); break; + case 'gemini-response': { + const geminiData = latestMessage.data; + + if (geminiData && geminiData.type === 'message' && typeof geminiData.content === 'string') { + const content = decodeHtmlEntities(geminiData.content); + + if (content) { + streamBufferRef.current += streamBufferRef.current ? `\n${content}` : content; + } + + if (!geminiData.isPartial) { + // Immediate flush and finalization for the last chunk + if (streamTimerRef.current) { + clearTimeout(streamTimerRef.current); + streamTimerRef.current = null; + } + const chunk = streamBufferRef.current; + streamBufferRef.current = ''; + + if (chunk) { + appendStreamingChunk(setChatMessages, chunk, true); + } + finalizeStreamingMessage(setChatMessages); + } else if (!streamTimerRef.current && streamBufferRef.current) { + streamTimerRef.current = window.setTimeout(() => { + const chunk = streamBufferRef.current; + streamBufferRef.current = ''; + streamTimerRef.current = null; + + if (chunk) { + appendStreamingChunk(setChatMessages, chunk, true); + } + }, 100); + } + } + break; + } + + case 'gemini-error': + setIsLoading(false); + setCanAbortSession(false); + setChatMessages((previous) => [ + ...previous, + { + type: 'error', + content: latestMessage.error || 'An error occurred with Gemini', + timestamp: new Date(), + }, + ]); + break; + + case 'gemini-tool-use': + setChatMessages((previous) => [ + ...previous, + { + type: 'assistant', + content: '', + timestamp: new Date(), + isToolUse: true, + toolName: latestMessage.toolName, + toolInput: latestMessage.parameters ? JSON.stringify(latestMessage.parameters, null, 2) : '', + toolId: latestMessage.toolId, + toolResult: null, + } + ]); + break; + + case 'gemini-tool-result': + setChatMessages((previous) => + previous.map((message) => { + if (message.isToolUse && message.toolId === latestMessage.toolId) { + return { + ...message, + toolResult: { + content: latestMessage.output || `Status: ${latestMessage.status}`, + isError: latestMessage.status === 'error', + timestamp: new Date(), + }, + }; + } + return message; + }), + ); + break; + case 'session-aborted': { const pendingSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 11091e5..65c9043 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -64,6 +64,8 @@ function ChatInterface({ setClaudeModel, codexModel, setCodexModel, + geminiModel, + setGeminiModel, permissionMode, pendingPermissionRequests, setPendingPermissionRequests, @@ -174,6 +176,7 @@ function ChatInterface({ cursorModel, claudeModel, codexModel, + geminiModel, isLoading, canAbortSession, tokenBudget, @@ -251,7 +254,9 @@ function ChatInterface({ ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') - : t('messageTypes.claude'); + : provider === 'gemini' + ? t('messageTypes.gemini') + : t('messageTypes.claude'); return (
@@ -287,6 +292,8 @@ function ChatInterface({ setCursorModel={setCursorModel} codexModel={codexModel} setCodexModel={setCodexModel} + geminiModel={geminiModel} + setGeminiModel={setGeminiModel} tasksEnabled={tasksEnabled} isTaskMasterInstalled={isTaskMasterInstalled} onShowAllTasks={onShowAllTasks} @@ -374,8 +381,10 @@ function ChatInterface({ provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' - ? t('messageTypes.codex') - : t('messageTypes.claude'), + ? t('messageTypes.codex') + : provider === 'gemini' + ? t('messageTypes.gemini') + : t('messageTypes.claude'), })} isTextareaExpanded={isTextareaExpanded} sendByCtrlEnter={sendByCtrlEnter} diff --git a/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx b/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx index 8d873cc..3fc8ca8 100644 --- a/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx +++ b/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx @@ -16,7 +16,7 @@ export default function AssistantThinkingIndicator({ selectedProvider }: Assista
- {selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'} + {selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}
diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index ea38ed7..4ca67bd 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -26,6 +26,8 @@ interface ChatMessagesPaneProps { setCursorModel: (model: string) => void; codexModel: string; setCodexModel: (model: string) => void; + geminiModel: string; + setGeminiModel: (model: string) => void; tasksEnabled: boolean; isTaskMasterInstalled: boolean | null; onShowAllTasks?: (() => void) | null; @@ -70,6 +72,8 @@ export default function ChatMessagesPane({ setCursorModel, codexModel, setCodexModel, + geminiModel, + setGeminiModel, tasksEnabled, isTaskMasterInstalled, onShowAllTasks, @@ -152,6 +156,8 @@ export default function ChatMessagesPane({ setCursorModel={setCursorModel} codexModel={codexModel} setCodexModel={setCodexModel} + geminiModel={geminiModel} + setGeminiModel={setGeminiModel} tasksEnabled={tasksEnabled} isTaskMasterInstalled={isTaskMasterInstalled} onShowAllTasks={onShowAllTasks} diff --git a/src/components/chat/view/subcomponents/ClaudeStatus.tsx b/src/components/chat/view/subcomponents/ClaudeStatus.tsx index 8427def..c5e41cd 100644 --- a/src/components/chat/view/subcomponents/ClaudeStatus.tsx +++ b/src/components/chat/view/subcomponents/ClaudeStatus.tsx @@ -60,6 +60,7 @@ export default function ClaudeStatus({ return null; } + // Note: showThinking only controls the reasoning accordion in messages, not this processing indicator const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length; const statusText = status?.text || ACTION_WORDS[actionIndex]; const tokens = status?.tokens || fakeTokens; @@ -101,6 +102,7 @@ export default function ClaudeStatus({ {canInterrupt && onAbort && (
)}
- {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))} + {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}
)} - +
{message.isToolUse ? ( @@ -188,7 +188,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile subagentState={message.subagentState} /> )} - + {/* Tool Result Section */} {message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && ( message.toolResult.isError ? ( @@ -222,11 +222,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile } }} disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'} - className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${ - permissionSuggestion.isAllowed || permissionGrantState === 'granted' - ? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default' - : 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70' - }`} + className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted' + ? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default' + : 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70' + }`} > {permissionSuggestion.isAllowed || permissionGrantState === 'granted' ? t('permissions.added') @@ -294,7 +293,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile const lines = (message.content || '').split('\n').filter((line) => line.trim()); const questionLine = lines.find((line) => line.includes('?')) || lines[0] || ''; const options: InteractiveOption[] = []; - + // Parse the menu options lines.forEach((line) => { // Match lines like "❯ 1. Yes" or " 2. No" @@ -308,31 +307,29 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile }); } }); - + return ( <>

{questionLine}

- + {/* Option buttons */}
{options.map((option) => ( ))}
- +

{t('interactive.waiting')} @@ -399,7 +396,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile // Detect if content is pure JSON (starts with { or [) const trimmedContent = content.trim(); if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) && - (trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) { + (trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) { try { const parsed = JSON.parse(trimmedContent); const formatted = JSON.stringify(parsed, null, 2); @@ -439,7 +436,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile })()}

)} - + {!isGrouped && (
{formattedTime} diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 3fef5a9..e17b9a4 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -3,7 +3,7 @@ import { Check, ChevronDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import NextTaskBanner from '../../../NextTaskBanner.jsx'; -import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants'; +import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants'; import type { ProjectSession, SessionProvider } from '../../../../types/app'; interface ProviderSelectionEmptyStateProps { @@ -18,6 +18,8 @@ interface ProviderSelectionEmptyStateProps { setCursorModel: (model: string) => void; codexModel: string; setCodexModel: (model: string) => void; + geminiModel: string; + setGeminiModel: (model: string) => void; tasksEnabled: boolean; isTaskMasterInstalled: boolean | null; onShowAllTasks?: (() => void) | null; @@ -58,17 +60,27 @@ const PROVIDERS: ProviderDef[] = [ ring: 'ring-emerald-600/15', check: 'bg-emerald-600 dark:bg-emerald-500 text-white', }, + { + id: 'gemini', + name: 'Gemini', + infoKey: 'providerSelection.providerInfo.google', + accent: 'border-blue-500 dark:border-blue-400', + ring: 'ring-blue-500/15', + check: 'bg-blue-500 text-white', + }, ]; function getModelConfig(p: SessionProvider) { if (p === 'claude') return CLAUDE_MODELS; if (p === 'codex') return CODEX_MODELS; + if (p === 'gemini') return GEMINI_MODELS; return CURSOR_MODELS; } -function getModelValue(p: SessionProvider, c: string, cu: string, co: string) { +function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string) { if (p === 'claude') return c; if (p === 'codex') return co; + if (p === 'gemini') return g; return cu; } @@ -84,6 +96,8 @@ export default function ProviderSelectionEmptyState({ setCursorModel, codexModel, setCodexModel, + geminiModel, + setGeminiModel, tasksEnabled, isTaskMasterInstalled, onShowAllTasks, @@ -101,11 +115,12 @@ export default function ProviderSelectionEmptyState({ const handleModelChange = (value: string) => { if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); } else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); } + else if (provider === 'gemini') { setGeminiModel(value); localStorage.setItem('gemini-model', value); } else { setCursorModel(value); localStorage.setItem('cursor-model', value); } }; const modelConfig = getModelConfig(provider); - const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel); + const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel); /* ── New session — provider picker ── */ if (!selectedSession && !currentSessionId) { @@ -123,7 +138,7 @@ export default function ProviderSelectionEmptyState({
{/* Provider cards — horizontal row, equal width */} -
+
{PROVIDERS.map((p) => { const active = provider === p.id; return ( @@ -179,13 +194,14 @@ export default function ProviderSelectionEmptyState({

- {provider === 'claude' - ? t('providerSelection.readyPrompt.claude', { model: claudeModel }) - : provider === 'cursor' - ? t('providerSelection.readyPrompt.cursor', { model: cursorModel }) - : provider === 'codex' - ? t('providerSelection.readyPrompt.codex', { model: codexModel }) - : t('providerSelection.readyPrompt.default')} + { + { + claude: t('providerSelection.readyPrompt.claude', { model: claudeModel }), + cursor: t('providerSelection.readyPrompt.cursor', { model: cursorModel }), + codex: t('providerSelection.readyPrompt.codex', { model: codexModel }), + gemini: t('providerSelection.readyPrompt.gemini', { model: geminiModel }), + }[provider] + }

diff --git a/src/components/llm-logo-provider/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx index c80efe1..c6c2372 100644 --- a/src/components/llm-logo-provider/SessionProviderLogo.tsx +++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx @@ -2,6 +2,7 @@ import type { SessionProvider } from '../../types/app'; import ClaudeLogo from './ClaudeLogo'; import CodexLogo from './CodexLogo'; import CursorLogo from './CursorLogo'; +import GeminiLogo from '../GeminiLogo'; type SessionProviderLogoProps = { provider?: SessionProvider | string | null; @@ -20,5 +21,9 @@ export default function SessionProviderLogo({ return ; } + if (provider === 'gemini') { + return ; + } + return ; } diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index 628d8d8..36f4539 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -92,4 +92,5 @@ export const AUTH_STATUS_ENDPOINTS: Record = { claude: '/api/cli/claude/status', cursor: '/api/cli/cursor/status', codex: '/api/cli/codex/status', + gemini: '/api/cli/gemini/status', }; diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index ddb84ea..13d00cc 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -16,6 +16,7 @@ import type { CodexMcpFormState, CodexPermissionMode, CursorPermissionsState, + GeminiPermissionMode, McpServer, McpToolsResult, McpTestResult, @@ -225,6 +226,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: createDefaultNotificationPreferences() )); const [codexPermissionMode, setCodexPermissionMode] = useState('default'); + const [geminiPermissionMode, setGeminiPermissionMode] = useState('default'); const [mcpServers, setMcpServers] = useState([]); const [cursorMcpServers, setCursorMcpServers] = useState([]); @@ -245,6 +247,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: const [claudeAuthStatus, setClaudeAuthStatus] = useState(DEFAULT_AUTH_STATUS); const [cursorAuthStatus, setCursorAuthStatus] = useState(DEFAULT_AUTH_STATUS); const [codexAuthStatus, setCodexAuthStatus] = useState(DEFAULT_AUTH_STATUS); + const [geminiAuthStatus, setGeminiAuthStatus] = useState(DEFAULT_AUTH_STATUS); const setAuthStatusByProvider = useCallback((provider: AgentProvider, status: AuthStatus) => { if (provider === 'claude') { @@ -257,6 +260,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: return; } + if (provider === 'gemini') { + setGeminiAuthStatus(status); + return; + } + setCodexAuthStatus(status); }, []); @@ -676,6 +684,12 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: ); setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode)); + const savedGeminiSettings = parseJson<{ permissionMode?: GeminiPermissionMode }>( + localStorage.getItem('gemini-settings'), + {}, + ); + setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default'); + try { const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences'); if (notificationResponse.ok) { @@ -748,6 +762,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: lastUpdated: now, })); + localStorage.setItem('gemini-settings', JSON.stringify({ + permissionMode: geminiPermissionMode, + lastUpdated: now, + })); + const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', { method: 'PUT', body: JSON.stringify(notificationPreferences), @@ -818,6 +837,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: void checkAuthStatus('claude'); void checkAuthStatus('cursor'); void checkAuthStatus('codex'); + void checkAuthStatus('gemini'); }, [checkAuthStatus, initialTab, isOpen, loadSettings]); useEffect(() => { @@ -879,6 +899,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: claudeAuthStatus, cursorAuthStatus, codexAuthStatus, + geminiAuthStatus, + geminiPermissionMode, + setGeminiPermissionMode, openLoginForProvider, showLoginModal, setShowLoginModal, diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index 950e914..be2d2cd 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -1,11 +1,12 @@ import type { Dispatch, SetStateAction } from 'react'; export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications'; -export type AgentProvider = 'claude' | 'cursor' | 'codex'; +export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini'; export type AgentCategory = 'account' | 'permissions' | 'mcp'; export type ProjectSortOrder = 'name' | 'date'; export type SaveStatus = 'success' | 'error' | null; export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; +export type GeminiPermissionMode = 'default' | 'auto_edit' | 'yolo'; export type McpImportMode = 'form' | 'json'; export type McpScope = 'user' | 'local'; export type McpTransportType = 'stdio' | 'sse' | 'http'; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index b8164ea..c5da46e 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -69,6 +69,9 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set claudeAuthStatus, cursorAuthStatus, codexAuthStatus, + geminiAuthStatus, + geminiPermissionMode, + setGeminiPermissionMode, openLoginForProvider, showLoginModal, setShowLoginModal, @@ -114,10 +117,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set const isAuthenticated = loginProvider === 'claude' ? claudeAuthStatus.authenticated : loginProvider === 'cursor' - ? cursorAuthStatus.authenticated - : loginProvider === 'codex' - ? codexAuthStatus.authenticated - : false; + ? cursorAuthStatus.authenticated + : loginProvider === 'codex' + ? codexAuthStatus.authenticated + : false; return (
@@ -161,15 +164,19 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set claudeAuthStatus={claudeAuthStatus} cursorAuthStatus={cursorAuthStatus} codexAuthStatus={codexAuthStatus} + geminiAuthStatus={geminiAuthStatus} onClaudeLogin={() => openLoginForProvider('claude')} onCursorLogin={() => openLoginForProvider('cursor')} onCodexLogin={() => openLoginForProvider('codex')} + onGeminiLogin={() => openLoginForProvider('gemini')} claudePermissions={claudePermissions} onClaudePermissionsChange={setClaudePermissions} cursorPermissions={cursorPermissions} onCursorPermissionsChange={setCursorPermissions} codexPermissionMode={codexPermissionMode} onCodexPermissionModeChange={setCodexPermissionMode} + geminiPermissionMode={geminiPermissionMode} + onGeminiPermissionModeChange={setGeminiPermissionMode} mcpServers={mcpServers} cursorMcpServers={cursorMcpServers} codexMcpServers={codexMcpServers} diff --git a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx index 21f07cc..a4cc590 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx @@ -12,7 +12,7 @@ type AgentListItemProps = { type AgentConfig = { name: string; - color: 'blue' | 'purple' | 'gray'; + color: 'blue' | 'purple' | 'gray' | 'indigo'; }; const agentConfig: Record = { @@ -28,6 +28,10 @@ const agentConfig: Record = { name: 'Codex', color: 'gray', }, + gemini: { + name: 'Gemini', + color: 'indigo', + } }; const colorClasses = { @@ -49,6 +53,12 @@ const colorClasses = { bg: 'bg-gray-100 dark:bg-gray-800/50', dot: 'bg-gray-700 dark:bg-gray-300', }, + indigo: { + border: 'border-l-indigo-500 md:border-l-indigo-500', + borderBottom: 'border-b-indigo-500', + bg: 'bg-indigo-50 dark:bg-indigo-900/20', + dot: 'bg-indigo-500', + }, } as const; export default function AgentListItem({ @@ -66,11 +76,10 @@ export default function AgentListItem({ return (