From 4cee5e7286a88779f96f819dbce17c4acae52a9f Mon Sep 17 00:00:00 2001 From: Haile <118998054+blackmammoth@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:32:32 +0300 Subject: [PATCH] Fix/resolve different bugs (#964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: don't overlap thinking banner over conversation * feat: support message queue and add attention indicator * fix(shell): use c to copy for claude * fix: strip timestamp tags from cursor * fix: select project when loading session from direct URL * feat: support attached images for all providers * feat(opencode): support permission options * fix: resolve source control and chat UX bugs - make staging real in the git panel: new /stage and /unstage endpoints, /status now reports the actual index via porcelain -z (handles renames, conflicts, and paths with spaces), and Stage/Unstage All run real git commands instead of toggling client-side state - add branch search to the header dropdown and Branches tab - persist the chosen permission mode per provider so a brand-new chat keeps it once the session id arrives, instead of reverting to default - replace cmdk's fuzzy model search with strict token matching so "chatgpt" no longer surfaces unrelated models - unit tests for the git status parser * feat(git): commit graph in history view and cross-spawn everywhere - render a VSCode-style commit graph in the source control history: lane-assignment algorithm, SVG strip with colored rails, merge and branch curves, commit dots, and branch/tag badges per commit - /commits returns parents and ref decorations across all branches (--branches --remotes --tags --topo-order) using unit-separator fields so pipes in commit subjects can't break parsing - collect stats via a single --shortstat pass instead of one `git show --stat` call per commit; raise the history limit to 50 - replace child_process spawn with cross-spawn in all runtimes, routes, and services: it resolves .cmd shims and PATHEXT on Windows (fixing taskmaster's npx invocations) and delegates to native spawn elsewhere, removing the per-file win32 ternaries - unit tests for the log parser and lane assignment * fix: remove gemini support since google discontinued it * fix: address code review findings - validate chat image attachments server-side: only files inside the ~/.cloudcli/assets upload store may reach provider file reads - harden asset serving with nosniff and attachment disposition for SVGs to prevent stored XSS - show the timestamp on image-only user messages - serialize git stage/unstage calls and defer the status re-sync so rapid toggles can't interleave or flicker - use a literal hex fallback for commit ref badges (alpha suffix on a var() string produced invalid CSS) - stop binding a URL session to a guening project instead - add the missing attentionRequiredIndicator key to all sidebar locales - clean up dangling conjunctions left the de/ru/tr/ja/zh-CN/zh-TW READMEs * fix: address code scanning findings - enforce a second image trust boundary in the provider builders: buildClaudeUserContent/buildCodexInputItems only accept files inside the upload store or the run's working directory (CodeQL path injection) - drop an always-true selectedProject check in handleSubmit - remove the unused resume option from spawnCursor * fix: use CodeQL-recognized containment check for image reads Replace the path.relative guard in isPathInsideDirectory with the resolve + startsWith(root + sep) idiom so the path-injection barrier is visible to code scanning; behavior is unchanged. * fix(security): canonicalize Claude image attachment reads Resolve image attachment paths with realpath before reading so symlinks inside allowed roots cannot escape to arbitrary files. Preserve support for symlinked project/upload roots and add focused coverage for both cases. * fix: show agent subtask * fix(sessions): title app-created sessions from the first user message App-created sessions (started by sending a message from cloudcli) were titled with placeholder names — "Untitled Codex Session" from the disk indexer and, briefly, "New session" from the empty canonical upsert — before a later sync finally settled on the right text, causing the sidebar title to flicker. - Codex/OpenCode synchronizers now title app-created sessions (distinct app id mapped to a provider id) from the first user message, while sessions found purely by indexing keep their existing setup. Claude keeps its AI-generated titles; Cursor already used the first message. - Decode OpenCode's JSON-string-literal prompts so titles no longer surface wrapped in quotes; hoist unwrapJsonStringLiteral into shared/utils since it's now used by both the reader and synchronizer. - Guard the sidebar upsert merge so an empty summary can never blank out a title that is already set. - Add codex/opencode synchronizer tests for the app-created vs indexed naming paths. * fix(chat): make message queuing reliable across sessions and turn boundaries Queued messages had four related defects: - A queued message flashed and then vanished at flush time: concurrent transcript refreshes (the `complete` handler racing the watcher-triggered update) could resolve out of order, letting a stale response overwrite newer server messages after the optimistic row was pruned. Session slots now carry a monotonic fetch ticket and discard stale fetch/refresh/ fetchMore responses. - Switching sessions flushed the previous session's queued draft into the newly viewed one (sending it with the wrong provider's settings, e.g. a Claude model into a Codex session). The composer flush is now scoped to its session, and a draft restored into an idle session sends after a short grace period so a live-run ack can cancel it. - The thinking banner never appeared for a queued turn: the chat handler's session-keyed completeRun safety net could fire after a queued message had already started the session's next run, emitting a spurious `complete` that killed it. The safety net is now scoped to its own run via completeRunIfCurrent (with a regression test). - Queued messages only sent while their session was being viewed. Drafts now persist their send options (model, effort, permissions) at queue time, and a new app-level useQueuedMessageAutoSend hook dispatches a non-viewed session's queued message as soon as its run completes, using the storage key as the claim ticket to prevent double sends. --- README.de.md | 33 +- README.ja.md | 31 +- README.ko.md | 33 +- README.md | 37 +- README.ru.md | 33 +- README.tr.md | 33 +- README.zh-CN.md | 33 +- README.zh-TW.md | 33 +- docker/README.md | 9 +- docker/gemini/Dockerfile | 11 - electron/launcher/launcher.js | 2 +- eslint.config.js | 2 +- package-lock.json | 19 + package.json | 4 +- public/api-docs.html | 5 +- public/icons/gemini-ai-icon.svg | 1 - redirect-package/README.md | 31 +- redirect-package/package.json | 2 - server/claude-sdk.js | 142 +--- server/cli.js | 5 +- server/cursor-cli.js | 25 +- server/gemini-cli.js | 638 ------------------ server/gemini-response-handler.js | 117 ---- server/index.js | 170 +---- server/modules/assets/assets.routes.ts | 103 +++ server/modules/assets/index.ts | 3 + .../assets/services/image-assets.service.ts | 82 +++ .../assets/tests/image-assets.service.test.ts | 46 ++ .../browser-use/browser-use.service.ts | 10 +- .../tests/sessions-provider-mapping.test.ts | 4 +- .../notification-orchestrator.service.js | 1 - .../services/project-clone.service.ts | 4 +- server/modules/providers/README.md | 8 +- .../list/claude/claude-sessions.provider.ts | 30 + .../codex-session-synchronizer.provider.ts | 61 +- .../list/codex/codex-sessions.provider.ts | 42 +- .../list/cursor/cursor-models.provider.ts | 20 +- .../cursor-session-synchronizer.provider.ts | 8 +- .../list/cursor/cursor-sessions.provider.ts | 71 +- .../list/gemini/gemini-auth.provider.ts | 307 --------- .../list/gemini/gemini-mcp.provider.ts | 110 --- .../list/gemini/gemini-models.provider.ts | 39 -- .../gemini-session-synchronizer.provider.ts | 405 ----------- .../list/gemini/gemini-sessions.provider.ts | 540 --------------- .../list/gemini/gemini-skills.provider.ts | 44 -- .../providers/list/gemini/gemini.provider.ts | 27 - .../list/opencode/opencode-auth.provider.ts | 1 - .../list/opencode/opencode-models.provider.ts | 34 +- .../opencode-session-synchronizer.provider.ts | 29 +- .../opencode/opencode-sessions.provider.ts | 31 +- server/modules/providers/provider.registry.ts | 2 - server/modules/providers/provider.routes.ts | 1 - .../services/provider-capabilities.service.ts | 21 +- .../services/provider-models.service.ts | 2 +- .../session-conversations-search.service.ts | 96 +-- .../services/session-synchronizer.service.ts | 1 - .../services/sessions-watcher.service.ts | 14 - .../providers/tests/codex-sessions.test.ts | 111 +++ server/modules/providers/tests/mcp.test.ts | 37 +- .../providers/tests/opencode-models.test.ts | 7 + .../providers/tests/opencode-sessions.test.ts | 139 ++++ .../tests/provider-image-history.test.ts | 180 +++++ .../tests/provider-models.service.test.ts | 16 +- server/modules/providers/tests/skills.test.ts | 45 +- .../services/chat-run-registry.service.ts | 16 + .../services/chat-websocket.service.ts | 43 +- .../services/shell-websocket.service.ts | 12 +- .../websocket/tests/chat-image-filter.test.ts | 44 ++ .../websocket/tests/chat-run-registry.test.ts | 46 +- server/openai-codex.js | 10 +- server/opencode-cli.js | 50 +- server/opencode-cli.test.js | 115 +++- server/routes/agent.js | 28 +- server/routes/commands.js | 3 +- server/routes/gemini.js | 25 - server/routes/git.js | 261 +++++-- server/routes/git.test.js | 106 +++ server/routes/taskmaster.js | 4 +- server/routes/user.js | 3 +- server/sessionManager.js | 226 ------- server/shared/image-attachments.ts | 341 ++++++++++ server/shared/tests/image-attachments.test.ts | 298 ++++++++ server/shared/types.ts | 2 +- server/shared/utils.ts | 45 ++ server/utils/gitConfig.js | 3 +- server/utils/plugin-process-manager.js | 4 +- src/components/app/AppContent.tsx | 12 + .../chat/hooks/useChatComposerState.ts | 284 ++++++-- src/components/chat/hooks/useChatMessages.ts | 65 +- .../chat/hooks/useChatProviderState.ts | 68 +- .../chat/hooks/useChatSessionState.ts | 3 + src/components/chat/types/types.ts | 8 +- src/components/chat/utils/chatStorage.ts | 48 +- src/components/chat/view/ChatInterface.tsx | 25 +- .../chat/view/subcomponents/ChatComposer.tsx | 69 +- .../view/subcomponents/ChatMessageImages.tsx | 180 +++++ .../view/subcomponents/ChatMessagesPane.tsx | 13 +- .../view/subcomponents/CommandResultModal.tsx | 1 - .../view/subcomponents/ImageAttachment.tsx | 10 +- .../view/subcomponents/MessageComponent.tsx | 49 +- .../ProviderSelectionEmptyState.tsx | 31 +- .../view/subcomponents/QueuedMessageCard.tsx | 57 ++ .../git-panel/constants/constants.ts | 3 +- .../git-panel/hooks/useGitPanelController.ts | 67 ++ src/components/git-panel/types/types.ts | 8 + .../git-panel/utils/commitGraph.test.ts | 83 +++ src/components/git-panel/utils/commitGraph.ts | 138 ++++ src/components/git-panel/view/GitPanel.tsx | 4 + .../git-panel/view/GitPanelHeader.tsx | 71 +- .../git-panel/view/branches/BranchesView.tsx | 52 +- .../git-panel/view/changes/ChangesView.tsx | 79 ++- .../view/history/CommitGraphStrip.tsx | 95 +++ .../view/history/CommitHistoryItem.tsx | 46 +- .../git-panel/view/history/HistoryView.tsx | 15 +- .../llm-logo-provider/GeminiLogo.tsx | 263 -------- .../llm-logo-provider/SessionProviderLogo.tsx | 5 - src/components/mcp/constants.ts | 5 - src/components/mcp/view/McpServers.tsx | 4 +- .../subcomponents/AgentConnectionsStep.tsx | 7 - src/components/provider-auth/types.ts | 4 +- .../provider-auth/view/ProviderLoginModal.tsx | 62 +- .../settings/constants/constants.ts | 2 +- .../settings/hooks/useSettingsController.ts | 16 - src/components/settings/types/types.ts | 1 - src/components/settings/view/Settings.tsx | 4 - .../tabs/agents-settings/AgentListItem.tsx | 9 +- .../agents-settings/AgentsSettingsTab.tsx | 11 +- .../sections/AgentSelectorSection.tsx | 2 - .../sections/content/AccountContent.tsx | 9 - .../sections/content/PermissionsContent.tsx | 112 +-- .../view/tabs/agents-settings/types.ts | 5 - .../shell/hooks/useShellTerminal.ts | 40 ++ src/components/sidebar/types/types.ts | 1 + src/components/sidebar/view/Sidebar.tsx | 2 + .../view/subcomponents/SidebarProjectItem.tsx | 3 + .../view/subcomponents/SidebarProjectList.tsx | 3 + .../subcomponents/SidebarProjectSessions.tsx | 3 + .../view/subcomponents/SidebarSessionItem.tsx | 23 +- src/components/skills/view/ProviderSkills.tsx | 2 - src/hooks/useProjectsState.ts | 85 ++- src/hooks/useQueuedMessageAutoSend.ts | 70 ++ src/i18n/locales/de/chat.json | 22 +- src/i18n/locales/de/settings.json | 3 - src/i18n/locales/de/sidebar.json | 3 +- src/i18n/locales/en/chat.json | 34 +- src/i18n/locales/en/settings.json | 3 - src/i18n/locales/en/sidebar.json | 3 +- src/i18n/locales/fr/chat.json | 20 - src/i18n/locales/fr/settings.json | 3 - src/i18n/locales/fr/sidebar.json | 3 +- src/i18n/locales/it/chat.json | 22 +- src/i18n/locales/it/settings.json | 3 - src/i18n/locales/it/sidebar.json | 3 +- src/i18n/locales/ja/settings.json | 3 - src/i18n/locales/ja/sidebar.json | 3 +- src/i18n/locales/ko/chat.json | 4 +- src/i18n/locales/ko/settings.json | 3 - src/i18n/locales/ko/sidebar.json | 5 +- src/i18n/locales/ru/chat.json | 22 +- src/i18n/locales/ru/settings.json | 3 - src/i18n/locales/ru/sidebar.json | 3 +- src/i18n/locales/tr/chat.json | 22 +- src/i18n/locales/tr/settings.json | 3 - src/i18n/locales/tr/sidebar.json | 3 +- src/i18n/locales/zh-CN/chat.json | 4 +- src/i18n/locales/zh-CN/settings.json | 3 - src/i18n/locales/zh-CN/sidebar.json | 3 +- src/i18n/locales/zh-TW/chat.json | 4 +- src/i18n/locales/zh-TW/settings.json | 3 - src/i18n/locales/zh-TW/sidebar.json | 3 +- src/shared/view/ui/PromptInput.tsx | 2 +- src/stores/useSessionStore.ts | 45 +- src/types/app.ts | 2 +- 173 files changed, 4172 insertions(+), 4296 deletions(-) delete mode 100644 docker/gemini/Dockerfile delete mode 100644 public/icons/gemini-ai-icon.svg delete mode 100644 server/gemini-cli.js delete mode 100644 server/gemini-response-handler.js create mode 100644 server/modules/assets/assets.routes.ts create mode 100644 server/modules/assets/index.ts create mode 100644 server/modules/assets/services/image-assets.service.ts create mode 100644 server/modules/assets/tests/image-assets.service.test.ts delete mode 100644 server/modules/providers/list/gemini/gemini-auth.provider.ts delete mode 100644 server/modules/providers/list/gemini/gemini-mcp.provider.ts delete mode 100644 server/modules/providers/list/gemini/gemini-models.provider.ts delete mode 100644 server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts delete mode 100644 server/modules/providers/list/gemini/gemini-sessions.provider.ts delete mode 100644 server/modules/providers/list/gemini/gemini-skills.provider.ts delete mode 100644 server/modules/providers/list/gemini/gemini.provider.ts create mode 100644 server/modules/providers/tests/codex-sessions.test.ts create mode 100644 server/modules/providers/tests/provider-image-history.test.ts create mode 100644 server/modules/websocket/tests/chat-image-filter.test.ts delete mode 100644 server/routes/gemini.js create mode 100644 server/routes/git.test.js delete mode 100644 server/sessionManager.js create mode 100644 server/shared/image-attachments.ts create mode 100644 server/shared/tests/image-attachments.test.ts create mode 100644 src/components/chat/view/subcomponents/ChatMessageImages.tsx create mode 100644 src/components/chat/view/subcomponents/QueuedMessageCard.tsx create mode 100644 src/components/git-panel/utils/commitGraph.test.ts create mode 100644 src/components/git-panel/utils/commitGraph.ts create mode 100644 src/components/git-panel/view/history/CommitGraphStrip.tsx delete mode 100644 src/components/llm-logo-provider/GeminiLogo.tsx create mode 100644 src/hooks/useQueuedMessageAutoSend.ts diff --git a/README.de.md b/README.de.md index fcbafb5f..c2ee36aa 100644 --- a/README.de.md +++ b/README.de.md @@ -1,18 +1,18 @@
- CloudCLI UI -

Cloud CLI (auch bekannt als Claude Code UI)

-

Eine Desktop- und Mobile-Oberfläche für Claude Code, Cursor CLI, Codex und Gemini-CLI.
Lokal oder remote nutzbar – verwalte deine aktiven Projekte und Sitzungen von überall.

+ CloudCLI UI +

Cloud CLI (auch bekannt als Claude Code UI)

+

Eine Desktop- und Mobile-Oberfläche für Claude Code, Cursor CLI und Codex.
Lokal oder remote nutzbar – verwalte deine aktiven Projekte und Sitzungen von überall.

- CloudCLI Cloud · Dokumentation · Discord · Fehler melden · Mitwirken + CloudCLI Cloud · Dokumentation · Discord · Fehler melden · Mitwirken

- CloudCLI Cloud - Join Community -

- siteboon%2Fclaudecodeui | Trendshift + CloudCLI Cloud + Join Community +

+ siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 简体中文 · 繁體中文 · 日本語 · Türkçe
@@ -43,7 +43,7 @@

CLI-Auswahl

CLI-Auswahl
-Wähle zwischen Claude Code, Gemini, Cursor CLI und Codex +Wähle zwischen Claude Code, Cursor CLI und Codex @@ -62,7 +62,7 @@ - **Sitzungsverwaltung** – Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen - **Plugin-System** – CloudCLI mit eigenen Plugins erweitern – neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI Integration** *(Optional)* – Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung -- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle zur Laufzeit über `GET /api/providers/:provider/models`) +- **Modell-Kompatibilität** – Funktioniert mit den Claude- und GPT-Modellfamilien (vollständige Liste unterstützter Modelle zur Laufzeit über `GET /api/providers/:provider/models`) ## Schnellstart @@ -103,7 +103,7 @@ Agents in isolierten Sandboxes mit Hypervisor-Isolation ausführen. Standardmä npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project ``` -Unterstützt Claude Code, Codex und Gemini CLI. Weitere Details in der [Sandbox-Dokumentation](docker/). +Unterstützt Claude Code und Codex. Weitere Details in der [Sandbox-Dokumentation](docker/). --- @@ -119,7 +119,7 @@ CloudCLI UI ist die Open-Source-UI-Schicht, die CloudCLI Cloud antreibt. Du kann | **Rechner muss laufen** | Ja | Nein | | **Mobiler Zugriff** | Jeder Browser im Netzwerk | Jedes Gerät, native App in Entwicklung | | **Verfügbare Sitzungen** | Alle Sitzungen automatisch aus `~/.claude` erkannt | Alle Sitzungen in deiner Cloud-Umgebung | -| **Unterstützte Agents** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | +| **Unterstützte Agents** | Claude Code, Cursor CLI, Codex | Claude Code, Cursor CLI, Codex | | **Datei-Explorer und Git** | Ja, direkt in der UI | Ja, direkt in der UI | | **MCP-Konfiguration** | Über UI verwaltet, synchronisiert mit lokalem `~/.claude` | Über UI verwaltet | | **IDE-Zugriff** | Deine lokale IDE | Jede IDE, die mit deiner Cloud-Umgebung verbunden ist | @@ -166,7 +166,7 @@ CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit e | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Vollwertiges xterm.js-Terminal mit Multi-Tab-Unterstützung | | **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Überwacht lange laufende Claude-Code-Sitzungen auf Hänger und stellt Prozesssteuerungen bereit | -| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex, Claude Code oder Gemini CLI aus | +| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex oder Claude Code aus | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Sitzungsintelligenz für Claude Code in CloudCLI, inklusive Sichtbarkeit des Token-Verbrauchs | | **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktive Claude-Code-Sitzungen anzeigen, verwalten und beenden | | **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | API-Kosten anhand von Modellpreisen und Token-Nutzung berechnen, mit Unterstützung für Preisvorlagen | @@ -193,7 +193,7 @@ Das bedeutet in der Praxis: - **Alle Sitzungen, nicht nur eine** – CloudCLI UI erkennt automatisch jede Sitzung aus dem `~/.claude`-Ordner. Remote Control stellt nur die einzelne aktive Sitzung bereit, um sie in der Claude Mobile App verfügbar zu machen. - **Deine Einstellungen sind deine Einstellungen** – MCP-Server, Tool-Berechtigungen und Projektkonfiguration, die in CloudCLI UI geändert werden, werden direkt in die Claude Code-Konfiguration geschrieben und treten sofort in Kraft – und umgekehrt. -- **Funktioniert mit mehr Agents** – Claude Code, Cursor CLI, Codex und Gemini CLI, nicht nur Claude Code. +- **Funktioniert mit mehr Agents** – Claude Code, Cursor CLI und Codex, nicht nur Claude Code. - **Vollständige UI, nicht nur ein Chat-Fenster** – Datei-Explorer, Git-Integration, MCP-Verwaltung und ein Shell-Terminal sind alle eingebaut. - **CloudCLI Cloud läuft in der Cloud** – Laptop zuklappen, der Agent läuft weiter. Kein Terminal zu überwachen, kein Rechner, der laufen muss. @@ -202,7 +202,7 @@ Das bedeutet in der Praxis:
Muss ich ein KI-Abonnement separat bezahlen? -Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei €7/Monat für die gehostete Umgebung zusätzlich dazu. +Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor- oder Codex-Abonnement mit. CloudCLI Cloud beginnt bei €7/Monat für die gehostete Umgebung zusätzlich dazu.
@@ -241,7 +241,6 @@ Dieses Projekt ist Open Source und kann unter der GPL v3-Lizenz kostenlos genutz - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropics offizielle CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursors offizielle CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex -- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - UI-Bibliothek - **[Vite](https://vitejs.dev/)** - Schnelles Build-Tool und Dev-Server - **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS-Framework @@ -254,5 +253,5 @@ Dieses Projekt ist Open Source und kann unter der GPL v3-Lizenz kostenlos genutz ---
- Mit Sorgfalt für die Claude Code-, Cursor- und Codex-Community erstellt. + Mit Sorgfalt für die Claude Code-, Cursor- und Codex-Community erstellt.
diff --git a/README.ja.md b/README.ja.md index 8872b51e..a4cbf2c3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,18 +1,18 @@
- CloudCLI UI -

Cloud CLI(別名 Claude Code UI)

-

Claude CodeCursor CLICodexGemini-CLI のためのデスクトップ/モバイル UI。
ローカルでもリモートでも使え、アクティブなプロジェクトとセッションをどこからでも閲覧できます。

+ CloudCLI UI +

Cloud CLI(別名 Claude Code UI)

+

Claude CodeCursor CLICodex のためのデスクトップ/モバイル UI。
ローカルでもリモートでも使え、アクティブなプロジェクトとセッションをどこからでも閲覧できます。

- CloudCLI Cloud · ドキュメント · Discord · バグ報告 · コントリビュート + CloudCLI Cloud · ドキュメント · Discord · バグ報告 · コントリビュート

- CloudCLI Cloud - Discord コミュニティに参加 -

- siteboon%2Fclaudecodeui | Trendshift + CloudCLI Cloud + Discord コミュニティに参加 +

+ siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 简体中文 · 繁體中文 · 日本語 · Türkçe
@@ -43,7 +43,7 @@

CLI 選択

CLI 選択
-Claude Code、Gemini、Cursor CLI、Codex から選択 +Claude Code、Cursor CLI、Codex から選択 @@ -99,7 +99,7 @@ cloudcli npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project ``` -Claude Code、Codex、Gemini CLI に対応。詳細は[サンドボックスのドキュメント](docker/)をご覧ください。 +Claude Code、Codex に対応。詳細は[サンドボックスのドキュメント](docker/)をご覧ください。 --- @@ -115,7 +115,7 @@ CloudCLI UI は、CloudCLI Cloud を支えるオープンソースの UI レイ | **マシンの稼働継続** | はい | いいえ | | **モバイルアクセス** | 同一ネットワーク内の任意のブラウザ | 任意のデバイス(ネイティブアプリも準備中) | | **利用可能なセッション** | `~/.claude` から全セッションを自動検出 | クラウド環境内の全セッション | -| **対応エージェント** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI | +| **対応エージェント** | Claude Code、Cursor CLI、Codex | Claude Code、Cursor CLI、Codex | | **ファイルエクスプローラとGit** | はい(UI に内蔵) | はい(UI に内蔵) | | **MCP設定** | UI で管理し、ローカルの `~/.claude` 設定と同期 | UI で管理 | | **IDEアクセス** | ローカル IDE | クラウド環境に接続された任意の IDE | @@ -160,7 +160,7 @@ CloudCLI にはプラグインシステムがあり、独自のフロントエ | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル | | **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 長時間実行中の Claude Code セッションのハングを監視し、プロセス操作を提供 | -| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | ワークスペース単位のスケジュール済みプロンプトを作成し、Codex、Claude Code、Gemini CLI などのローカル CLI で実行 | +| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | ワークスペース単位のスケジュール済みプロンプトを作成し、Codex、Claude Code などのローカル CLI で実行 | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 内で Claude Code のセッション分析を行い、トークン消費の可視化も提供 | | **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | アクティブな Claude Code セッションを表示、管理、終了 | | **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | モデル価格とトークン使用量から API コストを計算し、モデル価格プリセットにも対応 | @@ -185,7 +185,7 @@ CloudCLI UI と CloudCLI Cloud は、Claude Code の横に別物として存在 - **すべてのセッションにアクセス** — CloudCLI UI は `~/.claude` フォルダのすべてのセッションを自動検出します。Remote Control は、Claude モバイルアプリで利用可能にするため、1つのアクティブセッションだけを公開します。 - **設定はあなたの設定** — CloudCLI UI で変更した MCP サーバー、ツール権限、プロジェクト構成は、Claude Code の設定に直接書き込まれて即座に反映され、その逆(Claude Code での変更が UI に反映)も同様です。 -- **対応エージェントがさらに充実** — Claude Code に加えて Cursor CLI、Codex、Gemini CLI にも対応しています。 +- **対応エージェントがさらに充実** — Claude Code に加えて Cursor CLI、Codex にも対応しています。 - **チャット窓だけではない完全な UI** — ファイルエクスプローラー、Git 統合、MCP 管理、シェル端末などがすべて組み込まれています。 - **CloudCLI Cloud はクラウド上で稼働** — ノートパソコンを閉じてもエージェントは動き続けます。監視が要る端末も、スリープ防止も不要です。 @@ -194,7 +194,7 @@ CloudCLI UI と CloudCLI Cloud は、Claude Code の横に別物として存在
AI のサブスクリプションは別途支払いが必要ですか? -はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex、または Gemini のサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 €7 から提供されます。 +はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex のいずれかのサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 €7 から提供されます。
@@ -234,7 +234,6 @@ GNU General Public License v3.0 - 詳細は [LICENSE](LICENSE) ファイルを - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic の公式 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor の公式 CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex -- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - ユーザーインターフェースライブラリ - **[Vite](https://vitejs.dev/)** - 高速ビルドツールと開発サーバー - **[Tailwind CSS](https://tailwindcss.com/)** - ユーティリティファーストの CSS フレームワーク @@ -246,5 +245,5 @@ GNU General Public License v3.0 - 詳細は [LICENSE](LICENSE) ファイルを ---
- Claude Code、Cursor、Codex コミュニティのために心を込めて作りました。 + Claude Code、Cursor、Codex コミュニティのために心を込めて作りました。
diff --git a/README.ko.md b/README.ko.md index a782efdc..cbd2317b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,18 +1,18 @@
- CloudCLI UI -

Cloud CLI (일명 Claude Code UI)

-

Claude Code, Cursor CLI, Codex, Gemini-CLI 용 데스크톱 및 모바일 UI입니다.
로컬 또는 원격에서 실행하여 어디서나 활성 프로젝트와 세션을 확인하세요.

+ CloudCLI UI +

Cloud CLI (일명 Claude Code UI)

+

Claude Code, Cursor CLI, Codex, 용 데스크톱 및 모바일 UI입니다.
로컬 또는 원격에서 실행하여 어디서나 활성 프로젝트와 세션을 확인하세요.

- CloudCLI Cloud · 문서 · Discord · 버그 신고 · 기여 안내 + CloudCLI Cloud · 문서 · Discord · 버그 신고 · 기여 안내

- CloudCLI Cloud - Discord 커뮤니티 -

- siteboon%2Fclaudecodeui | Trendshift + CloudCLI Cloud + Discord 커뮤니티 +

+ siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 简体中文 · 繁體中文 · 日本語 · Türkçe
@@ -43,7 +43,7 @@

CLI 선택

CLI 선택
-Claude Code, Gemini, Cursor CLI 및 Codex 중 선택 +Claude Code, Cursor CLI 및 Codex 중 선택 @@ -60,7 +60,7 @@ - **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적 - **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리 -- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인) +- **모델 호환성** - Claude, GPT 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인) ## 빠른 시작 @@ -99,7 +99,7 @@ cloudcli npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project ``` -Claude Code, Codex, Gemini CLI를 지원합니다. 자세한 내용은 [샌드박스 문서](docker/)를 참고하세요. +Claude Code, Codex를 지원합니다. 자세한 내용은 [샌드박스 문서](docker/)를 참고하세요. --- @@ -115,7 +115,7 @@ CloudCLI UI는 CloudCLI Cloud를 구동하는 오픈 소스 UI 계층입니다. | **기기 유지 필요 여부** | 예 (머신 켜둬야 함) | 아니오 | | **모바일 접근** | 네트워크 내 브라우저 | 모든 기기 (네이티브 앱 예정) | | **세션 접근** | `~/.claude`에서 자동 발견 | 클라우드 환경 내 세션 | -| **지원 에이전트** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | +| **지원 에이전트** | Claude Code, Cursor CLI, Codex | Claude Code, Cursor CLI, Codex | | **파일 탐색기 및 Git** | UI에 통합됨 | UI에 통합됨 | | **MCP 구성** | UI에서 관리, 로컬 `~/.claude` 설정과 동기화됨 | UI에서 관리 | | **IDE 접근** | 로컬 IDE | 클라우드 환경에 연결된 모든 IDE | @@ -160,7 +160,7 @@ CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그 | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 | | **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 장시간 실행 중인 Claude Code 세션의 중단 상태를 감시하고 프로세스 제어를 제공 | -| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 워크스페이스 범위 예약 프롬프트를 만들고 Codex, Claude Code, Gemini CLI 같은 로컬 CLI로 실행 | +| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 워크스페이스 범위 예약 프롬프트를 만들고 Codex, Claude Code 같은 로컬 CLI로 실행 | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 안에서 Claude Code 세션 인텔리전스와 토큰 소모 가시성을 제공 | | **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 활성 Claude Code 세션을 보고, 관리하고, 종료 | | **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 모델 가격과 토큰 사용량으로 API 비용을 계산하고 모델 가격 프리셋을 지원 | @@ -186,7 +186,7 @@ CloudCLI UI와 CloudCLI Cloud는 Claude Code를 확장하며 별도로 존재하 - **모든 세션을 다룬다** — CloudCLI UI는 `~/.claude` 폴더에서 모든 세션을 자동 발견합니다. Remote Control은 단일 활성 세션만 노출합니다. - **설정은 그대로** — CloudCLI UI에서 변경한 MCP, 도구 권한, 프로젝트 설정은 Claude Code에 즉시 반영됩니다. -- **지원 에이전트가 더 많음** — Claude Code, Cursor CLI, Codex, Gemini CLI 지원. +- **지원 에이전트가 더 많음** — Claude Code, Cursor CLI, Codex 지원. - **전체 UI 제공** — 단일 채팅 창이 아닌 파일 탐색기, Git 통합, MCP 관리 및 셸 터미널 포함. - **CloudCLI Cloud는 클라우드에서 실행** — 노트북을 닫아도 에이전트가 실행됩니다. 터미널을 계속 확인할 필요 없음. @@ -195,7 +195,7 @@ CloudCLI UI와 CloudCLI Cloud는 Claude Code를 확장하며 별도로 존재하
AI 구독을 별도로 결제해야 하나요? -네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex, Gemini 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 €7부터 제공합니다. +네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 €7부터 제공합니다.
@@ -234,7 +234,6 @@ GNU General Public License v3.0 - 자세한 내용은 [LICENSE](LICENSE) 파일 - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 공식 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 공식 CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex -- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - 사용자 인터페이스 라이브러리 - **[Vite](https://vitejs.dev/)** - 빠른 빌드 도구 및 개발 서버 - **[Tailwind CSS](https://tailwindcss.com/)** - 유틸리티 우선 CSS 프레임워크 @@ -246,5 +245,5 @@ GNU General Public License v3.0 - 자세한 내용은 [LICENSE](LICENSE) 파일 ---
- Claude Code, Cursor, Codex 커뮤니티를 위해 정성껏 제작되었습니다. + Claude Code, Cursor, Codex 커뮤니티를 위해 정성껏 제작되었습니다.
diff --git a/README.md b/README.md index 65184902..9fa3d665 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@
- CloudCLI UI -

Cloud CLI (aka Claude Code UI)

-

A desktop and mobile UI for Claude Code, Cursor CLI, Codex, and Gemini-CLI.
Use it locally or remotely to view your active projects and sessions from everywhere.

+ CloudCLI UI +

Cloud CLI (aka Claude Code UI)

+

A desktop and mobile UI for Claude Code, Cursor CLI, and Codex.
Use it locally or remotely to view your active projects and sessions from everywhere.

- CloudCLI Cloud · Documentation · Discord · Bug Reports · Contributing + CloudCLI Cloud · Documentation · Discord · Bug Reports · Contributing

- CloudCLI Cloud - Join our Discord -

- siteboon%2Fclaudecodeui | Trendshift + CloudCLI Cloud + Join our Discord +

+ siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 简体中文 · 繁體中文 · 日本語 · Türkçe
@@ -22,7 +22,7 @@ ## Screenshots
- +
@@ -43,7 +43,7 @@

CLI Selection

CLI Selection
-Select between Claude Code, Gemini, Cursor CLI and Codex +Select between Claude Code, Cursor CLI and Codex
@@ -63,7 +63,7 @@ - **Session Management** - Resume conversations, manage multiple sessions, and track history - **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation -- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`) +- **Model Compatibility** - Works with Claude and GPT model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`) ## Quick Start @@ -103,7 +103,7 @@ Run agents in isolated sandboxes with hypervisor-level isolation. Starts Claude npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project ``` -Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options. +Supports Claude Code and Codex. See the [sandbox docs](docker/) for setup and advanced options. ### Desktop Companion App @@ -131,7 +131,7 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self | **Machine needs to stay on** | Yes | Yes | No | | **Mobile access** | Any browser on your network | Any browser on your network | Any device | | **Desktop companion** | Optional. Choose Local CloudCLI | Optional. Choose Local CloudCLI | Optional. Opens cloud environments | -| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | +| **Agents supported** | Claude Code, Cursor CLI, Codex | Claude Code, Codex | Claude Code, Cursor CLI, Codex | | **File explorer and Git** | Yes | Yes | Yes | | **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI | | **REST API** | Yes | Yes | Yes | @@ -176,7 +176,7 @@ CloudCLI has a plugin system that lets you add custom tabs with their own fronte | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support | | **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Watches long-running Claude Code sessions for hangs and exposes process controls | -| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI | +| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex or Claude Code | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Session intelligence for Claude Code inside CloudCLI, including token burn visibility | | **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | View, manage, and kill active Claude Code sessions | | **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Calculate API costs from model prices and token usage, with preset model pricing support | @@ -203,7 +203,7 @@ Here's what that means in practice: - **All your sessions, not just one** — CloudCLI UI auto-discovers every session from your `~/.claude` folder. Remote Control only exposes the single active session to make it available in the Claude mobile app. - **Your settings are your settings** — MCP servers, tool permissions, and project config you change in CloudCLI UI are written directly to your Claude Code config and take effect immediately, and vice versa. -- **Works with more agents** — Claude Code, Cursor CLI, Codex, and Gemini CLI, not just Claude Code. +- **Works with more agents** — Claude Code, Cursor CLI and Codex, not just Claude Code. - **Full UI, not just a chat window** — file explorer, Git integration, MCP management, and a shell terminal are all built in. - **CloudCLI Cloud runs in the cloud** — close your laptop, the agent keeps running. No terminal to babysit, no machine to keep awake. @@ -212,7 +212,7 @@ Here's what that means in practice:
Do I need to pay for an AI subscription separately? -Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at €7/month for the hosted environment on top of that. +Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, or Codex subscription. CloudCLI Cloud starts at €7/month for the hosted environment on top of that.
@@ -245,7 +245,7 @@ GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later) — see [LIC This project is open source and free to use, modify, and distribute under the AGPL-3.0-or-later license. If you modify this software and run it as a network service, you must make your modified source code available to users of that service. -CloudCLI UI - (https://cloudcli.ai). +CloudCLI UI - (https://cloudcli.ai). ## Acknowledgments @@ -253,7 +253,6 @@ CloudCLI UI - (https://cloudcli.ai). - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex -- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - User interface library - **[Vite](https://vitejs.dev/)** - Fast build tool and dev server - **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework @@ -266,5 +265,5 @@ CloudCLI UI - (https://cloudcli.ai). ---
- Made with care for the Claude Code, Cursor and Codex community. + Made with care for the Claude Code, Cursor and Codex community.
diff --git a/README.ru.md b/README.ru.md index 12210761..82c5fc86 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,18 +1,18 @@
- CloudCLI UI -

Cloud CLI (aka Claude Code UI)

-

Десктопный и мобильный UI для Claude Code, Cursor CLI, Codex и Gemini-CLI.
Используйте локально или удалённо, чтобы просматривать активные проекты и сессии отовсюду.

+ CloudCLI UI +

Cloud CLI (aka Claude Code UI)

+

Десктопный и мобильный UI для Claude Code, Cursor CLI, Codex.
Используйте локально или удалённо, чтобы просматривать активные проекты и сессии отовсюду.

- CloudCLI Cloud · Документация · Discord · Сообщить об ошибке · Участие в разработке + CloudCLI Cloud · Документация · Discord · Сообщить об ошибке · Участие в разработке

- CloudCLI Cloud - Join our Discord -

- siteboon%2Fclaudecodeui | Trendshift + CloudCLI Cloud + Join our Discord +

+ siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 简体中文 · 繁體中文 · 日本語 · Türkçe
@@ -43,7 +43,7 @@

Выбор CLI

CLI Selection
-Выбирайте между Claude Code, Gemini, Cursor CLI и Codex +Выбирайте между Claude Code, Cursor CLI и Codex @@ -62,7 +62,7 @@ - **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю - **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow -- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`) +- **Совместимость с моделями** - работает с семействами моделей Claude и GPT (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`) ## Быстрый старт @@ -103,7 +103,7 @@ cloudcli npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project ``` -Поддерживаются Claude Code, Codex и Gemini CLI. Подробнее в [документации sandbox](docker/). +Поддерживаются Claude Code и Codex. Подробнее в [документации sandbox](docker/). --- @@ -119,7 +119,7 @@ CloudCLI UI — это open source UI-слой, на котором постро | **Машина должна оставаться включённой** | Да | Нет | | **Доступ с мобильных устройств** | Любой браузер в вашей сети | Любое устройство, нативное приложение в разработке | | **Доступные сессии** | Все сессии автоматически обнаруживаются из `~/.claude` | Все сессии внутри вашей облачной среды | -| **Поддерживаемые агенты** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | +| **Поддерживаемые агенты** | Claude Code, Cursor CLI, Codex | Claude Code, Cursor CLI, Codex | | **Проводник файлов и Git** | Да, встроены в UI | Да, встроены в UI | | **Конфигурация MCP** | Управляется через UI, синхронизируется с вашим локальным конфигом `~/.claude` | Управляется через UI | | **Доступ из IDE** | Ваша локальная IDE | Любая IDE, подключенная к вашей облачной среде | @@ -166,7 +166,7 @@ CloudCLI UI — это open source UI-слой, на котором постро | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок | | **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Отслеживает зависания долгих сессий Claude Code и предоставляет управление процессами | -| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Создаёт запланированные промпты для рабочей области и запускает их через локальную CLI, например Codex, Claude Code или Gemini CLI | +| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Создаёт запланированные промпты для рабочей области и запускает их через локальную CLI, например Codex или Claude Code | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Аналитика сессий Claude Code внутри CloudCLI, включая видимость расхода токенов | | **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Просмотр, управление и завершение активных сессий Claude Code | | **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Расчёт стоимости API по ценам моделей и использованию токенов, с поддержкой пресетов цен | @@ -193,7 +193,7 @@ CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работ - **Все ваши сессии, а не одна** — CloudCLI UI автоматически находит каждую сессию из папки `~/.claude`. Remote Control предоставляет только одну активную сессию, чтобы сделать её доступной в мобильном приложении Claude. - **Ваши настройки — это ваши настройки** — MCP-серверы, права инструментов и конфигурация проекта, изменённые в CloudCLI UI, записываются напрямую в конфиг Claude Code и вступают в силу сразу же, и наоборот. -- **Работает с большим числом агентов** — Claude Code, Cursor CLI, Codex и Gemini CLI, а не только Claude Code. +- **Работает с большим числом агентов** — Claude Code, Cursor CLI и Codex, а не только Claude Code. - **Полноценный UI, а не просто окно чата** — проводник файлов, Git-интеграция, управление MCP и shell-терминал — всё встроено. - **CloudCLI Cloud работает в облаке** — закройте ноутбук, и агент продолжит работать. Не нужно следить за терминалом и держать машину постоянно активной. @@ -202,7 +202,7 @@ CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работ
Нужно ли отдельно платить за AI-подписку? -Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud начинается от €7/месяц за хостируемую среду поверх этого. +Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor или Codex. CloudCLI Cloud начинается от €7/месяц за хостируемую среду поверх этого.
@@ -241,7 +241,6 @@ GNU General Public License v3.0 - подробности в файле [LICENSE] - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - официальный CLI от Anthropic - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - официальный CLI от Cursor - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex -- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - библиотека пользовательских интерфейсов - **[Vite](https://vitejs.dev/)** - быстрый инструмент сборки и dev-сервер - **[Tailwind CSS](https://tailwindcss.com/)** - utility-first CSS framework @@ -254,5 +253,5 @@ GNU General Public License v3.0 - подробности в файле [LICENSE] ---
- Сделано с заботой для сообщества Claude Code, Cursor и Codex. + Сделано с заботой для сообщества Claude Code, Cursor и Codex.
diff --git a/README.tr.md b/README.tr.md index bba939f9..24c7f06b 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,18 +1,18 @@
- CloudCLI UI -

Cloud CLI (Claude Code UI olarak da bilinir)

-

Claude Code, Cursor CLI, Codex ve Gemini-CLI için masaüstü ve mobil arayüz.
Yerel ya da uzaktan kullanarak aktif projelerine ve oturumlarına her yerden erişebilirsin.

+ CloudCLI UI +

Cloud CLI (Claude Code UI olarak da bilinir)

+

Claude Code, Cursor CLI, Codex için masaüstü ve mobil arayüz.
Yerel ya da uzaktan kullanarak aktif projelerine ve oturumlarına her yerden erişebilirsin.

- CloudCLI Cloud · Dokümantasyon · Discord · Sorun Bildir · Katkıda Bulun + CloudCLI Cloud · Dokümantasyon · Discord · Sorun Bildir · Katkıda Bulun

- CloudCLI Cloud - Discord'a Katıl -

- siteboon%2Fclaudecodeui | Trendshift + CloudCLI Cloud + Discord'a Katıl +

+ siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 简体中文 · 繁體中文 · 日本語 · Türkçe
@@ -43,7 +43,7 @@

CLI Seçimi

CLI Seçimi
-Claude Code, Gemini, Cursor CLI ve Codex arasında seçim yap +Claude Code, Cursor CLI ve Codex arasında seçim yap @@ -62,7 +62,7 @@ - **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et - **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi -- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için `GET /api/providers/:provider/models` API'sine bak) +- **Model Uyumluluğu** — Claude ve GPT model aileleriyle çalışır (desteklenen tüm modeller için `GET /api/providers/:provider/models` API'sine bak) ## Hızlı Başlangıç @@ -103,7 +103,7 @@ Ajanları hipervizör seviyesinde izolasyonlu sandbox'larda çalıştır. Varsay npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project ``` -Claude Code, Codex ve Gemini CLI destekler. Kurulum ve gelişmiş seçenekler için [sandbox dokümantasyonuna](docker/) bak. +Claude Code ve Codex'i destekler. Kurulum ve gelişmiş seçenekler için [sandbox dokümantasyonuna](docker/) bak. --- @@ -120,7 +120,7 @@ CloudCLI UI, CloudCLI Cloud'u güçlendiren açık kaynak arayüz katmanıdır. | **İzolasyon** | Kendi host'unda çalışır | Hipervizör seviyesi sandbox (microVM) | Tam bulut izolasyonu | | **Makinenin açık kalması gerek** | Evet | Evet | Hayır | | **Mobil erişim** | Ağındaki herhangi bir tarayıcı | Ağındaki herhangi bir tarayıcı | Herhangi bir cihaz, native uygulama yolda | -| **Desteklenen ajanlar** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | +| **Desteklenen ajanlar** | Claude Code, Cursor CLI, Codex | Claude Code, Codex | Claude Code, Cursor CLI, Codex | | **Dosya gezgini ve Git** | Evet | Evet | Evet | | **MCP yapılandırması** | `~/.claude` ile senkron | UI üzerinden yönetilir | UI üzerinden yönetilir | | **REST API** | Evet | Evet | Evet | @@ -165,7 +165,7 @@ CloudCLI, kendi frontend UI'sı ve isteğe bağlı Node.js arka ucu olan özel s | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali | | **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Uzun süren Claude Code oturumlarını takılmalara karşı izler ve süreç kontrolleri sunar | -| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Çalışma alanı kapsamlı zamanlanmış prompt'lar oluşturur ve bunları Codex, Claude Code veya Gemini CLI gibi yerel CLI'larla çalıştırır | +| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Çalışma alanı kapsamlı zamanlanmış prompt'lar oluşturur ve bunları Codex veya Claude Code gibi yerel CLI'larla çalıştırır | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI içinde Claude Code oturum zekası ve token tüketimi görünürlüğü sağlar | | **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktif Claude Code oturumlarını görüntülemeni, yönetmeni ve sonlandırmanı sağlar | | **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Model fiyatları ve token kullanımından API maliyetlerini hesaplar; model fiyatı hazır ayarlarını destekler | @@ -192,7 +192,7 @@ Pratikte bu ne demek: - **Tek oturum değil, tüm oturumların** — CloudCLI UI, `~/.claude` klasöründeki her oturumu otomatik keşfeder. Remote Control yalnızca tek aktif oturumu Claude mobil uygulamasına açar. - **Ayarların sana ait** — UI'da değiştirdiğin MCP sunucuları, araç izinleri ve proje yapılandırması doğrudan Claude Code yapılandırmana yazılır ve anında etkili olur; tersi de geçerli. -- **Daha fazla ajanla çalışır** — Sadece Claude Code değil; Cursor CLI, Codex ve Gemini CLI de. +- **Daha fazla ajanla çalışır** — Sadece Claude Code değil; Cursor CLI ve Codex de. - **Sadece sohbet penceresi değil, tam UI** — dosya gezgini, Git entegrasyonu, MCP yönetimi ve shell terminali hepsi yerleşik. - **CloudCLI Cloud bulutta çalışır** — laptop'unu kapat, ajan çalışmaya devam eder. Beklemen gereken terminal yok, uyanık tutman gereken makine yok. @@ -201,7 +201,7 @@ Pratikte bu ne demek:
AI aboneliği için ayrıca ödeme yapmam gerekiyor mu? -Evet. CloudCLI AI'yi değil, ortamı sağlar. Kendi Claude, Cursor, Codex veya Gemini aboneliğini getirirsin. CloudCLI Cloud, barındırılan ortam için aylık 7 €'dan başlar — bunun üzerine eklenir. +Evet. CloudCLI AI'yi değil, ortamı sağlar. Kendi Claude, Cursor veya Codex aboneliğini getirirsin. CloudCLI Cloud, barındırılan ortam için aylık 7 €'dan başlar — bunun üzerine eklenir.
@@ -242,7 +242,6 @@ CloudCLI UI — (https://cloudcli.ai). - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic'in resmi CLI'ı - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor'un resmi CLI'ı - **[Codex](https://developers.openai.com/codex)** — OpenAI Codex -- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI - **[React](https://react.dev/)** — Kullanıcı arayüzü kütüphanesi - **[Vite](https://vitejs.dev/)** — Hızlı derleme aracı ve geliştirme sunucusu - **[Tailwind CSS](https://tailwindcss.com/)** — Utility-first CSS framework @@ -255,5 +254,5 @@ CloudCLI UI — (https://cloudcli.ai). ---
- Claude Code, Cursor ve Codex topluluğu için özenle yapıldı. + Claude Code, Cursor ve Codex topluluğu için özenle yapıldı.
diff --git a/README.zh-CN.md b/README.zh-CN.md index 97da920e..9b7c71b6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,18 +1,18 @@
- CloudCLI UI -

Cloud CLI(又名 Claude Code UI)

-

Claude CodeCursor CLICodexGemini-CLI 的桌面和移动端 UI。可在本地或远程使用,从任何地方查看激活的项目与会话。

+ CloudCLI UI +

Cloud CLI(又名 Claude Code UI)

+

Claude CodeCursor CLICodex 的桌面和移动端 UI。可在本地或远程使用,从任何地方查看激活的项目与会话。

- CloudCLI Cloud · 文档 · Discord · Bug 报告 · 贡献指南 + CloudCLI Cloud · 文档 · Discord · Bug 报告 · 贡献指南

- CloudCLI Cloud - 加入 Discord 社区 -

- siteboon%2Fclaudecodeui | Trendshift + CloudCLI Cloud + 加入 Discord 社区 +

+ siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 简体中文 · 繁體中文 · 日本語 · Türkçe
@@ -43,7 +43,7 @@

CLI 选择

CLI 选择
-在 Claude Code、Gemini、Cursor CLI 与 Codex 之间进行选择 +在 Claude Code、Cursor CLI 与 Codex 之间进行选择 @@ -60,7 +60,7 @@ - **会话管理** - 恢复对话、管理多个会话并跟踪历史记录 - **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理 -- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取) +- **模型兼容性** - 支持 Claude、GPT 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取) ## 快速开始 @@ -99,7 +99,7 @@ cloudcli npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project ``` -支持 Claude Code、Codex 和 Gemini CLI。详情请参阅 [沙箱文档](docker/)。 +支持 Claude Code 和 Codex。详情请参阅 [沙箱文档](docker/)。 --- @@ -115,7 +115,7 @@ CloudCLI UI 是 CloudCLI Cloud 的开源 UI 层。你可以在本地机器上自 | **机器需保持开机吗** | 是 | 否 | | **移动端访问** | 网络内任意浏览器 | 任意设备(原生应用即将推出) | | **可用会话** | 自动发现 `~/.claude` 中的所有会话 | 云端环境内的会话 | -| **支持的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI | +| **支持的 Agents** | Claude Code、Cursor CLI、Codex | Claude Code、Cursor CLI、Codex | | **文件浏览与 Git** | 内置于 UI | 内置于 UI | | **MCP 配置** | UI 管理,与本地 `~/.claude` 配置同步 | UI 管理 | | **IDE 访问** | 本地 IDE | 任何连接到云环境的 IDE | @@ -160,7 +160,7 @@ CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Nod | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 | | **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 监控长时间运行的 Claude Code 会话是否卡住,并提供进程控制 | -| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 创建工作区范围的定时提示词,并通过 Codex、Claude Code 或 Gemini CLI 等本地 CLI 执行 | +| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 创建工作区范围的定时提示词,并通过 Codex、Claude Code 等本地 CLI 执行 | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 会话智能分析,包括 token 消耗可视化 | | **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 查看、管理并终止活动的 Claude Code 会话 | | **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根据模型价格和 token 用量计算 API 成本,并支持模型价格预设 | @@ -186,7 +186,7 @@ CloudCLI UI 与 CloudCLI Cloud 是对 Claude Code 的扩展,而非旁观 — M - **覆盖全部会话** — CloudCLI UI 会自动扫描 `~/.claude` 文件夹中的每个会话。Remote Control 只暴露当前活动的会话。 - **设置统一** — 在 CloudCLI UI 中修改的 MCP、工具权限等设置会立即写入 Claude Code。 -- **支持更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。 +- **支持更多 Agents** — Claude Code、Cursor CLI、Codex。 - **完整 UI** — 除了聊天界面,还包括文件浏览器、Git 集成、MCP 管理和 Shell 终端。 - **CloudCLI Cloud 保持运行于云端** — 关闭本地设备也不会中断代理运行,无需监控终端。 @@ -195,7 +195,7 @@ CloudCLI UI 与 CloudCLI Cloud 是对 Claude Code 的扩展,而非旁观 — M
需要额外购买 AI 订阅吗? -需要。CloudCLI 只提供环境。你仍需自行获取 Claude、Cursor、Codex 或 Gemini 订阅。CloudCLI Cloud 从 €7/月起提供托管环境。 +需要。CloudCLI 只提供环境。你仍需自行获取 Claude、Cursor 或 Codex 订阅。CloudCLI Cloud 从 €7/月起提供托管环境。
@@ -234,7 +234,6 @@ GNU 通用公共许可证 v3.0 - 详见 [LICENSE](LICENSE) 文件。 - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 官方 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 官方 CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex -- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - 用户界面库 - **[Vite](https://vitejs.dev/)** - 快速构建工具与开发服务器 - **[Tailwind CSS](https://tailwindcss.com/)** - 实用先行 CSS 框架 @@ -246,5 +245,5 @@ GNU 通用公共许可证 v3.0 - 详见 [LICENSE](LICENSE) 文件。 ---
- 为 Claude Code、Cursor 和 Codex 社区精心打造。 + 为 Claude Code、Cursor 和 Codex 社区精心打造。
diff --git a/README.zh-TW.md b/README.zh-TW.md index 9f8c972a..de375429 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,18 +1,18 @@
- CloudCLI UI -

Cloud CLI(又名 Claude Code UI)

-

Claude CodeCursor CLICodexGemini-CLI 的桌面和行動裝置 UI。可在本機或遠端使用,從任何地方查看您的專案與工作階段。

+ CloudCLI UI +

Cloud CLI(又名 Claude Code UI)

+

Claude CodeCursor CLICodex 的桌面和行動裝置 UI。可在本機或遠端使用,從任何地方查看您的專案與工作階段。

- CloudCLI Cloud · 文件 · Discord · Bug 回報 · 貢獻指南 + CloudCLI Cloud · 文件 · Discord · Bug 回報 · 貢獻指南

- CloudCLI Cloud - 加入 Discord 社群 -

- siteboon%2Fclaudecodeui | Trendshift + CloudCLI Cloud + 加入 Discord 社群 +

+ siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 简体中文 · 繁體中文 · 日本語 · Türkçe
@@ -43,7 +43,7 @@

CLI 選擇

CLI 選擇
-在 Claude Code、Gemini、Cursor CLI 與 Codex 之間進行選擇 +在 Claude Code、Cursor CLI 與 Codex 之間進行選擇 @@ -60,7 +60,7 @@ - **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄 - **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理 -- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表可透過 `GET /api/providers/:provider/models` 介面取得) +- **模型相容性** — 支援 Claude、GPT 模型家族(完整支援列表可透過 `GET /api/providers/:provider/models` 介面取得) ## 快速開始 @@ -99,7 +99,7 @@ cloudcli npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project ``` -支援 Claude Code、Codex 和 Gemini CLI。詳情請參閱[沙箱文件](docker/)。 +支援 Claude Code 和 Codex。詳情請參閱[沙箱文件](docker/)。 --- @@ -115,7 +115,7 @@ CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它 | **機器需保持開機嗎** | 是 | 否 | | **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) | | **可用工作階段** | 自動發現 `~/.claude` 中的所有工作階段 | 雲端環境內的工作階段 | -| **支援的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI | +| **支援的 Agents** | Claude Code、Cursor CLI、Codex | Claude Code、Cursor CLI、Codex | | **檔案瀏覽與 Git** | 內建於 UI | 內建於 UI | | **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 | | **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE | @@ -160,7 +160,7 @@ CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Nod | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支援多分頁的完整 xterm.js 終端機 | | **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 監控長時間執行的 Claude Code 工作階段是否卡住,並提供程序控制 | -| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 建立工作區範圍的排程提示詞,並透過 Codex、Claude Code 或 Gemini CLI 等本機 CLI 執行 | +| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 建立工作區範圍的排程提示詞,並透過 Codex、Claude Code 等本機 CLI 執行 | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 工作階段智慧分析,包括 token 消耗可視化 | | **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 檢視、管理並終止作用中的 Claude Code 工作階段 | | **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根據模型價格與 token 用量計算 API 成本,並支援模型價格預設 | @@ -186,7 +186,7 @@ CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — M - **涵蓋全部工作階段** — CloudCLI UI 會自動掃描 `~/.claude` 資料夾中的每個工作階段。Remote Control 只暴露目前活動的工作階段。 - **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。 -- **支援更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。 +- **支援更多 Agents** — Claude Code、Cursor CLI、Codex。 - **完整 UI** — 除了聊天介面,還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。 - **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。 @@ -195,7 +195,7 @@ CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — M
需要額外購買 AI 訂閱嗎? -需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 €7/月起提供託管環境。 +需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor 或 Codex 訂閱。CloudCLI Cloud 從 €7/月起提供託管環境。
@@ -234,7 +234,6 @@ GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。 - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic 官方 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor 官方 CLI - **[Codex](https://developers.openai.com/codex)** — OpenAI Codex -- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI - **[React](https://react.dev/)** — 使用者介面函式庫 - **[Vite](https://vitejs.dev/)** — 快速建構工具與開發伺服器 - **[Tailwind CSS](https://tailwindcss.com/)** — 實用優先 CSS 框架 @@ -246,5 +245,5 @@ GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。 ---
- 為 Claude Code、Cursor 和 Codex 社群精心打造。 + 為 Claude Code、Cursor 和 Codex 社群精心打造。
diff --git a/docker/README.md b/docker/README.md index 134ebad0..b84ffeb4 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,9 +1,9 @@ - + # Sandboxed coding agents with a web & mobile IDE (CloudCLI) -[Docker Sandbox](https://docs.docker.com/ai/sandboxes/) templates that add [CloudCLI](https://cloudcli.ai) on top of Claude Code, Codex, and Gemini CLI. You get a full web and mobile IDE accessible from any browser on any device. +[Docker Sandbox](https://docs.docker.com/ai/sandboxes/) templates that add [CloudCLI](https://cloudcli.ai) on top of Claude Code and Codex. You get a full web and mobile IDE accessible from any browser on any device. ## Get started @@ -42,10 +42,6 @@ Store the matching API key and pass `--agent`: # OpenAI Codex sbx secret set -g openai npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent codex - -# Gemini CLI -sbx secret set -g google -npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent gemini ``` ### Available templates @@ -54,7 +50,6 @@ npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent gemini |-------|----------| | **Claude Code** (default) | `docker.io/cloudcliai/sandbox:claude-code` | | OpenAI Codex | `docker.io/cloudcliai/sandbox:codex` | -| Gemini CLI | `docker.io/cloudcliai/sandbox:gemini` | These are used with `--template` when running `sbx` directly (see [Advanced usage](#advanced-usage)). diff --git a/docker/gemini/Dockerfile b/docker/gemini/Dockerfile deleted file mode 100644 index dc06db69..00000000 --- a/docker/gemini/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM docker/sandbox-templates:gemini - -USER root -COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh -RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh - -USER agent -RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version - -COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh -RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc diff --git a/electron/launcher/launcher.js b/electron/launcher/launcher.js index 80e30b52..93abea41 100644 --- a/electron/launcher/launcher.js +++ b/electron/launcher/launcher.js @@ -12,7 +12,7 @@ window.__MOCK_STATE__ = { { id: 'env-api', name: 'api-gateway', subdomain: 'api-gateway', access_url: 'https://api-gateway.cloudcli.ai', status: 'running', region: 'fra1', agent: 'Claude Code' }, { id: 'env-web', name: 'web-frontend', subdomain: 'web-frontend', access_url: 'https://web-frontend.cloudcli.ai', status: 'stopped', region: 'sfo1', agent: 'Codex' }, { id: 'env-data', name: 'data-pipeline', subdomain: 'data-pipeline', access_url: 'https://data-pipeline.cloudcli.ai', status: 'stopped', region: 'fra1', agent: 'Cursor' }, - { id: 'env-ml', name: 'ml-trainer', subdomain: 'ml-trainer', access_url: 'https://ml-trainer.cloudcli.ai', status: 'paused', region: 'iad1', agent: 'Gemini' }, + { id: 'env-ml', name: 'ml-trainer', subdomain: 'ml-trainer', access_url: 'https://ml-trainer.cloudcli.ai', status: 'paused', region: 'iad1', agent: 'OpenCode' }, ], }; diff --git a/eslint.config.js b/eslint.config.js index e002aece..df10d25e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -161,6 +161,7 @@ export default tseslint.config( "server/shared/utils.{js,ts}", "server/shared/frontmatter.ts", "server/shared/claude-cli-path.ts", + "server/shared/image-attachments.ts", ], // classify shared utility files so modules can depend on them explicitly mode: "file", }, @@ -168,7 +169,6 @@ export default tseslint.config( type: "backend-legacy-runtime", // legacy runtime persistence modules used while providers migrate into server/modules pattern: [ "server/projects.js", - "server/sessionManager.js", "server/utils/runtime-paths.js", ], // provider history loading still resolves session data through these legacy runtime files mode: "file", diff --git a/package-lock.json b/package-lock.json index ec345985..8b870c71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,8 @@ "@types/better-sqlite3": "^7.6.13", "@types/cross-spawn": "^6.0.6", "@types/express": "^5.0.6", + "@types/mime-types": "^3.0.1", + "@types/multer": "^2.2.0", "@types/node": "^22.19.7", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", @@ -5994,12 +5996,29 @@ "@types/unist": "*" } }, + "node_modules/@types/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.19.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", diff --git a/package.json b/package.json index 25861d43..1cad3c55 100644 --- a/package.json +++ b/package.json @@ -121,8 +121,6 @@ "claude-code-ui", "cloudcli", "codex", - "gemini", - "gemini-cli", "cursor", "cursor-cli", "anthropic", @@ -203,6 +201,8 @@ "@types/better-sqlite3": "^7.6.13", "@types/cross-spawn": "^6.0.6", "@types/express": "^5.0.6", + "@types/mime-types": "^3.0.1", + "@types/multer": "^2.2.0", "@types/node": "^22.19.7", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", diff --git a/public/api-docs.html b/public/api-docs.html index 040d8680..c2d60173 100644 --- a/public/api-docs.html +++ b/public/api-docs.html @@ -489,7 +489,7 @@ http://localhost:3001/api/agent
-

Trigger an AI agent (Claude, Cursor, Codex, Gemini, or OpenCode) to work on a project.

+

Trigger an AI agent (Claude, Cursor, Codex, or OpenCode) to work on a project.

Request Body Parameters

@@ -524,7 +524,7 @@ - + @@ -837,7 +837,6 @@ data: {"type":"done"} const PROVIDER_ORDER = [ { id: 'claude', name: 'Anthropic' }, { id: 'codex', name: 'OpenAI' }, - { id: 'gemini', name: 'Google' }, { id: 'cursor', name: 'Cursor' }, { id: 'opencode', name: 'OpenCode' }, ]; diff --git a/public/icons/gemini-ai-icon.svg b/public/icons/gemini-ai-icon.svg deleted file mode 100644 index f1cf3575..00000000 --- a/public/icons/gemini-ai-icon.svg +++ /dev/null @@ -1 +0,0 @@ -Gemini \ No newline at end of file diff --git a/redirect-package/README.md b/redirect-package/README.md index bcc82037..7c7c8669 100644 --- a/redirect-package/README.md +++ b/redirect-package/README.md @@ -14,20 +14,20 @@ ---
- CloudCLI UI -

Cloud CLI (aka Claude Code UI)

-

A desktop and mobile UI for Claude Code, Cursor CLI, Codex, and Gemini-CLI.
Use it locally or remotely to view your active projects and sessions from everywhere.

+ CloudCLI UI +

Cloud CLI (aka Claude Code UI)

+

A desktop and mobile UI for Claude Code, Cursor CLI, and Codex.
Use it locally or remotely to view your active projects and sessions from everywhere.

- CloudCLI Cloud · Documentation · Discord · Bug Reports · Contributing + CloudCLI Cloud · Documentation · Discord · Bug Reports · Contributing

- CloudCLI Cloud - Join our Discord -

- siteboon%2Fclaudecodeui | Trendshift + CloudCLI Cloud + Join our Discord +

+ siteboon%2Fclaudecodeui | Trendshift

--- @@ -56,7 +56,7 @@

CLI Selection

CLI Selection
-Select between Claude Code, Gemini, Cursor CLI and Codex +Select between Claude Code, Cursor CLI and Codex
provider string Optionalclaude, cursor, codex, gemini, or opencode (default: claude)claude, cursor, codex, or opencode (default: claude)
stream
@@ -75,7 +75,7 @@ - **Session Management** - Resume conversations, manage multiple sessions, and track history - **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation -- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`) +- **Model Compatibility** - Works with Claude and GPT model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`) ## Quick Start @@ -121,7 +121,7 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self | **Machine needs to stay on** | Yes | No | | **Mobile access** | Any browser on your network | Any device, native app coming | | **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment | -| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | +| **Agents supported** | Claude Code, Cursor CLI, Codex | Claude Code, Cursor CLI, Codex | | **File explorer and Git** | Yes, built into the UI | Yes, built into the UI | | **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI | | **IDE access** | Your local IDE | Any IDE connected to your cloud environment | @@ -181,7 +181,7 @@ Here's what that means in practice: - **All your sessions, not just one** — CloudCLI UI auto-discovers every session from your `~/.claude` folder. Remote Control only exposes the single active session to make it available in the Claude mobile app. - **Your settings are your settings** — MCP servers, tool permissions, and project config you change in CloudCLI UI are written directly to your Claude Code config and take effect immediately, and vice versa. -- **Works with more agents** — Claude Code, Cursor CLI, Codex, and Gemini CLI, not just Claude Code. +- **Works with more agents** — Claude Code, Cursor CLI and Codex, not just Claude Code. - **Full UI, not just a chat window** — file explorer, Git integration, MCP management, and a shell terminal are all built in. - **CloudCLI Cloud runs in the cloud** — close your laptop, the agent keeps running. No terminal to babysit, no machine to keep awake. @@ -190,7 +190,7 @@ Here's what that means in practice:
Do I need to pay for an AI subscription separately? -Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at $7/month for the hosted environment on top of that. +Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, or Codex subscription. CloudCLI Cloud starts at $7/month for the hosted environment on top of that.
@@ -223,7 +223,7 @@ GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later) — see [LIC This project is open source and free to use, modify, and distribute under the AGPL-3.0-or-later license. If you modify this software and run it as a network service, you must make your modified source code available to users of that service. -CloudCLI UI - (https://cloudcli.ai). +CloudCLI UI - (https://cloudcli.ai). ## Acknowledgments @@ -231,7 +231,6 @@ CloudCLI UI - (https://cloudcli.ai). - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex -- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - User interface library - **[Vite](https://vitejs.dev/)** - Fast build tool and dev server - **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework @@ -244,5 +243,5 @@ CloudCLI UI - (https://cloudcli.ai). ---
- Made with care for the Claude Code, Cursor and Codex community. + Made with care for the Claude Code, Cursor and Codex community.
diff --git a/redirect-package/package.json b/redirect-package/package.json index 04563d83..035ffb20 100644 --- a/redirect-package/package.json +++ b/redirect-package/package.json @@ -22,8 +22,6 @@ "claude-code-ui", "cloudcli", "codex", - "gemini", - "gemini-cli", "cursor", "cursor-cli", "anthropic", diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 426ce029..6e5bb5ce 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -19,6 +19,7 @@ import path from 'path'; import { query } from '@anthropic-ai/claude-agent-sdk'; +import { buildClaudeUserContent, normalizeImageDescriptors } from './shared/image-attachments.js'; import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js'; @@ -236,16 +237,13 @@ function mapCliOptionsToSDK(options = {}) { * Adds a session to the active sessions map * @param {string} sessionId - Session identifier * @param {Object} queryInstance - SDK query instance - * @param {Array} tempImagePaths - Temp image file paths for cleanup - * @param {string} tempDir - Temp directory for cleanup + * @param {Object} writer - WebSocket writer for reconnect support */ -function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) { +function addSession(sessionId, queryInstance, writer = null) { activeSessions.set(sessionId, { instance: queryInstance, startTime: Date.now(), status: 'active', - tempImagePaths, - tempDir, writer }); } @@ -364,90 +362,35 @@ function extractTokenBudget(sdkMessage) { } /** - * Handles image processing for SDK queries - * Saves base64 images to temporary files and returns modified prompt with file paths - * @param {string} command - Original user prompt - * @param {Array} images - Array of image objects with base64 data - * @param {string} cwd - Working directory for temp file creation - * @returns {Promise} {modifiedCommand, tempImagePaths, tempDir} + * Builds the SDK `prompt` payload for one turn. + * + * Plain text turns pass the string through unchanged. Turns with image + * attachments use the SDK's streaming-input mode: a single SDKUserMessage + * whose content carries the prompt text plus one base64 `image` block per + * attachment (read from the global `~/.cloudcli/assets` folder). + * + * @param {string} command - User prompt + * @param {Array} images - Image descriptors ({ path, name?, mimeType? }) + * @param {string} cwd - Project working directory image paths resolve against + * @returns {Promise} SDK prompt payload */ -async function handleImages(command, images, cwd) { - const tempImagePaths = []; - let tempDir = null; - - if (!images || images.length === 0) { - return { modifiedCommand: command, tempImagePaths, tempDir }; +async function buildPromptPayload(command, images, cwd) { + if (normalizeImageDescriptors(images).length === 0) { + return command; } - try { - // Create temp directory in the project directory - const workingDir = cwd || process.cwd(); - 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) { - console.error('Invalid image data format'); - 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 - let modifiedCommand = command; - if (tempImagePaths.length > 0 && command && command.trim()) { - const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`; - modifiedCommand = command + imageNote; - } - - // Images processed - return { modifiedCommand, tempImagePaths, tempDir }; - } catch (error) { - console.error('Error processing images for SDK:', error); - return { modifiedCommand: command, tempImagePaths, tempDir }; - } -} - -/** - * Cleans up temporary image files - * @param {Array} tempImagePaths - Array of temp file paths to delete - * @param {string} tempDir - Temp directory to remove - */ -async function cleanupTempFiles(tempImagePaths, tempDir) { - if (!tempImagePaths || tempImagePaths.length === 0) { - return; - } - - try { - // Delete individual temp files - for (const imagePath of tempImagePaths) { - await fs.unlink(imagePath).catch(err => - console.error(`Failed to delete temp image ${imagePath}:`, err) - ); - } - - // Delete temp directory - if (tempDir) { - await fs.rm(tempDir, { recursive: true, force: true }).catch(err => - console.error(`Failed to delete temp directory ${tempDir}:`, err) - ); - } - - // Temp files cleaned - } catch (error) { - console.error('Error during temp file cleanup:', error); - } + const content = await buildClaudeUserContent(command, images, cwd); + return (async function* () { + yield { + type: 'user', + message: { + role: 'user', + content + }, + parent_tool_use_id: null, + timestamp: new Date().toISOString() + }; + })(); } /** @@ -518,8 +461,6 @@ async function queryClaudeSDK(command, options = {}, ws) { const { sessionId, sessionSummary } = options; let capturedSessionId = sessionId; let sessionCreatedSent = false; - let tempImagePaths = []; - let tempDir = null; const emitNotification = (event) => { notifyUserIfEnabled({ @@ -553,10 +494,10 @@ async function queryClaudeSDK(command, options = {}, ws) { sdkOptions.mcpServers = mcpServers; } - const imageResult = await handleImages(command, options.images, options.cwd); - const finalCommand = imageResult.modifiedCommand; - tempImagePaths = imageResult.tempImagePaths; - tempDir = imageResult.tempDir; + // Turns with image attachments switch to streaming input so the images + // ride along as real content blocks. Built per query attempt because an + // async generator cannot be replayed once consumed. + const createPrompt = () => buildPromptPayload(command, options.images, options.cwd); sdkOptions.hooks = { Notification: [{ @@ -663,7 +604,7 @@ async function queryClaudeSDK(command, options = {}, ws) { let queryInstance; try { queryInstance = query({ - prompt: finalCommand, + prompt: await createPrompt(), options: sdkOptions }); } catch (hookError) { @@ -672,7 +613,7 @@ async function queryClaudeSDK(command, options = {}, ws) { console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError); delete sdkOptions.hooks; queryInstance = query({ - prompt: finalCommand, + prompt: await createPrompt(), options: sdkOptions }); } @@ -686,7 +627,7 @@ async function queryClaudeSDK(command, options = {}, ws) { // Track the query instance for abort capability if (capturedSessionId) { - addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws); + addSession(capturedSessionId, queryInstance, ws); } // Process streaming messages @@ -696,7 +637,7 @@ async function queryClaudeSDK(command, options = {}, ws) { if (message.session_id && !capturedSessionId) { capturedSessionId = message.session_id; - addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws); + addSession(capturedSessionId, queryInstance, ws); // Set session ID on writer if (ws.setSessionId && typeof ws.setSessionId === 'function') { @@ -738,9 +679,6 @@ async function queryClaudeSDK(command, options = {}, ws) { removeSession(capturedSessionId); } - // Clean up temporary image files - await cleanupTempFiles(tempImagePaths, tempDir); - // Send the terminal completion event — skipped for aborted runs, whose // terminal `complete` (aborted: true) was already sent by abort-session. const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false; @@ -764,9 +702,6 @@ async function queryClaudeSDK(command, options = {}, ws) { removeSession(capturedSessionId); } - // Clean up temporary image files on error - await cleanupTempFiles(tempImagePaths, tempDir); - const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false; if (wasAborted) { // The abort already produced the terminal complete; a generator throw @@ -819,9 +754,6 @@ async function abortClaudeSDKSession(sessionId) { // Update session status session.status = 'aborted'; - // Clean up temporary image files - await cleanupTempFiles(session.tempImagePaths, session.tempDir); - // Clean up session removeSession(sessionId); diff --git a/server/cli.js b/server/cli.js index 0be3bf72..08d3af48 100755 --- a/server/cli.js +++ b/server/cli.js @@ -256,13 +256,11 @@ async function updatePackage() { const SANDBOX_TEMPLATES = { claude: 'docker.io/cloudcliai/sandbox:claude-code', codex: 'docker.io/cloudcliai/sandbox:codex', - gemini: 'docker.io/cloudcliai/sandbox:gemini', }; const SANDBOX_SECRETS = { claude: 'anthropic', codex: 'openai', - gemini: 'google', }; function parseSandboxArgs(args) { @@ -338,7 +336,7 @@ Subcommands: ${c.bright('help')} Show this help Options: - -a, --agent Agent to use: claude, codex, gemini (default: claude) + -a, --agent Agent to use: claude, codex (default: claude) -n, --name Sandbox name (default: derived from workspace folder) -t, --template Custom template image -e, --env Set environment variable (repeatable) @@ -359,7 +357,6 @@ Prerequisites: sbx login sbx secret set -g anthropic # for Claude sbx secret set -g openai # for Codex - sbx secret set -g google # for Gemini Advanced usage: For branch mode, multiple workspaces, memory limits, network policies, diff --git a/server/cursor-cli.js b/server/cursor-cli.js index 07fa1993..1c635630 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -1,13 +1,15 @@ -import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; + +import { appendImagesInputTag } from './shared/image-attachments.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { sessionsService } from './modules/providers/services/sessions.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; -import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; +import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js'; -// Use cross-spawn on Windows for better command execution -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to +// child_process.spawn everywhere else. +const spawnFunction = crossSpawn; let activeCursorProcesses = new Map(); // Track active processes by session ID @@ -28,7 +30,7 @@ function isWorkspaceTrustPrompt(text = '') { async function spawnCursor(command, options = {}, ws) { return new Promise(async (resolve, reject) => { - const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options; + const { sessionId, projectPath, cwd, toolsSettings, skipPermissions, model, sessionSummary, images } = options; const resolvedModel = await providerModelsService.resolveResumeModel('cursor', sessionId, model); let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event @@ -55,8 +57,12 @@ async function spawnCursor(command, options = {}, ws) { } if (command && command.trim()) { - // Provide a prompt (works for both new and resumed sessions) - baseArgs.push('-p', command); + // Provide a prompt (works for both new and resumed sessions). Image + // attachments ride along as an path list appended to the + // prompt; the session history reader strips the tag back out for display. + // cursor-agent is a .cmd shim on Windows, so the whole argument must be + // newline-free or cmd.exe silently truncates it at the first newline. + baseArgs.push('-p', flattenPromptForWindowsShell(appendImagesInputTag(command, images))); // Model overrides are applied to both new and resumed sessions so a // session-scoped change request can take effect on the next turn. @@ -71,7 +77,6 @@ async function spawnCursor(command, options = {}, ws) { // Add skip permissions flag if enabled if (skipPermissions || settings.skipPermissions) { baseArgs.push('-f'); - console.log('Using -f flag (skip permissions)'); } // Use cwd (actual project directory) instead of projectPath @@ -126,10 +131,6 @@ async function spawnCursor(command, options = {}, ws) { console.log('Retrying Cursor CLI with --trust after workspace trust prompt'); } - console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' ')); - console.log('Working directory:', workingDir); - console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume); - const cursorProcess = spawnFunction('cursor-agent', args, { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/server/gemini-cli.js b/server/gemini-cli.js deleted file mode 100644 index 72da84d9..00000000 --- a/server/gemini-cli.js +++ /dev/null @@ -1,638 +0,0 @@ -import { spawn } from 'child_process'; -import { promises as fs } from 'fs'; -import os from 'os'; -import path from 'path'; - -import crossSpawn from 'cross-spawn'; - -import sessionManager from './sessionManager.js'; -import GeminiResponseHandler from './gemini-response-handler.js'; -import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; -import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; -import { providerModelsService } from './modules/providers/services/provider-models.service.js'; -import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; - -// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; - -let activeGeminiProcesses = new Map(); // Track active processes by session ID - -function mapGeminiExitCodeToMessage(exitCode) { - switch (exitCode) { - case 42: - return 'Gemini rejected the request input (exit code 42).'; - case 44: - return 'Gemini sandbox error (exit code 44). Check local sandbox/container settings.'; - case 52: - return 'Gemini configuration error (exit code 52). Check your Gemini settings files for invalid JSON/config.'; - case 53: - return 'Gemini conversation turn limit reached (exit code 53). Start a new Gemini session.'; - default: - return null; - } -} - -const GEMINI_AUTH_ENV_KEYS = [ - 'GEMINI_API_KEY', - 'GOOGLE_API_KEY', - 'GOOGLE_CLOUD_PROJECT', - 'GOOGLE_CLOUD_PROJECT_ID', - 'GOOGLE_CLOUD_LOCATION', - 'GOOGLE_APPLICATION_CREDENTIALS' -]; - -function parseEnvFileContent(content) { - const parsed = {}; - - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) { - continue; - } - - const exportPrefix = 'export '; - const normalizedLine = line.startsWith(exportPrefix) ? line.slice(exportPrefix.length).trim() : line; - const separatorIndex = normalizedLine.indexOf('='); - - if (separatorIndex <= 0) { - continue; - } - - const key = normalizedLine.slice(0, separatorIndex).trim(); - if (!key) { - continue; - } - - let value = normalizedLine.slice(separatorIndex + 1).trim(); - const hasDoubleQuotes = value.startsWith('"') && value.endsWith('"'); - const hasSingleQuotes = value.startsWith('\'') && value.endsWith('\''); - - if (hasDoubleQuotes || hasSingleQuotes) { - value = value.slice(1, -1); - } else { - // Support inline comments in unquoted values: KEY=value # comment - value = value.replace(/\s+#.*$/, '').trim(); - } - - parsed[key] = value; - } - - return parsed; -} - -async function loadGeminiUserLevelEnv() { - const geminiCliHome = (process.env.GEMINI_CLI_HOME || '').trim() || os.homedir(); - const envCandidates = [ - path.join(geminiCliHome, '.gemini', '.env'), - path.join(geminiCliHome, '.env') - ]; - - for (const envPath of envCandidates) { - try { - await fs.access(envPath); - const content = await fs.readFile(envPath, 'utf8'); - return parseEnvFileContent(content); - } catch { - // Keep scanning for the next candidate. - } - } - - return {}; -} - -async function buildGeminiProcessEnv() { - const processEnv = { ...process.env }; - if (processEnv.GEMINI_API_KEY || processEnv.GOOGLE_API_KEY || processEnv.GOOGLE_APPLICATION_CREDENTIALS) { - return processEnv; - } - - // Gemini CLI docs recommend ~/.gemini/.env for persistent headless auth settings. - // When the server process was launched without shell profile variables, we still - // want the spawned CLI process to inherit those user-level credentials. - const userEnv = await loadGeminiUserLevelEnv(); - for (const key of GEMINI_AUTH_ENV_KEYS) { - if (!processEnv[key] && userEnv[key]) { - processEnv[key] = userEnv[key]; - } - } - - return processEnv; -} - -async function spawnGemini(command, options = {}, ws) { - const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options; - const resolvedModel = await providerModelsService.resolveResumeModel( - 'gemini', - sessionId, - options.model - ); - 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 - // Unified lifecycle contract: exactly one terminal `complete` per run - // (close and error handlers can both fire for spawn failures). - let completeSent = false; - - // 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'); - } - - // This integration runs Gemini in headless mode and cannot answer trust prompts. - // Skip folder-trust interactivity so authenticated runs don't fail with - // FatalUntrustedWorkspaceError in previously unseen directories. - args.push('--skip-trust'); - - // 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 = resolvedModel || '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'; - 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]; - } - - const spawnEnv = await buildGeminiProcessEnv(); - - return new Promise((resolve, reject) => { - const geminiProcess = spawnFunction(spawnCmd, spawnArgs, { - cwd: workingDir, - stdio: ['pipe', 'pipe', 'pipe'], - env: spawnEnv - }); - let terminalNotificationSent = false; - let terminalFailureReason = null; - - const notifyTerminalState = ({ code = null, error = null } = {}) => { - if (terminalNotificationSent) { - return; - } - - terminalNotificationSent = true; - - const finalSessionId = capturedSessionId || sessionId || processKey; - if (code === 0 && !error) { - notifyRunStopped({ - userId: ws?.userId || null, - provider: 'gemini', - sessionId: finalSessionId, - sessionName: sessionSummary, - stopReason: 'completed' - }); - return; - } - - notifyRunFailed({ - userId: ws?.userId || null, - provider: 'gemini', - sessionId: finalSessionId, - sessionName: sessionSummary, - error: error || terminalFailureReason || `Gemini CLI exited with code ${code}` - }); - }; - - // 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 - 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); - terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`; - ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); - 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) => { - const discoveredSessionId = event?.session_id; - if (!discoveredSessionId) { - return; - } - - // New Gemini sessions announce their canonical ID asynchronously via the - // initial `init` stream event. Avoid synthetic IDs and only register - // the session once that real ID is known (same model used by Claude/Codex). - if (!capturedSessionId) { - capturedSessionId = discoveredSessionId; - - sessionManager.createSession(capturedSessionId, cwd || process.cwd()); - if (command) { - sessionManager.addMessage(capturedSessionId, 'user', command); - } - - if (processKey !== capturedSessionId) { - activeGeminiProcesses.delete(processKey); - activeGeminiProcesses.set(capturedSessionId, geminiProcess); - } - - geminiProcess.sessionId = capturedSessionId; - - if (ws.setSessionId && typeof ws.setSessionId === 'function') { - ws.setSessionId(capturedSessionId); - } - - if (!sessionId && !sessionCreatedSent) { - sessionCreatedSent = true; - ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' })); - } - } - - const sess = sessionManager.getSession(capturedSessionId); - if (sess && !sess.cliSessionId) { - sess.cliSessionId = discoveredSessionId; - sessionManager.saveSession(capturedSessionId); - } - } - }); - } - - // Handle stdout - geminiProcess.stdout.on('data', (data) => { - const rawOutput = data.toString(); - startTimeout(); // Re-arm the timeout - - 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(createNormalizedMessage({ kind: 'stream_delta', content: rawOutput, sessionId: socketSessionId, provider: 'gemini' })); - } - }); - - // 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(createNormalizedMessage({ kind: 'error', content: errorMsg, sessionId: socketSessionId, provider: 'gemini' })); - }); - - // 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); - } - - // Terminal complete — skipped for aborted runs (abort-session - // already sent the aborted complete on this run's behalf). - if (!completeSent && !geminiProcess.aborted) { - completeSent = true; - ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code })); - } - - // 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) { - notifyTerminalState({ code }); - resolve(); - } else { - const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; - - // code 127 = shell "command not found" - check installation - if (code === 127) { - const installed = await providerAuthService.isProviderInstalled('gemini'); - if (!installed) { - terminalFailureReason = 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'; - ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); - } - } else if (code === 41) { - // Gemini CLI documents exit code 41 as FatalAuthenticationError. - // Surface an actionable auth error instead of a generic exit-code message. - let authErrorSuffix = ''; - try { - const authStatus = await providerAuthService.getProviderAuthStatus('gemini'); - if (!authStatus?.authenticated && authStatus?.error) { - authErrorSuffix = ` Details: ${authStatus.error}`; - } - } catch { - // Keep base remediation text when auth status lookup fails. - } - - terminalFailureReason = - 'Gemini authentication failed (exit code 41). ' - + 'Run `gemini` in a terminal to choose an auth method, or configure a valid `GEMINI_API_KEY`.' - + authErrorSuffix; - ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); - } else { - const mappedError = mapGeminiExitCodeToMessage(code); - if (mappedError) { - terminalFailureReason = mappedError; - ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); - } - } - - notifyTerminalState({ - code, - error: code === null ? 'Gemini CLI process was terminated or timed out' : null - }); - reject( - new Error( - terminalFailureReason - || (code === null - ? 'Gemini CLI process was terminated or timed out' - : `Gemini CLI exited with code ${code}`) - ) - ); - } - }); - - // Handle process errors - geminiProcess.on('error', async (error) => { - // Clean up process reference on error - const finalSessionId = capturedSessionId || sessionId || processKey; - activeGeminiProcesses.delete(finalSessionId); - - // Check if Gemini CLI is installed for a clearer error message - const installed = await providerAuthService.isProviderInstalled('gemini'); - const errorContent = !installed - ? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli' - : error.message; - - const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; - ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' })); - if (!completeSent && !geminiProcess.aborted) { - completeSent = true; - ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 })); - } - notifyTerminalState({ error }); - - 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 { - // The abort handler sends the terminal complete (aborted: true); - // flag the process so its close handler does not emit a second one. - geminiProc.aborted = true; - geminiProc.kill('SIGTERM'); - setTimeout(() => { - if (activeGeminiProcesses.has(processKey)) { - try { - geminiProc.kill('SIGKILL'); - } catch (e) { } - } - }, 2000); // Wait 2 seconds before force kill - - return true; - } catch (error) { - return false; - } - } - return false; -} - -function isGeminiSessionActive(sessionId) { - return activeGeminiProcesses.has(sessionId); -} - -function getActiveGeminiSessions() { - return Array.from(activeGeminiProcesses.keys()); -} - -export { - spawnGemini, - abortGeminiSession, - isGeminiSessionActive, - getActiveGeminiSessions -}; diff --git a/server/gemini-response-handler.js b/server/gemini-response-handler.js deleted file mode 100644 index 6f314443..00000000 --- a/server/gemini-response-handler.js +++ /dev/null @@ -1,117 +0,0 @@ -// Gemini Response Handler - JSON Stream processing -import { sessionsService } from './modules/providers/services/sessions.service.js'; -import { createNormalizedMessage } from './shared/utils.js'; - -function buildGeminiTokenBudget(tokens) { - if (!tokens || typeof tokens !== 'object') { - return null; - } - - const parsedInputTokens = Number(tokens.input); - const parsedOutputTokens = Number(tokens.output); - const inputTokens = Number.isFinite(parsedInputTokens) ? parsedInputTokens : 0; - const outputTokens = Number.isFinite(parsedOutputTokens) ? parsedOutputTokens : 0; - const parsedUsed = Number(tokens.total); - const used = Number.isFinite(parsedUsed) ? parsedUsed : inputTokens + outputTokens; - if (!Number.isFinite(used) || used <= 0) { - return null; - } - - return { - used, - inputTokens, - outputTokens, - breakdown: { - input: inputTokens, - output: outputTokens, - }, - }; -} - -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 - } - } - } - - handleEvent(event) { - const sid = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null; - - if (event.type === 'init') { - if (this.onInit) { - this.onInit(event); - } - return; - } - - // Invoke per-type callbacks for session tracking - if (event.type === 'message' && event.role === 'assistant') { - const content = event.content || ''; - if (this.onContentFragment && content) { - this.onContentFragment(content); - } - } else if (event.type === 'tool_use' && this.onToolUse) { - this.onToolUse(event); - } else if (event.type === 'tool_result' && this.onToolResult) { - this.onToolResult(event); - } - - // Normalize via adapter and send all resulting messages - const normalized = sessionsService.normalizeMessage('gemini', event, sid); - for (const msg of normalized) { - this.ws.send(msg); - } - - const tokenBudget = buildGeminiTokenBudget(event.tokens); - if (tokenBudget) { - this.ws.send(createNormalizedMessage({ - kind: 'status', - text: 'token_budget', - tokenBudget, - sessionId: sid, - provider: 'gemini', - })); - } - } - - forceFlush() { - if (this.buffer.trim()) { - try { - const event = JSON.parse(this.buffer); - this.handleEvent(event); - } catch (err) { } - } - } - - destroy() { - this.buffer = ''; - } -} - -export default GeminiResponseHandler; diff --git a/server/index.js b/server/index.js index 679645a9..0832c490 100755 --- a/server/index.js +++ b/server/index.js @@ -5,8 +5,10 @@ import fs, { promises as fsPromises } from 'fs'; import path from 'path'; import os from 'os'; import http from 'http'; -import { spawn } from 'child_process'; +// cross-spawn is a drop-in for child_process.spawn that resolves .cmd +// shims/PATHEXT on Windows and delegates to the native spawn elsewhere. +import spawn from 'cross-spawn'; import express from 'express'; import cors from 'cors'; import mime from 'mime-types'; @@ -33,15 +35,10 @@ import { queryCodex, abortCodexSession, } from './openai-codex.js'; -import { - spawnGemini, - abortGeminiSession, -} from './gemini-cli.js'; import { spawnOpenCode, abortOpenCodeSession, } from './opencode-cli.js'; -import sessionManager from './sessionManager.js'; import { stripAnsiSequences, normalizeDetectedUrl, @@ -59,11 +56,11 @@ import agentRoutes from './routes/agent.js'; import projectModuleRoutes from './modules/projects/projects.routes.js'; import notificationRoutes from './modules/notifications/notifications.routes.js'; import userRoutes from './routes/user.js'; -import geminiRoutes from './routes/gemini.js'; import pluginsRoutes from './routes/plugins.js'; import providerRoutes from './modules/providers/provider.routes.js'; import voiceRoutes from './voice-proxy.js'; import browserUseRoutes from './modules/browser-use/browser-use.routes.js'; +import { assetsRoutes } from './modules/assets/index.js'; import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js'; import { browserUseService } from './modules/browser-use/browser-use.service.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; @@ -116,14 +113,12 @@ const wss = createWebSocketServer(server, { claude: queryClaudeSDK, cursor: spawnCursor, codex: queryCodex, - gemini: spawnGemini, opencode: spawnOpenCode, }, abortFns: { claude: abortClaudeSDKSession, cursor: abortCursorSession, codex: abortCodexSession, - gemini: abortGeminiSession, opencode: abortOpenCodeSession, }, resolveToolApproval, @@ -132,14 +127,11 @@ const wss = createWebSocketServer(server, { shell: { resolveProviderSessionId: (sessionId, provider) => { const dbSession = sessionsDb.getSessionById(sessionId); - const legacyGeminiSession = - provider === 'gemini' ? sessionManager.getSession(sessionId) : null; - if (dbSession) { - return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null; + return dbSession.provider_session_id ?? null; } - return legacyGeminiSession?.cliSessionId; + return null; }, stripAnsiSequences, normalizeDetectedUrl, @@ -185,6 +177,9 @@ app.use('/api/auth', authRoutes); // Projects API Routes (protected) app.use('/api/projects', authenticateToken, projectModuleRoutes); +// Chat image asset upload/serving (global ~/.cloudcli/assets store, protected) +app.use('/api/assets', authenticateToken, assetsRoutes); + // Git API Routes (protected) app.use('/api/git', authenticateToken, gitRoutes); @@ -208,9 +203,6 @@ app.use('/api/notifications', authenticateToken, notificationRoutes); // User API Routes (protected) app.use('/api/user', authenticateToken, userRoutes); -// Gemini API Routes (protected) -app.use('/api/gemini', authenticateToken, geminiRoutes); - // Plugins API Routes (protected) app.use('/api/plugins', authenticateToken, pluginsRoutes); @@ -1072,92 +1064,8 @@ const uploadFilesHandler = async (req, res) => { app.post('/api/projects/:projectId/files/upload', authenticateToken, uploadFilesHandler); -// Image upload endpoint. Accepts the DB-assigned `projectId` (not a folder name) -// but the current implementation doesn't need to touch the project directory, -// so we just leave the param rename for consistency with the rest of the API. -app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req, res) => { - try { - const multer = (await import('multer')).default; - const path = (await import('path')).default; - const fs = (await import('fs')).promises; - const os = (await import('os')).default; - - // Configure multer for image uploads - const storage = multer.diskStorage({ - destination: async (req, file, cb) => { - const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id)); - await fs.mkdir(uploadDir, { recursive: true }); - cb(null, uploadDir); - }, - filename: (req, file, cb) => { - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); - cb(null, uniqueSuffix + '-' + sanitizedName); - } - }); - - const fileFilter = (req, file, cb) => { - const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; - if (allowedMimes.includes(file.mimetype)) { - cb(null, true); - } else { - cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.')); - } - }; - - const upload = multer({ - storage, - fileFilter, - limits: { - fileSize: 5 * 1024 * 1024, // 5MB - files: 5 - } - }); - - // Handle multipart form data - upload.array('images', 5)(req, res, async (err) => { - if (err) { - return res.status(400).json({ error: err.message }); - } - - if (!req.files || req.files.length === 0) { - return res.status(400).json({ error: 'No image files provided' }); - } - - try { - // Process uploaded images - const processedImages = await Promise.all( - req.files.map(async (file) => { - // Read file and convert to base64 - const buffer = await fs.readFile(file.path); - const base64 = buffer.toString('base64'); - const mimeType = file.mimetype; - - // Clean up temp file immediately - await fs.unlink(file.path); - - return { - name: file.originalname, - data: `data:${mimeType};base64,${base64}`, - size: file.size, - mimeType: mimeType - }; - }) - ); - - res.json({ images: processedImages }); - } catch (error) { - console.error('Error processing images:', error); - // Clean up any remaining files - await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { }))); - res.status(500).json({ error: 'Failed to process images' }); - } - }); - } catch (error) { - console.error('Error in image upload endpoint:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); +// Chat image uploads moved to POST /api/assets/images (server/modules/assets), +// which stores them in the global ~/.cloudcli/assets folder. // Get token usage for a specific session. `projectId` is the DB primary key; // the Claude branch below resolves it to an absolute path via the DB. @@ -1197,62 +1105,6 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate }); } - if (provider === 'gemini') { - const session = sessionsDb.getSessionById(safeSessionId); - const sessionFilePath = session?.jsonl_path; - if (!sessionFilePath) { - return res.json({ - used: 0, - inputTokens: 0, - outputTokens: 0, - breakdown: { input: 0, output: 0 }, - unsupported: true, - message: 'Token usage tracking not available for this Gemini session' - }); - } - - 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 inputTokens = 0; - let outputTokens = 0; - let totalTokens = 0; - - for (let i = lines.length - 1; i >= 0; i--) { - try { - const entry = JSON.parse(lines[i]); - if (!entry.tokens || typeof entry.tokens !== 'object') { - continue; - } - - inputTokens = Number(entry.tokens.input || 0); - outputTokens = Number(entry.tokens.output || 0); - totalTokens = Number(entry.tokens.total || inputTokens + outputTokens || 0); - break; - } catch { - continue; - } - } - - return res.json({ - used: totalTokens, - inputTokens, - outputTokens, - breakdown: { - input: inputTokens, - output: outputTokens - } - }); - } - if (provider === 'opencode') { const dbPath = getOpenCodeDatabasePath(); if (!fs.existsSync(dbPath)) { diff --git a/server/modules/assets/assets.routes.ts b/server/modules/assets/assets.routes.ts new file mode 100644 index 00000000..f0417c92 --- /dev/null +++ b/server/modules/assets/assets.routes.ts @@ -0,0 +1,103 @@ +import fsSync, { promises as fs } from 'node:fs'; + +import express from 'express'; +import mime from 'mime-types'; +import multer from 'multer'; + +import { + buildStoredImageRecords, + ensureImageAssetsDir, + isAllowedImageMimeType, + resolveImageAssetFile, +} from '@/modules/assets/services/image-assets.service.js'; + +const router = express.Router(); + +// Multer writes uploads straight into the global assets folder; the service +// owns the folder location and the response record shape. +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + ensureImageAssetsDir() + .then((assetsDir) => cb(null, assetsDir)) + .catch((error) => cb(error as Error, '')); + }, + filename: (req, file, cb) => { + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); + cb(null, `${uniqueSuffix}-${sanitizedName}`); + }, +}); + +const upload = multer({ + storage, + fileFilter: (req, file, cb) => { + if (isAllowedImageMimeType(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.')); + } + }, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB + files: 5, + }, +}); + +/** + * Stores chat image attachments in the global `~/.cloudcli/assets` folder and + * returns their absolute paths for use in provider prompts and chat history. + */ +router.post('/images', (req, res) => { + upload.array('images', 5)(req, res, (err: unknown) => { + if (err) { + const message = err instanceof Error ? err.message : 'Upload failed'; + return res.status(400).json({ error: message }); + } + + const files = Array.isArray(req.files) ? req.files : []; + if (files.length === 0) { + return res.status(400).json({ error: 'No image files provided' }); + } + + res.json({ images: buildStoredImageRecords(files) }); + }); +}); + +/** + * Serves one stored image asset by filename. Only files directly inside the + * global assets folder are reachable; traversal attempts resolve to null. + */ +router.get('/images/:filename', async (req, res) => { + const resolved = resolveImageAssetFile(req.params.filename); + if (!resolved) { + return res.status(400).json({ error: 'Invalid asset filename' }); + } + + try { + await fs.access(resolved); + } catch { + return res.status(404).json({ error: 'Asset not found' }); + } + + const contentType = mime.lookup(resolved) || 'application/octet-stream'; + res.setHeader('Content-Type', contentType); + // Stored-XSS hardening: never let the browser sniff a different type, and + // force SVGs (which can carry scripts when rendered as a document) to + // download instead of rendering inline. The chat UI is unaffected — it + // fetches assets as blobs and shows them through , where SVG scripts + // never execute. + res.setHeader('X-Content-Type-Options', 'nosniff'); + if (contentType === 'image/svg+xml') { + res.setHeader('Content-Disposition', 'attachment'); + } + const fileStream = fsSync.createReadStream(resolved); + fileStream.pipe(res); + fileStream.on('error', (error) => { + console.error('Error streaming image asset:', error); + if (!res.headersSent) { + res.status(500).json({ error: 'Error reading asset' }); + } + }); +}); + +export default router; diff --git a/server/modules/assets/index.ts b/server/modules/assets/index.ts new file mode 100644 index 00000000..a4747cf1 --- /dev/null +++ b/server/modules/assets/index.ts @@ -0,0 +1,3 @@ +// Express router mounted at /api/assets by server/index.js (upload + serving +// of chat image attachments stored in the global ~/.cloudcli/assets folder). +export { default as assetsRoutes } from './assets.routes.js'; diff --git a/server/modules/assets/services/image-assets.service.ts b/server/modules/assets/services/image-assets.service.ts new file mode 100644 index 00000000..a282622f --- /dev/null +++ b/server/modules/assets/services/image-assets.service.ts @@ -0,0 +1,82 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { getGlobalImageAssetsDir, toPosixPath } from '@/shared/image-attachments.js'; + +/** + * Image mime types accepted for chat attachment uploads. SVG is allowed for + * storage/preview even though some providers (Claude API) skip it at send time. + */ +const ALLOWED_IMAGE_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', +]); + +// Used only by this service and the assets routes via the barrel file. +type StoredImageAsset = { + /** Original upload filename, for display. */ + name: string; + /** Absolute posix-normalized path inside the global assets folder. */ + path: string; + size: number; + mimeType: string; +}; + +// Shape of one multer-stored file; kept local because only this module reads it. +type UploadedImageFile = { + originalname: string; + filename: string; + size: number; + mimetype: string; +}; + +/** Returns whether one uploaded mime type may be stored as a chat image asset. */ +export function isAllowedImageMimeType(mimeType: string): boolean { + return ALLOWED_IMAGE_MIME_TYPES.has(mimeType); +} + +/** Creates the global `~/.cloudcli/assets` folder if needed and returns it. */ +export async function ensureImageAssetsDir(): Promise { + const assetsDir = getGlobalImageAssetsDir(); + await fs.mkdir(assetsDir, { recursive: true }); + return assetsDir; +} + +/** + * Maps multer-stored upload files to the attachment records returned to the + * chat composer. The absolute path is what providers receive and what session + * history carries back to the UI. + */ +export function buildStoredImageRecords(files: UploadedImageFile[]): StoredImageAsset[] { + const assetsDir = getGlobalImageAssetsDir(); + return files.map((file) => ({ + name: file.originalname, + path: toPosixPath(path.join(assetsDir, file.filename)), + size: file.size, + mimeType: file.mimetype, + })); +} + +/** + * Resolves one asset filename to its absolute path inside the global assets + * folder, or null when the name is empty, contains path separators/traversal, + * or would escape the folder. This is the only lookup the serving route uses, + * so nothing outside `~/.cloudcli/assets` can ever be read through it. + */ +export function resolveImageAssetFile(filename: string): string | null { + const trimmed = typeof filename === 'string' ? filename.trim() : ''; + if (!trimmed || trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) { + return null; + } + + const assetsDir = path.resolve(getGlobalImageAssetsDir()); + const resolved = path.resolve(assetsDir, trimmed); + if (!resolved.startsWith(assetsDir + path.sep)) { + return null; + } + + return resolved; +} diff --git a/server/modules/assets/tests/image-assets.service.test.ts b/server/modules/assets/tests/image-assets.service.test.ts new file mode 100644 index 00000000..e4721c7a --- /dev/null +++ b/server/modules/assets/tests/image-assets.service.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + buildStoredImageRecords, + isAllowedImageMimeType, + resolveImageAssetFile, +} from '@/modules/assets/services/image-assets.service.js'; + +const ASSETS_DIR = path.join(os.homedir(), '.cloudcli', 'assets'); + +test('isAllowedImageMimeType accepts image formats and rejects the rest', () => { + assert.equal(isAllowedImageMimeType('image/png'), true); + assert.equal(isAllowedImageMimeType('image/svg+xml'), true); + assert.equal(isAllowedImageMimeType('application/pdf'), false); + assert.equal(isAllowedImageMimeType('text/html'), false); +}); + +test('buildStoredImageRecords returns absolute posix paths in the assets dir', () => { + const records = buildStoredImageRecords([ + { originalname: 'shot.png', filename: '123-456-shot.png', size: 42, mimetype: 'image/png' }, + ]); + + assert.equal(records.length, 1); + assert.equal(records[0].name, 'shot.png'); + assert.equal(records[0].size, 42); + assert.equal(records[0].mimeType, 'image/png'); + assert.equal(records[0].path, `${ASSETS_DIR.replace(/\\/g, '/')}/123-456-shot.png`); +}); + +test('resolveImageAssetFile resolves plain filenames inside the assets dir', () => { + const resolved = resolveImageAssetFile('123-shot.png'); + assert.equal(resolved, path.join(path.resolve(ASSETS_DIR), '123-shot.png')); +}); + +test('resolveImageAssetFile rejects traversal and separator attempts', () => { + assert.equal(resolveImageAssetFile(''), null); + assert.equal(resolveImageAssetFile(' '), null); + assert.equal(resolveImageAssetFile('../auth.db'), null); + assert.equal(resolveImageAssetFile('..'), null); + assert.equal(resolveImageAssetFile('sub/dir.png'), null); + assert.equal(resolveImageAssetFile('sub\\dir.png'), null); + assert.equal(resolveImageAssetFile('a..b/../c.png'), null); +}); diff --git a/server/modules/browser-use/browser-use.service.ts b/server/modules/browser-use/browser-use.service.ts index 280ff730..3bda8e78 100644 --- a/server/modules/browser-use/browser-use.service.ts +++ b/server/modules/browser-use/browser-use.service.ts @@ -1,10 +1,12 @@ import { createRequire } from 'node:module'; import { randomBytes, randomUUID } from 'node:crypto'; -import { spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; + import { appConfigDb } from '@/modules/database/index.js'; import { providerMcpService } from '@/modules/providers/index.js'; import { getModuleDir } from '@/utils/runtime-paths.js'; @@ -270,8 +272,10 @@ function runCommand(command: string, args: string[]): Promise { }, INSTALL_COMMAND_TIMEOUT_MS); timer.unref?.(); - child.stdout.on('data', (chunk) => output.push(String(chunk))); - child.stderr.on('data', (chunk) => output.push(String(chunk))); + // stdio config above guarantees the pipes exist; cross-spawn's types + // just don't narrow them the way node's spawn overloads do. + child.stdout?.on('data', (chunk) => output.push(String(chunk))); + child.stderr?.on('data', (chunk) => output.push(String(chunk))); child.on('error', (error) => finish(() => reject(error))); child.on('close', (code) => finish(() => { if (code === 0) { diff --git a/server/modules/database/tests/sessions-provider-mapping.test.ts b/server/modules/database/tests/sessions-provider-mapping.test.ts index a9d91478..45e4a411 100644 --- a/server/modules/database/tests/sessions-provider-mapping.test.ts +++ b/server/modules/database/tests/sessions-provider-mapping.test.ts @@ -100,9 +100,9 @@ test('assignProviderSessionId merges a watcher-created duplicate into the app ro test('legacy provider-keyed rows stay resolvable through both lookups', async () => { await withIsolatedDatabase(() => { - sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo'); + sessionsDb.createSession('legacy-1', 'opencode', '/workspace/demo'); - assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini'); + assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'opencode'); assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1'); }); }); diff --git a/server/modules/notifications/services/notification-orchestrator.service.js b/server/modules/notifications/services/notification-orchestrator.service.js index b9a1c6ee..ad700f1b 100644 --- a/server/modules/notifications/services/notification-orchestrator.service.js +++ b/server/modules/notifications/services/notification-orchestrator.service.js @@ -13,7 +13,6 @@ const PROVIDER_LABELS = { claude: 'Claude', cursor: 'Cursor', codex: 'Codex', - gemini: 'Gemini', system: 'System' }; diff --git a/server/modules/projects/services/project-clone.service.ts b/server/modules/projects/services/project-clone.service.ts index 1a91b879..4211f560 100644 --- a/server/modules/projects/services/project-clone.service.ts +++ b/server/modules/projects/services/project-clone.service.ts @@ -1,7 +1,9 @@ -import { spawn } from 'node:child_process'; import { access, mkdir, rm } from 'node:fs/promises'; import path from 'node:path'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; + import { githubTokensDb } from '@/modules/database/index.js'; import { createProject } from '@/modules/projects/services/project-management.service.js'; import type { WorkspacePathValidationResult } from '@/shared/types.js'; diff --git a/server/modules/providers/README.md b/server/modules/providers/README.md index 32b862c8..a7f87ffa 100644 --- a/server/modules/providers/README.md +++ b/server/modules/providers/README.md @@ -36,7 +36,6 @@ Current provider ids in this repo are: - `claude` - `codex` - `cursor` -- `gemini` - `opencode` Those ids are mirrored in backend unions and frontend provider constants. If @@ -56,8 +55,7 @@ server/modules/providers/list// -session-synchronizer.provider.ts ``` -The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and -`opencode`. +The existing provider folders are `claude`, `codex`, `cursor`, and `opencode`. ## What Each Facet Does @@ -123,7 +121,6 @@ Current MCP formats in this repo are: | Claude | `.mcp.json` in user / local / project locations | `user`, `local`, `project` | `stdio`, `http`, `sse` | | Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` | | Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` | -| Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` | | OpenCode | `~/.config/opencode/opencode.json` or `/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` | 5. Implement skills. @@ -144,7 +141,6 @@ Current skill discovery roots are: | Claude | `~/.claude/skills` | `/.claude/skills` | `/` | Also discovers Claude plugin skills from enabled plugin installs. Command skills live under `commands/`; markdown skills live under `skills/` and are scanned recursively. | | Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. | | Cursor | `~/.cursor/skills` | `/.cursor/skills`, `/.agents/skills` | `/` | Uses slash-style commands. | -| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `/.gemini/skills`, `/.agents/skills` | `/` | Uses slash-style commands. | | OpenCode | `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | Cwd-to-topmost-git-root `.opencode/skills`, `.claude/skills`, and `.agents/skills` | `/` | Reuses OpenCode, Claude, and Agents skill locations. Overlapping roots are deduplicated before scanning. | Command forms currently used by the providers are: @@ -153,7 +149,6 @@ Command forms currently used by the providers are: - Claude plugin skills: `/plugin-name:skill-name` - Codex skills: `$skill-name` - Cursor skills: `/skill-name` -- Gemini skills: `/skill-name` - OpenCode skills: `/skill-name` 6. Implement sessions. @@ -191,7 +186,6 @@ Current session sync roots are: | Claude | `~/.claude/projects/**/*.jsonl` | Uses `~/.claude/history.jsonl` for name lookup and the trailing `ai-title`, `last-prompt`, or `custom-title` entries for title recovery. | | Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. | | Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. | -| Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. | | OpenCode | `~/.local/share/opencode/opencode.db` | Reads active sessions/messages/parts from OpenCode's shared SQLite database and stores `jsonl_path` as `null` so deleting one app session cannot remove the shared DB. | 8. Register the provider. diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index 0c7c27c2..8cdf44fa 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -313,6 +313,18 @@ export class ClaudeSessionsProvider implements IProviderSessions { if (raw.message?.role === 'user' && raw.message?.content && raw.isMeta !== true) { if (Array.isArray(raw.message.content)) { + // Image attachments sent through the SDK are persisted as base64 + // `image` blocks next to the prompt text. Collect them so the UI can + // render them on the user bubble. + const imageAttachments: Array<{ data: string }> = []; + for (const part of raw.message.content) { + if (part?.type === 'image' && part.source?.type === 'base64' && typeof part.source.data === 'string') { + const mediaType = typeof part.source.media_type === 'string' ? part.source.media_type : 'image/png'; + imageAttachments.push({ data: `data:${mediaType};base64,${part.source.data}` }); + } + } + let imagesAttached = false; + for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) { const part = raw.message.content[partIndex]; if (part.type === 'tool_result') { @@ -339,7 +351,9 @@ export class ClaudeSessionsProvider implements IProviderSessions { kind: 'text', role: 'user', content: text, + images: !imagesAttached && imageAttachments.length > 0 ? imageAttachments : undefined, })); + imagesAttached = true; } } } @@ -359,9 +373,25 @@ export class ClaudeSessionsProvider implements IProviderSessions { kind: 'text', role: 'user', content: textParts, + images: imageAttachments.length > 0 ? imageAttachments : undefined, })); + imagesAttached = true; } } + + // Image-only turns still deserve a user bubble even without text. + if (!imagesAttached && imageAttachments.length > 0) { + messages.push(createNormalizedMessage({ + id: `${baseId}_images`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: '', + images: imageAttachments, + })); + } } else if (typeof raw.message.content === 'string') { const text = raw.message.content; diff --git a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts index 818d71c9..f6c79ff9 100644 --- a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +++ b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts @@ -133,7 +133,23 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { }; } - let sessionName = nameMap.get(parsed.sessionId); + // Sessions started by sending a message from cloudcli carry a distinct + // app-allocated session_id mapped to the provider id. For these we title the + // conversation from the first user message the user typed, instead of the + // generic "Untitled Codex Session" placeholder. Sessions discovered purely + // by indexing (session_id === provider_session_id) keep the existing + // thread_name/last-agent-message setup below. + const isAppCreated = + existingSession != null && + existingSession.provider_session_id != null && + existingSession.session_id !== existingSession.provider_session_id; + + let sessionName = isAppCreated + ? await this.extractFirstUserMessageFromStart(filePath) + : undefined; + if (!sessionName) { + sessionName = nameMap.get(parsed.sessionId); + } if (!sessionName) { sessionName = await this.extractLastAgentMessageFromEnd(filePath); } @@ -144,6 +160,49 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { }; } + /** + * Returns the first user message text in a Codex transcript, used to title + * app-created sessions from the prompt the user sent from cloudcli. + * + * Reads the `event_msg`/`user_message` payload rather than the raw + * `response_item` user turn so injected `` boilerplate is + * never mistaken for the user's prompt. + */ + private async extractFirstUserMessageFromStart(filePath: string): Promise { + try { + const content = await readFile(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + const data = parsed as Record; + const eventType = typeof data.type === 'string' ? data.type : undefined; + const payload = data.payload as Record | undefined; + const payloadType = typeof payload?.type === 'string' ? payload.type : undefined; + const message = typeof payload?.message === 'string' ? payload.message : undefined; + + if (eventType === 'event_msg' && payloadType === 'user_message' && message?.trim()) { + return message; + } + } + } catch { + // Ignore missing/unreadable files so sync can continue. + } + + return undefined; + } + private async extractLastAgentMessageFromEnd(filePath: string): Promise { try { const content = await readFile(filePath, 'utf8'); diff --git a/server/modules/providers/list/codex/codex-sessions.provider.ts b/server/modules/providers/list/codex/codex-sessions.provider.ts index d166d20c..64549c2c 100644 --- a/server/modules/providers/list/codex/codex-sessions.provider.ts +++ b/server/modules/providers/list/codex/codex-sessions.provider.ts @@ -2,6 +2,7 @@ import fsSync from 'node:fs'; import readline from 'node:readline'; import { sessionsDb } from '@/modules/database/index.js'; +import { toImageAttachments } from '@/shared/image-attachments.js'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; @@ -31,6 +32,42 @@ function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boole return typeof payload.message === 'string' && payload.message.trim().length > 0; } +/** + * Reads the image attachments Codex records on `user_message` events. + * Turns sent with `local_image` input items land in `local_images` as file + * paths (verified against real rollout JSONL); the `images` array can carry + * base64 data URLs, which are passed through as inline `data` attachments so + * the UI can preview them without a file lookup. + * + * Exported for tests. + */ +export function extractCodexUserImages( + payload: AnyRecord | null | undefined, +): Array<{ path?: string; data?: string }> | undefined { + if (!payload) { + return undefined; + } + + const candidates = [ + ...(Array.isArray(payload.local_images) ? payload.local_images : []), + ...(Array.isArray(payload.images) ? payload.images : []), + ]; + + const attachments: Array<{ path?: string; data?: string }> = []; + for (const entry of candidates) { + if (typeof entry !== 'string' || !entry.trim()) { + continue; + } + if (entry.startsWith('data:')) { + attachments.push({ data: entry }); + } else { + attachments.push(...toImageAttachments([entry])); + } + } + + return attachments.length > 0 ? attachments : undefined; +} + function extractCodexTextContent(content: unknown): string { if (!Array.isArray(content)) { return typeof content === 'string' ? content : ''; @@ -104,6 +141,7 @@ async function getCodexSessionMessages( role: 'user', content: entry.payload.message, }, + images: extractCodexUserImages(entry.payload as AnyRecord), }); } @@ -296,7 +334,8 @@ export class CodexSessionsProvider implements IProviderSessions { .filter(Boolean) .join('\n') : String(raw.message.content || ''); - if (!content.trim()) { + const rawImages = Array.isArray(raw.images) && raw.images.length > 0 ? raw.images : undefined; + if (!content.trim() && !rawImages) { return []; } return [createNormalizedMessage({ @@ -307,6 +346,7 @@ export class CodexSessionsProvider implements IProviderSessions { kind: 'text', role: 'user', content, + images: rawImages, })]; } diff --git a/server/modules/providers/list/cursor/cursor-models.provider.ts b/server/modules/providers/list/cursor/cursor-models.provider.ts index da8daaed..78105db3 100644 --- a/server/modules/providers/list/cursor/cursor-models.provider.ts +++ b/server/modules/providers/list/cursor/cursor-models.provider.ts @@ -1,7 +1,6 @@ import { access, readdir } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawn } from 'node:child_process'; import crossSpawn from 'cross-spawn'; @@ -446,11 +445,6 @@ export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = { label: "gpt-5.2-xhigh-fast", description: "GPT-5.2 Extra High Fast", }, - { - value: "gemini-3.1-pro", - label: "gemini-3.1-pro", - description: "Gemini 3.1 Pro", - }, { value: "gpt-5.4-mini-none", label: "gpt-5.4-mini-none", @@ -531,16 +525,6 @@ export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = { label: "gpt-5.1-high", description: "GPT-5.1 High", }, - { - value: "gemini-3-flash", - label: "gemini-3-flash", - description: "Gemini 3 Flash", - }, - { - value: "gemini-3.5-flash", - label: "gemini-3.5-flash", - description: "Gemini 3.5 Flash", - }, { value: "gpt-5.1-codex-mini-low", label: "gpt-5.1-codex-mini-low", @@ -589,7 +573,9 @@ type CursorModelRow = { const CURSOR_MODELS_TIMEOUT_MS = 10_000; const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats'); -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to +// child_process.spawn everywhere else. +const spawnFunction = crossSpawn; const ANSI_PATTERN = new RegExp( // eslint-disable-next-line no-control-regex '[\\u001B\\u009B][[\\]()#;?]*(?:' diff --git a/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts b/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts index d5ea9b3c..2b2fc5fe 100644 --- a/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +++ b/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts @@ -141,7 +141,13 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer { } const text = typeof data.message?.content?.[0]?.text === 'string' ? data.message.content[0].text : ''; - const firstLine = text.replace(/<\/?user_query>/g, '').trim().split('\n')[0]; + // Drop Cursor's `` prefix and `` tags + // so the session name comes from the actual first line the user typed. + const firstLine = text + .replace(/[\s\S]*?<\/timestamp>/g, '') + .replace(/<\/?user_query>/g, '') + .trim() + .split('\n')[0]; return { sessionId, diff --git a/server/modules/providers/list/cursor/cursor-sessions.provider.ts b/server/modules/providers/list/cursor/cursor-sessions.provider.ts index 307d9638..5c61c64a 100644 --- a/server/modules/providers/list/cursor/cursor-sessions.provider.ts +++ b/server/modules/providers/list/cursor/cursor-sessions.provider.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; +import { parseImagesInputTag } from '@/shared/image-attachments.js'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { @@ -24,7 +25,7 @@ type CursorJsonBlob = CursorDbBlob & { parsed: AnyRecord; }; -type CursorMessageBlob = { +export type CursorMessageBlob = { id: string; sequence: number; rowid: number; @@ -59,17 +60,42 @@ function unwrapUserQueryText(value: string, role: 'user' | 'assistant'): string return value; } - const normalized = value.trimStart(); + // Cursor wraps user turns as `\n`. + // Show only the `` content, trimmed so there are no blank lines + // at the top/bottom and the `` prefix is dropped entirely. const openTag = ''; const closeTag = ''; - if (!normalized.startsWith(openTag)) { - return value; + const openIndex = value.indexOf(openTag); + if (openIndex >= 0) { + const afterOpen = value.slice(openIndex + openTag.length); + const closeIndex = afterOpen.lastIndexOf(closeTag); + const inner = closeIndex >= 0 ? afterOpen.slice(0, closeIndex) : afterOpen; + return inner.trim(); } - const afterOpen = normalized.slice(openTag.length); - const closeIndex = afterOpen.lastIndexOf(closeTag); - const inner = closeIndex >= 0 ? afterOpen.slice(0, closeIndex) : afterOpen; - return inner.trim(); + // No `` wrapper: still strip a leading ``. + return value.replace(/^\s*[\s\S]*?<\/timestamp>\s*/, '').trim(); +} + +/** + * Unwraps one user-authored text payload and splits off the `` + * attachment block appended by the chat composer. Assistant text passes + * through untouched. + */ +function extractUserTextAndImages( + value: string, + role: 'user' | 'assistant', +): { text: string; images?: Array<{ path: string; name?: string }> } { + const unwrapped = unwrapUserQueryText(value, role); + if (role !== 'user') { + return { text: unwrapped }; + } + + const { text, attachments } = parseImagesInputTag(unwrapped); + return { + text, + images: attachments.length > 0 ? attachments : undefined, + }; } function normalizeToolId(value: unknown): string | null { @@ -401,8 +427,11 @@ export class CursorSessionsProvider implements IProviderSessions { /** * Converts Cursor SQLite message blobs into normalized messages and attaches * matching tool results to their tool_use entries. + * + * Public so tests can drive history normalization with synthetic blobs + * without needing a real Cursor store.db. */ - private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] { + normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] { const messages: NormalizedMessage[] = []; const toolUseMap = new Map(); const baseTime = Date.now(); @@ -442,7 +471,16 @@ export class CursorSessionsProvider implements IProviderSessions { text = unwrapUserQueryText(content.message.content, role); } } - if (text?.trim()) { + const { text: cleanText, images } = role === 'user' + ? (() => { + const parsed = parseImagesInputTag(text); + return { + text: parsed.text, + images: parsed.attachments.length > 0 ? parsed.attachments : undefined, + }; + })() + : { text, images: undefined }; + if (cleanText?.trim() || images) { messages.push(createNormalizedMessage({ id: baseId, sessionId, @@ -450,7 +488,8 @@ export class CursorSessionsProvider implements IProviderSessions { provider: PROVIDER, kind: 'text', role, - content: text, + content: cleanText, + images, sequence: blob.sequence, rowid: blob.rowid, })); @@ -502,8 +541,8 @@ export class CursorSessionsProvider implements IProviderSessions { } if (part?.type === 'text' && part?.text) { - const normalizedPartText = unwrapUserQueryText(part.text, role); - if (!normalizedPartText) { + const { text: normalizedPartText, images } = extractUserTextAndImages(part.text, role); + if (!normalizedPartText && !images) { continue; } messages.push(createNormalizedMessage({ @@ -514,6 +553,7 @@ export class CursorSessionsProvider implements IProviderSessions { kind: 'text', role, content: normalizedPartText, + images, sequence: blob.sequence, rowid: blob.rowid, })); @@ -553,8 +593,8 @@ export class CursorSessionsProvider implements IProviderSessions { && content.content.trim() && !isInternalCursorText(content.content) ) { - const normalizedText = unwrapUserQueryText(content.content, role); - if (!normalizedText) { + const { text: normalizedText, images } = extractUserTextAndImages(content.content, role); + if (!normalizedText && !images) { continue; } messages.push(createNormalizedMessage({ @@ -565,6 +605,7 @@ export class CursorSessionsProvider implements IProviderSessions { kind: 'text', role, content: normalizedText, + images, sequence: blob.sequence, rowid: blob.rowid, })); diff --git a/server/modules/providers/list/gemini/gemini-auth.provider.ts b/server/modules/providers/list/gemini/gemini-auth.provider.ts deleted file mode 100644 index c6897e74..00000000 --- a/server/modules/providers/list/gemini/gemini-auth.provider.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; - -import spawn from 'cross-spawn'; - -import type { IProviderAuth } from '@/shared/interfaces.js'; -import type { ProviderAuthStatus } from '@/shared/types.js'; -import { readObjectRecord, readOptionalString } from '@/shared/utils.js'; - -type GeminiCredentialsStatus = { - authenticated: boolean; - email: string | null; - method: string | null; - error?: string; -}; - -type GeminiAuthType = - | 'oauth-personal' - | 'gemini-api-key' - | 'vertex-ai' - | 'compute-default-credentials' - | 'gateway' - | 'cloud-shell' - | null; - -export class GeminiProviderAuth implements IProviderAuth { - /** - * Gemini CLI can override its home root via GEMINI_CLI_HOME. - * Use the same resolution so status checks match runtime behavior. - */ - private getGeminiCliHome(): string { - return process.env.GEMINI_CLI_HOME?.trim() || os.homedir(); - } - - /** - * Checks whether the Gemini CLI is available on this host. - */ - private checkInstalled(): boolean { - const cliPath = process.env.GEMINI_PATH || 'gemini'; - try { - spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 }); - return true; - } catch { - return false; - } - } - - /** - * Returns Gemini CLI installation and credential status. - */ - async getStatus(): Promise { - const installed = this.checkInstalled(); - - if (!installed) { - return { - installed, - provider: 'gemini', - authenticated: false, - email: null, - method: null, - error: 'Gemini CLI is not installed', - }; - } - - const credentials = await this.checkCredentials(); - - return { - installed, - provider: 'gemini', - authenticated: credentials.authenticated, - email: credentials.email, - method: credentials.method, - error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated', - }; - } - - /** - * Parses dotenv-style key/value pairs. - */ - private parseEnvFile(content: string): Record { - const parsed: Record = {}; - - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) { - continue; - } - - const normalizedLine = line.startsWith('export ') - ? line.slice('export '.length).trim() - : line; - const separatorIndex = normalizedLine.indexOf('='); - if (separatorIndex <= 0) { - continue; - } - - const key = normalizedLine.slice(0, separatorIndex).trim(); - if (!key) { - continue; - } - - let value = normalizedLine.slice(separatorIndex + 1).trim(); - const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')); - if (quoted) { - value = value.slice(1, -1); - } else { - value = value.replace(/\s+#.*$/, '').trim(); - } - - parsed[key] = value; - } - - return parsed; - } - - /** - * Loads user-level auth env in Gemini's "first file found" order. - */ - private async loadUserLevelAuthEnv(): Promise> { - const geminiCliHome = this.getGeminiCliHome(); - const envCandidates = [ - path.join(geminiCliHome, '.gemini', '.env'), - path.join(geminiCliHome, '.env'), - ]; - - for (const envPath of envCandidates) { - try { - const content = await readFile(envPath, 'utf8'); - return this.parseEnvFile(content); - } catch { - // Continue to the next fallback. - } - } - - return {}; - } - - /** - * Reads Gemini's selected auth type from settings.json when available. - */ - private async readSelectedAuthType(): Promise { - try { - const settingsPath = path.join(this.getGeminiCliHome(), '.gemini', 'settings.json'); - const content = await readFile(settingsPath, 'utf8'); - const settings = readObjectRecord(JSON.parse(content)); - const security = readObjectRecord(settings?.security); - const auth = readObjectRecord(security?.auth); - const selectedType = readOptionalString(auth?.selectedType); - if (!selectedType) { - return null; - } - - return selectedType as GeminiAuthType; - } catch { - return null; - } - } - - /** - * Checks Gemini credentials from API key env vars or local OAuth credential files. - */ - private async checkCredentials(): Promise { - if (process.env.GEMINI_API_KEY?.trim()) { - return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; - } - - const userEnv = await this.loadUserLevelAuthEnv(); - if (readOptionalString(userEnv.GEMINI_API_KEY)) { - return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; - } - - const selectedType = await this.readSelectedAuthType(); - if (selectedType === 'vertex-ai') { - const hasGoogleApiKey = Boolean( - process.env.GOOGLE_API_KEY?.trim() - || readOptionalString(userEnv.GOOGLE_API_KEY) - ); - const hasProject = Boolean( - process.env.GOOGLE_CLOUD_PROJECT?.trim() - || process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() - || readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT) - || readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT_ID) - ); - const hasLocation = Boolean( - process.env.GOOGLE_CLOUD_LOCATION?.trim() - || readOptionalString(userEnv.GOOGLE_CLOUD_LOCATION) - ); - const hasServiceAccount = Boolean( - process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim() - || readOptionalString(userEnv.GOOGLE_APPLICATION_CREDENTIALS) - ); - - if (hasGoogleApiKey || hasServiceAccount || (hasProject && hasLocation)) { - return { authenticated: true, email: 'Vertex AI Auth', method: 'vertex_ai' }; - } - - return { - authenticated: false, - email: null, - method: 'vertex_ai', - error: 'Gemini is set to Vertex AI, but required env vars are missing', - }; - } - - try { - const credsPath = path.join(this.getGeminiCliHome(), '.gemini', 'oauth_creds.json'); - const content = await readFile(credsPath, 'utf8'); - const creds = readObjectRecord(JSON.parse(content)) ?? {}; - const accessToken = readOptionalString(creds.access_token); - - if (!accessToken) { - return { - authenticated: false, - email: null, - method: null, - error: 'No valid tokens found in oauth_creds', - }; - } - - const refreshToken = readOptionalString(creds.refresh_token); - const tokenInfo = await this.getTokenInfoEmail(accessToken); - if (tokenInfo.valid) { - return { - authenticated: true, - email: tokenInfo.email || 'OAuth Session', - method: 'credentials_file', - }; - } - - if (!refreshToken) { - return { - authenticated: false, - email: null, - method: 'credentials_file', - error: 'Access token invalid and no refresh token found', - }; - } - - return { - authenticated: true, - email: await this.getActiveAccountEmail() || 'OAuth Session', - method: 'credentials_file', - }; - } catch { - if (selectedType === 'gemini-api-key') { - return { - authenticated: false, - email: null, - method: 'api_key', - error: 'Gemini is set to "Use Gemini API key", but GEMINI_API_KEY is unavailable', - }; - } - - if (selectedType === 'oauth-personal') { - return { - authenticated: false, - email: null, - method: 'credentials_file', - error: 'Gemini is set to Google sign-in, but no cached OAuth credentials were found', - }; - } - - // If no explicit auth type was selected, surface the generic "not configured" error. - return { - authenticated: false, - email: null, - method: null, - error: 'Gemini CLI not configured', - }; - } - } - - /** - * Validates a Gemini OAuth access token and returns an email when Google reports one. - */ - private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> { - try { - const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`); - if (!tokenRes.ok) { - return { valid: false, email: null }; - } - - const tokenInfo = readObjectRecord(await tokenRes.json()); - return { - valid: true, - email: readOptionalString(tokenInfo?.email) ?? null, - }; - } catch { - return { valid: false, email: null }; - } - } - - /** - * Reads Gemini's active local Google account as an offline fallback for display. - */ - private async getActiveAccountEmail(): Promise { - try { - const accPath = path.join(this.getGeminiCliHome(), '.gemini', 'google_accounts.json'); - const accContent = await readFile(accPath, 'utf8'); - const accounts = readObjectRecord(JSON.parse(accContent)); - return readOptionalString(accounts?.active) ?? null; - } catch { - return null; - } - } -} diff --git a/server/modules/providers/list/gemini/gemini-mcp.provider.ts b/server/modules/providers/list/gemini/gemini-mcp.provider.ts deleted file mode 100644 index b86b8f2d..00000000 --- a/server/modules/providers/list/gemini/gemini-mcp.provider.ts +++ /dev/null @@ -1,110 +0,0 @@ -import os from 'node:os'; -import path from 'node:path'; - -import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js'; -import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; -import { - AppError, - readJsonConfig, - readObjectRecord, - readOptionalString, - readStringArray, - readStringRecord, - writeJsonConfig, -} from '@/shared/utils.js'; - -export class GeminiMcpProvider extends McpProvider { - constructor() { - super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']); - } - - protected async readScopedServers(scope: McpScope, workspacePath: string): Promise> { - const filePath = scope === 'user' - ? path.join(os.homedir(), '.gemini', 'settings.json') - : path.join(workspacePath, '.gemini', 'settings.json'); - const config = await readJsonConfig(filePath); - return readObjectRecord(config.mcpServers) ?? {}; - } - - protected async writeScopedServers( - scope: McpScope, - workspacePath: string, - servers: Record, - ): Promise { - const filePath = scope === 'user' - ? path.join(os.homedir(), '.gemini', 'settings.json') - : path.join(workspacePath, '.gemini', 'settings.json'); - const config = await readJsonConfig(filePath); - config.mcpServers = servers; - await writeJsonConfig(filePath, config); - } - - protected buildServerConfig(input: UpsertProviderMcpServerInput): Record { - if (input.transport === 'stdio') { - if (!input.command?.trim()) { - throw new AppError('command is required for stdio MCP servers.', { - code: 'MCP_COMMAND_REQUIRED', - statusCode: 400, - }); - } - - return { - command: input.command, - args: input.args ?? [], - env: input.env ?? {}, - cwd: input.cwd, - }; - } - - if (!input.url?.trim()) { - throw new AppError('url is required for http/sse MCP servers.', { - code: 'MCP_URL_REQUIRED', - statusCode: 400, - }); - } - - return { - type: input.transport, - url: input.url, - headers: input.headers ?? {}, - }; - } - - protected normalizeServerConfig( - scope: McpScope, - name: string, - rawConfig: unknown, - ): ProviderMcpServer | null { - if (!rawConfig || typeof rawConfig !== 'object') { - return null; - } - - const config = rawConfig as Record; - if (typeof config.command === 'string') { - return { - provider: 'gemini', - name, - scope, - transport: 'stdio', - command: config.command, - args: readStringArray(config.args), - env: readStringRecord(config.env), - cwd: readOptionalString(config.cwd), - }; - } - - if (typeof config.url === 'string') { - const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http'; - return { - provider: 'gemini', - name, - scope, - transport, - url: config.url, - headers: readStringRecord(config.headers), - }; - } - - return null; - } -} diff --git a/server/modules/providers/list/gemini/gemini-models.provider.ts b/server/modules/providers/list/gemini/gemini-models.provider.ts deleted file mode 100644 index d59612cd..00000000 --- a/server/modules/providers/list/gemini/gemini-models.provider.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { IProviderModels } from '@/shared/interfaces.js'; -import type { - ProviderChangeActiveModelInput, - ProviderCurrentActiveModel, - ProviderModelsDefinition, - ProviderSessionActiveModelChange, -} from '@/shared/types.js'; -import { - buildDefaultProviderCurrentActiveModel, - writeProviderSessionActiveModelChange, -} from '@/shared/utils.js'; - -export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = { - OPTIONS: [ - { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }, - { value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' }, - { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, - { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' }, - { value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' }, - { value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' }, - ], - DEFAULT: 'gemini-3-flash-preview', -}; - -export class GeminiProviderModels implements IProviderModels { - async getSupportedModels(): Promise { - return GEMINI_FALLBACK_MODELS; - } - - async getCurrentActiveModel(): Promise { - return buildDefaultProviderCurrentActiveModel(GEMINI_FALLBACK_MODELS); - } - - async changeActiveModel( - input: ProviderChangeActiveModelInput, - ): Promise { - return writeProviderSessionActiveModelChange('gemini', input); - } -} diff --git a/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts b/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts deleted file mode 100644 index 7ec3eff9..00000000 --- a/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts +++ /dev/null @@ -1,405 +0,0 @@ -import crypto from 'node:crypto'; -import os from 'node:os'; -import path from 'node:path'; -import { readFile } from 'node:fs/promises'; - -import { projectsDb, sessionsDb } from '@/modules/database/index.js'; -import { - findFilesRecursivelyCreatedAfter, - normalizeProjectPath, - normalizeSessionName, - readFileTimestamps, -} from '@/shared/utils.js'; -import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; -import type { AnyRecord } from '@/shared/types.js'; - -type ParsedSession = { - sessionId: string; - projectPath: string; - sessionName?: string; -}; - -type GeminiJsonlMetadata = { - sessionId: string; - projectPath?: string; - projectHash?: string; - firstUserMessage?: string; -}; - -/** - * Session indexer for Gemini transcript artifacts. - */ -export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer { - private readonly provider = 'gemini' as const; - private readonly geminiHome = path.join(os.homedir(), '.gemini'); - - /** - * Scans Gemini legacy JSON and new JSONL artifacts and upserts sessions into DB. - */ - async synchronize(since?: Date): Promise { - const projectHashLookup = this.buildProjectHashLookup(); - - // const legacySessionFiles = await findFilesRecursivelyCreatedAfter( - // path.join(this.geminiHome, 'sessions'), - // '.json', - // since ?? null - // ); - // Gemini creates overlapping artifacts across `sessions/` and `tmp/`. - // We currently index only `tmp/*/chats/*.jsonl` because those files are the - // live transcript source and avoid duplicate session rows from mirrored files. - // const legacyTempFiles = await findFilesRecursivelyCreatedAfter( - // path.join(this.geminiHome, 'tmp'), - // '.json', - // since ?? null - // ); - // const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter( - // path.join(this.geminiHome, 'sessions'), - // '.jsonl', - // since ?? null - // ); - const jsonlTempFiles = await findFilesRecursivelyCreatedAfter( - path.join(this.geminiHome, 'tmp'), - '.jsonl', - since ?? null - ); - - // Current strategy: index only temp chat JSONL artifacts. - const files = [ - // ...legacySessionFiles, - // Intentionally disabled to avoid duplicate indexing from mirrored - // `sessions/*.json` and `sessions/*.jsonl` artifacts. - // ...legacyTempFiles, - // ...jsonlSessionFiles, - ...jsonlTempFiles, - ]; - - let processed = 0; - for (const filePath of files) { - if (this.shouldSkipTempArtifact(filePath)) { - continue; - } - - const parsed = filePath.endsWith('.jsonl') - ? await this.processJsonlSessionFile(filePath, projectHashLookup) - : await this.processLegacySessionFile(filePath); - if (!parsed) { - continue; - } - - const timestamps = await readFileTimestamps(filePath); - sessionsDb.createSession( - parsed.sessionId, - this.provider, - parsed.projectPath, - parsed.sessionName, - timestamps.createdAt, - timestamps.updatedAt, - filePath - ); - processed += 1; - } - - return processed; - } - - /** - * Parses and upserts one Gemini legacy JSON or JSONL artifact. - */ - async synchronizeFile(filePath: string): Promise { - if (!filePath.endsWith('.json') && !filePath.endsWith('.jsonl')) { - return null; - } - - if (this.shouldSkipTempArtifact(filePath)) { - return null; - } - - const parsed = filePath.endsWith('.jsonl') - ? await this.processJsonlSessionFile(filePath, this.buildProjectHashLookup()) - : await this.processLegacySessionFile(filePath); - if (!parsed) { - return null; - } - - const timestamps = await readFileTimestamps(filePath); - return sessionsDb.createSession( - parsed.sessionId, - this.provider, - parsed.projectPath, - parsed.sessionName, - timestamps.createdAt, - timestamps.updatedAt, - filePath - ); - } - - /** - * Extracts session metadata from one Gemini legacy JSON artifact. - */ - private async processLegacySessionFile(filePath: string): Promise { - try { - const content = await readFile(filePath, 'utf8'); - const data = JSON.parse(content) as AnyRecord; - - const sessionId = - typeof data.sessionId === 'string' - ? data.sessionId - : typeof data.id === 'string' - ? data.id - : undefined; - if (!sessionId) { - return null; - } - - const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath); - const projectPath = typeof data.projectPath === 'string' && data.projectPath.trim().length > 0 - ? data.projectPath - : workspaceProjectPath; - if (!projectPath) { - return null; - } - - const messages = Array.isArray(data.messages) ? data.messages : []; - const firstMessage = messages[0] as AnyRecord | undefined; - let rawName: string | undefined; - - if (Array.isArray(firstMessage?.content) && typeof firstMessage.content[0]?.text === 'string') { - rawName = firstMessage.content[0].text; - } else if (typeof firstMessage?.content === 'string') { - rawName = firstMessage.content; - } - - return { - sessionId, - projectPath, - sessionName: normalizeSessionName(rawName, 'New Gemini Chat'), - }; - } catch { - return null; - } - } - - /** - * Extracts session metadata from one Gemini JSONL artifact. - */ - private async processJsonlSessionFile( - filePath: string, - projectHashLookup: Map - ): Promise { - const metadata = await this.extractJsonlMetadata(filePath); - if (!metadata) { - return null; - } - - let projectPath = typeof metadata.projectPath === 'string' ? metadata.projectPath.trim() : ''; - if (!projectPath) { - const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath); - if (workspaceProjectPath) { - projectPath = workspaceProjectPath; - } - } - if (!projectPath && typeof metadata.projectHash === 'string') { - projectPath = projectHashLookup.get(metadata.projectHash.trim().toLowerCase()) ?? ''; - } - if (!projectPath) { - return null; - } - - // Once we resolve a project hash/path pair, keep it in-memory for this sync run. - if (typeof metadata.projectHash === 'string' && metadata.projectHash.trim()) { - projectHashLookup.set(metadata.projectHash.trim().toLowerCase(), projectPath); - } - - return { - sessionId: metadata.sessionId, - projectPath, - sessionName: normalizeSessionName(metadata.firstUserMessage, 'New Gemini Chat'), - }; - } - - /** - * Reads first useful metadata from Gemini JSONL files. - */ - private async extractJsonlMetadata(filePath: string): Promise { - try { - const content = await readFile(filePath, 'utf8'); - const lines = content.split('\n'); - - let sessionId: string | undefined; - let projectPath: string | undefined; - let projectHash: string | undefined; - let firstUserMessage: string | undefined; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - - let parsed: AnyRecord; - try { - parsed = JSON.parse(trimmed) as AnyRecord; - } catch { - continue; - } - - if (!sessionId && typeof parsed.sessionId === 'string') { - sessionId = parsed.sessionId; - } - if (!projectPath && typeof parsed.projectPath === 'string') { - projectPath = parsed.projectPath; - } - if (!projectHash && typeof parsed.projectHash === 'string') { - projectHash = parsed.projectHash; - } - - if (!firstUserMessage && parsed.type === 'user') { - firstUserMessage = this.extractGeminiTextContent(parsed.content); - } - - if (sessionId && (projectPath || projectHash) && firstUserMessage) { - break; - } - } - - if (!sessionId) { - return null; - } - - return { - sessionId, - projectPath, - projectHash, - firstUserMessage, - }; - } catch { - return null; - } - } - - /** - * Tries to resolve project root from Gemini tmp chat workspaces. - */ - private async resolveProjectPathFromChatWorkspace(filePath: string): Promise { - if (!filePath.includes(`${path.sep}chats${path.sep}`)) { - return ''; - } - - const chatsDir = path.dirname(filePath); - const workspaceDir = path.dirname(chatsDir); - const projectRootPath = path.join(workspaceDir, '.project_root'); - - try { - const rootContent = await readFile(projectRootPath, 'utf8'); - return rootContent.trim(); - } catch { - return ''; - } - } - - /** - * Builds a hash->path lookup for Gemini JSONL metadata that stores projectHash. - */ - private buildProjectHashLookup(): Map { - const lookup = new Map(); - const knownPaths = new Set(); - - for (const project of projectsDb.getProjectPaths()) { - if (typeof project.project_path === 'string' && project.project_path.trim()) { - knownPaths.add(project.project_path.trim()); - } - } - - for (const session of sessionsDb.getAllSessions()) { - if (session.provider === this.provider && typeof session.project_path === 'string' && session.project_path.trim()) { - knownPaths.add(session.project_path.trim()); - } - } - - for (const knownPath of knownPaths) { - this.addProjectHashCandidates(lookup, knownPath); - } - - return lookup; - } - - /** - * Adds likely Gemini hash variants for one project path. - */ - private addProjectHashCandidates(lookup: Map, projectPath: string): void { - const trimmed = projectPath.trim(); - if (!trimmed) { - return; - } - - const normalized = normalizeProjectPath(trimmed); - const resolved = path.resolve(trimmed); - const resolvedNormalized = normalizeProjectPath(resolved); - - const candidates = new Set([ - trimmed, - normalized, - resolved, - resolvedNormalized, - ]); - - if (process.platform === 'win32') { - for (const candidate of [...candidates]) { - candidates.add(candidate.toLowerCase()); - } - } - - for (const candidate of candidates) { - if (!candidate) { - continue; - } - - const hash = this.sha256(candidate); - if (!lookup.has(hash)) { - lookup.set(hash, trimmed); - } - } - } - - /** - * Returns first user text from Gemini content payload shapes. - */ - private extractGeminiTextContent(content: unknown): string | undefined { - if (typeof content === 'string' && content.trim().length > 0) { - return content; - } - - if (!Array.isArray(content)) { - return undefined; - } - - for (const part of content) { - if (typeof part === 'string' && part.trim().length > 0) { - return part; - } - - if (part && typeof part === 'object' && typeof (part as AnyRecord).text === 'string') { - const text = (part as AnyRecord).text; - if (text.trim().length > 0) { - return text; - } - } - } - - return undefined; - } - - /** - * Keeps tmp scanning scoped to chat artifacts only. - */ - private shouldSkipTempArtifact(filePath: string): boolean { - return ( - filePath.startsWith(path.join(this.geminiHome, 'tmp')) - && !filePath.includes(`${path.sep}chats${path.sep}`) - ); - } - - private sha256(value: string): string { - return crypto.createHash('sha256').update(value).digest('hex'); - } -} diff --git a/server/modules/providers/list/gemini/gemini-sessions.provider.ts b/server/modules/providers/list/gemini/gemini-sessions.provider.ts deleted file mode 100644 index 4046919a..00000000 --- a/server/modules/providers/list/gemini/gemini-sessions.provider.ts +++ /dev/null @@ -1,540 +0,0 @@ -import fsSync from 'node:fs'; -import fs from 'node:fs/promises'; -import readline from 'node:readline'; - -import { sessionsDb } from '@/modules/database/index.js'; -import type { IProviderSessions } from '@/shared/interfaces.js'; -import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; - -const PROVIDER = 'gemini'; - -type GeminiHistoryResult = { - messages: AnyRecord[]; - tokenUsage?: unknown; -}; - -function mapGeminiRole(value: unknown): 'user' | 'assistant' | null { - if (value === 'user') { - return 'user'; - } - - if (value === 'gemini' || value === 'assistant') { - return 'assistant'; - } - - return null; -} - -function extractGeminiTextContent(content: unknown): string { - if (typeof content === 'string') { - return content; - } - - if (!Array.isArray(content)) { - return ''; - } - - return content - .map((part) => { - if (typeof part === 'string') { - return part; - } - if (!part || typeof part !== 'object') { - return ''; - } - - const record = part as AnyRecord; - if (typeof record.text === 'string') { - return record.text; - } - - return ''; - }) - .filter(Boolean) - .join('\n'); -} - -function extractGeminiThoughts(thoughts: unknown): string { - if (!Array.isArray(thoughts)) { - return ''; - } - - return thoughts - .map((item) => { - if (!item || typeof item !== 'object') { - return ''; - } - - const record = item as AnyRecord; - const subject = typeof record.subject === 'string' ? record.subject.trim() : ''; - const description = typeof record.description === 'string' ? record.description.trim() : ''; - - if (subject && description) { - return `${subject}: ${description}`; - } - - return description || subject; - }) - .filter(Boolean) - .join('\n'); -} - -function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined { - if (!tokens || typeof tokens !== 'object') { - return undefined; - } - - const record = tokens as AnyRecord; - const input = Number(record.input || 0); - const output = Number(record.output || 0); - const total = Number(record.total || input + output || 0); - - return { - used: total, - inputTokens: input, - outputTokens: output, - breakdown: { - input, - output, - }, - }; -} - -async function getGeminiLegacySessionMessages(sessionFilePath: string): Promise { - try { - const data = await fs.readFile(sessionFilePath, 'utf8'); - const session = JSON.parse(data) as AnyRecord; - const sourceMessages = Array.isArray(session.messages) ? session.messages : []; - - const messages: AnyRecord[] = []; - for (const msg of sourceMessages) { - const role = mapGeminiRole(msg.type ?? msg.role); - if (!role) { - continue; - } - - messages.push({ - type: 'message', - uuid: typeof msg.id === 'string' ? msg.id : undefined, - message: { role, content: msg.content }, - timestamp: msg.timestamp || null, - }); - } - - return { messages }; - } catch { - return { messages: [] }; - } -} - -async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise { - const messages: AnyRecord[] = []; - let tokenUsage: AnyRecord | undefined; - - try { - const fileStream = fsSync.createReadStream(sessionFilePath); - const lineReader = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - - for await (const line of lineReader) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - - let entry: AnyRecord; - try { - entry = JSON.parse(trimmed) as AnyRecord; - } catch { - continue; - } - - // Metadata/update lines (e.g. {$set:{lastUpdated:...}}) do not represent chat messages. - if (entry.$set) { - continue; - } - - const role = mapGeminiRole(entry.type); - if (role) { - const textContent = extractGeminiTextContent(entry.content); - if (textContent.trim()) { - messages.push({ - type: 'message', - uuid: typeof entry.id === 'string' ? entry.id : undefined, - message: { role, content: textContent }, - timestamp: entry.timestamp || null, - }); - } - - const thinkingContent = extractGeminiThoughts(entry.thoughts); - if (thinkingContent.trim()) { - messages.push({ - type: 'thinking', - uuid: typeof entry.id === 'string' ? `${entry.id}_thinking` : undefined, - message: { role: 'assistant', content: thinkingContent }, - timestamp: entry.timestamp || null, - isReasoning: true, - }); - } - - if (role === 'assistant') { - const usage = buildGeminiTokenUsage(entry.tokens); - if (usage) { - tokenUsage = usage; - } - } - - continue; - } - - if (entry.type === 'tool_use') { - messages.push({ - type: 'tool_use', - uuid: typeof entry.id === 'string' ? entry.id : undefined, - timestamp: entry.timestamp || null, - toolName: entry.tool_name || entry.name || 'Tool', - toolInput: entry.parameters ?? entry.input ?? entry.arguments ?? '', - toolCallId: entry.tool_id || entry.toolCallId || entry.id, - }); - continue; - } - - if (entry.type === 'tool_result') { - messages.push({ - type: 'tool_result', - uuid: typeof entry.id === 'string' ? entry.id : undefined, - timestamp: entry.timestamp || null, - toolCallId: entry.tool_id || entry.toolCallId || entry.id || '', - output: entry.output ?? entry.result ?? '', - isError: Boolean(entry.error) || entry.status === 'error', - }); - } - } - } catch { - return { messages: [] }; - } - - messages.sort( - (a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(), - ); - - return { messages, tokenUsage }; -} - -async function getGeminiCliSessionMessages(sessionId: string): Promise { - const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path; - if (!sessionFilePath) { - return { messages: [] }; - } - - if (sessionFilePath.endsWith('.jsonl')) { - return getGeminiJsonlSessionMessages(sessionFilePath); - } - - return getGeminiLegacySessionMessages(sessionFilePath); -} - -export class GeminiSessionsProvider implements IProviderSessions { - /** - * Normalizes live Gemini stream-json events into the shared message shape. - * - * Gemini history uses a different session file shape, so fetchHistory handles - * that separately after loading raw persisted messages. - */ - normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { - const raw = readObjectRecord(rawMessage); - if (!raw) { - return []; - } - - const ts = raw.timestamp || new Date().toISOString(); - const baseId = raw.uuid || generateMessageId('gemini'); - - if (raw.type === 'message' && raw.role === 'assistant') { - const content = raw.content || ''; - const messages: NormalizedMessage[] = []; - if (content) { - messages.push(createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'stream_delta', - content, - })); - } - if (raw.delta !== true) { - messages.push(createNormalizedMessage({ - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'stream_end', - })); - } - return messages; - } - - if (raw.type === 'tool_use') { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: raw.tool_name, - toolInput: raw.parameters || {}, - toolId: raw.tool_id || baseId, - })]; - } - - if (raw.type === 'tool_result') { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_result', - toolId: raw.tool_id || '', - content: raw.output === undefined ? '' : String(raw.output), - isError: raw.status === 'error', - })]; - } - - if (raw.type === 'result') { - const messages = [createNormalizedMessage({ - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'stream_end', - })]; - if (raw.stats?.total_tokens) { - messages.push(createNormalizedMessage({ - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'status', - text: 'Complete', - tokens: raw.stats.total_tokens, - canInterrupt: false, - })); - } - return messages; - } - - if (raw.type === 'error') { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'error', - content: raw.error || raw.message || 'Unknown Gemini streaming error', - })]; - } - - return []; - } - - /** - * Loads Gemini history from Gemini CLI session files on disk. - */ - async fetchHistory( - sessionId: string, - options: FetchHistoryOptions = {}, - ): Promise { - const { limit = null, offset = 0 } = options; - - let result: GeminiHistoryResult; - try { - result = await getGeminiCliSessionMessages(sessionId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message); - return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; - } - - const rawMessages = result.messages; - const normalized: NormalizedMessage[] = []; - - for (let i = 0; i < rawMessages.length; i++) { - const raw = rawMessages[i]; - const ts = raw.timestamp || new Date().toISOString(); - const baseId = raw.uuid || generateMessageId('gemini'); - - if (raw.type === 'thinking' || raw.isReasoning) { - const thinkingContent = typeof raw.message?.content === 'string' - ? raw.message.content - : typeof raw.content === 'string' - ? raw.content - : ''; - - if (thinkingContent.trim()) { - normalized.push(createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'thinking', - content: thinkingContent, - })); - } - continue; - } - - if (raw.type === 'tool_use' || raw.toolName) { - normalized.push(createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: raw.toolName || 'Tool', - toolInput: raw.toolInput, - toolId: raw.toolCallId || baseId, - })); - continue; - } - - if (raw.type === 'tool_result') { - normalized.push(createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_result', - toolId: raw.toolCallId || '', - content: raw.output === undefined ? '' : String(raw.output), - isError: Boolean(raw.isError), - })); - continue; - } - - const role = raw.message?.role || raw.role; - const content = raw.message?.content || raw.content; - if (!role || !content) { - continue; - } - - const normalizedRole = role === 'user' ? 'user' : 'assistant'; - - if (Array.isArray(content)) { - for (let partIdx = 0; partIdx < content.length; partIdx++) { - const part = content[partIdx] as AnyRecord | string; - - if (typeof part === 'string' && part.trim()) { - normalized.push(createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role: normalizedRole, - content: part, - })); - continue; - } - - if (!part || typeof part !== 'object') { - continue; - } - - if ((part.type === 'text' || !part.type) && typeof part.text === 'string' && part.text.trim()) { - normalized.push(createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role: normalizedRole, - content: part.text, - })); - } else if (part.type === 'tool_use') { - normalized.push(createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: part.name, - toolInput: part.input, - toolId: part.id || generateMessageId('gemini_tool'), - })); - } else if (part.type === 'tool_result') { - normalized.push(createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_result', - toolId: part.tool_use_id || '', - content: part.content === undefined ? '' : String(part.content), - isError: Boolean(part.is_error), - })); - } - } - } else if (typeof content === 'string' && content.trim()) { - normalized.push(createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role: normalizedRole, - content, - })); - } else { - const textContent = extractGeminiTextContent(content); - if (textContent.trim()) { - normalized.push(createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role: normalizedRole, - content: textContent, - })); - } - } - } - - const toolResultMap = new Map(); - for (const msg of normalized) { - if (msg.kind === 'tool_result' && msg.toolId) { - toolResultMap.set(msg.toolId, msg); - } - } - for (const msg of normalized) { - if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { - const toolResult = toolResultMap.get(msg.toolId); - if (toolResult) { - msg.toolResult = { content: toolResult.content, isError: toolResult.isError }; - } - } - } - - const start = Math.max(0, offset); - const pageLimit = limit === null ? null : Math.max(0, limit); - // Tail pagination via the shared contract: offset 0 returns the most - // recent page, matching every other provider. - const { page, hasMore } = sliceTailPage(normalized, pageLimit, start); - let total = 0; - for (const msg of normalized) { - if (msg.kind !== 'tool_result') { - total += 1; - } - } - - return { - messages: page, - total, - hasMore, - offset: start, - limit: pageLimit, - tokenUsage: result.tokenUsage, - }; - } -} diff --git a/server/modules/providers/list/gemini/gemini-skills.provider.ts b/server/modules/providers/list/gemini/gemini-skills.provider.ts deleted file mode 100644 index f42ebb6f..00000000 --- a/server/modules/providers/list/gemini/gemini-skills.provider.ts +++ /dev/null @@ -1,44 +0,0 @@ -import os from 'node:os'; -import path from 'node:path'; - -import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js'; -import type { ProviderSkillSource } from '@/shared/types.js'; - -export class GeminiSkillsProvider extends SkillsProvider { - constructor() { - super('gemini'); - } - - protected async getSkillSources(workspacePath: string): Promise { - return [ - { - scope: 'user', - rootDir: path.join(os.homedir(), '.gemini', 'skills'), - commandPrefix: '/', - }, - { - scope: 'user', - rootDir: path.join(os.homedir(), '.agents', 'skills'), - commandPrefix: '/', - }, - { - scope: 'project', - rootDir: path.join(workspacePath, '.gemini', 'skills'), - commandPrefix: '/', - }, - { - scope: 'project', - rootDir: path.join(workspacePath, '.agents', 'skills'), - commandPrefix: '/', - }, - ]; - } - - protected async getGlobalSkillSource(): Promise { - return { - scope: 'user', - rootDir: path.join(os.homedir(), '.gemini', 'skills'), - commandPrefix: '/', - }; - } -} diff --git a/server/modules/providers/list/gemini/gemini.provider.ts b/server/modules/providers/list/gemini/gemini.provider.ts deleted file mode 100644 index 38d25245..00000000 --- a/server/modules/providers/list/gemini/gemini.provider.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; -import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js'; -import { GeminiProviderModels } from '@/modules/providers/list/gemini/gemini-models.provider.js'; -import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js'; -import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js'; -import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js'; -import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js'; -import type { - IProviderAuth, - IProviderModels, - IProviderSessionSynchronizer, - IProviderSkills, - IProviderSessions, -} from '@/shared/interfaces.js'; - -export class GeminiProvider extends AbstractProvider { - readonly models: IProviderModels = new GeminiProviderModels(); - readonly mcp = new GeminiMcpProvider(); - readonly auth: IProviderAuth = new GeminiProviderAuth(); - readonly skills: IProviderSkills = new GeminiSkillsProvider(); - readonly sessions: IProviderSessions = new GeminiSessionsProvider(); - readonly sessionSynchronizer: IProviderSessionSynchronizer = new GeminiSessionSynchronizer(); - - constructor() { - super('gemini'); - } -} diff --git a/server/modules/providers/list/opencode/opencode-auth.provider.ts b/server/modules/providers/list/opencode/opencode-auth.provider.ts index 9f55d18b..ad24a9a9 100644 --- a/server/modules/providers/list/opencode/opencode-auth.provider.ts +++ b/server/modules/providers/list/opencode/opencode-auth.provider.ts @@ -19,7 +19,6 @@ const OPENCODE_ENV_CREDENTIAL_KEYS = [ 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY', - 'GEMINI_API_KEY', 'GROQ_API_KEY', 'OPENROUTER_API_KEY', ]; diff --git a/server/modules/providers/list/opencode/opencode-models.provider.ts b/server/modules/providers/list/opencode/opencode-models.provider.ts index 6891b4df..c18e265d 100644 --- a/server/modules/providers/list/opencode/opencode-models.provider.ts +++ b/server/modules/providers/list/opencode/opencode-models.provider.ts @@ -1,5 +1,3 @@ -import { spawn } from 'node:child_process'; - import Database from 'better-sqlite3'; import crossSpawn from 'cross-spawn'; @@ -51,23 +49,15 @@ export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = { label: 'GPT-5.4 Mini', description: 'openai - openai/gpt-5.4-mini', }, - { - value: 'google/gemini-2.5-pro', - label: 'Gemini 2.5 Pro', - description: 'google - google/gemini-2.5-pro', - }, - { - value: 'google/gemini-2.5-flash', - label: 'Gemini 2.5 Flash', - description: 'google - google/gemini-2.5-flash', - }, ], DEFAULT: 'anthropic/claude-sonnet-4-5', }; const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000; const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i; -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to +// child_process.spawn everywhere else. +const spawnFunction = crossSpawn; const DATE_TOKEN = /^\d{8}$/; const SIMPLE_NUMBER_TOKEN = /^\d$/; const VERSION_TOKEN = /^[a-z]\d+$/i; @@ -239,6 +229,10 @@ const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: s }; }; +const isSupportedOpenCodeModelId = (id: string): boolean => ( + readOpenCodeModelParts(id).upstreamProvider.toLowerCase() !== 'google' +); + const readOpenCodeVerboseModelId = (model: OpenCodeVerboseModel): string | null => { const id = readOptionalString(model.id); if (!id) { @@ -296,7 +290,7 @@ const readOpenCodeEffortValues = ( const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOption | null => { const value = readOpenCodeVerboseModelId(model); - if (!value) { + if (!value || !isSupportedOpenCodeModelId(value)) { return null; } @@ -315,11 +309,13 @@ const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOpti }; export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => { - const options: ProviderModelOption[] = ids.map((value) => ({ - value, - label: labelForOpenCodeModelId(value), - description: descriptionForOpenCodeModelId(value), - })); + const options: ProviderModelOption[] = ids + .filter(isSupportedOpenCodeModelId) + .map((value) => ({ + value, + label: labelForOpenCodeModelId(value), + description: descriptionForOpenCodeModelId(value), + })); const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value ?? options[0]?.value diff --git a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts index ea63c776..a3c2ba95 100644 --- a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +++ b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts @@ -11,6 +11,7 @@ import { normalizeSessionName, readJsonRecord, readOptionalString, + unwrapJsonStringLiteral, } from '@/shared/utils.js'; type OpenCodeSessionRow = { @@ -128,9 +129,26 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId) ?? sessionsDb.getSessionById(sessionId); const existingName = existingSession?.custom_name; - const nextName = existingName && existingName !== fallbackTitle - ? existingName - : readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId); + + // Sessions started by sending a message from cloudcli carry a distinct + // app-allocated session_id mapped to the provider id. For these we title the + // conversation from the first user message the user typed, matching how the + // app titles a brand-new conversation. Sessions discovered purely by + // indexing (session_id === provider_session_id) keep OpenCode's own stored + // title. + const isAppCreated = + existingSession != null && + existingSession.provider_session_id != null && + existingSession.session_id !== existingSession.provider_session_id; + + let nextName: string | undefined; + if (existingName && existingName !== fallbackTitle) { + nextName = existingName; + } else if (isAppCreated) { + nextName = this.readFirstUserText(db, sessionId) ?? readOptionalString(row.title); + } else { + nextName = readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId); + } // OpenCode stores every session in one shared sqlite database, so jsonl_path // must stay null to avoid deleting opencode.db when one app session is removed. @@ -163,7 +181,10 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer `).get(sessionId) as { data: string | null } | undefined; const data = readJsonRecord(row?.data); - return readOptionalString(data?.text); + const text = readOptionalString(data?.text); + // OpenCode persists the first prompt as a JSON string literal (e.g. + // `"hello"`), so decode it to avoid titling the session with quotes. + return text === undefined ? undefined : unwrapJsonStringLiteral(text); } catch { return undefined; } diff --git a/server/modules/providers/list/opencode/opencode-sessions.provider.ts b/server/modules/providers/list/opencode/opencode-sessions.provider.ts index 6da4857d..f406438b 100644 --- a/server/modules/providers/list/opencode/opencode-sessions.provider.ts +++ b/server/modules/providers/list/opencode/opencode-sessions.provider.ts @@ -2,6 +2,7 @@ import fsSync from 'node:fs'; import Database from 'better-sqlite3'; +import { parseImagesInputTag } from '@/shared/image-attachments.js'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { @@ -13,6 +14,7 @@ import { readJsonRecord, readOptionalString, sliceTailPage, + unwrapJsonStringLiteral, } from '@/shared/utils.js'; const PROVIDER = 'opencode'; @@ -59,25 +61,6 @@ const formatToolContent = (value: unknown): string => { } }; -/** - * OpenCode can persist the first prompt as a JSON string literal inside a text - * part, for example `"hello"` instead of `hello`. Decode only complete JSON - * string literals so normal assistant/user prose remains untouched. - */ -const unwrapJsonStringLiteral = (value: string): string => { - const trimmed = value.trim(); - if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) { - return value; - } - - try { - const parsed = JSON.parse(trimmed); - return typeof parsed === 'string' ? parsed : value; - } catch { - return value; - } -}; - const extractText = (value: unknown): string => { if (typeof value === 'string') { return unwrapJsonStringLiteral(value); @@ -418,8 +401,13 @@ export class OpenCodeSessionsProvider implements IProviderSessions { } if (partType === 'text') { - const content = extractText(partData); - if (content.trim()) { + const rawContent = extractText(partData); + // User prompts sent with attachments carry an path + // list; strip it for display and surface the paths as images. + const { text: content, attachments } = messageRole === 'user' + ? parseImagesInputTag(rawContent) + : { text: rawContent, attachments: [] }; + if (content.trim() || attachments.length > 0) { normalized.push(createNormalizedMessage({ id: baseId, sessionId, @@ -428,6 +416,7 @@ export class OpenCodeSessionsProvider implements IProviderSessions { kind: 'text', role: messageRole === 'user' ? 'user' : 'assistant', content, + images: attachments.length > 0 ? attachments : undefined, })); } continue; diff --git a/server/modules/providers/provider.registry.ts b/server/modules/providers/provider.registry.ts index a9f0d26b..8c333fcb 100644 --- a/server/modules/providers/provider.registry.ts +++ b/server/modules/providers/provider.registry.ts @@ -1,7 +1,6 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js'; import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js'; import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js'; -import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js'; import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js'; import type { IProvider } from '@/shared/interfaces.js'; import type { LLMProvider } from '@/shared/types.js'; @@ -11,7 +10,6 @@ const providers: Record = { claude: new ClaudeProvider(), codex: new CodexProvider(), cursor: new CursorProvider(), - gemini: new GeminiProvider(), opencode: new OpenCodeProvider(), }; diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 7fa7947e..2352f3a1 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -285,7 +285,6 @@ const parseProvider = (value: unknown): LLMProvider => { normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' - || normalized === 'gemini' || normalized === 'opencode' ) { return normalized; diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index dcd5f1ab..a1446062 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -46,7 +46,7 @@ const PROVIDER_CAPABILITIES: Record = { provider: 'cursor', permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], defaultPermissionMode: 'default', - supportsImages: false, + supportsImages: true, supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: false, @@ -56,27 +56,20 @@ const PROVIDER_CAPABILITIES: Record = { provider: 'codex', permissionModes: ['default', 'acceptEdits', 'bypassPermissions'], defaultPermissionMode: 'default', - supportsImages: false, + supportsImages: true, supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, supportsEffort: true, }, - gemini: { - provider: 'gemini', - permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], - defaultPermissionMode: 'default', - supportsImages: false, - supportsAbort: true, - supportsPermissionRequests: false, - supportsTokenUsage: true, - supportsEffort: false, - }, opencode: { provider: 'opencode', - permissionModes: ['default'], + // Mapped by the runtime onto OpenCode's controls: `--agent plan` (plan), + // `--auto` (bypassPermissions) and the OPENCODE_PERMISSION env var + // (acceptEdits). See resolveOpenCodePermissionOptions in opencode-cli.js. + permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], defaultPermissionMode: 'default', - supportsImages: false, + supportsImages: true, supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, diff --git a/server/modules/providers/services/provider-models.service.ts b/server/modules/providers/services/provider-models.service.ts index cb183f99..3bd990f3 100644 --- a/server/modules/providers/services/provider-models.service.ts +++ b/server/modules/providers/services/provider-models.service.ts @@ -17,7 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js'; export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; const PROVIDER_MODELS_CACHE_VERSION = 2; -const UNCACHED_PROVIDERS = new Set(['claude', 'gemini']); +const UNCACHED_PROVIDERS = new Set(['claude']); type ProviderModelsServiceDependencies = { resolveProvider?: (provider: LLMProvider) => Pick; diff --git a/server/modules/providers/services/session-conversations-search.service.ts b/server/modules/providers/services/session-conversations-search.service.ts index 101a0955..1854eadd 100644 --- a/server/modules/providers/services/session-conversations-search.service.ts +++ b/server/modules/providers/services/session-conversations-search.service.ts @@ -8,7 +8,7 @@ import { rgPath } from '@vscode/ripgrep'; import { projectsDb, sessionsDb } from '@/modules/database/index.js'; type AnyRecord = Record; -type SearchableProvider = 'claude' | 'codex' | 'gemini'; +type SearchableProvider = 'claude' | 'codex'; type SearchSnippetHighlight = { start: number; @@ -82,7 +82,7 @@ type ProjectBucket = { sessions: SearchableSessionRow[]; }; -const SUPPORTED_PROVIDERS = new Set(['claude', 'codex', 'gemini']); +const SUPPORTED_PROVIDERS = new Set(['claude', 'codex']); const MAX_MATCHES_PER_SESSION = 2; const RIPGREP_FILE_CHUNK_SIZE = 40; const RIPGREP_CHUNK_CONCURRENCY = 6; @@ -455,21 +455,6 @@ function extractCodexText(content: unknown): string { .join(' '); } -function extractGeminiText(content: unknown): string { - if (typeof content === 'string') { - return content; - } - - if (!Array.isArray(content)) { - return ''; - } - - return content - .filter((part: AnyRecord) => typeof part?.text === 'string') - .map((part: AnyRecord) => String(part.text)) - .join(' '); -} - function normalizeSearchableSessions(rows: SessionRepositoryRow[]): SearchableSessionRow[] { const normalizedRows: SearchableSessionRow[] = []; const projectArchiveStateByPath = new Map(); @@ -1065,81 +1050,6 @@ async function parseCodexSessionMatches( }; } -async function parseGeminiSessionMatches( - session: SearchableSessionRow, - runtime: SearchRuntime, -): Promise { - let data: string; - try { - data = await fs.readFile(session.jsonl_path, 'utf8'); - } catch { - return null; - } - - let parsed: AnyRecord; - try { - parsed = JSON.parse(data) as AnyRecord; - } catch { - return null; - } - - const sourceMessages = Array.isArray(parsed.messages) ? parsed.messages as AnyRecord[] : []; - if (sourceMessages.length === 0) { - return null; - } - - const matches: SessionConversationMatch[] = []; - let firstUserText: string | null = null; - - for (const msg of sourceMessages) { - if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) { - break; - } - - const role = msg.type === 'user' - ? 'user' - : (msg.type === 'gemini' || msg.type === 'assistant') - ? 'assistant' - : null; - if (!role) { - continue; - } - - const text = extractGeminiText(msg.content); - if (!text) { - continue; - } - - if (role === 'user' && !firstUserText) { - firstUserText = text; - } - - if (!runtime.matchesQuery(text)) { - continue; - } - - const { snippet, highlights } = runtime.buildSnippet(text); - addSessionMatch(runtime, matches, { - role, - snippet, - highlights, - timestamp: msg.timestamp ? String(msg.timestamp) : null, - provider: 'gemini', - }); - } - - if (matches.length === 0) { - return null; - } - - return { - sessionId: session.session_id, - provider: 'gemini', - sessionSummary: toSummaryText(session.custom_name, firstUserText, 'Gemini Session'), - matches, - }; -} - async function parseSessionMatches( session: SearchableSessionRow, runtime: SearchRuntime, @@ -1150,7 +1060,7 @@ async function parseSessionMatches( if (session.provider === 'codex') { return parseCodexSessionMatches(session, runtime); } - return parseGeminiSessionMatches(session, runtime); + return null; } export async function searchConversations( diff --git a/server/modules/providers/services/session-synchronizer.service.ts b/server/modules/providers/services/session-synchronizer.service.ts index 55b41f9e..5bb2b864 100644 --- a/server/modules/providers/services/session-synchronizer.service.ts +++ b/server/modules/providers/services/session-synchronizer.service.ts @@ -21,7 +21,6 @@ export const sessionSynchronizerService = { claude: 0, codex: 0, cursor: 0, - gemini: 0, opencode: 0, }; const failures: string[] = []; diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index cfbdb887..96f13e2a 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -25,16 +25,6 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions'), }, - // { - // provider: 'gemini', - // rootPath: path.join(os.homedir(), '.gemini', 'sessions'), - // }, - // Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there, - // which causes duplicate synchronization events. - { - provider: 'gemini', - rootPath: path.join(os.homedir(), '.gemini', 'tmp'), - }, { provider: 'opencode', rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'), @@ -81,10 +71,6 @@ function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean { return path.basename(filePath) === 'opencode.db'; } - if (provider === 'gemini') { - return filePath.endsWith('.json') || filePath.endsWith('.jsonl'); - } - return filePath.endsWith('.jsonl'); } diff --git a/server/modules/providers/tests/codex-sessions.test.ts b/server/modules/providers/tests/codex-sessions.test.ts new file mode 100644 index 00000000..f6687856 --- /dev/null +++ b/server/modules/providers/tests/codex-sessions.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js'; + +const patchHomeDir = (nextHomeDir: string) => { + const original = os.homedir; + (os as any).homedir = () => nextHomeDir; + return () => { + (os as any).homedir = original; + }; +}; + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'codex-provider-db-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +/** + * Writes one Codex rollout transcript. `firstUserMessage` mirrors the + * `event_msg`/`user_message` payload the runtime records for the prompt the + * user typed; omitting it produces a transcript with no user turn. + */ +const writeCodexTranscript = async ( + homeDir: string, + codexSessionId: string, + workspacePath: string, + firstUserMessage?: string, +): Promise => { + const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '07', '07'); + await mkdir(sessionsDir, { recursive: true }); + + const lines: string[] = [ + JSON.stringify({ type: 'session_meta', payload: { id: codexSessionId, cwd: workspacePath } }), + ]; + if (firstUserMessage !== undefined) { + lines.push(JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: firstUserMessage } })); + } + + const filePath = path.join(sessionsDir, `rollout-${codexSessionId}.jsonl`); + await writeFile(filePath, `${lines.join('\n')}\n`, 'utf8'); + return filePath; +}; + +test('Codex synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-app-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await writeCodexTranscript(tempRoot, 'codex-app-1', workspacePath, 'Fix the login redirect bug'); + await withIsolatedDatabase(async () => { + // The app allocates its own id and later maps the provider id onto it, + // exactly as a message sent from cloudcli does. + sessionsDb.createAppSession('app-1', 'codex', workspacePath); + sessionsDb.assignProviderSessionId('app-1', 'codex-app-1'); + + const synchronizer = new CodexSessionSynchronizer(); + await synchronizer.synchronize(); + + assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the login redirect bug'); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('Codex synchronizer leaves indexed sessions untitled when no name is available', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-indexed-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + // A CLI-created session has no app row; its first user message must NOT be + // used as the title, preserving the existing indexing behavior. + await writeCodexTranscript(tempRoot, 'codex-indexed-1', workspacePath, 'This prompt should be ignored'); + await withIsolatedDatabase(async () => { + const synchronizer = new CodexSessionSynchronizer(); + await synchronizer.synchronize(); + + assert.equal(sessionsDb.getSessionById('codex-indexed-1')?.custom_name, 'Untitled Codex Session'); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/server/modules/providers/tests/mcp.test.ts b/server/modules/providers/tests/mcp.test.ts index f10b1354..439966ac 100644 --- a/server/modules/providers/tests/mcp.test.ts +++ b/server/modules/providers/tests/mcp.test.ts @@ -257,34 +257,15 @@ test('providerMcpService handles opencode MCP config and capability validation', }); /** - * This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence. + * This test covers Cursor MCP JSON format and user/project scope persistence. */ -test('providerMcpService handles gemini and cursor MCP JSON config formats', { concurrency: false }, async () => { +test('providerMcpService handles cursor MCP JSON config formats', { concurrency: false }, async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-gc-')); const workspacePath = path.join(tempRoot, 'workspace'); await fs.mkdir(workspacePath, { recursive: true }); const restoreHomeDir = patchHomeDir(tempRoot); try { - await providerMcpService.upsertProviderMcpServer('gemini', { - name: 'gemini-stdio', - scope: 'user', - transport: 'stdio', - command: 'node', - args: ['server.js'], - env: { TOKEN: '$TOKEN' }, - cwd: './server', - }); - - await providerMcpService.upsertProviderMcpServer('gemini', { - name: 'gemini-http', - scope: 'project', - transport: 'http', - url: 'https://gemini.example.com/mcp', - headers: { Authorization: 'Bearer token' }, - workspacePath, - }); - await providerMcpService.upsertProviderMcpServer('cursor', { name: 'cursor-stdio', scope: 'project', @@ -303,15 +284,6 @@ test('providerMcpService handles gemini and cursor MCP JSON config formats', { c headers: { API_KEY: 'value' }, }); - const geminiUserConfig = await readJson(path.join(tempRoot, '.gemini', 'settings.json')); - const geminiUserServer = (geminiUserConfig.mcpServers as Record)['gemini-stdio'] as Record; - assert.equal(geminiUserServer.command, 'node'); - assert.equal(geminiUserServer.type, undefined); - - const geminiProjectConfig = await readJson(path.join(workspacePath, '.gemini', 'settings.json')); - const geminiProjectServer = (geminiProjectConfig.mcpServers as Record)['gemini-http'] as Record; - assert.equal(geminiProjectServer.type, 'http'); - const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json')); const cursorHttpServer = (cursorUserConfig.mcpServers as Record)['cursor-http'] as Record; assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp'); @@ -341,7 +313,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp workspacePath, }); - assert.equal(globalResult.length, 5); + assert.equal(globalResult.length, 4); assert.ok(globalResult.every((entry) => entry.created === true)); const claudeProject = await readJson(path.join(workspacePath, '.mcp.json')); @@ -350,9 +322,6 @@ test('providerMcpService global adder writes to all providers and rejects unsupp const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record; assert.ok((codexProject.mcp_servers as Record)['global-http']); - const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json')); - assert.ok((geminiProject.mcpServers as Record)['global-http']); - const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json')); assert.ok((opencodeProject.mcp as Record)['global-http']); diff --git a/server/modules/providers/tests/opencode-models.test.ts b/server/modules/providers/tests/opencode-models.test.ts index 097c1272..c6f0e72c 100644 --- a/server/modules/providers/tests/opencode-models.test.ts +++ b/server/modules/providers/tests/opencode-models.test.ts @@ -30,6 +30,7 @@ test('OpenCode models provider formats frontend labels from provider-prefixed id 'opencode/nemotron-3-super-free', 'anthropic/claude-3-5-sonnet-20241022', 'anthropic/claude-opus-4-7-fast', + 'google/model-alpha', 'openai/gpt-5.4-mini-fast', 'openai/gpt-5.5-pro', 'newprovider/alpha-v12-special-20261231', @@ -104,6 +105,12 @@ anthropic/claude-sonnet-5 } } } +google/model-alpha +{ + "id": "model-alpha", + "providerID": "google", + "name": "Model Alpha" +} `); const definition = buildOpenCodeDefinitionFromVerboseModels(models); diff --git a/server/modules/providers/tests/opencode-sessions.test.ts b/server/modules/providers/tests/opencode-sessions.test.ts index 2a98ea6f..2866632e 100644 --- a/server/modules/providers/tests/opencode-sessions.test.ts +++ b/server/modules/providers/tests/opencode-sessions.test.ts @@ -9,6 +9,7 @@ import Database from 'better-sqlite3'; import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js'; import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js'; +import { appendImagesInputTag } from '@/shared/image-attachments.js'; const patchHomeDir = (nextHomeDir: string) => { const original = os.homedir; @@ -321,6 +322,41 @@ test('OpenCode session synchronizer adopts the pending app session before watche } }); +test('OpenCode sessions provider strips from user turns and exposes attachments', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-images-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await createOpenCodeDatabase(tempRoot, workspacePath); + + // Rewrite the user text part with the tagged prompt the runtime sends. + const taggedPrompt = appendImagesInputTag('Look at this screenshot.', [ + { path: 'C:/Users/x/.cloudcli/assets/shot.png' }, + ]); + const db = new Database(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')); + try { + db.prepare('UPDATE part SET data = ? WHERE id = ?').run( + JSON.stringify({ type: 'text', text: taggedPrompt }), + 'part-user-text', + ); + } finally { + db.close(); + } + + const provider = new OpenCodeSessionsProvider(); + const history = await provider.fetchHistory('open-session-1'); + const userMessage = history.messages.find((message) => message.kind === 'text' && message.role === 'user'); + + assert.equal(userMessage?.content, 'Look at this screenshot.'); + assert.deepEqual(userMessage?.images, [{ path: 'C:/Users/x/.cloudcli/assets/shot.png' }]); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => { const provider = new OpenCodeSessionsProvider(); const normalized = provider.normalizeMessage({ @@ -381,3 +417,106 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur await rm(tempRoot, { recursive: true, force: true }); } }); + +/** + * Seeds a single OpenCode session with a controllable stored title and first + * user message. Uses a minimal schema (only the columns the synchronizer reads) + * with a plain-text user part so the derived name is unambiguous. + */ +const seedOpenCodeSession = async ( + homeDir: string, + workspacePath: string, + options: { sessionId: string; title: string | null; firstUserText: string }, +): Promise => { + const dataDir = path.join(homeDir, '.local', 'share', 'opencode'); + await mkdir(dataDir, { recursive: true }); + + const db = new Database(path.join(dataDir, 'opencode.db')); + try { + db.exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER, + time_archived INTEGER + ); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT); + `); + + db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', workspacePath); + db.prepare(` + INSERT INTO session (id, project_id, directory, title, time_created, time_updated, time_archived) + VALUES (?, ?, ?, ?, ?, ?, NULL) + `).run(options.sessionId, 'project-1', workspacePath, options.title, 1_700_000_000_000, 1_700_000_001_000); + db.prepare('INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)') + .run('message-user', options.sessionId, 1_700_000_001_000, JSON.stringify({ role: 'user' })); + db.prepare('INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)') + .run( + 'part-user', + 'message-user', + options.sessionId, + 1_700_000_001_000, + // OpenCode persists the prompt as a JSON string literal inside the text + // field, so double-encode it here to exercise the unwrap on read. + JSON.stringify({ type: 'text', text: JSON.stringify(options.firstUserText) }), + ); + } finally { + db.close(); + } +}; + +test('OpenCode synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-app-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + // Stored title differs from the first message so we can prove the first + // message wins for sessions started from cloudcli. + await seedOpenCodeSession(tempRoot, workspacePath, { + sessionId: 'oc-app-1', + title: 'OpenCode generated title', + firstUserText: 'Fix the checkout crash', + }); + await withIsolatedDatabase(async () => { + sessionsDb.createAppSession('app-1', 'opencode', workspacePath); + sessionsDb.assignProviderSessionId('app-1', 'oc-app-1'); + + await new OpenCodeSessionSynchronizer().synchronize(); + + assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the checkout crash'); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('OpenCode synchronizer keeps the stored title for indexed sessions', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-indexed-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await seedOpenCodeSession(tempRoot, workspacePath, { + sessionId: 'oc-indexed-1', + title: 'OpenCode generated title', + firstUserText: 'This prompt should be ignored', + }); + await withIsolatedDatabase(async () => { + await new OpenCodeSessionSynchronizer().synchronize(); + + assert.equal(sessionsDb.getSessionById('oc-indexed-1')?.custom_name, 'OpenCode generated title'); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/server/modules/providers/tests/provider-image-history.test.ts b/server/modules/providers/tests/provider-image-history.test.ts new file mode 100644 index 00000000..d52ea1a6 --- /dev/null +++ b/server/modules/providers/tests/provider-image-history.test.ts @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js'; +import { CodexSessionsProvider, extractCodexUserImages } from '@/modules/providers/list/codex/codex-sessions.provider.js'; +import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js'; +import { appendImagesInputTag } from '@/shared/image-attachments.js'; + +const SESSION_ID = 'session-1'; + +// ---------------------------------------------------------------- Claude + +test('claude history: base64 image blocks surface as user message images', () => { + const provider = new ClaudeSessionsProvider(); + const entry = { + uuid: 'u1', + timestamp: '2026-07-03T10:00:00.000Z', + message: { + role: 'user', + content: [ + { type: 'text', text: 'What is in this screenshot?' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } }, + { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'REVG' } }, + ], + }, + }; + + const messages = provider.normalizeMessage(entry, SESSION_ID); + + assert.equal(messages.length, 1); + assert.equal(messages[0].kind, 'text'); + assert.equal(messages[0].role, 'user'); + assert.equal(messages[0].content, 'What is in this screenshot?'); + assert.deepEqual(messages[0].images, [ + { data: 'data:image/png;base64,QUJD' }, + { data: 'data:image/jpeg;base64,REVG' }, + ]); +}); + +test('claude history: image-only user turns still produce a bubble', () => { + const provider = new ClaudeSessionsProvider(); + const entry = { + uuid: 'u2', + timestamp: '2026-07-03T10:00:00.000Z', + message: { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } }, + ], + }, + }; + + const messages = provider.normalizeMessage(entry, SESSION_ID); + + assert.equal(messages.length, 1); + assert.equal(messages[0].role, 'user'); + assert.equal(messages[0].content, ''); + assert.deepEqual(messages[0].images, [{ data: 'data:image/png;base64,QUJD' }]); +}); + +test('claude history: plain text user turns carry no images field', () => { + const provider = new ClaudeSessionsProvider(); + const entry = { + uuid: 'u3', + timestamp: '2026-07-03T10:00:00.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + }; + + const messages = provider.normalizeMessage(entry, SESSION_ID); + assert.equal(messages.length, 1); + assert.equal(messages[0].images, undefined); +}); + +// ---------------------------------------------------------------- Codex + +test('codex history: user_message payload images become path attachments', () => { + // Real rollout shape: local_image input items land in `local_images`, + // while `images` stays an empty array. + assert.deepEqual( + extractCodexUserImages({ + type: 'user_message', + message: 'can u see attached image?', + images: [], + local_images: ['C:\\proj\\.cloudcli\\assets\\a.png'], + }), + [{ path: 'C:/proj/.cloudcli/assets/a.png' }], + ); + assert.deepEqual( + extractCodexUserImages({ type: 'user_message', message: 'hi', images: ['/proj/b.jpg'] }), + [{ path: '/proj/b.jpg' }], + ); + assert.equal(extractCodexUserImages({ type: 'user_message', message: 'hi' }), undefined); + assert.equal(extractCodexUserImages({ type: 'user_message', message: 'hi', images: [], local_images: [] }), undefined); +}); + +test('codex history: base64 data URLs pass through as inline data attachments', () => { + const dataUrl = 'data:image/png;base64,QUJD'; + assert.deepEqual( + extractCodexUserImages({ + type: 'user_message', + message: 'look', + images: [dataUrl], + local_images: ['C:\\proj\\a.png'], + }), + [{ path: 'C:/proj/a.png' }, { data: dataUrl }], + ); +}); + +test('codex history: normalized user entries keep their images', () => { + const provider = new CodexSessionsProvider(); + const messages = provider.normalizeMessage( + { + timestamp: '2026-07-03T10:00:00.000Z', + message: { role: 'user', content: 'Look at this' }, + images: [{ path: '.cloudcli/assets/a.png' }], + }, + SESSION_ID, + ); + + assert.equal(messages.length, 1); + assert.equal(messages[0].role, 'user'); + assert.equal(messages[0].content, 'Look at this'); + assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/a.png' }]); +}); + +// ---------------------------------------------------------------- Cursor + +test('cursor history: inside user_query is stripped and attached', () => { + const provider = new CursorSessionsProvider(); + const taggedPrompt = appendImagesInputTag('Fix the layout bug', [{ path: '.cloudcli/assets/shot.png' }]); + const blobs = [ + { + id: 'blob1', + sequence: 1, + rowid: 1, + content: { + role: 'user', + content: `2026-07-03\n${taggedPrompt}`, + }, + }, + { + id: 'blob2', + sequence: 2, + rowid: 2, + content: { + role: 'assistant', + content: [{ type: 'text', text: 'Done — the flex container was wrong.' }], + }, + }, + ]; + + const messages = provider.normalizeCursorBlobs(blobs, SESSION_ID); + + assert.equal(messages.length, 2); + assert.equal(messages[0].role, 'user'); + assert.equal(messages[0].content, 'Fix the layout bug'); + assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/shot.png' }]); + assert.equal(messages[1].role, 'assistant'); + assert.equal(messages[1].images, undefined); +}); + +test('cursor history: user text without a tag keeps existing behavior', () => { + const provider = new CursorSessionsProvider(); + const blobs = [ + { + id: 'blob1', + sequence: 1, + rowid: 1, + content: { + role: 'user', + content: '2026-07-03\nplain question', + }, + }, + ]; + + const messages = provider.normalizeCursorBlobs(blobs, SESSION_ID); + assert.equal(messages.length, 1); + assert.equal(messages[0].content, 'plain question'); + assert.equal(messages[0].images, undefined); +}); diff --git a/server/modules/providers/tests/provider-models.service.test.ts b/server/modules/providers/tests/provider-models.service.test.ts index 36cbfc6d..5f44ab86 100644 --- a/server/modules/providers/tests/provider-models.service.test.ts +++ b/server/modules/providers/tests/provider-models.service.test.ts @@ -170,13 +170,13 @@ test('provider model cache is persisted across service instances', async () => { cachePath, resolveProvider: () => ({ models: { - getSupportedModels: async () => createModels('gemini-cached'), - getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'), - changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input), + getSupportedModels: async () => createModels('cursor-cached'), + getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'), + changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input), }, }), }); - await writer.getProviderModels('gemini'); + await writer.getProviderModels('cursor'); const reader = createProviderModelsService({ cachePath, @@ -185,13 +185,13 @@ test('provider model cache is persisted across service instances', async () => { getSupportedModels: async () => { throw new Error('loader should not be called for persisted cache hits'); }, - getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'), - changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input), + getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'), + changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input), }, }), }); - const models = await reader.getProviderModels('gemini'); - assert.equal(models.models.DEFAULT, 'gemini-cached'); + const models = await reader.getProviderModels('cursor'); + assert.equal(models.models.DEFAULT, 'cursor-cached'); assert.equal(models.cache.source, 'disk'); } finally { await rm(tempRoot, { recursive: true, force: true }); diff --git a/server/modules/providers/tests/skills.test.ts b/server/modules/providers/tests/skills.test.ts index 147366cc..e6bfacb2 100644 --- a/server/modules/providers/tests/skills.test.ts +++ b/server/modules/providers/tests/skills.test.ts @@ -444,34 +444,22 @@ test('providerSkillsService lists opencode project and user compatibility skills }); /** - * This test covers Gemini and Cursor skill directory rules, including shared + * This test covers Cursor skill directory rules, including shared * `.agents/skills` project support. */ -test('providerSkillsService lists gemini and cursor skills from their configured directories', { concurrency: false }, async () => { +test('providerSkillsService lists cursor skills from its configured directories', { concurrency: false }, async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-')); const workspacePath = path.join(tempRoot, 'workspace'); await fs.mkdir(workspacePath, { recursive: true }); const restoreHomeDir = patchHomeDir(tempRoot); try { - await writeSkill( - path.join(tempRoot, '.gemini', 'skills'), - 'gemini-user-dir', - 'gemini-user', - 'Gemini user skill', - ); await writeSkill( path.join(tempRoot, '.agents', 'skills'), 'agents-user-dir', 'agents-user', 'Agents user skill', ); - await writeSkill( - path.join(workspacePath, '.gemini', 'skills'), - 'gemini-project-dir', - 'gemini-project', - 'Gemini project skill', - ); await writeSkill( path.join(workspacePath, '.agents', 'skills'), 'agents-project-dir', @@ -491,14 +479,6 @@ test('providerSkillsService lists gemini and cursor skills from their configured 'Cursor user skill', ); - const geminiSkills = await providerSkillsService.listProviderSkills('gemini', { workspacePath }); - const geminiByName = new Map(geminiSkills.map((skill) => [skill.name, skill])); - assert.equal(geminiByName.get('gemini-user')?.scope, 'user'); - assert.equal(geminiByName.get('agents-user')?.scope, 'user'); - assert.equal(geminiByName.get('gemini-project')?.scope, 'project'); - assert.equal(geminiByName.get('agents-project')?.scope, 'project'); - assert.equal(geminiByName.get('gemini-project')?.command, '/gemini-project'); - const cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath }); const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill])); assert.equal(cursorByName.get('agents-project')?.scope, 'project'); @@ -515,7 +495,7 @@ test('providerSkillsService lists gemini and cursor skills from their configured * This test covers managed global skill creation for providers that own a * writable user skill directory. */ -test('providerSkillsService adds global skills for claude, codex, gemini, and cursor', { concurrency: false }, async () => { +test('providerSkillsService adds global skills for claude, codex, and cursor', { concurrency: false }, async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-')); const restoreHomeDir = patchHomeDir(tempRoot); @@ -618,22 +598,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu ); await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' }); - const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', { - entries: [ - { - directoryName: 'gemini-global-dir', - content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n', - }, - ], - }); - const createdGeminiSkill = createdGeminiSkills[0]; - assert.ok(createdGeminiSkill); - assert.equal(createdGeminiSkill.command, '/gemini-global'); - assert.equal( - createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')), - true, - ); - const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', { entries: [ { @@ -656,9 +620,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu const listedCodexSkills = await providerSkillsService.listProviderSkills('codex'); assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true); - const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini'); - assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true); - const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor'); assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true); diff --git a/server/modules/websocket/services/chat-run-registry.service.ts b/server/modules/websocket/services/chat-run-registry.service.ts index a5e51b5f..101d3bac 100644 --- a/server/modules/websocket/services/chat-run-registry.service.ts +++ b/server/modules/websocket/services/chat-run-registry.service.ts @@ -318,6 +318,22 @@ export const chatRunRegistry = { run.writer.sendComplete(opts); }, + /** + * Safety-net variant of `completeRun` scoped to one specific run: a no-op + * unless `run` is still the session's current, running run. A runtime + * promise can resolve after its own `complete` already streamed AND a new + * run has replaced it in the registry (a queued message sends within + * milliseconds of the previous turn ending) — the session-keyed + * `completeRun` would terminate that newer run. + */ + completeRunIfCurrent(run: ChatRun, opts: { exitCode: number; aborted?: boolean }): void { + if (runs.get(run.appSessionId) !== run || run.status !== 'running') { + return; + } + + run.writer.sendComplete(opts); + }, + /** * Test-only escape hatch: clears every tracked run. */ diff --git a/server/modules/websocket/services/chat-websocket.service.ts b/server/modules/websocket/services/chat-websocket.service.ts index 4e676ec3..48db6750 100644 --- a/server/modules/websocket/services/chat-websocket.service.ts +++ b/server/modules/websocket/services/chat-websocket.service.ts @@ -1,8 +1,11 @@ +import path from 'node:path'; + import type { WebSocket } from 'ws'; import { sessionsDb } from '@/modules/database/index.js'; import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js'; import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js'; +import { getGlobalImageAssetsDir, normalizeImageDescriptors } from '@/shared/image-attachments.js'; import type { AnyRecord, AuthenticatedWebSocketRequest, @@ -10,6 +13,37 @@ import type { } from '@/shared/types.js'; import { parseIncomingJsonObject } from '@/shared/utils.js'; +/** + * Trust boundary for client-supplied image attachments: chat.send options come + * straight from the browser, and the provider runtimes read the referenced + * files off disk (Claude base64-encodes them into the prompt). Only images + * that live directly inside the global upload store (`~/.cloudcli/assets`, + * where POST /api/assets/images puts them) are allowed through — anything + * else (absolute paths elsewhere, traversal, subdirectories) is dropped. + * + * Exported for tests; `assetsRootOverride` exists only for them. + */ +export function filterImagesToUploadStore(images: unknown, assetsRootOverride?: string): AnyRecord[] { + const assetsRoot = path.resolve(assetsRootOverride ?? getGlobalImageAssetsDir()); + + return normalizeImageDescriptors(images).filter((descriptor) => { + // Relative paths are anchored in the store; absolute ones must already be in it. + const resolved = path.resolve(assetsRoot, descriptor.path); + const relative = path.relative(assetsRoot, resolved); + const isDirectChild = + relative.length > 0 && + !relative.startsWith('..') && + !path.isAbsolute(relative) && + !relative.includes(path.sep) && + !relative.includes('/'); + + if (!isDirectChild) { + console.warn(`[Chat] Dropping image outside the upload store: ${descriptor.path}`); + } + return isDirectChild; + }); +} + /** * One provider runtime entry point. All five runtimes share this signature, * which lets the chat handler dispatch through a provider-keyed map instead @@ -161,6 +195,9 @@ async function handleChatSend( // gateway writer captures and maps back to the app session id. const runtimeOptions: AnyRecord = { ...clientOptions, + // Image attachments are re-validated server-side: only files inside the + // global upload store may reach the provider runtimes' file reads. + images: filterImagesToUploadStore(clientOptions.images), sessionId: session.provider_session_id ?? undefined, resume: Boolean(session.provider_session_id), cwd: clientOptions.cwd ?? session.project_path ?? undefined, @@ -175,8 +212,10 @@ async function handleChatSend( } finally { // Safety net: a runtime that crashed (or resolved) without emitting its // terminal `complete` would otherwise leave the session stuck in - // "processing" forever on every connected client. - chatRunRegistry.completeRun(sessionId, { exitCode: 1 }); + // "processing" forever on every connected client. Scoped to THIS run — + // a queued message can start the session's next run before this promise + // settles, and the session-keyed completeRun would kill that new run. + chatRunRegistry.completeRunIfCurrent(run, { exitCode: 1 }); } } diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index 55680b8c..bb4ac5aa 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -146,14 +146,6 @@ function buildShellCommand( return 'codex'; } - if (provider === 'gemini') { - const command = initialCommand || 'gemini'; - if (resumeSessionId) { - return `${command} --resume "${resumeSessionId}"`; - } - return command; - } - if (provider === 'opencode') { if (resumeSessionId) { return `opencode --session "${resumeSessionId}"`; @@ -477,9 +469,7 @@ export function handleShellConnection( ? 'Cursor' : provider === 'codex' ? 'Codex' - : provider === 'gemini' - ? 'Gemini' - : provider === 'opencode' + : provider === 'opencode' ? 'OpenCode' : 'Claude'; welcomeMsg = hasSession && resumeSessionId diff --git a/server/modules/websocket/tests/chat-image-filter.test.ts b/server/modules/websocket/tests/chat-image-filter.test.ts new file mode 100644 index 00000000..9b1212c4 --- /dev/null +++ b/server/modules/websocket/tests/chat-image-filter.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { filterImagesToUploadStore } from '@/modules/websocket/services/chat-websocket.service.js'; + +const STORE = path.join(os.tmpdir(), 'cloudcli-assets-store'); + +test('images inside the upload store pass through', () => { + const inside = path.join(STORE, 'shot.png'); + const result = filterImagesToUploadStore( + [{ path: inside, name: 'shot.png', mimeType: 'image/png' }], + STORE, + ); + assert.equal(result.length, 1); + assert.equal(result[0].path, inside); +}); + +test('bare filenames are anchored inside the store', () => { + const result = filterImagesToUploadStore(['shot.png'], STORE); + assert.equal(result.length, 1); +}); + +test('paths outside the store, traversal, and subdirs are dropped', () => { + const result = filterImagesToUploadStore( + [ + { path: 'C:/Users/victim/.ssh/id_rsa' }, + { path: '/etc/passwd' }, + { path: '../outside.png' }, + { path: path.join(STORE, '..', 'escaped.png') }, + { path: path.join(STORE, 'nested', 'deep.png') }, + { path: STORE }, // the store folder itself is not a file + ], + STORE, + ); + assert.deepEqual(result, []); +}); + +test('malformed payloads yield no images', () => { + assert.deepEqual(filterImagesToUploadStore(undefined, STORE), []); + assert.deepEqual(filterImagesToUploadStore('nope', STORE), []); + assert.deepEqual(filterImagesToUploadStore([{ name: 'no-path' }, 42], STORE), []); +}); diff --git a/server/modules/websocket/tests/chat-run-registry.test.ts b/server/modules/websocket/tests/chat-run-registry.test.ts index cc6250c0..1e911622 100644 --- a/server/modules/websocket/tests/chat-run-registry.test.ts +++ b/server/modules/websocket/tests/chat-run-registry.test.ts @@ -129,6 +129,44 @@ test('complete marks the run finished and duplicate completes are dropped', asyn }); }); +test('a finished run\'s safety net cannot complete the session\'s next run', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-9', 'codex', '/workspace/demo'); + const connection = new FakeConnection(); + + const firstRun = chatRunRegistry.startRun({ + appSessionId: 'app-run-9', + provider: 'codex', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(firstRun); + firstRun.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-9', exitCode: 0 }); + + // A queued message starts the next run before the first run's runtime + // promise settles (the chat handler's `finally` hasn't executed yet). + const secondRun = chatRunRegistry.startRun({ + appSessionId: 'app-run-9', + provider: 'codex', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(secondRun); + + // First run's safety net fires late: it must not touch the new run. + chatRunRegistry.completeRunIfCurrent(firstRun, { exitCode: 1 }); + assert.equal(chatRunRegistry.isProcessing('app-run-9'), true); + assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1); + + // The second run's own safety net still works while it is current. + chatRunRegistry.completeRunIfCurrent(secondRun, { exitCode: 1 }); + assert.equal(chatRunRegistry.isProcessing('app-run-9'), false); + assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 2); + }); +}); + test('listRunningRuns returns only currently running app sessions', async () => { await withIsolatedDatabase(() => { sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo'); @@ -186,22 +224,22 @@ test('replayEvents returns only events after the requested seq', async () => { test('attachConnection reroutes the live stream to a new socket', async () => { await withIsolatedDatabase(() => { - sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo'); + sessionsDb.createAppSession('app-run-5', 'opencode', '/workspace/demo'); const firstConnection = new FakeConnection(); const run = chatRunRegistry.startRun({ appSessionId: 'app-run-5', - provider: 'gemini', + provider: 'opencode', providerSessionId: null, connection: firstConnection, userId: null, }); assert.ok(run); - run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' }); + run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'before' }); const secondConnection = new FakeConnection(); assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true); - run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' }); + run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'after' }); assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']); assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']); diff --git a/server/openai-codex.js b/server/openai-codex.js index 47e89ab6..7ddbecf4 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -14,6 +14,8 @@ */ import { Codex } from '@openai/codex-sdk'; + +import { buildCodexInputItems, normalizeImageDescriptors } from './shared/image-attachments.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { sessionsService } from './modules/providers/services/sessions.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; @@ -228,6 +230,7 @@ export async function queryCodex(command, options = {}, ws) { projectPath, model, effort, + images, permissionMode = 'default' } = options; @@ -288,7 +291,12 @@ export async function queryCodex(command, options = {}, ws) { registerSession(capturedSessionId); } - const streamedTurn = await thread.runStreamed(command, { + // Execute with streaming. Turns with image attachments send structured + // input items so Codex reads the images from their local asset paths. + const turnInput = normalizeImageDescriptors(images).length > 0 + ? buildCodexInputItems(command, images, workingDirectory) + : command; + const streamedTurn = await thread.runStreamed(turnInput, { signal: abortController.signal }); diff --git a/server/opencode-cli.js b/server/opencode-cli.js index d486d949..9b961f07 100644 --- a/server/opencode-cli.js +++ b/server/opencode-cli.js @@ -1,19 +1,51 @@ -import { spawn } from 'child_process'; import fsSync from 'node:fs'; import crossSpawn from 'cross-spawn'; import Database from 'better-sqlite3'; +import { appendImagesInputTag } from './shared/image-attachments.js'; import { sessionsService } from './modules/providers/services/sessions.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; -import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js'; +import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell, getOpenCodeDatabasePath } from './shared/utils.js'; -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to +// child_process.spawn everywhere else. +const spawnFunction = crossSpawn; const activeOpenCodeProcesses = new Map(); +/** + * Maps the UI permission mode onto OpenCode's non-interactive controls. + * + * OpenCode has no single "permission mode" flag; each mode uses a different + * lever of the `opencode run` CLI (verified against v1.17.13): + * - plan → the built-in read-only `plan` agent (`--agent plan`). + * - bypassPermissions → `--auto`, which auto-approves every permission that + * is not explicitly denied in the user's config. + * - acceptEdits → the OPENCODE_PERMISSION env var, whose JSON body the + * CLI merges into its permission config. Forcing + * `edit: allow` guarantees file edits go through while + * every other rule stays under the user's own config. + * - default → nothing; the user's opencode.json governs. In + * non-interactive `run` mode any `ask` rule is denied. + * + * Exported for tests only. + */ +export function resolveOpenCodePermissionOptions(permissionMode) { + switch (permissionMode) { + case 'plan': + return { args: ['--agent', 'plan'], env: {} }; + case 'bypassPermissions': + return { args: ['--auto'], env: {} }; + case 'acceptEdits': + return { args: [], env: { OPENCODE_PERMISSION: JSON.stringify({ edit: 'allow' }) } }; + default: + return { args: [], env: {} }; + } +} + function resolveOpenCodeEffort(model, effort, modelsDefinition) { const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model); const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || []; @@ -92,7 +124,7 @@ function readOpenCodeTokenUsage(sessionId) { async function spawnOpenCode(command, options = {}, ws) { return new Promise((resolve, reject) => { - const { sessionId, projectPath, cwd, model, effort, sessionSummary } = options; + const { sessionId, projectPath, cwd, model, effort, sessionSummary, images, permissionMode } = options; const workingDir = cwd || projectPath || process.cwd(); const processKey = sessionId || Date.now().toString(); let capturedSessionId = sessionId || null; @@ -223,14 +255,20 @@ async function spawnOpenCode(command, options = {}, ws) { if (resolvedEffort) { args.push('--variant', resolvedEffort); } + const permissionOptions = resolveOpenCodePermissionOptions(permissionMode); + args.push(...permissionOptions.args); if (command && command.trim()) { - args.push(command.trim()); + // Image attachments ride along as an path list appended + // to the prompt; the session history reader strips the tag back out. + // opencode is a .cmd shim on Windows, so the whole argument must be + // newline-free or cmd.exe silently truncates it at the first newline. + args.push(flattenPromptForWindowsShell(appendImagesInputTag(command.trim(), images))); } opencodeProcess = spawnFunction('opencode', args, { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env }, + env: { ...process.env, ...permissionOptions.env }, }); activeOpenCodeProcesses.set(processKey, opencodeProcess); diff --git a/server/opencode-cli.test.js b/server/opencode-cli.test.js index acac202c..f7973e3e 100644 --- a/server/opencode-cli.test.js +++ b/server/opencode-cli.test.js @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { spawnOpenCode } from './opencode-cli.js'; +import { resolveOpenCodePermissionOptions, spawnOpenCode } from './opencode-cli.js'; const findEnvKey = (name) => Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name; @@ -14,7 +14,10 @@ async function createFakeOpenCodeExecutable(binDir) { await writeFile(scriptPath, ` const capturePath = process.env.OPENCODE_ARGS_CAPTURE; if (capturePath) { - require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2))); + require('node:fs').writeFileSync(capturePath, JSON.stringify({ + args: process.argv.slice(2), + permissionEnv: process.env.OPENCODE_PERMISSION ?? null, + })); } const events = [ @@ -86,10 +89,116 @@ test('spawnOpenCode emits session_created before normalized live messages for ne assert.equal(complete?.sessionId, 'open-live-1'); assert.equal(messages.some((message) => message.kind === 'error'), false); - const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8')); + const capture = JSON.parse(await readFile(argsCapturePath, 'utf8')); + const launchedArgs = capture.args; assert.ok(Array.isArray(launchedArgs)); assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']); assert.equal(launchedArgs[4], tempRoot); + // No permission mode requested → no permission flags and no env override. + assert.equal(launchedArgs.includes('--auto'), false); + assert.equal(launchedArgs.includes('--agent'), false); + assert.equal(capture.permissionEnv, null); + } finally { + if (previousPath === undefined) { + delete process.env[pathKey]; + } else { + process.env[pathKey] = previousPath; + } + + if (previousPathExt === undefined) { + delete process.env[pathExtKey]; + } else { + process.env[pathExtKey] = previousPathExt; + } + + if (previousArgsCapture === undefined) { + delete process.env.OPENCODE_ARGS_CAPTURE; + } else { + process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture; + } + + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('resolveOpenCodePermissionOptions maps UI permission modes onto OpenCode controls', () => { + assert.deepEqual(resolveOpenCodePermissionOptions('plan'), { + args: ['--agent', 'plan'], + env: {}, + }); + assert.deepEqual(resolveOpenCodePermissionOptions('bypassPermissions'), { + args: ['--auto'], + env: {}, + }); + assert.deepEqual(resolveOpenCodePermissionOptions('acceptEdits'), { + args: [], + env: { OPENCODE_PERMISSION: '{"edit":"allow"}' }, + }); + // default and anything unknown leave the user's own opencode config in charge. + assert.deepEqual(resolveOpenCodePermissionOptions('default'), { args: [], env: {} }); + assert.deepEqual(resolveOpenCodePermissionOptions(undefined), { args: [], env: {} }); +}); + +test('spawnOpenCode passes permission mode flags and env to the CLI', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-perms-')); + const pathKey = findEnvKey('PATH'); + const pathExtKey = findEnvKey('PATHEXT'); + const previousPath = process.env[pathKey]; + const previousPathExt = process.env[pathExtKey]; + const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE; + const writer = { + userId: null, + sessionId: null, + send() {}, + setSessionId(sessionId) { + this.sessionId = sessionId; + }, + }; + + try { + await createFakeOpenCodeExecutable(tempRoot); + process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`; + if (process.platform === 'win32') { + process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD') + ? previousPathExt + : `.COM;.EXE;.BAT;.CMD${previousPathExt ? `;${previousPathExt}` : ''}`; + } + + const scenarios = [ + { + permissionMode: 'plan', + expectArgs: ['--agent', 'plan'], + expectPermissionEnv: null, + }, + { + permissionMode: 'bypassPermissions', + expectArgs: ['--auto'], + expectPermissionEnv: null, + }, + { + permissionMode: 'acceptEdits', + expectArgs: [], + expectPermissionEnv: '{"edit":"allow"}', + }, + ]; + + for (const scenario of scenarios) { + const argsCapturePath = path.join(tempRoot, `opencode-args-${scenario.permissionMode}.json`); + process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath; + + await spawnOpenCode('Hi', { cwd: tempRoot, permissionMode: scenario.permissionMode }, writer); + + const capture = JSON.parse(await readFile(argsCapturePath, 'utf8')); + for (const expectedArg of scenario.expectArgs) { + assert.ok( + capture.args.includes(expectedArg), + `${scenario.permissionMode}: expected "${expectedArg}" in ${JSON.stringify(capture.args)}`, + ); + } + // The prompt stays the last positional argument, after any permission flags. + assert.equal(capture.args[capture.args.length - 1], 'Hi'); + assert.equal(capture.permissionEnv, scenario.expectPermissionEnv); + } } finally { if (previousPath === undefined) { delete process.env[pathKey]; diff --git a/server/routes/agent.js b/server/routes/agent.js index 5febbf61..1ae605b6 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -1,5 +1,6 @@ import express from 'express'; -import { spawn } from 'child_process'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; import path from 'path'; import os from 'os'; import { promises as fs } from 'fs'; @@ -8,7 +9,6 @@ import { userDb, apiKeysDb, githubTokensDb, projectsDb } from '../modules/databa import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; import { queryCodex } from '../openai-codex.js'; -import { spawnGemini } from '../gemini-cli.js'; import { spawnOpenCode } from '../opencode-cli.js'; import { Octokit } from '@octokit/rest'; import { providerModelsService } from '../modules/providers/services/provider-models.service.js'; @@ -636,7 +636,7 @@ class ResponseCollector { * - Source for auto-generated branch names (if createBranch=true and no branchName) * - Fallback for PR title if no commits are made * - * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' + * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'opencode' * Default: 'claude' * * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates. @@ -648,7 +648,7 @@ class ResponseCollector { * * Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'fable' * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5', - * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high', + * 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high', * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max', * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants * Codex models: 'gpt-5.4' (default), 'gpt-5.5', 'gpt-5.4-mini' @@ -759,7 +759,7 @@ class ResponseCollector { * Input Validations (400 Bad Request): * - Either githubUrl OR projectPath must be provided (not neither) * - message must be non-empty string - * - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode' + * - provider must be 'claude', 'cursor', 'codex', or 'opencode' * - createBranch/createPR requires githubUrl OR projectPath (not neither) * - branchName must pass Git naming rules (if provided) * @@ -870,8 +870,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { return res.status(400).json({ error: 'message is required' }); } - if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) { - return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' }); + if (!['claude', 'cursor', 'codex', 'opencode'].includes(provider)) { + return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "opencode"' }); } // Validate GitHub branch/PR creation requirements @@ -950,7 +950,6 @@ router.post('/', validateExternalApiKey, async (req, res) => { } const codexModels = (await providerModelsService.getProviderModels('codex')).models; - const geminiModels = (await providerModelsService.getProviderModels('gemini')).models; const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models; // Start the appropriate session @@ -987,16 +986,6 @@ router.post('/', validateExternalApiKey, async (req, res) => { effort, permissionMode: 'bypassPermissions' }, writer); - } else if (provider === 'gemini') { - console.log('✨ Starting Gemini CLI session'); - - await spawnGemini(message.trim(), { - projectPath: finalProjectPath, - cwd: finalProjectPath, - sessionId: sessionId || null, - model: model || geminiModels.DEFAULT, - skipPermissions: true // CLI mode bypasses permissions - }, writer); } else if (provider === 'opencode') { console.log('Starting OpenCode CLI session'); @@ -1005,7 +994,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { cwd: finalProjectPath, sessionId: sessionId || null, model: model || opencodeModels.DEFAULT, - effort + effort, + permissionMode: 'bypassPermissions' // Agent runs are non-interactive, like the other providers above }, writer); } diff --git a/server/routes/commands.js b/server/routes/commands.js index ea223f12..722fd545 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -15,13 +15,12 @@ const APP_ROOT = findAppRoot(__dirname); const router = express.Router(); -const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"]; +const MODEL_PROVIDERS = ["claude", "cursor", "codex", "opencode"]; const MODEL_PROVIDER_LABELS = { claude: "Claude", cursor: "Cursor", codex: "Codex", - gemini: "Gemini", opencode: "OpenCode", }; diff --git a/server/routes/gemini.js b/server/routes/gemini.js deleted file mode 100644 index 341365b4..00000000 --- a/server/routes/gemini.js +++ /dev/null @@ -1,25 +0,0 @@ -import express from 'express'; - -import sessionManager from '../sessionManager.js'; -import { sessionsDb } from '../modules/database/index.js'; - -const router = express.Router(); - -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); - sessionsDb.deleteSessionById(sessionId); - res.json({ success: true }); - } catch (error) { - console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error); - res.status(500).json({ success: false, error: error.message }); - } -}); - -export default router; diff --git a/server/routes/git.js b/server/routes/git.js index 2aebdad4..90801719 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -1,5 +1,6 @@ import express from 'express'; -import { spawn } from 'child_process'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; import path from 'path'; import { promises as fs } from 'fs'; import { projectsDb } from '../modules/database/index.js'; @@ -293,6 +294,76 @@ async function resolveRepositoryFilePath(projectPath, filePath) { } // Get git status for a project +/** + * Parses `git status --porcelain=v1 -z` output into the response shape the + * git panel consumes. NUL-separated entries carry no path quoting, so names + * with spaces/unicode survive intact (the plain porcelain output quotes and + * escapes them, which broke the old line-based parser). + * + * `staged` lists paths with index-side changes. The UI renders its "Staged" + * section from this list so it always mirrors the real git index (including + * files staged outside the app, e.g. via VSCode or the terminal). + * + * Exported for tests. + */ +export function parseGitStatusOutput(statusOutput) { + const modified = []; + const added = []; + const deleted = []; + const untracked = []; + const staged = []; + + const statusEntries = statusOutput.split('\0'); + for (let entryIndex = 0; entryIndex < statusEntries.length; entryIndex++) { + const entry = statusEntries[entryIndex]; + if (!entry || entry.length < 4) continue; + + // Porcelain v1: X = index (staged) status, Y = worktree (unstaged) status. + const indexStatus = entry[0]; + const worktreeStatus = entry[1]; + const file = entry.slice(3); + + // Renames/copies carry the original path as the following NUL entry; + // the UI tracks the post-rename path only. + if (indexStatus === 'R' || indexStatus === 'C') { + entryIndex += 1; + } + + if (indexStatus === '?') { + untracked.push(file); + continue; + } + if (indexStatus === '!') { + continue; // ignored files are never reported + } + + const isConflict = + indexStatus === 'U' || worktreeStatus === 'U' || + (indexStatus === 'A' && worktreeStatus === 'A') || + (indexStatus === 'D' && worktreeStatus === 'D'); + if (isConflict) { + // Merge conflicts must be resolved in the worktree first; surface them + // as modified and never as staged. + modified.push(file); + continue; + } + + if (indexStatus !== ' ') { + staged.push(file); + } + + if (indexStatus === 'D' || worktreeStatus === 'D') { + deleted.push(file); + } else if (indexStatus === 'A' || worktreeStatus === 'A') { + added.push(file); + } else { + modified.push(file); + } + } + + return { modified, added, deleted, untracked, staged }; +} + router.get('/status', async (req, res) => { const { project } = req.query; @@ -309,30 +380,8 @@ router.get('/status', async (req, res) => { const branch = await getCurrentBranchName(projectPath); const hasCommits = await repositoryHasCommits(projectPath); - // Get git status - const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath }); - - const modified = []; - const added = []; - const deleted = []; - const untracked = []; - - statusOutput.split('\n').forEach(line => { - if (!line.trim()) return; - - const status = line.substring(0, 2); - const file = line.substring(3); - - if (status === 'M ' || status === ' M' || status === 'MM') { - modified.push(file); - } else if (status === 'A ' || status === 'AM') { - added.push(file); - } else if (status === 'D ' || status === ' D') { - deleted.push(file); - } else if (status === '??') { - untracked.push(file); - } - }); + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain=v1', '-z'], { cwd: projectPath }); + const { modified, added, deleted, untracked, staged } = parseGitStatusOutput(statusOutput); res.json({ branch, @@ -340,7 +389,8 @@ router.get('/status', async (req, res) => { modified, added, deleted, - untracked + untracked, + staged }); } catch (error) { console.error('Git status error:', error); @@ -593,6 +643,64 @@ router.post('/commit', async (req, res) => { } }); +// Stage files (git add). Mirrors what the UI shows as the "Staged" section, +// so the app's staging state and the real git index never drift apart. +router.post('/stage', async (req, res) => { + const { project, files } = req.body; + + if (!project || !Array.isArray(files) || files.length === 0) { + return res.status(400).json({ error: 'Project id and files are required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + const repositoryRootPath = await getRepositoryRootPath(projectPath); + + for (const file of files) { + const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); + await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); + } + + res.json({ success: true }); + } catch (error) { + console.error('Git stage error:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Unstage files (remove from the index, keep the worktree changes) +router.post('/unstage', async (req, res) => { + const { project, files } = req.body; + + if (!project || !Array.isArray(files) || files.length === 0) { + return res.status(400).json({ error: 'Project id and files are required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + const repositoryRootPath = await getRepositoryRootPath(projectPath); + const hasCommits = await repositoryHasCommits(projectPath); + + for (const file of files) { + const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); + if (hasCommits) { + await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); + } else { + // No HEAD to reset against before the first commit; dropping the + // index entry is the only way to unstage while keeping the file. + await spawnAsync('git', ['rm', '--cached', '-r', '--force', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); + } + } + + res.json({ success: true }); + } catch (error) { + console.error('Git unstage error:', error); + res.status(500).json({ error: error.message }); + } +}); + // Revert latest local commit (keeps changes staged) router.post('/revert-local-commit', async (req, res) => { const { project } = req.body; @@ -754,10 +862,57 @@ router.post('/delete-branch', async (req, res) => { } }); -// Get recent commits +// Fields are joined with the ASCII unit separator so pipes (or anything else +// typed into a commit subject) cannot break parsing. +const GIT_LOG_FIELD_SEPARATOR = '\u001f'; +const GIT_LOG_PRETTY_FORMAT = '%H%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%ad%x1f%s'; + +/** + * Parses `git log --shortstat` output produced with GIT_LOG_PRETTY_FORMAT. + * + * Each commit is one format line (hash, parent hashes, ref decorations, + * author, email, date, subject) optionally followed by its `--shortstat` + * summary line ("N files changed, ..."). Parents and refs feed the commit + * graph rendered by the History view; merge commits carry no shortstat line, + * so their `stats` stays empty. + * + * Exported for tests. + */ +export function parseGitLogWithStats(stdout) { + const commits = []; + + for (const rawLine of stdout.split('\n')) { + const line = rawLine.trimEnd(); + if (!line.trim()) continue; + + if (line.includes(GIT_LOG_FIELD_SEPARATOR)) { + const [hash, parents, refs, author, email, date, ...messageParts] = line.split(GIT_LOG_FIELD_SEPARATOR); + commits.push({ + hash, + parents: parents ? parents.split(' ').filter(Boolean) : [], + // `%D` decorations, e.g. "HEAD -> main", "origin/main", "tag: v1.0". + refs: refs ? refs.split(', ').filter(Boolean) : [], + author, + email, + date, + message: messageParts.join(GIT_LOG_FIELD_SEPARATOR), + stats: '' + }); + continue; + } + + if (commits.length > 0 && /files? changed/.test(line)) { + commits[commits.length - 1].stats = line.trim(); + } + } + + return commits; +} + +// Get recent commits (across all branches, in graph order) router.get('/commits', async (req, res) => { const { project, limit = 10 } = req.query; - + if (!project) { return res.status(400).json({ error: 'Project id is required' }); } @@ -769,42 +924,28 @@ router.get('/commits', async (req, res) => { const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 10; - - // Get commit log with stats + + // Branches/remotes/tags (not --all, which would drag in refs/stash) with + // `--topo-order` guarantee children appear before their parents across + // every branch, which the frontend lane-assignment relies on. + // `--shortstat` replaces the previous per-commit `git show --stat` calls. const { stdout } = await spawnAsync( 'git', - ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)], + [ + 'log', + '--branches', + '--remotes', + '--tags', + '--topo-order', + '--shortstat', + `--pretty=format:${GIT_LOG_PRETTY_FORMAT}`, + '--date=iso-strict', + '-n', String(safeLimit) + ], { cwd: projectPath }, ); - - const commits = stdout - .split('\n') - .filter(line => line.trim()) - .map(line => { - const [hash, author, email, date, ...messageParts] = line.split('|'); - return { - hash, - author, - email, - date, - message: messageParts.join('|') - }; - }); - - // Get stats for each commit - for (const commit of commits) { - try { - const { stdout: stats } = await spawnAsync( - 'git', ['show', '--stat', '--format=', commit.hash], - { cwd: projectPath } - ); - commit.stats = stats.trim().split('\n').pop(); // Get the summary line - } catch (error) { - commit.stats = ''; - } - } - - res.json({ commits }); + + res.json({ commits: parseGitLogWithStats(stdout) }); } catch (error) { console.error('Git commits error:', error); res.json({ error: error.message }); diff --git a/server/routes/git.test.js b/server/routes/git.test.js new file mode 100644 index 00000000..9dcda053 --- /dev/null +++ b/server/routes/git.test.js @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseGitLogWithStats, parseGitStatusOutput } from './git.js'; + +// Builds `git status --porcelain=v1 -z` output: NUL-separated entries with a +// trailing NUL, exactly as git emits it. +const porcelain = (...entries) => entries.join('\0') + '\0'; + +test('parseGitStatusOutput buckets files and reports index-side staging', () => { + const output = porcelain( + 'M staged-modified.ts', + ' M unstaged-modified.ts', + 'MM staged-and-unstaged.ts', + 'A staged-new.ts', + 'D staged-deleted.ts', + ' D unstaged-deleted.ts', + '?? untracked.ts', + ); + + const result = parseGitStatusOutput(output); + + assert.deepEqual(result.modified, ['staged-modified.ts', 'unstaged-modified.ts', 'staged-and-unstaged.ts']); + assert.deepEqual(result.added, ['staged-new.ts']); + assert.deepEqual(result.deleted, ['staged-deleted.ts', 'unstaged-deleted.ts']); + assert.deepEqual(result.untracked, ['untracked.ts']); + // Only index-side (X) changes count as staged. + assert.deepEqual(result.staged, [ + 'staged-modified.ts', + 'staged-and-unstaged.ts', + 'staged-new.ts', + 'staged-deleted.ts', + ]); +}); + +test('parseGitStatusOutput keeps paths with spaces intact (-z output has no quoting)', () => { + const result = parseGitStatusOutput(porcelain('M src/my folder/some file.ts')); + assert.deepEqual(result.modified, ['src/my folder/some file.ts']); + assert.deepEqual(result.staged, ['src/my folder/some file.ts']); +}); + +test('parseGitStatusOutput tracks the post-rename path and skips the original', () => { + const output = porcelain('R renamed-to.ts', 'renamed-from.ts', ' M other.ts'); + const result = parseGitStatusOutput(output); + + assert.deepEqual(result.modified, ['renamed-to.ts', 'other.ts']); + assert.deepEqual(result.staged, ['renamed-to.ts']); + // The pre-rename path is metadata, not a change entry. + assert.equal(JSON.stringify(result).includes('renamed-from.ts'), false); +}); + +test('parseGitStatusOutput never reports merge conflicts as staged', () => { + const output = porcelain('UU conflicted.ts', 'AA both-added.ts', 'DD both-deleted.ts'); + const result = parseGitStatusOutput(output); + + assert.deepEqual(result.modified, ['conflicted.ts', 'both-added.ts', 'both-deleted.ts']); + assert.deepEqual(result.staged, []); +}); + +test('parseGitStatusOutput handles empty output', () => { + assert.deepEqual(parseGitStatusOutput(''), { + modified: [], + added: [], + deleted: [], + untracked: [], + staged: [], + }); +}); + +// Builds one `git log --pretty=format:%H%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%ad%x1f%s` line. +const US = ''; +const logLine = (hash, parents, refs, subject) => + [hash, parents, refs, 'Alice', 'a@x.com', '2026-07-06T10:00:00+03:00', subject].join(US); + +test('parseGitLogWithStats parses commits with parents, refs, and shortstat lines', () => { + const output = [ + logLine('c3', 'c2', 'HEAD -> main, origin/main, tag: v1.0', 'feat: add | pipes | to subject'), + ' 3 files changed, 10 insertions(+), 2 deletions(-)', + '', + logLine('c2', 'c1 c0', '', 'Merge branch feature'), + '', + logLine('c0', '', '', 'initial commit'), + ' 1 file changed, 1 insertion(+)', + ].join('\n'); + + const commits = parseGitLogWithStats(output); + + assert.equal(commits.length, 3); + assert.deepEqual(commits[0].parents, ['c2']); + assert.deepEqual(commits[0].refs, ['HEAD -> main', 'origin/main', 'tag: v1.0']); + // Pipes in the subject survive because fields are joined with . + assert.equal(commits[0].message, 'feat: add | pipes | to subject'); + assert.equal(commits[0].stats, '3 files changed, 10 insertions(+), 2 deletions(-)'); + + // Merge commit: two parents, no shortstat line. + assert.deepEqual(commits[1].parents, ['c1', 'c0']); + assert.equal(commits[1].stats, ''); + + // Root commit: no parents. + assert.deepEqual(commits[2].parents, []); + assert.equal(commits[2].stats, '1 file changed, 1 insertion(+)'); +}); + +test('parseGitLogWithStats handles empty output', () => { + assert.deepEqual(parseGitLogWithStats(''), []); +}); diff --git a/server/routes/taskmaster.js b/server/routes/taskmaster.js index 01a8d801..64dcde37 100644 --- a/server/routes/taskmaster.js +++ b/server/routes/taskmaster.js @@ -12,7 +12,9 @@ import express from 'express'; import fs from 'fs'; import path from 'path'; import { promises as fsPromises } from 'fs'; -import { spawn } from 'child_process'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution — required +// here since task-master/npx are .cmd shims on Windows. +import spawn from 'cross-spawn'; import { projectsDb } from '../modules/database/index.js'; import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js'; import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js'; diff --git a/server/routes/user.js b/server/routes/user.js index dcb8ecd7..95215a8c 100644 --- a/server/routes/user.js +++ b/server/routes/user.js @@ -1,8 +1,9 @@ import express from 'express'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; import { userDb } from '../modules/database/index.js'; import { authenticateToken } from '../middleware/auth.js'; import { getSystemGitConfig } from '../utils/gitConfig.js'; -import { spawn } from 'child_process'; const router = express.Router(); diff --git a/server/sessionManager.js b/server/sessionManager.js deleted file mode 100644 index 1bf33bd4..00000000 --- a/server/sessionManager.js +++ /dev/null @@ -1,226 +0,0 @@ -import { promises as fs } from 'fs'; -import path from 'path'; -import os from 'os'; - -class SessionManager { - constructor() { - // Store sessions in memory with conversation history - this.sessions = new Map(); - this.maxSessions = 100; - this.sessionsDir = path.join(os.homedir(), '.gemini', 'sessions'); - this.ready = this.init(); - } - - async init() { - await this.initSessionsDir(); - await this.loadSessions(); - } - - async initSessionsDir() { - try { - await fs.mkdir(this.sessionsDir, { recursive: true }); - } catch (error) { - // console.error('Error creating sessions directory:', error); - } - } - - // Create a new session - createSession(sessionId, projectPath) { - const session = { - id: sessionId, - projectPath: projectPath, - messages: [], - createdAt: new Date(), - lastActivity: new Date() - }; - - // Evict oldest session from memory if we exceed limit - if (this.sessions.size >= this.maxSessions) { - const oldestKey = this.sessions.keys().next().value; - if (oldestKey) this.sessions.delete(oldestKey); - } - - this.sessions.set(sessionId, session); - this.saveSession(sessionId); - - return session; - } - - // Add a message to session - addMessage(sessionId, role, content) { - let session = this.sessions.get(sessionId); - - if (!session) { - // Create session if it doesn't exist - session = this.createSession(sessionId, ''); - } - - const message = { - role: role, // 'user' or 'assistant' - content: content, - timestamp: new Date() - }; - - session.messages.push(message); - session.lastActivity = new Date(); - - this.saveSession(sessionId); - - return session; - } - - // Get session by ID - getSession(sessionId) { - return this.sessions.get(sessionId); - } - - // Get all sessions for a project - getProjectSessions(projectPath) { - const sessions = []; - - for (const [id, session] of this.sessions) { - if (session.projectPath === projectPath) { - sessions.push({ - id: session.id, - summary: this.getSessionSummary(session), - messageCount: session.messages.length, - lastActivity: session.lastActivity - }); - } - } - - return sessions.sort((a, b) => - new Date(b.lastActivity) - new Date(a.lastActivity) - ); - } - - // Get session summary - getSessionSummary(session) { - if (session.messages.length === 0) { - return 'New Session'; - } - - // Find first user message - const firstUserMessage = session.messages.find(m => m.role === 'user'); - if (firstUserMessage) { - const content = firstUserMessage.content; - return content.length > 50 ? content.substring(0, 50) + '...' : content; - } - - return 'New Session'; - } - - // Build conversation context for Gemini - buildConversationContext(sessionId, maxMessages = 10) { - const session = this.sessions.get(sessionId); - - if (!session || session.messages.length === 0) { - return ''; - } - - // Get last N messages for context - const recentMessages = session.messages.slice(-maxMessages); - - let context = 'Here is the conversation history:\n\n'; - - for (const msg of recentMessages) { - if (msg.role === 'user') { - context += `User: ${msg.content}\n`; - } else { - context += `Assistant: ${msg.content}\n`; - } - } - - context += '\nBased on the conversation history above, please answer the following:\n'; - - return context; - } - - // Prevent path traversal - _safeFilePath(sessionId) { - const safeId = String(sessionId).replace(/[/\\]|\.\./g, ''); - return path.join(this.sessionsDir, `${safeId}.json`); - } - - // Save session to disk - async saveSession(sessionId) { - const session = this.sessions.get(sessionId); - if (!session) return; - - try { - const filePath = this._safeFilePath(sessionId); - await fs.writeFile(filePath, JSON.stringify(session, null, 2)); - } catch (error) { - // console.error('Error saving session:', error); - } - } - - // Load sessions from disk - async loadSessions() { - try { - const files = await fs.readdir(this.sessionsDir); - - for (const file of files) { - if (file.endsWith('.json')) { - try { - const filePath = path.join(this.sessionsDir, file); - const data = await fs.readFile(filePath, 'utf8'); - const session = JSON.parse(data); - - // Convert dates - session.createdAt = new Date(session.createdAt); - session.lastActivity = new Date(session.lastActivity); - session.messages.forEach(msg => { - msg.timestamp = new Date(msg.timestamp); - }); - - this.sessions.set(session.id, session); - } catch (error) { - // console.error(`Error loading session ${file}:`, error); - } - } - } - - // Enforce eviction after loading to prevent massive memory usage - while (this.sessions.size > this.maxSessions) { - const oldestKey = this.sessions.keys().next().value; - if (oldestKey) this.sessions.delete(oldestKey); - } - } catch (error) { - // console.error('Error loading sessions:', error); - } - } - - // Delete a session - async deleteSession(sessionId) { - this.sessions.delete(sessionId); - - try { - const filePath = this._safeFilePath(sessionId); - await fs.unlink(filePath); - } catch (error) { - // console.error('Error deleting session file:', error); - } - } - - // Get session messages for display - getSessionMessages(sessionId) { - const session = this.sessions.get(sessionId); - if (!session) return []; - - return session.messages.map(msg => ({ - type: 'message', - message: { - role: msg.role, - content: msg.content - }, - timestamp: msg.timestamp.toISOString() - })); - } -} - -// Singleton instance -const sessionManager = new SessionManager(); - -export const ready = sessionManager.ready; -export default sessionManager; \ No newline at end of file diff --git a/server/shared/image-attachments.ts b/server/shared/image-attachments.ts new file mode 100644 index 00000000..752be34e --- /dev/null +++ b/server/shared/image-attachments.ts @@ -0,0 +1,341 @@ +import { promises as fs, realpathSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * Shared image-attachment plumbing for every provider runtime. + * + * Uploaded chat images are persisted once in the global `~/.cloudcli/assets` + * folder and referenced by absolute path everywhere else: + * - Claude: paths are read back into base64 `image` content blocks. + * - Codex: paths become `local_image` input items. + * - Cursor/OpenCode: paths are appended to the prompt inside an + * `` tag, which is stripped again when history is read. + * + * The chat UI loads them through the dedicated `/api/assets/images/:filename` + * route, which serves only from this folder. + */ + +/** Global storage folder for uploaded chat image attachments. */ +export function getGlobalImageAssetsDir(): string { + return path.join(os.homedir(), '.cloudcli', 'assets'); +} + +export type ImageAttachmentDescriptor = { + /** Project-relative (preferred) or absolute path to the stored image. */ + path: string; + name?: string; + mimeType?: string; +}; + +/** Media types the Claude Messages API accepts for base64 image blocks. */ +const CLAUDE_IMAGE_MEDIA_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +]); + +const EXTENSION_TO_MEDIA_TYPE: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', +}; + +/** + * Accepts the loosely-typed `options.images` payload from chat.send and + * returns only well-formed descriptors. Plain path strings are supported so + * callers can also pass bare path arrays. + */ +export function normalizeImageDescriptors(images: unknown): ImageAttachmentDescriptor[] { + if (!Array.isArray(images)) { + return []; + } + + const descriptors: ImageAttachmentDescriptor[] = []; + for (const entry of images) { + if (typeof entry === 'string' && entry.trim()) { + descriptors.push({ path: entry.trim() }); + continue; + } + if (entry && typeof entry === 'object') { + const record = entry as Record; + const entryPath = typeof record.path === 'string' ? record.path.trim() : ''; + if (!entryPath) { + continue; + } + descriptors.push({ + path: entryPath, + name: typeof record.name === 'string' ? record.name : undefined, + mimeType: typeof record.mimeType === 'string' ? record.mimeType : undefined, + }); + } + } + return descriptors; +} + +/** Normalizes Windows separators so stored references stay portable. */ +export function toPosixPath(value: string): string { + return value.replace(/\\/g, '/'); +} + +/** Resolves a project-relative image path against the run's working directory. */ +export function resolveImageAbsolutePath(cwd: string | undefined, imagePath: string): string { + if (path.isAbsolute(imagePath)) { + return imagePath; + } + return path.resolve(cwd || process.cwd(), imagePath); +} + +function isPathInsideDirectory(candidate: string, directory: string): boolean { + // resolve + startsWith(root + separator) is the containment idiom CodeQL + // recognizes as a path-injection barrier, and matches the check used by + // resolveImageAssetFile in the assets module. The root itself never + // matches (no trailing separator after resolve), only entries below it. + const resolvedRoot = path.resolve(directory) + path.sep; + return path.resolve(candidate).startsWith(resolvedRoot); +} + +function getDirectoryPathVariants(directory: string): string[] { + const resolvedDirectory = path.resolve(directory); + try { + const canonicalDirectory = path.resolve(realpathSync(directory)); + return canonicalDirectory === resolvedDirectory + ? [resolvedDirectory] + : [resolvedDirectory, canonicalDirectory]; + } catch { + return [resolvedDirectory]; + } +} + +/** + * Second layer of the image trust boundary (the first is the chat.send filter + * in the websocket gateway): provider builders only reference files that live + * in the global upload store or inside the run's working directory — places + * the agent could already access on its own. Anything else (e.g. `~/.ssh`) is + * refused, so a caller-supplied descriptor can never leak arbitrary files. + */ +export function isAllowedImageSourcePath(resolvedPath: string, cwd?: string): boolean { + return [getGlobalImageAssetsDir(), cwd || process.cwd()].some((directory) => + getDirectoryPathVariants(directory).some((directoryVariant) => + isPathInsideDirectory(resolvedPath, directoryVariant) + ) + ); +} + +/** + * Resolves the media type for one image, preferring the uploaded mime type and + * falling back to the file extension. + */ +export function resolveImageMediaType(descriptor: ImageAttachmentDescriptor): string | null { + if (descriptor.mimeType) { + return descriptor.mimeType; + } + const extension = path.extname(descriptor.path).toLowerCase(); + return EXTENSION_TO_MEDIA_TYPE[extension] || null; +} + +const IMAGES_INPUT_TAG_PATTERN = /\s*([\s\S]*?)<\/images_input>\s*/g; + +// One image reference recovered from an block: the stored +// asset path plus the user's original filename when it was recorded. +export type ParsedImageAttachment = { + path: string; + name?: string; +}; + +// Result of stripping an block out of persisted prompt text. +// `imagePaths` mirrors `attachments` for callers that only need paths. +export type ParsedImagesInput = { + text: string; + imagePaths: string[]; + attachments: ParsedImageAttachment[]; +}; + +/** + * Appends the `` reference block used by the Cursor and + * OpenCode CLIs. The block carries one numbered line per attachment with + * the stored file path (quote-free on purpose — Windows .cmd shims mangle + * quoted text) and the user's original filename, plus an explicit instruction + * to read the files and keep the block out of the reply. The same block is + * stripped back out of persisted history by {@link parseImagesInputTag}. + */ +export function appendImagesInputTag(prompt: string, images: unknown): string { + const descriptors = normalizeImageDescriptors(images); + if (descriptors.length === 0) { + return prompt; + } + + const entryLines = descriptors.map((descriptor, index) => { + const entryPath = toPosixPath(descriptor.path); + // Parentheses and newlines would break the "(original name: ...)" suffix + // the parser looks for, so drop them from the display name. + const cleanName = descriptor.name?.replace(/[()\r\n]/g, '').trim(); + return cleanName + ? `${index + 1}. ${entryPath} (original name: ${cleanName})` + : `${index + 1}. ${entryPath}`; + }); + + return [ + prompt, + '', + '', + `The user attached ${descriptors.length} image(s) to this message. Read each file listed below with your file/image reading tool and use what you see to answer the prompt above. Respond as if the images were attached directly. Do not mention this block or the file paths unless the user asks about them.`, + ...entryLines, + '', + ].join('\n'); +} + +// Matches one numbered attachment entry inside the tag body. Works for both +// the multi-line block and the Windows-flattened single-line form, where the +// next ` N. ` marker (or the end of the body) delimits each entry. +const IMAGES_INPUT_ENTRY_PATTERN = /\d+\.\s+(.+?)(?=\s+\d+\.\s+|\s*$)/g; + +const ORIGINAL_NAME_SUFFIX_PATTERN = /\(original name: ([^)]*)\)\s*$/; + +function parseNumberedImageEntries(inner: string): ParsedImageAttachment[] { + const attachments: ParsedImageAttachment[] = []; + for (const entryMatch of inner.matchAll(IMAGES_INPUT_ENTRY_PATTERN)) { + let entryText = entryMatch[1].trim(); + let name: string | undefined; + + const nameMatch = ORIGINAL_NAME_SUFFIX_PATTERN.exec(entryText); + if (nameMatch) { + name = nameMatch[1].trim() || undefined; + entryText = entryText.slice(0, nameMatch.index).trim(); + } + + if (entryText) { + attachments.push(name ? { path: toPosixPath(entryText), name } : { path: toPosixPath(entryText) }); + } + } + return attachments; +} + +/** + * Strips one `` block from persisted prompt text and returns + * the clean text plus the referenced attachments (path and original name). + * + * Only the LAST block in the text is treated as the attachment carrier — the + * composer always appends it at the end, so a user who literally typed + * `` earlier in their prompt keeps that text intact. + * + * Understands the numbered-line body in both its multi-line and + * Windows-flattened single-line forms. + */ +export function parseImagesInputTag(text: string): ParsedImagesInput { + if (typeof text !== 'string' || !text.includes('')) { + return { text, imagePaths: [], attachments: [] }; + } + + let lastMatch: RegExpExecArray | null = null; + IMAGES_INPUT_TAG_PATTERN.lastIndex = 0; + for (let match = IMAGES_INPUT_TAG_PATTERN.exec(text); match; match = IMAGES_INPUT_TAG_PATTERN.exec(text)) { + lastMatch = match; + } + if (!lastMatch) { + return { text, imagePaths: [], attachments: [] }; + } + + const attachments = parseNumberedImageEntries(lastMatch[1]); + + const stripped = ( + text.slice(0, lastMatch.index) + '\n' + text.slice(lastMatch.index + lastMatch[0].length) + ).trim(); + + return { + text: stripped, + imagePaths: attachments.map((attachment) => attachment.path), + attachments, + }; +} + +/** Maps raw image paths to the attachment shape carried by NormalizedMessage.images. */ +export function toImageAttachments(imagePaths: string[]): Array<{ path: string }> { + return imagePaths.map((imagePath) => ({ path: toPosixPath(imagePath) })); +} + +type ClaudeContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }; + +/** + * Builds the Claude user-message content list: the prompt text followed by one + * base64 `image` block per attachment. Images the Claude API cannot accept + * (e.g. SVG) or that fail to read are skipped with a warning so the prompt + * itself still goes through. + */ +export async function buildClaudeUserContent( + prompt: string, + images: unknown, + cwd?: string, +): Promise { + const blocks: ClaudeContentBlock[] = [{ type: 'text', text: prompt }]; + + for (const descriptor of normalizeImageDescriptors(images)) { + const mediaType = resolveImageMediaType(descriptor); + if (!mediaType || !CLAUDE_IMAGE_MEDIA_TYPES.has(mediaType)) { + console.warn(`[Images] Skipping unsupported Claude image type for ${descriptor.path}`); + continue; + } + + const resolvedPath = resolveImageAbsolutePath(cwd, descriptor.path); + if (!isAllowedImageSourcePath(resolvedPath, cwd)) { + console.warn(`[Images] Refusing to read image outside allowed roots: ${descriptor.path}`); + continue; + } + + try { + const canonicalPath = await fs.realpath(resolvedPath); + if (!isAllowedImageSourcePath(canonicalPath, cwd)) { + console.warn(`[Images] Refusing to read symlinked image outside allowed roots: ${descriptor.path}`); + continue; + } + + const bytes = await fs.readFile(canonicalPath); + blocks.push({ + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: bytes.toString('base64'), + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[Images] Failed to read image ${descriptor.path}: ${message}`); + } + } + + return blocks; +} + +type CodexInputItem = + | { type: 'text'; text: string } + | { type: 'local_image'; path: string }; + +/** + * Builds the Codex `runStreamed` input list: prompt text plus one + * `local_image` item per attachment, resolved to absolute paths so the Codex + * runtime can read them regardless of its own working directory handling. + */ +export function buildCodexInputItems(prompt: string, images: unknown, cwd?: string): CodexInputItem[] { + const items: CodexInputItem[] = [{ type: 'text', text: prompt }]; + for (const descriptor of normalizeImageDescriptors(images)) { + const resolvedPath = resolveImageAbsolutePath(cwd, descriptor.path); + if (!isAllowedImageSourcePath(resolvedPath, cwd)) { + // Same trust boundary as buildClaudeUserContent — the Codex runtime + // reads this file, so it must stay within the allowed roots. + console.warn(`[Images] Refusing to attach image outside allowed roots: ${descriptor.path}`); + continue; + } + items.push({ + type: 'local_image', + path: resolvedPath, + }); + } + return items; +} diff --git a/server/shared/tests/image-attachments.test.ts b/server/shared/tests/image-attachments.test.ts new file mode 100644 index 00000000..0ddba881 --- /dev/null +++ b/server/shared/tests/image-attachments.test.ts @@ -0,0 +1,298 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + appendImagesInputTag, + buildClaudeUserContent, + buildCodexInputItems, + isAllowedImageSourcePath, + normalizeImageDescriptors, + parseImagesInputTag, + resolveImageMediaType, + toImageAttachments, +} from '@/shared/image-attachments.js'; + +// 1x1 transparent PNG +const PNG_BYTES = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +); + +const SYMLINK_UNSUPPORTED_CODES = new Set(['EACCES', 'EINVAL', 'ENOSYS', 'ENOTSUP', 'EPERM']); + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +async function createSymlinkIfSupported( + target: string, + linkPath: string, + type: 'dir' | 'file' | 'junction', +): Promise { + try { + await symlink(target, linkPath, type); + return true; + } catch (error) { + if ( + isErrnoException(error) && + typeof error.code === 'string' && + SYMLINK_UNSUPPORTED_CODES.has(error.code) + ) { + return false; + } + throw error; + } +} + +test('normalizeImageDescriptors accepts objects and bare paths, drops junk', () => { + const descriptors = normalizeImageDescriptors([ + { path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' }, + 'scripts/pic.jpg', + { name: 'no-path.png' }, + 42, + null, + '', + ]); + + assert.deepEqual(descriptors, [ + { path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' }, + { path: 'scripts/pic.jpg' }, + ]); + assert.deepEqual(normalizeImageDescriptors(undefined), []); + assert.deepEqual(normalizeImageDescriptors('not-an-array'), []); +}); + +test('appendImagesInputTag and parseImagesInputTag round-trip', () => { + const prompt = 'Describe these screenshots.\n\nFocus on the header.'; + const tagged = appendImagesInputTag(prompt, [ + { path: '.cloudcli/assets/1-a.png' }, + { path: '.cloudcli\\assets\\2-b.jpg' }, + ]); + + assert.ok(tagged.startsWith(prompt)); + assert.ok(tagged.includes('')); + assert.ok(tagged.includes('')); + assert.ok(tagged.includes('The user attached 2 image(s)')); + + const parsed = parseImagesInputTag(tagged); + assert.equal(parsed.text, prompt); + // Backslashes are normalized so references stay portable. + assert.deepEqual(parsed.imagePaths, ['.cloudcli/assets/1-a.png', '.cloudcli/assets/2-b.jpg']); +}); + +test('original filenames round-trip through the tag', () => { + const tagged = appendImagesInputTag('compare these', [ + { path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot (final).png' }, + { path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' }, + ]); + + const parsed = parseImagesInputTag(tagged); + assert.equal(parsed.text, 'compare these'); + // Parentheses are dropped from names so the "(original name: ...)" suffix + // stays parseable; the path-only entry carries no name. + assert.deepEqual(parsed.attachments, [ + { path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot final.png' }, + { path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' }, + ]); +}); + +test('only the LAST images_input block is treated as the attachment carrier', () => { + const userTypedTag = 'What does mean in this codebase?'; + const tagged = appendImagesInputTag( + `${userTypedTag}\n\n\nfake user block\n\n\nAlso check this.`, + [{ path: 'C:/Users/x/.cloudcli/assets/real.png' }], + ); + + const parsed = parseImagesInputTag(tagged); + assert.ok(parsed.text.includes('fake user block')); + assert.ok(parsed.text.includes('Also check this.')); + assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/real.png']); +}); + +test('appendImagesInputTag without images returns the prompt untouched', () => { + assert.equal(appendImagesInputTag('hello', []), 'hello'); + assert.equal(appendImagesInputTag('hello', undefined), 'hello'); +}); + +test('parseImagesInputTag handles prompts flattened to one line for cmd.exe shims', () => { + // Windows spawn runtimes collapse newlines before passing the argument to + // .cmd-shimmed CLIs; the persisted prompt is then a single line. + const flattened = appendImagesInputTag('now?', [{ path: 'C:/Users/x/.cloudcli/assets/a.jpg' }]) + .replace(/\s*\r?\n\s*/g, ' ') + .trim(); + + assert.ok(!flattened.includes('\n')); + const parsed = parseImagesInputTag(flattened); + assert.equal(parsed.text, 'now?'); + assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/a.jpg']); +}); + +test('parseImagesInputTag leaves text without a tag untouched', () => { + const text = 'Just a normal prompt with [brackets] and JSON ["like"] content.'; + const parsed = parseImagesInputTag(text); + assert.equal(parsed.text, text); + assert.deepEqual(parsed.imagePaths, []); +}); + +test('parseImagesInputTag strips a malformed tag body without attaching images', () => { + const text = 'prompt\n\n\nnot json here\n'; + const parsed = parseImagesInputTag(text); + assert.equal(parsed.text, 'prompt'); + assert.deepEqual(parsed.imagePaths, []); +}); + +test('toImageAttachments maps paths to posix attachment records', () => { + assert.deepEqual(toImageAttachments(['a\\b\\c.png', 'd/e.jpg']), [ + { path: 'a/b/c.png' }, + { path: 'd/e.jpg' }, + ]); +}); + +test('resolveImageMediaType prefers the mime type and falls back to the extension', () => { + assert.equal(resolveImageMediaType({ path: 'x.bin', mimeType: 'image/webp' }), 'image/webp'); + assert.equal(resolveImageMediaType({ path: 'x.JPG' }), 'image/jpeg'); + assert.equal(resolveImageMediaType({ path: 'x.png' }), 'image/png'); + assert.equal(resolveImageMediaType({ path: 'x.unknown' }), null); +}); + +test('buildClaudeUserContent reads image bytes into base64 blocks', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-')); + try { + await writeFile(path.join(tempDir, 'shot.png'), PNG_BYTES); + + const content = await buildClaudeUserContent( + 'What is in this image?', + [{ path: 'shot.png', mimeType: 'image/png' }], + tempDir, + ); + + assert.equal(content.length, 2); + assert.deepEqual(content[0], { type: 'text', text: 'What is in this image?' }); + assert.equal(content[1].type, 'image'); + const imageBlock = content[1] as Extract<(typeof content)[number], { type: 'image' }>; + assert.equal(imageBlock.source.type, 'base64'); + assert.equal(imageBlock.source.media_type, 'image/png'); + assert.equal(imageBlock.source.data, PNG_BYTES.toString('base64')); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('buildClaudeUserContent skips unsupported types and unreadable files', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-')); + try { + await writeFile(path.join(tempDir, 'vector.svg'), ''); + + const content = await buildClaudeUserContent( + 'prompt', + [ + { path: 'vector.svg', mimeType: 'image/svg+xml' }, + { path: 'missing.png', mimeType: 'image/png' }, + ], + tempDir, + ); + + // Only the text block survives; the prompt still goes through. + assert.deepEqual(content, [{ type: 'text', text: 'prompt' }]); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('buildClaudeUserContent refuses symlinked images outside allowed roots', async (t) => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-')); + const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-outside-')); + try { + const outsideFile = path.join(outsideDir, 'secret.png'); + await writeFile(outsideFile, PNG_BYTES); + + const linkPath = path.join(tempDir, 'linked-secret.png'); + if (!(await createSymlinkIfSupported(outsideFile, linkPath, 'file'))) { + t.skip('Symlink creation is not supported in this environment'); + return; + } + + const content = await buildClaudeUserContent( + 'prompt', + [{ path: 'linked-secret.png', mimeType: 'image/png' }], + tempDir, + ); + + assert.deepEqual(content, [{ type: 'text', text: 'prompt' }]); + } finally { + await rm(tempDir, { recursive: true, force: true }); + await rm(outsideDir, { recursive: true, force: true }); + } +}); + +test('buildClaudeUserContent accepts images under a symlinked cwd', async (t) => { + const realProjectDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-project-')); + const linkParentDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-link-')); + try { + await writeFile(path.join(realProjectDir, 'shot.png'), PNG_BYTES); + + const linkCwd = path.join(linkParentDir, 'project-link'); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + if (!(await createSymlinkIfSupported(realProjectDir, linkCwd, linkType))) { + t.skip('Symlink creation is not supported in this environment'); + return; + } + + const content = await buildClaudeUserContent( + 'prompt', + [{ path: 'shot.png', mimeType: 'image/png' }], + linkCwd, + ); + + assert.equal(content.length, 2); + assert.equal(content[1].type, 'image'); + const imageBlock = content[1] as Extract<(typeof content)[number], { type: 'image' }>; + assert.equal(imageBlock.source.data, PNG_BYTES.toString('base64')); + } finally { + await rm(linkParentDir, { recursive: true, force: true }); + await rm(realProjectDir, { recursive: true, force: true }); + } +}); + +test('buildCodexInputItems emits text plus absolute local_image paths', () => { + const cwd = path.join(os.tmpdir(), 'codex-project'); + const items = buildCodexInputItems('Describe this image:', [{ path: '.cloudcli/assets/pic.jpg' }], cwd); + + assert.equal(items.length, 2); + assert.deepEqual(items[0], { type: 'text', text: 'Describe this image:' }); + assert.equal(items[1].type, 'local_image'); + const imageItem = items[1] as Extract<(typeof items)[number], { type: 'local_image' }>; + assert.ok(path.isAbsolute(imageItem.path)); + assert.equal(imageItem.path, path.resolve(cwd, '.cloudcli/assets/pic.jpg')); +}); + +test('isAllowedImageSourcePath only accepts the upload store and the run cwd', () => { + const cwd = path.join(os.tmpdir(), 'some-project'); + const uploadStore = path.join(os.homedir(), '.cloudcli', 'assets'); + + assert.equal(isAllowedImageSourcePath(path.join(uploadStore, 'shot.png'), cwd), true); + assert.equal(isAllowedImageSourcePath(path.join(cwd, 'docs', 'diagram.png'), cwd), true); + + assert.equal(isAllowedImageSourcePath(path.join(os.homedir(), '.ssh', 'id_rsa'), cwd), false); + assert.equal(isAllowedImageSourcePath(path.join(cwd, '..', 'other-project', 'x.png'), cwd), false); + // The roots themselves are directories, not readable image files. + assert.equal(isAllowedImageSourcePath(cwd, cwd), false); +}); + +test('provider builders refuse descriptors outside the allowed roots', async () => { + const cwd = path.join(os.tmpdir(), 'codex-project'); + const outsidePath = path.join(os.homedir(), '.ssh', 'id_rsa.png'); + + const codexItems = buildCodexInputItems('prompt', [{ path: outsidePath }], cwd); + assert.deepEqual(codexItems, [{ type: 'text', text: 'prompt' }]); + + const claudeContent = await buildClaudeUserContent( + 'prompt', + [{ path: outsidePath, mimeType: 'image/png' }], + cwd, + ); + assert.deepEqual(claudeContent, [{ type: 'text', text: 'prompt' }]); +}); diff --git a/server/shared/types.ts b/server/shared/types.ts index c803856b..45092ecf 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -65,7 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & { * Use this as the source of truth whenever a function or payload needs to identify * a specific LLM integration. */ -export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode'; +export type LLMProvider = 'claude' | 'codex' | 'cursor' | 'opencode'; /** * One selectable model row in a provider model catalog. diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 503b21a8..e448595c 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -1066,6 +1066,30 @@ export function getOpenCodeDatabasePath(): string { return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db'); } +/** + * Decodes an OpenCode text payload that was persisted as a JSON string literal. + * + * OpenCode can store the first user prompt (and other text parts) as `"hello"` + * instead of `hello`. Used by both the OpenCode session reader (transcript + * history) and the OpenCode synchronizer (session titling) so a session name or + * message body never surfaces with surrounding quote characters. Only fully + * quoted, valid JSON string literals are unwrapped; ordinary prose that merely + * happens to start/end with a quote is returned untouched. + */ +export function unwrapJsonStringLiteral(value: string): string { + const trimmed = value.trim(); + if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) { + return value; + } + + try { + const parsed = JSON.parse(trimmed); + return typeof parsed === 'string' ? parsed : value; + } catch { + return value; + } +} + // --------------------------- //----------------- SAFE DIRECTORY NAME UTILITIES ------------ /** @@ -1239,3 +1263,24 @@ export async function extractFirstValidJsonlData( return null; } +// --------------------------- +//----------------- CLI PROMPT ARGUMENT UTILITIES ------------ +/** + * Makes a prompt safe to pass as one CLI argument to `.cmd`-shimmed tools on + * Windows (cursor-agent and opencode installed via npm-style shims). + * + * cmd.exe cannot carry newlines inside an argument: everything after the + * first newline is silently dropped before the target CLI ever sees it, which + * truncates multi-line prompts and any appended `` block. + * Collapsing newline runs to single spaces loses formatting but never loses + * content, so runtimes should call this on win32 right before spawning. + * + * Used by the cursor and opencode spawn runtimes. + */ +export function flattenPromptForWindowsShell(prompt: string): string { + if (process.platform !== 'win32' || typeof prompt !== 'string') { + return prompt; + } + return prompt.replace(/\s*\r?\n\s*/g, ' ').trim(); +} + diff --git a/server/utils/gitConfig.js b/server/utils/gitConfig.js index 586933a0..f418b397 100644 --- a/server/utils/gitConfig.js +++ b/server/utils/gitConfig.js @@ -1,4 +1,5 @@ -import { spawn } from 'child_process'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; function spawnAsync(command, args) { return new Promise((resolve, reject) => { diff --git a/server/utils/plugin-process-manager.js b/server/utils/plugin-process-manager.js index f7e325af..847b50bb 100644 --- a/server/utils/plugin-process-manager.js +++ b/server/utils/plugin-process-manager.js @@ -1,5 +1,7 @@ -import { spawn } from 'child_process'; import path from 'path'; + +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js'; // Map diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 6cae890e..d94b75d9 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -10,6 +10,7 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useProjectsState } from '../../hooks/useProjectsState'; +import { useQueuedMessageAutoSend } from '../../hooks/useQueuedMessageAutoSend'; import { api } from '../../utils/api'; type RunningSessionApiItem = { @@ -84,6 +85,17 @@ function AppContentInner() { activeSessions: processingSessions, }); + // Queued messages for sessions that finish while another session (or none) + // is being viewed are sent from here; the viewed session's composer handles + // its own queue. + useQueuedMessageAutoSend({ + processingSessions, + activeSessionId: selectedSession?.id ?? sessionId ?? null, + ws, + sendMessage, + markSessionProcessing, + }); + const refreshRunningSessions = useCallback(async () => { try { const response = await api.runningSessions(); diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 9dd1bbc0..7543ebae 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -14,7 +14,13 @@ import { useDropzone } from 'react-dropzone'; import { authenticatedFetch } from '../../../utils/api'; import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection'; import { grantClaudeToolPermission } from '../utils/chatPermissions'; -import { safeLocalStorage } from '../utils/chatStorage'; +import { + clearQueuedMessage, + readQueuedMessage, + safeLocalStorage, + writeQueuedMessage, + type QueuedSendOptions, +} from '../utils/chatStorage'; import type { ChatMessage, PendingPermissionRequest, @@ -39,7 +45,6 @@ interface UseChatComposerStateArgs { claudeModel: string; codexModel: string; currentProviderEffort: string; - geminiModel: string; opencodeModel: string; isLoading: boolean; canAbortSession: boolean; @@ -145,6 +150,23 @@ const createFakeSubmitEvent = () => { return { preventDefault: () => undefined } as unknown as FormEvent; }; +export type QueuedDraft = { + content: string; + images: File[]; + /** + * Send options snapshotted at queue time. Persisted with the draft so the + * app-level auto-send can dispatch the message with the right model and + * permission settings while another session is being viewed. + */ + options?: QueuedSendOptions; +}; + +const restoreQueuedDraft = (sessionKey: string): QueuedDraft | null => { + const saved = readQueuedMessage(sessionKey); + // Image attachments can't survive a reload; only text and options persist. + return saved ? { content: saved.content, images: [], options: saved.options } : null; +}; + const getNotificationSessionSummary = ( selectedSession: ProjectSession | null, fallbackInput: string, @@ -175,7 +197,6 @@ export function useChatComposerState({ claudeModel, codexModel, currentProviderEffort, - geminiModel, opencodeModel, isLoading, canAbortSession, @@ -215,6 +236,22 @@ export function useChatComposerState({ >(null); const inputValueRef = useRef(input); const selectedProjectId = selectedProject?.projectId; + // Prefer the stable backend-allocated id (selectedSession.id) but fall back + // to currentSessionId for a just-established session that hasn't been + // handed back to the parent's `selectedSession` prop yet. + const sessionKey = selectedSession?.id || currentSessionId || null; + + const [queuedDraft, setQueuedDraft] = useState(() => { + if (typeof window === 'undefined' || !sessionKey) { + return null; + } + return restoreQueuedDraft(sessionKey); + }); + // Which session the in-memory `queuedDraft` belongs to. On a session switch + // there is one commit where `sessionKey` already points at the new session + // while `queuedDraft` still holds the old session's draft; the persistence + // effect must not write across that gap. + const queuedDraftSessionRef = useRef(sessionKey); const handleBuiltInCommand = useCallback( (result: CommandExecutionResult) => { @@ -336,9 +373,7 @@ export function useChatComposerState({ ? cursorModel : provider === 'codex' ? codexModel - : provider === 'gemini' - ? geminiModel - : provider === 'opencode' + : provider === 'opencode' ? opencodeModel : claudeModel, tokenUsage: tokenBudget, @@ -393,7 +428,6 @@ export function useChatComposerState({ codexModel, currentSessionId, cursorModel, - geminiModel, opencodeModel, handleBuiltInCommand, handleCustomCommand, @@ -549,13 +583,98 @@ export function useChatComposerState({ noKeyboard: true, }); + // Snapshot of everything `chat.send` needs beyond the text itself. Built at + // send time for immediate sends and at queue time for queued ones, so a + // queued message keeps the provider settings it was composed under even if + // it is later dispatched outside this composer (app-level auto-send). + const buildSendOptions = useCallback((currentInput: string): QueuedSendOptions => { + const getToolsSettings = () => { + try { + const settingsKey = + provider === 'cursor' + ? 'cursor-tools-settings' + : provider === 'codex' + ? 'codex-settings' + : provider === 'opencode' + ? 'opencode-settings' + : 'claude-settings'; + const savedSettings = safeLocalStorage.getItem(settingsKey); + if (savedSettings) { + return JSON.parse(savedSettings); + } + } catch (error) { + console.error('Error loading tools settings:', error); + } + + return { + allowedTools: [], + disallowedTools: [], + skipPermissions: false, + }; + }; + + const toolsSettings = getToolsSettings(); + const model = + provider === 'cursor' + ? cursorModel + : provider === 'codex' + ? codexModel + : provider === 'opencode' + ? opencodeModel + : claudeModel; + + return { + model, + effort: currentProviderEffort, + permissionMode: resolvePermissionModeForProvider(provider, permissionMode), + toolsSettings, + skipPermissions: toolsSettings?.skipPermissions || false, + sessionSummary: getNotificationSessionSummary(selectedSession, currentInput), + }; + }, [ + claudeModel, + codexModel, + currentProviderEffort, + cursorModel, + opencodeModel, + permissionMode, + provider, + resolvePermissionModeForProvider, + selectedSession, + ]); + const handleSubmit = useCallback( async ( event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, ) => { event.preventDefault(); const currentInput = inputValueRef.current; - if (!currentInput.trim() || isLoading || !selectedProject) { + if (!currentInput.trim() || !selectedProject) { + return; + } + + // A turn is already in flight: stash this message instead of sending it. + // It's auto-flushed (re-running this same function) once the turn ends, + // so it still goes through slash-command interception, image upload, etc. + if (isLoading) { + queuedDraftSessionRef.current = sessionKey; + setQueuedDraft({ + content: currentInput, + images: attachedImages, + options: buildSendOptions(currentInput), + }); + setInput(''); + inputValueRef.current = ''; + setAttachedImages([]); + setUploadingImages(new Map()); + setImageErrors(new Map()); + resetCommandMenuState(); + setIsTextareaExpanded(false); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + // selectedProject is guaranteed by the guard at the top of handleSubmit. + safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`); return; } @@ -604,7 +723,7 @@ export function useChatComposerState({ }); try { - const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, { + const response = await authenticatedFetch('/api/assets/images', { method: 'POST', headers: {}, body: formData, @@ -696,46 +815,6 @@ export function useChatComposerState({ setIsUserScrolledUp(false); setTimeout(() => scrollToBottom(), 100); - const getToolsSettings = () => { - try { - const settingsKey = - provider === 'cursor' - ? 'cursor-tools-settings' - : provider === 'codex' - ? 'codex-settings' - : provider === 'gemini' - ? 'gemini-settings' - : provider === 'opencode' - ? 'opencode-settings' - : 'claude-settings'; - const savedSettings = safeLocalStorage.getItem(settingsKey); - if (savedSettings) { - return JSON.parse(savedSettings); - } - } catch (error) { - console.error('Error loading tools settings:', error); - } - - return { - allowedTools: [], - disallowedTools: [], - skipPermissions: false, - }; - }; - - const toolsSettings = getToolsSettings(); - const model = - provider === 'cursor' - ? cursorModel - : provider === 'codex' - ? codexModel - : provider === 'gemini' - ? geminiModel - : provider === 'opencode' - ? opencodeModel - : claudeModel; - const effort = currentProviderEffort; - // One message shape for every provider. The backend resolves the // provider, project path, and provider-native resume id from the // session row; `options` only carries composer-level preferences. @@ -744,12 +823,7 @@ export function useChatComposerState({ sessionId: targetSessionId, content: messageContent, options: { - model, - effort, - permissionMode: resolvePermissionModeForProvider(provider, permissionMode), - toolsSettings, - skipPermissions: toolsSettings?.skipPermissions || false, - sessionSummary, + ...buildSendOptions(messageContent), images: uploadedImages, }, }); @@ -771,24 +845,18 @@ export function useChatComposerState({ [ selectedSession, attachedImages, - claudeModel, - codexModel, - currentProviderEffort, + buildSendOptions, currentSessionId, - cursorModel, executeCommand, - geminiModel, - opencodeModel, isLoading, onSessionProcessing, onSessionEstablished, - permissionMode, provider, - resolvePermissionModeForProvider, resetCommandMenuState, scrollToBottom, selectedProject, sendMessage, + sessionKey, addMessage, setIsUserScrolledUp, slashCommands, @@ -799,6 +867,66 @@ export function useChatComposerState({ handleSubmitRef.current = handleSubmit; }, [handleSubmit]); + // Once the in-flight turn ends, replay the queued draft through the normal + // submit path (slash commands, image upload, etc. all still apply). + const wasLoadingRef = useRef(isLoading); + const flushSessionKeyRef = useRef(sessionKey); + useEffect(() => { + const wasLoading = wasLoadingRef.current; + wasLoadingRef.current = isLoading; + + // A session switch changes which session `isLoading` describes, so this + // transition says nothing about the queued draft's own session. Never + // flush across it — the swap effect below replaces `queuedDraft` with the + // new session's saved draft right after this. + if (flushSessionKeyRef.current !== sessionKey) { + flushSessionKeyRef.current = sessionKey; + return; + } + + if (isLoading || !queuedDraft) { + return; + } + + // Turn just ended in this session: flush immediately. Otherwise this is a + // saved draft restored into an apparently idle session — hold it briefly + // so the `chat_subscribed` ack can flip `isLoading` if a run is actually + // still live (the cleanup below cancels the send in that case). + const delay = wasLoading ? 0 : 750; + const timer = setTimeout(() => { + // The saved key is the claim ticket shared with the app-level auto-send + // (which handles sessions that finish while not viewed). If it's gone, + // the message was already dispatched — don't send it twice. + if (sessionKey && !readQueuedMessage(sessionKey)) { + setQueuedDraft(null); + return; + } + setQueuedDraft(null); + setInput(queuedDraft.content); + inputValueRef.current = queuedDraft.content; + setAttachedImages(queuedDraft.images); + setTimeout(() => { + handleSubmitRef.current?.(createFakeSubmitEvent()); + }, 0); + }, delay); + return () => clearTimeout(timer); + }, [isLoading, queuedDraft, sessionKey, setInput]); + + const editQueuedDraft = useCallback(() => { + if (!queuedDraft) { + return; + } + setQueuedDraft(null); + setInput(queuedDraft.content); + inputValueRef.current = queuedDraft.content; + setAttachedImages(queuedDraft.images); + textareaRef.current?.focus(); + }, [queuedDraft]); + + const deleteQueuedDraft = useCallback(() => { + setQueuedDraft(null); + }, []); + // A voice transcript either fills the input (to edit before sending) or, when the // user tapped "stop and send", is submitted straight away. Mirror the value into // inputValueRef synchronously so handleSubmit reads the new text, not the stale state. @@ -837,6 +965,33 @@ export function useChatComposerState({ } }, [input, selectedProjectId]); + // Persist the queued draft under its session's key. Must be defined BEFORE + // the swap effect below: on a session switch there is one commit where + // `sessionKey` already points at the new session while `queuedDraft` (and + // the owner ref) still describe the old one — the ref mismatch makes this + // effect skip that commit instead of writing/clearing across sessions. + useEffect(() => { + if (!sessionKey || queuedDraftSessionRef.current !== sessionKey) { + return; + } + if (queuedDraft?.content) { + writeQueuedMessage(sessionKey, { content: queuedDraft.content, options: queuedDraft.options }); + } else { + clearQueuedMessage(sessionKey); + } + }, [queuedDraft, sessionKey]); + + // Switching sessions swaps in that session's queued draft (image + // attachments can't survive a reload, so only text and options restore). + useEffect(() => { + queuedDraftSessionRef.current = sessionKey; + if (!sessionKey) { + setQueuedDraft(null); + return; + } + setQueuedDraft(restoreQueuedDraft(sessionKey)); + }, [sessionKey]); + useEffect(() => { if (!textareaRef.current) { return; @@ -1044,6 +1199,9 @@ export function useChatComposerState({ isDragActive, openImagePicker: open, handleSubmit, + queuedDraft, + editQueuedDraft, + deleteQueuedDraft, handleVoiceTranscript, handleInputChange, handleKeyDown, diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index a9dd1923..90157d8b 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -13,6 +13,48 @@ function formatToolResultContent(content: unknown): string { return toolUseErrorMatch ? toolUseErrorMatch[1] : text; } +type ParsedTaskNotification = { + status: string; + summary: string; + result: string; +}; + +/** + * Parses a background-agent `` block. + * + * The harness injects these as user-role messages when a background task stops. + * Newer notifications carry extra fields (``, ``, ``, + * and a `` markdown payload) that the previous single-shot regex could + * not match, so the whole raw XML block leaked through as plain user text. + * Fields are extracted independently so the block renders as an assistant + * notification plus, when present, the agent's markdown result. + */ +function parseTaskNotification(content: string): ParsedTaskNotification | null { + if (!content.trimStart().startsWith('')) { + return null; + } + + const statusMatch = /([\s\S]*?)<\/status>/.exec(content); + const summaryMatch = /([\s\S]*?)<\/summary>/.exec(content); + + let result = ''; + const resultOpen = content.indexOf(''); + if (resultOpen !== -1) { + const afterOpen = content.slice(resultOpen + ''.length); + const closeIndex = afterOpen.indexOf(''); + result = + closeIndex === -1 + ? afterOpen.replace(/<\/task-notification>\s*$/, '').trim() + : afterOpen.slice(0, closeIndex).trim(); + } + + return { + status: statusMatch?.[1]?.trim() || 'completed', + summary: summaryMatch?.[1]?.trim() || 'Background task finished', + result, + }; +} + /** * Convert NormalizedMessage[] from the session store into ChatMessage[] * that the existing UI components expect. @@ -51,26 +93,37 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes switch (msg.kind) { case 'text': { const content = msg.content || ''; - if (!content.trim()) continue; + const images = Array.isArray(msg.images) && msg.images.length > 0 ? msg.images : undefined; + if (!content.trim() && !images) continue; if (msg.role === 'user') { // Parse task notifications - const taskNotifRegex = /\s*[^<]*<\/task-id>\s*[^<]*<\/output-file>\s*([^<]*)<\/status>\s*([^<]*)<\/summary>\s*<\/task-notification>/g; - const taskNotifMatch = taskNotifRegex.exec(content); - if (taskNotifMatch) { + const taskNotif = parseTaskNotification(content); + if (taskNotif) { converted.push({ type: 'assistant', - content: taskNotifMatch[2]?.trim() || 'Background task finished', + content: taskNotif.summary, timestamp: msg.timestamp, isTaskNotification: true, - taskStatus: taskNotifMatch[1]?.trim() || 'completed', + taskStatus: taskNotif.status, ...sharedMetadata, }); + // Render the agent's result as a normal assistant message so its + // markdown displays correctly instead of leaking raw XML. + if (taskNotif.result) { + converted.push({ + type: 'assistant', + content: formatUsageLimitText(unescapeWithMathProtection(decodeHtmlEntities(taskNotif.result))), + timestamp: msg.timestamp, + ...sharedMetadata, + }); + } } else { converted.push({ type: 'user', content: unescapeWithMathProtection(decodeHtmlEntities(content)), timestamp: msg.timestamp, + images, ...sharedMetadata, }); } diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 86a0eced..1d0aac5a 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -20,11 +20,17 @@ const FALLBACK_DEFAULT_MODEL: Record = { claude: 'default', cursor: 'gpt-5.3-codex', codex: 'gpt-5.4', - gemini: 'gemini-3.1-pro-preview', opencode: 'anthropic/claude-sonnet-4-5', }; -const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; +const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode']; + +const readStoredProvider = (): LLMProvider => { + const storedProvider = localStorage.getItem('selected-provider'); + return PROVIDERS.includes(storedProvider as LLMProvider) + ? storedProvider as LLMProvider + : 'claude'; +}; /** * Fallback permission-mode matrix used only until the backend capability @@ -36,8 +42,7 @@ const FALLBACK_PERMISSION_MODES: Record = { claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'], cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], codex: ['default', 'acceptEdits', 'bypassPermissions'], - gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], - opencode: ['default'], + opencode: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], }; type ProviderCapabilities = { @@ -85,9 +90,7 @@ type ChangeActiveModelApiResponse = { export function useChatProviderState({ selectedSession, selectedProject: _selectedProject }: UseChatProviderStateArgs) { const [permissionMode, setPermissionMode] = useState('default'); const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]); - const [provider, setProvider] = useState(() => { - return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; - }); + const [provider, setProvider] = useState(readStoredProvider); const [cursorModel, setCursorModel] = useState(() => { return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor; }); @@ -103,9 +106,6 @@ export function useChatProviderState({ selectedSession, selectedProject: _select return acc; }, {}); }); - const [geminiModel, setGeminiModel] = useState(() => { - return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini; - }); const [opencodeModel, setOpenCodeModel] = useState(() => { return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode; }); @@ -151,12 +151,6 @@ export function useChatProviderState({ selectedSession, selectedProject: _select return; } - if (targetProvider === 'gemini') { - setGeminiModel(model); - localStorage.setItem('gemini-model', model); - return; - } - setOpenCodeModel(model); localStorage.setItem('opencode-model', model); }, []); @@ -360,9 +354,8 @@ export function useChatProviderState({ selectedSession, selectedProject: _select claude: claudeModel, cursor: cursorModel, codex: codexModel, - gemini: geminiModel, opencode: opencodeModel, - }), [claudeModel, cursorModel, codexModel, geminiModel, opencodeModel]); + }), [claudeModel, cursorModel, codexModel, opencodeModel]); useEffect(() => { const claude = providerModelCatalog.claude; @@ -403,19 +396,6 @@ export function useChatProviderState({ selectedSession, selectedProject: _select } }, [providerModelCatalog.codex, codexModel]); - useEffect(() => { - const gemini = providerModelCatalog.gemini; - if (gemini) { - const next = pickStoredOrCurrent('gemini-model', geminiModel, gemini); - if (next !== geminiModel) { - setGeminiModel(next); - } - if (localStorage.getItem('gemini-model') !== next) { - localStorage.setItem('gemini-model', next); - } - } - }, [providerModelCatalog.gemini, geminiModel]); - useEffect(() => { const opencode = providerModelCatalog.opencode; if (opencode) { @@ -451,17 +431,19 @@ export function useChatProviderState({ selectedSession, selectedProject: _select }, [providerEfforts, providerModels, reconcileStoredEffort]); useEffect(() => { - if (!selectedSession?.id) { - return; - } - - const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null; const validModes = getPermissionModesForProvider(provider); - setPermissionMode( - savedMode && validModes.includes(savedMode) - ? savedMode - : getDefaultPermissionModeForProvider(provider), + const sessionSavedMode = selectedSession?.id + ? (localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null) + : null; + // Fall back to the last mode picked for this provider: a brand-new chat + // only receives its session id after the first send, so without this the + // mode chosen beforehand would snap back to the default as soon as the + // session id appears. + const providerSavedMode = localStorage.getItem(`permissionMode-last-${provider}`) as PermissionMode | null; + const savedMode = [sessionSavedMode, providerSavedMode].find( + (mode): mode is PermissionMode => Boolean(mode && validModes.includes(mode)), ); + setPermissionMode(savedMode ?? getDefaultPermissionModeForProvider(provider)); }, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]); useEffect(() => { @@ -511,6 +493,10 @@ export function useChatProviderState({ selectedSession, selectedProject: _select const nextMode = modes[nextIndex]; setPermissionMode(nextMode); + // Persist per provider as well as per session: a brand-new chat has no + // session id yet, and the per-provider key keeps the choice sticky when + // the real id arrives (and for future sessions of this provider). + localStorage.setItem(`permissionMode-last-${provider}`, nextMode); if (selectedSession?.id) { localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode); } @@ -583,8 +569,6 @@ export function useChatProviderState({ selectedSession, selectedProject: _select setCodexModel, currentProviderEffort, currentProviderEffortOptions, - geminiModel, - setGeminiModel, opencodeModel, setOpenCodeModel, permissionMode, diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 37540895..b89c82a6 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -83,6 +83,9 @@ function chatMessageToNormalized( kind: 'text', role: msg.type === 'user' ? 'user' : 'assistant', content: msg.content || '', + // Keep attachment references on the local echo so the user bubble shows + // its images immediately, before the server-backed copy replaces it. + images: Array.isArray(msg.images) && msg.images.length > 0 ? msg.images : undefined, } as NormalizedMessage; } diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 15cb90b8..4ec7ffe3 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -10,8 +10,12 @@ export type Provider = LLMProvider; export type PermissionMode = 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan'; export interface ChatImage { - data: string; - name: string; + /** Inline data URL (Claude history stores attachments as base64). */ + data?: string; + /** Project-relative path under `.cloudcli/assets` served via the files API. */ + path?: string; + name?: string; + mimeType?: string; } export interface ToolResult { diff --git a/src/components/chat/utils/chatStorage.ts b/src/components/chat/utils/chatStorage.ts index 367a305e..3e3636bb 100644 --- a/src/components/chat/utils/chatStorage.ts +++ b/src/components/chat/utils/chatStorage.ts @@ -11,7 +11,7 @@ export const safeLocalStorage = { console.warn('localStorage quota exceeded, clearing old data'); const keys = Object.keys(localStorage); - const draftKeys = keys.filter((k) => k.startsWith('draft_input_')); + const draftKeys = keys.filter((k) => k.startsWith('draft_input_') || k.startsWith('queued_message_')); draftKeys.forEach((k) => { localStorage.removeItem(k); }); @@ -43,6 +43,52 @@ export const safeLocalStorage = { }, }; +/** + * Composer options captured when a message is queued, so the message can be + * sent later with the exact settings (model, permission mode, tools) the + * session's composer had at queue time — even from outside the composer, + * e.g. the app-level auto-send that fires while another session is viewed. + */ +export type QueuedSendOptions = Record; + +export type StoredQueuedMessage = { + content: string; + options?: QueuedSendOptions; +}; + +export const queuedMessageKey = (sessionId: string) => `queued_message_${sessionId}`; + +/** + * Reads a session's queued message. Understands both the JSON + * `{ content, options }` format and the legacy raw-text format. + */ +export function readQueuedMessage(sessionId: string): StoredQueuedMessage | null { + const raw = safeLocalStorage.getItem(queuedMessageKey(sessionId)); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && typeof (parsed as StoredQueuedMessage).content === 'string') { + const { content, options } = parsed as StoredQueuedMessage; + return content.trim() ? { content, options } : null; + } + } catch { + // Legacy format: the raw draft text itself. + } + + return raw.trim() ? { content: raw } : null; +} + +export function writeQueuedMessage(sessionId: string, message: StoredQueuedMessage): void { + safeLocalStorage.setItem(queuedMessageKey(sessionId), JSON.stringify(message)); +} + +export function clearQueuedMessage(sessionId: string): void { + safeLocalStorage.removeItem(queuedMessageKey(sessionId)); +} + export function getClaudeSettings(): ClaudeSettings { const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY); if (!raw) { diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 7544ee44..2d7a8dcb 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -71,8 +71,6 @@ function ChatInterface({ setCodexModel, currentProviderEffort, currentProviderEffortOptions, - geminiModel, - setGeminiModel, opencodeModel, setOpenCodeModel, permissionMode, @@ -174,6 +172,9 @@ function ChatInterface({ isDragActive, openImagePicker, handleSubmit, + queuedDraft, + editQueuedDraft, + deleteQueuedDraft, handleVoiceTranscript, handleInputChange, handleKeyDown, @@ -201,7 +202,6 @@ function ChatInterface({ claudeModel, codexModel, currentProviderEffort, - geminiModel, opencodeModel, isLoading: isProcessing, canAbortSession, @@ -286,15 +286,18 @@ function ChatInterface({ handlePermissionDecision, }), [pendingPermissionRequests, handlePermissionDecision]); + // Mirrors ChatComposer's own visibility check so the message pane can + // reserve enough bottom space to keep the floating status tab from + // overlapping the last message. + const hasActivityIndicator = Boolean(sessionActivity && pendingPermissionRequests.length === 0); + if (!selectedProject) { const selectedProviderLabel = provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') - : provider === 'gemini' - ? t('messageTypes.gemini') - : provider === 'opencode' + : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) : t('messageTypes.claude'); @@ -321,6 +324,7 @@ function ChatInterface({ onTouchMove={handleScroll} isLoadingSessionMessages={isLoadingSessionMessages} isProcessing={isProcessing} + hasActivityIndicator={hasActivityIndicator} chatMessages={chatMessages} selectedSession={selectedSession} currentSessionId={currentSessionId} @@ -333,8 +337,6 @@ function ChatInterface({ setCursorModel={setCursorModel} codexModel={codexModel} setCodexModel={setCodexModel} - geminiModel={geminiModel} - setGeminiModel={setGeminiModel} opencodeModel={opencodeModel} setOpenCodeModel={setOpenCodeModel} providerModelCatalog={providerModelCatalog} @@ -399,6 +401,9 @@ function ChatInterface({ onClearInput={handleClearInput} onSubmit={handleSubmit} isDragActive={isDragActive} + queuedDraft={queuedDraft} + onEditQueuedDraft={editQueuedDraft} + onDeleteQueuedDraft={deleteQueuedDraft} attachedImages={attachedImages} onRemoveImage={(index) => setAttachedImages((previous) => @@ -439,9 +444,7 @@ function ChatInterface({ ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') - : provider === 'gemini' - ? t('messageTypes.gemini') - : provider === 'opencode' + : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) : t('messageTypes.claude'), })} diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index ba35dfd8..0abd2463 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -11,10 +11,11 @@ import type { RefObject, TouchEvent, } from 'react'; -import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check } from 'lucide-react'; +import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check, ArrowUpIcon } from 'lucide-react'; import { useVoiceInput } from '../../hooks/useVoiceInput'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; +import type { QueuedDraft } from '../../hooks/useChatComposerState'; import type { SessionActivity } from '../../../../hooks/useSessionProtection'; import type { PendingPermissionRequest, PermissionMode } from '../../types/types'; import type { ProviderModelOption } from '../../../../types/app'; @@ -35,6 +36,7 @@ import ImageAttachment from './ImageAttachment'; import VoiceInputButton from './VoiceInputButton'; import PermissionRequestsBanner from './PermissionRequestsBanner'; import TokenUsageSummary from './TokenUsageSummary'; +import QueuedMessageCard from './QueuedMessageCard'; interface MentionableFile { name: string; @@ -74,6 +76,9 @@ interface ChatComposerProps { onClearInput: () => void; onSubmit: (event: FormEvent | MouseEvent | TouchEvent) => void; isDragActive: boolean; + queuedDraft: QueuedDraft | null; + onEditQueuedDraft: () => void; + onDeleteQueuedDraft: () => void; attachedImages: File[]; onRemoveImage: (index: number) => void; uploadingImages: Map; @@ -129,6 +134,9 @@ export default function ChatComposer({ onClearInput, onSubmit, isDragActive, + queuedDraft, + onEditQueuedDraft, + onDeleteQueuedDraft, attachedImages, onRemoveImage, uploadingImages, @@ -267,6 +275,23 @@ export default function ChatComposer({ const hasPendingPermissions = pendingPermissionRequests.length > 0; const hasActivityIndicator = Boolean(activity && !hasPendingPermissions); + const hasQueuedDraft = Boolean(queuedDraft); + const canQueueDraft = isLoading && Boolean(input.trim()); + const submitHint = canQueueDraft + ? hasQueuedDraft + ? t('input.hintText.updateQueued', { defaultValue: 'Enter to update queued message' }) + : t('input.hintText.queue', { defaultValue: 'Enter to queue your next message' }) + : sendByCtrlEnter + ? t('input.hintText.ctrlEnter') + : t('input.hintText.enter'); + const submitAriaLabel = canQueueDraft + ? hasQueuedDraft + ? t('input.queue.update', { defaultValue: 'Update queued message' }) + : t('input.queue.sendNext', { defaultValue: 'Queue next message' }) + : isLoading + ? t('input.stop') + : t('input.send'); + return (
{!hasPendingPermissions && ( @@ -285,6 +310,15 @@ export default function ChatComposer({
)} + {queuedDraft && ( + + )} + {!hasQuestionPanel &&
{showFileDropdown && filteredFiles.length > 0 && (
@@ -540,26 +574,37 @@ export default function ChatComposer({
- {sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')} + {submitHint}
) => { - e.preventDefault(); - voiceStop({ send: true }); - } - : undefined + canQueueDraft + ? (e: MouseEvent) => { + e.preventDefault(); + onSubmit(e); + } + : isLoading + ? onAbortSession + : isRecording + ? (e: MouseEvent) => { + e.preventDefault(); + voiceStop({ send: true }); + } + : undefined } disabled={isLoading ? false : isRecording ? false : isTranscribing ? true : !input.trim()} + aria-label={submitAriaLabel} + title={submitAriaLabel} className="h-10 w-10 sm:h-10 sm:w-10" > - {isTranscribing ? : undefined} + {isTranscribing ? ( + + ) : canQueueDraft ? ( + + ) : undefined}
diff --git a/src/components/chat/view/subcomponents/ChatMessageImages.tsx b/src/components/chat/view/subcomponents/ChatMessageImages.tsx new file mode 100644 index 00000000..50bc56ed --- /dev/null +++ b/src/components/chat/view/subcomponents/ChatMessageImages.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X } from 'lucide-react'; + +import { authenticatedFetch } from '../../../../utils/api'; +import type { ChatImage } from '../../types/types'; + +type ChatMessageImagesProps = { + images: ChatImage[]; + projectId?: string | null; +}; + +/** + * Resolves one chat image to a displayable src. Inline data URLs are used + * directly; path-based attachments are fetched as blobs (a bare + * cannot carry the auth header) — first from the global assets route + * (`~/.cloudcli/assets`), then from the project files route as a fallback for + * sessions recorded before attachments moved to the global store. + */ +function useChatImageSrc(image: ChatImage, projectId?: string | null): { src: string | null; failed: boolean } { + const [src, setSrc] = useState(image.data || null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (image.data) { + setSrc(image.data); + setFailed(false); + return; + } + + const imagePath = image.path; + if (!imagePath) { + setSrc(null); + setFailed(true); + return; + } + + const filename = imagePath.split(/[\\/]/).pop() || ''; + const candidateUrls = [ + `/api/assets/images/${encodeURIComponent(filename)}`, + ...(projectId + ? [`/api/projects/${projectId}/files/content?path=${encodeURIComponent(imagePath)}`] + : []), + ]; + + let objectUrl: string | null = null; + const controller = new AbortController(); + + const load = async () => { + setFailed(false); + for (const url of candidateUrls) { + try { + const response = await authenticatedFetch(url, { signal: controller.signal }); + if (!response.ok) { + continue; + } + const blob = await response.blob(); + objectUrl = URL.createObjectURL(blob); + setSrc(objectUrl); + return; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + } + } + setSrc(null); + setFailed(true); + }; + + void load(); + + return () => { + controller.abort(); + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [image.data, image.path, projectId]); + + return { src, failed }; +} + +/** + * Fullscreen image overlay in the claude.ai style: dark backdrop, centered + * image, closes on backdrop click, close button, or Escape. + */ +function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) { + useEffect(() => { + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { + event.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown, true); + return () => document.removeEventListener('keydown', handleKeyDown, true); + }, [onClose]); + + return createPortal( +
+ + {alt} event.stopPropagation()} + className="max-h-[90vh] max-w-[92vw] rounded-lg object-contain shadow-2xl" + /> +
, + document.body, + ); +} + +function ChatMessageImage({ image, projectId }: { image: ChatImage; projectId?: string | null }) { + const { src, failed } = useChatImageSrc(image, projectId); + const [expanded, setExpanded] = useState(false); + const alt = image.name || 'Attached image'; + + if (failed) { + return ( +
+ {alt} +
+ ); + } + + if (!src) { + return
; + } + + return ( + <> + + {expanded && setExpanded(false)} />} + + ); +} + +/** + * Image attachments for a user turn, rendered claude.ai-style: standalone + * rounded square cards shown above the message bubble. Each thumbnail + * expands to a fullscreen lightbox on click. + */ +export default function ChatMessageImages({ images, projectId }: ChatMessageImagesProps) { + if (!images || images.length === 0) { + return null; + } + + return ( +
+ {images.map((image, index) => ( + + ))} +
+ ); +} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 6c9ba116..23ffaef3 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -24,6 +24,8 @@ interface ChatMessagesPaneProps { isLoadingSessionMessages: boolean; /** True while the viewed session has an active provider run in flight. */ isProcessing?: boolean; + /** True while ChatComposer's floating activity/stop tab is rendered above the input. */ + hasActivityIndicator?: boolean; chatMessages: ChatMessage[]; selectedSession: ProjectSession | null; currentSessionId: string | null; @@ -36,8 +38,6 @@ interface ChatMessagesPaneProps { setCursorModel: (model: string) => void; codexModel: string; setCodexModel: (model: string) => void; - geminiModel: string; - setGeminiModel: (model: string) => void; opencodeModel: string; setOpenCodeModel: (model: string) => void; providerModelCatalog: Partial>; @@ -73,6 +73,7 @@ function ChatMessagesPane({ onTouchMove, isLoadingSessionMessages, isProcessing = false, + hasActivityIndicator = false, chatMessages, selectedSession, currentSessionId, @@ -85,8 +86,6 @@ function ChatMessagesPane({ setCursorModel, codexModel, setCodexModel, - geminiModel, - setGeminiModel, opencodeModel, setOpenCodeModel, providerModelCatalog, @@ -161,7 +160,9 @@ function ChatMessagesPane({ ref={scrollContainerRef} onWheel={onWheel} onTouchMove={onTouchMove} - className="chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3 sm:py-4" + className={`chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-3 sm:pt-4 ${ + hasActivityIndicator ? 'pb-12 sm:pb-14' : 'pb-3 sm:pb-4' + }`} >
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? ( @@ -184,8 +185,6 @@ function ChatMessagesPane({ setCursorModel={setCursorModel} codexModel={codexModel} setCodexModel={setCodexModel} - geminiModel={geminiModel} - setGeminiModel={setGeminiModel} opencodeModel={opencodeModel} setOpenCodeModel={setOpenCodeModel} providerModelCatalog={providerModelCatalog} diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx index fc71aa36..5096734d 100644 --- a/src/components/chat/view/subcomponents/CommandResultModal.tsx +++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx @@ -61,7 +61,6 @@ const PROVIDER_LABELS: Record = { claude: 'Claude', cursor: 'Cursor', codex: 'Codex', - gemini: 'Gemini', opencode: 'OpenCode', }; diff --git a/src/components/chat/view/subcomponents/ImageAttachment.tsx b/src/components/chat/view/subcomponents/ImageAttachment.tsx index 1a0ff225..3a0a9a92 100644 --- a/src/components/chat/view/subcomponents/ImageAttachment.tsx +++ b/src/components/chat/view/subcomponents/ImageAttachment.tsx @@ -18,14 +18,16 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm return (
- {file.name} +
+ {file.name} +
{uploadProgress !== undefined && uploadProgress < 100 && ( -
+
{uploadProgress}%
)} {error && ( -
+
@@ -34,7 +36,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm + +
+
+
+ ); +} diff --git a/src/components/git-panel/constants/constants.ts b/src/components/git-panel/constants/constants.ts index 5e2ff191..414e191c 100644 --- a/src/components/git-panel/constants/constants.ts +++ b/src/components/git-panel/constants/constants.ts @@ -1,7 +1,8 @@ import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types'; export const DEFAULT_BRANCH = 'main'; -export const RECENT_COMMITS_LIMIT = 10; +// High enough for the commit graph to show meaningful branch structure. +export const RECENT_COMMITS_LIMIT = 50; export const FILE_STATUS_GROUPS: GitStatusGroupEntry[] = [ { key: 'modified', status: 'M' }, diff --git a/src/components/git-panel/hooks/useGitPanelController.ts b/src/components/git-panel/hooks/useGitPanelController.ts index 4ef54002..d6fd826e 100644 --- a/src/components/git-panel/hooks/useGitPanelController.ts +++ b/src/components/git-panel/hooks/useGitPanelController.ts @@ -495,6 +495,71 @@ export function useGitPanelController({ [fetchGitStatus, selectedProject], ); + const stageFiles = useCallback( + async (files: string[]) => { + if (!selectedProject || files.length === 0) { + return false; + } + + try { + const response = await fetchWithAuth('/api/git/stage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project: selectedProject.projectId, + files, + }), + }); + + const data = await readJson(response); + if (!data.success) { + setOperationError(data.error ?? 'Stage failed'); + return false; + } + + // Refresh so the Staged section re-syncs from the real index. + await fetchGitStatus(); + return true; + } catch (error) { + setOperationError(error instanceof Error ? error.message : 'Stage failed'); + return false; + } + }, + [fetchGitStatus, selectedProject], + ); + + const unstageFiles = useCallback( + async (files: string[]) => { + if (!selectedProject || files.length === 0) { + return false; + } + + try { + const response = await fetchWithAuth('/api/git/unstage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project: selectedProject.projectId, + files, + }), + }); + + const data = await readJson(response); + if (!data.success) { + setOperationError(data.error ?? 'Unstage failed'); + return false; + } + + await fetchGitStatus(); + return true; + } catch (error) { + setOperationError(error instanceof Error ? error.message : 'Unstage failed'); + return false; + } + }, + [fetchGitStatus, selectedProject], + ); + const fetchRecentCommits = useCallback(async () => { if (!selectedProject) { return; @@ -744,6 +809,8 @@ export function useGitPanelController({ handlePublish, discardChanges, deleteUntrackedFile, + stageFiles, + unstageFiles, fetchCommitDiff, generateCommitMessage, commitChanges, diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts index 7abf9820..eb6ef196 100644 --- a/src/components/git-panel/types/types.ts +++ b/src/components/git-panel/types/types.ts @@ -25,6 +25,8 @@ export type GitStatusResponse = { added?: string[]; deleted?: string[]; untracked?: string[]; + /** Paths with index-side changes — mirrors the real git index. */ + staged?: string[]; error?: string; details?: string; }; @@ -49,6 +51,10 @@ export type GitCommitSummary = { date: string; message: string; stats?: string; + /** Parent commit hashes — drives the History view commit graph. */ + parents?: string[]; + /** Ref decorations, e.g. "HEAD -> main", "origin/main", "tag: v1.0". */ + refs?: string[]; }; export type GitDiffMap = Record; @@ -99,6 +105,8 @@ export type GitPanelController = { handlePublish: () => Promise; discardChanges: (filePath: string) => Promise; deleteUntrackedFile: (filePath: string) => Promise; + stageFiles: (files: string[]) => Promise; + unstageFiles: (files: string[]) => Promise; fetchCommitDiff: (commitHash: string) => Promise; generateCommitMessage: (files: string[]) => Promise; commitChanges: (message: string, files: string[]) => Promise; diff --git a/src/components/git-panel/utils/commitGraph.test.ts b/src/components/git-panel/utils/commitGraph.test.ts new file mode 100644 index 00000000..7cdd93b1 --- /dev/null +++ b/src/components/git-panel/utils/commitGraph.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { computeCommitGraph } from './commitGraph'; + +test('linear history stays in a single lane', () => { + const rows = computeCommitGraph([ + { hash: 'c3', parents: ['c2'] }, + { hash: 'c2', parents: ['c1'] }, + { hash: 'c1', parents: [] }, + ]); + + assert.deepEqual(rows.map((row) => row.nodeLane), [0, 0, 0]); + assert.deepEqual(rows.map((row) => row.laneCount), [1, 1, 1]); + // Tip has no line from above; root has no line below. + assert.equal(rows[0].hasTopContinuation, false); + assert.equal(rows[0].hasParentContinuation, true); + assert.equal(rows[2].hasParentContinuation, false); + assert.deepEqual(rows[1].passThrough, []); +}); + +test('merge commit opens a second lane that joins back at the fork point', () => { + // main: m2 --- m1 --- base + // feature: \- f1 -/ (m2 merges f1; both branch from base) + const rows = computeCommitGraph([ + { hash: 'm2', parents: ['m1', 'f1'] }, + { hash: 'm1', parents: ['base'] }, + { hash: 'f1', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]); + + // Merge commit sits in lane 0 and branches a line out to lane 1. + assert.equal(rows[0].nodeLane, 0); + assert.deepEqual(rows[0].outbound, [1]); + assert.equal(rows[0].laneCount, 2); + + // m1 passes lane 1 (feature line) straight through. + assert.equal(rows[1].nodeLane, 0); + assert.deepEqual(rows[1].passThrough, [1]); + + // f1 is the feature commit in lane 1; lane 0 (main) passes through. + assert.equal(rows[2].nodeLane, 1); + assert.deepEqual(rows[2].passThrough, [0]); + + // base: both lanes converge — lane 1 merges into the node in lane 0. + assert.equal(rows[3].nodeLane, 0); + assert.deepEqual(rows[3].inbound, [1]); + assert.deepEqual(rows[3].bottomLanes, []); +}); + +test('independent branch tips get their own lanes', () => { + // Two branch tips pointing at the same parent (e.g. main and a feature). + const rows = computeCommitGraph([ + { hash: 'tipA', parents: ['base'] }, + { hash: 'tipB', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]); + + assert.equal(rows[0].nodeLane, 0); + assert.equal(rows[1].nodeLane, 1); + // Both lanes collapse into base. + assert.equal(rows[2].nodeLane, 0); + assert.deepEqual(rows[2].inbound, [1]); +}); + +test('freed lanes are reused by later branch tips', () => { + const rows = computeCommitGraph([ + { hash: 'a2', parents: ['a1'] }, + { hash: 'a1', parents: [] }, // lane 0 ends here + { hash: 'b1', parents: [] }, // new tip should reuse lane 0 + ]); + + assert.equal(rows[2].nodeLane, 0); + assert.equal(rows[2].laneCount, 1); +}); + +test('commits without parents metadata degrade gracefully', () => { + const rows = computeCommitGraph([{ hash: 'x' }, { hash: 'y' }]); + // Without parent info every commit is a standalone tip; the second one + // reuses the freed lane. + assert.deepEqual(rows.map((row) => row.nodeLane), [0, 0]); + assert.deepEqual(rows.map((row) => row.hasParentContinuation), [false, false]); +}); diff --git a/src/components/git-panel/utils/commitGraph.ts b/src/components/git-panel/utils/commitGraph.ts new file mode 100644 index 00000000..e7ba4107 --- /dev/null +++ b/src/components/git-panel/utils/commitGraph.ts @@ -0,0 +1,138 @@ +/** + * Lane assignment for the History view commit graph (VSCode Git Graph style). + * + * Commits must arrive in graph order (children before their parents — the + * backend guarantees this via `git log --all --topo-order`). Each commit is + * assigned a lane; lines connect commits to their parents across rows. + */ + +export type CommitGraphRow = { + /** Lane the commit dot sits in. */ + nodeLane: number; + /** Total lanes visible in this row — determines the strip width. */ + laneCount: number; + /** A line arrives at the node from the row above (some child expects this commit). */ + hasTopContinuation: boolean; + /** The node's own lane continues below toward its first parent. */ + hasParentContinuation: boolean; + /** Extra top lanes that merge into the node (multiple children / branch tips joining). */ + inbound: number[]; + /** Bottom lanes branching out of the node toward its extra parents (merge commits). */ + outbound: number[]; + /** Lanes whose lines pass straight through this row untouched. */ + passThrough: number[]; + /** Every lane still active below this row — rails continue through expanded content. */ + bottomLanes: number[]; +}; + +type GraphCommit = { + hash: string; + parents?: string[]; +}; + +// Colors cycle per lane, VSCode Git Graph style. Chosen to stay readable on +// both light and dark backgrounds. +const GRAPH_COLORS = [ + '#0ea5e9', // sky + '#f97316', // orange + '#a855f7', // purple + '#22c55e', // green + '#ef4444', // red + '#eab308', // yellow + '#14b8a6', // teal + '#ec4899', // pink + '#6366f1', // indigo + '#84cc16', // lime +]; + +export const laneColor = (lane: number) => GRAPH_COLORS[lane % GRAPH_COLORS.length]; + +export function computeCommitGraph(commits: GraphCommit[]): CommitGraphRow[] { + // Each slot holds the commit hash that lane is waiting to reach, or null + // when the lane is free. + const lanes: (string | null)[] = []; + const rows: CommitGraphRow[] = []; + + const takeFirstFreeLane = (): number => { + const free = lanes.indexOf(null); + if (free !== -1) { + return free; + } + lanes.push(null); + return lanes.length - 1; + }; + + for (const commit of commits) { + const activeBefore = new Set(); + lanes.forEach((expected, index) => { + if (expected !== null) { + activeBefore.add(index); + } + }); + + // Lanes whose next expected commit is this one. + const waiting: number[] = []; + lanes.forEach((expected, index) => { + if (expected === commit.hash) { + waiting.push(index); + } + }); + + const hasTopContinuation = waiting.length > 0; + const nodeLane = hasTopContinuation ? waiting[0] : takeFirstFreeLane(); + + // Additional lanes converging on this commit merge into the node and free up. + const inbound = waiting.slice(1); + for (const lane of inbound) { + lanes[lane] = null; + } + + const parents = commit.parents ?? []; + lanes[nodeLane] = parents.length > 0 ? parents[0] : null; + + // Extra parents (merge commits) either join a lane already heading to that + // parent or open a new lane for it. + const outbound: number[] = []; + for (const parent of parents.slice(1)) { + const existing = lanes.findIndex((expected) => expected === parent); + if (existing !== -1 && existing !== nodeLane) { + outbound.push(existing); + } else { + const lane = takeFirstFreeLane(); + lanes[lane] = parent; + outbound.push(lane); + } + } + + const passThrough = [...activeBefore] + .filter((lane) => lane !== nodeLane && !waiting.includes(lane)) + .sort((a, b) => a - b); + + const bottomLanes: number[] = []; + lanes.forEach((expected, index) => { + if (expected !== null) { + bottomLanes.push(index); + } + }); + + const laneCount = Math.max(lanes.length, nodeLane + 1); + + // Keep the lane array tight so later rows don't inherit phantom width. + while (lanes.length > 0 && lanes[lanes.length - 1] === null) { + lanes.pop(); + } + + rows.push({ + nodeLane, + laneCount, + hasTopContinuation, + hasParentContinuation: parents.length > 0, + inbound, + outbound, + passThrough, + bottomLanes, + }); + } + + return rows; +} diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx index de9891dd..1b65f569 100644 --- a/src/components/git-panel/view/GitPanel.tsx +++ b/src/components/git-panel/view/GitPanel.tsx @@ -46,6 +46,8 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen handlePublish, discardChanges, deleteUntrackedFile, + stageFiles, + unstageFiles, fetchCommitDiff, generateCommitMessage, commitChanges, @@ -138,6 +140,8 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen onOpenFile={openFile} onDiscardFile={discardChanges} onDeleteFile={deleteUntrackedFile} + onStageFiles={stageFiles} + onUnstageFiles={unstageFiles} onCommitChanges={commitChanges} onGenerateCommitMessage={generateCommitMessage} onRequestConfirmation={setConfirmAction} diff --git a/src/components/git-panel/view/GitPanelHeader.tsx b/src/components/git-panel/view/GitPanelHeader.tsx index 31e64ba6..a83ed921 100644 --- a/src/components/git-panel/view/GitPanelHeader.tsx +++ b/src/components/git-panel/view/GitPanelHeader.tsx @@ -1,5 +1,5 @@ -import { AlertCircle, Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload, X } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { AlertCircle, Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Search, Upload, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { ConfirmationRequest, GitRemoteStatus } from '../types/types'; import NewBranchModal from './modals/NewBranchModal'; @@ -54,7 +54,26 @@ export default function GitPanelHeader({ }: GitPanelHeaderProps) { const [showBranchDropdown, setShowBranchDropdown] = useState(false); const [showNewBranchModal, setShowNewBranchModal] = useState(false); + const [branchSearchQuery, setBranchSearchQuery] = useState(''); const dropdownRef = useRef(null); + const branchSearchInputRef = useRef(null); + + // Focus the search box on open; drop any stale query on close. + useEffect(() => { + if (showBranchDropdown) { + branchSearchInputRef.current?.focus(); + } else { + setBranchSearchQuery(''); + } + }, [showBranchDropdown]); + + const filteredBranches = useMemo(() => { + const query = branchSearchQuery.trim().toLowerCase(); + if (!query) { + return branches; + } + return branches.filter((branch) => branch.toLowerCase().includes(query)); + }, [branches, branchSearchQuery]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -149,21 +168,45 @@ export default function GitPanelHeader({ {showBranchDropdown && (
-
- {branches.map((branch) => ( +
+ + setBranchSearchQuery(event.target.value)} + placeholder="Search branches..." + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" + /> + {branchSearchQuery && ( - ))} + )} +
+
+ {filteredBranches.length === 0 ? ( +
No matching branches
+ ) : ( + filteredBranches.map((branch) => ( + + )) + )}
+ {/* Branch search */} +
+ + setBranchSearchQuery(event.target.value)} + placeholder="Search branches..." + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" + /> + {branchSearchQuery && ( + + )} +
+ {/* Branch list */}
- {localBranches.length > 0 && ( + {filteredLocalBranches.length > 0 && ( <> - - {localBranches.map((branch) => ( + + {filteredLocalBranches.map((branch) => ( )} - {remoteBranches.length > 0 && ( + {filteredRemoteBranches.length > 0 && ( <> - - {remoteBranches.map((branch) => ( + + {filteredRemoteBranches.map((branch) => ( )} - {localBranches.length === 0 && remoteBranches.length === 0 && ( + {filteredLocalBranches.length === 0 && filteredRemoteBranches.length === 0 && (
-

No branches found

+

{normalizedQuery ? 'No branches match your search' : 'No branches found'}

)}
diff --git a/src/components/git-panel/view/changes/ChangesView.tsx b/src/components/git-panel/view/changes/ChangesView.tsx index ddc21e95..d898e791 100644 --- a/src/components/git-panel/view/changes/ChangesView.tsx +++ b/src/components/git-panel/view/changes/ChangesView.tsx @@ -1,5 +1,5 @@ import { GitBranch, GitCommit, RefreshCw } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ConfirmationRequest, FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types'; import { getAllChangedFiles, hasChangedFiles } from '../../utils/gitPanelUtils'; import CommitComposer from './CommitComposer'; @@ -19,6 +19,8 @@ type ChangesViewProps = { onOpenFile: (filePath: string) => Promise; onDiscardFile: (filePath: string) => Promise; onDeleteFile: (filePath: string) => Promise; + onStageFiles: (files: string[]) => Promise; + onUnstageFiles: (files: string[]) => Promise; onCommitChanges: (message: string, files: string[]) => Promise; onGenerateCommitMessage: (files: string[]) => Promise; onRequestConfirmation: (request: ConfirmationRequest) => void; @@ -38,6 +40,8 @@ export default function ChangesView({ onOpenFile, onDiscardFile, onDeleteFile, + onStageFiles, + onUnstageFiles, onCommitChanges, onGenerateCommitMessage, onRequestConfirmation, @@ -45,23 +49,40 @@ export default function ChangesView({ }: ChangesViewProps) { const [expandedFiles, setExpandedFiles] = useState>(new Set()); const [selectedFiles, setSelectedFiles] = useState>(new Set()); + // Stage/unstage calls in flight or queued. While > 0, status refreshes must + // not overwrite the optimistic selection with a snapshot that predates the + // later clicks. + const [pendingStageOps, setPendingStageOps] = useState(0); + // Serializes stage/unstage requests so rapid toggles cannot interleave on + // the server or resolve out of order. + const stageOpQueueRef = useRef>(Promise.resolve()); const changedFiles = useMemo(() => getAllChangedFiles(gitStatus), [gitStatus]); const hasExpandedFiles = expandedFiles.size > 0; + const enqueueStageOp = useCallback((operation: () => Promise) => { + setPendingStageOps((count) => count + 1); + stageOpQueueRef.current = stageOpQueueRef.current + .catch(() => {}) // a failed op must not block the queue + .then(operation) + .finally(() => setPendingStageOps((count) => count - 1)); + }, []); + useEffect(() => { if (!gitStatus || gitStatus.error) { setSelectedFiles(new Set()); return; } - // Remove any selected files that no longer exist in the status - setSelectedFiles((prev) => { - const allFiles = new Set(getAllChangedFiles(gitStatus)); - const next = new Set([...prev].filter((f) => allFiles.has(f))); - return next; - }); - }, [gitStatus]); + if (pendingStageOps > 0) { + return; // keep the optimistic state until the queued ops settle + } + + // The Staged section mirrors the real git index reported by /status, so + // files staged outside the app (VSCode, terminal) show up here too. Also + // re-runs when the queue drains, syncing to the final refreshed status. + setSelectedFiles(new Set(gitStatus.staged ?? [])); + }, [gitStatus, pendingStageOps]); useEffect(() => { onExpandedFilesChange(hasExpandedFiles); @@ -85,17 +106,25 @@ export default function ChangesView({ }); }, []); - const toggleFileSelected = useCallback((filePath: string) => { - setSelectedFiles((previous) => { - const next = new Set(previous); - if (next.has(filePath)) { - next.delete(filePath); - } else { - next.add(filePath); - } - return next; - }); - }, []); + // Staging is real: every toggle runs git add / git reset through the API. + // The set is flipped optimistically; the queued API call keeps the git + // index in sync and the final status refresh re-syncs once the queue drains. + const toggleFileSelected = useCallback( + (filePath: string) => { + const isStaged = selectedFiles.has(filePath); + setSelectedFiles((previous) => { + const next = new Set(previous); + if (isStaged) { + next.delete(filePath); + } else { + next.add(filePath); + } + return next; + }); + enqueueStageOp(() => (isStaged ? onUnstageFiles([filePath]) : onStageFiles([filePath]))); + }, + [enqueueStageOp, onStageFiles, onUnstageFiles, selectedFiles], + ); const requestFileAction = useCallback( (filePath: string, status: FileStatusCode) => { @@ -197,7 +226,11 @@ export default function ChangesView({ {selectedFiles.size > 0 && (
); } diff --git a/src/components/git-panel/view/history/HistoryView.tsx b/src/components/git-panel/view/history/HistoryView.tsx index 4636af20..26664c38 100644 --- a/src/components/git-panel/view/history/HistoryView.tsx +++ b/src/components/git-panel/view/history/HistoryView.tsx @@ -1,6 +1,7 @@ import { History, RefreshCw } from 'lucide-react'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { GitDiffMap, GitCommitSummary } from '../../types/types'; +import { computeCommitGraph } from '../../utils/commitGraph'; import CommitHistoryItem from './CommitHistoryItem'; type HistoryViewProps = { @@ -22,6 +23,15 @@ export default function HistoryView({ }: HistoryViewProps) { const [expandedCommits, setExpandedCommits] = useState>(new Set()); + // Lane layout for the commit graph; rows align 1:1 with recentCommits. + // Older API responses without `parents` degrade to plain rows (no strip). + const graphRows = useMemo(() => { + if (!recentCommits.some((commit) => commit.parents !== undefined)) { + return null; + } + return computeCommitGraph(recentCommits); + }, [recentCommits]); + const toggleCommitExpanded = useCallback( (commitHash: string) => { const isExpanding = !expandedCommits.has(commitHash); @@ -59,7 +69,7 @@ export default function HistoryView({
) : (
- {recentCommits.map((commit) => ( + {recentCommits.map((commit, index) => ( toggleCommitExpanded(commit.hash)} /> ))} diff --git a/src/components/llm-logo-provider/GeminiLogo.tsx b/src/components/llm-logo-provider/GeminiLogo.tsx deleted file mode 100644 index 17212bd1..00000000 --- a/src/components/llm-logo-provider/GeminiLogo.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { useId } from 'react'; - -type GeminiLogoProps = { - className?: string; -}; - -const GeminiLogo = ({ className = 'w-5 h-5' }: GeminiLogoProps) => { - const id = useId().replace(/:/g, ''); - const maskId = `${id}-gemini-mask`; - const gradientId = `${id}-gemini-gradient`; - const filterIds = Array.from({ length: 11 }, (_, index) => `${id}-gemini-filter-${index}`); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export default GeminiLogo; diff --git a/src/components/llm-logo-provider/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx index e29ecd6d..b93d9eb5 100644 --- a/src/components/llm-logo-provider/SessionProviderLogo.tsx +++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx @@ -2,7 +2,6 @@ import type { LLMProvider } from '../../types/app'; import ClaudeLogo from './ClaudeLogo'; import CodexLogo from './CodexLogo'; import CursorLogo from './CursorLogo'; -import GeminiLogo from './GeminiLogo'; import OpenCodeLogo from './OpenCodeLogo'; type SessionProviderLogoProps = { @@ -22,10 +21,6 @@ export default function SessionProviderLogo({ return ; } - if (provider === 'gemini') { - return ; - } - if (provider === 'opencode') { return ; } diff --git a/src/components/mcp/constants.ts b/src/components/mcp/constants.ts index 2e23c5cb..5a1848d6 100644 --- a/src/components/mcp/constants.ts +++ b/src/components/mcp/constants.ts @@ -4,7 +4,6 @@ export const MCP_PROVIDER_NAMES: Record = { claude: 'Claude', cursor: 'Cursor', codex: 'Codex', - gemini: 'Gemini', opencode: 'OpenCode', }; @@ -12,7 +11,6 @@ export const MCP_SUPPORTED_SCOPES: Record = { claude: ['user', 'project', 'local'], cursor: ['user', 'project'], codex: ['user', 'project'], - gemini: ['user', 'project'], opencode: ['user', 'project'], }; @@ -20,7 +18,6 @@ export const MCP_SUPPORTED_TRANSPORTS: Record = { claude: ['stdio', 'http', 'sse'], cursor: ['stdio', 'http'], codex: ['stdio', 'http'], - gemini: ['stdio', 'http', 'sse'], opencode: ['stdio', 'http'], }; @@ -32,7 +29,6 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record = { claude: 'bg-primary text-primary-foreground hover:bg-primary/90', cursor: 'bg-primary text-primary-foreground hover:bg-primary/90', codex: 'bg-primary text-primary-foreground hover:bg-primary/90', - gemini: 'bg-primary text-primary-foreground hover:bg-primary/90', opencode: 'bg-primary text-primary-foreground hover:bg-primary/90', }; @@ -40,7 +36,6 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record = { claude: false, cursor: false, codex: true, - gemini: true, opencode: false, }; diff --git a/src/components/mcp/view/McpServers.tsx b/src/components/mcp/view/McpServers.tsx index 327fd740..a1436fe9 100644 --- a/src/components/mcp/view/McpServers.tsx +++ b/src/components/mcp/view/McpServers.tsx @@ -127,9 +127,9 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer }); const globalButtonLabel = 'Add Global MCP Server'; const providerButtonLabel = `Add ${providerName} MCP Server`; - const globalAddDescription = 'Add Global MCP Server writes one common stdio or HTTP server to Claude, Cursor, Codex, and Gemini.'; + const globalAddDescription = 'Add Global MCP Server writes one common stdio or HTTP server to Claude, Cursor, Codex, and OpenCode.'; const providerAddDescription = `${providerButtonLabel} only changes ${providerName}.`; - const globalModalDescription = 'Adds this MCP server to every provider: Claude, Cursor, Codex, and Gemini. ' + const globalModalDescription = 'Adds this MCP server to every provider: Claude, Cursor, Codex, and OpenCode. ' + 'Only stdio and HTTP transports are supported because the same config must work across all providers.'; return ( diff --git a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx index 776e9823..d9148a95 100644 --- a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx +++ b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx @@ -30,13 +30,6 @@ const providerCards = [ iconContainerClassName: 'bg-gray-100 dark:bg-gray-800', loginButtonClassName: 'bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600', }, - { - provider: 'gemini' as const, - title: 'Gemini', - connectedClassName: 'bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800', - iconContainerClassName: 'bg-teal-100 dark:bg-teal-900/30', - loginButtonClassName: 'bg-teal-600 hover:bg-teal-700', - }, { provider: 'opencode' as const, title: 'OpenCode', diff --git a/src/components/provider-auth/types.ts b/src/components/provider-auth/types.ts index afa08094..5511c184 100644 --- a/src/components/provider-auth/types.ts +++ b/src/components/provider-auth/types.ts @@ -10,13 +10,12 @@ export type ProviderAuthStatus = { export type ProviderAuthStatusMap = Record; -export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; +export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode']; export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = { claude: '/api/providers/claude/auth/status', cursor: '/api/providers/cursor/auth/status', codex: '/api/providers/codex/auth/status', - gemini: '/api/providers/gemini/auth/status', opencode: '/api/providers/opencode/auth/status', }; @@ -24,6 +23,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth claude: { authenticated: false, email: null, method: null, error: null, loading }, cursor: { authenticated: false, email: null, method: null, error: null, loading }, codex: { authenticated: false, email: null, method: null, error: null, loading }, - gemini: { authenticated: false, email: null, method: null, error: null, loading }, opencode: { authenticated: false, email: null, method: null, error: null, loading }, }); diff --git a/src/components/provider-auth/view/ProviderLoginModal.tsx b/src/components/provider-auth/view/ProviderLoginModal.tsx index 9de1d227..2a0cfc2a 100644 --- a/src/components/provider-auth/view/ProviderLoginModal.tsx +++ b/src/components/provider-auth/view/ProviderLoginModal.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, KeyRound, X } from 'lucide-react'; +import { X } from 'lucide-react'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; import { DEFAULT_PROJECT_FOR_EMPTY_SHELL, IS_PLATFORM } from '../../../constants/config'; import type { LLMProvider } from '../../../types/app'; @@ -41,7 +41,7 @@ const getProviderCommand = ({ return 'opencode auth login'; } - return 'gemini status'; + return 'claude --dangerously-skip-permissions /login'; }; const getProviderTitle = (provider: LLMProvider) => { @@ -49,7 +49,7 @@ const getProviderTitle = (provider: LLMProvider) => { if (provider === 'cursor') return 'Cursor CLI Login'; if (provider === 'codex') return 'Codex CLI Login'; if (provider === 'opencode') return 'OpenCode CLI Login'; - return 'Gemini CLI Configuration'; + return 'Claude CLI Login'; }; export default function ProviderLoginModal({ @@ -87,61 +87,7 @@ export default function ProviderLoginModal({
- {provider === 'gemini' ? ( -
-
- -
- -

Setup Gemini API Access

- -

- The Gemini CLI requires an API key to function. Configure it in your terminal first. -

- -
-
    -
  1. -
    - 1 -
    -
    -

    Get your API key

    - - Google AI Studio - -
    -
  2. -
  3. -
    - 2 -
    -
    -

    Run configuration

    -

    Open your terminal and run:

    - - gemini config set api_key YOUR_KEY - -
    -
  4. -
-
- - -
- ) : ( - - )} +
diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index 366c5989..8c429083 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -39,7 +39,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [ { id: 'about', label: 'About', keywords: 'about version info', icon: Info }, ]; -export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; +export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'opencode']; export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp']; export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name'; diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index e42a1e1b..cc57d377 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -14,7 +14,6 @@ import type { CodeEditorSettingsState, CodexPermissionMode, CursorPermissionsState, - GeminiPermissionMode, NotificationPreferencesState, ProjectSortOrder, SettingsMainTab, @@ -159,7 +158,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl createDefaultNotificationPreferences() )); const [codexPermissionMode, setCodexPermissionMode] = useState('default'); - const [geminiPermissionMode, setGeminiPermissionMode] = useState('default'); const [showLoginModal, setShowLoginModal] = useState(false); const [loginProvider, setLoginProvider] = useState(''); @@ -198,12 +196,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl ); setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode)); - const savedGeminiSettings = parseJson<{ permissionMode?: GeminiPermissionMode }>( - localStorage.getItem('gemini-settings'), - {}, - ); - setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default'); - try { const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences'); if (notificationResponse.ok) { @@ -276,11 +268,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl lastUpdated: now, })); - localStorage.setItem('gemini-settings', JSON.stringify({ - permissionMode: geminiPermissionMode, - lastUpdated: now, - })); - const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', { method: 'PUT', body: JSON.stringify(notificationPreferences), @@ -303,7 +290,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl cursorPermissions.disallowedCommands, cursorPermissions.skipPermissions, notificationPreferences, - geminiPermissionMode, projectSortOrder, ]); @@ -409,8 +395,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl codexPermissionMode, setCodexPermissionMode, providerAuthStatus, - geminiPermissionMode, - setGeminiPermissionMode, openLoginForProvider, showLoginModal, setShowLoginModal, diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index c6801f39..514de2f3 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -9,7 +9,6 @@ export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills'; export type ProjectSortOrder = 'name' | 'date'; export type SaveStatus = 'success' | 'error' | null; export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; -export type GeminiPermissionMode = 'default' | 'auto_edit' | 'yolo'; export type SettingsProject = { name: string; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 72499556..37f40bc9 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -52,8 +52,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set codexPermissionMode, setCodexPermissionMode, providerAuthStatus, - geminiPermissionMode, - setGeminiPermissionMode, openLoginForProvider, showLoginModal, setShowLoginModal, @@ -187,8 +185,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set onCursorPermissionsChange={setCursorPermissions} codexPermissionMode={codexPermissionMode} onCodexPermissionModeChange={setCodexPermissionMode} - geminiPermissionMode={geminiPermissionMode} - onGeminiPermissionModeChange={setGeminiPermissionMode} projects={projects} /> )} diff --git a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx index b23784ee..52e87c32 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx @@ -12,7 +12,7 @@ type AgentListItemProps = { type AgentConfig = { name: string; - color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc'; + color: 'blue' | 'purple' | 'gray' | 'zinc'; }; const agentConfig: Record = { @@ -28,10 +28,6 @@ const agentConfig: Record = { name: 'Codex', color: 'gray', }, - gemini: { - name: 'Gemini', - color: 'indigo', - }, opencode: { name: 'OpenCode', color: 'zinc', @@ -48,9 +44,6 @@ const colorClasses = { gray: { dot: 'bg-foreground/60', }, - indigo: { - dot: 'bg-indigo-500', - }, zinc: { dot: 'bg-zinc-500', }, diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx index a5221f83..31216a10 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx @@ -16,8 +16,6 @@ export default function AgentsSettingsTab({ onCursorPermissionsChange, codexPermissionMode, onCodexPermissionModeChange, - geminiPermissionMode, - onGeminiPermissionModeChange, projects, }: AgentsSettingsTabProps) { const [selectedAgent, setSelectedAgent] = useState('claude'); @@ -29,7 +27,7 @@ export default function AgentsSettingsTab({ ), [selectedAgent]); const visibleAgents = useMemo(() => { - return ['claude', 'cursor', 'codex', 'gemini', 'opencode']; + return ['claude', 'cursor', 'codex', 'opencode']; }, []); const agentContextById = useMemo>(() => ({ @@ -45,10 +43,6 @@ export default function AgentsSettingsTab({ authStatus: providerAuthStatus.codex, onLogin: () => onProviderLogin('codex'), }, - gemini: { - authStatus: providerAuthStatus.gemini, - onLogin: () => onProviderLogin('gemini'), - }, opencode: { authStatus: providerAuthStatus.opencode, onLogin: () => onProviderLogin('opencode'), @@ -58,7 +52,6 @@ export default function AgentsSettingsTab({ providerAuthStatus.claude, providerAuthStatus.codex, providerAuthStatus.cursor, - providerAuthStatus.gemini, providerAuthStatus.opencode, ]); @@ -95,8 +88,6 @@ export default function AgentsSettingsTab({ onCursorPermissionsChange={onCursorPermissionsChange} codexPermissionMode={codexPermissionMode} onCodexPermissionModeChange={onCodexPermissionModeChange} - geminiPermissionMode={geminiPermissionMode} - onGeminiPermissionModeChange={onGeminiPermissionModeChange} projects={projects} />
diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx index a6d017fa..e25733b4 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx @@ -7,7 +7,6 @@ const AGENT_NAMES: Record = { claude: 'Claude', cursor: 'Cursor', codex: 'Codex', - gemini: 'Gemini', opencode: 'OpenCode', }; @@ -24,7 +23,6 @@ export default function AgentSelectorSection({ const dotColor = agent === 'claude' ? 'bg-blue-500' : agent === 'cursor' ? 'bg-purple-500' : - agent === 'gemini' ? 'bg-indigo-500' : agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60'; return ( diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx index c0ed69d5..83fbb249 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx @@ -45,15 +45,6 @@ const agentConfig: Record = { subtextClass: 'text-gray-700 dark:text-gray-300', buttonClass: 'bg-gray-800 hover:bg-gray-900 active:bg-gray-950 dark:bg-gray-700 dark:hover:bg-gray-600 dark:active:bg-gray-500', }, - 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 active:bg-indigo-800', - }, opencode: { name: 'OpenCode', description: 'OpenCode CLI assistant', diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx index fa545aaa..6c41cfa6 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { AlertTriangle, Plus, Shield, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Input } from '../../../../../../../shared/view/ui'; -import type { CodexPermissionMode, GeminiPermissionMode } from '../../../../../types/types'; +import type { CodexPermissionMode } from '../../../../../types/types'; const COMMON_CLAUDE_TOOLS = [ 'Bash(git log:*)', @@ -579,111 +579,7 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit void; -}; - -// Gemini Permissions -function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit) { - const { t } = useTranslation(['settings', 'chat']); - return ( -
-
-
- -

- {t('gemini.permissionMode')} -

-
-

- {t('gemini.description')} -

- - {/* Default Mode */} -
onPermissionModeChange('default')} - > - -
- - {/* Auto Edit Mode */} -
onPermissionModeChange('auto_edit')} - > - -
- - {/* YOLO Mode */} -
onPermissionModeChange('yolo')} - > - -
-
-
- ); -} - -type PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps | GeminiPermissionsProps; +type PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps; export default function PermissionsContent(props: PermissionsContentProps) { if (props.agent === 'claude') { @@ -694,9 +590,5 @@ export default function PermissionsContent(props: PermissionsContentProps) { return ; } - if (props.agent === 'gemini') { - return ; - } - return ; } diff --git a/src/components/settings/view/tabs/agents-settings/types.ts b/src/components/settings/view/tabs/agents-settings/types.ts index 731ce8d3..a5ed788e 100644 --- a/src/components/settings/view/tabs/agents-settings/types.ts +++ b/src/components/settings/view/tabs/agents-settings/types.ts @@ -5,7 +5,6 @@ import type { ClaudePermissionsState, CursorPermissionsState, CodexPermissionMode, - GeminiPermissionMode, SettingsProject, } from '../../../types/types'; @@ -26,8 +25,6 @@ export type AgentsSettingsTabProps = { onCursorPermissionsChange: (value: CursorPermissionsState) => void; codexPermissionMode: CodexPermissionMode; onCodexPermissionModeChange: (value: CodexPermissionMode) => void; - geminiPermissionMode: GeminiPermissionMode; - onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void; projects: SettingsProject[]; }; @@ -55,7 +52,5 @@ export type AgentCategoryContentSectionProps = { onCursorPermissionsChange: (value: CursorPermissionsState) => void; codexPermissionMode: CodexPermissionMode; onCodexPermissionModeChange: (value: CodexPermissionMode) => void; - geminiPermissionMode: GeminiPermissionMode; - onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void; projects: SettingsProject[]; }; diff --git a/src/components/shell/hooks/useShellTerminal.ts b/src/components/shell/hooks/useShellTerminal.ts index 076c5b76..8b343811 100644 --- a/src/components/shell/hooks/useShellTerminal.ts +++ b/src/components/shell/hooks/useShellTerminal.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { MutableRefObject, RefObject } from 'react'; +import { ClipboardAddon, type IClipboardProvider } from '@xterm/addon-clipboard'; import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebglAddon } from '@xterm/addon-webgl'; @@ -19,6 +20,43 @@ import { import { sendSocketMessage } from '../utils/socket'; import { ensureXtermFocusStyles } from '../utils/terminalStyles'; +// CLIs running inside the pty (e.g. `claude auth login`'s "press c to copy" +// device-flow prompt) write to the clipboard via an OSC 52 escape sequence, +// not a browser event — xterm.js ignores OSC 52 unless a clipboard addon is +// loaded. Routes writes through the same fallback-aware helper the terminal's +// own selection-copy shortcut uses, since `navigator.clipboard` is often +// unavailable on self-hosted, non-HTTPS deployments. +// `ClipboardSelectionType.SYSTEM` is `'c'` (vs. `'p'` for the X11 primary +// selection) — compared as a literal since the addon ships it as a const +// enum, which isolatedModules builds (esbuild/Vite) can't import as a value. +const oscClipboardProvider: IClipboardProvider = { + readText: async (selection) => { + if (selection !== 'c') { + return ''; + } + try { + return (await navigator.clipboard?.readText?.()) || ''; + } catch { + return ''; + } + }, + writeText: async (selection, text) => { + if (selection !== 'c') { + return; + } + await copyTextToClipboard(text); + }, +}; + +// The addon's published typings declare a single `(provider?)` constructor +// param, but the shipped runtime actually takes `(base64?, provider?)` — see +// node_modules/@xterm/addon-clipboard/lib/addon-clipboard.js. Cast to call it +// the way it's really implemented. +const ClipboardAddonCtor = ClipboardAddon as unknown as new ( + base64?: unknown, + provider?: IClipboardProvider, +) => ClipboardAddon; + type UseShellTerminalOptions = { terminalContainerRef: RefObject; terminalRef: MutableRefObject; @@ -93,6 +131,8 @@ export function useShellTerminal({ fitAddonRef.current = nextFitAddon; nextTerminal.loadAddon(nextFitAddon); + nextTerminal.loadAddon(new ClipboardAddonCtor(undefined, oscClipboardProvider)); + // Avoid wrapped partial links in compact login flows. if (!minimal) { nextTerminal.loadAddon(new WebLinksAddon()); diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index 672fdd34..f5393d16 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -42,6 +42,7 @@ export type SidebarProps = { selectedProject: Project | null; selectedSession: ProjectSession | null; activeSessions: SessionActivityMap; + attentionSessionIds: ReadonlySet; onProjectSelect: (project: Project) => void; onSessionSelect: (session: ProjectSession) => void; onNewSession: (project: Project) => void; diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index 5e544b08..3189b699 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -26,6 +26,7 @@ function Sidebar({ selectedProject, selectedSession, activeSessions, + attentionSessionIds, onProjectSelect, onSessionSelect, onNewSession, @@ -163,6 +164,7 @@ function Sidebar({ getProjectSessions, loadingMoreProjects, activeSessions, + attentionSessionIds, forceExpanded: searchMode === 'running', isProjectStarred, onEditingNameChange: setEditingName, diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx index 618e0326..4382240c 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx @@ -45,6 +45,7 @@ type SidebarProjectItemProps = { ) => void; onLoadMoreSessions: (projectId: string) => void; activeSessions: SessionActivityMap; + attentionSessionIds: ReadonlySet; onNewSession: (project: Project) => void; onEditingSessionNameChange: (value: string) => void; onStartEditingSession: (sessionId: string, initialName: string) => void; @@ -87,6 +88,7 @@ export default function SidebarProjectItem({ onDeleteSession, onLoadMoreSessions, activeSessions, + attentionSessionIds, onNewSession, onEditingSessionNameChange, onStartEditingSession, @@ -399,6 +401,7 @@ export default function SidebarProjectItem({ hasMoreSessions={Boolean(project.sessionMeta?.hasMore)} isLoadingMoreSessions={isLoadingMoreSessions} activeSessions={activeSessions} + attentionSessionIds={attentionSessionIds} currentTime={currentTime} editingSession={editingSession} editingSessionName={editingSessionName} diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx index 90e6ec7c..84b9302f 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx @@ -29,6 +29,7 @@ export type SidebarProjectListProps = { onLoadMoreSessions: (projectId: string) => void; loadingMoreProjects: Set; activeSessions: SessionActivityMap; + attentionSessionIds: ReadonlySet; forceExpanded?: boolean; isProjectStarred: (projectName: string) => boolean; onEditingNameChange: (value: string) => void; @@ -75,6 +76,7 @@ export default function SidebarProjectList({ onLoadMoreSessions, loadingMoreProjects, activeSessions, + attentionSessionIds, forceExpanded = false, isProjectStarred, onEditingNameChange, @@ -152,6 +154,7 @@ export default function SidebarProjectList({ onDeleteSession={onDeleteSession} onLoadMoreSessions={onLoadMoreSessions} activeSessions={activeSessions} + attentionSessionIds={attentionSessionIds} onNewSession={onNewSession} onEditingSessionNameChange={onEditingSessionNameChange} onStartEditingSession={onStartEditingSession} diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx index 1c8763bb..df08e4f3 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx @@ -17,6 +17,7 @@ type SidebarProjectSessionsProps = { hasMoreSessions: boolean; isLoadingMoreSessions: boolean; activeSessions: SessionActivityMap; + attentionSessionIds: ReadonlySet; currentTime: Date; editingSession: string | null; editingSessionName: string; @@ -64,6 +65,7 @@ export default function SidebarProjectSessions({ hasMoreSessions, isLoadingMoreSessions, activeSessions, + attentionSessionIds, currentTime, editingSession, editingSessionName, @@ -124,6 +126,7 @@ export default function SidebarProjectSessions({ session={session} selectedSession={selectedSession} isProcessing={activeSessions.has(session.id)} + needsAttention={attentionSessionIds.has(session.id)} currentTime={currentTime} editingSession={editingSession} editingSessionName={editingSessionName} diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx index 85a25250..91ea437c 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx @@ -14,6 +14,7 @@ type SidebarSessionItemProps = { session: SessionWithProvider; selectedSession: ProjectSession | null; isProcessing: boolean; + needsAttention: boolean; currentTime: Date; editingSession: string | null; editingSessionName: string; @@ -65,6 +66,7 @@ export default function SidebarSessionItem({ session, selectedSession, isProcessing, + needsAttention, currentTime, editingSession, editingSessionName, @@ -82,7 +84,8 @@ export default function SidebarSessionItem({ const isEditing = editingSession === session.id; const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime); const editingContainerRef = useRef(null); - const showRecentIndicator = !isProcessing && sessionView.isActive; + const showAttentionIndicator = needsAttention && !isSelected; + const showRecentIndicator = !showAttentionIndicator && !isProcessing && sessionView.isActive; // The rename panel sits inside a group-hover opacity wrapper, so leaving the row // would visually hide it. While editing, dismiss only when the user clicks outside @@ -120,13 +123,23 @@ export default function SidebarSessionItem({ return (
- {showRecentIndicator && ( + {(showAttentionIndicator || showRecentIndicator) && (
- +
diff --git a/src/components/skills/view/ProviderSkills.tsx b/src/components/skills/view/ProviderSkills.tsx index 42f7159c..908ed076 100644 --- a/src/components/skills/view/ProviderSkills.tsx +++ b/src/components/skills/view/ProviderSkills.tsx @@ -59,7 +59,6 @@ const PROVIDER_NAMES: Record = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor', - gemini: 'Gemini', opencode: 'OpenCode', }; @@ -67,7 +66,6 @@ const PROVIDER_SKILL_PATHS: Record, string> claude: '~/.claude/skills//SKILL.md', codex: '~/.agents/skills//SKILL.md', cursor: '~/.cursor/skills//SKILL.md', - gemini: '~/.gemini/skills//SKILL.md', }; const SCOPE_LABELS: Record = { diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index a2cdbd06..f5574adc 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -255,6 +255,13 @@ const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent) for (const [index, session] of sessions.entries()) { if (index === existingIndex) { const updated = { ...session, ...normalizedSession }; + // Never let a later upsert that carries an empty summary blank out a + // title we already have. Fresh sessions momentarily broadcast an empty + // custom_name before the disk indexer fills it in, which would + // otherwise flash the row back to the "New session" placeholder. + if (!normalizedSession.summary?.trim() && session.summary?.trim()) { + updated.summary = session.summary; + } if (serialize(session) !== serialize(updated)) { changed = true; } @@ -352,6 +359,7 @@ export function useProjectsState({ const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); const [selectedSession, setSelectedSession] = useState(null); + const [attentionSessionIds, setAttentionSessionIds] = useState>(new Set()); const [activeTab, setActiveTab] = useState(readPersistedTab); useEffect(() => { @@ -405,6 +413,43 @@ export function useProjectsState({ const activeSessionsRef = useRef(activeSessions); activeSessionsRef.current = activeSessions; + const markSessionAttention = useCallback((targetSessionId?: string | null) => { + if (!targetSessionId) { + return; + } + + const viewedSessionId = selectedSessionRef.current?.id ?? sessionId ?? null; + if (targetSessionId === viewedSessionId) { + return; + } + + setAttentionSessionIds((previous) => { + if (previous.has(targetSessionId)) { + return previous; + } + + const next = new Set(previous); + next.add(targetSessionId); + return next; + }); + }, [sessionId]); + + const clearSessionAttention = useCallback((targetSessionId?: string | null) => { + if (!targetSessionId) { + return; + } + + setAttentionSessionIds((previous) => { + if (!previous.has(targetSessionId)) { + return previous; + } + + const next = new Set(previous); + next.delete(targetSessionId); + return next; + }); + }, []); + const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => { try { if (showLoadingState) { @@ -598,6 +643,25 @@ export function useProjectsState({ return; } + const eventSessionId = typeof event.sessionId === 'string' && event.sessionId + ? event.sessionId + : null; + const viewedSessionId = selectedSessionRef.current?.id ?? sessionId ?? null; + + if ( + eventSessionId + && eventSessionId !== viewedSessionId + && event.kind !== 'chat_subscribed' + && event.kind !== 'loading_progress' + && event.kind !== 'session_upserted' + && event.kind !== 'status' + && event.kind !== 'stream_end' + && event.kind !== 'permission_cancelled' + && event.kind !== 'websocket_reconnected' + ) { + markSessionAttention(eventSessionId); + } + if (event.kind !== 'session_upserted') { return; } @@ -617,6 +681,8 @@ export function useProjectsState({ && !activeSessionsRef.current.has(upsert.sessionId) ) { setExternalMessageUpdate((prev) => prev + 1); + } else { + markSessionAttention(upsert.sessionId); } setProjects((previousProjects) => { @@ -702,7 +768,7 @@ export function useProjectsState({ }; return subscribe(handleEvent); - }, [navigate, sessionId, subscribe]); + }, [markSessionAttention, navigate, sessionId, subscribe]); useEffect(() => { return () => { @@ -713,6 +779,10 @@ export function useProjectsState({ }; }, []); + useEffect(() => { + clearSessionAttention(selectedSession?.id ?? sessionId ?? null); + }, [clearSessionAttention, selectedSession?.id, sessionId]); + useEffect(() => { if (!sessionId || projects.length === 0) { return; @@ -747,6 +817,10 @@ export function useProjectsState({ return; } + // Only the currently selected project may host the placeholder. Guessing + // another project (e.g. "first one with sessions") could bind the URL + // session to the wrong project — better to wait until the owning project + // arrives in a later `projects` payload and is matched by the loop above. if (!selectedProject) { return; } @@ -774,6 +848,7 @@ export function useProjectsState({ const handleSessionSelect = useCallback( (session: ProjectSession) => { + clearSessionAttention(session.id); setSelectedSession(session); if (activeTab === 'tasks' || activeTab === 'browser') { @@ -795,7 +870,7 @@ export function useProjectsState({ navigate(`/session/${session.id}`); }, - [activeTab, isMobile, navigate, selectedProject?.projectId], + [activeTab, clearSessionAttention, isMobile, navigate, selectedProject?.projectId], ); const handleNewSession = useCallback( @@ -815,6 +890,8 @@ export function useProjectsState({ const handleSessionDelete = useCallback( (sessionIdToDelete: string) => { + clearSessionAttention(sessionIdToDelete); + if (selectedSession?.id === sessionIdToDelete) { setSelectedSession(null); navigate('/'); @@ -824,7 +901,7 @@ export function useProjectsState({ prevProjects.map((project) => removeSessionFromProject(project, sessionIdToDelete)), ); }, - [navigate, selectedSession?.id], + [clearSessionAttention, navigate, selectedSession?.id], ); const handleSidebarRefresh = useCallback(async () => { @@ -945,6 +1022,7 @@ export function useProjectsState({ selectedProject, selectedSession, activeSessions, + attentionSessionIds, onProjectSelect: handleProjectSelect, onSessionSelect: handleSessionSelect, onNewSession: handleNewSession, @@ -961,6 +1039,7 @@ export function useProjectsState({ isMobile, }), [ + attentionSessionIds, handleNewSession, handleProjectDelete, handleProjectSelect, diff --git a/src/hooks/useQueuedMessageAutoSend.ts b/src/hooks/useQueuedMessageAutoSend.ts new file mode 100644 index 00000000..7be47377 --- /dev/null +++ b/src/hooks/useQueuedMessageAutoSend.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef } from 'react'; + +import { clearQueuedMessage, readQueuedMessage } from '../components/chat/utils/chatStorage'; + +import type { MarkSessionProcessing, SessionActivityMap } from './useSessionProtection'; + +interface UseQueuedMessageAutoSendArgs { + processingSessions: SessionActivityMap; + /** + * The session currently open in the chat view. Its queued draft is owned by + * the composer (which also handles image attachments and slash commands), + * so this hook never touches it. + */ + activeSessionId: string | null; + ws: WebSocket | null; + sendMessage: (message: unknown) => void; + markSessionProcessing: MarkSessionProcessing; +} + +/** + * Dispatches queued messages for sessions the user is NOT currently viewing. + * + * The composer persists each queued draft (text + send options snapshotted at + * queue time) under `queued_message_`. When a session's run leaves + * the processing map — its previous response completed — this hook sends that + * session's queued message immediately instead of waiting for the user to + * open the session again. Removing the storage key before sending is the + * claim that keeps the composer's own flush from double-sending. + */ +export function useQueuedMessageAutoSend({ + processingSessions, + activeSessionId, + ws, + sendMessage, + markSessionProcessing, +}: UseQueuedMessageAutoSendArgs) { + const prevProcessingRef = useRef>(new Set()); + + useEffect(() => { + const prev = prevProcessingRef.current; + const current = new Set(processingSessions.keys()); + prevProcessingRef.current = current; + + for (const sessionId of prev) { + if (current.has(sessionId) || sessionId === activeSessionId) { + continue; + } + + const queued = readQueuedMessage(sessionId); + if (!queued) { + continue; + } + + // A closed socket would drop the send silently; keep the draft so the + // composer (or a later completion) can retry once we're connected. + if (!ws || ws.readyState !== WebSocket.OPEN) { + continue; + } + + clearQueuedMessage(sessionId); + sendMessage({ + type: 'chat.send', + sessionId, + content: queued.content, + options: { ...(queued.options ?? {}), images: [] }, + }); + markSessionProcessing(sessionId, { statusText: null, canInterrupt: true }); + } + }, [processingSessions, activeSessionId, ws, sendMessage, markSessionProcessing]); +} diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index f0b10d7a..244cdd12 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -17,8 +17,7 @@ "tool": "Werkzeug", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex", - "gemini": "Gemini" + "codex": "Codex" }, "tools": { "settings": "Werkzeugeinstellungen", @@ -103,24 +102,6 @@ }, "technicalDetails": "Technische Details" }, - "gemini": { - "permissionMode": "Gemini-Berechtigungsmodus", - "description": "Steuert, wie Gemini CLI Vorgangsgenehmigungen handhabt.", - "modes": { - "default": { - "title": "Standard (Genehmigung erforderlich)", - "description": "Gemini fordert vor der Ausführung von Befehlen, dem Schreiben von Dateien und dem Abrufen von Web-Ressourcen eine Genehmigung an." - }, - "autoEdit": { - "title": "Auto-Bearbeitung (Dateigenehmigungen überspringen)", - "description": "Gemini genehmigt automatisch Dateibearbeitungen und Web-Abrufe, fragt aber weiterhin bei Shell-Befehlen nach." - }, - "yolo": { - "title": "YOLO (Alle Berechtigungen umgehen)", - "description": "Gemini führt alle Vorgänge ohne Rückfrage aus. Vorsicht ist geboten." - } - } - }, "input": { "placeholder": "/ für Befehle, @ für Dateien eingeben oder {{provider}} etwas fragen...", "placeholderDefault": "Nachricht eingeben...", @@ -152,7 +133,6 @@ "claude": "Bereit, Claude mit {{model}} zu verwenden. Gib unten deine Nachricht ein.", "cursor": "Bereit, Cursor mit {{model}} zu verwenden. Gib unten deine Nachricht ein.", "codex": "Bereit, Codex mit {{model}} zu verwenden. Gib unten deine Nachricht ein.", - "gemini": "Bereit, Gemini mit {{model}} zu verwenden. Gib unten deine Nachricht ein.", "default": "Wähl oben einen Anbieter, um zu beginnen" }, "pressToSearch": "Drücke {{shortcut}}, um Sitzungen, Dateien und Commits zu durchsuchen" diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 958735bd..94a1516c 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -322,9 +322,6 @@ }, "codex": { "description": "OpenAI Codex KI-Assistent" - }, - "gemini": { - "description": "Google Gemini KI-Assistent" } }, "connectionStatus": "Verbindungsstatus", diff --git a/src/i18n/locales/de/sidebar.json b/src/i18n/locales/de/sidebar.json index d45c372f..b5469b13 100644 --- a/src/i18n/locales/de/sidebar.json +++ b/src/i18n/locales/de/sidebar.json @@ -49,7 +49,8 @@ "save": "Speichern", "cancel": "Abbrechen", "clearSearch": "Suche leeren", - "openCommandPalette": "Befehlspalette öffnen" + "openCommandPalette": "Befehlspalette öffnen", + "attentionRequiredIndicator": "Sitzung erfordert Aufmerksamkeit" }, "navigation": { "chat": "Chat", diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 656fa328..87f22adb 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -18,7 +18,6 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "gemini": "Gemini", "opencode": "OpenCode" }, "tools": { @@ -104,24 +103,6 @@ }, "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." - } - } - }, "voice": { "input": "Voice input", "stopRecording": "Stop recording", @@ -140,12 +121,22 @@ "stop": "Stop", "hintText": { "ctrlEnter": "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands", - "enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands" + "enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands", + "queue": "Enter to queue your next message", + "updateQueued": "Enter to update queued message" }, "clickToChangeMode": "Click to change permission mode (or press Tab in input)", "showAllCommands": "Show all commands", "clearInput": "Clear input", - "scrollToBottom": "Scroll to bottom" + "scrollToBottom": "Scroll to bottom", + "queue": { + "sendNext": "Queue next message", + "update": "Update queued message", + "label": "Queued", + "willSend": "Will send when this finishes", + "edit": "Edit queued message", + "delete": "Delete queued message" + } }, "providerSelection": { "title": "Choose Your AI Assistant", @@ -161,7 +152,6 @@ "claude": "Ready to use Claude with {{model}}. Start typing your message below.", "cursor": "Ready to use Cursor with {{model}}. Start typing your message below.", "codex": "Ready to use Codex with {{model}}. Start typing your message below.", - "gemini": "Ready to use Gemini with {{model}}. Start typing your message below.", "opencode": "Ready to use OpenCode with {{model}}. Start typing your message below.", "default": "Select a provider above to begin" }, diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index aabfc143..079dfe46 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -349,9 +349,6 @@ "codex": { "description": "OpenAI Codex AI assistant" }, - "gemini": { - "description": "Google Gemini AI assistant" - }, "opencode": { "description": "OpenCode CLI assistant" } diff --git a/src/i18n/locales/en/sidebar.json b/src/i18n/locales/en/sidebar.json index 05783430..a1392745 100644 --- a/src/i18n/locales/en/sidebar.json +++ b/src/i18n/locales/en/sidebar.json @@ -49,7 +49,8 @@ "save": "Save", "cancel": "Cancel", "clearSearch": "Clear search", - "openCommandPalette": "Open command palette" + "openCommandPalette": "Open command palette", + "attentionRequiredIndicator": "Session needs attention" }, "navigation": { "chat": "Chat", diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json index 524c8d00..89dcb7b3 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -18,7 +18,6 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "gemini": "Gemini", "opencode": "OpenCode" }, "tools": { @@ -104,24 +103,6 @@ }, "technicalDetails": "Détails techniques" }, - "gemini": { - "permissionMode": "Mode de permission Gemini", - "description": "Contrôlez comment Gemini CLI gère les approbations d'opérations.", - "modes": { - "default": { - "title": "Standard (demander l'approbation)", - "description": "Gemini demandera une approbation avant d'exécuter des commandes, d'écrire des fichiers et de récupérer des ressources web." - }, - "autoEdit": { - "title": "Modification automatique (ignorer les approbations de fichiers)", - "description": "Gemini approuvera automatiquement les modifications de fichiers et les récupérations web, mais demandera toujours pour les commandes shell." - }, - "yolo": { - "title": "YOLO (contourner toutes les permissions)", - "description": "Gemini exécutera toutes les opérations sans demander d'approbation. Utilisez avec prudence." - } - } - }, "input": { "placeholder": "Tapez / pour les commandes, @ pour les fichiers, ou posez une question à {{provider}}...", "placeholderDefault": "Tapez votre message...", @@ -153,7 +134,6 @@ "claude": "Prêt à utiliser Claude avec {{model}}. Commencez à taper votre message ci-dessous.", "cursor": "Prêt à utiliser Cursor avec {{model}}. Commencez à taper votre message ci-dessous.", "codex": "Prêt à utiliser Codex avec {{model}}. Commencez à taper votre message ci-dessous.", - "gemini": "Prêt à utiliser Gemini avec {{model}}. Commencez à taper votre message ci-dessous.", "opencode": "Prêt à utiliser OpenCode avec {{model}}. Commencez à taper votre message ci-dessous.", "default": "Sélectionnez un fournisseur ci-dessus pour commencer" }, diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index bb25cd8a..bf8994ed 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -323,9 +323,6 @@ "codex": { "description": "Assistant IA Codex d'OpenAI" }, - "gemini": { - "description": "Assistant IA Gemini de Google" - }, "opencode": { "description": "Assistant CLI OpenCode" } diff --git a/src/i18n/locales/fr/sidebar.json b/src/i18n/locales/fr/sidebar.json index 682ca437..155d1ff3 100644 --- a/src/i18n/locales/fr/sidebar.json +++ b/src/i18n/locales/fr/sidebar.json @@ -49,7 +49,8 @@ "save": "Enregistrer", "cancel": "Annuler", "clearSearch": "Effacer la recherche", - "openCommandPalette": "Ouvrir la palette de commandes" + "openCommandPalette": "Ouvrir la palette de commandes", + "attentionRequiredIndicator": "La session nécessite votre attention" }, "navigation": { "chat": "Chat", diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index ae845d9a..71f4ac3d 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -17,8 +17,7 @@ "tool": "Strumento", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex", - "gemini": "Gemini" + "codex": "Codex" }, "tools": { "settings": "Impostazioni strumento", @@ -103,24 +102,6 @@ }, "technicalDetails": "Dettagli tecnici" }, - "gemini": { - "permissionMode": "Modalità permessi Gemini", - "description": "Controlla come Gemini CLI gestisce le approvazioni delle operazioni.", - "modes": { - "default": { - "title": "Standard (chiedi approvazione)", - "description": "Gemini chiederà l'approvazione prima di eseguire comandi, scrivere file e recuperare risorse web." - }, - "autoEdit": { - "title": "Modifica automatica (salta approvazioni file)", - "description": "Gemini approverà automaticamente modifiche ai file e recupero web, ma chiederà conferma per i comandi shell." - }, - "yolo": { - "title": "YOLO (ignora tutti i permessi)", - "description": "Gemini eseguirà tutte le operazioni senza chiedere approvazione. Usa con cautela." - } - } - }, "input": { "placeholder": "Digita / per i comandi, @ per i file, o chiedi qualcosa a {{provider}}...", "placeholderDefault": "Scrivi il tuo messaggio...", @@ -152,7 +133,6 @@ "claude": "Pronto a usare Claude con {{model}}. Inizia a digitare il tuo messaggio qui sotto.", "cursor": "Pronto a usare Cursor con {{model}}. Inizia a digitare il tuo messaggio qui sotto.", "codex": "Pronto a usare Codex con {{model}}. Inizia a digitare il tuo messaggio qui sotto.", - "gemini": "Pronto a usare Gemini con {{model}}. Inizia a digitare il tuo messaggio qui sotto.", "default": "Seleziona un provider sopra per iniziare" }, "pressToSearch": "Premi {{shortcut}} per cercare sessioni, file e commit" diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 45f320f1..914c6aa5 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -322,9 +322,6 @@ }, "codex": { "description": "Assistente AI OpenAI Codex" - }, - "gemini": { - "description": "Assistente AI Google Gemini" } }, "connectionStatus": "Stato connessione", diff --git a/src/i18n/locales/it/sidebar.json b/src/i18n/locales/it/sidebar.json index f019b216..17f5a9f4 100644 --- a/src/i18n/locales/it/sidebar.json +++ b/src/i18n/locales/it/sidebar.json @@ -49,7 +49,8 @@ "save": "Salva", "cancel": "Annulla", "clearSearch": "Cancella ricerca", - "openCommandPalette": "Apri tavolozza comandi" + "openCommandPalette": "Apri tavolozza comandi", + "attentionRequiredIndicator": "La sessione richiede attenzione" }, "navigation": { "chat": "Chat", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index ee3b7f20..abe4df4f 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -322,9 +322,6 @@ }, "codex": { "description": "OpenAI Codex AIアシスタント" - }, - "gemini": { - "description": "Google Gemini AIアシスタント" } }, "connectionStatus": "接続状態", diff --git a/src/i18n/locales/ja/sidebar.json b/src/i18n/locales/ja/sidebar.json index e3d782ac..576ff7b5 100644 --- a/src/i18n/locales/ja/sidebar.json +++ b/src/i18n/locales/ja/sidebar.json @@ -48,7 +48,8 @@ "activeSessionIndicator": "最近アクティブなセッション(過去10分以内)", "save": "保存", "cancel": "キャンセル", - "openCommandPalette": "コマンドパレットを開く" + "openCommandPalette": "コマンドパレットを開く", + "attentionRequiredIndicator": "セッションが対応を必要としています" }, "navigation": { "chat": "チャット", diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index c9df5c2e..7c7f7da3 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -17,8 +17,7 @@ "tool": "도구", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex", - "gemini": "Gemini" + "codex": "Codex" }, "tools": { "settings": "도구 설정", @@ -134,7 +133,6 @@ "claude": "{{model}} 모델로 Claude를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.", "cursor": "{{model}} 모델로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.", "codex": "{{model}} 모델로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.", - "gemini": "{{model}} 모델로 Gemini를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.", "default": "시작하려면 위에서 제공자를 선택하세요" }, "pressToSearch": "{{shortcut}}를 눌러 세션, 파일 및 커밋을 검색하세요" diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 6a23ac6c..8577ecd4 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -322,9 +322,6 @@ }, "codex": { "description": "OpenAI Codex AI 어시스턴트" - }, - "gemini": { - "description": "Google Gemini AI 어시스턴트" } }, "connectionStatus": "연결 상태", diff --git a/src/i18n/locales/ko/sidebar.json b/src/i18n/locales/ko/sidebar.json index 6eaef5b2..32635a0b 100644 --- a/src/i18n/locales/ko/sidebar.json +++ b/src/i18n/locales/ko/sidebar.json @@ -48,7 +48,8 @@ "activeSessionIndicator": "최근 활성 세션 (지난 10분)", "save": "저장", "cancel": "취소", - "openCommandPalette": "명령 팔레트 열기" + "openCommandPalette": "명령 팔레트 열기", + "attentionRequiredIndicator": "세션에 주의가 필요합니다" }, "navigation": { "chat": "채팅", @@ -128,4 +129,4 @@ "allConversationsDeleted": "프로젝트가 사이드바에서 제거됩니다. 파일, 메모리 및 세션 데이터는 보존됩니다.", "cannotUndo": "나중에 프로젝트를 다시 추가할 수 있습니다." } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 8d3e9e09..af853ce1 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -17,8 +17,7 @@ "tool": "Инструмент", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex", - "gemini": "Gemini" + "codex": "Codex" }, "tools": { "settings": "Настройки инструмента", @@ -103,24 +102,6 @@ }, "technicalDetails": "Технические детали" }, - "gemini": { - "permissionMode": "Режим разрешений Gemini", - "description": "Управление тем, как Gemini CLI обрабатывает подтверждения операций.", - "modes": { - "default": { - "title": "Стандартный (запрашивать подтверждение)", - "description": "Gemini будет запрашивать подтверждение перед выполнением команд, записью файлов и получением веб-ресурсов." - }, - "autoEdit": { - "title": "Автоматическое редактирование (пропускать подтверждения файлов)", - "description": "Gemini будет автоматически подтверждать редактирование файлов и веб-запросы, но все еще будет запрашивать подтверждение для команд оболочки." - }, - "yolo": { - "title": "YOLO (обход всех разрешений)", - "description": "Gemini будет выполнять все операции без запроса подтверждения. Будьте осторожны." - } - } - }, "input": { "placeholder": "Введите / для команд, @ для файлов, или спросите {{provider}} что угодно...", "placeholderDefault": "Введите ваше сообщение...", @@ -152,7 +133,6 @@ "claude": "Готов использовать Claude с {{model}}. Начните вводить сообщение ниже.", "cursor": "Готов использовать Cursor с {{model}}. Начните вводить сообщение ниже.", "codex": "Готов использовать Codex с {{model}}. Начните вводить сообщение ниже.", - "gemini": "Готов использовать Gemini с {{model}}. Начните вводить сообщение ниже.", "default": "Выберите провайдера выше для начала" }, "pressToSearch": "Нажмите {{shortcut}}, чтобы искать сессии, файлы и коммиты" diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 480cbcdf..7e47acda 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -322,9 +322,6 @@ }, "codex": { "description": "AI-ассистент OpenAI Codex" - }, - "gemini": { - "description": "AI-ассистент Google Gemini" } }, "connectionStatus": "Статус подключения", diff --git a/src/i18n/locales/ru/sidebar.json b/src/i18n/locales/ru/sidebar.json index 8a85cf59..1fa5135f 100644 --- a/src/i18n/locales/ru/sidebar.json +++ b/src/i18n/locales/ru/sidebar.json @@ -49,7 +49,8 @@ "save": "Сохранить", "cancel": "Отмена", "clearSearch": "Очистить поиск", - "openCommandPalette": "Открыть палитру команд" + "openCommandPalette": "Открыть палитру команд", + "attentionRequiredIndicator": "Сессия требует внимания" }, "navigation": { "chat": "Чат", diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json index 74ce9548..19e6ee4d 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -17,8 +17,7 @@ "tool": "Araç", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex", - "gemini": "Gemini" + "codex": "Codex" }, "tools": { "settings": "Araç Ayarları", @@ -103,24 +102,6 @@ }, "technicalDetails": "Teknik ayrıntılar" }, - "gemini": { - "permissionMode": "Gemini İzin Modu", - "description": "Gemini CLI'ın işlem onaylarını nasıl yönettiğini kontrol et.", - "modes": { - "default": { - "title": "Standart (Onay İste)", - "description": "Gemini, komut çalıştırmadan, dosya yazmadan ve web kaynağı getirmeden önce onay ister." - }, - "autoEdit": { - "title": "Otomatik Düzenleme (Dosya Onaylarını Atla)", - "description": "Gemini dosya düzenlemelerini ve web getirmelerini otomatik onaylar, ama shell komutları için yine de onay ister." - }, - "yolo": { - "title": "YOLO (Tüm İzinleri Atla)", - "description": "Gemini tüm işlemleri onay almadan çalıştırır. Dikkatli kullan." - } - } - }, "input": { "placeholder": "Komutlar için /, dosyalar için @ yaz ya da {{provider}}'a her şeyi sor...", "placeholderDefault": "Mesajını yaz...", @@ -152,7 +133,6 @@ "claude": "Claude'u {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.", "cursor": "Cursor'ı {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.", "codex": "Codex'i {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.", - "gemini": "Gemini'yi {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.", "default": "Başlamak için yukarıdan bir sağlayıcı seç" }, "pressToSearch": "Oturumlarda, dosyalarda ve commit'lerde arama yapmak için {{shortcut}} tuşlarına bas" diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 00b0465b..52377b3a 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -323,9 +323,6 @@ }, "codex": { "description": "OpenAI Codex AI asistanı" - }, - "gemini": { - "description": "Google Gemini AI asistanı" } }, "connectionStatus": "Bağlantı Durumu", diff --git a/src/i18n/locales/tr/sidebar.json b/src/i18n/locales/tr/sidebar.json index 2f6047ef..8932e15e 100644 --- a/src/i18n/locales/tr/sidebar.json +++ b/src/i18n/locales/tr/sidebar.json @@ -49,7 +49,8 @@ "save": "Kaydet", "cancel": "İptal", "clearSearch": "Aramayı temizle", - "openCommandPalette": "Komut paletini aç" + "openCommandPalette": "Komut paletini aç", + "attentionRequiredIndicator": "Oturum ilgi bekliyor" }, "navigation": { "chat": "Sohbet", diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 8ebe68d1..67abca24 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -17,8 +17,7 @@ "tool": "工具", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex", - "gemini": "Gemini" + "codex": "Codex" }, "tools": { "settings": "工具设置", @@ -134,7 +133,6 @@ "claude": "准备好使用带有 {{model}} 的 Claude。请在下方开始输入您的消息。", "cursor": "准备好使用带有 {{model}} 的 Cursor。请在下方开始输入您的消息。", "codex": "准备好使用带有 {{model}} 的 Codex。请在下方开始输入您的消息。", - "gemini": "准备好使用带有 {{model}} 的 Gemini。请在下方开始输入您的消息。", "default": "请在上方选择一个提供者以开始" }, "pressToSearch": "按 {{shortcut}} 搜索会话、文件和提交" diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index e383f84f..381c10b8 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -323,9 +323,6 @@ }, "codex": { "description": "OpenAI Codex AI 助手" - }, - "gemini": { - "description": "Google Gemini AI 助手" } }, "connectionStatus": "连接状态", diff --git a/src/i18n/locales/zh-CN/sidebar.json b/src/i18n/locales/zh-CN/sidebar.json index 812bbc15..e5f4e267 100644 --- a/src/i18n/locales/zh-CN/sidebar.json +++ b/src/i18n/locales/zh-CN/sidebar.json @@ -49,7 +49,8 @@ "save": "保存", "cancel": "取消", "clearSearch": "清除搜索", - "openCommandPalette": "打开命令面板" + "openCommandPalette": "打开命令面板", + "attentionRequiredIndicator": "会话需要处理" }, "navigation": { "chat": "聊天", diff --git a/src/i18n/locales/zh-TW/chat.json b/src/i18n/locales/zh-TW/chat.json index 1440bd52..4474d7ee 100644 --- a/src/i18n/locales/zh-TW/chat.json +++ b/src/i18n/locales/zh-TW/chat.json @@ -17,8 +17,7 @@ "tool": "工具", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex", - "gemini": "Gemini" + "codex": "Codex" }, "tools": { "settings": "工具設定", @@ -170,7 +169,6 @@ "claude": "準備好使用 {{model}} 的 Claude。請在下方開始輸入您的訊息。", "cursor": "準備好使用 {{model}} 的 Cursor。請在下方開始輸入您的訊息。", "codex": "準備好使用 {{model}} 的 Codex。請在下方開始輸入您的訊息。", - "gemini": "準備好使用 {{model}} 的 Gemini。請在下方開始輸入您的訊息。", "default": "請在上方選擇一個提供者以開始" }, "pressToSearch": "按 {{shortcut}} 搜尋工作階段、檔案和提交" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 1f1fe22f..5fffcd68 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -323,9 +323,6 @@ }, "codex": { "description": "OpenAI Codex AI 助手" - }, - "gemini": { - "description": "Google Gemini AI 助手" } }, "connectionStatus": "連線狀態", diff --git a/src/i18n/locales/zh-TW/sidebar.json b/src/i18n/locales/zh-TW/sidebar.json index dab8ad9d..c2491997 100644 --- a/src/i18n/locales/zh-TW/sidebar.json +++ b/src/i18n/locales/zh-TW/sidebar.json @@ -48,7 +48,8 @@ "save": "儲存", "cancel": "取消", "clearSearch": "清除搜尋", - "openCommandPalette": "開啟指令面板" + "openCommandPalette": "開啟指令面板", + "attentionRequiredIndicator": "工作階段需要處理" }, "navigation": { "chat": "聊天", diff --git a/src/shared/view/ui/PromptInput.tsx b/src/shared/view/ui/PromptInput.tsx index c4d23890..2637da7a 100644 --- a/src/shared/view/ui/PromptInput.tsx +++ b/src/shared/view/ui/PromptInput.tsx @@ -202,7 +202,7 @@ export const PromptInputSubmit = React.forwardRef {children ?? (isActive ? ( diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 999e1645..6117cbb7 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -60,7 +60,7 @@ export interface NormalizedMessage { isLocalCommand?: boolean; isLocalCommandStdout?: boolean; isCompactSummary?: boolean; - images?: string[]; + images?: Array<{ path?: string; data?: string; name?: string }>; toolName?: string; toolInput?: unknown; toolId?: string; @@ -97,6 +97,16 @@ export interface SessionSlot { /** @internal Cache-invalidation refs for computeMerged */ _lastServerRef: NormalizedMessage[]; _lastRealtimeRef: NormalizedMessage[]; + /** + * @internal Monotonic ticket per server fetch (fetch/refresh/fetchMore) and + * the ticket of the last response applied. Concurrent fetches for the same + * session can resolve out of order — e.g. the `complete` refresh racing the + * watcher-triggered refresh right as a queued message is flushed — and a + * stale response applied last would wind `serverMessages` back to a + * transcript that no longer matches what the user already saw. + */ + _fetchSeq: number; + _appliedFetchSeq: number; status: SessionStatus; fetchedAt: number; total: number; @@ -120,6 +130,8 @@ function createEmptySlot(): SessionSlot { hasMore: false, offset: 0, tokenUsage: null, + _fetchSeq: 0, + _appliedFetchSeq: 0, }; } @@ -459,6 +471,7 @@ export function useSessionStore() { } = {}, ) => { const slot = getSlot(sessionId); + const fetchTicket = ++slot._fetchSeq; slot.status = 'loading'; notify(sessionId); @@ -481,6 +494,12 @@ export function useSessionStore() { const data = body?.data ?? body; const messages: NormalizedMessage[] = data.messages || []; + // A later-started fetch already applied: this response is stale. + if (fetchTicket <= slot._appliedFetchSeq) { + return slot; + } + slot._appliedFetchSeq = fetchTicket; + slot.serverMessages = messages; slot.total = data.total ?? messages.length; slot.hasMore = Boolean(data.hasMore); @@ -496,8 +515,11 @@ export function useSessionStore() { return slot; } catch (error) { console.error(`[SessionStore] fetch failed for ${sessionId}:`, error); - slot.status = 'error'; - notify(sessionId); + // Don't clobber a newer fetch's result with a stale failure. + if (fetchTicket > slot._appliedFetchSeq) { + slot.status = 'error'; + notify(sessionId); + } return slot; } }, [getSlot, notify]); @@ -514,6 +536,7 @@ export function useSessionStore() { const slot = getSlot(sessionId); if (!slot.hasMore) return slot; + const fetchTicket = ++slot._fetchSeq; const params = new URLSearchParams(); const limit = opts.limit ?? 20; params.append('limit', String(limit)); @@ -529,6 +552,13 @@ export function useSessionStore() { const data = body?.data ?? body; const olderMessages: NormalizedMessage[] = data.messages || []; + // A full fetch/refresh replaced serverMessages while this page was in + // flight — prepending onto the new array would duplicate or misorder. + if (fetchTicket <= slot._appliedFetchSeq) { + return slot; + } + slot._appliedFetchSeq = fetchTicket; + // Prepend older messages (they're earlier in the conversation) slot.serverMessages = [...olderMessages, ...slot.serverMessages]; slot.hasMore = Boolean(data.hasMore); @@ -588,6 +618,7 @@ export function useSessionStore() { sessionId: string, ) => { const slot = getSlot(sessionId); + const fetchTicket = ++slot._fetchSeq; try { const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages`; const response = await authenticatedFetch(url); @@ -596,6 +627,14 @@ export function useSessionStore() { const body = await response.json(); const data = body?.data ?? body; + // A later-started fetch already applied: applying this stale transcript + // would erase rows the user has already seen (and re-prune realtime + // rows against an outdated snapshot). + if (fetchTicket <= slot._appliedFetchSeq) { + return; + } + slot._appliedFetchSeq = fetchTicket; + slot.serverMessages = data.messages || []; slot.total = data.total ?? slot.serverMessages.length; slot.hasMore = Boolean(data.hasMore); diff --git a/src/types/app.ts b/src/types/app.ts index b2ea97c4..8fa4a684 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,4 +1,4 @@ -export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'; +export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; export type ProviderModelOption = { value: string;