mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-07 05:45:39 +08:00
feat: Google's gemini-cli integration (#422)
* feat: integrate Gemini AI agent provider - Core Backend: Ported gemini-cli.js and gemini-response-handler.js to establish the CLI bridge. Registered 'gemini' as an active provider within index.js. - Core Frontend: Extended QuickSettingsPanel.jsx, Settings.jsx, and AgentListItem.jsx to render the Gemini provider option, models (gemini-pro, gemini-flash, etc.), and handle OAuth states. - WebSocket Pipeline: Added support for gemini-command executions in backend and payload processing of gemini-response and gemini-error streams in useChatRealtimeHandlers.ts. Resolved JSON double-stringification and sessionId stripping issues in the transmission handler. - Platform Compatibility: Added scripts/fix-node-pty.js postinstall script and modified posix_spawnp calls with sh -c wrapper to prevent ENOEXEC and MacOS permission errors when spawning the gemini headless binary. - UX & Design: Imported official Google Gemini branding via GeminiLogo.jsx and gemini-ai-icon.svg. Updated translations (chat.json) for en, zh-CN, and ko locales. * fix: propagate gemini permission mode from settings to cli - Added Gemini Permissions UI in Settings to toggle Auto Edit and YOLO modes - Synced gemini permission mode to localStorage - Passed permissionMode in useChatComposerState for Gemini commands - Mapped frontend permission modes to --yolo and --approval-mode options in gemini-cli.js * feat(gemini): Refactor Gemini CLI integration to use stream-json - Replaced regex buffering text-system with NDJSON stream parsing - Added fallback for restricted models like gemini-3.1-pro-preview * feat(gemini): Render tool_use and tool_result UI bubbles - Forwarded gemini tool NDJSON objects to the websocket - Added React state handlers in useChatRealtimeHandlers to match Claude's tool UI behavior * feat(gemini): Add native session resumption and UI token tracking - Captured cliSessionId from init events to map ClaudeCodeUI's chat sessionId directly into Gemini's internal session manager. - Updated gemini-cli.js spawn arguments to append the --resume proxy flag instead of naively dumping the accumulated chat history into the command prompt. - Handled result stream objects by proxying total_tokens back into the frontend's claude-status tracker to natively populate the UI label. - Eliminated gemini-3 model proxy filter entirely. * fix(gemini): Fix static 'Claude' name rendering in chat UI header - Added "gemini": "Gemini" translation strings to messageTypes across English, Korean, and Chinese loc dictionaries. - Updated AssistantThinkingIndicator and MessageComponent ternary checks to identify provider === 'gemini' and render the appropriate brand label instead of statically defaulting to Claude. * feat: Add Gemini session persistence API mapping and Sidebar UI * fix(gemini): Watch ~/.gemini/sessions for live UI updates Added the .gemini/sessions directory to PROVIDER_WATCH_PATHS so that Chokidar emits projects_updated websocket events when new Gemini sessions are created or modified, fixing live sidebar updates. * fix(gemini): Fix Gemini authentication status display in Settings UI - Injected 'checkGeminiAuthStatus' into the Settings.jsx React effect hook so that the UI can poll and render the 'geminiAuthStatus' state. - Updated 'checkGeminiCredentials()' inside server/routes/cli-auth.js to read from '~/.gemini/oauth_creds.json' and '~/.gemini/google_accounts.json', resolving the email address correctly. * Use logo-only icon for gemini * feat(gemini): Add Gemini 3 preview models to UI selection list * Fix Gemini CLI session resume bug and PR #422 review nitpicks * Fix Gemini tool calls disappearing from UI after completion * fix(gemini): resolve outstanding PR #422 feedback and stabilize gemini CLI timeouts * fix(gemini): resolve resume flag and shell session initialization issues This commit addresses the remaining PR comments for the Gemini CLI integration: - Moves the `--resume` flag logic outside the prompt command block, ensuring Gemini sessions correctly resume even when a new prompt isn't passed. - Updates `handleShellConnection` to correctly lookup the native `cliSessionId` from the internal `sessionId` when spawning Gemini sessions in a plain shell. - Refactors dynamic import of `sessionManager.js` back to a native static import for code consistency. * chore: fix TypeScript errors and remove gemini CLI dependency * fix: use cross-spawn on Windows to resolve gemini.cmd correctly --------- Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
This commit is contained in:
73
package-lock.json
generated
73
package-lock.json
generated
@@ -168,6 +168,7 @@
|
||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -540,6 +541,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",
|
||||
@@ -554,6 +556,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",
|
||||
@@ -589,6 +592,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"
|
||||
}
|
||||
@@ -610,6 +614,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",
|
||||
@@ -2032,7 +2037,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",
|
||||
@@ -2050,6 +2056,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"
|
||||
}
|
||||
@@ -2260,6 +2267,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",
|
||||
@@ -3180,6 +3188,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"
|
||||
@@ -3324,7 +3333,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"
|
||||
@@ -3825,6 +3835,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
@@ -4732,9 +4743,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": {
|
||||
@@ -4765,9 +4776,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"
|
||||
@@ -5796,9 +5807,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": {
|
||||
@@ -6413,6 +6424,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -9033,22 +9045,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",
|
||||
@@ -9339,6 +9335,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -9720,6 +9717,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"
|
||||
},
|
||||
@@ -9732,6 +9730,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"
|
||||
@@ -10165,6 +10164,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nodeutils/defaults-deep": "1.1.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -11744,12 +11744,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"
|
||||
@@ -11900,6 +11900,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",
|
||||
@@ -12143,6 +12144,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12290,6 +12292,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12618,6 +12621,7 @@
|
||||
"integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12711,6 +12715,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -118,4 +118,4 @@
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
public/icons/gemini-ai-icon.svg
Normal file
1
public/icons/gemini-ai-icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
455
server/gemini-cli.js
Normal file
455
server/gemini-cli.js
Normal file
@@ -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
|
||||
};
|
||||
140
server/gemini-response-handler.js
Normal file
140
server/gemini-response-handler.js
Normal file
@@ -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;
|
||||
516
server/index.js
516
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 { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
import { IS_PLATFORM } from './constants/config.js';
|
||||
@@ -69,7 +72,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/**',
|
||||
@@ -319,25 +324,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)
|
||||
@@ -379,6 +384,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);
|
||||
|
||||
@@ -388,17 +396,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)
|
||||
@@ -496,13 +504,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
|
||||
@@ -585,13 +593,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);
|
||||
|
||||
@@ -601,22 +609,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')
|
||||
@@ -632,7 +640,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;
|
||||
@@ -645,17 +653,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' });
|
||||
@@ -899,26 +907,26 @@ wss.on('connection', (ws, request) => {
|
||||
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
||||
*/
|
||||
class WebSocketWriter {
|
||||
constructor(ws) {
|
||||
this.ws = ws;
|
||||
this.sessionId = null;
|
||||
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) {
|
||||
this.ws = ws;
|
||||
this.sessionId = null;
|
||||
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
|
||||
@@ -954,6 +962,12 @@ function handleChatConnection(ws) {
|
||||
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);
|
||||
@@ -971,6 +985,8 @@ function handleChatConnection(ws) {
|
||||
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);
|
||||
@@ -1013,6 +1029,8 @@ function handleChatConnection(ws) {
|
||||
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);
|
||||
@@ -1029,7 +1047,8 @@ function handleChatConnection(ws) {
|
||||
const activeSessions = {
|
||||
claude: getActiveClaudeSDKSessions(),
|
||||
cursor: getActiveCursorSessions(),
|
||||
codex: getActiveCodexSessions()
|
||||
codex: getActiveCodexSessions(),
|
||||
gemini: getActiveGeminiSessions()
|
||||
};
|
||||
writer.send({
|
||||
type: 'active-sessions',
|
||||
@@ -1138,7 +1157,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`;
|
||||
@@ -1174,6 +1193,7 @@ function handleShellConnection(ws) {
|
||||
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
||||
}
|
||||
}
|
||||
|
||||
} else if (provider === 'codex') {
|
||||
// Use codex command
|
||||
if (os.platform() === 'win32') {
|
||||
@@ -1191,6 +1211,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';
|
||||
@@ -1624,203 +1675,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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Object>} 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;
|
||||
|
||||
46
server/routes/gemini.js
Normal file
46
server/routes/gemini.js
Normal file
@@ -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;
|
||||
226
server/sessionManager.js
Normal file
226
server/sessionManager.js
Normal file
@@ -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;
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
9
src/components/GeminiLogo.jsx
Normal file
9
src/components/GeminiLogo.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const GeminiLogo = ({className = 'w-5 h-5'}) => {
|
||||
return (
|
||||
<img src="/icons/gemini-ai-icon.svg" alt="Gemini" className={className} />
|
||||
);
|
||||
};
|
||||
|
||||
export default GeminiLogo;
|
||||
90
src/components/GeminiStatus.jsx
Normal file
90
src/components/GeminiStatus.jsx
Normal file
@@ -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 (
|
||||
<div className="w-full mb-6 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gradient-to-r from-cyan-900 to-blue-900 dark:from-cyan-950 dark:to-blue-950 text-white rounded-lg shadow-lg px-4 py-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Animated spinner */}
|
||||
<span className={cn(
|
||||
"text-xl transition-all duration-500",
|
||||
animationPhase % 2 === 0 ? "text-cyan-400 scale-110" : "text-cyan-300"
|
||||
)}>
|
||||
{currentSpinner}
|
||||
</span>
|
||||
|
||||
{/* Status text - first line */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{statusText}...</span>
|
||||
<span className="text-gray-400 text-sm">({elapsedTime}s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interrupt button */}
|
||||
{canInterrupt && onAbort && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="ml-3 text-xs bg-red-600 hover:bg-red-700 text-white px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Stop</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeminiStatus;
|
||||
@@ -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({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={project}
|
||||
command={getCommand()}
|
||||
onComplete={handleComplete}
|
||||
minimal={true}
|
||||
/>
|
||||
{provider === 'gemini' ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mb-6">
|
||||
<KeyRound className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
|
||||
<h4 className="text-xl font-medium text-gray-900 dark:text-white mb-3">
|
||||
Setup Gemini API Access
|
||||
</h4>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-md mb-8">
|
||||
The Gemini CLI requires an API key to function. Unlike Claude, you'll need to configure this directly in your terminal first.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 max-w-lg w-full text-left shadow-sm">
|
||||
<ol className="space-y-4">
|
||||
<li className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Get your API Key</p>
|
||||
<a
|
||||
href="https://aistudio.google.com/app/apikey"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 inline-flex"
|
||||
>
|
||||
Google AI Studio <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Run configuration</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Open your terminal and run:</p>
|
||||
<code className="block bg-gray-100 dark:bg-gray-900 px-3 py-2 rounded text-sm text-pink-600 dark:text-pink-400 font-mono">
|
||||
gemini config set api_key YOUR_KEY
|
||||
</code>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-8 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<StandaloneShell
|
||||
project={project}
|
||||
command={getCommand()}
|
||||
onComplete={handleComplete}
|
||||
minimal={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 */}
|
||||
<div className="space-y-3">
|
||||
{/* Claude */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
claudeAuthStatus.authenticated
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className={`border rounded-lg p-4 transition-colors ${claudeAuthStatus.authenticated
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
|
||||
@@ -354,7 +309,7 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{claudeAuthStatus.loading ? 'Checking...' :
|
||||
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,11 +325,10 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
|
||||
{/* Cursor */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
cursorAuthStatus.authenticated
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className={`border rounded-lg p-4 transition-colors ${cursorAuthStatus.authenticated
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
|
||||
@@ -387,7 +341,7 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{cursorAuthStatus.loading ? 'Checking...' :
|
||||
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,11 +357,10 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
|
||||
{/* Codex */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
codexAuthStatus.authenticated
|
||||
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className={`border rounded-lg p-4 transition-colors ${codexAuthStatus.authenticated
|
||||
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
@@ -420,7 +373,7 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{codexAuthStatus.loading ? 'Checking...' :
|
||||
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,6 +387,38 @@ const Onboarding = ({ onComplete }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gemini */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${geminiAuthStatus.authenticated
|
||||
? 'bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-teal-100 dark:bg-teal-900/30 rounded-full flex items-center justify-center">
|
||||
<SessionProviderLogo provider="gemini" className="w-5 h-5 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
Gemini
|
||||
{geminiAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{geminiAuthStatus.loading ? 'Checking...' :
|
||||
geminiAuthStatus.authenticated ? geminiAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!geminiAuthStatus.authenticated && !geminiAuthStatus.loading && (
|
||||
<button
|
||||
onClick={handleGeminiLogin}
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground pt-2">
|
||||
@@ -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) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${
|
||||
index < currentStep ? 'bg-green-500 border-green-500 text-white' :
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500 border-green-500 text-white' :
|
||||
index === currentStep ? 'bg-blue-600 border-blue-600 text-white' :
|
||||
'bg-background border-border text-muted-foreground'
|
||||
}`}>
|
||||
'bg-background border-border text-muted-foreground'
|
||||
}`}>
|
||||
{index < currentStep ? (
|
||||
<Check className="w-6 h-6" />
|
||||
) : typeof step.icon === 'function' ? (
|
||||
@@ -482,9 +466,8 @@ const Onboarding = ({ onComplete }) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<p className={`text-sm font-medium ${
|
||||
index === currentStep ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>
|
||||
<p className={`text-sm font-medium ${index === currentStep ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{step.title}
|
||||
</p>
|
||||
{step.required && (
|
||||
@@ -493,9 +476,8 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${
|
||||
index < currentStep ? 'bg-green-500' : 'bg-border'
|
||||
}`} />
|
||||
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500' : 'bg-border'
|
||||
}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -41,6 +41,7 @@ interface UseChatComposerStateArgs {
|
||||
cursorModel: string;
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
geminiModel: string;
|
||||
isLoading: boolean;
|
||||
canAbortSession: boolean;
|
||||
tokenBudget: Record<string, unknown> | 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,
|
||||
|
||||
@@ -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<string>(() => {
|
||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
||||
});
|
||||
const [geminiModel, setGeminiModel] = useState<string>(() => {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -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}
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function AssistantThinkingIndicator({ selectedProvider }: Assista
|
||||
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'}
|
||||
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="ml-2 sm:ml-3 text-xs bg-red-600 hover:bg-red-700 active:bg-red-800 text-white px-2 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1 sm:gap-1.5 flex-shrink-0 font-medium"
|
||||
>
|
||||
|
||||
@@ -45,10 +45,10 @@ type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
(prevMessage.type === 'user') ||
|
||||
(prevMessage.type === 'tool') ||
|
||||
(prevMessage.type === 'error'));
|
||||
((prevMessage.type === 'assistant') ||
|
||||
(prevMessage.type === 'user') ||
|
||||
(prevMessage.type === 'tool') ||
|
||||
(prevMessage.type === 'error'));
|
||||
const messageRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||
@@ -154,11 +154,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{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'))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="w-full">
|
||||
|
||||
{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 (
|
||||
<>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
|
||||
{questionLine}
|
||||
</p>
|
||||
|
||||
|
||||
{/* Option buttons */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.number}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
|
||||
option.isSelected
|
||||
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
|
||||
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
|
||||
} cursor-not-allowed opacity-75`}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${option.isSelected
|
||||
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
|
||||
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
|
||||
} cursor-not-allowed opacity-75`}
|
||||
disabled
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
option.isSelected
|
||||
? 'bg-white/20'
|
||||
: 'bg-amber-100 dark:bg-amber-800/50'
|
||||
}`}>
|
||||
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${option.isSelected
|
||||
? 'bg-white/20'
|
||||
: 'bg-amber-100 dark:bg-amber-800/50'
|
||||
}`}>
|
||||
{option.number}
|
||||
</span>
|
||||
<span className="text-sm sm:text-base font-medium flex-1">
|
||||
@@ -345,7 +342,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
|
||||
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
|
||||
{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
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{!isGrouped && (
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
|
||||
{formattedTime}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
{/* Provider cards — horizontal row, equal width */}
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 mb-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-2.5 mb-6">
|
||||
{PROVIDERS.map((p) => {
|
||||
const active = provider === p.id;
|
||||
return (
|
||||
@@ -179,13 +194,14 @@ export default function ProviderSelectionEmptyState({
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground/70">
|
||||
{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]
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 <CodexLogo className={className} />;
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
return <GeminiLogo className={className} />;
|
||||
}
|
||||
|
||||
return <ClaudeLogo className={className} />;
|
||||
}
|
||||
|
||||
@@ -91,4 +91,5 @@ export const AUTH_STATUS_ENDPOINTS: Record<AgentProvider, string> = {
|
||||
claude: '/api/cli/claude/status',
|
||||
cursor: '/api/cli/cursor/status',
|
||||
codex: '/api/cli/codex/status',
|
||||
gemini: '/api/cli/gemini/status',
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
CodexMcpFormState,
|
||||
CodexPermissionMode,
|
||||
CursorPermissionsState,
|
||||
GeminiPermissionMode,
|
||||
McpServer,
|
||||
McpToolsResult,
|
||||
McpTestResult,
|
||||
@@ -204,6 +205,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
createEmptyCursorPermissions()
|
||||
));
|
||||
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
|
||||
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||
const [cursorMcpServers, setCursorMcpServers] = useState<McpServer[]>([]);
|
||||
@@ -224,6 +226,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
const [claudeAuthStatus, setClaudeAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
|
||||
const [cursorAuthStatus, setCursorAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
|
||||
const [codexAuthStatus, setCodexAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
|
||||
const [geminiAuthStatus, setGeminiAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
|
||||
|
||||
const setAuthStatusByProvider = useCallback((provider: AgentProvider, status: AuthStatus) => {
|
||||
if (provider === 'claude') {
|
||||
@@ -236,6 +239,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
setGeminiAuthStatus(status);
|
||||
return;
|
||||
}
|
||||
|
||||
setCodexAuthStatus(status);
|
||||
}, []);
|
||||
|
||||
@@ -655,6 +663,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');
|
||||
|
||||
await Promise.all([
|
||||
fetchMcpServers(),
|
||||
fetchCursorMcpServers(),
|
||||
@@ -710,6 +724,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
lastUpdated: now,
|
||||
}));
|
||||
|
||||
localStorage.setItem('gemini-settings', JSON.stringify({
|
||||
permissionMode: geminiPermissionMode,
|
||||
lastUpdated: now,
|
||||
}));
|
||||
|
||||
setSaveStatus('success');
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
@@ -771,6 +790,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(() => {
|
||||
@@ -830,6 +850,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
claudeAuthStatus,
|
||||
cursorAuthStatus,
|
||||
codexAuthStatus,
|
||||
geminiAuthStatus,
|
||||
geminiPermissionMode,
|
||||
setGeminiPermissionMode,
|
||||
openLoginForProvider,
|
||||
showLoginModal,
|
||||
setShowLoginModal,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
|
||||
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';
|
||||
|
||||
@@ -65,6 +65,9 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
claudeAuthStatus,
|
||||
cursorAuthStatus,
|
||||
codexAuthStatus,
|
||||
geminiAuthStatus,
|
||||
geminiPermissionMode,
|
||||
setGeminiPermissionMode,
|
||||
openLoginForProvider,
|
||||
showLoginModal,
|
||||
setShowLoginModal,
|
||||
@@ -86,10 +89,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 (
|
||||
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[9999] md:p-4 bg-background/95">
|
||||
@@ -133,15 +136,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}
|
||||
|
||||
@@ -12,7 +12,7 @@ type AgentListItemProps = {
|
||||
|
||||
type AgentConfig = {
|
||||
name: string;
|
||||
color: 'blue' | 'purple' | 'gray';
|
||||
color: 'blue' | 'purple' | 'gray' | 'indigo';
|
||||
};
|
||||
|
||||
const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||
@@ -28,6 +28,10 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||
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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex-1 text-center py-3 px-2 border-b-2 transition-colors ${
|
||||
isSelected
|
||||
? `${colors.borderBottom} ${colors.bg}`
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
className={`flex-1 text-center py-3 px-2 border-b-2 transition-colors ${isSelected
|
||||
? `${colors.borderBottom} ${colors.bg}`
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<SessionProviderLogo provider={agentId} className="w-5 h-5" />
|
||||
@@ -86,11 +95,10 @@ export default function AgentListItem({
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left p-3 border-l-4 transition-colors ${
|
||||
isSelected
|
||||
? `${colors.border} ${colors.bg}`
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
className={`w-full text-left p-3 border-l-4 transition-colors ${isSelected
|
||||
? `${colors.border} ${colors.bg}`
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<SessionProviderLogo provider={agentId} className="w-4 h-4" />
|
||||
|
||||
@@ -9,15 +9,19 @@ export default function AgentsSettingsTab({
|
||||
claudeAuthStatus,
|
||||
cursorAuthStatus,
|
||||
codexAuthStatus,
|
||||
geminiAuthStatus,
|
||||
onClaudeLogin,
|
||||
onCursorLogin,
|
||||
onCodexLogin,
|
||||
onGeminiLogin,
|
||||
claudePermissions,
|
||||
onClaudePermissionsChange,
|
||||
cursorPermissions,
|
||||
onCursorPermissionsChange,
|
||||
codexPermissionMode,
|
||||
onCodexPermissionModeChange,
|
||||
geminiPermissionMode,
|
||||
onGeminiPermissionModeChange,
|
||||
mcpServers,
|
||||
cursorMcpServers,
|
||||
codexMcpServers,
|
||||
@@ -48,13 +52,19 @@ export default function AgentsSettingsTab({
|
||||
authStatus: codexAuthStatus,
|
||||
onLogin: onCodexLogin,
|
||||
},
|
||||
gemini: {
|
||||
authStatus: geminiAuthStatus,
|
||||
onLogin: onGeminiLogin,
|
||||
},
|
||||
}), [
|
||||
claudeAuthStatus,
|
||||
codexAuthStatus,
|
||||
cursorAuthStatus,
|
||||
geminiAuthStatus,
|
||||
onClaudeLogin,
|
||||
onCodexLogin,
|
||||
onCursorLogin,
|
||||
onGeminiLogin,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -81,6 +91,8 @@ export default function AgentsSettingsTab({
|
||||
onCursorPermissionsChange={onCursorPermissionsChange}
|
||||
codexPermissionMode={codexPermissionMode}
|
||||
onCodexPermissionModeChange={onCodexPermissionModeChange}
|
||||
geminiPermissionMode={geminiPermissionMode}
|
||||
onGeminiPermissionModeChange={onGeminiPermissionModeChange}
|
||||
mcpServers={mcpServers}
|
||||
cursorMcpServers={cursorMcpServers}
|
||||
codexMcpServers={codexMcpServers}
|
||||
|
||||
@@ -18,6 +18,7 @@ type AgentVisualConfig = {
|
||||
textClass: string;
|
||||
subtextClass: string;
|
||||
buttonClass: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
||||
@@ -45,6 +46,15 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
||||
subtextClass: 'text-gray-700 dark:text-gray-300',
|
||||
buttonClass: 'bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||
},
|
||||
gemini: {
|
||||
name: 'Gemini',
|
||||
description: 'Google Gemini AI assistant',
|
||||
bgClass: 'bg-indigo-50 dark:bg-indigo-900/20',
|
||||
borderClass: 'border-indigo-200 dark:border-indigo-800',
|
||||
textClass: 'text-indigo-900 dark:text-indigo-100',
|
||||
subtextClass: 'text-indigo-700 dark:text-indigo-300',
|
||||
buttonClass: 'bg-indigo-600 hover:bg-indigo-700',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AlertTriangle, Plus, Shield, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../../../../../ui/button';
|
||||
import { Input } from '../../../../../../ui/input';
|
||||
import type { CodexPermissionMode } from '../../../../../types/types';
|
||||
import type { CodexPermissionMode, GeminiPermissionMode } from '../../../../../types/types';
|
||||
|
||||
const COMMON_CLAUDE_TOOLS = [
|
||||
'Bash(git log:*)',
|
||||
@@ -489,11 +489,10 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
|
||||
<p className="text-sm text-muted-foreground">{t('permissions.codex.description')}</p>
|
||||
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
permissionMode === 'default'
|
||||
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'default'
|
||||
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => onPermissionModeChange('default')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
@@ -514,11 +513,10 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
permissionMode === 'acceptEdits'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'acceptEdits'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => onPermissionModeChange('acceptEdits')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
@@ -539,11 +537,10 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
permissionMode === 'bypassPermissions'
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'bypassPermissions'
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => onPermissionModeChange('bypassPermissions')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
@@ -582,7 +579,111 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
|
||||
);
|
||||
}
|
||||
|
||||
type PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps;
|
||||
type GeminiPermissionsProps = {
|
||||
agent: 'gemini';
|
||||
permissionMode: GeminiPermissionMode;
|
||||
onPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||
};
|
||||
|
||||
// Gemini Permissions
|
||||
function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit<GeminiPermissionsProps, 'agent'>) {
|
||||
const { t } = useTranslation(['settings', 'chat']);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('gemini.permissionMode')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('gemini.description')}
|
||||
</p>
|
||||
|
||||
{/* Default Mode */}
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'default'
|
||||
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => onPermissionModeChange('default')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="geminiPermissionMode"
|
||||
checked={permissionMode === 'default'}
|
||||
onChange={() => onPermissionModeChange('default')}
|
||||
className="mt-1 w-4 h-4 text-green-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{t('gemini.modes.default.title')}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('gemini.modes.default.description')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Auto Edit Mode */}
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'auto_edit'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => onPermissionModeChange('auto_edit')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="geminiPermissionMode"
|
||||
checked={permissionMode === 'auto_edit'}
|
||||
onChange={() => onPermissionModeChange('auto_edit')}
|
||||
className="mt-1 w-4 h-4 text-green-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-green-900 dark:text-green-100">{t('gemini.modes.autoEdit.title')}</div>
|
||||
<div className="text-sm text-green-700 dark:text-green-300">
|
||||
{t('gemini.modes.autoEdit.description')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* YOLO Mode */}
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'yolo'
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => onPermissionModeChange('yolo')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="geminiPermissionMode"
|
||||
checked={permissionMode === 'yolo'}
|
||||
onChange={() => onPermissionModeChange('yolo')}
|
||||
className="mt-1 w-4 h-4 text-orange-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100 flex items-center gap-2">
|
||||
{t('gemini.modes.yolo.title')}
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-300">
|
||||
{t('gemini.modes.yolo.description')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps | GeminiPermissionsProps;
|
||||
|
||||
export default function PermissionsContent(props: PermissionsContentProps) {
|
||||
if (props.agent === 'claude') {
|
||||
@@ -593,5 +694,9 @@ export default function PermissionsContent(props: PermissionsContentProps) {
|
||||
return <CursorPermissions {...props} />;
|
||||
}
|
||||
|
||||
if (props.agent === 'gemini') {
|
||||
return <GeminiPermissions {...props} />;
|
||||
}
|
||||
|
||||
return <CodexPermissions {...props} />;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import type {
|
||||
AuthStatus,
|
||||
AgentCategory,
|
||||
ClaudePermissionsState,
|
||||
CodexPermissionMode,
|
||||
CursorPermissionsState,
|
||||
CodexPermissionMode,
|
||||
GeminiPermissionMode,
|
||||
McpServer,
|
||||
McpToolsResult,
|
||||
McpTestResult,
|
||||
@@ -21,15 +22,19 @@ export type AgentsSettingsTabProps = {
|
||||
claudeAuthStatus: AuthStatus;
|
||||
cursorAuthStatus: AuthStatus;
|
||||
codexAuthStatus: AuthStatus;
|
||||
geminiAuthStatus: AuthStatus;
|
||||
onClaudeLogin: () => void;
|
||||
onCursorLogin: () => void;
|
||||
onCodexLogin: () => void;
|
||||
onGeminiLogin: () => void;
|
||||
claudePermissions: ClaudePermissionsState;
|
||||
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
|
||||
cursorPermissions: CursorPermissionsState;
|
||||
onCursorPermissionsChange: (value: CursorPermissionsState) => void;
|
||||
codexPermissionMode: CodexPermissionMode;
|
||||
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||
geminiPermissionMode: GeminiPermissionMode;
|
||||
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||
mcpServers: McpServer[];
|
||||
cursorMcpServers: McpServer[];
|
||||
codexMcpServers: McpServer[];
|
||||
@@ -66,6 +71,8 @@ export type AgentCategoryContentSectionProps = {
|
||||
onCursorPermissionsChange: (value: CursorPermissionsState) => void;
|
||||
codexPermissionMode: CodexPermissionMode;
|
||||
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||
geminiPermissionMode: GeminiPermissionMode;
|
||||
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||
mcpServers: McpServer[];
|
||||
cursorMcpServers: McpServer[];
|
||||
codexMcpServers: McpServer[];
|
||||
|
||||
@@ -277,10 +277,14 @@ export function useSidebarController({
|
||||
setSessionDeleteConfirmation(null);
|
||||
|
||||
try {
|
||||
const response =
|
||||
provider === 'codex'
|
||||
? await api.deleteCodexSession(sessionId)
|
||||
: await api.deleteSession(projectName, sessionId);
|
||||
let response;
|
||||
if (provider === 'codex') {
|
||||
response = await api.deleteCodexSession(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
response = await api.deleteGeminiSession(sessionId);
|
||||
} else {
|
||||
response = await api.deleteSession(projectName, sessionId);
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
onSessionDelete?.(sessionId);
|
||||
|
||||
@@ -44,6 +44,7 @@ export type SidebarProps = {
|
||||
export type SessionViewModel = {
|
||||
isCursorSession: boolean;
|
||||
isCodexSession: boolean;
|
||||
isGeminiSession: boolean;
|
||||
isActive: boolean;
|
||||
sessionName: string;
|
||||
sessionTime: string;
|
||||
|
||||
@@ -48,7 +48,7 @@ export const getSessionDate = (session: SessionWithProvider): Date => {
|
||||
return new Date(session.createdAt || session.lastActivity || 0);
|
||||
}
|
||||
|
||||
return new Date(session.lastActivity || 0);
|
||||
return new Date(session.lastActivity || session.createdAt || 0);
|
||||
};
|
||||
|
||||
export const getSessionName = (session: SessionWithProvider, t: TFunction): string => {
|
||||
@@ -60,6 +60,10 @@ export const getSessionName = (session: SessionWithProvider, t: TFunction): stri
|
||||
return session.summary || session.name || t('projects.codexSession');
|
||||
}
|
||||
|
||||
if (session.__provider === 'gemini') {
|
||||
return session.summary || session.name || t('projects.newSession');
|
||||
}
|
||||
|
||||
return session.summary || t('projects.newSession');
|
||||
};
|
||||
|
||||
@@ -72,7 +76,7 @@ export const getSessionTime = (session: SessionWithProvider): string => {
|
||||
return String(session.createdAt || session.lastActivity || '');
|
||||
}
|
||||
|
||||
return String(session.lastActivity || '');
|
||||
return String(session.lastActivity || session.createdAt || '');
|
||||
};
|
||||
|
||||
export const createSessionViewModel = (
|
||||
@@ -86,6 +90,7 @@ export const createSessionViewModel = (
|
||||
return {
|
||||
isCursorSession: session.__provider === 'cursor',
|
||||
isCodexSession: session.__provider === 'codex',
|
||||
isGeminiSession: session.__provider === 'gemini',
|
||||
isActive: diffInMinutes < 10,
|
||||
sessionName: getSessionName(session, t),
|
||||
sessionTime: getSessionTime(session),
|
||||
@@ -112,7 +117,12 @@ export const getAllSessions = (
|
||||
__provider: 'codex' as const,
|
||||
}));
|
||||
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions].sort(
|
||||
const geminiSessions = (project.geminiSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'gemini' as const,
|
||||
}));
|
||||
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions].sort(
|
||||
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
|
||||
);
|
||||
};
|
||||
@@ -205,8 +215,8 @@ export const normalizeProjectForSettings = (project: Project): SettingsProject =
|
||||
typeof project.fullPath === 'string' && project.fullPath.length > 0
|
||||
? project.fullPath
|
||||
: typeof project.path === 'string'
|
||||
? project.path
|
||||
: '';
|
||||
? project.path
|
||||
: '';
|
||||
|
||||
return {
|
||||
name: project.name,
|
||||
|
||||
@@ -40,7 +40,8 @@ const projectsHaveChanges = (
|
||||
nextProject.displayName !== prevProject.displayName ||
|
||||
nextProject.fullPath !== prevProject.fullPath ||
|
||||
serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
|
||||
serialize(nextProject.sessions) !== serialize(prevProject.sessions);
|
||||
serialize(nextProject.sessions) !== serialize(prevProject.sessions) ||
|
||||
serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster);
|
||||
|
||||
if (baseChanged) {
|
||||
return true;
|
||||
@@ -52,7 +53,8 @@ const projectsHaveChanges = (
|
||||
|
||||
return (
|
||||
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
|
||||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions)
|
||||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
|
||||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions)
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -62,6 +64,7 @@ const getProjectSessions = (project: Project): ProjectSession[] => {
|
||||
...(project.sessions ?? []),
|
||||
...(project.codexSessions ?? []),
|
||||
...(project.cursorSessions ?? []),
|
||||
...(project.geminiSessions ?? []),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -333,6 +336,21 @@ export function useProjectsState({
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const geminiSession = project.geminiSessions?.find((session) => session.id === sessionId);
|
||||
if (geminiSession) {
|
||||
const shouldUpdateProject = selectedProject?.name !== project.name;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'gemini';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...geminiSession, __provider: 'gemini' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [sessionId, projects, selectedProject?.name, selectedSession?.id, selectedSession?.__provider]);
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"tool": "Tool",
|
||||
"claude": "Claude",
|
||||
"cursor": "Cursor",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"tools": {
|
||||
"settings": "Tool Settings",
|
||||
@@ -93,6 +94,24 @@
|
||||
},
|
||||
"technicalDetails": "Technical details"
|
||||
},
|
||||
"gemini": {
|
||||
"permissionMode": "Gemini Permission Mode",
|
||||
"description": "Control how Gemini CLI handles operation approvals.",
|
||||
"modes": {
|
||||
"default": {
|
||||
"title": "Standard (Ask for Approval)",
|
||||
"description": "Gemini will prompt for approval before executing commands, writing files, and fetching web resources."
|
||||
},
|
||||
"autoEdit": {
|
||||
"title": "Auto Edit (Skip File Approvals)",
|
||||
"description": "Gemini will automatically approve file edits and web fetches, but will still prompt for shell commands."
|
||||
},
|
||||
"yolo": {
|
||||
"title": "YOLO (Bypass All Permissions)",
|
||||
"description": "Gemini will execute all operations without asking for approval. Exercise caution."
|
||||
}
|
||||
}
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Type / for commands, @ for files, or ask {{provider}} anything...",
|
||||
"placeholderDefault": "Type your message...",
|
||||
@@ -153,12 +172,14 @@
|
||||
"providerInfo": {
|
||||
"anthropic": "by Anthropic",
|
||||
"openai": "by OpenAI",
|
||||
"cursorEditor": "AI Code Editor"
|
||||
"cursorEditor": "AI Code Editor",
|
||||
"google": "by Google"
|
||||
},
|
||||
"readyPrompt": {
|
||||
"claude": "Ready to use Claude with {{model}}. Start typing your message below.",
|
||||
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
|
||||
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
|
||||
"gemini": "Ready to use Gemini with {{model}}. Start typing your message below.",
|
||||
"default": "Select a provider above to begin"
|
||||
}
|
||||
},
|
||||
@@ -214,4 +235,4 @@
|
||||
"tasks": {
|
||||
"nextTaskPrompt": "Start the next task"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
"tool": "도구",
|
||||
"claude": "Claude",
|
||||
"cursor": "Cursor",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"tools": {
|
||||
"settings": "도구 설정",
|
||||
@@ -151,15 +152,17 @@
|
||||
"description": "새 대화를 시작할 프로바이더를 선택하세요",
|
||||
"selectModel": "모델 선택",
|
||||
"providerInfo": {
|
||||
"anthropic": "by Anthropic",
|
||||
"openai": "by OpenAI",
|
||||
"cursorEditor": "AI 코드 에디터"
|
||||
"anthropic": "Anthropic 제공",
|
||||
"openai": "OpenAI 제공",
|
||||
"cursorEditor": "AI 코드 에디터",
|
||||
"google": "Google 제공"
|
||||
},
|
||||
"readyPrompt": {
|
||||
"claude": "{{model}}로 Claude를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||
"cursor": "{{model}}로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||
"codex": "{{model}}로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||
"default": "시작하려면 위에서 프로바이더를 선택하세요"
|
||||
"claude": "{{model}} 모델로 Claude를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||
"cursor": "{{model}} 모델로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||
"codex": "{{model}} 모델로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||
"gemini": "{{model}} 모델로 Gemini를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||
"default": "시작하려면 위에서 제공자를 선택하세요"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
@@ -214,4 +217,4 @@
|
||||
"tasks": {
|
||||
"nextTaskPrompt": "다음 작업 시작"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
"tool": "工具",
|
||||
"claude": "Claude",
|
||||
"cursor": "Cursor",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"tools": {
|
||||
"settings": "工具设置",
|
||||
@@ -151,15 +152,17 @@
|
||||
"description": "选择一个供应商以开始新对话",
|
||||
"selectModel": "选择模型",
|
||||
"providerInfo": {
|
||||
"anthropic": "Anthropic",
|
||||
"openai": "OpenAI",
|
||||
"cursorEditor": "AI 代码编辑器"
|
||||
"anthropic": "由 Anthropic 提供",
|
||||
"openai": "由 OpenAI 提供",
|
||||
"cursorEditor": "AI 代码编辑器",
|
||||
"google": "由 Google 提供"
|
||||
},
|
||||
"readyPrompt": {
|
||||
"claude": "已准备好使用 Claude {{model}}。在下方输入您的消息。",
|
||||
"cursor": "已准备好使用 Cursor {{model}}。在下方输入您的消息。",
|
||||
"codex": "已准备好使用 Codex {{model}}。在下方输入您的消息。",
|
||||
"default": "请在上方选择一个供应商以开始"
|
||||
"claude": "准备好使用带有 {{model}} 的 Claude。请在下方开始输入您的消息。",
|
||||
"cursor": "准备好使用带有 {{model}} 的 Cursor。请在下方开始输入您的消息。",
|
||||
"codex": "准备好使用带有 {{model}} 的 Codex。请在下方开始输入您的消息。",
|
||||
"gemini": "准备好使用带有 {{model}} 的 Gemini。请在下方开始输入您的消息。",
|
||||
"default": "请在上方选择一个提供者以开始"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
@@ -214,4 +217,4 @@
|
||||
"tasks": {
|
||||
"nextTaskPrompt": "开始下一个任务"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SessionProvider = 'claude' | 'cursor' | 'codex';
|
||||
export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
|
||||
|
||||
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface Project {
|
||||
sessions?: ProjectSession[];
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
sessionMeta?: ProjectSessionMeta;
|
||||
taskmaster?: ProjectTaskmasterInfo;
|
||||
[key: string]: unknown;
|
||||
@@ -66,4 +67,4 @@ export interface LoadingProgressMessage extends LoadingProgress {
|
||||
export type AppSocketMessage =
|
||||
| LoadingProgressMessage
|
||||
| ProjectsUpdatedMessage
|
||||
| { type?: string; [key: string]: unknown };
|
||||
| { type?: string;[key: string]: unknown };
|
||||
|
||||
@@ -46,7 +46,7 @@ export const api = {
|
||||
// Protected endpoints
|
||||
// config endpoint removed - no longer needed (frontend uses window.location)
|
||||
projects: () => authenticatedFetch('/api/projects'),
|
||||
sessions: (projectName, limit = 5, offset = 0) =>
|
||||
sessions: (projectName, limit = 5, offset = 0) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
|
||||
sessionMessages: (projectName, sessionId, limit = null, offset = 0, provider = 'claude') => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -56,12 +56,13 @@ export const api = {
|
||||
}
|
||||
const queryString = params.toString();
|
||||
|
||||
// Route to the correct endpoint based on provider
|
||||
let url;
|
||||
if (provider === 'codex') {
|
||||
url = `/api/codex/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
} else if (provider === 'cursor') {
|
||||
url = `/api/cursor/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
} else if (provider === 'gemini') {
|
||||
url = `/api/gemini/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
} else {
|
||||
url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
}
|
||||
@@ -80,6 +81,10 @@ export const api = {
|
||||
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteGeminiSession: (sessionId) =>
|
||||
authenticatedFetch(`/api/gemini/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteProject: (projectName, force = false) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
|
||||
method: 'DELETE',
|
||||
@@ -113,18 +118,18 @@ export const api = {
|
||||
// TaskMaster endpoints
|
||||
taskmaster: {
|
||||
// Initialize TaskMaster in a project
|
||||
init: (projectName) =>
|
||||
init: (projectName) =>
|
||||
authenticatedFetch(`/api/taskmaster/init/${projectName}`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
|
||||
// Add a new task
|
||||
addTask: (projectName, { prompt, title, description, priority, dependencies }) =>
|
||||
authenticatedFetch(`/api/taskmaster/add-task/${projectName}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt, title, description, priority, dependencies }),
|
||||
}),
|
||||
|
||||
|
||||
// Parse PRD to generate tasks
|
||||
parsePRD: (projectName, { fileName, numTasks, append }) =>
|
||||
authenticatedFetch(`/api/taskmaster/parse-prd/${projectName}`, {
|
||||
@@ -150,7 +155,7 @@ export const api = {
|
||||
body: JSON.stringify(updates),
|
||||
}),
|
||||
},
|
||||
|
||||
|
||||
// Browse filesystem for project suggestions
|
||||
browseFilesystem: (dirPath = null) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Reference in New Issue
Block a user