mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-02 02:15:34 +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==",
|
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
@@ -540,6 +541,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz",
|
||||||
"integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==",
|
"integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
"@codemirror/view": "^6.23.0",
|
"@codemirror/view": "^6.23.0",
|
||||||
@@ -554,6 +556,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
|
||||||
"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
|
"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
"@codemirror/view": "^6.35.0",
|
"@codemirror/view": "^6.35.0",
|
||||||
@@ -589,6 +592,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
||||||
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marijn/find-cluster-break": "^1.0.0"
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -610,6 +614,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
|
||||||
"integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
|
"integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -2032,7 +2037,8 @@
|
|||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
|
||||||
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
|
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@lezer/css": {
|
"node_modules/@lezer/css": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
@@ -2050,6 +2056,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
|
||||||
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
|
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.0.0"
|
"@lezer/common": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -2260,6 +2267,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.4.tgz",
|
||||||
"integrity": "sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA==",
|
"integrity": "sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-token": "^6.0.0",
|
"@octokit/auth-token": "^6.0.0",
|
||||||
"@octokit/graphql": "^9.0.1",
|
"@octokit/graphql": "^9.0.1",
|
||||||
@@ -3180,6 +3188,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
||||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -3324,7 +3333,8 @@
|
|||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/abbrev": {
|
"node_modules/abbrev": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -3408,9 +3418,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "6.1.0",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -3825,6 +3835,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -4732,9 +4743,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/data-uri-to-buffer": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
@@ -4765,9 +4776,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -5796,9 +5807,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-east-asian-width": {
|
"node_modules/get-east-asian-width": {
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
|
||||||
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
|
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6413,6 +6424,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
@@ -9033,22 +9045,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/os-name": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/os-name/-/os-name-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/os-name/-/os-name-6.1.0.tgz",
|
||||||
@@ -9339,6 +9335,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -9720,6 +9717,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -9732,6 +9730,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -10165,6 +10164,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodeutils/defaults-deep": "1.1.0",
|
"@nodeutils/defaults-deep": "1.1.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
@@ -11744,12 +11744,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-ansi": {
|
"node_modules/strip-ansi": {
|
||||||
"version": "7.1.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^6.0.1"
|
"ansi-regex": "^6.2.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -11900,6 +11900,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -12143,6 +12144,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -12290,6 +12292,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -12618,6 +12621,7 @@
|
|||||||
"integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==",
|
"integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -12711,6 +12715,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -118,4 +118,4 @@
|
|||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.0.4"
|
"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 { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
|
||||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.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 gitRoutes from './routes/git.js';
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
import mcpRoutes from './routes/mcp.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 cliAuthRoutes from './routes/cli-auth.js';
|
||||||
import userRoutes from './routes/user.js';
|
import userRoutes from './routes/user.js';
|
||||||
import codexRoutes from './routes/codex.js';
|
import codexRoutes from './routes/codex.js';
|
||||||
|
import geminiRoutes from './routes/gemini.js';
|
||||||
import { initializeDatabase } from './database/db.js';
|
import { initializeDatabase } from './database/db.js';
|
||||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
import { IS_PLATFORM } from './constants/config.js';
|
import { IS_PLATFORM } from './constants/config.js';
|
||||||
@@ -69,7 +72,9 @@ import { IS_PLATFORM } from './constants/config.js';
|
|||||||
const PROVIDER_WATCH_PATHS = [
|
const PROVIDER_WATCH_PATHS = [
|
||||||
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
|
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
|
||||||
{ provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
|
{ 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 = [
|
const WATCHER_IGNORED_PATTERNS = [
|
||||||
'**/node_modules/**',
|
'**/node_modules/**',
|
||||||
@@ -319,25 +324,25 @@ app.locals.wss = wss;
|
|||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({
|
app.use(express.json({
|
||||||
limit: '50mb',
|
limit: '50mb',
|
||||||
type: (req) => {
|
type: (req) => {
|
||||||
// Skip multipart/form-data requests (for file uploads like images)
|
// Skip multipart/form-data requests (for file uploads like images)
|
||||||
const contentType = req.headers['content-type'] || '';
|
const contentType = req.headers['content-type'] || '';
|
||||||
if (contentType.includes('multipart/form-data')) {
|
if (contentType.includes('multipart/form-data')) {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
return contentType.includes('json');
|
||||||
}
|
}
|
||||||
return contentType.includes('json');
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||||
|
|
||||||
// Public health check endpoint (no authentication required)
|
// Public health check endpoint (no authentication required)
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
installMode
|
installMode
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optional API key validation (if configured)
|
// Optional API key validation (if configured)
|
||||||
@@ -379,6 +384,9 @@ app.use('/api/user', authenticateToken, userRoutes);
|
|||||||
// Codex API Routes (protected)
|
// Codex API Routes (protected)
|
||||||
app.use('/api/codex', authenticateToken, codexRoutes);
|
app.use('/api/codex', authenticateToken, codexRoutes);
|
||||||
|
|
||||||
|
// Gemini API Routes (protected)
|
||||||
|
app.use('/api/gemini', authenticateToken, geminiRoutes);
|
||||||
|
|
||||||
// Agent API Routes (uses API key authentication)
|
// Agent API Routes (uses API key authentication)
|
||||||
app.use('/api/agent', agentRoutes);
|
app.use('/api/agent', agentRoutes);
|
||||||
|
|
||||||
@@ -388,17 +396,17 @@ app.use(express.static(path.join(__dirname, '../public')));
|
|||||||
// Static files served after API routes
|
// Static files served after API routes
|
||||||
// Add cache control: HTML files should not be cached, but assets can be cached
|
// Add cache control: HTML files should not be cached, but assets can be cached
|
||||||
app.use(express.static(path.join(__dirname, '../dist'), {
|
app.use(express.static(path.join(__dirname, '../dist'), {
|
||||||
setHeaders: (res, filePath) => {
|
setHeaders: (res, filePath) => {
|
||||||
if (filePath.endsWith('.html')) {
|
if (filePath.endsWith('.html')) {
|
||||||
// Prevent HTML caching to avoid service worker issues after builds
|
// Prevent HTML caching to avoid service worker issues after builds
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
res.setHeader('Pragma', 'no-cache');
|
res.setHeader('Pragma', 'no-cache');
|
||||||
res.setHeader('Expires', '0');
|
res.setHeader('Expires', '0');
|
||||||
} else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
|
} 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)
|
// Cache static assets for 1 year (they have hashed names)
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// API Routes (protected)
|
// API Routes (protected)
|
||||||
@@ -496,13 +504,13 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateT
|
|||||||
try {
|
try {
|
||||||
const { projectName, sessionId } = req.params;
|
const { projectName, sessionId } = req.params;
|
||||||
const { limit, offset } = req.query;
|
const { limit, offset } = req.query;
|
||||||
|
|
||||||
// Parse limit and offset if provided
|
// Parse limit and offset if provided
|
||||||
const parsedLimit = limit ? parseInt(limit, 10) : null;
|
const parsedLimit = limit ? parseInt(limit, 10) : null;
|
||||||
const parsedOffset = offset ? parseInt(offset, 10) : 0;
|
const parsedOffset = offset ? parseInt(offset, 10) : 0;
|
||||||
|
|
||||||
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
|
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
|
||||||
|
|
||||||
// Handle both old and new response formats
|
// Handle both old and new response formats
|
||||||
if (Array.isArray(result)) {
|
if (Array.isArray(result)) {
|
||||||
// Backward compatibility: no pagination parameters were provided
|
// Backward compatibility: no pagination parameters were provided
|
||||||
@@ -585,13 +593,13 @@ const expandWorkspacePath = (inputPath) => {
|
|||||||
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { path: dirPath } = req.query;
|
const { path: dirPath } = req.query;
|
||||||
|
|
||||||
console.log('[API] Browse filesystem request for path:', dirPath);
|
console.log('[API] Browse filesystem request for path:', dirPath);
|
||||||
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
|
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
|
||||||
// Default to home directory if no path provided
|
// Default to home directory if no path provided
|
||||||
const defaultRoot = WORKSPACES_ROOT;
|
const defaultRoot = WORKSPACES_ROOT;
|
||||||
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
||||||
|
|
||||||
// Resolve and normalize the path
|
// Resolve and normalize the path
|
||||||
targetPath = path.resolve(targetPath);
|
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 });
|
return res.status(403).json({ error: validation.error });
|
||||||
}
|
}
|
||||||
const resolvedPath = validation.resolvedPath || targetPath;
|
const resolvedPath = validation.resolvedPath || targetPath;
|
||||||
|
|
||||||
// Security check - ensure path is accessible
|
// Security check - ensure path is accessible
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(resolvedPath);
|
await fs.promises.access(resolvedPath);
|
||||||
const stats = await fs.promises.stat(resolvedPath);
|
const stats = await fs.promises.stat(resolvedPath);
|
||||||
|
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
return res.status(400).json({ error: 'Path is not a directory' });
|
return res.status(400).json({ error: 'Path is not a directory' });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(404).json({ error: 'Directory not accessible' });
|
return res.status(404).json({ error: 'Directory not accessible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use existing getFileTree function with shallow depth (only direct children)
|
// Use existing getFileTree function with shallow depth (only direct children)
|
||||||
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
|
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
|
||||||
|
|
||||||
// Filter only directories and format for suggestions
|
// Filter only directories and format for suggestions
|
||||||
const directories = fileTree
|
const directories = fileTree
|
||||||
.filter(item => item.type === 'directory')
|
.filter(item => item.type === 'directory')
|
||||||
@@ -632,7 +640,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|||||||
if (!aHidden && bHidden) return -1;
|
if (!aHidden && bHidden) return -1;
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add common directories if browsing home directory
|
// Add common directories if browsing home directory
|
||||||
const suggestions = [];
|
const suggestions = [];
|
||||||
let resolvedWorkspaceRoot = defaultRoot;
|
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 commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
||||||
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
||||||
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
||||||
|
|
||||||
suggestions.push(...existingCommon, ...otherDirs);
|
suggestions.push(...existingCommon, ...otherDirs);
|
||||||
} else {
|
} else {
|
||||||
suggestions.push(...directories);
|
suggestions.push(...directories);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
path: resolvedPath,
|
path: resolvedPath,
|
||||||
suggestions: suggestions
|
suggestions: suggestions
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error browsing filesystem:', error);
|
console.error('Error browsing filesystem:', error);
|
||||||
res.status(500).json({ error: 'Failed to browse filesystem' });
|
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
|
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
||||||
*/
|
*/
|
||||||
class WebSocketWriter {
|
class WebSocketWriter {
|
||||||
constructor(ws) {
|
constructor(ws) {
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
this.sessionId = null;
|
this.sessionId = null;
|
||||||
this.isWebSocketWriter = true; // Marker for transport detection
|
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));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setSessionId(sessionId) {
|
send(data) {
|
||||||
this.sessionId = sessionId;
|
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
||||||
}
|
// Providers send raw objects, we stringify for WebSocket
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getSessionId() {
|
setSessionId(sessionId) {
|
||||||
return this.sessionId;
|
this.sessionId = sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSessionId() {
|
||||||
|
return this.sessionId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle chat WebSocket connections
|
// Handle chat WebSocket connections
|
||||||
@@ -954,6 +962,12 @@ function handleChatConnection(ws) {
|
|||||||
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
||||||
console.log('🤖 Model:', data.options?.model || 'default');
|
console.log('🤖 Model:', data.options?.model || 'default');
|
||||||
await queryCodex(data.command, data.options, writer);
|
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') {
|
} else if (data.type === 'cursor-resume') {
|
||||||
// Backward compatibility: treat as cursor-command with resume and no prompt
|
// Backward compatibility: treat as cursor-command with resume and no prompt
|
||||||
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
||||||
@@ -971,6 +985,8 @@ function handleChatConnection(ws) {
|
|||||||
success = abortCursorSession(data.sessionId);
|
success = abortCursorSession(data.sessionId);
|
||||||
} else if (provider === 'codex') {
|
} else if (provider === 'codex') {
|
||||||
success = abortCodexSession(data.sessionId);
|
success = abortCodexSession(data.sessionId);
|
||||||
|
} else if (provider === 'gemini') {
|
||||||
|
success = abortGeminiSession(data.sessionId);
|
||||||
} else {
|
} else {
|
||||||
// Use Claude Agents SDK
|
// Use Claude Agents SDK
|
||||||
success = await abortClaudeSDKSession(data.sessionId);
|
success = await abortClaudeSDKSession(data.sessionId);
|
||||||
@@ -1013,6 +1029,8 @@ function handleChatConnection(ws) {
|
|||||||
isActive = isCursorSessionActive(sessionId);
|
isActive = isCursorSessionActive(sessionId);
|
||||||
} else if (provider === 'codex') {
|
} else if (provider === 'codex') {
|
||||||
isActive = isCodexSessionActive(sessionId);
|
isActive = isCodexSessionActive(sessionId);
|
||||||
|
} else if (provider === 'gemini') {
|
||||||
|
isActive = isGeminiSessionActive(sessionId);
|
||||||
} else {
|
} else {
|
||||||
// Use Claude Agents SDK
|
// Use Claude Agents SDK
|
||||||
isActive = isClaudeSDKSessionActive(sessionId);
|
isActive = isClaudeSDKSessionActive(sessionId);
|
||||||
@@ -1029,7 +1047,8 @@ function handleChatConnection(ws) {
|
|||||||
const activeSessions = {
|
const activeSessions = {
|
||||||
claude: getActiveClaudeSDKSessions(),
|
claude: getActiveClaudeSDKSessions(),
|
||||||
cursor: getActiveCursorSessions(),
|
cursor: getActiveCursorSessions(),
|
||||||
codex: getActiveCodexSessions()
|
codex: getActiveCodexSessions(),
|
||||||
|
gemini: getActiveGeminiSessions()
|
||||||
};
|
};
|
||||||
writer.send({
|
writer.send({
|
||||||
type: 'active-sessions',
|
type: 'active-sessions',
|
||||||
@@ -1138,7 +1157,7 @@ function handleShellConnection(ws) {
|
|||||||
if (isPlainShell) {
|
if (isPlainShell) {
|
||||||
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
||||||
} else {
|
} else {
|
||||||
const providerName = provider === 'cursor' ? 'Cursor' : provider === 'codex' ? 'Codex' : 'Claude';
|
const providerName = provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude'));
|
||||||
welcomeMsg = hasSession ?
|
welcomeMsg = hasSession ?
|
||||||
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
||||||
`\x1b[36mStarting new ${providerName} session 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`;
|
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (provider === 'codex') {
|
} else if (provider === 'codex') {
|
||||||
// Use codex command
|
// Use codex command
|
||||||
if (os.platform() === 'win32') {
|
if (os.platform() === 'win32') {
|
||||||
@@ -1191,6 +1211,37 @@ function handleShellConnection(ws) {
|
|||||||
shellCommand = `cd "${projectPath}" && codex`;
|
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 {
|
} else {
|
||||||
// Use claude command (default) or initialCommand if provided
|
// Use claude command (default) or initialCommand if provided
|
||||||
const command = initialCommand || 'claude';
|
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
|
// Get token usage for a specific session
|
||||||
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { projectName, sessionId } = req.params;
|
const { projectName, sessionId } = req.params;
|
||||||
const { provider = 'claude' } = req.query;
|
const { provider = 'claude' } = req.query;
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
|
|
||||||
// Allow only safe characters in sessionId
|
// Allow only safe characters in sessionId
|
||||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||||
if (!safeSessionId) {
|
if (!safeSessionId) {
|
||||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
||||||
if (provider === 'cursor') {
|
if (provider === 'cursor') {
|
||||||
return res.json({
|
return res.json({
|
||||||
used: 0,
|
used: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||||
unsupported: true,
|
unsupported: true,
|
||||||
message: 'Token usage tracking not available for Cursor sessions'
|
message: 'Token usage tracking not available for Cursor sessions'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Codex sessions
|
// Handle Gemini sessions - they are raw logs in our current setup
|
||||||
if (provider === 'codex') {
|
if (provider === 'gemini') {
|
||||||
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
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
|
// Handle Codex sessions
|
||||||
const findSessionFile = async (dir) => {
|
if (provider === 'codex') {
|
||||||
try {
|
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
||||||
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
// Find the session file by searching for the session ID
|
||||||
const fullPath = path.join(dir, entry.name);
|
const findSessionFile = async (dir) => {
|
||||||
if (entry.isDirectory()) {
|
try {
|
||||||
const found = await findSessionFile(fullPath);
|
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
||||||
if (found) return found;
|
for (const entry of entries) {
|
||||||
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
const fullPath = path.join(dir, entry.name);
|
||||||
return fullPath;
|
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) {
|
} 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) {
|
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
||||||
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and parse the Codex JSONL file
|
// Constrain to projectDir
|
||||||
let fileContent;
|
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
||||||
try {
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||||
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
return res.status(400).json({ error: 'Invalid path' });
|
||||||
} 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)
|
// Read and parse the JSONL file
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
let fileContent;
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(lines[i]);
|
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
// Codex stores token info in event_msg with type: "token_count"
|
if (error.code === 'ENOENT') {
|
||||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
||||||
const tokenInfo = entry.payload.info;
|
|
||||||
if (tokenInfo.total_token_usage) {
|
|
||||||
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
|
||||||
}
|
}
|
||||||
if (tokenInfo.model_context_window) {
|
throw error; // Re-throw other errors to be caught by outer try-catch
|
||||||
contextWindow = tokenInfo.model_context_window;
|
}
|
||||||
|
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({
|
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
||||||
used: totalTokens,
|
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
||||||
total: contextWindow
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Claude sessions (default)
|
res.json({
|
||||||
// Extract actual project path
|
used: totalUsed,
|
||||||
let projectPath;
|
total: contextWindow,
|
||||||
try {
|
breakdown: {
|
||||||
projectPath = await extractProjectDirectory(projectName);
|
input: inputTokens,
|
||||||
|
cacheCreation: cacheCreationTokens,
|
||||||
|
cacheRead: cacheReadTokens
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error extracting project directory:', error);
|
console.error('Error reading session token usage:', error);
|
||||||
return res.status(500).json({ error: 'Failed to determine project path' });
|
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)
|
// Serve React app for all other routes (excluding static files)
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
// Skip requests for static assets (files with extensions)
|
// Skip requests for static assets (files with extensions)
|
||||||
if (path.extname(req.path)) {
|
if (path.extname(req.path)) {
|
||||||
return res.status(404).send('Not found');
|
return res.status(404).send('Not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only serve index.html for HTML routes, not for static assets
|
// Only serve index.html for HTML routes, not for static assets
|
||||||
// Static assets should already be handled by express.static middleware above
|
// Static assets should already be handled by express.static middleware above
|
||||||
const indexPath = path.join(__dirname, '../dist/index.html');
|
const indexPath = path.join(__dirname, '../dist/index.html');
|
||||||
|
|
||||||
// Check if dist/index.html exists (production build available)
|
// Check if dist/index.html exists (production build available)
|
||||||
if (fs.existsSync(indexPath)) {
|
if (fs.existsSync(indexPath)) {
|
||||||
// Set no-cache headers for HTML to prevent service worker issues
|
// Set no-cache headers for HTML to prevent service worker issues
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
res.setHeader('Pragma', 'no-cache');
|
res.setHeader('Pragma', 'no-cache');
|
||||||
res.setHeader('Expires', '0');
|
res.setHeader('Expires', '0');
|
||||||
res.sendFile(indexPath);
|
res.sendFile(indexPath);
|
||||||
} else {
|
} else {
|
||||||
// In development, redirect to Vite dev server only if dist doesn't exist
|
// In development, redirect to Vite dev server only if dist doesn't exist
|
||||||
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
|
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to convert permissions to rwx format
|
// Helper function to convert permissions to rwx format
|
||||||
|
|||||||
@@ -65,133 +65,134 @@ import crypto from 'crypto';
|
|||||||
import sqlite3 from 'sqlite3';
|
import sqlite3 from 'sqlite3';
|
||||||
import { open } from 'sqlite';
|
import { open } from 'sqlite';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
import sessionManager from './sessionManager.js';
|
||||||
|
|
||||||
// Import TaskMaster detection functions
|
// Import TaskMaster detection functions
|
||||||
async function detectTaskMasterFolder(projectPath) {
|
async function detectTaskMasterFolder(projectPath) {
|
||||||
|
try {
|
||||||
|
const taskMasterPath = path.join(projectPath, '.taskmaster');
|
||||||
|
|
||||||
|
// Check if .taskmaster directory exists
|
||||||
try {
|
try {
|
||||||
const taskMasterPath = path.join(projectPath, '.taskmaster');
|
const stats = await fs.stat(taskMasterPath);
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
// 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' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasTaskmaster: true,
|
hasTaskmaster: false,
|
||||||
hasEssentialFiles,
|
reason: '.taskmaster exists but is not a directory'
|
||||||
files: fileStatus,
|
|
||||||
metadata: taskMetadata,
|
|
||||||
path: taskMasterPath
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error detecting TaskMaster folder:', error);
|
if (error.code === 'ENOENT') {
|
||||||
return {
|
return {
|
||||||
hasTaskmaster: false,
|
hasTaskmaster: false,
|
||||||
reason: `Error checking directory: ${error.message}`
|
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
|
// Cache for extracted project directories
|
||||||
@@ -218,7 +219,7 @@ async function loadProjectConfig() {
|
|||||||
async function saveProjectConfig(config) {
|
async function saveProjectConfig(config) {
|
||||||
const claudeDir = path.join(os.homedir(), '.claude');
|
const claudeDir = path.join(os.homedir(), '.claude');
|
||||||
const configPath = path.join(claudeDir, 'project-config.json');
|
const configPath = path.join(claudeDir, 'project-config.json');
|
||||||
|
|
||||||
// Ensure the .claude directory exists
|
// Ensure the .claude directory exists
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(claudeDir, { recursive: true });
|
await fs.mkdir(claudeDir, { recursive: true });
|
||||||
@@ -227,7 +228,7 @@ async function saveProjectConfig(config) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,13 +236,13 @@ async function saveProjectConfig(config) {
|
|||||||
async function generateDisplayName(projectName, actualProjectDir = null) {
|
async function generateDisplayName(projectName, actualProjectDir = null) {
|
||||||
// Use actual project directory if provided, otherwise decode from project name
|
// Use actual project directory if provided, otherwise decode from project name
|
||||||
let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
|
let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
|
||||||
|
|
||||||
// Try to read package.json from the project path
|
// Try to read package.json from the project path
|
||||||
try {
|
try {
|
||||||
const packageJsonPath = path.join(projectPath, 'package.json');
|
const packageJsonPath = path.join(projectPath, 'package.json');
|
||||||
const packageData = await fs.readFile(packageJsonPath, 'utf8');
|
const packageData = await fs.readFile(packageJsonPath, 'utf8');
|
||||||
const packageJson = JSON.parse(packageData);
|
const packageJson = JSON.parse(packageData);
|
||||||
|
|
||||||
// Return the name from package.json if it exists
|
// Return the name from package.json if it exists
|
||||||
if (packageJson.name) {
|
if (packageJson.name) {
|
||||||
return packageJson.name;
|
return packageJson.name;
|
||||||
@@ -249,14 +250,14 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fall back to path-based naming if package.json doesn't exist or can't be read
|
// 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 it starts with /, it's an absolute path
|
||||||
if (projectPath.startsWith('/')) {
|
if (projectPath.startsWith('/')) {
|
||||||
const parts = projectPath.split('/').filter(Boolean);
|
const parts = projectPath.split('/').filter(Boolean);
|
||||||
// Return only the last folder name
|
// Return only the last folder name
|
||||||
return parts[parts.length - 1] || projectPath;
|
return parts[parts.length - 1] || projectPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
return projectPath;
|
return projectPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,14 +282,14 @@ async function extractProjectDirectory(projectName) {
|
|||||||
let latestTimestamp = 0;
|
let latestTimestamp = 0;
|
||||||
let latestCwd = null;
|
let latestCwd = null;
|
||||||
let extractedPath;
|
let extractedPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if the project directory exists
|
// Check if the project directory exists
|
||||||
await fs.access(projectDir);
|
await fs.access(projectDir);
|
||||||
|
|
||||||
const files = await fs.readdir(projectDir);
|
const files = await fs.readdir(projectDir);
|
||||||
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
||||||
|
|
||||||
if (jsonlFiles.length === 0) {
|
if (jsonlFiles.length === 0) {
|
||||||
// Fall back to decoded project name if no sessions
|
// Fall back to decoded project name if no sessions
|
||||||
extractedPath = projectName.replace(/-/g, '/');
|
extractedPath = projectName.replace(/-/g, '/');
|
||||||
@@ -301,16 +302,16 @@ async function extractProjectDirectory(projectName) {
|
|||||||
input: fileStream,
|
input: fileStream,
|
||||||
crlfDelay: Infinity
|
crlfDelay: Infinity
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line);
|
const entry = JSON.parse(line);
|
||||||
|
|
||||||
if (entry.cwd) {
|
if (entry.cwd) {
|
||||||
// Count occurrences of each cwd
|
// Count occurrences of each cwd
|
||||||
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
|
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
|
||||||
|
|
||||||
// Track the most recent cwd
|
// Track the most recent cwd
|
||||||
const timestamp = new Date(entry.timestamp || 0).getTime();
|
const timestamp = new Date(entry.timestamp || 0).getTime();
|
||||||
if (timestamp > latestTimestamp) {
|
if (timestamp > latestTimestamp) {
|
||||||
@@ -324,7 +325,7 @@ async function extractProjectDirectory(projectName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the best cwd to use
|
// Determine the best cwd to use
|
||||||
if (cwdCounts.size === 0) {
|
if (cwdCounts.size === 0) {
|
||||||
// No cwd found, fall back to decoded project name
|
// 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
|
// Multiple cwd values - prefer the most recent one if it has reasonable usage
|
||||||
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
|
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
|
||||||
const maxCount = Math.max(...cwdCounts.values());
|
const maxCount = Math.max(...cwdCounts.values());
|
||||||
|
|
||||||
// Use most recent if it has at least 25% of the max count
|
// Use most recent if it has at least 25% of the max count
|
||||||
if (mostRecentCount >= maxCount * 0.25) {
|
if (mostRecentCount >= maxCount * 0.25) {
|
||||||
extractedPath = latestCwd;
|
extractedPath = latestCwd;
|
||||||
@@ -349,19 +350,19 @@ async function extractProjectDirectory(projectName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback (shouldn't reach here)
|
// Fallback (shouldn't reach here)
|
||||||
if (!extractedPath) {
|
if (!extractedPath) {
|
||||||
extractedPath = latestCwd || projectName.replace(/-/g, '/');
|
extractedPath = latestCwd || projectName.replace(/-/g, '/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
projectDirectoryCache.set(projectName, extractedPath);
|
projectDirectoryCache.set(projectName, extractedPath);
|
||||||
|
|
||||||
return extractedPath;
|
return extractedPath;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If the directory doesn't exist, just use the decoded project name
|
// If the directory doesn't exist, just use the decoded project name
|
||||||
if (error.code === 'ENOENT') {
|
if (error.code === 'ENOENT') {
|
||||||
@@ -371,10 +372,10 @@ async function extractProjectDirectory(projectName) {
|
|||||||
// Fall back to decoded project name for other errors
|
// Fall back to decoded project name for other errors
|
||||||
extractedPath = projectName.replace(/-/g, '/');
|
extractedPath = projectName.replace(/-/g, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the fallback result too
|
// Cache the fallback result too
|
||||||
projectDirectoryCache.set(projectName, extractedPath);
|
projectDirectoryCache.set(projectName, extractedPath);
|
||||||
|
|
||||||
return extractedPath;
|
return extractedPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,91 +409,100 @@ async function getProjects(progressCallback = null) {
|
|||||||
totalProjects = directories.length + manualProjectsCount;
|
totalProjects = directories.length + manualProjectsCount;
|
||||||
|
|
||||||
for (const entry of directories) {
|
for (const entry of directories) {
|
||||||
processedProjects++;
|
processedProjects++;
|
||||||
|
|
||||||
// Emit progress
|
// Emit progress
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback({
|
progressCallback({
|
||||||
phase: 'loading',
|
phase: 'loading',
|
||||||
current: processedProjects,
|
current: processedProjects,
|
||||||
total: totalProjects,
|
total: totalProjects,
|
||||||
currentProject: entry.name
|
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
|
// Try to get sessions for this project (just first 5 for performance)
|
||||||
const actualProjectDir = await extractProjectDirectory(entry.name);
|
try {
|
||||||
|
const sessionResult = await getSessions(entry.name, 5, 0);
|
||||||
// Get display name from config or generate one
|
project.sessions = sessionResult.sessions || [];
|
||||||
const customName = config[entry.name]?.displayName;
|
project.sessionMeta = {
|
||||||
const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);
|
hasMore: sessionResult.hasMore,
|
||||||
const fullPath = actualProjectDir;
|
total: sessionResult.total
|
||||||
|
|
||||||
const project = {
|
|
||||||
name: entry.name,
|
|
||||||
path: actualProjectDir,
|
|
||||||
displayName: customName || autoDisplayName,
|
|
||||||
fullPath: fullPath,
|
|
||||||
isCustomName: !!customName,
|
|
||||||
sessions: [],
|
|
||||||
sessionMeta: {
|
|
||||||
hasMore: false,
|
|
||||||
total: 0
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
} catch (e) {
|
||||||
// Try to get sessions for this project (just first 5 for performance)
|
console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
|
||||||
try {
|
project.sessionMeta = {
|
||||||
const sessionResult = await getSessions(entry.name, 5, 0);
|
hasMore: false,
|
||||||
project.sessions = sessionResult.sessions || [];
|
total: 0
|
||||||
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 = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also fetch Codex sessions for this project
|
// Also fetch Cursor sessions for this project
|
||||||
try {
|
try {
|
||||||
project.codexSessions = await getCodexSessions(actualProjectDir, {
|
project.cursorSessions = await getCursorSessions(actualProjectDir);
|
||||||
indexRef: codexSessionsIndexRef,
|
} catch (e) {
|
||||||
});
|
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
|
||||||
} catch (e) {
|
project.cursorSessions = [];
|
||||||
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
|
}
|
||||||
project.codexSessions = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add TaskMaster detection
|
// Also fetch Codex sessions for this project
|
||||||
try {
|
try {
|
||||||
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
project.codexSessions = await getCodexSessions(actualProjectDir, {
|
||||||
project.taskmaster = {
|
indexRef: codexSessionsIndexRef,
|
||||||
hasTaskmaster: taskMasterResult.hasTaskmaster,
|
});
|
||||||
hasEssentialFiles: taskMasterResult.hasEssentialFiles,
|
} catch (e) {
|
||||||
metadata: taskMasterResult.metadata,
|
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
|
||||||
status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured'
|
project.codexSessions = [];
|
||||||
};
|
}
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message);
|
// Also fetch Gemini sessions for this project
|
||||||
project.taskmaster = {
|
try {
|
||||||
hasTaskmaster: false,
|
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||||
hasEssentialFiles: false,
|
} catch (e) {
|
||||||
metadata: null,
|
console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
|
||||||
status: 'error'
|
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);
|
projects.push(project);
|
||||||
}
|
}
|
||||||
@@ -506,7 +516,7 @@ async function getProjects(progressCallback = null) {
|
|||||||
.filter(([name, cfg]) => cfg.manuallyAdded)
|
.filter(([name, cfg]) => cfg.manuallyAdded)
|
||||||
.length;
|
.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add manually configured projects that don't exist as folders yet
|
// Add manually configured projects that don't exist as folders yet
|
||||||
for (const [projectName, projectConfig] of Object.entries(config)) {
|
for (const [projectName, projectConfig] of Object.entries(config)) {
|
||||||
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
|
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
|
// Use the original path if available, otherwise extract from potential sessions
|
||||||
let actualProjectDir = projectConfig.originalPath;
|
let actualProjectDir = projectConfig.originalPath;
|
||||||
|
|
||||||
if (!actualProjectDir) {
|
if (!actualProjectDir) {
|
||||||
try {
|
try {
|
||||||
actualProjectDir = await extractProjectDirectory(projectName);
|
actualProjectDir = await extractProjectDirectory(projectName);
|
||||||
@@ -533,21 +543,22 @@ async function getProjects(progressCallback = null) {
|
|||||||
actualProjectDir = projectName.replace(/-/g, '/');
|
actualProjectDir = projectName.replace(/-/g, '/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = {
|
const project = {
|
||||||
name: projectName,
|
name: projectName,
|
||||||
path: actualProjectDir,
|
path: actualProjectDir,
|
||||||
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
|
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
|
||||||
fullPath: actualProjectDir,
|
fullPath: actualProjectDir,
|
||||||
isCustomName: !!projectConfig.displayName,
|
isCustomName: !!projectConfig.displayName,
|
||||||
isManuallyAdded: true,
|
isManuallyAdded: true,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
sessionMeta: {
|
geminiSessions: [],
|
||||||
hasMore: false,
|
sessionMeta: {
|
||||||
total: 0
|
hasMore: false,
|
||||||
},
|
total: 0
|
||||||
cursorSessions: [],
|
},
|
||||||
codexSessions: []
|
cursorSessions: [],
|
||||||
|
codexSessions: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to fetch Cursor sessions for manual projects too
|
// 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);
|
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
|
// Add TaskMaster detection for manual projects
|
||||||
try {
|
try {
|
||||||
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
||||||
|
|
||||||
// Determine TaskMaster status
|
// Determine TaskMaster status
|
||||||
let taskMasterStatus = 'not-configured';
|
let taskMasterStatus = 'not-configured';
|
||||||
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
|
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
|
||||||
taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk
|
taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk
|
||||||
}
|
}
|
||||||
|
|
||||||
project.taskmaster = {
|
project.taskmaster = {
|
||||||
status: taskMasterStatus,
|
status: taskMasterStatus,
|
||||||
hasTaskmaster: taskMasterResult.hasTaskmaster,
|
hasTaskmaster: taskMasterResult.hasTaskmaster,
|
||||||
@@ -591,7 +609,7 @@ async function getProjects(progressCallback = null) {
|
|||||||
error: error.message
|
error: error.message
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
projects.push(project);
|
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
|
// 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
|
// 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-'));
|
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
|
||||||
|
|
||||||
if (jsonlFiles.length === 0) {
|
if (jsonlFiles.length === 0) {
|
||||||
return { sessions: [], hasMore: false, total: 0 };
|
return { sessions: [], hasMore: false, total: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort files by modification time (newest first)
|
// Sort files by modification time (newest first)
|
||||||
const filesWithStats = await Promise.all(
|
const filesWithStats = await Promise.all(
|
||||||
jsonlFiles.map(async (file) => {
|
jsonlFiles.map(async (file) => {
|
||||||
@@ -630,37 +648,37 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
const allSessions = new Map();
|
const allSessions = new Map();
|
||||||
const allEntries = [];
|
const allEntries = [];
|
||||||
const uuidToSessionMap = new Map();
|
const uuidToSessionMap = new Map();
|
||||||
|
|
||||||
// Collect all sessions and entries from all files
|
// Collect all sessions and entries from all files
|
||||||
for (const { file } of filesWithStats) {
|
for (const { file } of filesWithStats) {
|
||||||
const jsonlFile = path.join(projectDir, file);
|
const jsonlFile = path.join(projectDir, file);
|
||||||
const result = await parseJsonlSessions(jsonlFile);
|
const result = await parseJsonlSessions(jsonlFile);
|
||||||
|
|
||||||
result.sessions.forEach(session => {
|
result.sessions.forEach(session => {
|
||||||
if (!allSessions.has(session.id)) {
|
if (!allSessions.has(session.id)) {
|
||||||
allSessions.set(session.id, session);
|
allSessions.set(session.id, session);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
allEntries.push(...result.entries);
|
allEntries.push(...result.entries);
|
||||||
|
|
||||||
// Early exit optimization for large projects
|
// Early exit optimization for large projects
|
||||||
if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
|
if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build UUID-to-session mapping for timeline detection
|
// Build UUID-to-session mapping for timeline detection
|
||||||
allEntries.forEach(entry => {
|
allEntries.forEach(entry => {
|
||||||
if (entry.uuid && entry.sessionId) {
|
if (entry.uuid && entry.sessionId) {
|
||||||
uuidToSessionMap.set(entry.uuid, entry.sessionId);
|
uuidToSessionMap.set(entry.uuid, entry.sessionId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group sessions by first user message ID
|
// Group sessions by first user message ID
|
||||||
const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
|
const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
|
||||||
const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
|
const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
|
||||||
@@ -722,7 +740,7 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
|||||||
const total = visibleSessions.length;
|
const total = visibleSessions.length;
|
||||||
const paginatedSessions = visibleSessions.slice(offset, offset + limit);
|
const paginatedSessions = visibleSessions.slice(offset, offset + limit);
|
||||||
const hasMore = offset + limit < total;
|
const hasMore = offset + limit < total;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessions: paginatedSessions,
|
sessions: paginatedSessions,
|
||||||
hasMore,
|
hasMore,
|
||||||
@@ -926,8 +944,8 @@ async function parseAgentTools(filePath) {
|
|||||||
if (tool) {
|
if (tool) {
|
||||||
tool.toolResult = {
|
tool.toolResult = {
|
||||||
content: typeof part.content === 'string' ? part.content :
|
content: typeof part.content === 'string' ? part.content :
|
||||||
Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') :
|
Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') :
|
||||||
JSON.stringify(part.content),
|
JSON.stringify(part.content),
|
||||||
isError: Boolean(part.is_error)
|
isError: Boolean(part.is_error)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1015,7 +1033,6 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort messages by timestamp
|
// Sort messages by timestamp
|
||||||
const sortedMessages = messages.sort((a, b) =>
|
const sortedMessages = messages.sort((a, b) =>
|
||||||
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
|
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
|
// Rename a project's display name
|
||||||
async function renameProject(projectName, newDisplayName) {
|
async function renameProject(projectName, newDisplayName) {
|
||||||
const config = await loadProjectConfig();
|
const config = await loadProjectConfig();
|
||||||
|
|
||||||
if (!newDisplayName || newDisplayName.trim() === '') {
|
if (!newDisplayName || newDisplayName.trim() === '') {
|
||||||
// Remove custom name if empty, will fall back to auto-generated
|
// Remove custom name if empty, will fall back to auto-generated
|
||||||
delete config[projectName];
|
delete config[projectName];
|
||||||
@@ -1061,7 +1078,7 @@ async function renameProject(projectName, newDisplayName) {
|
|||||||
displayName: newDisplayName.trim()
|
displayName: newDisplayName.trim()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveProjectConfig(config);
|
await saveProjectConfig(config);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1069,21 +1086,21 @@ async function renameProject(projectName, newDisplayName) {
|
|||||||
// Delete a session from a project
|
// Delete a session from a project
|
||||||
async function deleteSession(projectName, sessionId) {
|
async function deleteSession(projectName, sessionId) {
|
||||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await fs.readdir(projectDir);
|
const files = await fs.readdir(projectDir);
|
||||||
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
||||||
|
|
||||||
if (jsonlFiles.length === 0) {
|
if (jsonlFiles.length === 0) {
|
||||||
throw new Error('No session files found for this project');
|
throw new Error('No session files found for this project');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all JSONL files to find which one contains the session
|
// Check all JSONL files to find which one contains the session
|
||||||
for (const file of jsonlFiles) {
|
for (const file of jsonlFiles) {
|
||||||
const jsonlFile = path.join(projectDir, file);
|
const jsonlFile = path.join(projectDir, file);
|
||||||
const content = await fs.readFile(jsonlFile, 'utf8');
|
const content = await fs.readFile(jsonlFile, 'utf8');
|
||||||
const lines = content.split('\n').filter(line => line.trim());
|
const lines = content.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
// Check if this file contains the session
|
// Check if this file contains the session
|
||||||
const hasSession = lines.some(line => {
|
const hasSession = lines.some(line => {
|
||||||
try {
|
try {
|
||||||
@@ -1093,7 +1110,7 @@ async function deleteSession(projectName, sessionId) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasSession) {
|
if (hasSession) {
|
||||||
// Filter out all entries for this session
|
// Filter out all entries for this session
|
||||||
const filteredLines = lines.filter(line => {
|
const filteredLines = lines.filter(line => {
|
||||||
@@ -1104,13 +1121,13 @@ async function deleteSession(projectName, sessionId) {
|
|||||||
return true; // Keep malformed lines
|
return true; // Keep malformed lines
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Write back the filtered content
|
// Write back the filtered content
|
||||||
await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : ''));
|
await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : ''));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Session ${sessionId} not found in any files`);
|
throw new Error(`Session ${sessionId} not found in any files`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);
|
console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);
|
||||||
@@ -1220,10 +1237,10 @@ async function addProjectManually(projectPath, displayName = null) {
|
|||||||
if (displayName) {
|
if (displayName) {
|
||||||
config[projectName].displayName = displayName;
|
config[projectName].displayName = displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveProjectConfig(config);
|
await saveProjectConfig(config);
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: projectName,
|
name: projectName,
|
||||||
path: absolutePath,
|
path: absolutePath,
|
||||||
@@ -1241,7 +1258,7 @@ async function getCursorSessions(projectPath) {
|
|||||||
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
|
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
|
||||||
const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');
|
const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');
|
||||||
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
||||||
|
|
||||||
// Check if the directory exists
|
// Check if the directory exists
|
||||||
try {
|
try {
|
||||||
await fs.access(cursorChatsPath);
|
await fs.access(cursorChatsPath);
|
||||||
@@ -1249,25 +1266,25 @@ async function getCursorSessions(projectPath) {
|
|||||||
// No sessions for this project
|
// No sessions for this project
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all session directories
|
// List all session directories
|
||||||
const sessionDirs = await fs.readdir(cursorChatsPath);
|
const sessionDirs = await fs.readdir(cursorChatsPath);
|
||||||
const sessions = [];
|
const sessions = [];
|
||||||
|
|
||||||
for (const sessionId of sessionDirs) {
|
for (const sessionId of sessionDirs) {
|
||||||
const sessionPath = path.join(cursorChatsPath, sessionId);
|
const sessionPath = path.join(cursorChatsPath, sessionId);
|
||||||
const storeDbPath = path.join(sessionPath, 'store.db');
|
const storeDbPath = path.join(sessionPath, 'store.db');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if store.db exists
|
// Check if store.db exists
|
||||||
await fs.access(storeDbPath);
|
await fs.access(storeDbPath);
|
||||||
|
|
||||||
// Capture store.db mtime as a reliable fallback timestamp
|
// Capture store.db mtime as a reliable fallback timestamp
|
||||||
let dbStatMtimeMs = null;
|
let dbStatMtimeMs = null;
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(storeDbPath);
|
const stat = await fs.stat(storeDbPath);
|
||||||
dbStatMtimeMs = stat.mtimeMs;
|
dbStatMtimeMs = stat.mtimeMs;
|
||||||
} catch (_) {}
|
} catch (_) { }
|
||||||
|
|
||||||
// Open SQLite database
|
// Open SQLite database
|
||||||
const db = await open({
|
const db = await open({
|
||||||
@@ -1275,12 +1292,12 @@ async function getCursorSessions(projectPath) {
|
|||||||
driver: sqlite3.Database,
|
driver: sqlite3.Database,
|
||||||
mode: sqlite3.OPEN_READONLY
|
mode: sqlite3.OPEN_READONLY
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get metadata from meta table
|
// Get metadata from meta table
|
||||||
const metaRows = await db.all(`
|
const metaRows = await db.all(`
|
||||||
SELECT key, value FROM meta
|
SELECT key, value FROM meta
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Parse metadata
|
// Parse metadata
|
||||||
let metadata = {};
|
let metadata = {};
|
||||||
for (const row of metaRows) {
|
for (const row of metaRows) {
|
||||||
@@ -1299,17 +1316,17 @@ async function getCursorSessions(projectPath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get message count
|
// Get message count
|
||||||
const messageCountResult = await db.get(`
|
const messageCountResult = await db.get(`
|
||||||
SELECT COUNT(*) as count FROM blobs
|
SELECT COUNT(*) as count FROM blobs
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
|
|
||||||
// Extract session info
|
// Extract session info
|
||||||
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
|
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
|
||||||
|
|
||||||
// Determine timestamp - prefer createdAt from metadata, fall back to db file mtime
|
// Determine timestamp - prefer createdAt from metadata, fall back to db file mtime
|
||||||
let createdAt = null;
|
let createdAt = null;
|
||||||
if (metadata.createdAt) {
|
if (metadata.createdAt) {
|
||||||
@@ -1319,7 +1336,7 @@ async function getCursorSessions(projectPath) {
|
|||||||
} else {
|
} else {
|
||||||
createdAt = new Date().toISOString();
|
createdAt = new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.push({
|
sessions.push({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
name: sessionName,
|
name: sessionName,
|
||||||
@@ -1328,18 +1345,18 @@ async function getCursorSessions(projectPath) {
|
|||||||
messageCount: messageCountResult.count || 0,
|
messageCount: messageCountResult.count || 0,
|
||||||
projectPath: projectPath
|
projectPath: projectPath
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Could not read Cursor session ${sessionId}:`, error.message);
|
console.warn(`Could not read Cursor session ${sessionId}:`, error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort sessions by creation time (newest first)
|
// Sort sessions by creation time (newest first)
|
||||||
sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||||
|
|
||||||
// Return only the first 5 sessions for performance
|
// Return only the first 5 sessions for performance
|
||||||
return sessions.slice(0, 5);
|
return sessions.slice(0, 5);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Cursor sessions:', error);
|
console.error('Error fetching Cursor sessions:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -1785,7 +1802,7 @@ async function deleteCodexSession(sessionId) {
|
|||||||
files.push(fullPath);
|
files.push(fullPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {}
|
} catch (error) { }
|
||||||
return files;
|
return files;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { addProjectManually } from '../projects.js';
|
|||||||
import { queryClaudeSDK } from '../claude-sdk.js';
|
import { queryClaudeSDK } from '../claude-sdk.js';
|
||||||
import { spawnCursor } from '../cursor-cli.js';
|
import { spawnCursor } from '../cursor-cli.js';
|
||||||
import { queryCodex } from '../openai-codex.js';
|
import { queryCodex } from '../openai-codex.js';
|
||||||
|
import { spawnGemini } from '../gemini-cli.js';
|
||||||
import { Octokit } from '@octokit/rest';
|
import { Octokit } from '@octokit/rest';
|
||||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||||
import { IS_PLATFORM } from '../constants/config.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)
|
* - Source for auto-generated branch names (if createBranch=true and no branchName)
|
||||||
* - Fallback for PR title if no commits are made
|
* - 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'
|
* Default: 'claude'
|
||||||
*
|
*
|
||||||
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
|
* @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):
|
* Input Validations (400 Bad Request):
|
||||||
* - Either githubUrl OR projectPath must be provided (not neither)
|
* - Either githubUrl OR projectPath must be provided (not neither)
|
||||||
* - message must be non-empty string
|
* - 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)
|
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
|
||||||
* - branchName must pass Git naming rules (if provided)
|
* - 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' });
|
return res.status(400).json({ error: 'message is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['claude', 'cursor', 'codex'].includes(provider)) {
|
if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
|
||||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
|
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate GitHub branch/PR creation requirements
|
// Validate GitHub branch/PR creation requirements
|
||||||
@@ -971,6 +972,16 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|||||||
model: model || CODEX_MODELS.DEFAULT,
|
model: model || CODEX_MODELS.DEFAULT,
|
||||||
permissionMode: 'bypassPermissions'
|
permissionMode: 'bypassPermissions'
|
||||||
}, writer);
|
}, 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
|
// 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() {
|
async function checkClaudeCredentials() {
|
||||||
try {
|
try {
|
||||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
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;
|
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'
|
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 StandaloneShell from './standalone-shell/view/StandaloneShell';
|
||||||
import { IS_PLATFORM } from '../constants/config';
|
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 {Object} props
|
||||||
* @param {boolean} props.isOpen - Whether the modal is visible
|
* @param {boolean} props.isOpen - Whether the modal is visible
|
||||||
* @param {Function} props.onClose - Callback when modal is closed
|
* @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 {Object} props.project - Project object containing name and path information
|
||||||
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
|
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
|
||||||
* @param {string} props.customCommand - Optional custom command to override defaults
|
* @param {string} props.customCommand - Optional custom command to override defaults
|
||||||
@@ -36,6 +36,9 @@ function LoginModal({
|
|||||||
return 'cursor-agent login';
|
return 'cursor-agent login';
|
||||||
case 'codex':
|
case 'codex':
|
||||||
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
|
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:
|
default:
|
||||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
|
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';
|
return 'Cursor CLI Login';
|
||||||
case 'codex':
|
case 'codex':
|
||||||
return 'Codex CLI Login';
|
return 'Codex CLI Login';
|
||||||
|
case 'gemini':
|
||||||
|
return 'Gemini CLI Configuration';
|
||||||
default:
|
default:
|
||||||
return 'CLI Login';
|
return 'CLI Login';
|
||||||
}
|
}
|
||||||
@@ -77,12 +82,68 @@ function LoginModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<StandaloneShell
|
{provider === 'gemini' ? (
|
||||||
project={project}
|
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-gray-50 dark:bg-gray-900/50">
|
||||||
command={getCommand()}
|
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mb-6">
|
||||||
onComplete={handleComplete}
|
<KeyRound className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||||
minimal={true}
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
error: null
|
error: null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [geminiAuthStatus, setGeminiAuthStatus] = useState({
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
loading: true,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const prevActiveLoginProviderRef = useRef(undefined);
|
const prevActiveLoginProviderRef = useRef(undefined);
|
||||||
@@ -69,22 +76,23 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
checkClaudeAuthStatus();
|
checkClaudeAuthStatus();
|
||||||
checkCursorAuthStatus();
|
checkCursorAuthStatus();
|
||||||
checkCodexAuthStatus();
|
checkCodexAuthStatus();
|
||||||
|
checkGeminiAuthStatus();
|
||||||
}
|
}
|
||||||
}, [activeLoginProvider]);
|
}, [activeLoginProvider]);
|
||||||
|
|
||||||
const checkClaudeAuthStatus = async () => {
|
const checkProviderAuthStatus = async (provider, setter) => {
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch('/api/cli/claude/status');
|
const response = await authenticatedFetch(`/api/cli/${provider}/status`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setClaudeAuthStatus({
|
setter({
|
||||||
authenticated: data.authenticated,
|
authenticated: data.authenticated,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: data.error || null
|
error: data.error || null
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setClaudeAuthStatus({
|
setter({
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email: null,
|
email: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -92,8 +100,8 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking Claude auth status:', error);
|
console.error(`Error checking ${provider} auth status:`, error);
|
||||||
setClaudeAuthStatus({
|
setter({
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email: null,
|
email: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -102,69 +110,15 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkCursorAuthStatus = async () => {
|
const checkClaudeAuthStatus = () => checkProviderAuthStatus('claude', setClaudeAuthStatus);
|
||||||
try {
|
const checkCursorAuthStatus = () => checkProviderAuthStatus('cursor', setCursorAuthStatus);
|
||||||
const response = await authenticatedFetch('/api/cli/cursor/status');
|
const checkCodexAuthStatus = () => checkProviderAuthStatus('codex', setCodexAuthStatus);
|
||||||
if (response.ok) {
|
const checkGeminiAuthStatus = () => checkProviderAuthStatus('gemini', setGeminiAuthStatus);
|
||||||
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 handleClaudeLogin = () => setActiveLoginProvider('claude');
|
const handleClaudeLogin = () => setActiveLoginProvider('claude');
|
||||||
const handleCursorLogin = () => setActiveLoginProvider('cursor');
|
const handleCursorLogin = () => setActiveLoginProvider('cursor');
|
||||||
const handleCodexLogin = () => setActiveLoginProvider('codex');
|
const handleCodexLogin = () => setActiveLoginProvider('codex');
|
||||||
|
const handleGeminiLogin = () => setActiveLoginProvider('gemini');
|
||||||
|
|
||||||
const handleLoginComplete = (exitCode) => {
|
const handleLoginComplete = (exitCode) => {
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
@@ -174,6 +128,8 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
checkCursorAuthStatus();
|
checkCursorAuthStatus();
|
||||||
} else if (activeLoginProvider === 'codex') {
|
} else if (activeLoginProvider === 'codex') {
|
||||||
checkCodexAuthStatus();
|
checkCodexAuthStatus();
|
||||||
|
} else if (activeLoginProvider === 'gemini') {
|
||||||
|
checkGeminiAuthStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -337,11 +293,10 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
{/* Agent Cards Grid */}
|
{/* Agent Cards Grid */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Claude */}
|
{/* Claude */}
|
||||||
<div className={`border rounded-lg p-4 transition-colors ${
|
<div className={`border rounded-lg p-4 transition-colors ${claudeAuthStatus.authenticated
|
||||||
claudeAuthStatus.authenticated
|
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
: 'border-border bg-card'
|
||||||
: 'border-border bg-card'
|
}`}>
|
||||||
}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{claudeAuthStatus.loading ? 'Checking...' :
|
{claudeAuthStatus.loading ? 'Checking...' :
|
||||||
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
|
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -370,11 +325,10 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cursor */}
|
{/* Cursor */}
|
||||||
<div className={`border rounded-lg p-4 transition-colors ${
|
<div className={`border rounded-lg p-4 transition-colors ${cursorAuthStatus.authenticated
|
||||||
cursorAuthStatus.authenticated
|
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||||
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
: 'border-border bg-card'
|
||||||
: 'border-border bg-card'
|
}`}>
|
||||||
}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{cursorAuthStatus.loading ? 'Checking...' :
|
{cursorAuthStatus.loading ? 'Checking...' :
|
||||||
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
|
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -403,11 +357,10 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Codex */}
|
{/* Codex */}
|
||||||
<div className={`border rounded-lg p-4 transition-colors ${
|
<div className={`border rounded-lg p-4 transition-colors ${codexAuthStatus.authenticated
|
||||||
codexAuthStatus.authenticated
|
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
|
||||||
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
|
: 'border-border bg-card'
|
||||||
: 'border-border bg-card'
|
}`}>
|
||||||
}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{codexAuthStatus.loading ? 'Checking...' :
|
{codexAuthStatus.loading ? 'Checking...' :
|
||||||
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
|
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -434,6 +387,38 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="text-center text-sm text-muted-foreground pt-2">
|
<div className="text-center text-sm text-muted-foreground pt-2">
|
||||||
@@ -452,7 +437,7 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
case 0:
|
case 0:
|
||||||
return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail);
|
return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail);
|
||||||
case 1:
|
case 1:
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -468,11 +453,10 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
<div className="flex flex-col items-center flex-1">
|
<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 ${
|
<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-green-500 border-green-500 text-white' :
|
|
||||||
index === currentStep ? 'bg-blue-600 border-blue-600 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 ? (
|
{index < currentStep ? (
|
||||||
<Check className="w-6 h-6" />
|
<Check className="w-6 h-6" />
|
||||||
) : typeof step.icon === 'function' ? (
|
) : typeof step.icon === 'function' ? (
|
||||||
@@ -482,9 +466,8 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-center">
|
<div className="mt-2 text-center">
|
||||||
<p className={`text-sm font-medium ${
|
<p className={`text-sm font-medium ${index === currentStep ? 'text-foreground' : 'text-muted-foreground'
|
||||||
index === currentStep ? 'text-foreground' : 'text-muted-foreground'
|
}`}>
|
||||||
}`}>
|
|
||||||
{step.title}
|
{step.title}
|
||||||
</p>
|
</p>
|
||||||
{step.required && (
|
{step.required && (
|
||||||
@@ -493,9 +476,8 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{index < steps.length - 1 && (
|
{index < steps.length - 1 && (
|
||||||
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${
|
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500' : 'bg-border'
|
||||||
index < currentStep ? 'bg-green-500' : 'bg-border'
|
}`} />
|
||||||
}`} />
|
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface UseChatComposerStateArgs {
|
|||||||
cursorModel: string;
|
cursorModel: string;
|
||||||
claudeModel: string;
|
claudeModel: string;
|
||||||
codexModel: string;
|
codexModel: string;
|
||||||
|
geminiModel: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
canAbortSession: boolean;
|
canAbortSession: boolean;
|
||||||
tokenBudget: Record<string, unknown> | null;
|
tokenBudget: Record<string, unknown> | null;
|
||||||
@@ -93,6 +94,7 @@ export function useChatComposerState({
|
|||||||
cursorModel,
|
cursorModel,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
|
geminiModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -289,7 +291,7 @@ export function useChatComposerState({
|
|||||||
projectName: selectedProject.name,
|
projectName: selectedProject.name,
|
||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
provider,
|
provider,
|
||||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : claudeModel,
|
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
|
||||||
tokenUsage: tokenBudget,
|
tokenUsage: tokenBudget,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -343,6 +345,7 @@ export function useChatComposerState({
|
|||||||
codexModel,
|
codexModel,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
cursorModel,
|
cursorModel,
|
||||||
|
geminiModel,
|
||||||
handleBuiltInCommand,
|
handleBuiltInCommand,
|
||||||
handleCustomCommand,
|
handleCustomCommand,
|
||||||
input,
|
input,
|
||||||
@@ -581,8 +584,10 @@ export function useChatComposerState({
|
|||||||
provider === 'cursor'
|
provider === 'cursor'
|
||||||
? 'cursor-tools-settings'
|
? 'cursor-tools-settings'
|
||||||
: provider === 'codex'
|
: provider === 'codex'
|
||||||
? 'codex-settings'
|
? 'codex-settings'
|
||||||
: 'claude-settings';
|
: provider === 'gemini'
|
||||||
|
? 'gemini-settings'
|
||||||
|
: 'claude-settings';
|
||||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
return JSON.parse(savedSettings);
|
return JSON.parse(savedSettings);
|
||||||
@@ -630,6 +635,21 @@ export function useChatComposerState({
|
|||||||
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
|
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 {
|
} else {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: 'claude-command',
|
type: 'claude-command',
|
||||||
@@ -669,6 +689,7 @@ export function useChatComposerState({
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
cursorModel,
|
cursorModel,
|
||||||
executeCommand,
|
executeCommand,
|
||||||
|
geminiModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
onSessionActive,
|
onSessionActive,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
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 { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
|
||||||
import type { ProjectSession, SessionProvider } from '../../../types/app';
|
import type { ProjectSession, SessionProvider } from '../../../types/app';
|
||||||
|
|
||||||
@@ -23,6 +23,9 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
|||||||
const [codexModel, setCodexModel] = useState<string>(() => {
|
const [codexModel, setCodexModel] = useState<string>(() => {
|
||||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
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);
|
const lastProviderRef = useRef(provider);
|
||||||
|
|
||||||
@@ -105,6 +108,8 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
|||||||
setClaudeModel,
|
setClaudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
setCodexModel,
|
setCodexModel,
|
||||||
|
geminiModel,
|
||||||
|
setGeminiModel,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
setPermissionMode,
|
setPermissionMode,
|
||||||
pendingPermissionRequests,
|
pendingPermissionRequests,
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export function useChatRealtimeHandlers({
|
|||||||
'claude-error',
|
'claude-error',
|
||||||
'cursor-error',
|
'cursor-error',
|
||||||
'codex-error',
|
'codex-error',
|
||||||
|
'gemini-error',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isClaudeSystemInit =
|
const isClaudeSystemInit =
|
||||||
@@ -162,8 +163,8 @@ export function useChatRealtimeHandlers({
|
|||||||
const systemInitSessionId = isClaudeSystemInit
|
const systemInitSessionId = isClaudeSystemInit
|
||||||
? structuredMessageData?.session_id
|
? structuredMessageData?.session_id
|
||||||
: isCursorSystemInit
|
: isCursorSystemInit
|
||||||
? rawStructuredData?.session_id
|
? rawStructuredData?.session_id
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const activeViewSessionId =
|
const activeViewSessionId =
|
||||||
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
|
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
|
||||||
@@ -176,7 +177,8 @@ export function useChatRealtimeHandlers({
|
|||||||
!pendingViewSessionRef.current.sessionId &&
|
!pendingViewSessionRef.current.sessionId &&
|
||||||
(latestMessage.type === 'claude-error' ||
|
(latestMessage.type === 'claude-error' ||
|
||||||
latestMessage.type === 'cursor-error' ||
|
latestMessage.type === 'cursor-error' ||
|
||||||
latestMessage.type === 'codex-error');
|
latestMessage.type === 'codex-error' ||
|
||||||
|
latestMessage.type === 'gemini-error');
|
||||||
|
|
||||||
const handleBackgroundLifecycle = (sessionId?: string) => {
|
const handleBackgroundLifecycle = (sessionId?: string) => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -225,12 +227,6 @@ export function useChatRealtimeHandlers({
|
|||||||
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
|
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
|
||||||
handleBackgroundLifecycle(latestMessage.sessionId);
|
handleBackgroundLifecycle(latestMessage.sessionId);
|
||||||
}
|
}
|
||||||
console.log(
|
|
||||||
'Skipping message for different session:',
|
|
||||||
latestMessage.sessionId,
|
|
||||||
'current:',
|
|
||||||
activeViewSessionId,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,11 +293,6 @@ export function useChatRealtimeHandlers({
|
|||||||
structuredMessageData.session_id !== currentSessionId &&
|
structuredMessageData.session_id !== currentSessionId &&
|
||||||
isSystemInitForView
|
isSystemInitForView
|
||||||
) {
|
) {
|
||||||
console.log('Claude CLI session duplication detected:', {
|
|
||||||
originalSession: currentSessionId,
|
|
||||||
newSession: structuredMessageData.session_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsSystemSessionChange(true);
|
setIsSystemSessionChange(true);
|
||||||
onNavigateToSession?.(structuredMessageData.session_id);
|
onNavigateToSession?.(structuredMessageData.session_id);
|
||||||
return;
|
return;
|
||||||
@@ -314,10 +305,6 @@ export function useChatRealtimeHandlers({
|
|||||||
!currentSessionId &&
|
!currentSessionId &&
|
||||||
isSystemInitForView
|
isSystemInitForView
|
||||||
) {
|
) {
|
||||||
console.log('New session init detected:', {
|
|
||||||
newSession: structuredMessageData.session_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsSystemSessionChange(true);
|
setIsSystemSessionChange(true);
|
||||||
onNavigateToSession?.(structuredMessageData.session_id);
|
onNavigateToSession?.(structuredMessageData.session_id);
|
||||||
return;
|
return;
|
||||||
@@ -331,7 +318,6 @@ export function useChatRealtimeHandlers({
|
|||||||
structuredMessageData.session_id === currentSessionId &&
|
structuredMessageData.session_id === currentSessionId &&
|
||||||
isSystemInitForView
|
isSystemInitForView
|
||||||
) {
|
) {
|
||||||
console.log('System init message for current session, ignoring');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,17 +569,12 @@ export function useChatRealtimeHandlers({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentSessionId && cursorData.session_id !== currentSessionId) {
|
if (currentSessionId && cursorData.session_id !== currentSessionId) {
|
||||||
console.log('Cursor session switch detected:', {
|
|
||||||
originalSession: currentSessionId,
|
|
||||||
newSession: cursorData.session_id,
|
|
||||||
});
|
|
||||||
setIsSystemSessionChange(true);
|
setIsSystemSessionChange(true);
|
||||||
onNavigateToSession?.(cursorData.session_id);
|
onNavigateToSession?.(cursorData.session_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
console.log('Cursor new session init detected:', { newSession: cursorData.session_id });
|
|
||||||
setIsSystemSessionChange(true);
|
setIsSystemSessionChange(true);
|
||||||
onNavigateToSession?.(cursorData.session_id);
|
onNavigateToSession?.(cursorData.session_id);
|
||||||
return;
|
return;
|
||||||
@@ -612,9 +593,8 @@ export function useChatRealtimeHandlers({
|
|||||||
...previous,
|
...previous,
|
||||||
{
|
{
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
content: `Using tool: ${latestMessage.tool} ${
|
content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''
|
||||||
latestMessage.input ? `with ${latestMessage.input}` : ''
|
}`,
|
||||||
}`,
|
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
isToolUse: true,
|
isToolUse: true,
|
||||||
toolName: latestMessage.tool,
|
toolName: latestMessage.tool,
|
||||||
@@ -897,7 +877,6 @@ export function useChatRealtimeHandlers({
|
|||||||
onNavigateToSession?.(codexActualSessionId);
|
onNavigateToSession?.(codexActualSessionId);
|
||||||
}
|
}
|
||||||
sessionStorage.removeItem('pendingSessionId');
|
sessionStorage.removeItem('pendingSessionId');
|
||||||
console.log('Codex session complete, ID set to:', codexPendingSessionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedProject) {
|
if (selectedProject) {
|
||||||
@@ -919,6 +898,91 @@ export function useChatRealtimeHandlers({
|
|||||||
]);
|
]);
|
||||||
break;
|
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': {
|
case 'session-aborted': {
|
||||||
const pendingSessionId =
|
const pendingSessionId =
|
||||||
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ function ChatInterface({
|
|||||||
setClaudeModel,
|
setClaudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
setCodexModel,
|
setCodexModel,
|
||||||
|
geminiModel,
|
||||||
|
setGeminiModel,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
pendingPermissionRequests,
|
pendingPermissionRequests,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
@@ -174,6 +176,7 @@ function ChatInterface({
|
|||||||
cursorModel,
|
cursorModel,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
|
geminiModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -251,7 +254,9 @@ function ChatInterface({
|
|||||||
? t('messageTypes.cursor')
|
? t('messageTypes.cursor')
|
||||||
: provider === 'codex'
|
: provider === 'codex'
|
||||||
? t('messageTypes.codex')
|
? t('messageTypes.codex')
|
||||||
: t('messageTypes.claude');
|
: provider === 'gemini'
|
||||||
|
? t('messageTypes.gemini')
|
||||||
|
: t('messageTypes.claude');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
@@ -287,6 +292,8 @@ function ChatInterface({
|
|||||||
setCursorModel={setCursorModel}
|
setCursorModel={setCursorModel}
|
||||||
codexModel={codexModel}
|
codexModel={codexModel}
|
||||||
setCodexModel={setCodexModel}
|
setCodexModel={setCodexModel}
|
||||||
|
geminiModel={geminiModel}
|
||||||
|
setGeminiModel={setGeminiModel}
|
||||||
tasksEnabled={tasksEnabled}
|
tasksEnabled={tasksEnabled}
|
||||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||||
onShowAllTasks={onShowAllTasks}
|
onShowAllTasks={onShowAllTasks}
|
||||||
@@ -374,8 +381,10 @@ function ChatInterface({
|
|||||||
provider === 'cursor'
|
provider === 'cursor'
|
||||||
? t('messageTypes.cursor')
|
? t('messageTypes.cursor')
|
||||||
: provider === 'codex'
|
: provider === 'codex'
|
||||||
? t('messageTypes.codex')
|
? t('messageTypes.codex')
|
||||||
: t('messageTypes.claude'),
|
: provider === 'gemini'
|
||||||
|
? t('messageTypes.gemini')
|
||||||
|
: t('messageTypes.claude'),
|
||||||
})}
|
})}
|
||||||
isTextareaExpanded={isTextareaExpanded}
|
isTextareaExpanded={isTextareaExpanded}
|
||||||
sendByCtrlEnter={sendByCtrlEnter}
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function AssistantThinkingIndicator({ selectedProvider }: Assista
|
|||||||
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" />
|
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
<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>
|
</div>
|
||||||
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
|
<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;
|
setCursorModel: (model: string) => void;
|
||||||
codexModel: string;
|
codexModel: string;
|
||||||
setCodexModel: (model: string) => void;
|
setCodexModel: (model: string) => void;
|
||||||
|
geminiModel: string;
|
||||||
|
setGeminiModel: (model: string) => void;
|
||||||
tasksEnabled: boolean;
|
tasksEnabled: boolean;
|
||||||
isTaskMasterInstalled: boolean | null;
|
isTaskMasterInstalled: boolean | null;
|
||||||
onShowAllTasks?: (() => void) | null;
|
onShowAllTasks?: (() => void) | null;
|
||||||
@@ -70,6 +72,8 @@ export default function ChatMessagesPane({
|
|||||||
setCursorModel,
|
setCursorModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
setCodexModel,
|
setCodexModel,
|
||||||
|
geminiModel,
|
||||||
|
setGeminiModel,
|
||||||
tasksEnabled,
|
tasksEnabled,
|
||||||
isTaskMasterInstalled,
|
isTaskMasterInstalled,
|
||||||
onShowAllTasks,
|
onShowAllTasks,
|
||||||
@@ -152,6 +156,8 @@ export default function ChatMessagesPane({
|
|||||||
setCursorModel={setCursorModel}
|
setCursorModel={setCursorModel}
|
||||||
codexModel={codexModel}
|
codexModel={codexModel}
|
||||||
setCodexModel={setCodexModel}
|
setCodexModel={setCodexModel}
|
||||||
|
geminiModel={geminiModel}
|
||||||
|
setGeminiModel={setGeminiModel}
|
||||||
tasksEnabled={tasksEnabled}
|
tasksEnabled={tasksEnabled}
|
||||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||||
onShowAllTasks={onShowAllTasks}
|
onShowAllTasks={onShowAllTasks}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function ClaudeStatus({
|
|||||||
return null;
|
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 actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length;
|
||||||
const statusText = status?.text || ACTION_WORDS[actionIndex];
|
const statusText = status?.text || ACTION_WORDS[actionIndex];
|
||||||
const tokens = status?.tokens || fakeTokens;
|
const tokens = status?.tokens || fakeTokens;
|
||||||
@@ -101,6 +102,7 @@ export default function ClaudeStatus({
|
|||||||
|
|
||||||
{canInterrupt && onAbort && (
|
{canInterrupt && onAbort && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onAbort}
|
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"
|
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 MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||||
((prevMessage.type === 'assistant') ||
|
((prevMessage.type === 'assistant') ||
|
||||||
(prevMessage.type === 'user') ||
|
(prevMessage.type === 'user') ||
|
||||||
(prevMessage.type === 'tool') ||
|
(prevMessage.type === 'tool') ||
|
||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = React.useRef<HTMLDivElement | null>(null);
|
const messageRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||||
@@ -154,11 +154,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|
||||||
{message.isToolUse ? (
|
{message.isToolUse ? (
|
||||||
@@ -188,7 +188,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
subagentState={message.subagentState}
|
subagentState={message.subagentState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tool Result Section */}
|
{/* Tool Result Section */}
|
||||||
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||||
message.toolResult.isError ? (
|
message.toolResult.isError ? (
|
||||||
@@ -222,11 +222,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
|
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 ${
|
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'
|
||||||
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-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'
|
||||||
: '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'
|
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||||
? t('permissions.added')
|
? 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 lines = (message.content || '').split('\n').filter((line) => line.trim());
|
||||||
const questionLine = lines.find((line) => line.includes('?')) || lines[0] || '';
|
const questionLine = lines.find((line) => line.includes('?')) || lines[0] || '';
|
||||||
const options: InteractiveOption[] = [];
|
const options: InteractiveOption[] = [];
|
||||||
|
|
||||||
// Parse the menu options
|
// Parse the menu options
|
||||||
lines.forEach((line) => {
|
lines.forEach((line) => {
|
||||||
// Match lines like "❯ 1. Yes" or " 2. No"
|
// Match lines like "❯ 1. Yes" or " 2. No"
|
||||||
@@ -308,31 +307,29 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
|
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
|
||||||
{questionLine}
|
{questionLine}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Option buttons */}
|
{/* Option buttons */}
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.number}
|
key={option.number}
|
||||||
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
|
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${option.isSelected
|
||||||
option.isSelected
|
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
|
||||||
? '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'
|
||||||
: '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`}
|
||||||
} cursor-not-allowed opacity-75`}
|
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<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 ${
|
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${option.isSelected
|
||||||
option.isSelected
|
? 'bg-white/20'
|
||||||
? 'bg-white/20'
|
: 'bg-amber-100 dark:bg-amber-800/50'
|
||||||
: 'bg-amber-100 dark:bg-amber-800/50'
|
}`}>
|
||||||
}`}>
|
|
||||||
{option.number}
|
{option.number}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm sm:text-base font-medium flex-1">
|
<span className="text-sm sm:text-base font-medium flex-1">
|
||||||
@@ -345,7 +342,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
|
<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">
|
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
|
||||||
{t('interactive.waiting')}
|
{t('interactive.waiting')}
|
||||||
@@ -399,7 +396,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
// Detect if content is pure JSON (starts with { or [)
|
// Detect if content is pure JSON (starts with { or [)
|
||||||
const trimmedContent = content.trim();
|
const trimmedContent = content.trim();
|
||||||
if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) &&
|
if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) &&
|
||||||
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
|
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(trimmedContent);
|
const parsed = JSON.parse(trimmedContent);
|
||||||
const formatted = JSON.stringify(parsed, null, 2);
|
const formatted = JSON.stringify(parsed, null, 2);
|
||||||
@@ -439,7 +436,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isGrouped && (
|
{!isGrouped && (
|
||||||
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
|
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Check, ChevronDown } from 'lucide-react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import NextTaskBanner from '../../../NextTaskBanner.jsx';
|
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';
|
import type { ProjectSession, SessionProvider } from '../../../../types/app';
|
||||||
|
|
||||||
interface ProviderSelectionEmptyStateProps {
|
interface ProviderSelectionEmptyStateProps {
|
||||||
@@ -18,6 +18,8 @@ interface ProviderSelectionEmptyStateProps {
|
|||||||
setCursorModel: (model: string) => void;
|
setCursorModel: (model: string) => void;
|
||||||
codexModel: string;
|
codexModel: string;
|
||||||
setCodexModel: (model: string) => void;
|
setCodexModel: (model: string) => void;
|
||||||
|
geminiModel: string;
|
||||||
|
setGeminiModel: (model: string) => void;
|
||||||
tasksEnabled: boolean;
|
tasksEnabled: boolean;
|
||||||
isTaskMasterInstalled: boolean | null;
|
isTaskMasterInstalled: boolean | null;
|
||||||
onShowAllTasks?: (() => void) | null;
|
onShowAllTasks?: (() => void) | null;
|
||||||
@@ -58,17 +60,27 @@ const PROVIDERS: ProviderDef[] = [
|
|||||||
ring: 'ring-emerald-600/15',
|
ring: 'ring-emerald-600/15',
|
||||||
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
|
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) {
|
function getModelConfig(p: SessionProvider) {
|
||||||
if (p === 'claude') return CLAUDE_MODELS;
|
if (p === 'claude') return CLAUDE_MODELS;
|
||||||
if (p === 'codex') return CODEX_MODELS;
|
if (p === 'codex') return CODEX_MODELS;
|
||||||
|
if (p === 'gemini') return GEMINI_MODELS;
|
||||||
return CURSOR_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 === 'claude') return c;
|
||||||
if (p === 'codex') return co;
|
if (p === 'codex') return co;
|
||||||
|
if (p === 'gemini') return g;
|
||||||
return cu;
|
return cu;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +96,8 @@ export default function ProviderSelectionEmptyState({
|
|||||||
setCursorModel,
|
setCursorModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
setCodexModel,
|
setCodexModel,
|
||||||
|
geminiModel,
|
||||||
|
setGeminiModel,
|
||||||
tasksEnabled,
|
tasksEnabled,
|
||||||
isTaskMasterInstalled,
|
isTaskMasterInstalled,
|
||||||
onShowAllTasks,
|
onShowAllTasks,
|
||||||
@@ -101,11 +115,12 @@ export default function ProviderSelectionEmptyState({
|
|||||||
const handleModelChange = (value: string) => {
|
const handleModelChange = (value: string) => {
|
||||||
if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
|
if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
|
||||||
else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-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); }
|
else { setCursorModel(value); localStorage.setItem('cursor-model', value); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const modelConfig = getModelConfig(provider);
|
const modelConfig = getModelConfig(provider);
|
||||||
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel);
|
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel);
|
||||||
|
|
||||||
/* ── New session — provider picker ── */
|
/* ── New session — provider picker ── */
|
||||||
if (!selectedSession && !currentSessionId) {
|
if (!selectedSession && !currentSessionId) {
|
||||||
@@ -123,7 +138,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider cards — horizontal row, equal width */}
|
{/* 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) => {
|
{PROVIDERS.map((p) => {
|
||||||
const active = provider === p.id;
|
const active = provider === p.id;
|
||||||
return (
|
return (
|
||||||
@@ -179,13 +194,14 @@ export default function ProviderSelectionEmptyState({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground/70">
|
<p className="text-center text-sm text-muted-foreground/70">
|
||||||
{provider === 'claude'
|
{
|
||||||
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
|
{
|
||||||
: provider === 'cursor'
|
claude: t('providerSelection.readyPrompt.claude', { model: claudeModel }),
|
||||||
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
|
cursor: t('providerSelection.readyPrompt.cursor', { model: cursorModel }),
|
||||||
: provider === 'codex'
|
codex: t('providerSelection.readyPrompt.codex', { model: codexModel }),
|
||||||
? t('providerSelection.readyPrompt.codex', { model: codexModel })
|
gemini: t('providerSelection.readyPrompt.gemini', { model: geminiModel }),
|
||||||
: t('providerSelection.readyPrompt.default')}
|
}[provider]
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { SessionProvider } from '../../types/app';
|
|||||||
import ClaudeLogo from './ClaudeLogo';
|
import ClaudeLogo from './ClaudeLogo';
|
||||||
import CodexLogo from './CodexLogo';
|
import CodexLogo from './CodexLogo';
|
||||||
import CursorLogo from './CursorLogo';
|
import CursorLogo from './CursorLogo';
|
||||||
|
import GeminiLogo from '../GeminiLogo';
|
||||||
|
|
||||||
type SessionProviderLogoProps = {
|
type SessionProviderLogoProps = {
|
||||||
provider?: SessionProvider | string | null;
|
provider?: SessionProvider | string | null;
|
||||||
@@ -20,5 +21,9 @@ export default function SessionProviderLogo({
|
|||||||
return <CodexLogo className={className} />;
|
return <CodexLogo className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'gemini') {
|
||||||
|
return <GeminiLogo className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <ClaudeLogo className={className} />;
|
return <ClaudeLogo className={className} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,4 +91,5 @@ export const AUTH_STATUS_ENDPOINTS: Record<AgentProvider, string> = {
|
|||||||
claude: '/api/cli/claude/status',
|
claude: '/api/cli/claude/status',
|
||||||
cursor: '/api/cli/cursor/status',
|
cursor: '/api/cli/cursor/status',
|
||||||
codex: '/api/cli/codex/status',
|
codex: '/api/cli/codex/status',
|
||||||
|
gemini: '/api/cli/gemini/status',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
CodexMcpFormState,
|
CodexMcpFormState,
|
||||||
CodexPermissionMode,
|
CodexPermissionMode,
|
||||||
CursorPermissionsState,
|
CursorPermissionsState,
|
||||||
|
GeminiPermissionMode,
|
||||||
McpServer,
|
McpServer,
|
||||||
McpToolsResult,
|
McpToolsResult,
|
||||||
McpTestResult,
|
McpTestResult,
|
||||||
@@ -204,6 +205,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
createEmptyCursorPermissions()
|
createEmptyCursorPermissions()
|
||||||
));
|
));
|
||||||
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||||
|
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
|
||||||
|
|
||||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||||
const [cursorMcpServers, setCursorMcpServers] = 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 [claudeAuthStatus, setClaudeAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
|
||||||
const [cursorAuthStatus, setCursorAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
|
const [cursorAuthStatus, setCursorAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
|
||||||
const [codexAuthStatus, setCodexAuthStatus] = 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) => {
|
const setAuthStatusByProvider = useCallback((provider: AgentProvider, status: AuthStatus) => {
|
||||||
if (provider === 'claude') {
|
if (provider === 'claude') {
|
||||||
@@ -236,6 +239,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'gemini') {
|
||||||
|
setGeminiAuthStatus(status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setCodexAuthStatus(status);
|
setCodexAuthStatus(status);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -655,6 +663,12 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
);
|
);
|
||||||
setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode));
|
setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode));
|
||||||
|
|
||||||
|
const savedGeminiSettings = parseJson<{ permissionMode?: GeminiPermissionMode }>(
|
||||||
|
localStorage.getItem('gemini-settings'),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default');
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchMcpServers(),
|
fetchMcpServers(),
|
||||||
fetchCursorMcpServers(),
|
fetchCursorMcpServers(),
|
||||||
@@ -710,6 +724,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
lastUpdated: now,
|
lastUpdated: now,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
localStorage.setItem('gemini-settings', JSON.stringify({
|
||||||
|
permissionMode: geminiPermissionMode,
|
||||||
|
lastUpdated: now,
|
||||||
|
}));
|
||||||
|
|
||||||
setSaveStatus('success');
|
setSaveStatus('success');
|
||||||
if (closeTimerRef.current !== null) {
|
if (closeTimerRef.current !== null) {
|
||||||
window.clearTimeout(closeTimerRef.current);
|
window.clearTimeout(closeTimerRef.current);
|
||||||
@@ -771,6 +790,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
void checkAuthStatus('claude');
|
void checkAuthStatus('claude');
|
||||||
void checkAuthStatus('cursor');
|
void checkAuthStatus('cursor');
|
||||||
void checkAuthStatus('codex');
|
void checkAuthStatus('codex');
|
||||||
|
void checkAuthStatus('gemini');
|
||||||
}, [checkAuthStatus, initialTab, isOpen, loadSettings]);
|
}, [checkAuthStatus, initialTab, isOpen, loadSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -830,6 +850,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
claudeAuthStatus,
|
claudeAuthStatus,
|
||||||
cursorAuthStatus,
|
cursorAuthStatus,
|
||||||
codexAuthStatus,
|
codexAuthStatus,
|
||||||
|
geminiAuthStatus,
|
||||||
|
geminiPermissionMode,
|
||||||
|
setGeminiPermissionMode,
|
||||||
openLoginForProvider,
|
openLoginForProvider,
|
||||||
showLoginModal,
|
showLoginModal,
|
||||||
setShowLoginModal,
|
setShowLoginModal,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
|
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 AgentCategory = 'account' | 'permissions' | 'mcp';
|
||||||
export type ProjectSortOrder = 'name' | 'date';
|
export type ProjectSortOrder = 'name' | 'date';
|
||||||
export type SaveStatus = 'success' | 'error' | null;
|
export type SaveStatus = 'success' | 'error' | null;
|
||||||
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
||||||
|
export type GeminiPermissionMode = 'default' | 'auto_edit' | 'yolo';
|
||||||
export type McpImportMode = 'form' | 'json';
|
export type McpImportMode = 'form' | 'json';
|
||||||
export type McpScope = 'user' | 'local';
|
export type McpScope = 'user' | 'local';
|
||||||
export type McpTransportType = 'stdio' | 'sse' | 'http';
|
export type McpTransportType = 'stdio' | 'sse' | 'http';
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
claudeAuthStatus,
|
claudeAuthStatus,
|
||||||
cursorAuthStatus,
|
cursorAuthStatus,
|
||||||
codexAuthStatus,
|
codexAuthStatus,
|
||||||
|
geminiAuthStatus,
|
||||||
|
geminiPermissionMode,
|
||||||
|
setGeminiPermissionMode,
|
||||||
openLoginForProvider,
|
openLoginForProvider,
|
||||||
showLoginModal,
|
showLoginModal,
|
||||||
setShowLoginModal,
|
setShowLoginModal,
|
||||||
@@ -86,10 +89,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
const isAuthenticated = loginProvider === 'claude'
|
const isAuthenticated = loginProvider === 'claude'
|
||||||
? claudeAuthStatus.authenticated
|
? claudeAuthStatus.authenticated
|
||||||
: loginProvider === 'cursor'
|
: loginProvider === 'cursor'
|
||||||
? cursorAuthStatus.authenticated
|
? cursorAuthStatus.authenticated
|
||||||
: loginProvider === 'codex'
|
: loginProvider === 'codex'
|
||||||
? codexAuthStatus.authenticated
|
? codexAuthStatus.authenticated
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[9999] md:p-4 bg-background/95">
|
<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}
|
claudeAuthStatus={claudeAuthStatus}
|
||||||
cursorAuthStatus={cursorAuthStatus}
|
cursorAuthStatus={cursorAuthStatus}
|
||||||
codexAuthStatus={codexAuthStatus}
|
codexAuthStatus={codexAuthStatus}
|
||||||
|
geminiAuthStatus={geminiAuthStatus}
|
||||||
onClaudeLogin={() => openLoginForProvider('claude')}
|
onClaudeLogin={() => openLoginForProvider('claude')}
|
||||||
onCursorLogin={() => openLoginForProvider('cursor')}
|
onCursorLogin={() => openLoginForProvider('cursor')}
|
||||||
onCodexLogin={() => openLoginForProvider('codex')}
|
onCodexLogin={() => openLoginForProvider('codex')}
|
||||||
|
onGeminiLogin={() => openLoginForProvider('gemini')}
|
||||||
claudePermissions={claudePermissions}
|
claudePermissions={claudePermissions}
|
||||||
onClaudePermissionsChange={setClaudePermissions}
|
onClaudePermissionsChange={setClaudePermissions}
|
||||||
cursorPermissions={cursorPermissions}
|
cursorPermissions={cursorPermissions}
|
||||||
onCursorPermissionsChange={setCursorPermissions}
|
onCursorPermissionsChange={setCursorPermissions}
|
||||||
codexPermissionMode={codexPermissionMode}
|
codexPermissionMode={codexPermissionMode}
|
||||||
onCodexPermissionModeChange={setCodexPermissionMode}
|
onCodexPermissionModeChange={setCodexPermissionMode}
|
||||||
|
geminiPermissionMode={geminiPermissionMode}
|
||||||
|
onGeminiPermissionModeChange={setGeminiPermissionMode}
|
||||||
mcpServers={mcpServers}
|
mcpServers={mcpServers}
|
||||||
cursorMcpServers={cursorMcpServers}
|
cursorMcpServers={cursorMcpServers}
|
||||||
codexMcpServers={codexMcpServers}
|
codexMcpServers={codexMcpServers}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type AgentListItemProps = {
|
|||||||
|
|
||||||
type AgentConfig = {
|
type AgentConfig = {
|
||||||
name: string;
|
name: string;
|
||||||
color: 'blue' | 'purple' | 'gray';
|
color: 'blue' | 'purple' | 'gray' | 'indigo';
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentConfig: Record<AgentProvider, AgentConfig> = {
|
const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||||
@@ -28,6 +28,10 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
|
|||||||
name: 'Codex',
|
name: 'Codex',
|
||||||
color: 'gray',
|
color: 'gray',
|
||||||
},
|
},
|
||||||
|
gemini: {
|
||||||
|
name: 'Gemini',
|
||||||
|
color: 'indigo',
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorClasses = {
|
const colorClasses = {
|
||||||
@@ -49,6 +53,12 @@ const colorClasses = {
|
|||||||
bg: 'bg-gray-100 dark:bg-gray-800/50',
|
bg: 'bg-gray-100 dark:bg-gray-800/50',
|
||||||
dot: 'bg-gray-700 dark:bg-gray-300',
|
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;
|
} as const;
|
||||||
|
|
||||||
export default function AgentListItem({
|
export default function AgentListItem({
|
||||||
@@ -66,11 +76,10 @@ export default function AgentListItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`flex-1 text-center py-3 px-2 border-b-2 transition-colors ${
|
className={`flex-1 text-center py-3 px-2 border-b-2 transition-colors ${isSelected
|
||||||
isSelected
|
? `${colors.borderBottom} ${colors.bg}`
|
||||||
? `${colors.borderBottom} ${colors.bg}`
|
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<SessionProviderLogo provider={agentId} className="w-5 h-5" />
|
<SessionProviderLogo provider={agentId} className="w-5 h-5" />
|
||||||
@@ -86,11 +95,10 @@ export default function AgentListItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`w-full text-left p-3 border-l-4 transition-colors ${
|
className={`w-full text-left p-3 border-l-4 transition-colors ${isSelected
|
||||||
isSelected
|
? `${colors.border} ${colors.bg}`
|
||||||
? `${colors.border} ${colors.bg}`
|
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<SessionProviderLogo provider={agentId} className="w-4 h-4" />
|
<SessionProviderLogo provider={agentId} className="w-4 h-4" />
|
||||||
|
|||||||
@@ -9,15 +9,19 @@ export default function AgentsSettingsTab({
|
|||||||
claudeAuthStatus,
|
claudeAuthStatus,
|
||||||
cursorAuthStatus,
|
cursorAuthStatus,
|
||||||
codexAuthStatus,
|
codexAuthStatus,
|
||||||
|
geminiAuthStatus,
|
||||||
onClaudeLogin,
|
onClaudeLogin,
|
||||||
onCursorLogin,
|
onCursorLogin,
|
||||||
onCodexLogin,
|
onCodexLogin,
|
||||||
|
onGeminiLogin,
|
||||||
claudePermissions,
|
claudePermissions,
|
||||||
onClaudePermissionsChange,
|
onClaudePermissionsChange,
|
||||||
cursorPermissions,
|
cursorPermissions,
|
||||||
onCursorPermissionsChange,
|
onCursorPermissionsChange,
|
||||||
codexPermissionMode,
|
codexPermissionMode,
|
||||||
onCodexPermissionModeChange,
|
onCodexPermissionModeChange,
|
||||||
|
geminiPermissionMode,
|
||||||
|
onGeminiPermissionModeChange,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
cursorMcpServers,
|
cursorMcpServers,
|
||||||
codexMcpServers,
|
codexMcpServers,
|
||||||
@@ -48,13 +52,19 @@ export default function AgentsSettingsTab({
|
|||||||
authStatus: codexAuthStatus,
|
authStatus: codexAuthStatus,
|
||||||
onLogin: onCodexLogin,
|
onLogin: onCodexLogin,
|
||||||
},
|
},
|
||||||
|
gemini: {
|
||||||
|
authStatus: geminiAuthStatus,
|
||||||
|
onLogin: onGeminiLogin,
|
||||||
|
},
|
||||||
}), [
|
}), [
|
||||||
claudeAuthStatus,
|
claudeAuthStatus,
|
||||||
codexAuthStatus,
|
codexAuthStatus,
|
||||||
cursorAuthStatus,
|
cursorAuthStatus,
|
||||||
|
geminiAuthStatus,
|
||||||
onClaudeLogin,
|
onClaudeLogin,
|
||||||
onCodexLogin,
|
onCodexLogin,
|
||||||
onCursorLogin,
|
onCursorLogin,
|
||||||
|
onGeminiLogin,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,6 +91,8 @@ export default function AgentsSettingsTab({
|
|||||||
onCursorPermissionsChange={onCursorPermissionsChange}
|
onCursorPermissionsChange={onCursorPermissionsChange}
|
||||||
codexPermissionMode={codexPermissionMode}
|
codexPermissionMode={codexPermissionMode}
|
||||||
onCodexPermissionModeChange={onCodexPermissionModeChange}
|
onCodexPermissionModeChange={onCodexPermissionModeChange}
|
||||||
|
geminiPermissionMode={geminiPermissionMode}
|
||||||
|
onGeminiPermissionModeChange={onGeminiPermissionModeChange}
|
||||||
mcpServers={mcpServers}
|
mcpServers={mcpServers}
|
||||||
cursorMcpServers={cursorMcpServers}
|
cursorMcpServers={cursorMcpServers}
|
||||||
codexMcpServers={codexMcpServers}
|
codexMcpServers={codexMcpServers}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type AgentVisualConfig = {
|
|||||||
textClass: string;
|
textClass: string;
|
||||||
subtextClass: string;
|
subtextClass: string;
|
||||||
buttonClass: string;
|
buttonClass: string;
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
||||||
@@ -45,6 +46,15 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
|||||||
subtextClass: 'text-gray-700 dark:text-gray-300',
|
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',
|
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) {
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '../../../../../../ui/button';
|
import { Button } from '../../../../../../ui/button';
|
||||||
import { Input } from '../../../../../../ui/input';
|
import { Input } from '../../../../../../ui/input';
|
||||||
import type { CodexPermissionMode } from '../../../../../types/types';
|
import type { CodexPermissionMode, GeminiPermissionMode } from '../../../../../types/types';
|
||||||
|
|
||||||
const COMMON_CLAUDE_TOOLS = [
|
const COMMON_CLAUDE_TOOLS = [
|
||||||
'Bash(git log:*)',
|
'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>
|
<p className="text-sm text-muted-foreground">{t('permissions.codex.description')}</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'default'
|
||||||
permissionMode === 'default'
|
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
|
||||||
? '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'
|
||||||
: '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')}
|
onClick={() => onPermissionModeChange('default')}
|
||||||
>
|
>
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
@@ -514,11 +513,10 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'acceptEdits'
|
||||||
permissionMode === 'acceptEdits'
|
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
|
||||||
? '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'
|
||||||
: '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')}
|
onClick={() => onPermissionModeChange('acceptEdits')}
|
||||||
>
|
>
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
@@ -539,11 +537,10 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'bypassPermissions'
|
||||||
permissionMode === 'bypassPermissions'
|
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
|
||||||
? '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'
|
||||||
: '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')}
|
onClick={() => onPermissionModeChange('bypassPermissions')}
|
||||||
>
|
>
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<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) {
|
export default function PermissionsContent(props: PermissionsContentProps) {
|
||||||
if (props.agent === 'claude') {
|
if (props.agent === 'claude') {
|
||||||
@@ -593,5 +694,9 @@ export default function PermissionsContent(props: PermissionsContentProps) {
|
|||||||
return <CursorPermissions {...props} />;
|
return <CursorPermissions {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.agent === 'gemini') {
|
||||||
|
return <GeminiPermissions {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <CodexPermissions {...props} />;
|
return <CodexPermissions {...props} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import type {
|
|||||||
AuthStatus,
|
AuthStatus,
|
||||||
AgentCategory,
|
AgentCategory,
|
||||||
ClaudePermissionsState,
|
ClaudePermissionsState,
|
||||||
CodexPermissionMode,
|
|
||||||
CursorPermissionsState,
|
CursorPermissionsState,
|
||||||
|
CodexPermissionMode,
|
||||||
|
GeminiPermissionMode,
|
||||||
McpServer,
|
McpServer,
|
||||||
McpToolsResult,
|
McpToolsResult,
|
||||||
McpTestResult,
|
McpTestResult,
|
||||||
@@ -21,15 +22,19 @@ export type AgentsSettingsTabProps = {
|
|||||||
claudeAuthStatus: AuthStatus;
|
claudeAuthStatus: AuthStatus;
|
||||||
cursorAuthStatus: AuthStatus;
|
cursorAuthStatus: AuthStatus;
|
||||||
codexAuthStatus: AuthStatus;
|
codexAuthStatus: AuthStatus;
|
||||||
|
geminiAuthStatus: AuthStatus;
|
||||||
onClaudeLogin: () => void;
|
onClaudeLogin: () => void;
|
||||||
onCursorLogin: () => void;
|
onCursorLogin: () => void;
|
||||||
onCodexLogin: () => void;
|
onCodexLogin: () => void;
|
||||||
|
onGeminiLogin: () => void;
|
||||||
claudePermissions: ClaudePermissionsState;
|
claudePermissions: ClaudePermissionsState;
|
||||||
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
|
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
|
||||||
cursorPermissions: CursorPermissionsState;
|
cursorPermissions: CursorPermissionsState;
|
||||||
onCursorPermissionsChange: (value: CursorPermissionsState) => void;
|
onCursorPermissionsChange: (value: CursorPermissionsState) => void;
|
||||||
codexPermissionMode: CodexPermissionMode;
|
codexPermissionMode: CodexPermissionMode;
|
||||||
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||||
|
geminiPermissionMode: GeminiPermissionMode;
|
||||||
|
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||||
mcpServers: McpServer[];
|
mcpServers: McpServer[];
|
||||||
cursorMcpServers: McpServer[];
|
cursorMcpServers: McpServer[];
|
||||||
codexMcpServers: McpServer[];
|
codexMcpServers: McpServer[];
|
||||||
@@ -66,6 +71,8 @@ export type AgentCategoryContentSectionProps = {
|
|||||||
onCursorPermissionsChange: (value: CursorPermissionsState) => void;
|
onCursorPermissionsChange: (value: CursorPermissionsState) => void;
|
||||||
codexPermissionMode: CodexPermissionMode;
|
codexPermissionMode: CodexPermissionMode;
|
||||||
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||||
|
geminiPermissionMode: GeminiPermissionMode;
|
||||||
|
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||||
mcpServers: McpServer[];
|
mcpServers: McpServer[];
|
||||||
cursorMcpServers: McpServer[];
|
cursorMcpServers: McpServer[];
|
||||||
codexMcpServers: McpServer[];
|
codexMcpServers: McpServer[];
|
||||||
|
|||||||
@@ -277,10 +277,14 @@ export function useSidebarController({
|
|||||||
setSessionDeleteConfirmation(null);
|
setSessionDeleteConfirmation(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response =
|
let response;
|
||||||
provider === 'codex'
|
if (provider === 'codex') {
|
||||||
? await api.deleteCodexSession(sessionId)
|
response = await api.deleteCodexSession(sessionId);
|
||||||
: await api.deleteSession(projectName, sessionId);
|
} else if (provider === 'gemini') {
|
||||||
|
response = await api.deleteGeminiSession(sessionId);
|
||||||
|
} else {
|
||||||
|
response = await api.deleteSession(projectName, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
onSessionDelete?.(sessionId);
|
onSessionDelete?.(sessionId);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type SidebarProps = {
|
|||||||
export type SessionViewModel = {
|
export type SessionViewModel = {
|
||||||
isCursorSession: boolean;
|
isCursorSession: boolean;
|
||||||
isCodexSession: boolean;
|
isCodexSession: boolean;
|
||||||
|
isGeminiSession: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
sessionName: string;
|
sessionName: string;
|
||||||
sessionTime: 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.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 => {
|
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');
|
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');
|
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.createdAt || session.lastActivity || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(session.lastActivity || '');
|
return String(session.lastActivity || session.createdAt || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createSessionViewModel = (
|
export const createSessionViewModel = (
|
||||||
@@ -86,6 +90,7 @@ export const createSessionViewModel = (
|
|||||||
return {
|
return {
|
||||||
isCursorSession: session.__provider === 'cursor',
|
isCursorSession: session.__provider === 'cursor',
|
||||||
isCodexSession: session.__provider === 'codex',
|
isCodexSession: session.__provider === 'codex',
|
||||||
|
isGeminiSession: session.__provider === 'gemini',
|
||||||
isActive: diffInMinutes < 10,
|
isActive: diffInMinutes < 10,
|
||||||
sessionName: getSessionName(session, t),
|
sessionName: getSessionName(session, t),
|
||||||
sessionTime: getSessionTime(session),
|
sessionTime: getSessionTime(session),
|
||||||
@@ -112,7 +117,12 @@ export const getAllSessions = (
|
|||||||
__provider: 'codex' as const,
|
__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(),
|
(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
|
typeof project.fullPath === 'string' && project.fullPath.length > 0
|
||||||
? project.fullPath
|
? project.fullPath
|
||||||
: typeof project.path === 'string'
|
: typeof project.path === 'string'
|
||||||
? project.path
|
? project.path
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: project.name,
|
name: project.name,
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ const projectsHaveChanges = (
|
|||||||
nextProject.displayName !== prevProject.displayName ||
|
nextProject.displayName !== prevProject.displayName ||
|
||||||
nextProject.fullPath !== prevProject.fullPath ||
|
nextProject.fullPath !== prevProject.fullPath ||
|
||||||
serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
|
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) {
|
if (baseChanged) {
|
||||||
return true;
|
return true;
|
||||||
@@ -52,7 +53,8 @@ const projectsHaveChanges = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
|
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.sessions ?? []),
|
||||||
...(project.codexSessions ?? []),
|
...(project.codexSessions ?? []),
|
||||||
...(project.cursorSessions ?? []),
|
...(project.cursorSessions ?? []),
|
||||||
|
...(project.geminiSessions ?? []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -333,6 +336,21 @@ export function useProjectsState({
|
|||||||
}
|
}
|
||||||
return;
|
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]);
|
}, [sessionId, projects, selectedProject?.name, selectedSession?.id, selectedSession?.__provider]);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"tool": "Tool",
|
"tool": "Tool",
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"cursor": "Cursor",
|
"cursor": "Cursor",
|
||||||
"codex": "Codex"
|
"codex": "Codex",
|
||||||
|
"gemini": "Gemini"
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"settings": "Tool Settings",
|
"settings": "Tool Settings",
|
||||||
@@ -93,6 +94,24 @@
|
|||||||
},
|
},
|
||||||
"technicalDetails": "Technical details"
|
"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": {
|
"input": {
|
||||||
"placeholder": "Type / for commands, @ for files, or ask {{provider}} anything...",
|
"placeholder": "Type / for commands, @ for files, or ask {{provider}} anything...",
|
||||||
"placeholderDefault": "Type your message...",
|
"placeholderDefault": "Type your message...",
|
||||||
@@ -153,12 +172,14 @@
|
|||||||
"providerInfo": {
|
"providerInfo": {
|
||||||
"anthropic": "by Anthropic",
|
"anthropic": "by Anthropic",
|
||||||
"openai": "by OpenAI",
|
"openai": "by OpenAI",
|
||||||
"cursorEditor": "AI Code Editor"
|
"cursorEditor": "AI Code Editor",
|
||||||
|
"google": "by Google"
|
||||||
},
|
},
|
||||||
"readyPrompt": {
|
"readyPrompt": {
|
||||||
"claude": "Ready to use Claude with {{model}}. Start typing your message below.",
|
"claude": "Ready to use Claude with {{model}}. Start typing your message below.",
|
||||||
"cursor": "Ready to use Cursor 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.",
|
"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"
|
"default": "Select a provider above to begin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -214,4 +235,4 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"nextTaskPrompt": "Start the next task"
|
"nextTaskPrompt": "Start the next task"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"tool": "도구",
|
"tool": "도구",
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"cursor": "Cursor",
|
"cursor": "Cursor",
|
||||||
"codex": "Codex"
|
"codex": "Codex",
|
||||||
|
"gemini": "Gemini"
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"settings": "도구 설정",
|
"settings": "도구 설정",
|
||||||
@@ -151,15 +152,17 @@
|
|||||||
"description": "새 대화를 시작할 프로바이더를 선택하세요",
|
"description": "새 대화를 시작할 프로바이더를 선택하세요",
|
||||||
"selectModel": "모델 선택",
|
"selectModel": "모델 선택",
|
||||||
"providerInfo": {
|
"providerInfo": {
|
||||||
"anthropic": "by Anthropic",
|
"anthropic": "Anthropic 제공",
|
||||||
"openai": "by OpenAI",
|
"openai": "OpenAI 제공",
|
||||||
"cursorEditor": "AI 코드 에디터"
|
"cursorEditor": "AI 코드 에디터",
|
||||||
|
"google": "Google 제공"
|
||||||
},
|
},
|
||||||
"readyPrompt": {
|
"readyPrompt": {
|
||||||
"claude": "{{model}}로 Claude를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
"claude": "{{model}} 모델로 Claude를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||||
"cursor": "{{model}}로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
"cursor": "{{model}} 모델로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||||
"codex": "{{model}}로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
"codex": "{{model}} 모델로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||||
"default": "시작하려면 위에서 프로바이더를 선택하세요"
|
"gemini": "{{model}} 모델로 Gemini를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||||
|
"default": "시작하려면 위에서 제공자를 선택하세요"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
@@ -214,4 +217,4 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"nextTaskPrompt": "다음 작업 시작"
|
"nextTaskPrompt": "다음 작업 시작"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"tool": "工具",
|
"tool": "工具",
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"cursor": "Cursor",
|
"cursor": "Cursor",
|
||||||
"codex": "Codex"
|
"codex": "Codex",
|
||||||
|
"gemini": "Gemini"
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"settings": "工具设置",
|
"settings": "工具设置",
|
||||||
@@ -151,15 +152,17 @@
|
|||||||
"description": "选择一个供应商以开始新对话",
|
"description": "选择一个供应商以开始新对话",
|
||||||
"selectModel": "选择模型",
|
"selectModel": "选择模型",
|
||||||
"providerInfo": {
|
"providerInfo": {
|
||||||
"anthropic": "Anthropic",
|
"anthropic": "由 Anthropic 提供",
|
||||||
"openai": "OpenAI",
|
"openai": "由 OpenAI 提供",
|
||||||
"cursorEditor": "AI 代码编辑器"
|
"cursorEditor": "AI 代码编辑器",
|
||||||
|
"google": "由 Google 提供"
|
||||||
},
|
},
|
||||||
"readyPrompt": {
|
"readyPrompt": {
|
||||||
"claude": "已准备好使用 Claude {{model}}。在下方输入您的消息。",
|
"claude": "准备好使用带有 {{model}} 的 Claude。请在下方开始输入您的消息。",
|
||||||
"cursor": "已准备好使用 Cursor {{model}}。在下方输入您的消息。",
|
"cursor": "准备好使用带有 {{model}} 的 Cursor。请在下方开始输入您的消息。",
|
||||||
"codex": "已准备好使用 Codex {{model}}。在下方输入您的消息。",
|
"codex": "准备好使用带有 {{model}} 的 Codex。请在下方开始输入您的消息。",
|
||||||
"default": "请在上方选择一个供应商以开始"
|
"gemini": "准备好使用带有 {{model}} 的 Gemini。请在下方开始输入您的消息。",
|
||||||
|
"default": "请在上方选择一个提供者以开始"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
@@ -214,4 +217,4 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"nextTaskPrompt": "开始下一个任务"
|
"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';
|
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ export interface Project {
|
|||||||
sessions?: ProjectSession[];
|
sessions?: ProjectSession[];
|
||||||
cursorSessions?: ProjectSession[];
|
cursorSessions?: ProjectSession[];
|
||||||
codexSessions?: ProjectSession[];
|
codexSessions?: ProjectSession[];
|
||||||
|
geminiSessions?: ProjectSession[];
|
||||||
sessionMeta?: ProjectSessionMeta;
|
sessionMeta?: ProjectSessionMeta;
|
||||||
taskmaster?: ProjectTaskmasterInfo;
|
taskmaster?: ProjectTaskmasterInfo;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -66,4 +67,4 @@ export interface LoadingProgressMessage extends LoadingProgress {
|
|||||||
export type AppSocketMessage =
|
export type AppSocketMessage =
|
||||||
| LoadingProgressMessage
|
| LoadingProgressMessage
|
||||||
| ProjectsUpdatedMessage
|
| ProjectsUpdatedMessage
|
||||||
| { type?: string; [key: string]: unknown };
|
| { type?: string;[key: string]: unknown };
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const api = {
|
|||||||
// Protected endpoints
|
// Protected endpoints
|
||||||
// config endpoint removed - no longer needed (frontend uses window.location)
|
// config endpoint removed - no longer needed (frontend uses window.location)
|
||||||
projects: () => authenticatedFetch('/api/projects'),
|
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}`),
|
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
|
||||||
sessionMessages: (projectName, sessionId, limit = null, offset = 0, provider = 'claude') => {
|
sessionMessages: (projectName, sessionId, limit = null, offset = 0, provider = 'claude') => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -56,12 +56,13 @@ export const api = {
|
|||||||
}
|
}
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
|
|
||||||
// Route to the correct endpoint based on provider
|
|
||||||
let url;
|
let url;
|
||||||
if (provider === 'codex') {
|
if (provider === 'codex') {
|
||||||
url = `/api/codex/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
url = `/api/codex/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||||
} else if (provider === 'cursor') {
|
} else if (provider === 'cursor') {
|
||||||
url = `/api/cursor/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
url = `/api/cursor/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||||
|
} else if (provider === 'gemini') {
|
||||||
|
url = `/api/gemini/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||||
} else {
|
} else {
|
||||||
url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,10 @@ export const api = {
|
|||||||
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
|
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
|
deleteGeminiSession: (sessionId) =>
|
||||||
|
authenticatedFetch(`/api/gemini/sessions/${sessionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
deleteProject: (projectName, force = false) =>
|
deleteProject: (projectName, force = false) =>
|
||||||
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
|
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -113,18 +118,18 @@ export const api = {
|
|||||||
// TaskMaster endpoints
|
// TaskMaster endpoints
|
||||||
taskmaster: {
|
taskmaster: {
|
||||||
// Initialize TaskMaster in a project
|
// Initialize TaskMaster in a project
|
||||||
init: (projectName) =>
|
init: (projectName) =>
|
||||||
authenticatedFetch(`/api/taskmaster/init/${projectName}`, {
|
authenticatedFetch(`/api/taskmaster/init/${projectName}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Add a new task
|
// Add a new task
|
||||||
addTask: (projectName, { prompt, title, description, priority, dependencies }) =>
|
addTask: (projectName, { prompt, title, description, priority, dependencies }) =>
|
||||||
authenticatedFetch(`/api/taskmaster/add-task/${projectName}`, {
|
authenticatedFetch(`/api/taskmaster/add-task/${projectName}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ prompt, title, description, priority, dependencies }),
|
body: JSON.stringify({ prompt, title, description, priority, dependencies }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Parse PRD to generate tasks
|
// Parse PRD to generate tasks
|
||||||
parsePRD: (projectName, { fileName, numTasks, append }) =>
|
parsePRD: (projectName, { fileName, numTasks, append }) =>
|
||||||
authenticatedFetch(`/api/taskmaster/parse-prd/${projectName}`, {
|
authenticatedFetch(`/api/taskmaster/parse-prd/${projectName}`, {
|
||||||
@@ -150,7 +155,7 @@ export const api = {
|
|||||||
body: JSON.stringify(updates),
|
body: JSON.stringify(updates),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Browse filesystem for project suggestions
|
// Browse filesystem for project suggestions
|
||||||
browseFilesystem: (dirPath = null) => {
|
browseFilesystem: (dirPath = null) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
Reference in New Issue
Block a user