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:
Menny Even Danan
2026-02-27 09:36:35 -05:00
committed by GitHub
parent 917c353115
commit a367edd515
44 changed files with 2399 additions and 796 deletions

73
package-lock.json generated
View File

@@ -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"
}, },

View File

@@ -118,4 +118,4 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.0.4" "vite": "^7.0.4"
} }
} }

View 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
View 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
};

View 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;

View File

@@ -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

View File

@@ -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;
}; };

View File

@@ -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

View File

@@ -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
View 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
View 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;

View File

@@ -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'
};

View 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;

View 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;

View File

@@ -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>

View File

@@ -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>
))} ))}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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}

View File

@@ -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">

View File

@@ -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}

View File

@@ -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"
> >

View File

@@ -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}

View File

@@ -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>

View File

@@ -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} />;
} }

View File

@@ -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',
}; };

View File

@@ -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,

View File

@@ -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';

View File

@@ -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}

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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) {

View File

@@ -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} />;
} }

View File

@@ -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[];

View File

@@ -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);

View File

@@ -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;

View File

@@ -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,

View File

@@ -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]);

View File

@@ -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"
} }
} }

View File

@@ -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": "다음 작업 시작"
} }
} }

View File

@@ -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": "开始下一个任务"
} }
} }

View File

@@ -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 };

View File

@@ -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();