Fix/resolve different bugs (#964)

* 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.
This commit is contained in:
Haile
2026-07-08 11:32:32 +03:00
committed by GitHub
parent 41e0d309e0
commit 4cee5e7286
173 changed files with 4172 additions and 4296 deletions

View File

@@ -1,18 +1,18 @@
<div align="center"> <div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64"> <img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (auch bekannt als Claude Code UI)</h1> <h1>Cloud CLI (auch bekannt als Claude Code UI)</h1>
<p>Eine Desktop- und Mobile-Oberfläche für <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a> und <a href="https://geminicli.com/">Gemini-CLI</a>.<br>Lokal oder remote nutzbar verwalte deine aktiven Projekte und Sitzungen von überall.</p> <p>Eine Desktop- und Mobile-Oberfläche für <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a> und <a href="https://developers.openai.com/codex">Codex</a>.<br>Lokal oder remote nutzbar verwalte deine aktiven Projekte und Sitzungen von überall.</p>
</div> </div>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Dokumentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Fehler melden</a> · <a href="CONTRIBUTING.md">Mitwirken</a> <a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Dokumentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Fehler melden</a> · <a href="CONTRIBUTING.md">Mitwirken</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a> <a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Community"></a> <a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Community"></a>
<br><br> <br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
@@ -43,7 +43,7 @@
<h3>CLI-Auswahl</h3> <h3>CLI-Auswahl</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI-Auswahl" width="400"> <img src="public/screenshots/cli-selection.png" alt="CLI-Auswahl" width="400">
<br> <br>
<em>Wähle zwischen Claude Code, Gemini, Cursor CLI und Codex</em> <em>Wähle zwischen Claude Code, Cursor CLI und Codex</em>
</td> </td>
</tr> </tr>
</table> </table>
@@ -62,7 +62,7 @@
- **Sitzungsverwaltung** Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen - **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) - **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 - **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 ## Schnellstart
@@ -103,7 +103,7 @@ Agents in isolierten Sandboxes mit Hypervisor-Isolation ausführen. Standardmä
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project 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 | | **Rechner muss laufen** | Ja | Nein |
| **Mobiler Zugriff** | Jeder Browser im Netzwerk | Jedes Gerät, native App in Entwicklung | | **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 | | **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 | | **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 | | **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 | | **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 | | **[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 | | **[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 | | **[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 | | **[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 | | **[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 | | **[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. - **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. - **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. - **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. - **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:
<details> <details>
<summary>Muss ich ein KI-Abonnement separat bezahlen?</summary> <summary>Muss ich ein KI-Abonnement separat bezahlen?</summary>
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.
</details> </details>
@@ -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 - **[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 - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursors offizielle CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - UI-Bibliothek - **[React](https://react.dev/)** - UI-Bibliothek
- **[Vite](https://vitejs.dev/)** - Schnelles Build-Tool und Dev-Server - **[Vite](https://vitejs.dev/)** - Schnelles Build-Tool und Dev-Server
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS-Framework - **[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
--- ---
<div align="center"> <div align="center">
<strong>Mit Sorgfalt für die Claude Code-, Cursor- und Codex-Community erstellt.</strong> <strong>Mit Sorgfalt für die Claude Code-, Cursor- und Codex-Community erstellt.</strong>
</div> </div>

View File

@@ -1,18 +1,18 @@
<div align="center"> <div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64"> <img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI別名 Claude Code UI</h1> <h1>Cloud CLI別名 Claude Code UI</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a><a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a><a href="https://developers.openai.com/codex">Codex</a><a href="https://geminicli.com/">Gemini-CLI</a> のためのデスクトップ/モバイル UI。<br>ローカルでもリモートでも使え、アクティブなプロジェクトとセッションをどこからでも閲覧できます。</p> <p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a><a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a><a href="https://developers.openai.com/codex">Codex</a> のためのデスクトップ/モバイル UI。<br>ローカルでもリモートでも使え、アクティブなプロジェクトとセッションをどこからでも閲覧できます。</p>
</div> </div>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">ドキュメント</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">バグ報告</a> · <a href="CONTRIBUTING.md">コントリビュート</a> <a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">ドキュメント</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">バグ報告</a> · <a href="CONTRIBUTING.md">コントリビュート</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a> <a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord コミュニティに参加"></a> <a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord コミュニティに参加"></a>
<br><br> <br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
@@ -43,7 +43,7 @@
<h3>CLI 選択</h3> <h3>CLI 選択</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI 選択" width="400"> <img src="public/screenshots/cli-selection.png" alt="CLI 選択" width="400">
<br> <br>
<em>Claude Code、Gemini、Cursor CLI、Codex から選択</em> <em>Claude Code、Cursor CLI、Codex から選択</em>
</td> </td>
</tr> </tr>
</table> </table>
@@ -99,7 +99,7 @@ cloudcli
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project 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` から全セッションを自動検出 | クラウド環境内の全セッション |
| **対応エージェント** | 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 に内蔵) | | **ファイルエクスプローラとGit** | はいUI に内蔵) | はいUI に内蔵) |
| **MCP設定** | UI で管理し、ローカルの `~/.claude` 設定と同期 | UI で管理 | | **MCP設定** | UI で管理し、ローカルの `~/.claude` 設定と同期 | UI で管理 |
| **IDEアクセス** | ローカル IDE | クラウド環境に接続された任意の IDE | | **IDEアクセス** | ローカル IDE | クラウド環境に接続された任意の IDE |
@@ -160,7 +160,7 @@ CloudCLI にはプラグインシステムがあり、独自のフロントエ
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 長時間実行中の Claude Code セッションのハングを監視し、プロセス操作を提供 | | **[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 のセッション分析を行い、トークン消費の可視化も提供 | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 内で Claude Code のセッション分析を行い、トークン消費の可視化も提供 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | アクティブな 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 コストを計算し、モデル価格プリセットにも対応 | | **[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 は `~/.claude` フォルダのすべてのセッションを自動検出します。Remote Control は、Claude モバイルアプリで利用可能にするため、1つのアクティブセッションだけを公開します。
- **設定はあなたの設定** — CloudCLI UI で変更した MCP サーバー、ツール権限、プロジェクト構成は、Claude Code の設定に直接書き込まれて即座に反映され、その逆Claude Code での変更が UI に反映)も同様です。 - **設定はあなたの設定** — CloudCLI UI で変更した MCP サーバー、ツール権限、プロジェクト構成は、Claude Code の設定に直接書き込まれて即座に反映され、その逆Claude Code での変更が UI に反映)も同様です。
- **対応エージェントがさらに充実** — Claude Code に加えて Cursor CLI、Codex、Gemini CLI にも対応しています。 - **対応エージェントがさらに充実** — Claude Code に加えて Cursor CLI、Codex にも対応しています。
- **チャット窓だけではない完全な UI** — ファイルエクスプローラー、Git 統合、MCP 管理、シェル端末などがすべて組み込まれています。 - **チャット窓だけではない完全な UI** — ファイルエクスプローラー、Git 統合、MCP 管理、シェル端末などがすべて組み込まれています。
- **CloudCLI Cloud はクラウド上で稼働** — ノートパソコンを閉じてもエージェントは動き続けます。監視が要る端末も、スリープ防止も不要です。 - **CloudCLI Cloud はクラウド上で稼働** — ノートパソコンを閉じてもエージェントは動き続けます。監視が要る端末も、スリープ防止も不要です。
@@ -194,7 +194,7 @@ CloudCLI UI と CloudCLI Cloud は、Claude Code の横に別物として存在
<details> <details>
<summary>AI のサブスクリプションは別途支払いが必要ですか?</summary> <summary>AI のサブスクリプションは別途支払いが必要ですか?</summary>
はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex、または Gemini のサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 €7 から提供されます。 はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex のいずれかのサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 €7 から提供されます。
</details> </details>
@@ -234,7 +234,6 @@ GNU General Public License v3.0 - 詳細は [LICENSE](LICENSE) ファイルを
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic の公式 CLI - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic の公式 CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor の公式 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor の公式 CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - ユーザーインターフェースライブラリ - **[React](https://react.dev/)** - ユーザーインターフェースライブラリ
- **[Vite](https://vitejs.dev/)** - 高速ビルドツールと開発サーバー - **[Vite](https://vitejs.dev/)** - 高速ビルドツールと開発サーバー
- **[Tailwind CSS](https://tailwindcss.com/)** - ユーティリティファーストの CSS フレームワーク - **[Tailwind CSS](https://tailwindcss.com/)** - ユーティリティファーストの CSS フレームワーク
@@ -246,5 +245,5 @@ GNU General Public License v3.0 - 詳細は [LICENSE](LICENSE) ファイルを
--- ---
<div align="center"> <div align="center">
<strong>Claude Code、Cursor、Codex コミュニティのために心を込めて作りました。</strong> <strong>Claude Code、Cursor、Codex コミュニティのために心を込めて作りました。</strong>
</div> </div>

View File

@@ -1,18 +1,18 @@
<div align="center"> <div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64"> <img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (일명 Claude Code UI)</h1> <h1>Cloud CLI (일명 Claude Code UI)</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a>, <a href="https://geminicli.com/">Gemini-CLI</a> 용 데스크톱 및 모바일 UI입니다.<br>로컬 또는 원격에서 실행하여 어디서나 활성 프로젝트와 세션을 확인하세요.</p> <p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a>, 용 데스크톱 및 모바일 UI입니다.<br>로컬 또는 원격에서 실행하여 어디서나 활성 프로젝트와 세션을 확인하세요.</p>
</div> </div>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">문서</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">버그 신고</a> · <a href="CONTRIBUTING.md">기여 안내</a> <a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">문서</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">버그 신고</a> · <a href="CONTRIBUTING.md">기여 안내</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a> <a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord 커뮤니티"></a> <a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord 커뮤니티"></a>
<br><br> <br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
@@ -43,7 +43,7 @@
<h3>CLI 선택</h3> <h3>CLI 선택</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI 선택" width="400"> <img src="public/screenshots/cli-selection.png" alt="CLI 선택" width="400">
<br> <br>
<em>Claude Code, Gemini, Cursor CLI 및 Codex 중 선택</em> <em>Claude Code, Cursor CLI 및 Codex 중 선택</em>
</td> </td>
</tr> </tr>
</table> </table>
@@ -60,7 +60,7 @@
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적 - **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리 - **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 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`에서 자동 발견 | 클라우드 환경 내 세션 |
| **지원 에이전트** | 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에 통합됨 | | **파일 탐색기 및 Git** | UI에 통합됨 | UI에 통합됨 |
| **MCP 구성** | UI에서 관리, 로컬 `~/.claude` 설정과 동기화됨 | UI에서 관리 | | **MCP 구성** | UI에서 관리, 로컬 `~/.claude` 설정과 동기화됨 | UI에서 관리 |
| **IDE 접근** | 로컬 IDE | 클라우드 환경에 연결된 모든 IDE | | **IDE 접근** | 로컬 IDE | 클라우드 환경에 연결된 모든 IDE |
@@ -160,7 +160,7 @@ CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 장시간 실행 중인 Claude Code 세션의 중단 상태를 감시하고 프로세스 제어를 제공 | | **[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 세션 인텔리전스와 토큰 소모 가시성을 제공 | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 안에서 Claude Code 세션 인텔리전스와 토큰 소모 가시성을 제공 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 활성 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 비용을 계산하고 모델 가격 프리셋을 지원 | | **[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는 `~/.claude` 폴더에서 모든 세션을 자동 발견합니다. Remote Control은 단일 활성 세션만 노출합니다.
- **설정은 그대로** — CloudCLI UI에서 변경한 MCP, 도구 권한, 프로젝트 설정은 Claude Code에 즉시 반영됩니다. - **설정은 그대로** — CloudCLI UI에서 변경한 MCP, 도구 권한, 프로젝트 설정은 Claude Code에 즉시 반영됩니다.
- **지원 에이전트가 더 많음** — Claude Code, Cursor CLI, Codex, Gemini CLI 지원. - **지원 에이전트가 더 많음** — Claude Code, Cursor CLI, Codex 지원.
- **전체 UI 제공** — 단일 채팅 창이 아닌 파일 탐색기, Git 통합, MCP 관리 및 셸 터미널 포함. - **전체 UI 제공** — 단일 채팅 창이 아닌 파일 탐색기, Git 통합, MCP 관리 및 셸 터미널 포함.
- **CloudCLI Cloud는 클라우드에서 실행** — 노트북을 닫아도 에이전트가 실행됩니다. 터미널을 계속 확인할 필요 없음. - **CloudCLI Cloud는 클라우드에서 실행** — 노트북을 닫아도 에이전트가 실행됩니다. 터미널을 계속 확인할 필요 없음.
@@ -195,7 +195,7 @@ CloudCLI UI와 CloudCLI Cloud는 Claude Code를 확장하며 별도로 존재하
<details> <details>
<summary>AI 구독을 별도로 결제해야 하나요?</summary> <summary>AI 구독을 별도로 결제해야 하나요?</summary>
네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex, Gemini 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 €7부터 제공합니다. 네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 €7부터 제공합니다.
</details> </details>
@@ -234,7 +234,6 @@ GNU General Public License v3.0 - 자세한 내용은 [LICENSE](LICENSE) 파일
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 공식 CLI - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 공식 CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 공식 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 공식 CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - 사용자 인터페이스 라이브러리 - **[React](https://react.dev/)** - 사용자 인터페이스 라이브러리
- **[Vite](https://vitejs.dev/)** - 빠른 빌드 도구 및 개발 서버 - **[Vite](https://vitejs.dev/)** - 빠른 빌드 도구 및 개발 서버
- **[Tailwind CSS](https://tailwindcss.com/)** - 유틸리티 우선 CSS 프레임워크 - **[Tailwind CSS](https://tailwindcss.com/)** - 유틸리티 우선 CSS 프레임워크
@@ -246,5 +245,5 @@ GNU General Public License v3.0 - 자세한 내용은 [LICENSE](LICENSE) 파일
--- ---
<div align="center"> <div align="center">
<strong>Claude Code, Cursor, Codex 커뮤니티를 위해 정성껏 제작되었습니다.</strong> <strong>Claude Code, Cursor, Codex 커뮤니티를 위해 정성껏 제작되었습니다.</strong>
</div> </div>

View File

@@ -1,18 +1,18 @@
<div align="center"> <div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64"> <img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1> <h1>Cloud CLI (aka Claude Code UI)</h1>
<p>A desktop and mobile UI for <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a>, and <a href="https://geminicli.com/">Gemini-CLI</a>.<br>Use it locally or remotely to view your active projects and sessions from everywhere.</p> <p>A desktop and mobile UI for <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, and <a href="https://developers.openai.com/codex">Codex</a>.<br>Use it locally or remotely to view your active projects and sessions from everywhere.</p>
</div> </div>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Documentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a> <a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Documentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a> <a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a> <a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a>
<br><br> <br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
@@ -22,7 +22,7 @@
## Screenshots ## Screenshots
<div align="center"> <div align="center">
<table> <table>
<tr> <tr>
<td align="center"> <td align="center">
@@ -43,7 +43,7 @@
<h3>CLI Selection</h3> <h3>CLI Selection</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400"> <img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br> <br>
<em>Select between Claude Code, Gemini, Cursor CLI and Codex</em> <em>Select between Claude Code, Cursor CLI and Codex</em>
</td> </td>
</tr> </tr>
</table> </table>
@@ -63,7 +63,7 @@
- **Session Management** - Resume conversations, manage multiple sessions, and track history - **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) - **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 - **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 ## 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 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 ### 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 | | **Machine needs to stay on** | Yes | Yes | No |
| **Mobile access** | Any browser on your network | Any browser on your network | Any device | | **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 | | **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 | | **File explorer and Git** | Yes | Yes | Yes |
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI | | **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
| **REST API** | Yes | Yes | Yes | | **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 | | **[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 | | **[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 | | **[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 | | **[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 | | **[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 | | **[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. - **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. - **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. - **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. - **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:
<details> <details>
<summary>Do I need to pay for an AI subscription separately?</summary> <summary>Do I need to pay for an AI subscription separately?</summary>
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.
</details> </details>
@@ -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. 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 ## Acknowledgments
@@ -253,7 +253,6 @@ CloudCLI UI - (https://cloudcli.ai).
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI - **[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 - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - User interface library - **[React](https://react.dev/)** - User interface library
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server - **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework - **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
@@ -266,5 +265,5 @@ CloudCLI UI - (https://cloudcli.ai).
--- ---
<div align="center"> <div align="center">
<strong>Made with care for the Claude Code, Cursor and Codex community.</strong> <strong>Made with care for the Claude Code, Cursor and Codex community.</strong>
</div> </div>

View File

@@ -1,18 +1,18 @@
<div align="center"> <div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64"> <img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1> <h1>Cloud CLI (aka Claude Code UI)</h1>
<p>Десктопный и мобильный UI для <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a> и <a href="https://geminicli.com/">Gemini-CLI</a>.<br>Используйте локально или удалённо, чтобы просматривать активные проекты и сессии отовсюду.</p> <p>Десктопный и мобильный UI для <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a>.<br>Используйте локально или удалённо, чтобы просматривать активные проекты и сессии отовсюду.</p>
</div> </div>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Документация</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Сообщить об ошибке</a> · <a href="CONTRIBUTING.md">Участие в разработке</a> <a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Документация</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Сообщить об ошибке</a> · <a href="CONTRIBUTING.md">Участие в разработке</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a> <a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a> <a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a>
<br><br> <br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
@@ -43,7 +43,7 @@
<h3>Выбор CLI</h3> <h3>Выбор CLI</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400"> <img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br> <br>
<em>Выбирайте между Claude Code, Gemini, Cursor CLI и Codex</em> <em>Выбирайте между Claude Code, Cursor CLI и Codex</em>
</td> </td>
</tr> </tr>
</table> </table>
@@ -62,7 +62,7 @@
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю - **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow - **Интеграция с 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 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` | Все сессии внутри вашей облачной среды |
| **Поддерживаемые агенты** | 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 | | **Проводник файлов и Git** | Да, встроены в UI | Да, встроены в UI |
| **Конфигурация MCP** | Управляется через UI, синхронизируется с вашим локальным конфигом `~/.claude` | Управляется через UI | | **Конфигурация MCP** | Управляется через UI, синхронизируется с вашим локальным конфигом `~/.claude` | Управляется через UI |
| **Доступ из IDE** | Ваша локальная IDE | Любая IDE, подключенная к вашей облачной среде | | **Доступ из IDE** | Ваша локальная IDE | Любая IDE, подключенная к вашей облачной среде |
@@ -166,7 +166,7 @@ CloudCLI UI — это open source UI-слой, на котором постро
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Отслеживает зависания долгих сессий Claude Code и предоставляет управление процессами | | **[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, включая видимость расхода токенов | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Аналитика сессий Claude Code внутри CloudCLI, включая видимость расхода токенов |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Просмотр, управление и завершение активных сессий 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 по ценам моделей и использованию токенов, с поддержкой пресетов цен | | **[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. - **Все ваши сессии, а не одна** — CloudCLI UI автоматически находит каждую сессию из папки `~/.claude`. Remote Control предоставляет только одну активную сессию, чтобы сделать её доступной в мобильном приложении Claude.
- **Ваши настройки — это ваши настройки** — MCP-серверы, права инструментов и конфигурация проекта, изменённые в CloudCLI UI, записываются напрямую в конфиг Claude Code и вступают в силу сразу же, и наоборот. - **Ваши настройки — это ваши настройки** — 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-терминал — всё встроено. - **Полноценный UI, а не просто окно чата** — проводник файлов, Git-интеграция, управление MCP и shell-терминал — всё встроено.
- **CloudCLI Cloud работает в облаке** — закройте ноутбук, и агент продолжит работать. Не нужно следить за терминалом и держать машину постоянно активной. - **CloudCLI Cloud работает в облаке** — закройте ноутбук, и агент продолжит работать. Не нужно следить за терминалом и держать машину постоянно активной.
@@ -202,7 +202,7 @@ CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работ
<details> <details>
<summary>Нужно ли отдельно платить за AI-подписку?</summary> <summary>Нужно ли отдельно платить за AI-подписку?</summary>
Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud начинается от €7/месяц за хостируемую среду поверх этого. Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor или Codex. CloudCLI Cloud начинается от €7/месяц за хостируемую среду поверх этого.
</details> </details>
@@ -241,7 +241,6 @@ GNU General Public License v3.0 - подробности в файле [LICENSE]
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - официальный CLI от Anthropic - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - официальный CLI от Anthropic
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - официальный CLI от Cursor - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - официальный CLI от Cursor
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - библиотека пользовательских интерфейсов - **[React](https://react.dev/)** - библиотека пользовательских интерфейсов
- **[Vite](https://vitejs.dev/)** - быстрый инструмент сборки и dev-сервер - **[Vite](https://vitejs.dev/)** - быстрый инструмент сборки и dev-сервер
- **[Tailwind CSS](https://tailwindcss.com/)** - utility-first CSS framework - **[Tailwind CSS](https://tailwindcss.com/)** - utility-first CSS framework
@@ -254,5 +253,5 @@ GNU General Public License v3.0 - подробности в файле [LICENSE]
--- ---
<div align="center"> <div align="center">
<strong>Сделано с заботой для сообщества Claude Code, Cursor и Codex.</strong> <strong>Сделано с заботой для сообщества Claude Code, Cursor и Codex.</strong>
</div> </div>

View File

@@ -1,18 +1,18 @@
<div align="center"> <div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64"> <img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (Claude Code UI olarak da bilinir)</h1> <h1>Cloud CLI (Claude Code UI olarak da bilinir)</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a> ve <a href="https://geminicli.com/">Gemini-CLI</a> için masaüstü ve mobil arayüz.<br>Yerel ya da uzaktan kullanarak aktif projelerine ve oturumlarına her yerden erişebilirsin.</p> <p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a> için masaüstü ve mobil arayüz.<br>Yerel ya da uzaktan kullanarak aktif projelerine ve oturumlarına her yerden erişebilirsin.</p>
</div> </div>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Dokümantasyon</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Sorun Bildir</a> · <a href="CONTRIBUTING.md">Katkıda Bulun</a> <a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Dokümantasyon</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Sorun Bildir</a> · <a href="CONTRIBUTING.md">Katkıda Bulun</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Hemen_Dene-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a> <a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Hemen_Dene-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Toplulu%C4%9Fa%20Kat%C4%B1l-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord'a Katıl"></a> <a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Toplulu%C4%9Fa%20Kat%C4%B1l-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord'a Katıl"></a>
<br><br> <br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
@@ -43,7 +43,7 @@
<h3>CLI Seçimi</h3> <h3>CLI Seçimi</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Seçimi" width="400"> <img src="public/screenshots/cli-selection.png" alt="CLI Seçimi" width="400">
<br> <br>
<em>Claude Code, Gemini, Cursor CLI ve Codex arasında seçim yap</em> <em>Claude Code, Cursor CLI ve Codex arasında seçim yap</em>
</td> </td>
</tr> </tr>
</table> </table>
@@ -62,7 +62,7 @@
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et - **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) - **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 - **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ıç ## 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 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 | | **İzolasyon** | Kendi host'unda çalışır | Hipervizör seviyesi sandbox (microVM) | Tam bulut izolasyonu |
| **Makinenin açık kalması gerek** | Evet | Evet | Hayır | | **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 | | **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 | | **Dosya gezgini ve Git** | Evet | Evet | Evet |
| **MCP yapılandırması** | `~/.claude` ile senkron | UI üzerinden yönetilir | UI üzerinden yönetilir | | **MCP yapılandırması** | `~/.claude` ile senkron | UI üzerinden yönetilir | UI üzerinden yönetilir |
| **REST API** | Evet | Evet | Evet | | **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 | | **[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 | | **[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 | | **[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 | | **[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 | | **[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 | | **[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. - **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. - **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. - **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. - **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:
<details> <details>
<summary>AI aboneliği için ayrıca ödeme yapmam gerekiyor mu?</summary> <summary>AI aboneliği için ayrıca ödeme yapmam gerekiyor mu?</summary>
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.
</details> </details>
@@ -242,7 +242,6 @@ CloudCLI UI — (https://cloudcli.ai).
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic'in resmi CLI'ı - **[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'ı - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor'un resmi CLI'ı
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex - **[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 - **[React](https://react.dev/)** — Kullanıcı arayüzü kütüphanesi
- **[Vite](https://vitejs.dev/)** — Hızlı derleme aracı ve geliştirme sunucusu - **[Vite](https://vitejs.dev/)** — Hızlı derleme aracı ve geliştirme sunucusu
- **[Tailwind CSS](https://tailwindcss.com/)** — Utility-first CSS framework - **[Tailwind CSS](https://tailwindcss.com/)** — Utility-first CSS framework
@@ -255,5 +254,5 @@ CloudCLI UI — (https://cloudcli.ai).
--- ---
<div align="center"> <div align="center">
<strong>Claude Code, Cursor ve Codex topluluğu için özenle yapıldı.</strong> <strong>Claude Code, Cursor ve Codex topluluğu için özenle yapıldı.</strong>
</div> </div>

View File

@@ -1,18 +1,18 @@
<div align="center"> <div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64"> <img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI又名 Claude Code UI</h1> <h1>Cloud CLI又名 Claude Code UI</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a><a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a><a href="https://developers.openai.com/codex">Codex</a><a href="https://geminicli.com/">Gemini-CLI</a> 的桌面和移动端 UI。可在本地或远程使用从任何地方查看激活的项目与会话。</p> <p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a><a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a><a href="https://developers.openai.com/codex">Codex</a> 的桌面和移动端 UI。可在本地或远程使用从任何地方查看激活的项目与会话。</p>
</div> </div>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文档</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 报告</a> · <a href="CONTRIBUTING.md">贡献指南</a> <a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文档</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 报告</a> · <a href="CONTRIBUTING.md">贡献指南</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a> <a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社区"></a> <a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社区"></a>
<br><br> <br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
@@ -43,7 +43,7 @@
<h3>CLI 选择</h3> <h3>CLI 选择</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI 选择" width="400"> <img src="public/screenshots/cli-selection.png" alt="CLI 选择" width="400">
<br> <br>
<em>在 Claude Code、Gemini、Cursor CLI 与 Codex 之间进行选择</em> <em>在 Claude Code、Cursor CLI 与 Codex 之间进行选择</em>
</td> </td>
</tr> </tr>
</table> </table>
@@ -60,7 +60,7 @@
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录 - **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理 - **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 npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
``` ```
支持 Claude CodeCodex 和 Gemini CLI。详情请参阅 [沙箱文档](docker/)。 支持 Claude CodeCodex。详情请参阅 [沙箱文档](docker/)。
--- ---
@@ -115,7 +115,7 @@ CloudCLI UI 是 CloudCLI Cloud 的开源 UI 层。你可以在本地机器上自
| **机器需保持开机吗** | 是 | 否 | | **机器需保持开机吗** | 是 | 否 |
| **移动端访问** | 网络内任意浏览器 | 任意设备(原生应用即将推出) | | **移动端访问** | 网络内任意浏览器 | 任意设备(原生应用即将推出) |
| **可用会话** | 自动发现 `~/.claude` 中的所有会话 | 云端环境内的会话 | | **可用会话** | 自动发现 `~/.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 | | **文件浏览与 Git** | 内置于 UI | 内置于 UI |
| **MCP 配置** | UI 管理,与本地 `~/.claude` 配置同步 | UI 管理 | | **MCP 配置** | UI 管理,与本地 `~/.claude` 配置同步 | UI 管理 |
| **IDE 访问** | 本地 IDE | 任何连接到云环境的 IDE | | **IDE 访问** | 本地 IDE | 任何连接到云环境的 IDE |
@@ -160,7 +160,7 @@ CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Nod
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 监控长时间运行的 Claude Code 会话是否卡住,并提供进程控制 | | **[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 消耗可视化 | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 会话智能分析,包括 token 消耗可视化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 查看、管理并终止活动的 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)** | 根据模型价格和 token 用量计算 API 成本,并支持模型价格预设 | | **[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 会自动扫描 `~/.claude` 文件夹中的每个会话。Remote Control 只暴露当前活动的会话。
- **设置统一** — 在 CloudCLI UI 中修改的 MCP、工具权限等设置会立即写入 Claude Code。 - **设置统一** — 在 CloudCLI UI 中修改的 MCP、工具权限等设置会立即写入 Claude Code。
- **支持更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI - **支持更多 Agents** — Claude Code、Cursor CLI、Codex。
- **完整 UI** — 除了聊天界面还包括文件浏览器、Git 集成、MCP 管理和 Shell 终端。 - **完整 UI** — 除了聊天界面还包括文件浏览器、Git 集成、MCP 管理和 Shell 终端。
- **CloudCLI Cloud 保持运行于云端** — 关闭本地设备也不会中断代理运行,无需监控终端。 - **CloudCLI Cloud 保持运行于云端** — 关闭本地设备也不会中断代理运行,无需监控终端。
@@ -195,7 +195,7 @@ CloudCLI UI 与 CloudCLI Cloud 是对 Claude Code 的扩展,而非旁观 — M
<details> <details>
<summary>需要额外购买 AI 订阅吗?</summary> <summary>需要额外购买 AI 订阅吗?</summary>
需要。CloudCLI 只提供环境。你仍需自行获取 Claude、CursorCodex 或 Gemini 订阅。CloudCLI Cloud 从 €7/月起提供托管环境。 需要。CloudCLI 只提供环境。你仍需自行获取 Claude、CursorCodex 订阅。CloudCLI Cloud 从 €7/月起提供托管环境。
</details> </details>
@@ -234,7 +234,6 @@ GNU 通用公共许可证 v3.0 - 详见 [LICENSE](LICENSE) 文件。
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 官方 CLI - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 官方 CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 官方 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 官方 CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - 用户界面库 - **[React](https://react.dev/)** - 用户界面库
- **[Vite](https://vitejs.dev/)** - 快速构建工具与开发服务器 - **[Vite](https://vitejs.dev/)** - 快速构建工具与开发服务器
- **[Tailwind CSS](https://tailwindcss.com/)** - 实用先行 CSS 框架 - **[Tailwind CSS](https://tailwindcss.com/)** - 实用先行 CSS 框架
@@ -246,5 +245,5 @@ GNU 通用公共许可证 v3.0 - 详见 [LICENSE](LICENSE) 文件。
--- ---
<div align="center"> <div align="center">
<strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong> <strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong>
</div> </div>

View File

@@ -1,18 +1,18 @@
<div align="center"> <div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64"> <img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI又名 Claude Code UI</h1> <h1>Cloud CLI又名 Claude Code UI</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a><a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a><a href="https://developers.openai.com/codex">Codex</a><a href="https://geminicli.com/">Gemini-CLI</a> 的桌面和行動裝置 UI。可在本機或遠端使用從任何地方查看您的專案與工作階段。</p> <p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a><a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a><a href="https://developers.openai.com/codex">Codex</a> 的桌面和行動裝置 UI。可在本機或遠端使用從任何地方查看您的專案與工作階段。</p>
</div> </div>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文件</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 回報</a> · <a href="CONTRIBUTING.md">貢獻指南</a> <a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文件</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 回報</a> · <a href="CONTRIBUTING.md">貢獻指南</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a> <a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社群"></a> <a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社群"></a>
<br><br> <br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
@@ -43,7 +43,7 @@
<h3>CLI 選擇</h3> <h3>CLI 選擇</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI 選擇" width="400"> <img src="public/screenshots/cli-selection.png" alt="CLI 選擇" width="400">
<br> <br>
<em>在 Claude Code、Gemini、Cursor CLI 與 Codex 之間進行選擇</em> <em>在 Claude Code、Cursor CLI 與 Codex 之間進行選擇</em>
</td> </td>
</tr> </tr>
</table> </table>
@@ -60,7 +60,7 @@
- **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄 - **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄
- **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理 - **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 npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
``` ```
支援 Claude CodeCodex 和 Gemini CLI。詳情請參閱[沙箱文件](docker/)。 支援 Claude CodeCodex。詳情請參閱[沙箱文件](docker/)。
--- ---
@@ -115,7 +115,7 @@ CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它
| **機器需保持開機嗎** | 是 | 否 | | **機器需保持開機嗎** | 是 | 否 |
| **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) | | **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) |
| **可用工作階段** | 自動發現 `~/.claude` 中的所有工作階段 | 雲端環境內的工作階段 | | **可用工作階段** | 自動發現 `~/.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 | | **檔案瀏覽與 Git** | 內建於 UI | 內建於 UI |
| **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 | | **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 |
| **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE | | **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE |
@@ -160,7 +160,7 @@ CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Nod
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支援多分頁的完整 xterm.js 終端機 | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支援多分頁的完整 xterm.js 終端機 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 監控長時間執行的 Claude Code 工作階段是否卡住,並提供程序控制 | | **[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 消耗可視化 | | **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 工作階段智慧分析,包括 token 消耗可視化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 檢視、管理並終止作用中的 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)** | 根據模型價格與 token 用量計算 API 成本,並支援模型價格預設 | | **[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 會自動掃描 `~/.claude` 資料夾中的每個工作階段。Remote Control 只暴露目前活動的工作階段。
- **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。 - **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。
- **支援更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI - **支援更多 Agents** — Claude Code、Cursor CLI、Codex。
- **完整 UI** — 除了聊天介面還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。 - **完整 UI** — 除了聊天介面還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。
- **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。 - **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。
@@ -195,7 +195,7 @@ CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — M
<details> <details>
<summary>需要額外購買 AI 訂閱嗎?</summary> <summary>需要額外購買 AI 訂閱嗎?</summary>
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、CursorCodex 或 Gemini 訂閱。CloudCLI Cloud 從 €7/月起提供託管環境。 需要。CloudCLI 只提供環境。你仍需自行取得 Claude、CursorCodex 訂閱。CloudCLI Cloud 從 €7/月起提供託管環境。
</details> </details>
@@ -234,7 +234,6 @@ GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic 官方 CLI - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic 官方 CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor 官方 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor 官方 CLI
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex - **[Codex](https://developers.openai.com/codex)** — OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
- **[React](https://react.dev/)** — 使用者介面函式庫 - **[React](https://react.dev/)** — 使用者介面函式庫
- **[Vite](https://vitejs.dev/)** — 快速建構工具與開發伺服器 - **[Vite](https://vitejs.dev/)** — 快速建構工具與開發伺服器
- **[Tailwind CSS](https://tailwindcss.com/)** — 實用優先 CSS 框架 - **[Tailwind CSS](https://tailwindcss.com/)** — 實用優先 CSS 框架
@@ -246,5 +245,5 @@ GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。
--- ---
<div align="center"> <div align="center">
<strong>為 Claude Code、Cursor 和 Codex 社群精心打造。</strong> <strong>為 Claude Code、Cursor 和 Codex 社群精心打造。</strong>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Docker Hub short description (100 chars max): --> <!-- Docker Hub short description (100 chars max): -->
<!-- Sandbox templates for running AI coding agents with a web & mobile IDE (Claude Code, Codex, Gemini) --> <!-- Sandbox templates for running AI coding agents with a web & mobile IDE (Claude Code, Codex) -->
# Sandboxed coding agents with a web & mobile IDE (CloudCLI) # 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 ## Get started
@@ -42,10 +42,6 @@ Store the matching API key and pass `--agent`:
# OpenAI Codex # OpenAI Codex
sbx secret set -g openai sbx secret set -g openai
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent codex 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 ### 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` | | **Claude Code** (default) | `docker.io/cloudcliai/sandbox:claude-code` |
| OpenAI Codex | `docker.io/cloudcliai/sandbox:codex` | | 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)). These are used with `--template` when running `sbx` directly (see [Advanced usage](#advanced-usage)).

View File

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

View File

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

View File

@@ -161,6 +161,7 @@ export default tseslint.config(
"server/shared/utils.{js,ts}", "server/shared/utils.{js,ts}",
"server/shared/frontmatter.ts", "server/shared/frontmatter.ts",
"server/shared/claude-cli-path.ts", "server/shared/claude-cli-path.ts",
"server/shared/image-attachments.ts",
], // classify shared utility files so modules can depend on them explicitly ], // classify shared utility files so modules can depend on them explicitly
mode: "file", 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 type: "backend-legacy-runtime", // legacy runtime persistence modules used while providers migrate into server/modules
pattern: [ pattern: [
"server/projects.js", "server/projects.js",
"server/sessionManager.js",
"server/utils/runtime-paths.js", "server/utils/runtime-paths.js",
], // provider history loading still resolves session data through these legacy runtime files ], // provider history loading still resolves session data through these legacy runtime files
mode: "file", mode: "file",

19
package-lock.json generated
View File

@@ -80,6 +80,8 @@
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/cross-spawn": "^6.0.6", "@types/cross-spawn": "^6.0.6",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/mime-types": "^3.0.1",
"@types/multer": "^2.2.0",
"@types/node": "^22.19.7", "@types/node": "^22.19.7",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
@@ -5994,12 +5996,29 @@
"@types/unist": "*" "@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": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "22.19.7", "version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",

View File

@@ -121,8 +121,6 @@
"claude-code-ui", "claude-code-ui",
"cloudcli", "cloudcli",
"codex", "codex",
"gemini",
"gemini-cli",
"cursor", "cursor",
"cursor-cli", "cursor-cli",
"anthropic", "anthropic",
@@ -203,6 +201,8 @@
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/cross-spawn": "^6.0.6", "@types/cross-spawn": "^6.0.6",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/mime-types": "^3.0.1",
"@types/multer": "^2.2.0",
"@types/node": "^22.19.7", "@types/node": "^22.19.7",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",

View File

@@ -489,7 +489,7 @@
<span class="endpoint-path"><span class="api-url">http://localhost:3001</span>/api/agent</span> <span class="endpoint-path"><span class="api-url">http://localhost:3001</span>/api/agent</span>
</div> </div>
<p>Trigger an AI agent (Claude, Cursor, Codex, Gemini, or OpenCode) to work on a project.</p> <p>Trigger an AI agent (Claude, Cursor, Codex, or OpenCode) to work on a project.</p>
<h4>Request Body Parameters</h4> <h4>Request Body Parameters</h4>
<table> <table>
@@ -524,7 +524,7 @@
<td><code>provider</code></td> <td><code>provider</code></td>
<td>string</td> <td>string</td>
<td><span class="badge badge-optional">Optional</span></td> <td><span class="badge badge-optional">Optional</span></td>
<td><code>claude</code>, <code>cursor</code>, <code>codex</code>, <code>gemini</code>, or <code>opencode</code> (default: <code>claude</code>)</td> <td><code>claude</code>, <code>cursor</code>, <code>codex</code>, or <code>opencode</code> (default: <code>claude</code>)</td>
</tr> </tr>
<tr> <tr>
<td><code>stream</code></td> <td><code>stream</code></td>
@@ -837,7 +837,6 @@ data: {"type":"done"}</code></pre>
const PROVIDER_ORDER = [ const PROVIDER_ORDER = [
{ id: 'claude', name: 'Anthropic' }, { id: 'claude', name: 'Anthropic' },
{ id: 'codex', name: 'OpenAI' }, { id: 'codex', name: 'OpenAI' },
{ id: 'gemini', name: 'Google' },
{ id: 'cursor', name: 'Cursor' }, { id: 'cursor', name: 'Cursor' },
{ id: 'opencode', name: 'OpenCode' }, { id: 'opencode', name: 'OpenCode' },
]; ];

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -14,20 +14,20 @@
--- ---
<div align="center"> <div align="center">
<img src="https://raw.githubusercontent.com/siteboon/claudecodeui/main/public/logo.svg" alt="CloudCLI UI" width="64" height="64"> <img src="https://raw.githubusercontent.com/siteboon/claudecodeui/main/public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1> <h1>Cloud CLI (aka Claude Code UI)</h1>
<p>A desktop and mobile UI for <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a>, and <a href="https://geminicli.com/">Gemini-CLI</a>.<br>Use it locally or remotely to view your active projects and sessions from everywhere.</p> <p>A desktop and mobile UI for <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, and <a href="https://developers.openai.com/codex">Codex</a>.<br>Use it locally or remotely to view your active projects and sessions from everywhere.</p>
</div> </div>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Documentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="https://github.com/siteboon/claudecodeui/blob/main/CONTRIBUTING.md">Contributing</a> <a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Documentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="https://github.com/siteboon/claudecodeui/blob/main/CONTRIBUTING.md">Contributing</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a> <a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a> <a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a>
<br><br> <br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
--- ---
@@ -56,7 +56,7 @@
<h3>CLI Selection</h3> <h3>CLI Selection</h3>
<img src="https://raw.githubusercontent.com/siteboon/claudecodeui/main/public/screenshots/cli-selection.png" alt="CLI Selection" width="400"> <img src="https://raw.githubusercontent.com/siteboon/claudecodeui/main/public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br> <br>
<em>Select between Claude Code, Gemini, Cursor CLI and Codex</em> <em>Select between Claude Code, Cursor CLI and Codex</em>
</td> </td>
</tr> </tr>
</table> </table>
@@ -75,7 +75,7 @@
- **Session Management** - Resume conversations, manage multiple sessions, and track history - **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) - **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 - **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 ## 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 | | **Machine needs to stay on** | Yes | No |
| **Mobile access** | Any browser on your network | Any device, native app coming | | **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 | | **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 | | **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 | | **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 | | **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. - **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. - **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. - **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. - **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:
<details> <details>
<summary>Do I need to pay for an AI subscription separately?</summary> <summary>Do I need to pay for an AI subscription separately?</summary>
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.
</details> </details>
@@ -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. 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 ## Acknowledgments
@@ -231,7 +231,6 @@ CloudCLI UI - (https://cloudcli.ai).
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI - **[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 - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - User interface library - **[React](https://react.dev/)** - User interface library
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server - **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework - **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
@@ -244,5 +243,5 @@ CloudCLI UI - (https://cloudcli.ai).
--- ---
<div align="center"> <div align="center">
<strong>Made with care for the Claude Code, Cursor and Codex community.</strong> <strong>Made with care for the Claude Code, Cursor and Codex community.</strong>
</div> </div>

View File

@@ -22,8 +22,6 @@
"claude-code-ui", "claude-code-ui",
"cloudcli", "cloudcli",
"codex", "codex",
"gemini",
"gemini-cli",
"cursor", "cursor",
"cursor-cli", "cursor-cli",
"anthropic", "anthropic",

View File

@@ -19,6 +19,7 @@ import path from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk'; 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 { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js'; import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
@@ -236,16 +237,13 @@ function mapCliOptionsToSDK(options = {}) {
* Adds a session to the active sessions map * Adds a session to the active sessions map
* @param {string} sessionId - Session identifier * @param {string} sessionId - Session identifier
* @param {Object} queryInstance - SDK query instance * @param {Object} queryInstance - SDK query instance
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup * @param {Object} writer - WebSocket writer for reconnect support
* @param {string} tempDir - Temp directory for cleanup
*/ */
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) { function addSession(sessionId, queryInstance, writer = null) {
activeSessions.set(sessionId, { activeSessions.set(sessionId, {
instance: queryInstance, instance: queryInstance,
startTime: Date.now(), startTime: Date.now(),
status: 'active', status: 'active',
tempImagePaths,
tempDir,
writer writer
}); });
} }
@@ -364,90 +362,35 @@ function extractTokenBudget(sdkMessage) {
} }
/** /**
* Handles image processing for SDK queries * Builds the SDK `prompt` payload for one turn.
* Saves base64 images to temporary files and returns modified prompt with file paths *
* @param {string} command - Original user prompt * Plain text turns pass the string through unchanged. Turns with image
* @param {Array} images - Array of image objects with base64 data * attachments use the SDK's streaming-input mode: a single SDKUserMessage
* @param {string} cwd - Working directory for temp file creation * whose content carries the prompt text plus one base64 `image` block per
* @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir} * 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<string|AsyncIterable>} SDK prompt payload
*/ */
async function handleImages(command, images, cwd) { async function buildPromptPayload(command, images, cwd) {
const tempImagePaths = []; if (normalizeImageDescriptors(images).length === 0) {
let tempDir = null; return command;
if (!images || images.length === 0) {
return { modifiedCommand: command, tempImagePaths, tempDir };
} }
try { const content = await buildClaudeUserContent(command, images, cwd);
// Create temp directory in the project directory return (async function* () {
const workingDir = cwd || process.cwd(); yield {
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString()); type: 'user',
await fs.mkdir(tempDir, { recursive: true }); message: {
role: 'user',
// Save each image to a temp file content
for (const [index, image] of images.entries()) { },
// Extract base64 data and mime type parent_tool_use_id: null,
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/); timestamp: new Date().toISOString()
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<string>} 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);
}
} }
/** /**
@@ -518,8 +461,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
const { sessionId, sessionSummary } = options; const { sessionId, sessionSummary } = options;
let capturedSessionId = sessionId; let capturedSessionId = sessionId;
let sessionCreatedSent = false; let sessionCreatedSent = false;
let tempImagePaths = [];
let tempDir = null;
const emitNotification = (event) => { const emitNotification = (event) => {
notifyUserIfEnabled({ notifyUserIfEnabled({
@@ -553,10 +494,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
sdkOptions.mcpServers = mcpServers; sdkOptions.mcpServers = mcpServers;
} }
const imageResult = await handleImages(command, options.images, options.cwd); // Turns with image attachments switch to streaming input so the images
const finalCommand = imageResult.modifiedCommand; // ride along as real content blocks. Built per query attempt because an
tempImagePaths = imageResult.tempImagePaths; // async generator cannot be replayed once consumed.
tempDir = imageResult.tempDir; const createPrompt = () => buildPromptPayload(command, options.images, options.cwd);
sdkOptions.hooks = { sdkOptions.hooks = {
Notification: [{ Notification: [{
@@ -663,7 +604,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
let queryInstance; let queryInstance;
try { try {
queryInstance = query({ queryInstance = query({
prompt: finalCommand, prompt: await createPrompt(),
options: sdkOptions options: sdkOptions
}); });
} catch (hookError) { } 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); console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
delete sdkOptions.hooks; delete sdkOptions.hooks;
queryInstance = query({ queryInstance = query({
prompt: finalCommand, prompt: await createPrompt(),
options: sdkOptions options: sdkOptions
}); });
} }
@@ -686,7 +627,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Track the query instance for abort capability // Track the query instance for abort capability
if (capturedSessionId) { if (capturedSessionId) {
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws); addSession(capturedSessionId, queryInstance, ws);
} }
// Process streaming messages // Process streaming messages
@@ -696,7 +637,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
if (message.session_id && !capturedSessionId) { if (message.session_id && !capturedSessionId) {
capturedSessionId = message.session_id; capturedSessionId = message.session_id;
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws); addSession(capturedSessionId, queryInstance, ws);
// Set session ID on writer // Set session ID on writer
if (ws.setSessionId && typeof ws.setSessionId === 'function') { if (ws.setSessionId && typeof ws.setSessionId === 'function') {
@@ -738,9 +679,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
removeSession(capturedSessionId); removeSession(capturedSessionId);
} }
// Clean up temporary image files
await cleanupTempFiles(tempImagePaths, tempDir);
// Send the terminal completion event — skipped for aborted runs, whose // Send the terminal completion event — skipped for aborted runs, whose
// terminal `complete` (aborted: true) was already sent by abort-session. // terminal `complete` (aborted: true) was already sent by abort-session.
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false; const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
@@ -764,9 +702,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
removeSession(capturedSessionId); removeSession(capturedSessionId);
} }
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false; const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (wasAborted) { if (wasAborted) {
// The abort already produced the terminal complete; a generator throw // The abort already produced the terminal complete; a generator throw
@@ -819,9 +754,6 @@ async function abortClaudeSDKSession(sessionId) {
// Update session status // Update session status
session.status = 'aborted'; session.status = 'aborted';
// Clean up temporary image files
await cleanupTempFiles(session.tempImagePaths, session.tempDir);
// Clean up session // Clean up session
removeSession(sessionId); removeSession(sessionId);

View File

@@ -256,13 +256,11 @@ async function updatePackage() {
const SANDBOX_TEMPLATES = { const SANDBOX_TEMPLATES = {
claude: 'docker.io/cloudcliai/sandbox:claude-code', claude: 'docker.io/cloudcliai/sandbox:claude-code',
codex: 'docker.io/cloudcliai/sandbox:codex', codex: 'docker.io/cloudcliai/sandbox:codex',
gemini: 'docker.io/cloudcliai/sandbox:gemini',
}; };
const SANDBOX_SECRETS = { const SANDBOX_SECRETS = {
claude: 'anthropic', claude: 'anthropic',
codex: 'openai', codex: 'openai',
gemini: 'google',
}; };
function parseSandboxArgs(args) { function parseSandboxArgs(args) {
@@ -338,7 +336,7 @@ Subcommands:
${c.bright('help')} Show this help ${c.bright('help')} Show this help
Options: Options:
-a, --agent <agent> Agent to use: claude, codex, gemini (default: claude) -a, --agent <agent> Agent to use: claude, codex (default: claude)
-n, --name <name> Sandbox name (default: derived from workspace folder) -n, --name <name> Sandbox name (default: derived from workspace folder)
-t, --template <image> Custom template image -t, --template <image> Custom template image
-e, --env <KEY=VALUE> Set environment variable (repeatable) -e, --env <KEY=VALUE> Set environment variable (repeatable)
@@ -359,7 +357,6 @@ Prerequisites:
sbx login sbx login
sbx secret set -g anthropic # for Claude sbx secret set -g anthropic # for Claude
sbx secret set -g openai # for Codex sbx secret set -g openai # for Codex
sbx secret set -g google # for Gemini
Advanced usage: Advanced usage:
For branch mode, multiple workspaces, memory limits, network policies, For branch mode, multiple workspaces, memory limits, network policies,

View File

@@ -1,13 +1,15 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn'; import crossSpawn from 'cross-spawn';
import { appendImagesInputTag } from './shared/image-attachments.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.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 // cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; // child_process.spawn everywhere else.
const spawnFunction = crossSpawn;
let activeCursorProcesses = new Map(); // Track active processes by session ID let activeCursorProcesses = new Map(); // Track active processes by session ID
@@ -28,7 +30,7 @@ function isWorkspaceTrustPrompt(text = '') {
async function spawnCursor(command, options = {}, ws) { async function spawnCursor(command, options = {}, ws) {
return new Promise(async (resolve, reject) => { 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); const resolvedModel = await providerModelsService.resolveResumeModel('cursor', sessionId, model);
let capturedSessionId = sessionId; // Track session ID throughout the process let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event 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()) { if (command && command.trim()) {
// Provide a prompt (works for both new and resumed sessions) // Provide a prompt (works for both new and resumed sessions). Image
baseArgs.push('-p', command); // attachments ride along as an <images_input> 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 // Model overrides are applied to both new and resumed sessions so a
// session-scoped change request can take effect on the next turn. // 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 // Add skip permissions flag if enabled
if (skipPermissions || settings.skipPermissions) { if (skipPermissions || settings.skipPermissions) {
baseArgs.push('-f'); baseArgs.push('-f');
console.log('Using -f flag (skip permissions)');
} }
// Use cwd (actual project directory) instead of projectPath // 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('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, { const cursorProcess = spawnFunction('cursor-agent', args, {
cwd: workingDir, cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],

View File

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

View File

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

View File

@@ -5,8 +5,10 @@ import fs, { promises as fsPromises } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import http from 'http'; 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 express from 'express';
import cors from 'cors'; import cors from 'cors';
import mime from 'mime-types'; import mime from 'mime-types';
@@ -33,15 +35,10 @@ import {
queryCodex, queryCodex,
abortCodexSession, abortCodexSession,
} from './openai-codex.js'; } from './openai-codex.js';
import {
spawnGemini,
abortGeminiSession,
} from './gemini-cli.js';
import { import {
spawnOpenCode, spawnOpenCode,
abortOpenCodeSession, abortOpenCodeSession,
} from './opencode-cli.js'; } from './opencode-cli.js';
import sessionManager from './sessionManager.js';
import { import {
stripAnsiSequences, stripAnsiSequences,
normalizeDetectedUrl, normalizeDetectedUrl,
@@ -59,11 +56,11 @@ import agentRoutes from './routes/agent.js';
import projectModuleRoutes from './modules/projects/projects.routes.js'; import projectModuleRoutes from './modules/projects/projects.routes.js';
import notificationRoutes from './modules/notifications/notifications.routes.js'; import notificationRoutes from './modules/notifications/notifications.routes.js';
import userRoutes from './routes/user.js'; import userRoutes from './routes/user.js';
import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js'; import pluginsRoutes from './routes/plugins.js';
import providerRoutes from './modules/providers/provider.routes.js'; import providerRoutes from './modules/providers/provider.routes.js';
import voiceRoutes from './voice-proxy.js'; import voiceRoutes from './voice-proxy.js';
import browserUseRoutes from './modules/browser-use/browser-use.routes.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 browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
import { browserUseService } from './modules/browser-use/browser-use.service.js'; import { browserUseService } from './modules/browser-use/browser-use.service.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
@@ -116,14 +113,12 @@ const wss = createWebSocketServer(server, {
claude: queryClaudeSDK, claude: queryClaudeSDK,
cursor: spawnCursor, cursor: spawnCursor,
codex: queryCodex, codex: queryCodex,
gemini: spawnGemini,
opencode: spawnOpenCode, opencode: spawnOpenCode,
}, },
abortFns: { abortFns: {
claude: abortClaudeSDKSession, claude: abortClaudeSDKSession,
cursor: abortCursorSession, cursor: abortCursorSession,
codex: abortCodexSession, codex: abortCodexSession,
gemini: abortGeminiSession,
opencode: abortOpenCodeSession, opencode: abortOpenCodeSession,
}, },
resolveToolApproval, resolveToolApproval,
@@ -132,14 +127,11 @@ const wss = createWebSocketServer(server, {
shell: { shell: {
resolveProviderSessionId: (sessionId, provider) => { resolveProviderSessionId: (sessionId, provider) => {
const dbSession = sessionsDb.getSessionById(sessionId); const dbSession = sessionsDb.getSessionById(sessionId);
const legacyGeminiSession =
provider === 'gemini' ? sessionManager.getSession(sessionId) : null;
if (dbSession) { if (dbSession) {
return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null; return dbSession.provider_session_id ?? null;
} }
return legacyGeminiSession?.cliSessionId; return null;
}, },
stripAnsiSequences, stripAnsiSequences,
normalizeDetectedUrl, normalizeDetectedUrl,
@@ -185,6 +177,9 @@ app.use('/api/auth', authRoutes);
// Projects API Routes (protected) // Projects API Routes (protected)
app.use('/api/projects', authenticateToken, projectModuleRoutes); 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) // Git API Routes (protected)
app.use('/api/git', authenticateToken, gitRoutes); app.use('/api/git', authenticateToken, gitRoutes);
@@ -208,9 +203,6 @@ app.use('/api/notifications', authenticateToken, notificationRoutes);
// User API Routes (protected) // User API Routes (protected)
app.use('/api/user', authenticateToken, userRoutes); app.use('/api/user', authenticateToken, userRoutes);
// Gemini API Routes (protected)
app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected) // Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes); app.use('/api/plugins', authenticateToken, pluginsRoutes);
@@ -1072,92 +1064,8 @@ const uploadFilesHandler = async (req, res) => {
app.post('/api/projects/:projectId/files/upload', authenticateToken, uploadFilesHandler); app.post('/api/projects/:projectId/files/upload', authenticateToken, uploadFilesHandler);
// Image upload endpoint. Accepts the DB-assigned `projectId` (not a folder name) // Chat image uploads moved to POST /api/assets/images (server/modules/assets),
// but the current implementation doesn't need to touch the project directory, // which stores them in the global ~/.cloudcli/assets folder.
// 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' });
}
});
// Get token usage for a specific session. `projectId` is the DB primary key; // 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. // 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') { if (provider === 'opencode') {
const dbPath = getOpenCodeDatabasePath(); const dbPath = getOpenCodeDatabasePath();
if (!fs.existsSync(dbPath)) { if (!fs.existsSync(dbPath)) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto'; import { randomBytes, randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; 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 { appConfigDb } from '@/modules/database/index.js';
import { providerMcpService } from '@/modules/providers/index.js'; import { providerMcpService } from '@/modules/providers/index.js';
import { getModuleDir } from '@/utils/runtime-paths.js'; import { getModuleDir } from '@/utils/runtime-paths.js';
@@ -270,8 +272,10 @@ function runCommand(command: string, args: string[]): Promise<void> {
}, INSTALL_COMMAND_TIMEOUT_MS); }, INSTALL_COMMAND_TIMEOUT_MS);
timer.unref?.(); timer.unref?.();
child.stdout.on('data', (chunk) => output.push(String(chunk))); // stdio config above guarantees the pipes exist; cross-spawn's types
child.stderr.on('data', (chunk) => output.push(String(chunk))); // 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('error', (error) => finish(() => reject(error)));
child.on('close', (code) => finish(() => { child.on('close', (code) => finish(() => {
if (code === 0) { if (code === 0) {

View File

@@ -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 () => { test('legacy provider-keyed rows stay resolvable through both lookups', async () => {
await withIsolatedDatabase(() => { 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'); assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1');
}); });
}); });

View File

@@ -13,7 +13,6 @@ const PROVIDER_LABELS = {
claude: 'Claude', claude: 'Claude',
cursor: 'Cursor', cursor: 'Cursor',
codex: 'Codex', codex: 'Codex',
gemini: 'Gemini',
system: 'System' system: 'System'
}; };

View File

@@ -1,7 +1,9 @@
import { spawn } from 'node:child_process';
import { access, mkdir, rm } from 'node:fs/promises'; import { access, mkdir, rm } from 'node:fs/promises';
import path from 'node:path'; 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 { githubTokensDb } from '@/modules/database/index.js';
import { createProject } from '@/modules/projects/services/project-management.service.js'; import { createProject } from '@/modules/projects/services/project-management.service.js';
import type { WorkspacePathValidationResult } from '@/shared/types.js'; import type { WorkspacePathValidationResult } from '@/shared/types.js';

View File

@@ -36,7 +36,6 @@ Current provider ids in this repo are:
- `claude` - `claude`
- `codex` - `codex`
- `cursor` - `cursor`
- `gemini`
- `opencode` - `opencode`
Those ids are mirrored in backend unions and frontend provider constants. If Those ids are mirrored in backend unions and frontend provider constants. If
@@ -56,8 +55,7 @@ server/modules/providers/list/<provider>/
<provider>-session-synchronizer.provider.ts <provider>-session-synchronizer.provider.ts
``` ```
The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and The existing provider folders are `claude`, `codex`, `cursor`, and `opencode`.
`opencode`.
## What Each Facet Does ## 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` | | Claude | `.mcp.json` in user / local / project locations | `user`, `local`, `project` | `stdio`, `http`, `sse` |
| Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` | | Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` |
| Cursor | `.cursor/mcp.json` | `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 `<workspace>/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` | | OpenCode | `~/.config/opencode/opencode.json` or `<workspace>/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` |
5. Implement skills. 5. Implement skills.
@@ -144,7 +141,6 @@ Current skill discovery roots are:
| Claude | `~/.claude/skills` | `<workspace>/.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. | | Claude | `~/.claude/skills` | `<workspace>/.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` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. | | Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. |
| Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. | | Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/skills`, `<workspace>/.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. | | 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: 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` - Claude plugin skills: `/plugin-name:skill-name`
- Codex skills: `$skill-name` - Codex skills: `$skill-name`
- Cursor skills: `/skill-name` - Cursor skills: `/skill-name`
- Gemini skills: `/skill-name`
- OpenCode skills: `/skill-name` - OpenCode skills: `/skill-name`
6. Implement sessions. 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. | | 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. | | 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. | | 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. | | 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. 8. Register the provider.

View File

@@ -313,6 +313,18 @@ export class ClaudeSessionsProvider implements IProviderSessions {
if (raw.message?.role === 'user' && raw.message?.content && raw.isMeta !== true) { if (raw.message?.role === 'user' && raw.message?.content && raw.isMeta !== true) {
if (Array.isArray(raw.message.content)) { 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++) { for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
const part = raw.message.content[partIndex]; const part = raw.message.content[partIndex];
if (part.type === 'tool_result') { if (part.type === 'tool_result') {
@@ -339,7 +351,9 @@ export class ClaudeSessionsProvider implements IProviderSessions {
kind: 'text', kind: 'text',
role: 'user', role: 'user',
content: text, content: text,
images: !imagesAttached && imageAttachments.length > 0 ? imageAttachments : undefined,
})); }));
imagesAttached = true;
} }
} }
} }
@@ -359,9 +373,25 @@ export class ClaudeSessionsProvider implements IProviderSessions {
kind: 'text', kind: 'text',
role: 'user', role: 'user',
content: textParts, 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') { } else if (typeof raw.message.content === 'string') {
const text = raw.message.content; const text = raw.message.content;

View File

@@ -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) { if (!sessionName) {
sessionName = await this.extractLastAgentMessageFromEnd(filePath); 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 `<environment_context>` boilerplate is
* never mistaken for the user's prompt.
*/
private async extractFirstUserMessageFromStart(filePath: string): Promise<string | undefined> {
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<string, unknown>;
const eventType = typeof data.type === 'string' ? data.type : undefined;
const payload = data.payload as Record<string, unknown> | 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<string | undefined> { private async extractLastAgentMessageFromEnd(filePath: string): Promise<string | undefined> {
try { try {
const content = await readFile(filePath, 'utf8'); const content = await readFile(filePath, 'utf8');

View File

@@ -2,6 +2,7 @@ import fsSync from 'node:fs';
import readline from 'node:readline'; import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js'; import { sessionsDb } from '@/modules/database/index.js';
import { toImageAttachments } from '@/shared/image-attachments.js';
import type { IProviderSessions } from '@/shared/interfaces.js'; import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.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; 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 { function extractCodexTextContent(content: unknown): string {
if (!Array.isArray(content)) { if (!Array.isArray(content)) {
return typeof content === 'string' ? content : ''; return typeof content === 'string' ? content : '';
@@ -104,6 +141,7 @@ async function getCodexSessionMessages(
role: 'user', role: 'user',
content: entry.payload.message, content: entry.payload.message,
}, },
images: extractCodexUserImages(entry.payload as AnyRecord),
}); });
} }
@@ -296,7 +334,8 @@ export class CodexSessionsProvider implements IProviderSessions {
.filter(Boolean) .filter(Boolean)
.join('\n') .join('\n')
: String(raw.message.content || ''); : 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 [];
} }
return [createNormalizedMessage({ return [createNormalizedMessage({
@@ -307,6 +346,7 @@ export class CodexSessionsProvider implements IProviderSessions {
kind: 'text', kind: 'text',
role: 'user', role: 'user',
content, content,
images: rawImages,
})]; })];
} }

View File

@@ -1,7 +1,6 @@
import { access, readdir } from 'node:fs/promises'; import { access, readdir } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'node:child_process';
import crossSpawn from 'cross-spawn'; import crossSpawn from 'cross-spawn';
@@ -446,11 +445,6 @@ export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
label: "gpt-5.2-xhigh-fast", label: "gpt-5.2-xhigh-fast",
description: "GPT-5.2 Extra High 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", value: "gpt-5.4-mini-none",
label: "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", label: "gpt-5.1-high",
description: "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", value: "gpt-5.1-codex-mini-low",
label: "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_MODELS_TIMEOUT_MS = 10_000;
const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats'); 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( const ANSI_PATTERN = new RegExp(
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
'[\\u001B\\u009B][[\\]()#;?]*(?:' '[\\u001B\\u009B][[\\]()#;?]*(?:'

View File

@@ -141,7 +141,13 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer {
} }
const text = typeof data.message?.content?.[0]?.text === 'string' ? data.message.content[0].text : ''; 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 `<timestamp>…</timestamp>` prefix and `<user_query>` tags
// so the session name comes from the actual first line the user typed.
const firstLine = text
.replace(/<timestamp>[\s\S]*?<\/timestamp>/g, '')
.replace(/<\/?user_query>/g, '')
.trim()
.split('\n')[0];
return { return {
sessionId, sessionId,

View File

@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { parseImagesInputTag } from '@/shared/image-attachments.js';
import type { IProviderSessions } from '@/shared/interfaces.js'; import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { import {
@@ -24,7 +25,7 @@ type CursorJsonBlob = CursorDbBlob & {
parsed: AnyRecord; parsed: AnyRecord;
}; };
type CursorMessageBlob = { export type CursorMessageBlob = {
id: string; id: string;
sequence: number; sequence: number;
rowid: number; rowid: number;
@@ -59,17 +60,42 @@ function unwrapUserQueryText(value: string, role: 'user' | 'assistant'): string
return value; return value;
} }
const normalized = value.trimStart(); // Cursor wraps user turns as `<timestamp>…</timestamp>\n<user_query>…</user_query>`.
// Show only the `<user_query>` content, trimmed so there are no blank lines
// at the top/bottom and the `<timestamp>` prefix is dropped entirely.
const openTag = '<user_query>'; const openTag = '<user_query>';
const closeTag = '</user_query>'; const closeTag = '</user_query>';
if (!normalized.startsWith(openTag)) { const openIndex = value.indexOf(openTag);
return value; 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); // No `<user_query>` wrapper: still strip a leading `<timestamp>…</timestamp>`.
const closeIndex = afterOpen.lastIndexOf(closeTag); return value.replace(/^\s*<timestamp>[\s\S]*?<\/timestamp>\s*/, '').trim();
const inner = closeIndex >= 0 ? afterOpen.slice(0, closeIndex) : afterOpen; }
return inner.trim();
/**
* Unwraps one user-authored text payload and splits off the `<images_input>`
* 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 { 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 * Converts Cursor SQLite message blobs into normalized messages and attaches
* matching tool results to their tool_use entries. * 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 messages: NormalizedMessage[] = [];
const toolUseMap = new Map<string, NormalizedMessage>(); const toolUseMap = new Map<string, NormalizedMessage>();
const baseTime = Date.now(); const baseTime = Date.now();
@@ -442,7 +471,16 @@ export class CursorSessionsProvider implements IProviderSessions {
text = unwrapUserQueryText(content.message.content, role); 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({ messages.push(createNormalizedMessage({
id: baseId, id: baseId,
sessionId, sessionId,
@@ -450,7 +488,8 @@ export class CursorSessionsProvider implements IProviderSessions {
provider: PROVIDER, provider: PROVIDER,
kind: 'text', kind: 'text',
role, role,
content: text, content: cleanText,
images,
sequence: blob.sequence, sequence: blob.sequence,
rowid: blob.rowid, rowid: blob.rowid,
})); }));
@@ -502,8 +541,8 @@ export class CursorSessionsProvider implements IProviderSessions {
} }
if (part?.type === 'text' && part?.text) { if (part?.type === 'text' && part?.text) {
const normalizedPartText = unwrapUserQueryText(part.text, role); const { text: normalizedPartText, images } = extractUserTextAndImages(part.text, role);
if (!normalizedPartText) { if (!normalizedPartText && !images) {
continue; continue;
} }
messages.push(createNormalizedMessage({ messages.push(createNormalizedMessage({
@@ -514,6 +553,7 @@ export class CursorSessionsProvider implements IProviderSessions {
kind: 'text', kind: 'text',
role, role,
content: normalizedPartText, content: normalizedPartText,
images,
sequence: blob.sequence, sequence: blob.sequence,
rowid: blob.rowid, rowid: blob.rowid,
})); }));
@@ -553,8 +593,8 @@ export class CursorSessionsProvider implements IProviderSessions {
&& content.content.trim() && content.content.trim()
&& !isInternalCursorText(content.content) && !isInternalCursorText(content.content)
) { ) {
const normalizedText = unwrapUserQueryText(content.content, role); const { text: normalizedText, images } = extractUserTextAndImages(content.content, role);
if (!normalizedText) { if (!normalizedText && !images) {
continue; continue;
} }
messages.push(createNormalizedMessage({ messages.push(createNormalizedMessage({
@@ -565,6 +605,7 @@ export class CursorSessionsProvider implements IProviderSessions {
kind: 'text', kind: 'text',
role, role,
content: normalizedText, content: normalizedText,
images,
sequence: blob.sequence, sequence: blob.sequence,
rowid: blob.rowid, rowid: blob.rowid,
})); }));

View File

@@ -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<ProviderAuthStatus> {
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<string, string> {
const parsed: Record<string, string> = {};
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<Record<string, string>> {
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<GeminiAuthType> {
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<GeminiCredentialsStatus> {
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<string | null> {
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;
}
}
}

View File

@@ -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<Record<string, unknown>> {
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<string, unknown>,
): Promise<void> {
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<string, unknown> {
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<string, unknown>;
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;
}
}

View File

@@ -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<ProviderModelsDefinition> {
return GEMINI_FALLBACK_MODELS;
}
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
return buildDefaultProviderCurrentActiveModel(GEMINI_FALLBACK_MODELS);
}
async changeActiveModel(
input: ProviderChangeActiveModelInput,
): Promise<ProviderSessionActiveModelChange> {
return writeProviderSessionActiveModelChange('gemini', input);
}
}

View File

@@ -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<number> {
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<string | null> {
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<ParsedSession | null> {
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<string, string>
): Promise<ParsedSession | null> {
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<GeminiJsonlMetadata | null> {
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<string> {
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<string, string> {
const lookup = new Map<string, string>();
const knownPaths = new Set<string>();
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<string, string>, 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<string>([
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');
}
}

View File

@@ -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<GeminiHistoryResult> {
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<GeminiHistoryResult> {
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<GeminiHistoryResult> {
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<FetchHistoryResult> {
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<string, NormalizedMessage>();
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,
};
}
}

View File

@@ -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<ProviderSkillSource[]> {
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<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
commandPrefix: '/',
};
}
}

View File

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

View File

@@ -19,7 +19,6 @@ const OPENCODE_ENV_CREDENTIAL_KEYS = [
'ANTHROPIC_API_KEY', 'ANTHROPIC_API_KEY',
'OPENAI_API_KEY', 'OPENAI_API_KEY',
'GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY',
'GEMINI_API_KEY',
'GROQ_API_KEY', 'GROQ_API_KEY',
'OPENROUTER_API_KEY', 'OPENROUTER_API_KEY',
]; ];

View File

@@ -1,5 +1,3 @@
import { spawn } from 'node:child_process';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import crossSpawn from 'cross-spawn'; import crossSpawn from 'cross-spawn';
@@ -51,23 +49,15 @@ export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
label: 'GPT-5.4 Mini', label: 'GPT-5.4 Mini',
description: 'openai - openai/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', DEFAULT: 'anthropic/claude-sonnet-4-5',
}; };
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000; 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 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 DATE_TOKEN = /^\d{8}$/;
const SIMPLE_NUMBER_TOKEN = /^\d$/; const SIMPLE_NUMBER_TOKEN = /^\d$/;
const VERSION_TOKEN = /^[a-z]\d+$/i; 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 readOpenCodeVerboseModelId = (model: OpenCodeVerboseModel): string | null => {
const id = readOptionalString(model.id); const id = readOptionalString(model.id);
if (!id) { if (!id) {
@@ -296,7 +290,7 @@ const readOpenCodeEffortValues = (
const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOption | null => { const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOption | null => {
const value = readOpenCodeVerboseModelId(model); const value = readOpenCodeVerboseModelId(model);
if (!value) { if (!value || !isSupportedOpenCodeModelId(value)) {
return null; return null;
} }
@@ -315,11 +309,13 @@ const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOpti
}; };
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => { export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = ids.map((value) => ({ const options: ProviderModelOption[] = ids
value, .filter(isSupportedOpenCodeModelId)
label: labelForOpenCodeModelId(value), .map((value) => ({
description: descriptionForOpenCodeModelId(value), value,
})); label: labelForOpenCodeModelId(value),
description: descriptionForOpenCodeModelId(value),
}));
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value
?? options[0]?.value ?? options[0]?.value

View File

@@ -11,6 +11,7 @@ import {
normalizeSessionName, normalizeSessionName,
readJsonRecord, readJsonRecord,
readOptionalString, readOptionalString,
unwrapJsonStringLiteral,
} from '@/shared/utils.js'; } from '@/shared/utils.js';
type OpenCodeSessionRow = { type OpenCodeSessionRow = {
@@ -128,9 +129,26 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId) const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
?? sessionsDb.getSessionById(sessionId); ?? sessionsDb.getSessionById(sessionId);
const existingName = existingSession?.custom_name; const existingName = existingSession?.custom_name;
const nextName = existingName && existingName !== fallbackTitle
? existingName // Sessions started by sending a message from cloudcli carry a distinct
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId); // 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 // 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. // 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; `).get(sessionId) as { data: string | null } | undefined;
const data = readJsonRecord(row?.data); 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 { } catch {
return undefined; return undefined;
} }

View File

@@ -2,6 +2,7 @@ import fsSync from 'node:fs';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { parseImagesInputTag } from '@/shared/image-attachments.js';
import type { IProviderSessions } from '@/shared/interfaces.js'; import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { import {
@@ -13,6 +14,7 @@ import {
readJsonRecord, readJsonRecord,
readOptionalString, readOptionalString,
sliceTailPage, sliceTailPage,
unwrapJsonStringLiteral,
} from '@/shared/utils.js'; } from '@/shared/utils.js';
const PROVIDER = 'opencode'; 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 => { const extractText = (value: unknown): string => {
if (typeof value === 'string') { if (typeof value === 'string') {
return unwrapJsonStringLiteral(value); return unwrapJsonStringLiteral(value);
@@ -418,8 +401,13 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
} }
if (partType === 'text') { if (partType === 'text') {
const content = extractText(partData); const rawContent = extractText(partData);
if (content.trim()) { // User prompts sent with attachments carry an <images_input> 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({ normalized.push(createNormalizedMessage({
id: baseId, id: baseId,
sessionId, sessionId,
@@ -428,6 +416,7 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
kind: 'text', kind: 'text',
role: messageRole === 'user' ? 'user' : 'assistant', role: messageRole === 'user' ? 'user' : 'assistant',
content, content,
images: attachments.length > 0 ? attachments : undefined,
})); }));
} }
continue; continue;

View File

@@ -1,7 +1,6 @@
import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js'; import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js';
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js'; import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.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 { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
import type { IProvider } from '@/shared/interfaces.js'; import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js'; import type { LLMProvider } from '@/shared/types.js';
@@ -11,7 +10,6 @@ const providers: Record<LLMProvider, IProvider> = {
claude: new ClaudeProvider(), claude: new ClaudeProvider(),
codex: new CodexProvider(), codex: new CodexProvider(),
cursor: new CursorProvider(), cursor: new CursorProvider(),
gemini: new GeminiProvider(),
opencode: new OpenCodeProvider(), opencode: new OpenCodeProvider(),
}; };

View File

@@ -285,7 +285,6 @@ const parseProvider = (value: unknown): LLMProvider => {
normalized === 'claude' normalized === 'claude'
|| normalized === 'codex' || normalized === 'codex'
|| normalized === 'cursor' || normalized === 'cursor'
|| normalized === 'gemini'
|| normalized === 'opencode' || normalized === 'opencode'
) { ) {
return normalized; return normalized;

View File

@@ -46,7 +46,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
provider: 'cursor', provider: 'cursor',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default', defaultPermissionMode: 'default',
supportsImages: false, supportsImages: true,
supportsAbort: true, supportsAbort: true,
supportsPermissionRequests: false, supportsPermissionRequests: false,
supportsTokenUsage: false, supportsTokenUsage: false,
@@ -56,27 +56,20 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
provider: 'codex', provider: 'codex',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'], permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
defaultPermissionMode: 'default', defaultPermissionMode: 'default',
supportsImages: false, supportsImages: true,
supportsAbort: true, supportsAbort: true,
supportsPermissionRequests: false, supportsPermissionRequests: false,
supportsTokenUsage: true, supportsTokenUsage: true,
supportsEffort: true, supportsEffort: true,
}, },
gemini: {
provider: 'gemini',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
supportsEffort: false,
},
opencode: { opencode: {
provider: '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', defaultPermissionMode: 'default',
supportsImages: false, supportsImages: true,
supportsAbort: true, supportsAbort: true,
supportsPermissionRequests: false, supportsPermissionRequests: false,
supportsTokenUsage: true, supportsTokenUsage: true,

View File

@@ -17,7 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 2; const PROVIDER_MODELS_CACHE_VERSION = 2;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']); const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
type ProviderModelsServiceDependencies = { type ProviderModelsServiceDependencies = {
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>; resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;

View File

@@ -8,7 +8,7 @@ import { rgPath } from '@vscode/ripgrep';
import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { projectsDb, sessionsDb } from '@/modules/database/index.js';
type AnyRecord = Record<string, any>; type AnyRecord = Record<string, any>;
type SearchableProvider = 'claude' | 'codex' | 'gemini'; type SearchableProvider = 'claude' | 'codex';
type SearchSnippetHighlight = { type SearchSnippetHighlight = {
start: number; start: number;
@@ -82,7 +82,7 @@ type ProjectBucket = {
sessions: SearchableSessionRow[]; sessions: SearchableSessionRow[];
}; };
const SUPPORTED_PROVIDERS = new Set<SearchableProvider>(['claude', 'codex', 'gemini']); const SUPPORTED_PROVIDERS = new Set<SearchableProvider>(['claude', 'codex']);
const MAX_MATCHES_PER_SESSION = 2; const MAX_MATCHES_PER_SESSION = 2;
const RIPGREP_FILE_CHUNK_SIZE = 40; const RIPGREP_FILE_CHUNK_SIZE = 40;
const RIPGREP_CHUNK_CONCURRENCY = 6; const RIPGREP_CHUNK_CONCURRENCY = 6;
@@ -455,21 +455,6 @@ function extractCodexText(content: unknown): string {
.join(' '); .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[] { function normalizeSearchableSessions(rows: SessionRepositoryRow[]): SearchableSessionRow[] {
const normalizedRows: SearchableSessionRow[] = []; const normalizedRows: SearchableSessionRow[] = [];
const projectArchiveStateByPath = new Map<string, boolean>(); const projectArchiveStateByPath = new Map<string, boolean>();
@@ -1065,81 +1050,6 @@ async function parseCodexSessionMatches(
}; };
} }
async function parseGeminiSessionMatches(
session: SearchableSessionRow,
runtime: SearchRuntime,
): Promise<SessionConversationResult | null> {
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( async function parseSessionMatches(
session: SearchableSessionRow, session: SearchableSessionRow,
runtime: SearchRuntime, runtime: SearchRuntime,
@@ -1150,7 +1060,7 @@ async function parseSessionMatches(
if (session.provider === 'codex') { if (session.provider === 'codex') {
return parseCodexSessionMatches(session, runtime); return parseCodexSessionMatches(session, runtime);
} }
return parseGeminiSessionMatches(session, runtime); return null;
} }
export async function searchConversations( export async function searchConversations(

View File

@@ -21,7 +21,6 @@ export const sessionSynchronizerService = {
claude: 0, claude: 0,
codex: 0, codex: 0,
cursor: 0, cursor: 0,
gemini: 0,
opencode: 0, opencode: 0,
}; };
const failures: string[] = []; const failures: string[] = [];

View File

@@ -25,16 +25,6 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
provider: 'codex', provider: 'codex',
rootPath: path.join(os.homedir(), '.codex', 'sessions'), 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', provider: 'opencode',
rootPath: path.join(os.homedir(), '.local', 'share', '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'; return path.basename(filePath) === 'opencode.db';
} }
if (provider === 'gemini') {
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
}
return filePath.endsWith('.jsonl'); return filePath.endsWith('.jsonl');
} }

View File

@@ -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<void>): Promise<void> {
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<string> => {
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 });
}
});

View File

@@ -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 tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-gc-'));
const workspacePath = path.join(tempRoot, 'workspace'); const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true }); await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot); const restoreHomeDir = patchHomeDir(tempRoot);
try { 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', { await providerMcpService.upsertProviderMcpServer('cursor', {
name: 'cursor-stdio', name: 'cursor-stdio',
scope: 'project', scope: 'project',
@@ -303,15 +284,6 @@ test('providerMcpService handles gemini and cursor MCP JSON config formats', { c
headers: { API_KEY: 'value' }, headers: { API_KEY: 'value' },
}); });
const geminiUserConfig = await readJson(path.join(tempRoot, '.gemini', 'settings.json'));
const geminiUserServer = (geminiUserConfig.mcpServers as Record<string, unknown>)['gemini-stdio'] as Record<string, unknown>;
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<string, unknown>)['gemini-http'] as Record<string, unknown>;
assert.equal(geminiProjectServer.type, 'http');
const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json')); const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json'));
const cursorHttpServer = (cursorUserConfig.mcpServers as Record<string, unknown>)['cursor-http'] as Record<string, unknown>; const cursorHttpServer = (cursorUserConfig.mcpServers as Record<string, unknown>)['cursor-http'] as Record<string, unknown>;
assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp'); assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp');
@@ -341,7 +313,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
workspacePath, workspacePath,
}); });
assert.equal(globalResult.length, 5); assert.equal(globalResult.length, 4);
assert.ok(globalResult.every((entry) => entry.created === true)); assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json')); 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<string, unknown>; const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record<string, unknown>;
assert.ok((codexProject.mcp_servers as Record<string, unknown>)['global-http']); assert.ok((codexProject.mcp_servers as Record<string, unknown>)['global-http']);
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json')); const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']); assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);

View File

@@ -30,6 +30,7 @@ test('OpenCode models provider formats frontend labels from provider-prefixed id
'opencode/nemotron-3-super-free', 'opencode/nemotron-3-super-free',
'anthropic/claude-3-5-sonnet-20241022', 'anthropic/claude-3-5-sonnet-20241022',
'anthropic/claude-opus-4-7-fast', 'anthropic/claude-opus-4-7-fast',
'google/model-alpha',
'openai/gpt-5.4-mini-fast', 'openai/gpt-5.4-mini-fast',
'openai/gpt-5.5-pro', 'openai/gpt-5.5-pro',
'newprovider/alpha-v12-special-20261231', '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); const definition = buildOpenCodeDefinitionFromVerboseModels(models);

View File

@@ -9,6 +9,7 @@ import Database from 'better-sqlite3';
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.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 { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
import { appendImagesInputTag } from '@/shared/image-attachments.js';
const patchHomeDir = (nextHomeDir: string) => { const patchHomeDir = (nextHomeDir: string) => {
const original = os.homedir; const original = os.homedir;
@@ -321,6 +322,41 @@ test('OpenCode session synchronizer adopts the pending app session before watche
} }
}); });
test('OpenCode sessions provider strips <images_input> 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', () => { test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
const provider = new OpenCodeSessionsProvider(); const provider = new OpenCodeSessionsProvider();
const normalized = provider.normalizeMessage({ 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 }); 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<void> => {
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 });
}
});

View File

@@ -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: <images_input> 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: `<timestamp>2026-07-03</timestamp>\n<user_query>${taggedPrompt}</user_query>`,
},
},
{
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: '<timestamp>2026-07-03</timestamp>\n<user_query>plain question</user_query>',
},
},
];
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);
});

View File

@@ -170,13 +170,13 @@ test('provider model cache is persisted across service instances', async () => {
cachePath, cachePath,
resolveProvider: () => ({ resolveProvider: () => ({
models: { models: {
getSupportedModels: async () => createModels('gemini-cached'), getSupportedModels: async () => createModels('cursor-cached'),
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'), getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input), changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
}, },
}), }),
}); });
await writer.getProviderModels('gemini'); await writer.getProviderModels('cursor');
const reader = createProviderModelsService({ const reader = createProviderModelsService({
cachePath, cachePath,
@@ -185,13 +185,13 @@ test('provider model cache is persisted across service instances', async () => {
getSupportedModels: async () => { getSupportedModels: async () => {
throw new Error('loader should not be called for persisted cache hits'); throw new Error('loader should not be called for persisted cache hits');
}, },
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'), getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input), changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
}, },
}), }),
}); });
const models = await reader.getProviderModels('gemini'); const models = await reader.getProviderModels('cursor');
assert.equal(models.models.DEFAULT, 'gemini-cached'); assert.equal(models.models.DEFAULT, 'cursor-cached');
assert.equal(models.cache.source, 'disk'); assert.equal(models.cache.source, 'disk');
} finally { } finally {
await rm(tempRoot, { recursive: true, force: true }); await rm(tempRoot, { recursive: true, force: true });

View File

@@ -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. * `.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 tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-'));
const workspacePath = path.join(tempRoot, 'workspace'); const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true }); await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot); const restoreHomeDir = patchHomeDir(tempRoot);
try { try {
await writeSkill(
path.join(tempRoot, '.gemini', 'skills'),
'gemini-user-dir',
'gemini-user',
'Gemini user skill',
);
await writeSkill( await writeSkill(
path.join(tempRoot, '.agents', 'skills'), path.join(tempRoot, '.agents', 'skills'),
'agents-user-dir', 'agents-user-dir',
'agents-user', 'agents-user',
'Agents user skill', 'Agents user skill',
); );
await writeSkill(
path.join(workspacePath, '.gemini', 'skills'),
'gemini-project-dir',
'gemini-project',
'Gemini project skill',
);
await writeSkill( await writeSkill(
path.join(workspacePath, '.agents', 'skills'), path.join(workspacePath, '.agents', 'skills'),
'agents-project-dir', 'agents-project-dir',
@@ -491,14 +479,6 @@ test('providerSkillsService lists gemini and cursor skills from their configured
'Cursor user skill', '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 cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath });
const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill])); const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill]));
assert.equal(cursorByName.get('agents-project')?.scope, 'project'); 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 * This test covers managed global skill creation for providers that own a
* writable user skill directory. * 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 tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
const restoreHomeDir = patchHomeDir(tempRoot); 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' }); 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', { const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
entries: [ entries: [
{ {
@@ -656,9 +620,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
const listedCodexSkills = await providerSkillsService.listProviderSkills('codex'); const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true); 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'); const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true); assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);

View File

@@ -318,6 +318,22 @@ export const chatRunRegistry = {
run.writer.sendComplete(opts); 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. * Test-only escape hatch: clears every tracked run.
*/ */

View File

@@ -1,8 +1,11 @@
import path from 'node:path';
import type { WebSocket } from 'ws'; import type { WebSocket } from 'ws';
import { sessionsDb } from '@/modules/database/index.js'; import { sessionsDb } from '@/modules/database/index.js';
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.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 { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
import { getGlobalImageAssetsDir, normalizeImageDescriptors } from '@/shared/image-attachments.js';
import type { import type {
AnyRecord, AnyRecord,
AuthenticatedWebSocketRequest, AuthenticatedWebSocketRequest,
@@ -10,6 +13,37 @@ import type {
} from '@/shared/types.js'; } from '@/shared/types.js';
import { parseIncomingJsonObject } from '@/shared/utils.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, * One provider runtime entry point. All five runtimes share this signature,
* which lets the chat handler dispatch through a provider-keyed map instead * 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. // gateway writer captures and maps back to the app session id.
const runtimeOptions: AnyRecord = { const runtimeOptions: AnyRecord = {
...clientOptions, ...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, sessionId: session.provider_session_id ?? undefined,
resume: Boolean(session.provider_session_id), resume: Boolean(session.provider_session_id),
cwd: clientOptions.cwd ?? session.project_path ?? undefined, cwd: clientOptions.cwd ?? session.project_path ?? undefined,
@@ -175,8 +212,10 @@ async function handleChatSend(
} finally { } finally {
// Safety net: a runtime that crashed (or resolved) without emitting its // Safety net: a runtime that crashed (or resolved) without emitting its
// terminal `complete` would otherwise leave the session stuck in // terminal `complete` would otherwise leave the session stuck in
// "processing" forever on every connected client. // "processing" forever on every connected client. Scoped to THIS run —
chatRunRegistry.completeRun(sessionId, { exitCode: 1 }); // 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 });
} }
} }

View File

@@ -146,14 +146,6 @@ function buildShellCommand(
return 'codex'; return 'codex';
} }
if (provider === 'gemini') {
const command = initialCommand || 'gemini';
if (resumeSessionId) {
return `${command} --resume "${resumeSessionId}"`;
}
return command;
}
if (provider === 'opencode') { if (provider === 'opencode') {
if (resumeSessionId) { if (resumeSessionId) {
return `opencode --session "${resumeSessionId}"`; return `opencode --session "${resumeSessionId}"`;
@@ -477,9 +469,7 @@ export function handleShellConnection(
? 'Cursor' ? 'Cursor'
: provider === 'codex' : provider === 'codex'
? 'Codex' ? 'Codex'
: provider === 'gemini' : provider === 'opencode'
? 'Gemini'
: provider === 'opencode'
? 'OpenCode' ? 'OpenCode'
: 'Claude'; : 'Claude';
welcomeMsg = hasSession && resumeSessionId welcomeMsg = hasSession && resumeSessionId

View File

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

View File

@@ -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 () => { test('listRunningRuns returns only currently running app sessions', async () => {
await withIsolatedDatabase(() => { await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo'); 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 () => { test('attachConnection reroutes the live stream to a new socket', async () => {
await withIsolatedDatabase(() => { await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo'); sessionsDb.createAppSession('app-run-5', 'opencode', '/workspace/demo');
const firstConnection = new FakeConnection(); const firstConnection = new FakeConnection();
const run = chatRunRegistry.startRun({ const run = chatRunRegistry.startRun({
appSessionId: 'app-run-5', appSessionId: 'app-run-5',
provider: 'gemini', provider: 'opencode',
providerSessionId: null, providerSessionId: null,
connection: firstConnection, connection: firstConnection,
userId: null, userId: null,
}); });
assert.ok(run); 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(); const secondConnection = new FakeConnection();
assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true); 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(firstConnection.frames.map((frame) => frame.content), ['before']);
assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']); assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);

View File

@@ -14,6 +14,8 @@
*/ */
import { Codex } from '@openai/codex-sdk'; import { Codex } from '@openai/codex-sdk';
import { buildCodexInputItems, normalizeImageDescriptors } from './shared/image-attachments.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
@@ -228,6 +230,7 @@ export async function queryCodex(command, options = {}, ws) {
projectPath, projectPath,
model, model,
effort, effort,
images,
permissionMode = 'default' permissionMode = 'default'
} = options; } = options;
@@ -288,7 +291,12 @@ export async function queryCodex(command, options = {}, ws) {
registerSession(capturedSessionId); 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 signal: abortController.signal
}); });

View File

@@ -1,19 +1,51 @@
import { spawn } from 'child_process';
import fsSync from 'node:fs'; import fsSync from 'node:fs';
import crossSpawn from 'cross-spawn'; import crossSpawn from 'cross-spawn';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { appendImagesInputTag } from './shared/image-attachments.js';
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.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(); 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) { function resolveOpenCodeEffort(model, effort, modelsDefinition) {
const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model); const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model);
const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || []; const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
@@ -92,7 +124,7 @@ function readOpenCodeTokenUsage(sessionId) {
async function spawnOpenCode(command, options = {}, ws) { async function spawnOpenCode(command, options = {}, ws) {
return new Promise((resolve, reject) => { 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 workingDir = cwd || projectPath || process.cwd();
const processKey = sessionId || Date.now().toString(); const processKey = sessionId || Date.now().toString();
let capturedSessionId = sessionId || null; let capturedSessionId = sessionId || null;
@@ -223,14 +255,20 @@ async function spawnOpenCode(command, options = {}, ws) {
if (resolvedEffort) { if (resolvedEffort) {
args.push('--variant', resolvedEffort); args.push('--variant', resolvedEffort);
} }
const permissionOptions = resolveOpenCodePermissionOptions(permissionMode);
args.push(...permissionOptions.args);
if (command && command.trim()) { if (command && command.trim()) {
args.push(command.trim()); // Image attachments ride along as an <images_input> 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, { opencodeProcess = spawnFunction('opencode', args, {
cwd: workingDir, cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env }, env: { ...process.env, ...permissionOptions.env },
}); });
activeOpenCodeProcesses.set(processKey, opencodeProcess); activeOpenCodeProcesses.set(processKey, opencodeProcess);

View File

@@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { spawnOpenCode } from './opencode-cli.js'; import { resolveOpenCodePermissionOptions, spawnOpenCode } from './opencode-cli.js';
const findEnvKey = (name) => const findEnvKey = (name) =>
Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name; Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name;
@@ -14,7 +14,10 @@ async function createFakeOpenCodeExecutable(binDir) {
await writeFile(scriptPath, ` await writeFile(scriptPath, `
const capturePath = process.env.OPENCODE_ARGS_CAPTURE; const capturePath = process.env.OPENCODE_ARGS_CAPTURE;
if (capturePath) { 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 = [ 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(complete?.sessionId, 'open-live-1');
assert.equal(messages.some((message) => message.kind === 'error'), false); 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.ok(Array.isArray(launchedArgs));
assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']); assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']);
assert.equal(launchedArgs[4], tempRoot); 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 { } finally {
if (previousPath === undefined) { if (previousPath === undefined) {
delete process.env[pathKey]; delete process.env[pathKey];

View File

@@ -1,5 +1,6 @@
import express from 'express'; 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 path from 'path';
import os from 'os'; import os from 'os';
import { promises as fs } from 'fs'; 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 { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js'; import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js'; import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js';
import { spawnOpenCode } from '../opencode-cli.js'; import { spawnOpenCode } from '../opencode-cli.js';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
import { providerModelsService } from '../modules/providers/services/provider-models.service.js'; 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) * - Source for auto-generated branch names (if createBranch=true and no branchName)
* - Fallback for PR title if no commits are made * - Fallback for PR title if no commits are made
* *
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'opencode'
* Default: 'claude' * Default: 'claude'
* *
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates. * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
@@ -648,7 +648,7 @@ class ResponseCollector {
* *
* Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'fable' * 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', * 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', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
* 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants * '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' * Codex models: 'gpt-5.4' (default), 'gpt-5.5', 'gpt-5.4-mini'
@@ -759,7 +759,7 @@ class ResponseCollector {
* Input Validations (400 Bad Request): * Input Validations (400 Bad Request):
* - Either githubUrl OR projectPath must be provided (not neither) * - Either githubUrl OR projectPath must be provided (not neither)
* - message must be non-empty string * - message must be non-empty string
* - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode' * - provider must be 'claude', 'cursor', 'codex', or 'opencode'
* - createBranch/createPR requires githubUrl OR projectPath (not neither) * - createBranch/createPR requires githubUrl OR projectPath (not neither)
* - branchName must pass Git naming rules (if provided) * - branchName must pass Git naming rules (if provided)
* *
@@ -870,8 +870,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'message is required' }); return res.status(400).json({ error: 'message is required' });
} }
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) { if (!['claude', 'cursor', 'codex', 'opencode'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' }); return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "opencode"' });
} }
// Validate GitHub branch/PR creation requirements // Validate GitHub branch/PR creation requirements
@@ -950,7 +950,6 @@ router.post('/', validateExternalApiKey, async (req, res) => {
} }
const codexModels = (await providerModelsService.getProviderModels('codex')).models; const codexModels = (await providerModelsService.getProviderModels('codex')).models;
const geminiModels = (await providerModelsService.getProviderModels('gemini')).models;
const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models; const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models;
// Start the appropriate session // Start the appropriate session
@@ -987,16 +986,6 @@ router.post('/', validateExternalApiKey, async (req, res) => {
effort, effort,
permissionMode: 'bypassPermissions' permissionMode: 'bypassPermissions'
}, writer); }, writer);
} else if (provider === 'gemini') {
console.log('✨ Starting Gemini CLI session');
await spawnGemini(message.trim(), {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model || geminiModels.DEFAULT,
skipPermissions: true // CLI mode bypasses permissions
}, writer);
} else if (provider === 'opencode') { } else if (provider === 'opencode') {
console.log('Starting OpenCode CLI session'); console.log('Starting OpenCode CLI session');
@@ -1005,7 +994,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
cwd: finalProjectPath, cwd: finalProjectPath,
sessionId: sessionId || null, sessionId: sessionId || null,
model: model || opencodeModels.DEFAULT, model: model || opencodeModels.DEFAULT,
effort effort,
permissionMode: 'bypassPermissions' // Agent runs are non-interactive, like the other providers above
}, writer); }, writer);
} }

View File

@@ -15,13 +15,12 @@ const APP_ROOT = findAppRoot(__dirname);
const router = express.Router(); const router = express.Router();
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"]; const MODEL_PROVIDERS = ["claude", "cursor", "codex", "opencode"];
const MODEL_PROVIDER_LABELS = { const MODEL_PROVIDER_LABELS = {
claude: "Claude", claude: "Claude",
cursor: "Cursor", cursor: "Cursor",
codex: "Codex", codex: "Codex",
gemini: "Gemini",
opencode: "OpenCode", opencode: "OpenCode",
}; };

View File

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

View File

@@ -1,5 +1,6 @@
import express from 'express'; 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 path from 'path';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { projectsDb } from '../modules/database/index.js'; import { projectsDb } from '../modules/database/index.js';
@@ -293,6 +294,76 @@ async function resolveRepositoryFilePath(projectPath, filePath) {
} }
// Get git status for a project // 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) => { router.get('/status', async (req, res) => {
const { project } = req.query; const { project } = req.query;
@@ -309,30 +380,8 @@ router.get('/status', async (req, res) => {
const branch = await getCurrentBranchName(projectPath); const branch = await getCurrentBranchName(projectPath);
const hasCommits = await repositoryHasCommits(projectPath); const hasCommits = await repositoryHasCommits(projectPath);
// Get git status const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain=v1', '-z'], { cwd: projectPath });
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath }); const { modified, added, deleted, untracked, staged } = parseGitStatusOutput(statusOutput);
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);
}
});
res.json({ res.json({
branch, branch,
@@ -340,7 +389,8 @@ router.get('/status', async (req, res) => {
modified, modified,
added, added,
deleted, deleted,
untracked untracked,
staged
}); });
} catch (error) { } catch (error) {
console.error('Git status error:', 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) // Revert latest local commit (keeps changes staged)
router.post('/revert-local-commit', async (req, res) => { router.post('/revert-local-commit', async (req, res) => {
const { project } = req.body; 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) => { router.get('/commits', async (req, res) => {
const { project, limit = 10 } = req.query; const { project, limit = 10 } = req.query;
if (!project) { if (!project) {
return res.status(400).json({ error: 'Project id is required' }); 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 const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.min(parsedLimit, 100) ? Math.min(parsedLimit, 100)
: 10; : 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( const { stdout } = await spawnAsync(
'git', '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 }, { cwd: projectPath },
); );
const commits = stdout res.json({ commits: parseGitLogWithStats(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 });
} catch (error) { } catch (error) {
console.error('Git commits error:', error); console.error('Git commits error:', error);
res.json({ error: error.message }); res.json({ error: error.message });

106
server/routes/git.test.js Normal file
View File

@@ -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(''), []);
});

View File

@@ -12,7 +12,9 @@ import express from 'express';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { promises as fsPromises } from 'fs'; 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 { projectsDb } from '../modules/database/index.js';
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js'; import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js'; import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js';

View File

@@ -1,8 +1,9 @@
import express from 'express'; 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 { userDb } from '../modules/database/index.js';
import { authenticateToken } from '../middleware/auth.js'; import { authenticateToken } from '../middleware/auth.js';
import { getSystemGitConfig } from '../utils/gitConfig.js'; import { getSystemGitConfig } from '../utils/gitConfig.js';
import { spawn } from 'child_process';
const router = express.Router(); const router = express.Router();

View File

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

View File

@@ -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
* `<images_input>` 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<string, string> = {
'.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<string, unknown>;
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*<images_input>([\s\S]*?)<\/images_input>\s*/g;
// One image reference recovered from an <images_input> 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 <images_input> 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 `<images_input>` 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,
'',
'<images_input>',
`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,
'</images_input>',
].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 `<images_input>` 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
* `<images_input>` 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('<images_input>')) {
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<ClaudeContentBlock[]> {
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;
}

View File

@@ -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<boolean> {
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('<images_input>'));
assert.ok(tagged.includes('</images_input>'));
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 <images_input> mean in this codebase?';
const tagged = appendImagesInputTag(
`${userTypedTag}\n\n<images_input>\nfake user block\n</images_input>\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<images_input>\nnot json here\n</images_input>';
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'), '<svg></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' }]);
});

View File

@@ -65,7 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
* Use this as the source of truth whenever a function or payload needs to identify * Use this as the source of truth whenever a function or payload needs to identify
* a specific LLM integration. * 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. * One selectable model row in a provider model catalog.

View File

@@ -1066,6 +1066,30 @@ export function getOpenCodeDatabasePath(): string {
return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db'); 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 ------------ //----------------- SAFE DIRECTORY NAME UTILITIES ------------
/** /**
@@ -1239,3 +1263,24 @@ export async function extractFirstValidJsonlData<T>(
return null; 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 `<images_input>` 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();
}

View File

@@ -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) { function spawnAsync(command, args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -1,5 +1,7 @@
import { spawn } from 'child_process';
import path from 'path'; 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'; import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
// Map<pluginName, { process, port }> // Map<pluginName, { process, port }>

View File

@@ -10,6 +10,7 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett
import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useDeviceSettings } from '../../hooks/useDeviceSettings';
import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useSessionProtection } from '../../hooks/useSessionProtection';
import { useProjectsState } from '../../hooks/useProjectsState'; import { useProjectsState } from '../../hooks/useProjectsState';
import { useQueuedMessageAutoSend } from '../../hooks/useQueuedMessageAutoSend';
import { api } from '../../utils/api'; import { api } from '../../utils/api';
type RunningSessionApiItem = { type RunningSessionApiItem = {
@@ -84,6 +85,17 @@ function AppContentInner() {
activeSessions: processingSessions, 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 () => { const refreshRunningSessions = useCallback(async () => {
try { try {
const response = await api.runningSessions(); const response = await api.runningSessions();

View File

@@ -14,7 +14,13 @@ import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection'; import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import { grantClaudeToolPermission } from '../utils/chatPermissions'; import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage'; import {
clearQueuedMessage,
readQueuedMessage,
safeLocalStorage,
writeQueuedMessage,
type QueuedSendOptions,
} from '../utils/chatStorage';
import type { import type {
ChatMessage, ChatMessage,
PendingPermissionRequest, PendingPermissionRequest,
@@ -39,7 +45,6 @@ interface UseChatComposerStateArgs {
claudeModel: string; claudeModel: string;
codexModel: string; codexModel: string;
currentProviderEffort: string; currentProviderEffort: string;
geminiModel: string;
opencodeModel: string; opencodeModel: string;
isLoading: boolean; isLoading: boolean;
canAbortSession: boolean; canAbortSession: boolean;
@@ -145,6 +150,23 @@ const createFakeSubmitEvent = () => {
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>; return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
}; };
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 = ( const getNotificationSessionSummary = (
selectedSession: ProjectSession | null, selectedSession: ProjectSession | null,
fallbackInput: string, fallbackInput: string,
@@ -175,7 +197,6 @@ export function useChatComposerState({
claudeModel, claudeModel,
codexModel, codexModel,
currentProviderEffort, currentProviderEffort,
geminiModel,
opencodeModel, opencodeModel,
isLoading, isLoading,
canAbortSession, canAbortSession,
@@ -215,6 +236,22 @@ export function useChatComposerState({
>(null); >(null);
const inputValueRef = useRef(input); const inputValueRef = useRef(input);
const selectedProjectId = selectedProject?.projectId; 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<QueuedDraft | null>(() => {
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<string | null>(sessionKey);
const handleBuiltInCommand = useCallback( const handleBuiltInCommand = useCallback(
(result: CommandExecutionResult) => { (result: CommandExecutionResult) => {
@@ -336,9 +373,7 @@ export function useChatComposerState({
? cursorModel ? cursorModel
: provider === 'codex' : provider === 'codex'
? codexModel ? codexModel
: provider === 'gemini' : provider === 'opencode'
? geminiModel
: provider === 'opencode'
? opencodeModel ? opencodeModel
: claudeModel, : claudeModel,
tokenUsage: tokenBudget, tokenUsage: tokenBudget,
@@ -393,7 +428,6 @@ export function useChatComposerState({
codexModel, codexModel,
currentSessionId, currentSessionId,
cursorModel, cursorModel,
geminiModel,
opencodeModel, opencodeModel,
handleBuiltInCommand, handleBuiltInCommand,
handleCustomCommand, handleCustomCommand,
@@ -549,13 +583,98 @@ export function useChatComposerState({
noKeyboard: true, 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( const handleSubmit = useCallback(
async ( async (
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>, event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
) => { ) => {
event.preventDefault(); event.preventDefault();
const currentInput = inputValueRef.current; 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; return;
} }
@@ -604,7 +723,7 @@ export function useChatComposerState({
}); });
try { try {
const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, { const response = await authenticatedFetch('/api/assets/images', {
method: 'POST', method: 'POST',
headers: {}, headers: {},
body: formData, body: formData,
@@ -696,46 +815,6 @@ export function useChatComposerState({
setIsUserScrolledUp(false); setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 100); 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 // One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the // provider, project path, and provider-native resume id from the
// session row; `options` only carries composer-level preferences. // session row; `options` only carries composer-level preferences.
@@ -744,12 +823,7 @@ export function useChatComposerState({
sessionId: targetSessionId, sessionId: targetSessionId,
content: messageContent, content: messageContent,
options: { options: {
model, ...buildSendOptions(messageContent),
effort,
permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
toolsSettings,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
images: uploadedImages, images: uploadedImages,
}, },
}); });
@@ -771,24 +845,18 @@ export function useChatComposerState({
[ [
selectedSession, selectedSession,
attachedImages, attachedImages,
claudeModel, buildSendOptions,
codexModel,
currentProviderEffort,
currentSessionId, currentSessionId,
cursorModel,
executeCommand, executeCommand,
geminiModel,
opencodeModel,
isLoading, isLoading,
onSessionProcessing, onSessionProcessing,
onSessionEstablished, onSessionEstablished,
permissionMode,
provider, provider,
resolvePermissionModeForProvider,
resetCommandMenuState, resetCommandMenuState,
scrollToBottom, scrollToBottom,
selectedProject, selectedProject,
sendMessage, sendMessage,
sessionKey,
addMessage, addMessage,
setIsUserScrolledUp, setIsUserScrolledUp,
slashCommands, slashCommands,
@@ -799,6 +867,66 @@ export function useChatComposerState({
handleSubmitRef.current = handleSubmit; handleSubmitRef.current = handleSubmit;
}, [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 // 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 // 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. // inputValueRef synchronously so handleSubmit reads the new text, not the stale state.
@@ -837,6 +965,33 @@ export function useChatComposerState({
} }
}, [input, selectedProjectId]); }, [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(() => { useEffect(() => {
if (!textareaRef.current) { if (!textareaRef.current) {
return; return;
@@ -1044,6 +1199,9 @@ export function useChatComposerState({
isDragActive, isDragActive,
openImagePicker: open, openImagePicker: open,
handleSubmit, handleSubmit,
queuedDraft,
editQueuedDraft,
deleteQueuedDraft,
handleVoiceTranscript, handleVoiceTranscript,
handleInputChange, handleInputChange,
handleKeyDown, handleKeyDown,

View File

@@ -13,6 +13,48 @@ function formatToolResultContent(content: unknown): string {
return toolUseErrorMatch ? toolUseErrorMatch[1] : text; return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
} }
type ParsedTaskNotification = {
status: string;
summary: string;
result: string;
};
/**
* Parses a background-agent `<task-notification>` block.
*
* The harness injects these as user-role messages when a background task stops.
* Newer notifications carry extra fields (`<tool-use-id>`, `<note>`, `<usage>`,
* and a `<result>` 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('<task-notification>')) {
return null;
}
const statusMatch = /<status>([\s\S]*?)<\/status>/.exec(content);
const summaryMatch = /<summary>([\s\S]*?)<\/summary>/.exec(content);
let result = '';
const resultOpen = content.indexOf('<result>');
if (resultOpen !== -1) {
const afterOpen = content.slice(resultOpen + '<result>'.length);
const closeIndex = afterOpen.indexOf('</result>');
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[] * Convert NormalizedMessage[] from the session store into ChatMessage[]
* that the existing UI components expect. * that the existing UI components expect.
@@ -51,26 +93,37 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
switch (msg.kind) { switch (msg.kind) {
case 'text': { case 'text': {
const content = msg.content || ''; 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') { if (msg.role === 'user') {
// Parse task notifications // Parse task notifications
const taskNotifRegex = /<task-notification>\s*<task-id>[^<]*<\/task-id>\s*<output-file>[^<]*<\/output-file>\s*<status>([^<]*)<\/status>\s*<summary>([^<]*)<\/summary>\s*<\/task-notification>/g; const taskNotif = parseTaskNotification(content);
const taskNotifMatch = taskNotifRegex.exec(content); if (taskNotif) {
if (taskNotifMatch) {
converted.push({ converted.push({
type: 'assistant', type: 'assistant',
content: taskNotifMatch[2]?.trim() || 'Background task finished', content: taskNotif.summary,
timestamp: msg.timestamp, timestamp: msg.timestamp,
isTaskNotification: true, isTaskNotification: true,
taskStatus: taskNotifMatch[1]?.trim() || 'completed', taskStatus: taskNotif.status,
...sharedMetadata, ...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 { } else {
converted.push({ converted.push({
type: 'user', type: 'user',
content: unescapeWithMathProtection(decodeHtmlEntities(content)), content: unescapeWithMathProtection(decodeHtmlEntities(content)),
timestamp: msg.timestamp, timestamp: msg.timestamp,
images,
...sharedMetadata, ...sharedMetadata,
}); });
} }

View File

@@ -20,11 +20,17 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
claude: 'default', claude: 'default',
cursor: 'gpt-5.3-codex', cursor: 'gpt-5.3-codex',
codex: 'gpt-5.4', codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5', 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 * Fallback permission-mode matrix used only until the backend capability
@@ -36,8 +42,7 @@ const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'], claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
codex: ['default', 'acceptEdits', 'bypassPermissions'], codex: ['default', 'acceptEdits', 'bypassPermissions'],
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], opencode: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
opencode: ['default'],
}; };
type ProviderCapabilities = { type ProviderCapabilities = {
@@ -85,9 +90,7 @@ type ChangeActiveModelApiResponse = {
export function useChatProviderState({ selectedSession, selectedProject: _selectedProject }: UseChatProviderStateArgs) { export function useChatProviderState({ selectedSession, selectedProject: _selectedProject }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default'); const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]); const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<LLMProvider>(() => { const [provider, setProvider] = useState<LLMProvider>(readStoredProvider);
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
});
const [cursorModel, setCursorModel] = useState<string>(() => { const [cursorModel, setCursorModel] = useState<string>(() => {
return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor; return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor;
}); });
@@ -103,9 +106,6 @@ export function useChatProviderState({ selectedSession, selectedProject: _select
return acc; return acc;
}, {}); }, {});
}); });
const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
});
const [opencodeModel, setOpenCodeModel] = useState<string>(() => { const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode; return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
}); });
@@ -151,12 +151,6 @@ export function useChatProviderState({ selectedSession, selectedProject: _select
return; return;
} }
if (targetProvider === 'gemini') {
setGeminiModel(model);
localStorage.setItem('gemini-model', model);
return;
}
setOpenCodeModel(model); setOpenCodeModel(model);
localStorage.setItem('opencode-model', model); localStorage.setItem('opencode-model', model);
}, []); }, []);
@@ -360,9 +354,8 @@ export function useChatProviderState({ selectedSession, selectedProject: _select
claude: claudeModel, claude: claudeModel,
cursor: cursorModel, cursor: cursorModel,
codex: codexModel, codex: codexModel,
gemini: geminiModel,
opencode: opencodeModel, opencode: opencodeModel,
}), [claudeModel, cursorModel, codexModel, geminiModel, opencodeModel]); }), [claudeModel, cursorModel, codexModel, opencodeModel]);
useEffect(() => { useEffect(() => {
const claude = providerModelCatalog.claude; const claude = providerModelCatalog.claude;
@@ -403,19 +396,6 @@ export function useChatProviderState({ selectedSession, selectedProject: _select
} }
}, [providerModelCatalog.codex, codexModel]); }, [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(() => { useEffect(() => {
const opencode = providerModelCatalog.opencode; const opencode = providerModelCatalog.opencode;
if (opencode) { if (opencode) {
@@ -451,17 +431,19 @@ export function useChatProviderState({ selectedSession, selectedProject: _select
}, [providerEfforts, providerModels, reconcileStoredEffort]); }, [providerEfforts, providerModels, reconcileStoredEffort]);
useEffect(() => { useEffect(() => {
if (!selectedSession?.id) {
return;
}
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
const validModes = getPermissionModesForProvider(provider); const validModes = getPermissionModesForProvider(provider);
setPermissionMode( const sessionSavedMode = selectedSession?.id
savedMode && validModes.includes(savedMode) ? (localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null)
? savedMode : null;
: getDefaultPermissionModeForProvider(provider), // 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]); }, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
useEffect(() => { useEffect(() => {
@@ -511,6 +493,10 @@ export function useChatProviderState({ selectedSession, selectedProject: _select
const nextMode = modes[nextIndex]; const nextMode = modes[nextIndex];
setPermissionMode(nextMode); 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) { if (selectedSession?.id) {
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode); localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
} }
@@ -583,8 +569,6 @@ export function useChatProviderState({ selectedSession, selectedProject: _select
setCodexModel, setCodexModel,
currentProviderEffort, currentProviderEffort,
currentProviderEffortOptions, currentProviderEffortOptions,
geminiModel,
setGeminiModel,
opencodeModel, opencodeModel,
setOpenCodeModel, setOpenCodeModel,
permissionMode, permissionMode,

View File

@@ -83,6 +83,9 @@ function chatMessageToNormalized(
kind: 'text', kind: 'text',
role: msg.type === 'user' ? 'user' : 'assistant', role: msg.type === 'user' ? 'user' : 'assistant',
content: msg.content || '', 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; } as NormalizedMessage;
} }

View File

@@ -10,8 +10,12 @@ export type Provider = LLMProvider;
export type PermissionMode = 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan'; export type PermissionMode = 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan';
export interface ChatImage { export interface ChatImage {
data: string; /** Inline data URL (Claude history stores attachments as base64). */
name: string; data?: string;
/** Project-relative path under `.cloudcli/assets` served via the files API. */
path?: string;
name?: string;
mimeType?: string;
} }
export interface ToolResult { export interface ToolResult {

View File

@@ -11,7 +11,7 @@ export const safeLocalStorage = {
console.warn('localStorage quota exceeded, clearing old data'); console.warn('localStorage quota exceeded, clearing old data');
const keys = Object.keys(localStorage); 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) => { draftKeys.forEach((k) => {
localStorage.removeItem(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<string, unknown>;
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 { export function getClaudeSettings(): ClaudeSettings {
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY); const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
if (!raw) { if (!raw) {

View File

@@ -71,8 +71,6 @@ function ChatInterface({
setCodexModel, setCodexModel,
currentProviderEffort, currentProviderEffort,
currentProviderEffortOptions, currentProviderEffortOptions,
geminiModel,
setGeminiModel,
opencodeModel, opencodeModel,
setOpenCodeModel, setOpenCodeModel,
permissionMode, permissionMode,
@@ -174,6 +172,9 @@ function ChatInterface({
isDragActive, isDragActive,
openImagePicker, openImagePicker,
handleSubmit, handleSubmit,
queuedDraft,
editQueuedDraft,
deleteQueuedDraft,
handleVoiceTranscript, handleVoiceTranscript,
handleInputChange, handleInputChange,
handleKeyDown, handleKeyDown,
@@ -201,7 +202,6 @@ function ChatInterface({
claudeModel, claudeModel,
codexModel, codexModel,
currentProviderEffort, currentProviderEffort,
geminiModel,
opencodeModel, opencodeModel,
isLoading: isProcessing, isLoading: isProcessing,
canAbortSession, canAbortSession,
@@ -286,15 +286,18 @@ function ChatInterface({
handlePermissionDecision, handlePermissionDecision,
}), [pendingPermissionRequests, 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) { if (!selectedProject) {
const selectedProviderLabel = const selectedProviderLabel =
provider === 'cursor' provider === 'cursor'
? t('messageTypes.cursor') ? t('messageTypes.cursor')
: provider === 'codex' : provider === 'codex'
? t('messageTypes.codex') ? t('messageTypes.codex')
: provider === 'gemini' : provider === 'opencode'
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) ? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'); : t('messageTypes.claude');
@@ -321,6 +324,7 @@ function ChatInterface({
onTouchMove={handleScroll} onTouchMove={handleScroll}
isLoadingSessionMessages={isLoadingSessionMessages} isLoadingSessionMessages={isLoadingSessionMessages}
isProcessing={isProcessing} isProcessing={isProcessing}
hasActivityIndicator={hasActivityIndicator}
chatMessages={chatMessages} chatMessages={chatMessages}
selectedSession={selectedSession} selectedSession={selectedSession}
currentSessionId={currentSessionId} currentSessionId={currentSessionId}
@@ -333,8 +337,6 @@ function ChatInterface({
setCursorModel={setCursorModel} setCursorModel={setCursorModel}
codexModel={codexModel} codexModel={codexModel}
setCodexModel={setCodexModel} setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel} opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel} setOpenCodeModel={setOpenCodeModel}
providerModelCatalog={providerModelCatalog} providerModelCatalog={providerModelCatalog}
@@ -399,6 +401,9 @@ function ChatInterface({
onClearInput={handleClearInput} onClearInput={handleClearInput}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isDragActive={isDragActive} isDragActive={isDragActive}
queuedDraft={queuedDraft}
onEditQueuedDraft={editQueuedDraft}
onDeleteQueuedDraft={deleteQueuedDraft}
attachedImages={attachedImages} attachedImages={attachedImages}
onRemoveImage={(index) => onRemoveImage={(index) =>
setAttachedImages((previous) => setAttachedImages((previous) =>
@@ -439,9 +444,7 @@ function ChatInterface({
? t('messageTypes.cursor') ? t('messageTypes.cursor')
: provider === 'codex' : provider === 'codex'
? t('messageTypes.codex') ? t('messageTypes.codex')
: provider === 'gemini' : provider === 'opencode'
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) ? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'), : t('messageTypes.claude'),
})} })}

View File

@@ -11,10 +11,11 @@ import type {
RefObject, RefObject,
TouchEvent, TouchEvent,
} from 'react'; } 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 { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
import type { QueuedDraft } from '../../hooks/useChatComposerState';
import type { SessionActivity } from '../../../../hooks/useSessionProtection'; import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types'; import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import type { ProviderModelOption } from '../../../../types/app'; import type { ProviderModelOption } from '../../../../types/app';
@@ -35,6 +36,7 @@ import ImageAttachment from './ImageAttachment';
import VoiceInputButton from './VoiceInputButton'; import VoiceInputButton from './VoiceInputButton';
import PermissionRequestsBanner from './PermissionRequestsBanner'; import PermissionRequestsBanner from './PermissionRequestsBanner';
import TokenUsageSummary from './TokenUsageSummary'; import TokenUsageSummary from './TokenUsageSummary';
import QueuedMessageCard from './QueuedMessageCard';
interface MentionableFile { interface MentionableFile {
name: string; name: string;
@@ -74,6 +76,9 @@ interface ChatComposerProps {
onClearInput: () => void; onClearInput: () => void;
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void; onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
isDragActive: boolean; isDragActive: boolean;
queuedDraft: QueuedDraft | null;
onEditQueuedDraft: () => void;
onDeleteQueuedDraft: () => void;
attachedImages: File[]; attachedImages: File[];
onRemoveImage: (index: number) => void; onRemoveImage: (index: number) => void;
uploadingImages: Map<string, number>; uploadingImages: Map<string, number>;
@@ -129,6 +134,9 @@ export default function ChatComposer({
onClearInput, onClearInput,
onSubmit, onSubmit,
isDragActive, isDragActive,
queuedDraft,
onEditQueuedDraft,
onDeleteQueuedDraft,
attachedImages, attachedImages,
onRemoveImage, onRemoveImage,
uploadingImages, uploadingImages,
@@ -267,6 +275,23 @@ export default function ChatComposer({
const hasPendingPermissions = pendingPermissionRequests.length > 0; const hasPendingPermissions = pendingPermissionRequests.length > 0;
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions); 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 ( return (
<div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6"> <div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
{!hasPendingPermissions && ( {!hasPendingPermissions && (
@@ -285,6 +310,15 @@ export default function ChatComposer({
</div> </div>
)} )}
{queuedDraft && (
<QueuedMessageCard
content={queuedDraft.content}
imageCount={queuedDraft.images.length}
onEdit={onEditQueuedDraft}
onDelete={onDeleteQueuedDraft}
/>
)}
{!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]"> {!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]">
{showFileDropdown && filteredFiles.length > 0 && ( {showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md"> <div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
@@ -540,26 +574,37 @@ export default function ChatComposer({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${ className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${
input.trim() ? 'opacity-0' : 'opacity-100' input.trim() && !canQueueDraft ? 'opacity-0' : 'opacity-100'
}`} }`}
> >
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')} {submitHint}
</div> </div>
<PromptInputSubmit <PromptInputSubmit
onClick={ onClick={
isLoading canQueueDraft
? onAbortSession ? (e: MouseEvent<HTMLButtonElement>) => {
: isRecording e.preventDefault();
? (e: MouseEvent<HTMLButtonElement>) => { onSubmit(e);
e.preventDefault(); }
voiceStop({ send: true }); : isLoading
} ? onAbortSession
: undefined : isRecording
? (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
voiceStop({ send: true });
}
: undefined
} }
disabled={isLoading ? false : isRecording ? false : isTranscribing ? true : !input.trim()} 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" className="h-10 w-10 sm:h-10 sm:w-10"
> >
{isTranscribing ? <Loader2 className="h-4 w-4 animate-spin" /> : undefined} {isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : canQueueDraft ? (
<ArrowUpIcon className="h-4 w-4" />
) : undefined}
</PromptInputSubmit> </PromptInputSubmit>
</div> </div>
</PromptInputFooter> </PromptInputFooter>

View File

@@ -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 <img src>
* 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<string | null>(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(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={alt}
>
<button
type="button"
onClick={onClose}
aria-label="Close image preview"
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
>
<X className="h-5 w-5" />
</button>
<img
src={src}
alt={alt}
onClick={(event) => event.stopPropagation()}
className="max-h-[90vh] max-w-[92vw] rounded-lg object-contain shadow-2xl"
/>
</div>,
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 (
<div className="flex h-28 w-28 items-center justify-center rounded-xl border border-border/50 bg-muted px-2 text-center text-[10px] text-muted-foreground">
{alt}
</div>
);
}
if (!src) {
return <div className="h-28 w-28 animate-pulse rounded-xl border border-border/50 bg-muted" />;
}
return (
<>
<button
type="button"
onClick={() => setExpanded(true)}
aria-label={`Expand ${alt}`}
className="block overflow-hidden rounded-xl border border-border/50 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/60"
>
<img
src={src}
alt={alt}
className="h-28 w-28 cursor-zoom-in object-cover transition-transform duration-200 hover:scale-105"
/>
</button>
{expanded && <ImageLightbox src={src} alt={alt} onClose={() => 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 (
<div className="flex flex-wrap justify-end gap-2">
{images.map((image, index) => (
<ChatMessageImage key={image.path || image.name || index} image={image} projectId={projectId} />
))}
</div>
);
}

View File

@@ -24,6 +24,8 @@ interface ChatMessagesPaneProps {
isLoadingSessionMessages: boolean; isLoadingSessionMessages: boolean;
/** True while the viewed session has an active provider run in flight. */ /** True while the viewed session has an active provider run in flight. */
isProcessing?: boolean; isProcessing?: boolean;
/** True while ChatComposer's floating activity/stop tab is rendered above the input. */
hasActivityIndicator?: boolean;
chatMessages: ChatMessage[]; chatMessages: ChatMessage[];
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
currentSessionId: string | null; currentSessionId: string | null;
@@ -36,8 +38,6 @@ interface ChatMessagesPaneProps {
setCursorModel: (model: string) => void; setCursorModel: (model: string) => void;
codexModel: string; codexModel: string;
setCodexModel: (model: string) => void; setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
opencodeModel: string; opencodeModel: string;
setOpenCodeModel: (model: string) => void; setOpenCodeModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>; providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
@@ -73,6 +73,7 @@ function ChatMessagesPane({
onTouchMove, onTouchMove,
isLoadingSessionMessages, isLoadingSessionMessages,
isProcessing = false, isProcessing = false,
hasActivityIndicator = false,
chatMessages, chatMessages,
selectedSession, selectedSession,
currentSessionId, currentSessionId,
@@ -85,8 +86,6 @@ function ChatMessagesPane({
setCursorModel, setCursorModel,
codexModel, codexModel,
setCodexModel, setCodexModel,
geminiModel,
setGeminiModel,
opencodeModel, opencodeModel,
setOpenCodeModel, setOpenCodeModel,
providerModelCatalog, providerModelCatalog,
@@ -161,7 +160,9 @@ function ChatMessagesPane({
ref={scrollContainerRef} ref={scrollContainerRef}
onWheel={onWheel} onWheel={onWheel}
onTouchMove={onTouchMove} 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'
}`}
> >
<div className="mx-auto w-full max-w-[54.25rem] space-y-3 px-4 sm:space-y-4"> <div className="mx-auto w-full max-w-[54.25rem] space-y-3 px-4 sm:space-y-4">
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? ( {(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
@@ -184,8 +185,6 @@ function ChatMessagesPane({
setCursorModel={setCursorModel} setCursorModel={setCursorModel}
codexModel={codexModel} codexModel={codexModel}
setCodexModel={setCodexModel} setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel} opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel} setOpenCodeModel={setOpenCodeModel}
providerModelCatalog={providerModelCatalog} providerModelCatalog={providerModelCatalog}

View File

@@ -61,7 +61,6 @@ const PROVIDER_LABELS: Record<string, string> = {
claude: 'Claude', claude: 'Claude',
cursor: 'Cursor', cursor: 'Cursor',
codex: 'Codex', codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode', opencode: 'OpenCode',
}; };

View File

@@ -18,14 +18,16 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
return ( return (
<div className="group relative"> <div className="group relative">
<img src={preview} alt={file.name} className="h-20 w-20 rounded object-cover" /> <div className="overflow-hidden rounded-xl border border-border/50 shadow-sm">
<img src={preview} alt={file.name} className="h-20 w-20 object-cover" />
</div>
{uploadProgress !== undefined && uploadProgress < 100 && ( {uploadProgress !== undefined && uploadProgress < 100 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50"> <div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/50">
<div className="text-xs text-white">{uploadProgress}%</div> <div className="text-xs text-white">{uploadProgress}%</div>
</div> </div>
)} )}
{error && ( {error && (
<div className="absolute inset-0 flex items-center justify-center bg-red-500/50"> <div className="absolute inset-0 flex items-center justify-center rounded-xl bg-red-500/50">
<svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
@@ -34,7 +36,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
className="absolute -right-2 -top-2 rounded-full bg-red-500 p-1 text-white opacity-100 transition-opacity focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100" className="absolute -right-1.5 -top-1.5 rounded-full border border-border/40 bg-background/90 p-1 text-foreground shadow-sm backdrop-blur transition-opacity hover:bg-background focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
aria-label="Remove image" aria-label="Remove image"
> >
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -13,6 +13,7 @@ import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools'; import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui'; import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import ChatMessageImages from './ChatMessageImages';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl'; import MessageCopyControl from './MessageCopyControl';
import MessageSpeakControl from './MessageSpeakControl'; import MessageSpeakControl from './MessageSpeakControl';
@@ -84,31 +85,33 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`} className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
> >
{message.type === 'user' ? ( {message.type === 'user' ? (
/* User message bubble on the right */ /* User turn on the right: claude.ai-style attachment cards above the bubble */
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl"> <div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4"> <div className="flex min-w-0 flex-1 flex-col items-end gap-2 sm:flex-initial">
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
{message.content}
</div>
{message.images && message.images.length > 0 && ( {message.images && message.images.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2"> <ChatMessageImages
{message.images.map((img, idx) => ( images={message.images}
<img projectId={selectedProject?.projectId}
key={img.name || idx} />
src={img.data} )}
alt={img.name} {userCopyContent.trim().length > 0 || !message.images?.length ? (
className="h-auto max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90" <div className="group max-w-full rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:px-4">
onClick={() => window.open(img.data, '_blank')} <div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
/> {message.content}
))} </div>
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
{shouldShowUserCopyControl && (
<MessageCopyControl content={userCopyContent} messageType="user" />
)}
<span>{formattedTime}</span>
</div>
</div>
) : (
/* Image-only turn: no text bubble, but the timestamp still shows */
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<span>{formattedTime}</span>
</div> </div>
)} )}
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
{shouldShowUserCopyControl && (
<MessageCopyControl content={userCopyContent} messageType="user" />
)}
<span>{formattedTime}</span>
</div>
</div> </div>
{!isGrouped && ( {!isGrouped && (
<div className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm text-white sm:flex"> <div className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm text-white sm:flex">
@@ -151,9 +154,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
? t('messageTypes.cursor') ? t('messageTypes.cursor')
: provider === 'codex' : provider === 'codex'
? t('messageTypes.codex') ? t('messageTypes.codex')
: provider === 'gemini' : provider === 'opencode'
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) ? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'))} : t('messageTypes.claude'))}
</div> </div>

Some files were not shown because too many files have changed in this diff Show More