Compare commits

...

33 Commits

Author SHA1 Message Date
viper151
bc52d9ea28 Merge branch 'main' into feat/notifications 2026-03-02 16:04:55 +01:00
Menny Even Danan
503c384685 chore: add Gemini-CLI support to README (#453) 2026-03-02 10:56:36 +03:00
louis-thorp-datacom
506d43144b fix(claude): move model usage log to result message only (#454)
The modelUsage debug log ran on every streamed SDK message, but
modelUsage is only populated on result messages. This produced
repeated "Model was sent using: []" console output for every
non-result message during streaming.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:49:06 +03:00
Xì Gà
9e22f42a3d feat: update document title based on selected project (#448)
Show dynamic browser tab title based on selected project's name, post-fixed with "CloudCLI UI" when a project is selected, improving navigation across tabs.
2026-02-27 18:51:26 +03:00
Xì Gà
9c0e864532 fix(claude): correct project encoded path (#451)
fix wrong regex replace of Claude project path

related #447, reopen due to forced-push

to reproduce error steps, let's try

Create a folder with @ in name like @test
Add this folder as new project in CloudCLI
Choose Claude tool in new Session
Star by typing sth 'hi'
In the dev tools, you will see errors ajax response said that session does not find for 'some-session-id'

The main problem is current encode path doesn't encode '@' to '-' as Claude did
I reversed code Claude-SDK, file 'cli.js' to find exactly regex (using in PR) that used to encode path under ~/.claude/projects/<encoded-project-name-path/<session-id>.jsonl
2026-02-27 18:46:23 +03:00
simosmik
d19b1e949f Release 1.21.0 2026-02-27 15:41:20 +00:00
Xì Gà
b359c51527 feat: add copy icon for user messages (#449)
* feat: add copy icon for user messages

Expose a copy control on user chat bubbles so previous content can be reused quickly.

* fix: Copy control is effectively hidden on touch devices

* fix: copyTextToClipboard doesn't need timer

---------

Co-authored-by: dev <dev@host.local>
Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
2026-02-27 18:28:10 +03:00
simosmik
8339b8e624 Merge branch 'main' into feat/notifications
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:47:27 +00:00
simosmik
061f0fd297 feat: introduce notification system and claude notifications 2026-02-27 14:44:44 +00:00
Menny Even Danan
a367edd515 feat: Google's gemini-cli integration (#422)
* feat: integrate Gemini AI agent provider

- Core Backend: Ported gemini-cli.js and gemini-response-handler.js to establish the CLI bridge. Registered 'gemini' as an active provider within index.js.
- Core Frontend: Extended QuickSettingsPanel.jsx, Settings.jsx, and AgentListItem.jsx to render the Gemini provider option, models (gemini-pro, gemini-flash, etc.), and handle OAuth states.
- WebSocket Pipeline: Added support for gemini-command executions in backend and payload processing of gemini-response and gemini-error streams in useChatRealtimeHandlers.ts. Resolved JSON double-stringification and sessionId stripping issues in the transmission handler.
- Platform Compatibility: Added scripts/fix-node-pty.js postinstall script and modified posix_spawnp calls with sh -c wrapper to prevent ENOEXEC and MacOS permission errors when spawning the gemini headless binary.
- UX & Design: Imported official Google Gemini branding via GeminiLogo.jsx and gemini-ai-icon.svg. Updated translations (chat.json) for en, zh-CN, and ko locales.

* fix: propagate gemini permission mode from settings to cli

- Added Gemini Permissions UI in Settings to toggle Auto Edit and YOLO modes
- Synced gemini permission mode to localStorage
- Passed permissionMode in useChatComposerState for Gemini commands
- Mapped frontend permission modes to --yolo and --approval-mode options in gemini-cli.js

* feat(gemini): Refactor Gemini CLI integration to use stream-json

- Replaced regex buffering text-system with NDJSON stream parsing
- Added fallback for restricted models like gemini-3.1-pro-preview

* feat(gemini): Render tool_use and tool_result UI bubbles

- Forwarded gemini tool NDJSON objects to the websocket
- Added React state handlers in useChatRealtimeHandlers to match Claude's tool UI behavior

* feat(gemini): Add native session resumption and UI token tracking

- Captured cliSessionId from init events to map ClaudeCodeUI's chat sessionId directly into Gemini's internal session manager.
- Updated gemini-cli.js spawn arguments to append the --resume proxy flag instead of naively dumping the accumulated chat history into the command prompt.
- Handled result stream objects by proxying total_tokens back into the frontend's claude-status tracker to natively populate the UI label.
- Eliminated gemini-3 model proxy filter entirely.

* fix(gemini): Fix static 'Claude' name rendering in chat UI header

- Added "gemini": "Gemini" translation strings to messageTypes across English, Korean, and Chinese loc dictionaries.
- Updated AssistantThinkingIndicator and MessageComponent ternary checks to identify provider === 'gemini' and render the appropriate brand label instead of statically defaulting to Claude.

* feat: Add Gemini session persistence API mapping and Sidebar UI

* fix(gemini): Watch ~/.gemini/sessions for live UI updates

Added the .gemini/sessions directory to PROVIDER_WATCH_PATHS so that Chokidar emits projects_updated websocket events when new Gemini sessions are created or modified, fixing live sidebar updates.

* fix(gemini): Fix Gemini authentication status display in Settings UI

- Injected 'checkGeminiAuthStatus' into the Settings.jsx React effect hook so that the UI can poll and render the 'geminiAuthStatus' state.
- Updated 'checkGeminiCredentials()' inside server/routes/cli-auth.js to read from '~/.gemini/oauth_creds.json' and '~/.gemini/google_accounts.json', resolving the email address correctly.

* Use logo-only icon for gemini

* feat(gemini): Add Gemini 3 preview models to UI selection list

* Fix Gemini CLI session resume bug and PR #422 review nitpicks

* Fix Gemini tool calls disappearing from UI after completion

* fix(gemini): resolve outstanding PR #422 feedback and stabilize gemini CLI timeouts

* fix(gemini): resolve resume flag and shell session initialization issues

This commit addresses the remaining PR comments for the Gemini CLI integration:

- Moves the `--resume` flag logic outside the prompt command block, ensuring Gemini sessions correctly resume even when a new prompt isn't passed.

- Updates `handleShellConnection` to correctly lookup the native `cliSessionId` from the internal `sessionId` when spawning Gemini sessions in a plain shell.

- Refactors dynamic import of `sessionManager.js` back to a native static import for code consistency.

* chore: fix TypeScript errors and remove gemini CLI dependency

* fix: use cross-spawn on Windows to resolve gemini.cmd correctly

---------

Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
2026-02-27 17:36:35 +03:00
Haileyesus
917c353115 chore: upgrade @anthropic-ai/claude-agent-sdk to version 0.2.59 and add model usage logging (#446) 2026-02-26 20:32:31 +01:00
Haileyesus
4ab94fce42 chore: upgrade better-sqlite to latest version to support node 25 (#445)
Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: viper151 <simosmik@gmail.com>
2026-02-26 15:45:25 +03:00
PaloSP
e3b689214f feat: persist active tab across reloads via localStorage (#414)
* feat: persist active tab across reloads via localStorage (closes #387)

Remember the last active tab in localStorage instead of always resetting
to 'chat'. Also stop force-switching to chat tab on session change,
so users stay on their preferred tab (shell, git, etc.).

* fix: validate localStorage tab value and add try-catch for restricted contexts

Address CodeRabbit feedback: validate stored activeTab against known
tab IDs before using it, and wrap localStorage access in try-catch
to prevent crashes in restricted environments.
2026-02-26 14:46:01 +03:00
viper151
1f903baf2c Update README with Trendshift badge and language options
Added a Trendshift badge and language links to the README.
2026-02-25 17:12:31 +01:00
Haileyesus
5e3a7b69d7 Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402) 2026-02-25 17:07:07 +01:00
Matthew Lloyd
23801e9cc1 fix: add support for Codex in the shell (#424)
* fix: add support for Codex in the shell

* fix: harden codex shell session resume command
2026-02-23 23:36:58 +01:00
simosmik
4f6ff9260d Release 1.20.1 2026-02-23 22:23:33 +00:00
viper151
49061bc7a3 Update DEFAULT model version to gpt-5.3-codex (#426) 2026-02-23 23:13:50 +01:00
simosmik
50e097d4ac feat: migrate legacy database to new location and improve last login update handling 2026-02-23 22:12:00 +00:00
simosmik
f986004319 feat: implement install mode detection and update commands in version upgrade process 2026-02-23 21:55:53 +00:00
simosmik
f488a346ef Release 1.19.1 2026-02-23 21:29:06 +00:00
simosmik
82efac4704 fix: add prepublishOnly script to build before publishing 2026-02-23 21:27:45 +00:00
viper151
81697d0e73 Update DEFAULT model version to gpt-5.3-codex (#417) 2026-02-23 16:51:29 +01:00
simosmik
27bf09b0c1 Release 1.19.0 2026-02-23 11:56:33 +00:00
Haileyesus
7ccbc8d92d chore: update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json (#410) 2026-02-23 07:09:07 +01:00
Vadim Trunov
597e9c54b7 fix: slash commands with arguments bypass command execution (#392)
* fix: intercept slash commands in handleSubmit to pass arguments correctly

When a user types a slash command with arguments (e.g. `/feature implement dark mode`)
and presses Enter, the command was not being intercepted as a slash command. Instead,
the raw text was sent as a regular message to the Claude API, which responded with
"Unknown skill: feature".

Root cause: the command autocomplete menu (useSlashCommands) detects commands via the
regex `/(^|\s)\/(\S*)$/` which only matches when the cursor is right after the command
name with no spaces. As soon as the user types a space to add arguments, the pattern
stops matching, the menu closes, and pressing Enter falls through to handleSubmit which
sends the text as a plain message — completely bypassing command execution.

This fix adds a slash command interceptor at the top of handleSubmit:
- Checks if the trimmed input starts with `/`
- Extracts the command name (text before the first space)
- Looks up the command in the loaded slashCommands list
- If found, delegates to executeCommand() which properly extracts arguments
  via regex and sends them to the backend /api/commands/execute endpoint
- The backend then replaces $ARGUMENTS, $1, $2 etc. in the command template

Changes:
- Added `slashCommands` to the destructured return of useSlashCommands hook
- Added slash command interception logic in handleSubmit before message dispatch
- Added `executeCommand` and `slashCommands` to handleSubmit dependency array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review — pass rawInput param and cleanup UI state

- Pass trimmedInput to executeCommand to avoid stale closure reads
- Add UI cleanup (images, command menu, textarea) before early return
- Update executeCommand signature to accept optional rawInput parameter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:25:02 +03:00
mjfork
cccd915c33 feat: add HOST environment variable for configurable bind address (#360)
* feat: add HOST environment variable for configurable bind address

Allow users to specify which host/IP address the servers should bind to
via the HOST environment variable. Defaults to 0.0.0.0 (all interfaces)
to maintain backward compatibility.

This enables users to restrict the server to localhost only (127.0.0.1)
for security purposes or bind to a specific network interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use correct proxy host when HOST is set to specific interface

When HOST is set to a specific interface (e.g., 192.168.1.100), the Vite
proxy needs to connect to that same host, not localhost. This fix:

- Adds proxyHost logic that uses localhost when HOST=0.0.0.0, otherwise
  uses the specific HOST value for proxy targets
- Adds DISPLAY_HOST to show a user-friendly URL in console output
  (0.0.0.0 isn't a connectable address)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Michael Fork <mjfork@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
2026-02-20 21:23:10 +03:00
viper151
0207a1f3a3 Feat: subagent tool grouping (#398)
* fix(mobile): prevent bottom padding removal on input focus

* fix: change subagent rendering

* fix: subagent task name
2026-02-19 17:32:45 +01:00
Feraudet Cyril
38a593c97f fix(macos): fix node-pty posix_spawnp error with postinstall script (#347)
* fix(macos): fix node-pty posix_spawnp error with postinstall script

Add postinstall script that fixes spawn-helper permissions on macOS.
The node-pty package ships spawn-helper without execute permissions (644),
causing "posix_spawnp failed" errors when spawning terminal processes.

Closes #284
2026-02-18 12:21:00 +01:00
simosmik
fc369d047e refactor(releases): Create a contributing guide and proper release notes using a release-it plugin 2026-02-18 09:45:14 +00:00
simosmik
9d8e92b5a4 Release 1.18.2 2026-02-18 07:35:25 +00:00
simosmik
07f1d9a4a8 fix: pwa mode and mobile safe area padding 2026-02-18 07:32:39 +00:00
simosmik
e853d29584 feat: add japanese readme 2026-02-17 22:01:06 +00:00
221 changed files with 17033 additions and 9576 deletions

View File

@@ -21,6 +21,10 @@ PORT=3001
#Frontend port
VITE_PORT=5173
# Host/IP to bind servers to (default: 0.0.0.0 for all interfaces)
# Use 127.0.0.1 to restrict to localhost only
HOST=0.0.0.0
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
# CLAUDE_CLI_PATH=claude

View File

@@ -2,20 +2,39 @@
"git": {
"commitMessage": "Release ${version}",
"tagName": "v${version}",
"changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}"
"requireBranch": "main",
"requireCleanWorkingDir": true
},
"npm": {
"publish": true
},
"github": {
"release": true,
"releaseName": "CloudCLI UI v${version}",
"releaseNotes": {
"commit": "* ${commit.subject} (${sha}){ - thanks @${author.login}!}",
"excludeMatches": ["viper151"]
}
"releaseName": "CloudCLI UI v${version}"
},
"hooks": {
"before:init": ["npm run build"]
},
"plugins": {
"@release-it/conventional-changelog": {
"infile": "CHANGELOG.md",
"header": "# Changelog\n\nAll notable changes to CloudCLI UI will be documented in this file.\n",
"preset": {
"name": "conventionalcommits",
"types": [
{ "type": "feat", "section": "New Features" },
{ "type": "feature", "section": "New Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "refactor", "section": "Refactoring" },
{ "type": "docs", "section": "Documentation" },
{ "type": "style", "section": "Styling" },
{ "type": "chore", "section": "Maintenance" },
{ "type": "ci", "section": "CI/CD" },
{ "type": "test", "section": "Tests" },
{ "type": "build", "section": "Build" }
]
}
}
}
}
}

54
CHANGELOG.md Normal file
View File

@@ -0,0 +1,54 @@
# Changelog
All notable changes to CloudCLI UI will be documented in this file.
## [1.21.0](https://github.com/siteboon/claudecodeui/compare/v1.20.1...v1.21.0) (2026-02-27)
### New Features
* add copy icon for user messages ([#449](https://github.com/siteboon/claudecodeui/issues/449)) ([b359c51](https://github.com/siteboon/claudecodeui/commit/b359c515277b4266fde2fb9a29b5356949c07c4f))
* Google's gemini-cli integration ([#422](https://github.com/siteboon/claudecodeui/issues/422)) ([a367edd](https://github.com/siteboon/claudecodeui/commit/a367edd51578608b3281373cb4a95169dbf17f89))
* persist active tab across reloads via localStorage ([#414](https://github.com/siteboon/claudecodeui/issues/414)) ([e3b6892](https://github.com/siteboon/claudecodeui/commit/e3b689214f11d549ffe1b3a347476d58f25c5aca)), closes [#387](https://github.com/siteboon/claudecodeui/issues/387)
### Bug Fixes
* add support for Codex in the shell ([#424](https://github.com/siteboon/claudecodeui/issues/424)) ([23801e9](https://github.com/siteboon/claudecodeui/commit/23801e9cc15d2b8d1bfc6e39aee2fae93226d1ad))
### Maintenance
* upgrade @anthropic-ai/claude-agent-sdk to version 0.2.59 and add model usage logging ([#446](https://github.com/siteboon/claudecodeui/issues/446)) ([917c353](https://github.com/siteboon/claudecodeui/commit/917c353115653ee288bf97be01f62fad24123cbc))
* upgrade better-sqlite to latest version to support node 25 ([#445](https://github.com/siteboon/claudecodeui/issues/445)) ([4ab94fc](https://github.com/siteboon/claudecodeui/commit/4ab94fce4257e1e20370fa83fa4c0f6fadbb8a2b))
## [1.20.1](https://github.com/siteboon/claudecodeui/compare/v1.19.1...v1.20.1) (2026-02-23)
### New Features
* implement install mode detection and update commands in version upgrade process ([f986004](https://github.com/siteboon/claudecodeui/commit/f986004319207b068431f9f6adf338a8ce8decfc))
* migrate legacy database to new location and improve last login update handling ([50e097d](https://github.com/siteboon/claudecodeui/commit/50e097d4ac498aa9f1803ef3564843721833dc19))
## [1.19.1](https://github.com/siteboon/claudecodeui/compare/v1.19.0...v1.19.1) (2026-02-23)
### Bug Fixes
* add prepublishOnly script to build before publishing ([82efac4](https://github.com/siteboon/claudecodeui/commit/82efac4704cab11ed8d1a05fe84f41312140b223))
## [1.19.0](https://github.com/siteboon/claudecodeui/compare/v1.18.2...v1.19.0) (2026-02-23)
### New Features
* add HOST environment variable for configurable bind address ([#360](https://github.com/siteboon/claudecodeui/issues/360)) ([cccd915](https://github.com/siteboon/claudecodeui/commit/cccd915c336192216b6e6f68e2b5f3ece0ccf966))
* subagent tool grouping ([#398](https://github.com/siteboon/claudecodeui/issues/398)) ([0207a1f](https://github.com/siteboon/claudecodeui/commit/0207a1f3a3c87f1c6c1aee8213be999b23289386))
### Bug Fixes
* **macos:** fix node-pty posix_spawnp error with postinstall script ([#347](https://github.com/siteboon/claudecodeui/issues/347)) ([38a593c](https://github.com/siteboon/claudecodeui/commit/38a593c97fdb2bb7f051e09e8e99c16035448655)), closes [#284](https://github.com/siteboon/claudecodeui/issues/284)
* slash commands with arguments bypass command execution ([#392](https://github.com/siteboon/claudecodeui/issues/392)) ([597e9c5](https://github.com/siteboon/claudecodeui/commit/597e9c54b76e7c6cd1947299c668c78d24019cab))
### Refactoring
* **releases:** Create a contributing guide and proper release notes using a release-it plugin ([fc369d0](https://github.com/siteboon/claudecodeui/commit/fc369d047e13cba9443fe36c0b6bb2ce3beaf61c))
### Maintenance
* update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json ([#410](https://github.com/siteboon/claudecodeui/issues/410)) ([7ccbc8d](https://github.com/siteboon/claudecodeui/commit/7ccbc8d92d440e18c157b656c9ea2635044a64f6))

156
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,156 @@
# Contributing to CloudCLI UI
Thanks for your interest in contributing to CloudCLI UI! Before you start, please take a moment to read through this guide.
## Before You Start
- **Search first.** Check [existing issues](https://github.com/siteboon/claudecodeui/issues) and [pull requests](https://github.com/siteboon/claudecodeui/pulls) to avoid duplicating work.
- **Discuss first** for new features. Open an [issue](https://github.com/siteboon/claudecodeui/issues/new) to discuss your idea before investing time in implementation. We may already have plans or opinions on how it should work.
- **Bug fixes are always welcome.** If you spot a bug, feel free to open a PR directly.
## Prerequisites
- [Node.js](https://nodejs.org/) 22 or later
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
## Getting Started
1. Fork the repository
2. Clone your fork:
```bash
git clone https://github.com/<your-username>/claudecodeui.git
cd claudecodeui
```
3. Install dependencies:
```bash
npm install
```
4. Start the development server:
```bash
npm run dev
```
5. Create a branch for your changes:
```bash
git checkout -b feat/your-feature-name
```
## Project Structure
```
claudecodeui/
├── src/ # React frontend (Vite + Tailwind)
│ ├── components/ # UI components
│ ├── contexts/ # React context providers
│ ├── hooks/ # Custom React hooks
│ ├── i18n/ # Internationalization and translations
│ ├── lib/ # Shared frontend libraries
│ ├── types/ # TypeScript type definitions
│ └── utils/ # Frontend utilities
├── server/ # Express backend
│ ├── routes/ # API route handlers
│ ├── middleware/ # Express middleware
│ ├── database/ # SQLite database layer
│ └── tools/ # CLI tool integrations
├── shared/ # Code shared between client and server
└── public/ # Static assets, icons, PWA manifest
```
## Development Workflow
- `npm run dev` — Start both the frontend and backend in development mode
- `npm run build` — Create a production build
- `npm run server` — Start only the backend server
- `npm run client` — Start only the Vite dev server
## Making Changes
### Bug Fixes
- Reference the issue number in your PR if one exists
- Describe how to reproduce the bug in your PR description
- Add a screenshot or recording for visual bugs
### New Features
- Keep the scope focused — one feature per PR
- Include screenshots or recordings for UI changes
### Documentation
- Documentation improvements are always welcome
- Keep language clear and concise
## Commit Convention
We follow [Conventional Commits](https://conventionalcommits.org/) to generate release notes automatically. Every commit message should follow this format:
```
<type>(optional scope): <description>
```
Use imperative, present tense: "add feature" not "added feature" or "adds feature".
### Types
| Type | Description |
|------|-------------|
| `feat` | A new feature |
| `fix` | A bug fix |
| `perf` | A performance improvement |
| `refactor` | Code change that neither fixes a bug nor adds a feature |
| `docs` | Documentation only |
| `style` | CSS, formatting, visual changes |
| `chore` | Maintenance, dependencies, config |
| `ci` | CI/CD pipeline changes |
| `test` | Adding or updating tests |
| `build` | Build system changes |
### Examples
```bash
feat: add conversation search
feat(i18n): add Japanese language support
fix: redirect unauthenticated users to login
fix(editor): syntax highlighting for .env files
perf: lazy load code editor component
refactor(chat): extract message list component
docs: update API configuration guide
```
### Breaking Changes
Add `!` after the type or include `BREAKING CHANGE:` in the commit footer:
```bash
feat!: redesign settings page layout
```
## Pull Requests
- Give your PR a clear, descriptive title following the commit convention above
- Fill in the PR description with what changed and why
- Link any related issues
- Include screenshots for UI changes
- Make sure the build passes (`npm run build`)
- Keep PRs focused — avoid unrelated changes
## Releases
Releases are managed by maintainers using [release-it](https://github.com/release-it/release-it) with the [conventional changelog plugin](https://github.com/release-it/conventional-changelog).
```bash
npm run release # interactive (prompts for version bump)
npm run release -- patch # patch release
npm run release -- minor # minor release
```
This automatically:
- Bumps the version based on commit types (`feat` = minor, `fix` = patch)
- Generates categorized release notes
- Updates `CHANGELOG.md`
- Creates a git tag and GitHub Release
- Publishes to npm
## License
By contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](LICENSE).

346
README.ja.md Normal file
View File

@@ -0,0 +1,346 @@
<div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<h1>Cloud CLI (別名 Claude Code UI)</h1>
</div>
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview)、[Codex](https://developers.openai.com/codex) 向けのデスクトップ・モバイル UI です。ローカルまたはリモートで使用して、Claude Code、Cursor、Codex のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a></i></div>
## スクリーンショット
<div align="center">
<table>
<tr>
<td align="center">
<h3>デスクトップビュー</h3>
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
<br>
<em>プロジェクト概要とチャットを表示するメインインターフェース</em>
</td>
<td align="center">
<h3>モバイル体験</h3>
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
<br>
<em>タッチナビゲーション対応のレスポンシブモバイルデザイン</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>CLI 選択</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br>
<em>Claude Code、Cursor CLI、Codex から選択</em>
</td>
</tr>
</table>
</div>
## 機能
- **レスポンシブデザイン** - デスクトップ、タブレット、モバイルでシームレスに動作し、モバイルからも Claude Code、Cursor、Codex を使用可能
- **インタラクティブチャットインターフェース** - Claude Code、Cursor、Codex とシームレスに通信する組み込みチャットインターフェース
- **統合シェルターミナル** - 組み込みシェル機能による Claude Code、Cursor CLI、Codex への直接アクセス
- **ファイルエクスプローラー** - シンタックスハイライトとライブ編集対応のインタラクティブファイルツリー
- **Git エクスプローラー** - 変更の確認、ステージング、コミット。ブランチの切り替えも可能
- **セッション管理** - 会話の再開、複数セッションの管理、履歴の追跡
- **TaskMaster AI 統合** *(オプション)* - AI 駆動のタスク計画、PRD 解析、ワークフロー自動化による高度なプロジェクト管理
- **モデル互換性** - Claude Sonnet 4.5、Opus 4.5、GPT-5.2 に対応
## クイックスタート
### 前提条件
- [Node.js](https://nodejs.org/) v22 以上
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) のインストールと設定、および/または
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) のインストールと設定、および/または
- [Codex](https://developers.openai.com/codex) のインストールと設定
### ワンクリック実行(推奨)
インストール不要、直接実行:
```bash
npx @siteboon/claude-code-ui
```
サーバーが起動し、`http://localhost:3001`(または設定した PORTでアクセスできます。
**再起動**: サーバーを停止した後、同じ `npx` コマンドを再度実行するだけです
### グローバルインストール(定期的に使用する場合)
頻繁に使用する場合は、一度だけグローバルインストール:
```bash
npm install -g @siteboon/claude-code-ui
```
シンプルなコマンドで起動:
```bash
claude-code-ui
```
**再起動**: Ctrl+C で停止し、`claude-code-ui` を再度実行します。
**アップデート**:
```bash
cloudcli update
```
### CLI の使い方
グローバルインストール後、`claude-code-ui``cloudcli` コマンドが使用できます:
| コマンド / オプション | 短縮形 | 説明 |
|------------------|-------|-------------|
| `cloudcli` または `claude-code-ui` | | サーバーを起動(デフォルト) |
| `cloudcli start` | | サーバーを明示的に起動 |
| `cloudcli status` | | 設定とデータの場所を表示 |
| `cloudcli update` | | 最新バージョンに更新 |
| `cloudcli help` | | ヘルプ情報を表示 |
| `cloudcli version` | | バージョン情報を表示 |
| `--port <port>` | `-p` | サーバーポートを設定(デフォルト: 3001 |
| `--database-path <path>` | | カスタムデータベースの場所を設定 |
**例:**
```bash
cloudcli # デフォルト設定で起動
cloudcli -p 8080 # カスタムポートで起動
cloudcli status # 現在の設定を表示
```
### バックグラウンドサービスとして実行(本番環境推奨)
本番環境では、PM2Process Manager 2を使用して Claude Code UI をバックグラウンドサービスとして実行します:
#### PM2 のインストール
```bash
npm install -g pm2
```
#### バックグラウンドサービスとして起動
```bash
# バックグラウンドでサーバーを起動
pm2 start claude-code-ui --name "claude-code-ui"
# または短いエイリアスを使用
pm2 start cloudcli --name "claude-code-ui"
# カスタムポートで起動
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
```
#### システム起動時の自動起動
システム起動時に Claude Code UI を自動的に起動するには:
```bash
# プラットフォーム用の起動スクリプトを生成
pm2 startup
# 現在のプロセスリストを保存
pm2 save
```
### ローカル開発インストール
1. **リポジトリをクローン:**
```bash
git clone https://github.com/siteboon/claudecodeui.git
cd claudecodeui
```
2. **依存関係をインストール:**
```bash
npm install
```
3. **環境を設定:**
```bash
cp .env.example .env
# お好みの設定で .env を編集
```
4. **アプリケーションを起動:**
```bash
# 開発モード(ホットリロード付き)
npm run dev
```
アプリケーションは .env で指定したポートで起動します
5. **ブラウザを開く:**
- 開発: `http://localhost:3001`
## セキュリティとツール設定
**重要なお知らせ**: すべての Claude Code ツールは**デフォルトで無効**になっています。これにより、潜在的に有害な操作が自動的に実行されることを防ぎます。
### ツールの有効化
Claude Code の全機能を使用するには、手動でツールを有効にする必要があります:
1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック
3. **選択的に有効化** - 必要なツールのみを有効にする
4. **設定を適用** - 環境設定はローカルに保存されます
<div align="center">
![ツール設定モーダル](public/screenshots/tools-modal.png)
*ツール設定インターフェース - 必要なものだけを有効にしましょう*
</div>
**推奨アプローチ**: 基本的なツールから有効にし、必要に応じて追加してください。これらの設定はいつでも調整できます。
## TaskMaster AI 統合 *(オプション)*
Claude Code UI は、高度なプロジェクト管理と AI 駆動のタスク計画のための **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(別名 claude-task-master統合をサポートしています。
提供機能
- PRD製品要件ドキュメントからの AI 駆動タスク生成
- スマートなタスク分解と依存関係管理
- ビジュアルタスクボードと進捗追跡
**セットアップとドキュメント**: インストール手順、設定ガイド、使用例は [TaskMaster AI GitHub リポジトリ](https://github.com/eyaltoledano/claude-task-master)をご覧ください。
インストール後、設定から有効にできます
## 使用ガイド
### 主要機能
#### プロジェクト管理
Claude Code、Cursor、Codex のセッションが利用可能な場合、自動的に検出しプロジェクトとしてグループ化します
- **プロジェクト操作** - プロジェクトの名前変更、削除、整理
- **スマートナビゲーション** - 最近のプロジェクトやセッションへのクイックアクセス
- **MCP サポート** - UI から独自の MCP サーバーを追加
#### チャットインターフェース
- **レスポンシブチャットまたは Claude Code/Cursor CLI/Codex CLI を使用** - アダプティブチャットインターフェースを使用するか、シェルボタンで選択した CLI に接続できます
- **リアルタイム通信** - WebSocket 接続で選択した CLIClaude Code/Cursor/Codexからレスポンスをストリーミング
- **セッション管理** - 以前の会話を再開、または新しいセッションを開始
- **メッセージ履歴** - タイムスタンプとメタデータ付きの完全な会話履歴
- **マルチフォーマット対応** - テキスト、コードブロック、ファイル参照
#### ファイルエクスプローラーとエディター
- **インタラクティブファイルツリー** - 展開/折りたたみナビゲーションでプロジェクト構造を閲覧
- **ライブファイル編集** - インターフェースで直接ファイルの読み取り、変更、保存
- **シンタックスハイライト** - 複数のプログラミング言語に対応
- **ファイル操作** - ファイルやディレクトリの作成、名前変更、削除
#### Git エクスプローラー
#### TaskMaster AI 統合 *(オプション)*
- **ビジュアルタスクボード** - 開発タスク管理のためのカンバンスタイルインターフェース
- **PRD パーサー** - 製品要件ドキュメントを作成し、構造化されたタスクに変換
- **進捗追跡** - リアルタイムのステータス更新と完了追跡
#### セッション管理
- **セッション永続化** - すべての会話を自動保存
- **セッション整理** - プロジェクトとタイムスタンプでセッションをグループ化
- **セッション操作** - 会話履歴の名前変更、削除、エクスポート
- **クロスデバイス同期** - どのデバイスからでもセッションにアクセス
### モバイルアプリ
- **レスポンシブデザイン** - すべての画面サイズに最適化
- **タッチフレンドリーインターフェース** - スワイプジェスチャーとタッチナビゲーション
- **モバイルナビゲーション** - 親指で操作しやすいボトムタブバー
- **アダプティブレイアウト** - 折りたたみ可能なサイドバーとスマートコンテンツ優先順位
- **ホーム画面にショートカットを追加** - ホーム画面にショートカットを追加すると、アプリが PWA のように動作します
## アーキテクチャ
### システム概要
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Agent │
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
│ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### バックエンド (Node.js + Express)
- **Express サーバー** - 静的ファイル配信付きの RESTful API
- **WebSocket サーバー** - チャットとプロジェクト更新のための通信
- **エージェント統合 (Claude Code / Cursor CLI / Codex)** - プロセスの生成と管理
- **ファイルシステム API** - プロジェクト向けファイルブラウザの公開
### フロントエンド (React + Vite)
- **React 18** - hooks を使用したモダンなコンポーネントアーキテクチャ
- **CodeMirror** - シンタックスハイライト対応の高度なコードエディター
### コントリビューション
コントリビューションを歓迎します!コミット規約、開発ワークフロー、リリースプロセスの詳細は [Contributing Guide](CONTRIBUTING.md) をご覧ください。
## トラブルシューティング
### よくある問題と解決方法
#### 「Claude プロジェクトが見つかりません」
**問題**: UI にプロジェクトが表示されない、またはプロジェクトリストが空
**解決方法**:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) が正しくインストールされていることを確認
- 少なくとも1つのプロジェクトディレクトリで `claude` コマンドを実行して初期化
- `~/.claude/projects/` ディレクトリが存在し、適切な権限があることを確認
#### ファイルエクスプローラーの問題
**問題**: ファイルが読み込まれない、権限エラー、空のディレクトリ
**解決方法**:
- プロジェクトディレクトリの権限を確認(ターミナルで `ls -la`
- プロジェクトパスが存在しアクセス可能であることを確認
- 詳細なエラーメッセージについてはサーバーコンソールログを確認
- プロジェクト範囲外のシステムディレクトリにアクセスしていないことを確認
## ライセンス
GNU General Public License v3.0 - 詳細は [LICENSE](LICENSE) ファイルをご覧ください。
このプロジェクトはオープンソースであり、GPL v3 ライセンスの下で自由に使用、変更、配布できます。
## 謝辞
### 使用技術
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic の公式 CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor の公式 CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[React](https://react.dev/)** - ユーザーインターフェースライブラリ
- **[Vite](https://vitejs.dev/)** - 高速ビルドツールと開発サーバー
- **[Tailwind CSS](https://tailwindcss.com/)** - ユーティリティファースト CSS フレームワーク
- **[CodeMirror](https://codemirror.net/)** - 高度なコードエディター
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(オプション)* - AI 駆動のプロジェクト管理とタスク計画
## サポートとコミュニティ
### 最新情報を入手
- このリポジトリに **Star** をつけてサポートを表明
- **Watch** で更新や新リリースを確認
- プロジェクトを **Follow** してお知らせを受け取る
### スポンサー
- [Siteboon - AI powered website builder](https://siteboon.ai)
---
<div align="center">
<strong>Claude Code、Cursor、Codex コミュニティのために心を込めて作りました。</strong>
</div>

View File

@@ -6,7 +6,7 @@
[Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) 및 [Codex](https://developers.openai.com/codex)를 위한 데스크톱 및 모바일 UI입니다. 로컬 또는 원격으로 사용하여 Claude Code, Cursor 또는 Codex의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다.
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.zh-CN.md">中文</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
## 스크린샷
@@ -288,31 +288,7 @@ Claude Code, Cursor 또는 Codex 세션을 사용할 수 있을 때 자동으로
### 기여하기
기여를 환영합니다! 다음 가이드라인을 따라주세요:
#### 시작하기
1. 리포지토리를 **Fork**합니다
2. Fork를 **클론**: `git clone <your-fork-url>`
3. 의존성 **설치**: `npm install`
4. 기능 브랜치 **생성**: `git checkout -b feature/amazing-feature`
#### 개발 프로세스
1. 기존 코드 스타일을 따라 **변경 사항을 적용**합니다
2. **철저하게 테스트** - 모든 기능이 올바르게 작동하는지 확인
3. **품질 검사 실행**: `npm run lint && npm run format`
4. [Conventional Commits](https://conventionalcommits.org/)를 따르는 설명적 메시지로 **커밋**
5. 브랜치에 **푸시**: `git push origin feature/amazing-feature`
6. 다음을 포함하여 **풀 리퀘스트를 제출**:
- 변경 사항에 대한 명확한 설명
- UI 변경 시 스크린샷
- 해당되는 경우 테스트 결과
#### 기여 내용
- **버그 수정** - 안정성 향상에 도움
- **새로운 기능** - 기능 향상 (먼저 이슈에서 논의)
- **문서** - 가이드 및 API 문서 개선
- **UI/UX 개선** - 더 나은 사용자 경험
- **성능 최적화** - 더 빠르게 만들기
기여를 환영합니다! 커밋 규칙, 개발 워크플로우, 릴리스 프로세스에 대한 자세한 내용은 [Contributing Guide](CONTRIBUTING.md)를 참조해주세요.
## 문제 해결

View File

@@ -4,9 +4,10 @@
</div>
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) and [Codex](https://developers.openai.com/codex). You can use it locally or remotely to view your active projects and sessions in Claude Code, Cursor, or Codex and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a></i></div>
<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>
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
## Screenshots
@@ -43,14 +44,14 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
## Features
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code, Cursor, or Codex from mobile
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code, Cursor, or Codex
- **Integrated Shell Terminal** - Direct access to Claude Code, Cursor CLI, or Codex through built-in shell functionality
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Agents from mobile
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with the Agents
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, and GPT-5.2
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
## Quick Start
@@ -60,7 +61,8 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
- [Node.js](https://nodejs.org/) v22 or higher
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or
- [Codex](https://developers.openai.com/codex) installed and configured
- [Codex](https://developers.openai.com/codex) installed and configured, and/or
- [Gemini-CLI](https://geminicli.com/) installed and configured
### One-click Operation (Recommended)
@@ -278,7 +280,7 @@ session counts
### Backend (Node.js + Express)
- **Express Server** - RESTful API with static file serving
- **WebSocket Server** - Communication for chats and project refresh
- **Agent Integration (Claude Code / Cursor CLI / Codex)** - Process spawning and management
- **Agent Integration (Claude Code / Cursor CLI / Codex / Gemini CLI)** - Process spawning and management
- **File System API** - Exposing file browser for projects
### Frontend (React + Vite)
@@ -291,31 +293,7 @@ session counts
### Contributing
We welcome contributions! Please follow these guidelines:
#### Getting Started
1. **Fork** the repository
2. **Clone** your fork: `git clone <your-fork-url>`
3. **Install** dependencies: `npm install`
4. **Create** a feature branch: `git checkout -b feature/amazing-feature`
#### Development Process
1. **Make your changes** following the existing code style
2. **Test thoroughly** - ensure all features work correctly
3. **Run quality checks**: `npm run lint && npm run format`
4. **Commit** with descriptive messages following [Conventional Commits](https://conventionalcommits.org/)
5. **Push** to your branch: `git push origin feature/amazing-feature`
6. **Submit** a Pull Request with:
- Clear description of changes
- Screenshots for UI changes
- Test results if applicable
#### What to Contribute
- **Bug fixes** - Help us improve stability
- **New features** - Enhance functionality (discuss in issues first)
- **Documentation** - Improve guides and API docs
- **UI/UX improvements** - Better user experience
- **Performance optimizations** - Make it faster
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on commit conventions, development workflow, and release process.
## Troubleshooting
@@ -350,6 +328,7 @@ This project is open source and free to use, modify, and distribute under the GP
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - User interface library
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework

View File

@@ -6,7 +6,7 @@
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview) 和 [Codex](https://developers.openai.com/codex) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.ja.md">日本語</a></i></div>
## 截图
@@ -290,31 +290,7 @@ Claude Code UI 支持 **[TaskMaster AI](https://github.com/eyaltoledano/claude-t
### 贡献
我们欢迎贡献!请遵循以下指南:
#### 入门
1. **Fork** 仓库
2. **克隆** 您的 fork: `git clone <your-fork-url>`
3. **安装** 依赖: `npm install`
4. **创建** 特性分支: `git checkout -b feature/amazing-feature`
#### 开发流程
1. **进行更改**,遵循现有代码风格
2. **彻底测试** - 确保所有功能正常工作
3. **运行质量检查**: `npm run lint && npm run format`
4. **提交**,遵循 [Conventional Commits](https://conventionalcommits.org/)的描述性消息
5. **推送** 到您的分支: `git push origin feature/amazing-feature`
6. **提交** 拉取请求,包括:
- 更改的清晰描述
- UI 更改的截图
- 适用时的测试结果
#### 贡献内容
- **错误修复** - 帮助我们提高稳定性
- **新功能** - 增强功能(先在 issue 中讨论)
- **文档** - 改进指南和 API 文档
- **UI/UX 改进** - 更好的用户体验
- **性能优化** - 让它更快
我们欢迎贡献!有关提交规范、开发流程和发布流程的详细信息,请参阅 [Contributing Guide](CONTRIBUTING.md)。
## 故障排除

781
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.18.1",
"version": "1.21.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
@@ -12,6 +12,7 @@
"server/",
"shared/",
"dist/",
"scripts/",
"README.md"
],
"homepage": "https://cloudcli.ai",
@@ -30,7 +31,9 @@
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"start": "npm run build && npm run server",
"release": "./release.sh"
"release": "./release.sh",
"prepublishOnly": "npm run build",
"postinstall": "node scripts/fix-node-pty.js"
},
"keywords": [
"claude code",
@@ -42,7 +45,7 @@
"author": "CloudCLI UI Contributors",
"license": "GPL-3.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.71",
"@anthropic-ai/claude-agent-sdk": "^0.2.59",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -63,7 +66,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.2.0",
"better-sqlite3": "^12.6.2",
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -84,6 +87,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-error-boundary": "^4.1.2",
"react-i18next": "^16.5.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
@@ -95,9 +99,11 @@
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2"
},
"devDependencies": {
"@release-it/conventional-changelog": "^10.0.5",
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",

View File

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

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -22,11 +22,9 @@ self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Return cached response if found
if (response) {
return response;
}
// Otherwise fetch from network
return fetch(event.request);
}
)
@@ -46,4 +44,53 @@ self.addEventListener('activate', event => {
);
})
);
});
self.clients.claim();
});
// Push notification event
self.addEventListener('push', event => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
payload = { title: 'Claude Code UI', body: event.data.text() };
}
const options = {
body: payload.body || '',
icon: '/logo.png',
badge: '/logo.png',
data: payload.data || {},
tag: payload.data?.code || 'default',
renotify: true
};
event.waitUntil(
self.registration.showNotification(payload.title || 'Claude Code UI', options)
);
});
// Notification click event
self.addEventListener('notificationclick', event => {
event.notification.close();
const sessionId = event.notification.data?.sessionId;
const urlPath = sessionId ? `/session/${sessionId}` : '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
for (const client of clientList) {
if (client.url.includes(self.location.origin)) {
client.focus();
if (sessionId) {
client.navigate(self.location.origin + urlPath);
}
return;
}
}
return self.clients.openWindow(urlPath);
})
);
});

67
scripts/fix-node-pty.js Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* Fix node-pty spawn-helper permissions on macOS
*
* This script fixes a known issue with node-pty where the spawn-helper
* binary is shipped without execute permissions, causing "posix_spawnp failed" errors.
*
* @see https://github.com/microsoft/node-pty/issues/850
* @module scripts/fix-node-pty
*/
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Fixes the spawn-helper binary permissions for node-pty on macOS.
*
* The node-pty package ships the spawn-helper binary without execute permissions
* (644 instead of 755), which causes "posix_spawnp failed" errors when trying
* to spawn terminal processes.
*
* This function:
* 1. Checks if running on macOS (darwin)
* 2. Locates spawn-helper binaries for both arm64 and x64 architectures
* 3. Sets execute permissions (755) on each binary found
*
* @async
* @function fixSpawnHelper
* @returns {Promise<void>} Resolves when permissions are fixed or skipped
* @example
* // Run as postinstall script
* await fixSpawnHelper();
*/
async function fixSpawnHelper() {
const nodeModulesPath = path.join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds');
// Only run on macOS
if (process.platform !== 'darwin') {
return;
}
const darwinDirs = ['darwin-arm64', 'darwin-x64'];
for (const dir of darwinDirs) {
const spawnHelperPath = path.join(nodeModulesPath, dir, 'spawn-helper');
try {
// Check if file exists
await fs.access(spawnHelperPath);
// Make it executable (755)
await fs.chmod(spawnHelperPath, 0o755);
console.log(`[postinstall] Fixed permissions for ${spawnHelperPath}`);
} catch (err) {
// File doesn't exist or other error - ignore
if (err.code !== 'ENOENT') {
console.warn(`[postinstall] Warning: Could not fix ${spawnHelperPath}: ${err.message}`);
}
}
}
}
fixSpawnHelper().catch(console.error);

View File

@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
import { createNotificationEvent, notifyUserIfEnabled } from './services/notification-orchestrator.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
@@ -250,7 +251,13 @@ function getAllSessions() {
* @returns {Object} Transformed message ready for WebSocket
*/
function transformMessage(sdkMessage) {
// Pass-through; SDK messages match frontend format.
// Extract parent_tool_use_id for subagent tool grouping
if (sdkMessage.parent_tool_use_id) {
return {
...sdkMessage,
parentToolUseId: sdkMessage.parent_tool_use_id
};
}
return sdkMessage;
}
@@ -455,6 +462,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
let tempImagePaths = [];
let tempDir = null;
const emitNotification = (event) => {
notifyUserIfEnabled({
userId: ws?.userId || null,
writer: ws,
event
});
};
try {
// Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK(options);
@@ -471,6 +486,42 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir;
sdkOptions.hooks = {
Notification: [{
matcher: '',
hooks: [async (input) => {
const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'action_required',
code: 'agent.notification',
meta: { message },
severity: 'warning',
requiresUserAction: true,
dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
}));
return {};
}]
}],
Stop: [{
matcher: '',
hooks: [async (input) => {
const stopReason = typeof input?.stop_reason === 'string' ? input.stop_reason : 'completed';
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'stop',
code: 'run.stopped',
meta: { stopReason },
severity: 'info',
dedupeKey: `claude:hook:stop:${capturedSessionId || sessionId || 'none'}:${stopReason}`
}));
return {};
}]
}]
};
sdkOptions.canUseTool = async (toolName, input, context) => {
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
@@ -502,6 +553,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
input,
sessionId: capturedSessionId || sessionId || null
});
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'action_required',
code: 'permission.required',
meta: { toolName },
severity: 'warning',
requiresUserAction: true,
dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
}));
const decision = await waitForToolApproval(requestId, {
timeoutMs: requiresInteraction ? 0 : undefined,
@@ -542,10 +603,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
const queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
let queryInstance;
try {
queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
} catch (hookError) {
// Older/newer SDK versions may not accept hook shapes yet.
// Keep notification behavior operational via runtime events even if hook registration fails.
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
delete sdkOptions.hooks;
queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
}
// Restore immediately — Query constructor already captured the value
if (prevStreamTimeout !== undefined) {
@@ -597,6 +670,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const models = Object.keys(message.modelUsage || {});
if (models.length > 0) {
console.log("---> Model was sent using:", models);
}
const tokenBudget = extractTokenBudget(message);
if (tokenBudget) {
console.log('Token budget from modelUsage:', tokenBudget);
@@ -644,6 +721,15 @@ async function queryClaudeSDK(command, options = {}, ws) {
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'error',
code: 'run.failed',
meta: { error: error.message },
severity: 'error',
dedupeKey: `claude:error:${capturedSessionId || sessionId || 'none'}:${error.message}`
}));
throw error;
}

View File

@@ -40,6 +40,22 @@ if (process.env.DATABASE_PATH) {
}
}
// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location
const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
try {
fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
for (const suffix of ['-wal', '-shm']) {
if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
}
}
} catch (err) {
console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
}
}
// Create database connection
const db = new Database(DB_PATH);
@@ -75,6 +91,36 @@ const runMigrations = () => {
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
}
db.exec(`
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
console.log('Database migrations completed successfully');
} catch (error) {
console.error('Error running migrations:', error.message);
@@ -128,12 +174,12 @@ const userDb = {
}
},
// Update last login time
// Update last login time (non-fatal — logged but not thrown)
updateLastLogin: (userId) => {
try {
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
} catch (err) {
throw err;
console.warn('Failed to update last login:', err.message);
}
},
@@ -332,6 +378,116 @@ const credentialsDb = {
}
};
const DEFAULT_NOTIFICATION_PREFERENCES = {
channels: {
inApp: false,
webPush: true
},
events: {
actionRequired: true,
stop: true,
error: true
}
};
const normalizeNotificationPreferences = (value) => {
const source = value && typeof value === 'object' ? value : {};
return {
channels: {
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush !== false
},
events: {
actionRequired: source.events?.actionRequired !== false,
stop: source.events?.stop !== false,
error: source.events?.error !== false
}
};
};
const notificationPreferencesDb = {
getPreferences: (userId) => {
try {
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
if (!row) {
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
db.prepare(
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
).run(userId, JSON.stringify(defaults));
return defaults;
}
let parsed;
try {
parsed = JSON.parse(row.preferences_json);
} catch {
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
}
return normalizeNotificationPreferences(parsed);
} catch (err) {
throw err;
}
},
updatePreferences: (userId, preferences) => {
try {
const normalized = normalizeNotificationPreferences(preferences);
db.prepare(
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
preferences_json = excluded.preferences_json,
updated_at = CURRENT_TIMESTAMP`
).run(userId, JSON.stringify(normalized));
return normalized;
} catch (err) {
throw err;
}
}
};
const pushSubscriptionsDb = {
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
try {
db.prepare(
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
keys_p256dh = excluded.keys_p256dh,
keys_auth = excluded.keys_auth`
).run(userId, endpoint, keysP256dh, keysAuth);
} catch (err) {
throw err;
}
},
getSubscriptions: (userId) => {
try {
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
} catch (err) {
throw err;
}
},
removeSubscription: (endpoint) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
} catch (err) {
throw err;
}
},
removeAllForUser: (userId) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
} catch (err) {
throw err;
}
}
};
// Backward compatibility - keep old names pointing to new system
const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -357,5 +513,7 @@ export {
userDb,
apiKeysDb,
credentialsDb,
notificationPreferencesDb,
pushSubscriptionsDb,
githubTokensDb // Backward compatibility
};
};

View File

@@ -49,4 +49,31 @@ CREATE TABLE IF NOT EXISTS user_credentials (
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
-- User notification preferences (backend-owned, provider-agnostic)
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- VAPID key pair for Web Push notifications
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Browser push subscriptions
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

455
server/gemini-cli.js Normal file
View File

@@ -0,0 +1,455 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { getSessions, getSessionMessages } from './projects.js';
import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js';
let activeGeminiProcesses = new Map(); // Track active processes by session ID
async function spawnGemini(command, options = {}, ws) {
const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let assistantBlocks = []; // Accumulate the full response blocks including tools
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
allowedTools: [],
disallowedTools: [],
skipPermissions: false
};
// Build Gemini CLI command - start with print/resume flags first
const args = [];
// Add prompt flag with command if we have a command
if (command && command.trim()) {
args.push('--prompt', command);
}
// If we have a sessionId, we want to resume
if (sessionId) {
const session = sessionManager.getSession(sessionId);
if (session && session.cliSessionId) {
args.push('--resume', session.cliSessionId);
}
}
// Use cwd (actual project directory) instead of projectPath (Gemini's metadata directory)
// Clean the path by removing any non-printable characters
const cleanPath = (cwd || projectPath || process.cwd()).replace(/[^\x20-\x7E]/g, '').trim();
const workingDir = cleanPath;
// Handle images by saving them to temporary files and passing paths to Gemini
const tempImagePaths = [];
let tempDir = null;
if (images && images.length > 0) {
try {
// Create temp directory in the project directory so Gemini can access it
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
await fs.mkdir(tempDir, { recursive: true });
// Save each image to a temp file
for (const [index, image] of images.entries()) {
// Extract base64 data and mime type
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) {
continue;
}
const [, mimeType, base64Data] = matches;
const extension = mimeType.split('/')[1] || 'png';
const filename = `image_${index}.${extension}`;
const filepath = path.join(tempDir, filename);
// Write base64 data to file
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
tempImagePaths.push(filepath);
}
// Include the full image paths in the prompt for Gemini to reference
// Gemini CLI can read images from file paths in the prompt
if (tempImagePaths.length > 0 && command && command.trim()) {
const imageNote = `\n\n[Images given: ${tempImagePaths.length} images are located at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
const modifiedCommand = command + imageNote;
// Update the command in args
const promptIndex = args.indexOf('--prompt');
if (promptIndex !== -1 && args[promptIndex + 1] === command) {
args[promptIndex + 1] = modifiedCommand;
} else if (promptIndex !== -1) {
// If we're using context, update the full prompt
args[promptIndex + 1] = args[promptIndex + 1] + imageNote;
}
}
} catch (error) {
console.error('Error processing images for Gemini:', error);
}
}
// Add basic flags for Gemini
if (options.debug) {
args.push('--debug');
}
// Add MCP config flag only if MCP servers are configured
try {
const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
let hasMcpServers = false;
try {
await fs.access(geminiConfigPath);
const geminiConfigRaw = await fs.readFile(geminiConfigPath, 'utf8');
const geminiConfig = JSON.parse(geminiConfigRaw);
// Check global MCP servers
if (geminiConfig.mcpServers && Object.keys(geminiConfig.mcpServers).length > 0) {
hasMcpServers = true;
}
// Check project-specific MCP servers
if (!hasMcpServers && geminiConfig.geminiProjects) {
const currentProjectPath = process.cwd();
const projectConfig = geminiConfig.geminiProjects[currentProjectPath];
if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) {
hasMcpServers = true;
}
}
} catch (e) {
// Ignore if file doesn't exist or isn't parsable
}
if (hasMcpServers) {
args.push('--mcp-config', geminiConfigPath);
}
} catch (error) {
// Ignore outer errors
}
// Add model for all sessions (both new and resumed)
let modelToUse = options.model || 'gemini-2.5-flash';
args.push('--model', modelToUse);
args.push('--output-format', 'stream-json');
// Handle approval modes and allowed tools
if (settings.skipPermissions || options.skipPermissions || permissionMode === 'yolo') {
args.push('--yolo');
} else if (permissionMode === 'auto_edit') {
args.push('--approval-mode', 'auto_edit');
} else if (permissionMode === 'plan') {
args.push('--approval-mode', 'plan');
}
if (settings.allowedTools && settings.allowedTools.length > 0) {
args.push('--allowed-tools', settings.allowedTools.join(','));
}
// Try to find gemini in PATH first, then fall back to environment variable
const geminiPath = process.env.GEMINI_PATH || 'gemini';
console.log('Spawning Gemini CLI:', geminiPath, args.join(' '));
console.log('Working directory:', workingDir);
let spawnCmd = geminiPath;
let spawnArgs = args;
// On non-Windows platforms, wrap the execution in a shell to avoid ENOEXEC
// which happens when the target is a script lacking a shebang.
if (os.platform() !== 'win32') {
spawnCmd = 'sh';
// Use exec to replace the shell process, ensuring signals hit gemini directly
spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
}
return new Promise((resolve, reject) => {
const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
});
// Attach temp file info to process for cleanup later
geminiProcess.tempImagePaths = tempImagePaths;
geminiProcess.tempDir = tempDir;
// Store process reference for potential abort
const processKey = capturedSessionId || sessionId || Date.now().toString();
activeGeminiProcesses.set(processKey, geminiProcess);
// Store sessionId on the process object for debugging
geminiProcess.sessionId = processKey;
// Close stdin to signal we're done sending input
geminiProcess.stdin.end();
// Add timeout handler
let hasReceivedOutput = false;
const timeoutMs = 120000; // 120 seconds for slower models
let timeout;
const startTimeout = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);
ws.send({
type: 'gemini-error',
sessionId: socketSessionId,
error: `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`
});
try {
geminiProcess.kill('SIGTERM');
} catch (e) { }
}, timeoutMs);
};
startTimeout();
// Save user message to session when starting
if (command && capturedSessionId) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}
// Create response handler for NDJSON buffering
let responseHandler;
if (ws) {
responseHandler = new GeminiResponseHandler(ws, {
onContentFragment: (content) => {
if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
assistantBlocks[assistantBlocks.length - 1].text += content;
} else {
assistantBlocks.push({ type: 'text', text: content });
}
},
onToolUse: (event) => {
assistantBlocks.push({
type: 'tool_use',
id: event.tool_id,
name: event.tool_name,
input: event.parameters
});
},
onToolResult: (event) => {
if (capturedSessionId) {
if (assistantBlocks.length > 0) {
sessionManager.addMessage(capturedSessionId, 'assistant', [...assistantBlocks]);
assistantBlocks = [];
}
sessionManager.addMessage(capturedSessionId, 'user', [{
type: 'tool_result',
tool_use_id: event.tool_id,
content: event.output === undefined ? null : event.output,
is_error: event.status === 'error'
}]);
}
},
onInit: (event) => {
if (capturedSessionId) {
const sess = sessionManager.getSession(capturedSessionId);
if (sess && !sess.cliSessionId) {
sess.cliSessionId = event.session_id;
sessionManager.saveSession(capturedSessionId);
}
}
}
});
}
// Handle stdout
geminiProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
hasReceivedOutput = true;
startTimeout(); // Re-arm the timeout
// For new sessions, create a session ID FIRST
if (!sessionId && !sessionCreatedSent && !capturedSessionId) {
capturedSessionId = `gemini_${Date.now()}`;
sessionCreatedSent = true;
// Create session in session manager
sessionManager.createSession(capturedSessionId, cwd || process.cwd());
// Save the user message now that we have a session ID
if (command) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}
// Update process key with captured session ID
if (processKey !== capturedSessionId) {
activeGeminiProcesses.delete(processKey);
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
}
ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
ws.send({
type: 'session-created',
sessionId: capturedSessionId
});
// Emit fake system init so the frontend immediately navigates and saves the session
ws.send({
type: 'claude-response',
sessionId: capturedSessionId,
data: {
type: 'system',
subtype: 'init',
session_id: capturedSessionId
}
});
}
if (responseHandler) {
responseHandler.processData(rawOutput);
} else if (rawOutput) {
// Fallback to direct sending for raw CLI mode without WS
if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
assistantBlocks[assistantBlocks.length - 1].text += rawOutput;
} else {
assistantBlocks.push({ type: 'text', text: rawOutput });
}
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
ws.send({
type: 'gemini-response',
sessionId: socketSessionId,
data: {
type: 'message',
content: rawOutput
}
});
}
});
// Handle stderr
geminiProcess.stderr.on('data', (data) => {
const errorMsg = data.toString();
// Filter out deprecation warnings and "Loaded cached credentials" message
if (errorMsg.includes('[DEP0040]') ||
errorMsg.includes('DeprecationWarning') ||
errorMsg.includes('--trace-deprecation') ||
errorMsg.includes('Loaded cached credentials')) {
return;
}
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
ws.send({
type: 'gemini-error',
sessionId: socketSessionId,
error: errorMsg
});
});
// Handle process completion
geminiProcess.on('close', async (code) => {
clearTimeout(timeout);
// Flush any remaining buffered content
if (responseHandler) {
responseHandler.forceFlush();
responseHandler.destroy();
}
// Clean up process reference
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
// Save assistant response to session if we have one
if (finalSessionId && assistantBlocks.length > 0) {
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
}
ws.send({
type: 'claude-complete', // Use claude-complete for compatibility with UI
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
});
// Clean up temporary image files if any
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
for (const imagePath of geminiProcess.tempImagePaths) {
await fs.unlink(imagePath).catch(err => { });
}
if (geminiProcess.tempDir) {
await fs.rm(geminiProcess.tempDir, { recursive: true, force: true }).catch(err => { });
}
}
if (code === 0) {
resolve();
} else {
reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`));
}
});
// Handle process errors
geminiProcess.on('error', (error) => {
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send({
type: 'gemini-error',
sessionId: errorSessionId,
error: error.message
});
reject(error);
});
});
}
function abortGeminiSession(sessionId) {
let geminiProc = activeGeminiProcesses.get(sessionId);
let processKey = sessionId;
if (!geminiProc) {
for (const [key, proc] of activeGeminiProcesses.entries()) {
if (proc.sessionId === sessionId) {
geminiProc = proc;
processKey = key;
break;
}
}
}
if (geminiProc) {
try {
geminiProc.kill('SIGTERM');
setTimeout(() => {
if (activeGeminiProcesses.has(processKey)) {
try {
geminiProc.kill('SIGKILL');
} catch (e) { }
}
}, 2000); // Wait 2 seconds before force kill
return true;
} catch (error) {
return false;
}
}
return false;
}
function isGeminiSessionActive(sessionId) {
return activeGeminiProcesses.has(sessionId);
}
function getActiveGeminiSessions() {
return Array.from(activeGeminiProcesses.keys());
}
export {
spawnGemini,
abortGeminiSession,
isGeminiSessionActive,
getActiveGeminiSessions
};

View File

@@ -0,0 +1,140 @@
// Gemini Response Handler - JSON Stream processing
class GeminiResponseHandler {
constructor(ws, options = {}) {
this.ws = ws;
this.buffer = '';
this.onContentFragment = options.onContentFragment || null;
this.onInit = options.onInit || null;
this.onToolUse = options.onToolUse || null;
this.onToolResult = options.onToolResult || null;
}
// Process incoming raw data from Gemini stream-json
processData(data) {
this.buffer += data;
// Split by newline
const lines = this.buffer.split('\n');
// Keep the last incomplete line in the buffer
this.buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
this.handleEvent(event);
} catch (err) {
// Not a JSON line, probably debug output or CLI warnings
// console.error('[Gemini Handler] Non-JSON line ignored:', line);
}
}
}
handleEvent(event) {
const socketSessionId = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
if (event.type === 'init') {
if (this.onInit) {
this.onInit(event);
}
return;
}
if (event.type === 'message' && event.role === 'assistant') {
const content = event.content || '';
// Notify the parent CLI handler of accumulated text
if (this.onContentFragment && content) {
this.onContentFragment(content);
}
let payload = {
type: 'gemini-response',
data: {
type: 'message',
content: content,
isPartial: event.delta === true
}
};
if (socketSessionId) payload.sessionId = socketSessionId;
this.ws.send(payload);
}
else if (event.type === 'tool_use') {
if (this.onToolUse) {
this.onToolUse(event);
}
let payload = {
type: 'gemini-tool-use',
toolName: event.tool_name,
toolId: event.tool_id,
parameters: event.parameters || {}
};
if (socketSessionId) payload.sessionId = socketSessionId;
this.ws.send(payload);
}
else if (event.type === 'tool_result') {
if (this.onToolResult) {
this.onToolResult(event);
}
let payload = {
type: 'gemini-tool-result',
toolId: event.tool_id,
status: event.status,
output: event.output || ''
};
if (socketSessionId) payload.sessionId = socketSessionId;
this.ws.send(payload);
}
else if (event.type === 'result') {
// Send a finalize message string
let payload = {
type: 'gemini-response',
data: {
type: 'message',
content: '',
isPartial: false
}
};
if (socketSessionId) payload.sessionId = socketSessionId;
this.ws.send(payload);
if (event.stats && event.stats.total_tokens) {
let statsPayload = {
type: 'claude-status',
data: {
status: 'Complete',
tokens: event.stats.total_tokens
}
};
if (socketSessionId) statsPayload.sessionId = socketSessionId;
this.ws.send(statsPayload);
}
}
else if (event.type === 'error') {
let payload = {
type: 'gemini-error',
error: event.error || event.message || 'Unknown Gemini streaming error'
};
if (socketSessionId) payload.sessionId = socketSessionId;
this.ws.send(payload);
}
}
forceFlush() {
// If the buffer has content, try to parse it one last time
if (this.buffer.trim()) {
try {
const event = JSON.parse(this.buffer);
this.handleEvent(event);
} catch (err) { }
}
}
destroy() {
this.buffer = '';
}
}
export default GeminiResponseHandler;

View File

@@ -9,6 +9,8 @@ import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
@@ -46,6 +48,8 @@ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSess
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
import sessionManager from './sessionManager.js';
import gitRoutes from './routes/git.js';
import authRoutes from './routes/auth.js';
import mcpRoutes from './routes/mcp.js';
@@ -59,7 +63,9 @@ import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes
import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.js';
import { initializeDatabase } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js';
@@ -67,7 +73,9 @@ import { IS_PLATFORM } from './constants/config.js';
const PROVIDER_WATCH_PATHS = [
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
{ provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
{ provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
{ provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') },
{ provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'projects') },
{ provider: 'gemini_sessions', rootPath: path.join(os.homedir(), '.gemini', 'sessions') }
];
const WATCHER_IGNORED_PATTERNS = [
'**/node_modules/**',
@@ -317,24 +325,25 @@ app.locals.wss = wss;
app.use(cors());
app.use(express.json({
limit: '50mb',
type: (req) => {
// Skip multipart/form-data requests (for file uploads like images)
const contentType = req.headers['content-type'] || '';
if (contentType.includes('multipart/form-data')) {
return false;
limit: '50mb',
type: (req) => {
// Skip multipart/form-data requests (for file uploads like images)
const contentType = req.headers['content-type'] || '';
if (contentType.includes('multipart/form-data')) {
return false;
}
return contentType.includes('json');
}
return contentType.includes('json');
}
}));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// Public health check endpoint (no authentication required)
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString()
});
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
installMode
});
});
// Optional API key validation (if configured)
@@ -376,6 +385,9 @@ app.use('/api/user', authenticateToken, userRoutes);
// Codex API Routes (protected)
app.use('/api/codex', authenticateToken, codexRoutes);
// Gemini API Routes (protected)
app.use('/api/gemini', authenticateToken, geminiRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
@@ -385,17 +397,17 @@ app.use(express.static(path.join(__dirname, '../public')));
// Static files served after API routes
// Add cache control: HTML files should not be cached, but assets can be cached
app.use(express.static(path.join(__dirname, '../dist'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
// Prevent HTML caching to avoid service worker issues after builds
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
} else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
// Cache static assets for 1 year (they have hashed names)
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
// Prevent HTML caching to avoid service worker issues after builds
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
} else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
// Cache static assets for 1 year (they have hashed names)
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
}
}
}));
// API Routes (protected)
@@ -410,11 +422,13 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
console.log('Starting system update from directory:', projectRoot);
// Run the update command
const updateCommand = 'git checkout main && git pull && npm install';
// Run the update command based on install mode
const updateCommand = installMode === 'git'
? 'git checkout main && git pull && npm install'
: 'npm install -g @siteboon/claude-code-ui@latest';
const child = spawn('sh', ['-c', updateCommand], {
cwd: projectRoot,
cwd: installMode === 'git' ? projectRoot : os.homedir(),
env: process.env
});
@@ -491,13 +505,13 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateT
try {
const { projectName, sessionId } = req.params;
const { limit, offset } = req.query;
// Parse limit and offset if provided
const parsedLimit = limit ? parseInt(limit, 10) : null;
const parsedOffset = offset ? parseInt(offset, 10) : 0;
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
// Handle both old and new response formats
if (Array.isArray(result)) {
// Backward compatibility: no pagination parameters were provided
@@ -580,13 +594,13 @@ const expandWorkspacePath = (inputPath) => {
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
try {
const { path: dirPath } = req.query;
console.log('[API] Browse filesystem request for path:', dirPath);
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
// Default to home directory if no path provided
const defaultRoot = WORKSPACES_ROOT;
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
// Resolve and normalize the path
targetPath = path.resolve(targetPath);
@@ -596,22 +610,22 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
return res.status(403).json({ error: validation.error });
}
const resolvedPath = validation.resolvedPath || targetPath;
// Security check - ensure path is accessible
try {
await fs.promises.access(resolvedPath);
const stats = await fs.promises.stat(resolvedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path is not a directory' });
}
} catch (err) {
return res.status(404).json({ error: 'Directory not accessible' });
}
// Use existing getFileTree function with shallow depth (only direct children)
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
// Filter only directories and format for suggestions
const directories = fileTree
.filter(item => item.type === 'directory')
@@ -627,7 +641,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
if (!aHidden && bHidden) return -1;
return a.name.localeCompare(b.name);
});
// Add common directories if browsing home directory
const suggestions = [];
let resolvedWorkspaceRoot = defaultRoot;
@@ -640,17 +654,17 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
suggestions.push(...existingCommon, ...otherDirs);
} else {
suggestions.push(...directories);
}
res.json({
path: resolvedPath,
suggestions: suggestions
});
} catch (error) {
console.error('Error browsing filesystem:', error);
res.status(500).json({ error: 'Failed to browse filesystem' });
@@ -883,7 +897,7 @@ wss.on('connection', (ws, request) => {
if (pathname === '/shell') {
handleShellConnection(ws);
} else if (pathname === '/ws') {
handleChatConnection(ws);
handleChatConnection(ws, request);
} else {
console.log('[WARN] Unknown WebSocket path:', pathname);
ws.close();
@@ -894,37 +908,38 @@ wss.on('connection', (ws, request) => {
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
*/
class WebSocketWriter {
constructor(ws) {
this.ws = ws;
this.sessionId = null;
this.isWebSocketWriter = true; // Marker for transport detection
}
send(data) {
if (this.ws.readyState === 1) { // WebSocket.OPEN
// Providers send raw objects, we stringify for WebSocket
this.ws.send(JSON.stringify(data));
constructor(ws, userId = null) {
this.ws = ws;
this.sessionId = null;
this.userId = userId;
this.isWebSocketWriter = true; // Marker for transport detection
}
}
setSessionId(sessionId) {
this.sessionId = sessionId;
}
send(data) {
if (this.ws.readyState === 1) { // WebSocket.OPEN
// Providers send raw objects, we stringify for WebSocket
this.ws.send(JSON.stringify(data));
}
}
getSessionId() {
return this.sessionId;
}
setSessionId(sessionId) {
this.sessionId = sessionId;
}
getSessionId() {
return this.sessionId;
}
}
// Handle chat WebSocket connections
function handleChatConnection(ws) {
function handleChatConnection(ws, request) {
console.log('[INFO] Chat WebSocket connected');
// Add to connected clients for project updates
connectedClients.add(ws);
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
const writer = new WebSocketWriter(ws);
const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
ws.on('message', async (message) => {
try {
@@ -949,6 +964,12 @@ function handleChatConnection(ws) {
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
console.log('🤖 Model:', data.options?.model || 'default');
await queryCodex(data.command, data.options, writer);
} else if (data.type === 'gemini-command') {
console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
console.log('🤖 Model:', data.options?.model || 'default');
await spawnGemini(data.command, data.options, writer);
} else if (data.type === 'cursor-resume') {
// Backward compatibility: treat as cursor-command with resume and no prompt
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
@@ -966,6 +987,8 @@ function handleChatConnection(ws) {
success = abortCursorSession(data.sessionId);
} else if (provider === 'codex') {
success = abortCodexSession(data.sessionId);
} else if (provider === 'gemini') {
success = abortGeminiSession(data.sessionId);
} else {
// Use Claude Agents SDK
success = await abortClaudeSDKSession(data.sessionId);
@@ -1008,6 +1031,8 @@ function handleChatConnection(ws) {
isActive = isCursorSessionActive(sessionId);
} else if (provider === 'codex') {
isActive = isCodexSessionActive(sessionId);
} else if (provider === 'gemini') {
isActive = isGeminiSessionActive(sessionId);
} else {
// Use Claude Agents SDK
isActive = isClaudeSDKSessionActive(sessionId);
@@ -1024,7 +1049,8 @@ function handleChatConnection(ws) {
const activeSessions = {
claude: getActiveClaudeSDKSessions(),
cursor: getActiveCursorSessions(),
codex: getActiveCodexSessions()
codex: getActiveCodexSessions(),
gemini: getActiveGeminiSessions()
};
writer.send({
type: 'active-sessions',
@@ -1133,7 +1159,7 @@ function handleShellConnection(ws) {
if (isPlainShell) {
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
} else {
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
const providerName = provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude'));
welcomeMsg = hasSession ?
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
@@ -1169,6 +1195,55 @@ function handleShellConnection(ws) {
shellCommand = `cd "${projectPath}" && cursor-agent`;
}
}
} else if (provider === 'codex') {
// Use codex command
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
}
} else {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
} else {
shellCommand = `cd "${projectPath}" && codex`;
}
}
} else if (provider === 'gemini') {
// Use gemini command
const command = initialCommand || 'gemini';
let resumeId = sessionId;
if (hasSession && sessionId) {
try {
// Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
// We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
const sess = sessionManager.getSession(sessionId);
if (sess && sess.cliSessionId) {
resumeId = sess.cliSessionId;
}
} catch (err) {
console.error('Failed to get Gemini CLI session ID:', err);
}
}
if (os.platform() === 'win32') {
if (hasSession && resumeId) {
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
}
} else {
if (hasSession && resumeId) {
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
}
} else {
// Use claude command (default) or initialCommand if provided
const command = initialCommand || 'claude';
@@ -1602,203 +1677,214 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
// Get token usage for a specific session
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
const { provider = 'claude' } = req.query;
const homeDir = os.homedir();
try {
const { projectName, sessionId } = req.params;
const { provider = 'claude' } = req.query;
const homeDir = os.homedir();
// Allow only safe characters in sessionId
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId) {
return res.status(400).json({ error: 'Invalid sessionId' });
}
// Allow only safe characters in sessionId
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId) {
return res.status(400).json({ error: 'Invalid sessionId' });
}
// Handle Cursor sessions - they use SQLite and don't have token usage info
if (provider === 'cursor') {
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking not available for Cursor sessions'
});
}
// Handle Cursor sessions - they use SQLite and don't have token usage info
if (provider === 'cursor') {
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking not available for Cursor sessions'
});
}
// Handle Codex sessions
if (provider === 'codex') {
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
// Handle Gemini sessions - they are raw logs in our current setup
if (provider === 'gemini') {
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking not available for Gemini sessions'
});
}
// Find the session file by searching for the session ID
const findSessionFile = async (dir) => {
try {
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = await findSessionFile(fullPath);
if (found) return found;
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
return fullPath;
// Handle Codex sessions
if (provider === 'codex') {
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
// Find the session file by searching for the session ID
const findSessionFile = async (dir) => {
try {
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = await findSessionFile(fullPath);
if (found) return found;
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
return fullPath;
}
}
} catch (error) {
// Skip directories we can't read
}
return null;
};
const sessionFilePath = await findSessionFile(codexSessionsDir);
if (!sessionFilePath) {
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
}
}
// Read and parse the Codex JSONL file
let fileContent;
try {
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
}
throw error;
}
const lines = fileContent.trim().split('\n');
let totalTokens = 0;
let contextWindow = 200000; // Default for Codex/OpenAI
// Find the latest token_count event with info (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
// Codex stores token info in event_msg with type: "token_count"
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const tokenInfo = entry.payload.info;
if (tokenInfo.total_token_usage) {
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
}
if (tokenInfo.model_context_window) {
contextWindow = tokenInfo.model_context_window;
}
break; // Stop after finding the latest token count
}
} catch (parseError) {
// Skip lines that can't be parsed
continue;
}
}
return res.json({
used: totalTokens,
total: contextWindow
});
}
// Handle Claude sessions (default)
// Extract actual project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
// Skip directories we can't read
console.error('Error extracting project directory:', error);
return res.status(500).json({ error: 'Failed to determine project path' });
}
return null;
};
const sessionFilePath = await findSessionFile(codexSessionsDir);
// Construct the JSONL file path
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
// The encoding replaces any non-alphanumeric character (except -) with -
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
if (!sessionFilePath) {
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
}
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
// Read and parse the Codex JSONL file
let fileContent;
try {
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
// Constrain to projectDir
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
throw error;
}
const lines = fileContent.trim().split('\n');
let totalTokens = 0;
let contextWindow = 200000; // Default for Codex/OpenAI
// Find the latest token_count event with info (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
// Read and parse the JSONL file
let fileContent;
try {
const entry = JSON.parse(lines[i]);
// Codex stores token info in event_msg with type: "token_count"
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const tokenInfo = entry.payload.info;
if (tokenInfo.total_token_usage) {
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
}
if (tokenInfo.model_context_window) {
contextWindow = tokenInfo.model_context_window;
throw error; // Re-throw other errors to be caught by outer try-catch
}
const lines = fileContent.trim().split('\n');
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
let inputTokens = 0;
let cacheCreationTokens = 0;
let cacheReadTokens = 0;
// Find the latest assistant message with usage data (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
// Only count assistant messages which have usage data
if (entry.type === 'assistant' && entry.message?.usage) {
const usage = entry.message.usage;
// Use token counts from latest assistant message only
inputTokens = usage.input_tokens || 0;
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
cacheReadTokens = usage.cache_read_input_tokens || 0;
break; // Stop after finding the latest assistant message
}
} catch (parseError) {
// Skip lines that can't be parsed
continue;
}
break; // Stop after finding the latest token count
}
} catch (parseError) {
// Skip lines that can't be parsed
continue;
}
}
return res.json({
used: totalTokens,
total: contextWindow
});
}
// Calculate total context usage (excluding output_tokens, as per ccusage)
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
// Handle Claude sessions (default)
// Extract actual project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
res.json({
used: totalUsed,
total: contextWindow,
breakdown: {
input: inputTokens,
cacheCreation: cacheCreationTokens,
cacheRead: cacheReadTokens
}
});
} catch (error) {
console.error('Error extracting project directory:', error);
return res.status(500).json({ error: 'Failed to determine project path' });
console.error('Error reading session token usage:', error);
res.status(500).json({ error: 'Failed to read session token usage' });
}
// Construct the JSONL file path
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
// The encoding replaces /, spaces, ~, and _ with -
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
// Constrain to projectDir
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
// Read and parse the JSONL file
let fileContent;
try {
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
}
throw error; // Re-throw other errors to be caught by outer try-catch
}
const lines = fileContent.trim().split('\n');
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
let inputTokens = 0;
let cacheCreationTokens = 0;
let cacheReadTokens = 0;
// Find the latest assistant message with usage data (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
// Only count assistant messages which have usage data
if (entry.type === 'assistant' && entry.message?.usage) {
const usage = entry.message.usage;
// Use token counts from latest assistant message only
inputTokens = usage.input_tokens || 0;
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
cacheReadTokens = usage.cache_read_input_tokens || 0;
break; // Stop after finding the latest assistant message
}
} catch (parseError) {
// Skip lines that can't be parsed
continue;
}
}
// Calculate total context usage (excluding output_tokens, as per ccusage)
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
res.json({
used: totalUsed,
total: contextWindow,
breakdown: {
input: inputTokens,
cacheCreation: cacheCreationTokens,
cacheRead: cacheReadTokens
}
});
} catch (error) {
console.error('Error reading session token usage:', error);
res.status(500).json({ error: 'Failed to read session token usage' });
}
});
// Serve React app for all other routes (excluding static files)
app.get('*', (req, res) => {
// Skip requests for static assets (files with extensions)
if (path.extname(req.path)) {
return res.status(404).send('Not found');
}
// Skip requests for static assets (files with extensions)
if (path.extname(req.path)) {
return res.status(404).send('Not found');
}
// Only serve index.html for HTML routes, not for static assets
// Static assets should already be handled by express.static middleware above
const indexPath = path.join(__dirname, '../dist/index.html');
// Only serve index.html for HTML routes, not for static assets
// Static assets should already be handled by express.static middleware above
const indexPath = path.join(__dirname, '../dist/index.html');
// Check if dist/index.html exists (production build available)
if (fs.existsSync(indexPath)) {
// Set no-cache headers for HTML to prevent service worker issues
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.sendFile(indexPath);
} else {
// In development, redirect to Vite dev server only if dist doesn't exist
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
}
// Check if dist/index.html exists (production build available)
if (fs.existsSync(indexPath)) {
// Set no-cache headers for HTML to prevent service worker issues
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.sendFile(indexPath);
} else {
// In development, redirect to Vite dev server only if dist doesn't exist
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
}
});
// Helper function to convert permissions to rwx format
@@ -1886,6 +1972,9 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
}
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
// Show localhost in URL when binding to all interfaces (0.0.0.0 isn't a connectable address)
const DISPLAY_HOST = HOST === '0.0.0.0' ? 'localhost' : HOST;
// Initialize database and start server
async function startServer() {
@@ -1893,6 +1982,9 @@ async function startServer() {
// Initialize authentication database
await initializeDatabase();
// Configure Web Push (VAPID keys)
configureWebPush();
// Check if running in production mode (dist folder exists)
const distIndexPath = path.join(__dirname, '../dist/index.html');
const isProduction = fs.existsSync(distIndexPath);
@@ -1905,7 +1997,7 @@ async function startServer() {
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
}
server.listen(PORT, '0.0.0.0', async () => {
server.listen(PORT, HOST, async () => {
const appInstallPath = path.join(__dirname, '..');
console.log('');
@@ -1913,7 +2005,7 @@ async function startServer() {
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
console.log(c.dim('═'.repeat(63)));
console.log('');
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + PORT)}`);
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
console.log('');

View File

@@ -1,5 +1,6 @@
// Load environment variables from .env before other imports execute.
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
@@ -22,3 +23,7 @@ try {
} catch (e) {
console.log('No .env file found or error reading it:', e.message);
}
if (!process.env.DATABASE_PATH) {
process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
}

View File

@@ -85,7 +85,7 @@ const authenticateWebSocket = (token) => {
try {
const user = userDb.getFirstUser();
if (user) {
return { userId: user.id, username: user.username };
return { id: user.id, userId: user.id, username: user.username };
}
return null;
} catch (error) {
@@ -101,7 +101,10 @@ const authenticateWebSocket = (token) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);
return decoded;
return {
...decoded,
id: decoded.userId
};
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;
@@ -114,4 +117,4 @@ export {
generateToken,
authenticateWebSocket,
JWT_SECRET
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import { addProjectManually } from '../projects.js';
import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js';
import { Octokit } from '@octokit/rest';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { IS_PLATFORM } from '../constants/config.js';
@@ -629,7 +630,7 @@ class ResponseCollector {
* - Source for auto-generated branch names (if createBranch=true and no branchName)
* - Fallback for PR title if no commits are made
*
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor'
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'
* Default: 'claude'
*
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
@@ -747,7 +748,7 @@ class ResponseCollector {
* Input Validations (400 Bad Request):
* - Either githubUrl OR projectPath must be provided (not neither)
* - message must be non-empty string
* - provider must be 'claude' or 'cursor'
* - provider must be 'claude', 'cursor', 'codex', or 'gemini'
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
* - branchName must pass Git naming rules (if provided)
*
@@ -855,8 +856,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'message is required' });
}
if (!['claude', 'cursor', 'codex'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
}
// Validate GitHub branch/PR creation requirements
@@ -971,6 +972,16 @@ router.post('/', validateExternalApiKey, async (req, res) => {
model: model || CODEX_MODELS.DEFAULT,
permissionMode: 'bypassPermissions'
}, writer);
} else if (provider === 'gemini') {
console.log('✨ Starting Gemini CLI session');
await spawnGemini(message.trim(), {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: null,
model: model,
skipPermissions: true // CLI mode bypasses permissions
}, writer);
}
// Handle GitHub branch and PR creation after successful agent completion

View File

@@ -53,11 +53,11 @@ router.post('/register', async (req, res) => {
// Generate token
const token = generateToken(user);
// Update last login
db.prepare('COMMIT').run();
// Update last login (non-fatal, outside transaction)
userDb.updateLastLogin(user.id);
db.prepare('COMMIT').run();
res.json({
success: true,
user: { id: user.id, username: user.username },

View File

@@ -74,6 +74,46 @@ router.get('/codex/status', async (req, res) => {
}
});
router.get('/gemini/status', async (req, res) => {
try {
const result = await checkGeminiCredentials();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Gemini auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
/**
* Checks Claude authentication credentials using two methods with priority order:
*
* Priority 1: ANTHROPIC_API_KEY environment variable
* Priority 2: ~/.claude/.credentials.json OAuth tokens
*
* The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
* This matching behavior ensures consistency with how the SDK authenticates.
*
* References:
* - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
* "Claude Code prioritizes environment variable API keys over authenticated subscriptions"
* - https://platform.claude.com/docs/en/agent-sdk/overview
* SDK authentication documentation
*
* @returns {Promise<Object>} Authentication status with { authenticated, email, method }
* - authenticated: boolean indicating if valid credentials exist
* - email: user email or auth method identifier
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
*/
async function checkClaudeCredentials() {
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
@@ -260,4 +300,78 @@ async function checkCodexCredentials() {
}
}
async function checkGeminiCredentials() {
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth'
};
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await fs.readFile(credsPath, 'utf8');
const creds = JSON.parse(content);
if (creds.access_token) {
let email = 'OAuth Session';
try {
// Validate token against Google API
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
if (tokenRes.ok) {
const tokenInfo = await tokenRes.json();
if (tokenInfo.email) {
email = tokenInfo.email;
}
} else if (!creds.refresh_token) {
// Token invalid and no refresh token available
return {
authenticated: false,
email: null,
error: 'Access token invalid and no refresh token found'
};
} else {
// Token might be expired but we have a refresh token, so CLI will refresh it
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
if (accounts.active) {
email = accounts.active;
}
} catch (e) { }
}
} catch (e) {
// Network error, fallback to checking local accounts file
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
if (accounts.active) {
email = accounts.active;
}
} catch (err) { }
}
return {
authenticated: true,
email: email
};
}
return {
authenticated: false,
email: null,
error: 'No valid tokens found in oauth_creds'
};
} catch (error) {
return {
authenticated: false,
email: null,
error: 'Gemini CLI not configured'
};
}
}
export default router;

46
server/routes/gemini.js Normal file
View File

@@ -0,0 +1,46 @@
import express from 'express';
import sessionManager from '../sessionManager.js';
const router = express.Router();
router.get('/sessions/:sessionId/messages', async (req, res) => {
try {
const { sessionId } = req.params;
if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
}
const messages = sessionManager.getSessionMessages(sessionId);
res.json({
success: true,
messages: messages,
total: messages.length,
hasMore: false,
offset: 0,
limit: messages.length
});
} catch (error) {
console.error('Error fetching Gemini session messages:', error);
res.status(500).json({ success: false, error: error.message });
}
});
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
}
await sessionManager.deleteSession(sessionId);
res.json({ success: true });
} catch (error) {
console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
export default router;

View File

@@ -1,5 +1,6 @@
import express from 'express';
import { apiKeysDb, credentialsDb } from '../database/db.js';
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
import { getPublicKey } from '../services/vapid-keys.js';
const router = express.Router();
@@ -175,4 +176,70 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
}
});
// ===============================
// Notification Preferences
// ===============================
router.get('/notification-preferences', async (req, res) => {
try {
const preferences = notificationPreferencesDb.getPreferences(req.user.id);
res.json({ success: true, preferences });
} catch (error) {
console.error('Error fetching notification preferences:', error);
res.status(500).json({ error: 'Failed to fetch notification preferences' });
}
});
router.put('/notification-preferences', async (req, res) => {
try {
const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {});
res.json({ success: true, preferences });
} catch (error) {
console.error('Error saving notification preferences:', error);
res.status(500).json({ error: 'Failed to save notification preferences' });
}
});
// ===============================
// Push Subscription Management
// ===============================
router.get('/push/vapid-public-key', async (req, res) => {
try {
const publicKey = getPublicKey();
res.json({ publicKey });
} catch (error) {
console.error('Error fetching VAPID public key:', error);
res.status(500).json({ error: 'Failed to fetch VAPID public key' });
}
});
router.post('/push/subscribe', async (req, res) => {
try {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ error: 'Missing subscription fields' });
}
pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth);
res.json({ success: true });
} catch (error) {
console.error('Error saving push subscription:', error);
res.status(500).json({ error: 'Failed to save push subscription' });
}
});
router.post('/push/unsubscribe', async (req, res) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: 'Missing endpoint' });
}
pushSubscriptionsDb.removeSubscription(endpoint);
res.json({ success: true });
} catch (error) {
console.error('Error removing push subscription:', error);
res.status(500).json({ error: 'Failed to remove push subscription' });
}
});
export default router;

View File

@@ -0,0 +1,149 @@
import webPush from 'web-push';
import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
const KIND_TO_PREF_KEY = {
action_required: 'actionRequired',
stop: 'stop',
error: 'error'
};
const recentEventKeys = new Map();
const DEDUPE_WINDOW_MS = 20000;
const cleanupOldEventKeys = () => {
const now = Date.now();
for (const [key, timestamp] of recentEventKeys.entries()) {
if (now - timestamp > DEDUPE_WINDOW_MS) {
recentEventKeys.delete(key);
}
}
};
function shouldSendPush(preferences, event) {
const webPushEnabled = Boolean(preferences?.channels?.webPush);
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
return webPushEnabled && eventEnabled;
}
function isDuplicate(event) {
cleanupOldEventKeys();
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
if (recentEventKeys.has(key)) {
return true;
}
recentEventKeys.set(key, Date.now());
return false;
}
function createNotificationEvent({
provider,
sessionId = null,
kind = 'info',
code = 'generic.info',
meta = {},
severity = 'info',
dedupeKey = null,
requiresUserAction = false
}) {
return {
provider,
sessionId,
kind,
code,
meta,
severity,
requiresUserAction,
dedupeKey,
createdAt: new Date().toISOString()
};
}
function buildPushBody(event) {
const CODE_MAP = {
'permission.required': {
title: 'Action Required',
body: event.meta?.toolName
? `Tool "${event.meta.toolName}" needs approval`
: 'A tool needs your approval'
},
'run.stopped': {
title: 'Run Stopped',
body: event.meta?.stopReason || 'The run has stopped'
},
'run.failed': {
title: 'Run Failed',
body: event.meta?.error ? String(event.meta.error) : 'The run encountered an error'
},
'agent.notification': {
title: 'Agent Notification',
body: event.meta?.message ? String(event.meta.message) : 'You have a new notification'
}
};
const mapped = CODE_MAP[event.code];
return {
title: mapped?.title || 'Claude Code UI',
body: mapped?.body || 'You have a new notification',
data: {
sessionId: event.sessionId || null,
code: event.code
}
};
}
async function sendWebPush(userId, event) {
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
if (!subscriptions.length) return;
const payload = JSON.stringify(buildPushBody(event));
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webPush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.keys_p256dh,
auth: sub.keys_auth
}
},
payload
)
)
);
// Clean up gone subscriptions (410 Gone or 404)
results.forEach((result, index) => {
if (result.status === 'rejected') {
const statusCode = result.reason?.statusCode;
if (statusCode === 410 || statusCode === 404) {
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
}
}
});
}
function notifyUserIfEnabled({ userId, event }) {
if (!userId || !event) {
return;
}
const preferences = notificationPreferencesDb.getPreferences(userId);
if (!shouldSendPush(preferences, event)) {
return;
}
if (isDuplicate(event)) {
return;
}
sendWebPush(userId, event).catch((err) => {
console.error('Web push send error:', err);
});
}
export {
createNotificationEvent,
notifyUserIfEnabled
};

View File

@@ -0,0 +1,35 @@
import webPush from 'web-push';
import { db } from '../database/db.js';
let cachedKeys = null;
function ensureVapidKeys() {
if (cachedKeys) return cachedKeys;
const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get();
if (row) {
cachedKeys = { publicKey: row.public_key, privateKey: row.private_key };
return cachedKeys;
}
const keys = webPush.generateVAPIDKeys();
db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey);
cachedKeys = keys;
return cachedKeys;
}
function getPublicKey() {
return ensureVapidKeys().publicKey;
}
function configureWebPush() {
const keys = ensureVapidKeys();
webPush.setVapidDetails(
'mailto:noreply@claudecodeui.local',
keys.publicKey,
keys.privateKey
);
console.log('Web Push notifications configured');
}
export { ensureVapidKeys, getPublicKey, configureWebPush };

226
server/sessionManager.js Normal file
View File

@@ -0,0 +1,226 @@
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
class SessionManager {
constructor() {
// Store sessions in memory with conversation history
this.sessions = new Map();
this.maxSessions = 100;
this.sessionsDir = path.join(os.homedir(), '.gemini', 'sessions');
this.ready = this.init();
}
async init() {
await this.initSessionsDir();
await this.loadSessions();
}
async initSessionsDir() {
try {
await fs.mkdir(this.sessionsDir, { recursive: true });
} catch (error) {
// console.error('Error creating sessions directory:', error);
}
}
// Create a new session
createSession(sessionId, projectPath) {
const session = {
id: sessionId,
projectPath: projectPath,
messages: [],
createdAt: new Date(),
lastActivity: new Date()
};
// Evict oldest session from memory if we exceed limit
if (this.sessions.size >= this.maxSessions) {
const oldestKey = this.sessions.keys().next().value;
if (oldestKey) this.sessions.delete(oldestKey);
}
this.sessions.set(sessionId, session);
this.saveSession(sessionId);
return session;
}
// Add a message to session
addMessage(sessionId, role, content) {
let session = this.sessions.get(sessionId);
if (!session) {
// Create session if it doesn't exist
session = this.createSession(sessionId, '');
}
const message = {
role: role, // 'user' or 'assistant'
content: content,
timestamp: new Date()
};
session.messages.push(message);
session.lastActivity = new Date();
this.saveSession(sessionId);
return session;
}
// Get session by ID
getSession(sessionId) {
return this.sessions.get(sessionId);
}
// Get all sessions for a project
getProjectSessions(projectPath) {
const sessions = [];
for (const [id, session] of this.sessions) {
if (session.projectPath === projectPath) {
sessions.push({
id: session.id,
summary: this.getSessionSummary(session),
messageCount: session.messages.length,
lastActivity: session.lastActivity
});
}
}
return sessions.sort((a, b) =>
new Date(b.lastActivity) - new Date(a.lastActivity)
);
}
// Get session summary
getSessionSummary(session) {
if (session.messages.length === 0) {
return 'New Session';
}
// Find first user message
const firstUserMessage = session.messages.find(m => m.role === 'user');
if (firstUserMessage) {
const content = firstUserMessage.content;
return content.length > 50 ? content.substring(0, 50) + '...' : content;
}
return 'New Session';
}
// Build conversation context for Gemini
buildConversationContext(sessionId, maxMessages = 10) {
const session = this.sessions.get(sessionId);
if (!session || session.messages.length === 0) {
return '';
}
// Get last N messages for context
const recentMessages = session.messages.slice(-maxMessages);
let context = 'Here is the conversation history:\n\n';
for (const msg of recentMessages) {
if (msg.role === 'user') {
context += `User: ${msg.content}\n`;
} else {
context += `Assistant: ${msg.content}\n`;
}
}
context += '\nBased on the conversation history above, please answer the following:\n';
return context;
}
// Prevent path traversal
_safeFilePath(sessionId) {
const safeId = String(sessionId).replace(/[/\\]|\.\./g, '');
return path.join(this.sessionsDir, `${safeId}.json`);
}
// Save session to disk
async saveSession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
try {
const filePath = this._safeFilePath(sessionId);
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
} catch (error) {
// console.error('Error saving session:', error);
}
}
// Load sessions from disk
async loadSessions() {
try {
const files = await fs.readdir(this.sessionsDir);
for (const file of files) {
if (file.endsWith('.json')) {
try {
const filePath = path.join(this.sessionsDir, file);
const data = await fs.readFile(filePath, 'utf8');
const session = JSON.parse(data);
// Convert dates
session.createdAt = new Date(session.createdAt);
session.lastActivity = new Date(session.lastActivity);
session.messages.forEach(msg => {
msg.timestamp = new Date(msg.timestamp);
});
this.sessions.set(session.id, session);
} catch (error) {
// console.error(`Error loading session ${file}:`, error);
}
}
}
// Enforce eviction after loading to prevent massive memory usage
while (this.sessions.size > this.maxSessions) {
const oldestKey = this.sessions.keys().next().value;
if (oldestKey) this.sessions.delete(oldestKey);
}
} catch (error) {
// console.error('Error loading sessions:', error);
}
}
// Delete a session
async deleteSession(sessionId) {
this.sessions.delete(sessionId);
try {
const filePath = this._safeFilePath(sessionId);
await fs.unlink(filePath);
} catch (error) {
// console.error('Error deleting session file:', error);
}
}
// Get session messages for display
getSessionMessages(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return [];
return session.messages.map(msg => ({
type: 'message',
message: {
role: msg.role,
content: msg.content
},
timestamp: msg.timestamp.toISOString()
}));
}
}
// Singleton instance
const sessionManager = new SessionManager();
export const ready = sessionManager.ready;
export default sessionManager;

View File

@@ -63,5 +63,24 @@ export const CODEX_MODELS = {
{ value: 'o4-mini', label: 'O4-mini' }
],
DEFAULT: 'gpt-5.2'
DEFAULT: 'gpt-5.3-codex'
};
/**
* Gemini Models
*/
export const GEMINI_MODELS = {
OPTIONS: [
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }
],
DEFAULT: 'gemini-2.5-flash'
};

View File

@@ -1,373 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github } from 'lucide-react';
import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function ApiKeysSettings() {
const { t } = useTranslation('settings');
const [apiKeys, setApiKeys] = useState([]);
const [githubTokens, setGithubTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
const [showNewTokenForm, setShowNewTokenForm] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newTokenName, setNewTokenName] = useState('');
const [newGithubToken, setNewGithubToken] = useState('');
const [showToken, setShowToken] = useState({});
const [copiedKey, setCopiedKey] = useState(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
// Fetch API keys
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
const apiKeysData = await apiKeysRes.json();
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub tokens
const githubRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
const githubData = await githubRes.json();
setGithubTokens(githubData.credentials || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
setLoading(false);
}
};
const createApiKey = async () => {
if (!newKeyName.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/api-keys', {
method: 'POST',
body: JSON.stringify({ keyName: newKeyName })
});
const data = await res.json();
if (data.success) {
setNewlyCreatedKey(data.apiKey);
setNewKeyName('');
setShowNewKeyForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating API key:', error);
}
};
const deleteApiKey = async (keyId) => {
if (!confirm(t('apiKeys.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting API key:', error);
}
};
const toggleApiKey = async (keyId, isActive) => {
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling API key:', error);
}
};
const createGithubToken = async () => {
if (!newTokenName.trim() || !newGithubToken.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/credentials', {
method: 'POST',
body: JSON.stringify({
credentialName: newTokenName,
credentialType: 'github_token',
credentialValue: newGithubToken
})
});
const data = await res.json();
if (data.success) {
setNewTokenName('');
setNewGithubToken('');
setShowNewTokenForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating GitHub token:', error);
}
};
const deleteGithubToken = async (tokenId) => {
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/credentials/${tokenId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting GitHub token:', error);
}
};
const toggleGithubToken = async (tokenId, isActive) => {
try {
await authenticatedFetch(`/api/settings/credentials/${tokenId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling GitHub token:', error);
}
};
const copyToClipboard = (text, id) => {
navigator.clipboard.writeText(text);
setCopiedKey(id);
setTimeout(() => setCopiedKey(null), 2000);
};
if (loading) {
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
}
return (
<div className="space-y-8">
{/* New API Key Alert */}
{newlyCreatedKey && (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
<p className="text-sm text-muted-foreground mb-3">
{t('apiKeys.newKey.alertMessage')}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
{newlyCreatedKey.apiKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
>
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="mt-3"
onClick={() => setNewlyCreatedKey(null)}
>
{t('apiKeys.newKey.iveSavedIt')}
</Button>
</div>
)}
{/* API Keys Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Key className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.newButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('apiKeys.description')}
</p>
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder={t('apiKeys.form.placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
{t('apiKeys.form.cancelButton')}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
) : (
apiKeys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{key.key_name}</div>
<code className="text-xs text-muted-foreground">{key.api_key}</code>
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
{key.last_used && `${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)}
>
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteApiKey(key.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* GitHub Tokens Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Github className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewTokenForm(!showNewTokenForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.github.addButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('apiKeys.github.description')}
</p>
{showNewTokenForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder={t('apiKeys.github.form.namePlaceholder')}
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
className="mb-2"
/>
<div className="relative">
<Input
type={showToken['new'] ? 'text' : 'password'}
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
className="mb-2 pr-10"
/>
<button
type="button"
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
>
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="flex gap-2">
<Button onClick={createGithubToken}>{t('apiKeys.github.form.addButton')}</Button>
<Button variant="outline" onClick={() => {
setShowNewTokenForm(false);
setNewTokenName('');
setNewGithubToken('');
}}>
{t('apiKeys.github.form.cancelButton')}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{githubTokens.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
) : (
githubTokens.map((token) => (
<div
key={token.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{token.credential_name}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.github.added')} {new Date(token.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={token.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubToken(token.id, token.is_active)}
>
{token.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteGithubToken(token.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* Documentation Link */}
<div className="p-4 bg-muted/50 rounded-lg">
<h4 className="font-semibold mb-2">{t('apiKeys.documentation.title')}</h4>
<p className="text-sm text-muted-foreground mb-3">
{t('apiKeys.documentation.description')}
</p>
<a
href="/EXTERNAL_API.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
{t('apiKeys.documentation.viewLink')}
</a>
</div>
</div>
);
}
export default ApiKeysSettings;

View File

@@ -1,875 +0,0 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { StreamLanguage } from '@codemirror/language';
import { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
import { unifiedMergeView, getChunks } from '@codemirror/merge';
import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2, Settings as SettingsIcon } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
import { Eye, Code2 } from 'lucide-react';
// Custom .env file syntax highlighting
const envLanguage = StreamLanguage.define({
token(stream) {
// Comments
if (stream.match(/^#.*/)) return 'comment';
// Key (before =)
if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';
// Equals sign
if (stream.match(/^=/)) return 'operator';
// Double-quoted string
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
// Single-quoted string
if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string';
// Variable interpolation ${...}
if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special';
// Variable reference $VAR
if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';
// Numbers
if (stream.match(/^\d+/)) return 'number';
// Skip other characters
stream.next();
return null;
},
});
function MarkdownCodeBlock({ inline, className, children, ...props }) {
const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
const shouldInline = inline || !looksMultiline;
if (shouldInline) {
return (
<code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`}
{...props}
>
{children}
</code>
);
}
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text';
return (
<div className="relative group my-2">
{language && language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
)}
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(raw).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<SyntaxHighlighter
language={language}
style={prismOneDark}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
>
{raw}
</SyntaxHighlighter>
</div>
);
}
const markdownPreviewComponents = {
code: MarkdownCodeBlock,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
{children}
</blockquote>
),
a: ({ href, children }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
th: ({ children }) => (
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
),
td: ({ children }) => (
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
),
};
function MarkdownPreview({ content }) {
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeRaw, rehypeKatex], []);
return (
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownPreviewComponents}
>
{content}
</ReactMarkdown>
);
}
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null, onPopOut = null }) {
const { t } = useTranslation('codeEditor');
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(() => {
const savedTheme = localStorage.getItem('codeEditorTheme');
return savedTheme ? savedTheme === 'dark' : true;
});
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
const [wordWrap, setWordWrap] = useState(() => {
return localStorage.getItem('codeEditorWordWrap') === 'true';
});
const [minimapEnabled, setMinimapEnabled] = useState(() => {
return localStorage.getItem('codeEditorShowMinimap') !== 'false';
});
const [showLineNumbers, setShowLineNumbers] = useState(() => {
return localStorage.getItem('codeEditorLineNumbers') !== 'false';
});
const [fontSize, setFontSize] = useState(() => {
return localStorage.getItem('codeEditorFontSize') || '12';
});
const [markdownPreview, setMarkdownPreview] = useState(false);
const editorRef = useRef(null);
// Check if file is markdown
const isMarkdownFile = useMemo(() => {
const ext = file.name.split('.').pop()?.toLowerCase();
return ext === 'md' || ext === 'markdown';
}, [file.name]);
// Create minimap extension with chunk-based gutters
const minimapExtension = useMemo(() => {
if (!file.diffInfo || !showDiff || !minimapEnabled) return [];
const gutters = {};
return [
showMinimap.compute(['doc'], (state) => {
// Get actual chunks from merge view
const chunksData = getChunks(state);
const chunks = chunksData?.chunks || [];
// Clear previous gutters
Object.keys(gutters).forEach(key => delete gutters[key]);
// Mark lines that are part of chunks
chunks.forEach(chunk => {
// Mark the lines in the B side (current document)
const fromLine = state.doc.lineAt(chunk.fromB).number;
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
for (let lineNum = fromLine; lineNum <= toLine; lineNum++) {
gutters[lineNum] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
}
});
return {
create: () => ({ dom: document.createElement('div') }),
displayText: 'blocks',
showOverlay: 'always',
gutters: [gutters]
};
})
];
}, [file.diffInfo, showDiff, minimapEnabled, isDarkMode]);
// Create extension to scroll to first chunk on mount
const scrollToFirstChunkExtension = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
return [
ViewPlugin.fromClass(class {
constructor(view) {
// Delay to ensure merge view is fully initialized
setTimeout(() => {
const chunksData = getChunks(view.state);
const chunks = chunksData?.chunks || [];
if (chunks.length > 0) {
const firstChunk = chunks[0];
// Scroll to the first chunk
view.dispatch({
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' })
});
}
}, 100);
}
update() {}
destroy() {}
})
];
}, [file.diffInfo, showDiff]);
// Whether toolbar has any buttons worth showing
const hasToolbarButtons = !!(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));
// Create editor toolbar panel - only when there are buttons to show
const editorToolbarPanel = useMemo(() => {
if (!hasToolbarButtons) return [];
const createPanel = (view) => {
const dom = document.createElement('div');
dom.className = 'cm-editor-toolbar-panel';
let currentIndex = 0;
const updatePanel = () => {
// Check if we have diff info and it's enabled
const hasDiff = file.diffInfo && showDiff;
const chunksData = hasDiff ? getChunks(view.state) : null;
const chunks = chunksData?.chunks || [];
const chunkCount = chunks.length;
// Build the toolbar HTML
let toolbarHTML = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
// Left side - diff navigation (if applicable)
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) {
toolbarHTML += `
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${t('toolbar.changes')}</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${t('toolbar.previousChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${t('toolbar.nextChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
`;
}
toolbarHTML += '</div>';
// Right side - action buttons
toolbarHTML += '<div style="display: flex; align-items: center; gap: 4px;">';
// Show/hide diff button (only if there's diff info)
if (file.diffInfo) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? t('toolbar.hideDiff') : t('toolbar.showDiff')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${showDiff ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />'
}
</svg>
</button>
`;
}
// Pop out button (only in sidebar mode with onPopOut)
if (isSidebar && onPopOut) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-popout-btn" title="Open in modal">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</button>
`;
}
// Expand button (only in sidebar mode)
if (isSidebar && onToggleExpand) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? t('toolbar.collapse') : t('toolbar.expand')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${isExpanded ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />'
}
</svg>
</button>
`;
}
toolbarHTML += '</div>';
toolbarHTML += '</div>';
dom.innerHTML = toolbarHTML;
if (hasDiff) {
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
const nextBtn = dom.querySelector('.cm-diff-nav-next');
prevBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
nextBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
}
if (file.diffInfo) {
const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn');
toggleDiffBtn?.addEventListener('click', () => {
setShowDiff(!showDiff);
});
}
if (isSidebar && onPopOut) {
const popoutBtn = dom.querySelector('.cm-popout-btn');
popoutBtn?.addEventListener('click', () => {
onPopOut();
});
}
if (isSidebar && onToggleExpand) {
const expandBtn = dom.querySelector('.cm-expand-btn');
expandBtn?.addEventListener('click', () => {
onToggleExpand();
});
}
};
updatePanel();
return {
top: true,
dom,
update: updatePanel
};
};
return [showPanel.of(createPanel)];
}, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand, onPopOut]);
// Get language extension based on file extension
const getLanguageExtension = (filename) => {
const lowerName = filename.toLowerCase();
// Handle dotfiles like .env, .env.local, .env.production, etc.
if (lowerName === '.env' || lowerName.startsWith('.env.')) {
return [envLanguage];
}
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
case 'py':
return [python()];
case 'html':
case 'htm':
return [html()];
case 'css':
case 'scss':
case 'less':
return [css()];
case 'json':
return [json()];
case 'md':
case 'markdown':
return [markdown()];
case 'env':
return [envLanguage];
default:
return [];
}
};
// Load file content
useEffect(() => {
const loadFileContent = async () => {
try {
setLoading(true);
// If we have diffInfo with both old and new content, we can show the diff directly
// This handles both GitPanel (full content) and ChatInterface (full content from API)
if (file.diffInfo && file.diffInfo.new_string !== undefined && file.diffInfo.old_string !== undefined) {
// Use the new_string as the content to display
// The unifiedMergeView will compare it against old_string
setContent(file.diffInfo.new_string);
setLoading(false);
return;
}
// Otherwise, load from disk
const response = await api.readFile(file.projectName, file.path);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content);
} catch (error) {
console.error('Error loading file:', error);
setContent(`// Error loading file: ${error.message}\n// File: ${file.name}\n// Path: ${file.path}`);
} finally {
setLoading(false);
}
};
loadFileContent();
}, [file, projectPath]);
const handleSave = async () => {
setSaving(true);
try {
console.log('Saving file:', {
projectName: file.projectName,
path: file.path,
contentLength: content?.length
});
const response = await api.saveFile(file.projectName, file.path, content);
console.log('Save response:', {
status: response.status,
ok: response.ok,
contentType: response.headers.get('content-type')
});
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
} else {
const textError = await response.text();
console.error('Non-JSON error response:', textError);
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
}
}
const result = await response.json();
console.log('Save successful:', result);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
console.error('Error saving file:', error);
alert(`Error saving file: ${error.message}`);
} finally {
setSaving(false);
}
};
const handleDownload = () => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// Save theme preference to localStorage
useEffect(() => {
localStorage.setItem('codeEditorTheme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
// Save word wrap preference to localStorage
useEffect(() => {
localStorage.setItem('codeEditorWordWrap', wordWrap.toString());
}, [wordWrap]);
// Listen for settings changes from the Settings modal
useEffect(() => {
const handleStorageChange = () => {
const newTheme = localStorage.getItem('codeEditorTheme');
if (newTheme) {
setIsDarkMode(newTheme === 'dark');
}
const newWordWrap = localStorage.getItem('codeEditorWordWrap');
if (newWordWrap !== null) {
setWordWrap(newWordWrap === 'true');
}
const newShowMinimap = localStorage.getItem('codeEditorShowMinimap');
if (newShowMinimap !== null) {
setMinimapEnabled(newShowMinimap !== 'false');
}
const newShowLineNumbers = localStorage.getItem('codeEditorLineNumbers');
if (newShowLineNumbers !== null) {
setShowLineNumbers(newShowLineNumbers !== 'false');
}
const newFontSize = localStorage.getItem('codeEditorFontSize');
if (newFontSize) {
setFontSize(newFontSize);
}
};
// Listen for storage events (changes from other tabs/windows)
window.addEventListener('storage', handleStorageChange);
// Custom event for same-window updates
window.addEventListener('codeEditorSettingsChanged', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('codeEditorSettingsChanged', handleStorageChange);
};
}, []);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [content]);
if (loading) {
return (
<>
<style>
{`
.code-editor-loading {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-loading:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`}
</style>
{isSidebar ? (
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
</div>
</div>
) : (
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
</div>
</div>
</div>
)}
</>
);
}
return (
<>
<style>
{`
/* Light background for full line changes */
.cm-deletedChunk {
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
padding-left: 4px !important;
}
.cm-insertedChunk {
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
padding-left: 4px !important;
}
/* Override linear-gradient underline and use solid darker background for partial changes */
.cm-editor.cm-merge-b .cm-changedText {
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-editor .cm-deletedChunk .cm-changedText {
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
/* Minimap gutter styling */
.cm-gutter.cm-gutter-minimap {
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
}
/* Editor toolbar panel styling */
.cm-editor-toolbar-panel {
padding: 4px 10px;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
color: ${isDarkMode ? '#d1d5db' : '#374151'};
font-size: 12px;
}
.cm-diff-nav-btn,
.cm-toolbar-btn {
padding: 3px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
transition: background-color 0.2s;
}
.cm-diff-nav-btn:hover,
.cm-toolbar-btn:hover {
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
}
.cm-diff-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`}
</style>
<div className={isSidebar ?
'w-full h-full flex flex-col' :
`fixed inset-0 z-[9999] ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={isSidebar ?
'bg-background flex flex-col w-full h-full' :
`bg-background shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
{t('header.showingChanges')}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
{isMarkdownFile && (
<button
onClick={() => setMarkdownPreview(!markdownPreview)}
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
markdownPreview
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={markdownPreview ? t('actions.editMarkdown') : t('actions.previewMarkdown')}
>
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
<button
onClick={() => window.openSettings?.('appearance')}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('toolbar.settings')}
>
<SettingsIcon className="w-4 h-4" />
</button>
<button
onClick={handleDownload}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('actions.download')}
>
<Download className="w-4 h-4" />
</button>
<button
onClick={handleSave}
disabled={saving}
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
saveSuccess
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={saveSuccess ? t('actions.saved') : saving ? t('actions.saving') : t('actions.save')}
>
{saveSuccess ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<Save className="w-4 h-4" />
)}
</button>
{!isSidebar && (
<button
onClick={toggleFullscreen}
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? t('actions.exitFullscreen') : t('actions.fullscreen')}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<button
onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('actions.close')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Editor / Markdown Preview */}
<div className="flex-1 overflow-hidden">
{markdownPreview && isMarkdownFile ? (
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none">
<MarkdownPreview content={content} />
</div>
</div>
) : (
<CodeMirror
ref={editorRef}
value={content}
onChange={setContent}
extensions={[
...getLanguageExtension(file.name),
// Always show the toolbar
...editorToolbarPanel,
// Only show diff-related extensions when diff is enabled
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
? [
unifiedMergeView({
original: file.diffInfo.old_string,
mergeControls: false,
highlightChanges: true,
syntaxHighlightDeletions: false,
gutter: true
// NOTE: NO collapseUnchanged - this shows the full file!
}),
...minimapExtension,
...scrollToFirstChunkExtension
]
: []),
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: `${fontSize}px`,
height: '100%',
}}
basicSetup={{
lineNumbers: showLineNumbers,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
/>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0">
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<span>{t('footer.lines')} {content.split('\n').length}</span>
<span>{t('footer.characters')} {content.length}</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{t('footer.shortcuts')}
</div>
</div>
</div>
</div>
</>
);
}
export default CodeEditor;

View File

@@ -1,367 +0,0 @@
import React, { useEffect, useRef } from 'react';
/**
* CommandMenu - Autocomplete dropdown for slash commands
*
* @param {Array} commands - Array of command objects to display
* @param {number} selectedIndex - Currently selected command index (index in `commands`)
* @param {Function} onSelect - Callback when a command is selected
* @param {Function} onClose - Callback when menu should close
* @param {Object} position - Position object { top, left } for absolute positioning
* @param {boolean} isOpen - Whether the menu is open
* @param {Array} frequentCommands - Array of frequently used command objects
*/
const CommandMenu = ({
commands = [],
selectedIndex = -1,
onSelect,
onClose,
position = { top: 0, left: 0 },
isOpen = false,
frequentCommands = [],
}) => {
const menuRef = useRef(null);
const selectedItemRef = useRef(null);
// Calculate responsive menu positioning.
// Mobile: dock above chat input. Desktop: clamp to viewport.
const getMenuPosition = () => {
const isMobile = window.innerWidth < 640;
const viewportHeight = window.innerHeight;
if (isMobile) {
// On mobile, calculate bottom position dynamically to appear above the input.
// Use the bottom value calculated as: window.innerHeight - textarea.top + spacing.
const inputBottom = position.bottom || 90;
return {
position: 'fixed',
bottom: `${inputBottom}px`, // Position above the input with spacing already included.
left: '16px',
right: '16px',
width: 'auto',
maxWidth: 'calc(100vw - 32px)',
maxHeight: 'min(50vh, 300px)', // Limit to smaller of 50vh or 300px.
};
}
// On desktop, use provided position but ensure it stays on screen.
return {
position: 'fixed',
top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`,
left: `${position.left}px`,
width: 'min(400px, calc(100vw - 32px))',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '300px',
};
};
const menuPosition = getMenuPosition();
// Close menu when clicking outside.
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
return undefined;
}, [isOpen, onClose]);
// Keep selected keyboard item visible while navigating.
useEffect(() => {
if (selectedItemRef.current && menuRef.current) {
const menuRect = menuRef.current.getBoundingClientRect();
const itemRect = selectedItemRef.current.getBoundingClientRect();
if (itemRect.bottom > menuRect.bottom) {
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} else if (itemRect.top < menuRect.top) {
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}, [selectedIndex]);
if (!isOpen) {
return null;
}
// Show a message if no commands are available.
if (commands.length === 0) {
return (
<div
ref={menuRef}
className="command-menu command-menu-empty"
style={{
...menuPosition,
maxHeight: '300px',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
zIndex: 1000,
padding: '20px',
opacity: 1,
transform: 'translateY(0)',
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
textAlign: 'center',
}}
>
No commands available
</div>
);
}
// Add frequent commands as a special group if provided.
const hasFrequentCommands = frequentCommands.length > 0;
const getCommandKey = (command) =>
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
// Group commands by namespace for section rendering.
// When frequent commands are shown, avoid duplicate rows in other sections.
const groupedCommands = commands.reduce((groups, command) => {
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
return groups;
}
const namespace = command.namespace || command.type || 'other';
if (!groups[namespace]) {
groups[namespace] = [];
}
groups[namespace].push(command);
return groups;
}, {});
// Add frequent commands as a separate group.
if (hasFrequentCommands) {
groupedCommands.frequent = frequentCommands;
}
// Order: frequent, builtin, project, user, other.
const namespaceOrder = hasFrequentCommands
? ['frequent', 'builtin', 'project', 'user', 'other']
: ['builtin', 'project', 'user', 'other'];
const orderedNamespaces = namespaceOrder.filter((ns) => groupedCommands[ns]);
const namespaceLabels = {
frequent: '\u2B50 Frequently Used',
builtin: 'Built-in Commands',
project: 'Project Commands',
user: 'User Commands',
other: 'Other Commands',
};
// Keep all selection indices aligned to `commands` (filteredCommands from the hook).
// This prevents mismatches between mouse selection (rendered list) and keyboard selection.
const commandIndexByKey = new Map();
commands.forEach((command, index) => {
const key = getCommandKey(command);
if (!commandIndexByKey.has(key)) {
commandIndexByKey.set(key, index);
}
});
return (
<div
ref={menuRef}
role="listbox"
aria-label="Available commands"
className="command-menu"
style={{
...menuPosition,
maxHeight: '300px',
overflowY: 'auto',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
zIndex: 1000,
padding: '8px',
opacity: isOpen ? 1 : 0,
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
}}
>
{orderedNamespaces.map((namespace) => (
<div key={namespace} className="command-group">
{orderedNamespaces.length > 1 && (
<div
style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
color: '#6b7280',
padding: '8px 12px 4px',
letterSpacing: '0.05em',
}}
>
{namespaceLabels[namespace] || namespace}
</div>
)}
{groupedCommands[namespace].map((command) => {
const commandKey = getCommandKey(command);
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
const isSelected = commandIndex === selectedIndex;
return (
<div
key={`${namespace}-${command.name}-${command.path || ''}`}
ref={isSelected ? selectedItemRef : null}
role="option"
aria-selected={isSelected}
className="command-item"
onMouseEnter={() => {
if (onSelect && commandIndex >= 0) {
onSelect(command, commandIndex, true);
}
}}
onClick={() => {
if (onSelect) {
onSelect(command, commandIndex, false);
}
}}
style={{
display: 'flex',
alignItems: 'flex-start',
padding: '10px 12px',
borderRadius: '6px',
cursor: 'pointer',
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
transition: 'background-color 100ms ease-in-out',
marginBottom: '2px',
}}
// Prevent textarea blur when clicking a menu item.
onMouseDown={(e) => e.preventDefault()}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: command.description ? '4px' : 0,
}}
>
{/* Command icon based on namespace */}
<span style={{ fontSize: '16px', flexShrink: 0 }}>
{namespace === 'builtin' && '\u26A1'}
{namespace === 'project' && '\uD83D\uDCC1'}
{namespace === 'user' && '\uD83D\uDC64'}
{namespace === 'other' && '\uD83D\uDCDD'}
{namespace === 'frequent' && '\u2B50'}
</span>
{/* Command name */}
<span
style={{
fontWeight: 600,
fontSize: '14px',
color: '#111827',
fontFamily: 'monospace',
}}
>
{command.name}
</span>
{/* Command metadata badge */}
{command.metadata?.type && (
<span
className="command-metadata-badge"
style={{
fontSize: '10px',
padding: '2px 6px',
borderRadius: '4px',
backgroundColor: '#f3f4f6',
color: '#6b7280',
fontWeight: 500,
}}
>
{command.metadata.type}
</span>
)}
</div>
{/* Command description */}
{command.description && (
<div
style={{
fontSize: '13px',
color: '#6b7280',
marginLeft: '24px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{command.description}
</div>
)}
</div>
{/* Selection indicator */}
{isSelected && (
<span
style={{
marginLeft: '8px',
color: '#3b82f6',
fontSize: '12px',
fontWeight: 600,
}}
>
{'\u21B5'}
</span>
)}
</div>
);
})}
</div>
))}
{/* Default light mode styles */}
<style>{`
.command-menu {
background-color: white;
border: 1px solid #e5e7eb;
}
.command-menu-empty {
color: #6b7280;
}
@media (prefers-color-scheme: dark) {
.command-menu {
background-color: #1f2937 !important;
border: 1px solid #374151 !important;
}
.command-menu-empty {
color: #9ca3af !important;
}
.command-item[aria-selected="true"] {
background-color: #1e40af !important;
}
.command-item span:not(.command-metadata-badge) {
color: #f3f4f6 !important;
}
.command-metadata-badge {
background-color: #f3f4f6 !important;
color: #6b7280 !important;
}
.command-item div {
color: #d1d5db !important;
}
.command-group > div:first-child {
color: #9ca3af !important;
}
}
`}</style>
</div>
);
};
export default CommandMenu;

View File

@@ -1,421 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github, ExternalLink } from 'lucide-react';
import { useVersionCheck } from '../hooks/useVersionCheck';
import { version } from '../../package.json';
import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function CredentialsSettings() {
const { t } = useTranslation('settings');
const [apiKeys, setApiKeys] = useState([]);
const [githubCredentials, setGithubCredentials] = useState([]);
const [loading, setLoading] = useState(true);
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
const [showNewGithubForm, setShowNewGithubForm] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newGithubName, setNewGithubName] = useState('');
const [newGithubToken, setNewGithubToken] = useState('');
const [newGithubDescription, setNewGithubDescription] = useState('');
const [showToken, setShowToken] = useState({});
const [copiedKey, setCopiedKey] = useState(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
// Version check hook
const { updateAvailable, latestVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
// Fetch API keys
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
const apiKeysData = await apiKeysRes.json();
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub credentials only
const credentialsRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
const credentialsData = await credentialsRes.json();
setGithubCredentials(credentialsData.credentials || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
setLoading(false);
}
};
const createApiKey = async () => {
if (!newKeyName.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/api-keys', {
method: 'POST',
body: JSON.stringify({ keyName: newKeyName })
});
const data = await res.json();
if (data.success) {
setNewlyCreatedKey(data.apiKey);
setNewKeyName('');
setShowNewKeyForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating API key:', error);
}
};
const deleteApiKey = async (keyId) => {
if (!confirm(t('apiKeys.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting API key:', error);
}
};
const toggleApiKey = async (keyId, isActive) => {
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling API key:', error);
}
};
const createGithubCredential = async () => {
if (!newGithubName.trim() || !newGithubToken.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/credentials', {
method: 'POST',
body: JSON.stringify({
credentialName: newGithubName,
credentialType: 'github_token',
credentialValue: newGithubToken,
description: newGithubDescription
})
});
const data = await res.json();
if (data.success) {
setNewGithubName('');
setNewGithubToken('');
setNewGithubDescription('');
setShowNewGithubForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating GitHub credential:', error);
}
};
const deleteGithubCredential = async (credentialId) => {
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/credentials/${credentialId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting GitHub credential:', error);
}
};
const toggleGithubCredential = async (credentialId, isActive) => {
try {
await authenticatedFetch(`/api/settings/credentials/${credentialId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling GitHub credential:', error);
}
};
const copyToClipboard = (text, id) => {
navigator.clipboard.writeText(text);
setCopiedKey(id);
setTimeout(() => setCopiedKey(null), 2000);
};
if (loading) {
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
}
return (
<div className="space-y-8">
{/* New API Key Alert */}
{newlyCreatedKey && (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
<p className="text-sm text-muted-foreground mb-3">
{t('apiKeys.newKey.alertMessage')}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
{newlyCreatedKey.apiKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
>
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="mt-3"
onClick={() => setNewlyCreatedKey(null)}
>
{t('apiKeys.newKey.iveSavedIt')}
</Button>
</div>
)}
{/* API Keys Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Key className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.newButton')}
</Button>
</div>
<div className="mb-4">
<p className="text-sm text-muted-foreground mb-2">
{t('apiKeys.description')}
</p>
<a
href="/api-docs.html"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
{t('apiKeys.apiDocsLink')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder={t('apiKeys.form.placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
{t('apiKeys.form.cancelButton')}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
) : (
apiKeys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{key.key_name}</div>
<code className="text-xs text-muted-foreground">{key.api_key}</code>
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
{key.last_used && `${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)}
>
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteApiKey(key.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* GitHub Credentials Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Github className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewGithubForm(!showNewGithubForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.github.addButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('apiKeys.github.descriptionAlt')}
</p>
{showNewGithubForm && (
<div className="mb-4 p-4 border rounded-lg bg-card space-y-3">
<Input
placeholder={t('apiKeys.github.form.namePlaceholder')}
value={newGithubName}
onChange={(e) => setNewGithubName(e.target.value)}
/>
<div className="relative">
<Input
type={showToken['new'] ? 'text' : 'password'}
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
>
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Input
placeholder={t('apiKeys.github.form.descriptionPlaceholder')}
value={newGithubDescription}
onChange={(e) => setNewGithubDescription(e.target.value)}
/>
<div className="flex gap-2">
<Button onClick={createGithubCredential}>{t('apiKeys.github.form.addButton')}</Button>
<Button variant="outline" onClick={() => {
setShowNewGithubForm(false);
setNewGithubName('');
setNewGithubToken('');
setNewGithubDescription('');
}}>
{t('apiKeys.github.form.cancelButton')}
</Button>
</div>
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline block"
>
{t('apiKeys.github.form.howToCreate')}
</a>
</div>
)}
<div className="space-y-2">
{githubCredentials.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
) : (
githubCredentials.map((credential) => (
<div
key={credential.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{credential.credential_name}</div>
{credential.description && (
<div className="text-xs text-muted-foreground">{credential.description}</div>
)}
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.github.added')} {new Date(credential.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={credential.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubCredential(credential.id, credential.is_active)}
>
{credential.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteGithubCredential(credential.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* Version Information */}
<div className="pt-6 border-t border-border/50">
<div className="flex items-center justify-between text-xs italic text-muted-foreground/60">
<a
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
target="_blank"
rel="noopener noreferrer"
className="hover:text-muted-foreground transition-colors"
>
v{version}
</a>
{updateAvailable && latestVersion && (
<a
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors not-italic font-medium"
>
<span className="text-[10px]">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
</div>
</div>
);
}
export default CredentialsSettings;

View File

@@ -1,35 +0,0 @@
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
function DarkModeToggle() {
const { isDarkMode, toggleDarkMode } = useTheme();
return (
<button
onClick={toggleDarkMode}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={isDarkMode}
aria-label="Toggle dark mode"
>
<span className="sr-only">Toggle dark mode</span>
<span
className={`${
isDarkMode ? 'translate-x-7' : 'translate-x-1'
} inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
>
{isDarkMode ? (
<svg className="w-3.5 h-3.5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</span>
</button>
);
}
export default DarkModeToggle;

View File

@@ -0,0 +1,48 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
type DarkModeToggleProps = {
checked?: boolean;
onToggle?: (nextValue: boolean) => void;
ariaLabel?: string;
};
function DarkModeToggle({ checked, onToggle, ariaLabel = 'Toggle dark mode' }: DarkModeToggleProps) {
const { isDarkMode, toggleDarkMode } = useTheme();
const isControlled = typeof checked === 'boolean' && typeof onToggle === 'function';
const isEnabled = isControlled ? checked : isDarkMode;
const handleToggle = () => {
if (isControlled) {
onToggle(!isEnabled);
return;
}
toggleDarkMode();
};
return (
<button
onClick={handleToggle}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={isEnabled}
aria-label={ariaLabel}
>
<span className="sr-only">{ariaLabel}</span>
<span
className={`${
isEnabled ? 'translate-x-7' : 'translate-x-1'
} h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
>
{isEnabled ? (
<Moon className="h-3.5 w-3.5 text-gray-700" />
) : (
<Sun className="h-3.5 w-3.5 text-yellow-500" />
)}
</span>
</button>
);
}
export default DarkModeToggle;

View File

@@ -1,73 +1,77 @@
import React from 'react';
import React, { useCallback, useState } from 'react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Log the error details
console.error('ErrorBoundary caught an error:', error, errorInfo);
// You can also log the error to an error reporting service here
this.setState({
error: error,
errorInfo: errorInfo
});
}
render() {
if (this.state.hasError) {
// Fallback UI
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div>
<div className="text-sm text-red-700">
<p className="mb-2">An error occurred while loading the chat interface.</p>
{this.props.showDetails && this.state.error && (
<details className="mt-4">
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{this.state.error.toString()}
{this.state.errorInfo && this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={() => {
this.setState({ hasError: false, error: null, errorInfo: null });
if (this.props.onRetry) this.props.onRetry();
}}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
function ErrorFallback({ error, resetErrorBoundary, showDetails, componentStack }) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div>
);
}
return this.props.children;
}
<div className="text-sm text-red-700">
<p className="mb-2">An error occurred while loading the chat interface.</p>
{showDetails && error && (
<details className="mt-4">
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{error.toString()}
{componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={resetErrorBoundary}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
</div>
</div>
);
}
export default ErrorBoundary;
function ErrorBoundary({ children, showDetails = false, onRetry = undefined, resetKeys = undefined }) {
const [componentStack, setComponentStack] = useState(null);
const handleError = useCallback((error, errorInfo) => {
console.error('ErrorBoundary caught an error:', error, errorInfo);
setComponentStack(errorInfo?.componentStack || null);
}, []);
const handleReset = useCallback(() => {
setComponentStack(null);
onRetry?.();
}, [onRetry]);
const renderFallback = useCallback(({ error, resetErrorBoundary }) => (
<ErrorFallback
error={error}
resetErrorBoundary={resetErrorBoundary}
showDetails={showDetails}
componentStack={componentStack}
/>
), [showDetails, componentStack]);
return (
<ReactErrorBoundary
fallbackRender={renderFallback}
onError={handleError}
onReset={handleReset}
resetKeys={resetKeys}
>
{children}
</ReactErrorBoundary>
);
}
export default ErrorBoundary;

View File

@@ -1,729 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Input } from './ui/input';
import {
Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X,
ChevronRight,
FileJson, FileType, FileSpreadsheet, FileArchive,
Hash, Braces, Terminal, Database, Globe, Palette, Music2, Video, Archive,
Lock, Shield, Settings, Image, BookOpen, Cpu, Box, Gem, Coffee,
Flame, Hexagon, FileCode2, Code2, Cog, FileWarning, Binary, SquareFunction,
Scroll, FlaskConical, NotebookPen, FileCheck, Workflow, Blocks
} from 'lucide-react';
import { cn } from '../lib/utils';
import ImageViewer from './ImageViewer';
import { api } from '../utils/api';
// ─── File Icon Registry ──────────────────────────────────────────────
// Maps file extensions (and special filenames) to { icon, colorClass } pairs.
// Uses lucide-react icons mapped semantically to file types.
const ICON_SIZE = 'w-4 h-4 flex-shrink-0';
const FILE_ICON_MAP = {
// ── JavaScript / TypeScript ──
js: { icon: FileCode, color: 'text-yellow-500' },
jsx: { icon: FileCode, color: 'text-yellow-500' },
mjs: { icon: FileCode, color: 'text-yellow-500' },
cjs: { icon: FileCode, color: 'text-yellow-500' },
ts: { icon: FileCode2, color: 'text-blue-500' },
tsx: { icon: FileCode2, color: 'text-blue-500' },
mts: { icon: FileCode2, color: 'text-blue-500' },
// ── Python ──
py: { icon: Code2, color: 'text-emerald-500' },
pyw: { icon: Code2, color: 'text-emerald-500' },
pyi: { icon: Code2, color: 'text-emerald-400' },
ipynb:{ icon: NotebookPen, color: 'text-orange-500' },
// ── Rust ──
rs: { icon: Cog, color: 'text-orange-600' },
toml: { icon: Settings, color: 'text-gray-500' },
// ── Go ──
go: { icon: Hexagon, color: 'text-cyan-500' },
// ── Ruby ──
rb: { icon: Gem, color: 'text-red-500' },
erb: { icon: Gem, color: 'text-red-400' },
// ── PHP ──
php: { icon: Blocks, color: 'text-violet-500' },
// ── Java / Kotlin ──
java: { icon: Coffee, color: 'text-red-600' },
jar: { icon: Coffee, color: 'text-red-500' },
kt: { icon: Hexagon, color: 'text-violet-500' },
kts: { icon: Hexagon, color: 'text-violet-400' },
// ── C / C++ ──
c: { icon: Cpu, color: 'text-blue-600' },
h: { icon: Cpu, color: 'text-blue-400' },
cpp: { icon: Cpu, color: 'text-blue-700' },
hpp: { icon: Cpu, color: 'text-blue-500' },
cc: { icon: Cpu, color: 'text-blue-700' },
// ── C# ──
cs: { icon: Hexagon, color: 'text-purple-600' },
// ── Swift ──
swift:{ icon: Flame, color: 'text-orange-500' },
// ── Lua ──
lua: { icon: SquareFunction, color: 'text-blue-500' },
// ── R ──
r: { icon: FlaskConical, color: 'text-blue-600' },
// ── Web ──
html: { icon: Globe, color: 'text-orange-600' },
htm: { icon: Globe, color: 'text-orange-600' },
css: { icon: Hash, color: 'text-blue-500' },
scss: { icon: Hash, color: 'text-pink-500' },
sass: { icon: Hash, color: 'text-pink-400' },
less: { icon: Hash, color: 'text-indigo-500' },
vue: { icon: FileCode2, color: 'text-emerald-500' },
svelte:{ icon: FileCode2, color: 'text-orange-500' },
// ── Data / Config ──
json: { icon: Braces, color: 'text-yellow-600' },
jsonc:{ icon: Braces, color: 'text-yellow-500' },
json5:{ icon: Braces, color: 'text-yellow-500' },
yaml: { icon: Settings, color: 'text-purple-400' },
yml: { icon: Settings, color: 'text-purple-400' },
xml: { icon: FileCode, color: 'text-orange-500' },
csv: { icon: FileSpreadsheet, color: 'text-green-600' },
tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
sql: { icon: Database, color: 'text-blue-500' },
graphql:{ icon: Workflow, color: 'text-pink-500' },
gql: { icon: Workflow, color: 'text-pink-500' },
proto:{ icon: Box, color: 'text-green-500' },
env: { icon: Shield, color: 'text-yellow-600' },
// ── Documents ──
md: { icon: BookOpen, color: 'text-blue-500' },
mdx: { icon: BookOpen, color: 'text-blue-400' },
txt: { icon: FileText, color: 'text-gray-500' },
doc: { icon: FileText, color: 'text-blue-600' },
docx: { icon: FileText, color: 'text-blue-600' },
pdf: { icon: FileCheck, color: 'text-red-600' },
rtf: { icon: FileText, color: 'text-gray-500' },
tex: { icon: Scroll, color: 'text-teal-600' },
rst: { icon: FileText, color: 'text-gray-400' },
// ── Shell / Scripts ──
sh: { icon: Terminal, color: 'text-green-500' },
bash: { icon: Terminal, color: 'text-green-500' },
zsh: { icon: Terminal, color: 'text-green-400' },
fish: { icon: Terminal, color: 'text-green-400' },
ps1: { icon: Terminal, color: 'text-blue-400' },
bat: { icon: Terminal, color: 'text-gray-500' },
cmd: { icon: Terminal, color: 'text-gray-500' },
// ── Images ──
png: { icon: Image, color: 'text-purple-500' },
jpg: { icon: Image, color: 'text-purple-500' },
jpeg: { icon: Image, color: 'text-purple-500' },
gif: { icon: Image, color: 'text-purple-400' },
webp: { icon: Image, color: 'text-purple-400' },
ico: { icon: Image, color: 'text-purple-400' },
bmp: { icon: Image, color: 'text-purple-400' },
tiff: { icon: Image, color: 'text-purple-400' },
svg: { icon: Palette, color: 'text-amber-500' },
// ── Audio ──
mp3: { icon: Music2, color: 'text-pink-500' },
wav: { icon: Music2, color: 'text-pink-500' },
ogg: { icon: Music2, color: 'text-pink-400' },
flac: { icon: Music2, color: 'text-pink-400' },
aac: { icon: Music2, color: 'text-pink-400' },
m4a: { icon: Music2, color: 'text-pink-400' },
// ── Video ──
mp4: { icon: Video, color: 'text-rose-500' },
mov: { icon: Video, color: 'text-rose-500' },
avi: { icon: Video, color: 'text-rose-500' },
webm: { icon: Video, color: 'text-rose-400' },
mkv: { icon: Video, color: 'text-rose-400' },
// ── Fonts ──
ttf: { icon: FileType, color: 'text-red-500' },
otf: { icon: FileType, color: 'text-red-500' },
woff: { icon: FileType, color: 'text-red-400' },
woff2:{ icon: FileType, color: 'text-red-400' },
eot: { icon: FileType, color: 'text-red-400' },
// ── Archives ──
zip: { icon: Archive, color: 'text-amber-600' },
tar: { icon: Archive, color: 'text-amber-600' },
gz: { icon: Archive, color: 'text-amber-600' },
bz2: { icon: Archive, color: 'text-amber-600' },
rar: { icon: Archive, color: 'text-amber-500' },
'7z': { icon: Archive, color: 'text-amber-500' },
// ── Lock files ──
lock: { icon: Lock, color: 'text-gray-500' },
// ── Binary / Executable ──
exe: { icon: Binary, color: 'text-gray-500' },
bin: { icon: Binary, color: 'text-gray-500' },
dll: { icon: Binary, color: 'text-gray-400' },
so: { icon: Binary, color: 'text-gray-400' },
dylib:{ icon: Binary, color: 'text-gray-400' },
wasm: { icon: Binary, color: 'text-purple-500' },
// ── Misc config ──
ini: { icon: Settings, color: 'text-gray-500' },
cfg: { icon: Settings, color: 'text-gray-500' },
conf: { icon: Settings, color: 'text-gray-500' },
log: { icon: Scroll, color: 'text-gray-400' },
map: { icon: File, color: 'text-gray-400' },
};
// Special full-filename matches (highest priority)
const FILENAME_ICON_MAP = {
'Dockerfile': { icon: Box, color: 'text-blue-500' },
'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
'.dockerignore': { icon: Box, color: 'text-gray-500' },
'.gitignore': { icon: Settings, color: 'text-gray-500' },
'.gitmodules': { icon: Settings, color: 'text-gray-500' },
'.gitattributes': { icon: Settings, color: 'text-gray-500' },
'.editorconfig': { icon: Settings, color: 'text-gray-500' },
'.prettierrc': { icon: Settings, color: 'text-pink-400' },
'.prettierignore': { icon: Settings, color: 'text-gray-500' },
'.eslintrc': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
'eslint.config.mjs':{ icon: Settings, color: 'text-violet-500' },
'.env': { icon: Shield, color: 'text-yellow-600' },
'.env.local': { icon: Shield, color: 'text-yellow-600' },
'.env.development': { icon: Shield, color: 'text-yellow-500' },
'.env.production': { icon: Shield, color: 'text-yellow-600' },
'.env.example': { icon: Shield, color: 'text-yellow-400' },
'package.json': { icon: Braces, color: 'text-green-500' },
'package-lock.json':{ icon: Lock, color: 'text-gray-500' },
'yarn.lock': { icon: Lock, color: 'text-blue-400' },
'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
'bun.lockb': { icon: Lock, color: 'text-gray-400' },
'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
'Gemfile': { icon: Gem, color: 'text-red-500' },
'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
'Makefile': { icon: Terminal, color: 'text-gray-500' },
'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
'vite.config.js': { icon: Flame, color: 'text-purple-500' },
'webpack.config.js':{ icon: Cog, color: 'text-blue-500' },
'tailwind.config.js':{ icon: Hash, color: 'text-cyan-500' },
'tailwind.config.ts':{ icon: Hash, color: 'text-cyan-500' },
'postcss.config.js':{ icon: Cog, color: 'text-red-400' },
'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
'.babelrc': { icon: Settings, color: 'text-yellow-500' },
'README.md': { icon: BookOpen, color: 'text-blue-500' },
'LICENSE': { icon: FileCheck, color: 'text-gray-500' },
'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
'go.sum': { icon: Lock, color: 'text-cyan-400' },
};
function getFileIconData(filename) {
// 1. Exact filename match
if (FILENAME_ICON_MAP[filename]) {
return FILENAME_ICON_MAP[filename];
}
// 2. Check for .env prefix pattern
if (filename.startsWith('.env')) {
return { icon: Shield, color: 'text-yellow-600' };
}
// 3. Extension-based lookup
const ext = filename.split('.').pop()?.toLowerCase();
if (ext && FILE_ICON_MAP[ext]) {
return FILE_ICON_MAP[ext];
}
// 4. Fallback
return { icon: File, color: 'text-muted-foreground' };
}
// ─── Component ───────────────────────────────────────────────────────
function FileTree({ selectedProject, onFileOpen }) {
const { t } = useTranslation();
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [expandedDirs, setExpandedDirs] = useState(new Set());
const [selectedImage, setSelectedImage] = useState(null);
const [viewMode, setViewMode] = useState('detailed');
const [searchQuery, setSearchQuery] = useState('');
const [filteredFiles, setFilteredFiles] = useState([]);
useEffect(() => {
if (selectedProject) {
fetchFiles();
}
}, [selectedProject]);
useEffect(() => {
const savedViewMode = localStorage.getItem('file-tree-view-mode');
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
setViewMode(savedViewMode);
}
}, []);
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredFiles(files);
} else {
const filtered = filterFiles(files, searchQuery.toLowerCase());
setFilteredFiles(filtered);
const expandMatches = (items) => {
items.forEach(item => {
if (item.type === 'directory' && item.children && item.children.length > 0) {
setExpandedDirs(prev => new Set(prev.add(item.path)));
expandMatches(item.children);
}
});
};
expandMatches(filtered);
}
}, [files, searchQuery]);
const filterFiles = (items, query) => {
return items.reduce((filtered, item) => {
const matchesName = item.name.toLowerCase().includes(query);
let filteredChildren = [];
if (item.type === 'directory' && item.children) {
filteredChildren = filterFiles(item.children, query);
}
if (matchesName || filteredChildren.length > 0) {
filtered.push({
...item,
children: filteredChildren
});
}
return filtered;
}, []);
};
const fetchFiles = async () => {
setLoading(true);
try {
const response = await api.getFiles(selectedProject.name);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ File fetch failed:', response.status, errorText);
setFiles([]);
return;
}
const data = await response.json();
setFiles(data);
} catch (error) {
console.error('❌ Error fetching files:', error);
setFiles([]);
} finally {
setLoading(false);
}
};
const toggleDirectory = (path) => {
const newExpanded = new Set(expandedDirs);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedDirs(newExpanded);
};
const changeViewMode = (mode) => {
setViewMode(mode);
localStorage.setItem('file-tree-view-mode', mode);
};
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const formatRelativeTime = (date) => {
if (!date) return '-';
const now = new Date();
const past = new Date(date);
const diffInSeconds = Math.floor((now - past) / 1000);
if (diffInSeconds < 60) return t('fileTree.justNow');
if (diffInSeconds < 3600) return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });
if (diffInSeconds < 86400) return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });
if (diffInSeconds < 2592000) return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });
return past.toLocaleDateString();
};
const isImageFile = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
return imageExtensions.includes(ext);
};
const getFileIcon = (filename) => {
const { icon: Icon, color } = getFileIconData(filename);
return <Icon className={cn(ICON_SIZE, color)} />;
};
// ── Click handler shared across all view modes ──
const handleItemClick = (item) => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else if (onFileOpen) {
onFileOpen(item.path);
}
};
// ── Indent guide + folder/file icon rendering ──
const renderIndentGuides = (level) => {
if (level === 0) return null;
return (
<span className="flex items-center flex-shrink-0" aria-hidden="true">
{Array.from({ length: level }).map((_, i) => (
<span
key={i}
className="inline-block w-4 h-full border-l border-border/50"
/>
))}
</span>
);
};
const renderItemIcons = (item) => {
const isDir = item.type === 'directory';
const isOpen = expandedDirs.has(item.path);
if (isDir) {
return (
<span className="flex items-center gap-0.5 flex-shrink-0">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',
isOpen && 'rotate-90'
)}
/>
{isOpen ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</span>
);
}
return (
<span className="flex items-center flex-shrink-0 ml-[18px]">
{getFileIcon(item.name)}
</span>
);
};
// ─── Simple (Tree) View ────────────────────────────────────────────
const renderFileTree = (items, level = 0) => {
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm',
'hover:bg-accent/60 transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
{isDir && isOpen && item.children && item.children.length > 0 && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderFileTree(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Detailed View ────────────────────────────────────────────────
const renderDetailedView = (items, level = 0) => {
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group grid grid-cols-12 gap-2 py-[3px] pr-2 hover:bg-accent/60 cursor-pointer items-center rounded-sm transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
{item.type === 'file' ? formatFileSize(item.size) : ''}
</div>
<div className="col-span-3 text-sm text-muted-foreground">
{formatRelativeTime(item.modified)}
</div>
<div className="col-span-2 text-sm text-muted-foreground font-mono">
{item.permissionsRwx || ''}
</div>
</div>
{isDir && isOpen && item.children && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderDetailedView(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Compact View ──────────────────────────────────────────────────
const renderCompactView = (items, level = 0) => {
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
<div className="flex items-center gap-1.5 min-w-0">
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
{item.type === 'file' && (
<>
<span className="tabular-nums">{formatFileSize(item.size)}</span>
<span className="font-mono">{item.permissionsRwx}</span>
</>
)}
</div>
</div>
{isDir && isOpen && item.children && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderCompactView(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Loading state ─────────────────────────────────────────────────
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-muted-foreground text-sm">
{t('fileTree.loading')}
</div>
</div>
);
}
// ─── Main render ───────────────────────────────────────────────────
return (
<div className="h-full flex flex-col bg-background">
{/* Header */}
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">
{t('fileTree.files')}
</h3>
<div className="flex gap-0.5">
<Button
variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('simple')}
title={t('fileTree.simpleView')}
>
<List className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('compact')}
title={t('fileTree.compactView')}
>
<Eye className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('detailed')}
title={t('fileTree.detailedView')}
>
<TableProperties className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={t('fileTree.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-8 text-sm"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-0.5 top-1/2 transform -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
onClick={() => setSearchQuery('')}
title={t('fileTree.clearSearch')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
{/* Column Headers for Detailed View */}
{viewMode === 'detailed' && filteredFiles.length > 0 && (
<div className="px-3 pt-1.5 pb-1 border-b border-border">
<div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
<div className="col-span-5">{t('fileTree.name')}</div>
<div className="col-span-2">{t('fileTree.size')}</div>
<div className="col-span-3">{t('fileTree.modified')}</div>
<div className="col-span-2">{t('fileTree.permissions')}</div>
</div>
</div>
)}
<ScrollArea className="flex-1 px-2 py-1">
{files.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Folder className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noFilesFound')}</h4>
<p className="text-sm text-muted-foreground">
{t('fileTree.checkProjectPath')}
</p>
</div>
) : filteredFiles.length === 0 && searchQuery ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noMatchesFound')}</h4>
<p className="text-sm text-muted-foreground">
{t('fileTree.tryDifferentSearch')}
</p>
</div>
) : (
<div>
{viewMode === 'simple' && renderFileTree(filteredFiles)}
{viewMode === 'compact' && renderCompactView(filteredFiles)}
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
</div>
)}
</ScrollArea>
{/* Image Viewer Modal */}
{selectedImage && (
<ImageViewer
file={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}
export default FileTree;

View File

@@ -0,0 +1,9 @@
import React from 'react';
const GeminiLogo = ({className = 'w-5 h-5'}) => {
return (
<img src="/icons/gemini-ai-icon.svg" alt="Gemini" className={className} />
);
};
export default GeminiLogo;

View File

@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react';
import { cn } from '../lib/utils';
function GeminiStatus({ status, onAbort, isLoading }) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
// Update elapsed time every second
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
return;
}
const startTime = Date.now();
const timer = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed);
}, 1000);
return () => clearInterval(timer);
}, [isLoading]);
// Animate the status indicator
useEffect(() => {
if (!isLoading) return;
const timer = setInterval(() => {
setAnimationPhase(prev => (prev + 1) % 4);
}, 500);
return () => clearInterval(timer);
}, [isLoading]);
if (!isLoading) return null;
// Clever action words that cycle
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
// Parse status data
const statusText = status?.text || actionWords[actionIndex];
const canInterrupt = status?.can_interrupt !== false;
// Animation characters
const spinners = ['✻', '✹', '✸', '✶'];
const currentSpinner = spinners[animationPhase];
return (
<div className="w-full mb-6 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gradient-to-r from-cyan-900 to-blue-900 dark:from-cyan-950 dark:to-blue-950 text-white rounded-lg shadow-lg px-4 py-3">
<div className="flex-1">
<div className="flex items-center gap-3">
{/* Animated spinner */}
<span className={cn(
"text-xl transition-all duration-500",
animationPhase % 2 === 0 ? "text-cyan-400 scale-110" : "text-cyan-300"
)}>
{currentSpinner}
</span>
{/* Status text - first line */}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{statusText}...</span>
<span className="text-gray-400 text-sm">({elapsedTime}s)</span>
</div>
</div>
</div>
</div>
{/* Interrupt button */}
{canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-3 text-xs bg-red-600 hover:bg-red-700 text-white px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1.5 flex-shrink-0"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="hidden sm:inline">Stop</span>
</button>
)}
</div>
</div>
);
}
export default GeminiStatus;

File diff suppressed because it is too large Load Diff

View File

@@ -1,131 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { GitBranch, Check } from 'lucide-react';
import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function GitSettings() {
const { t } = useTranslation('settings');
const [gitName, setGitName] = useState('');
const [gitEmail, setGitEmail] = useState('');
const [gitConfigLoading, setGitConfigLoading] = useState(false);
const [gitConfigSaving, setGitConfigSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState(null);
useEffect(() => {
loadGitConfig();
}, []);
const loadGitConfig = async () => {
try {
setGitConfigLoading(true);
const response = await authenticatedFetch('/api/user/git-config');
if (response.ok) {
const data = await response.json();
setGitName(data.gitName || '');
setGitEmail(data.gitEmail || '');
}
} catch (error) {
console.error('Error loading git config:', error);
} finally {
setGitConfigLoading(false);
}
};
const saveGitConfig = async () => {
try {
setGitConfigSaving(true);
const response = await authenticatedFetch('/api/user/git-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gitName, gitEmail })
});
if (response.ok) {
setSaveStatus('success');
setTimeout(() => setSaveStatus(null), 3000);
} else {
const data = await response.json();
setSaveStatus('error');
console.error('Failed to save git config:', data.error);
}
} catch (error) {
console.error('Error saving git config:', error);
setSaveStatus('error');
} finally {
setGitConfigSaving(false);
}
};
return (
<div className="space-y-8">
<div>
<div className="flex items-center gap-2 mb-4">
<GitBranch className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('git.title')}</h3>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('git.description')}
</p>
<div className="p-4 border rounded-lg bg-card space-y-3">
<div>
<label htmlFor="settings-git-name" className="block text-sm font-medium text-foreground mb-2">
{t('git.name.label')}
</label>
<Input
id="settings-git-name"
type="text"
value={gitName}
onChange={(e) => setGitName(e.target.value)}
placeholder="John Doe"
disabled={gitConfigLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('git.name.help')}
</p>
</div>
<div>
<label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2">
{t('git.email.label')}
</label>
<Input
id="settings-git-email"
type="email"
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
placeholder="john@example.com"
disabled={gitConfigLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('git.email.help')}
</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={saveGitConfig}
disabled={gitConfigSaving || !gitName || !gitEmail}
>
{gitConfigSaving ? t('git.actions.saving') : t('git.actions.save')}
</Button>
{saveStatus === 'success' && (
<div className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
<Check className="w-4 h-4" />
{t('git.status.success')}
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default GitSettings;

View File

@@ -1,14 +1,14 @@
import { X } from 'lucide-react';
import StandaloneShell from './StandaloneShell';
import { X, ExternalLink, KeyRound } from 'lucide-react';
import StandaloneShell from './standalone-shell/view/StandaloneShell';
import { IS_PLATFORM } from '../constants/config';
/**
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication
* Reusable login modal component for Claude, Cursor, Codex, and Gemini CLI authentication
*
* @param {Object} props
* @param {boolean} props.isOpen - Whether the modal is visible
* @param {Function} props.onClose - Callback when modal is closed
* @param {'claude'|'cursor'|'codex'} props.provider - Which CLI provider to authenticate with
* @param {'claude'|'cursor'|'codex'|'gemini'} props.provider - Which CLI provider to authenticate with
* @param {Object} props.project - Project object containing name and path information
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
* @param {string} props.customCommand - Optional custom command to override defaults
@@ -36,6 +36,9 @@ function LoginModal({
return 'cursor-agent login';
case 'codex':
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
case 'gemini':
// No explicit interactive login command for gemini CLI exists yet similar to Claude, so we'll just check status or instruct the user to configure `.gemini.json`
return 'gemini status';
default:
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
}
@@ -49,6 +52,8 @@ function LoginModal({
return 'Cursor CLI Login';
case 'codex':
return 'Codex CLI Login';
case 'gemini':
return 'Gemini CLI Configuration';
default:
return 'CLI Login';
}
@@ -77,12 +82,68 @@ function LoginModal({
</button>
</div>
<div className="flex-1 overflow-hidden">
<StandaloneShell
project={project}
command={getCommand()}
onComplete={handleComplete}
minimal={true}
/>
{provider === 'gemini' ? (
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-gray-50 dark:bg-gray-900/50">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mb-6">
<KeyRound className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h4 className="text-xl font-medium text-gray-900 dark:text-white mb-3">
Setup Gemini API Access
</h4>
<p className="text-gray-600 dark:text-gray-400 max-w-md mb-8">
The Gemini CLI requires an API key to function. Unlike Claude, you'll need to configure this directly in your terminal first.
</p>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 max-w-lg w-full text-left shadow-sm">
<ol className="space-y-4">
<li className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
1
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Get your API Key</p>
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noreferrer"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 inline-flex"
>
Google AI Studio <ExternalLink className="w-3 h-3" />
</a>
</div>
</li>
<li className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
2
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Run configuration</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Open your terminal and run:</p>
<code className="block bg-gray-100 dark:bg-gray-900 px-3 py-2 rounded text-sm text-pink-600 dark:text-pink-400 font-mono">
gemini config set api_key YOUR_KEY
</code>
</div>
</li>
</ol>
</div>
<button
onClick={onClose}
className="mt-8 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
Done
</button>
</div>
) : (
<StandaloneShell
project={project}
command={getCommand()}
onComplete={handleComplete}
minimal={true}
/>
)}
</div>
</div>
</div>

View File

@@ -1,272 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Mic, Loader2, Brain } from 'lucide-react';
import { transcribeWithWhisper } from '../utils/whisper';
export function MicButton({ onTranscript, className = '' }) {
const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
const [error, setError] = useState(null);
const [isSupported, setIsSupported] = useState(true);
const mediaRecorderRef = useRef(null);
const streamRef = useRef(null);
const chunksRef = useRef([]);
const lastTapRef = useRef(0);
// Check microphone support on mount
useEffect(() => {
const checkSupport = () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setIsSupported(false);
setError('Microphone not supported. Please use HTTPS or a modern browser.');
return;
}
// Additional check for secure context
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
setIsSupported(false);
setError('Microphone requires HTTPS. Please use a secure connection.');
return;
}
setIsSupported(true);
setError(null);
};
checkSupport();
}, []);
// Start recording
const startRecording = async () => {
try {
console.log('Starting recording...');
setError(null);
chunksRef.current = [];
// Check if getUserMedia is available
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4';
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
recorder.onstop = async () => {
console.log('Recording stopped, creating blob...');
const blob = new Blob(chunksRef.current, { type: mimeType });
// Clean up stream
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Start transcribing
setState('transcribing');
// Check if we're in an enhancement mode
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
const isEnhancementMode = whisperMode === 'prompt' || whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect';
// Set up a timer to switch to processing state for enhancement modes
let processingTimer;
if (isEnhancementMode) {
processingTimer = setTimeout(() => {
setState('processing');
}, 2000); // Switch to processing after 2 seconds
}
try {
const text = await transcribeWithWhisper(blob);
if (text && onTranscript) {
onTranscript(text);
}
} catch (err) {
console.error('Transcription error:', err);
setError(err.message);
} finally {
if (processingTimer) {
clearTimeout(processingTimer);
}
setState('idle');
}
};
recorder.start();
setState('recording');
console.log('Recording started successfully');
} catch (err) {
console.error('Failed to start recording:', err);
// Provide specific error messages based on error type
let errorMessage = 'Microphone access failed';
if (err.name === 'NotAllowedError') {
errorMessage = 'Microphone access denied. Please allow microphone permissions.';
} else if (err.name === 'NotFoundError') {
errorMessage = 'No microphone found. Please check your audio devices.';
} else if (err.name === 'NotSupportedError') {
errorMessage = 'Microphone not supported by this browser.';
} else if (err.name === 'NotReadableError') {
errorMessage = 'Microphone is being used by another application.';
} else if (err.message.includes('HTTPS')) {
errorMessage = err.message;
}
setError(errorMessage);
setState('idle');
}
};
// Stop recording
const stopRecording = () => {
console.log('Stopping recording...');
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
// Don't set state here - let the onstop handler do it
} else {
// If recorder isn't in recording state, force cleanup
console.log('Recorder not in recording state, forcing cleanup');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setState('idle');
}
};
// Handle button click
const handleClick = (e) => {
// Prevent double firing on mobile
if (e) {
e.preventDefault();
e.stopPropagation();
}
// Don't proceed if microphone is not supported
if (!isSupported) {
return;
}
// Debounce for mobile double-tap issue
const now = Date.now();
if (now - lastTapRef.current < 300) {
console.log('Ignoring rapid tap');
return;
}
lastTapRef.current = now;
console.log('Button clicked, current state:', state);
if (state === 'idle') {
startRecording();
} else if (state === 'recording') {
stopRecording();
}
// Do nothing if transcribing or processing
};
// Clean up on unmount
useEffect(() => {
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
// Button appearance based on state
const getButtonAppearance = () => {
if (!isSupported) {
return {
icon: <Mic className="w-5 h-5" />,
className: 'bg-gray-400 cursor-not-allowed',
disabled: true
};
}
switch (state) {
case 'recording':
return {
icon: <Mic className="w-5 h-5 text-white" />,
className: 'bg-red-500 hover:bg-red-600 animate-pulse',
disabled: false
};
case 'transcribing':
return {
icon: <Loader2 className="w-5 h-5 animate-spin" />,
className: 'bg-blue-500 hover:bg-blue-600',
disabled: true
};
case 'processing':
return {
icon: <Brain className="w-5 h-5 animate-pulse" />,
className: 'bg-purple-500 hover:bg-purple-600',
disabled: true
};
default: // idle
return {
icon: <Mic className="w-5 h-5" />,
className: 'bg-gray-700 hover:bg-gray-600',
disabled: false
};
}
};
const { icon, className: buttonClass, disabled } = getButtonAppearance();
return (
<div className="relative">
<button
type="button"
style={{
backgroundColor: state === 'recording' ? '#ef4444' :
state === 'transcribing' ? '#3b82f6' :
state === 'processing' ? '#a855f7' :
'#374151'
}}
className={`
flex items-center justify-center
w-12 h-12 rounded-full
text-white transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
dark:ring-offset-gray-800
touch-action-manipulation
${disabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
${state === 'recording' ? 'animate-pulse' : ''}
hover:opacity-90
${className}
`}
onClick={handleClick}
disabled={disabled}
>
{icon}
</button>
{error && (
<div className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2
bg-red-500 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10
animate-fade-in">
{error}
</div>
)}
{state === 'recording' && (
<div className="absolute -inset-1 rounded-full border-2 border-red-500 animate-ping pointer-events-none" />
)}
{state === 'processing' && (
<div className="absolute -inset-1 rounded-full border-2 border-purple-500 animate-ping pointer-events-none" />
)}
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause,
import { cn } from '../lib/utils';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { api } from '../utils/api';
import Shell from './Shell';
import Shell from './shell/view/Shell';
import TaskDetail from './TaskDetail';
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {

View File

@@ -1,8 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo';
import CodexLogo from './CodexLogo';
import SessionProviderLogo from './llm-logo-provider/SessionProviderLogo';
import LoginModal from './LoginModal';
import { authenticatedFetch } from '../utils/api';
import { useAuth } from '../contexts/AuthContext';
@@ -39,6 +37,13 @@ const Onboarding = ({ onComplete }) => {
error: null
});
const [geminiAuthStatus, setGeminiAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const { user } = useAuth();
const prevActiveLoginProviderRef = useRef(undefined);
@@ -71,22 +76,23 @@ const Onboarding = ({ onComplete }) => {
checkClaudeAuthStatus();
checkCursorAuthStatus();
checkCodexAuthStatus();
checkGeminiAuthStatus();
}
}, [activeLoginProvider]);
const checkClaudeAuthStatus = async () => {
const checkProviderAuthStatus = async (provider, setter) => {
try {
const response = await authenticatedFetch('/api/cli/claude/status');
const response = await authenticatedFetch(`/api/cli/${provider}/status`);
if (response.ok) {
const data = await response.json();
setClaudeAuthStatus({
setter({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setClaudeAuthStatus({
setter({
authenticated: false,
email: null,
loading: false,
@@ -94,8 +100,8 @@ const Onboarding = ({ onComplete }) => {
});
}
} catch (error) {
console.error('Error checking Claude auth status:', error);
setClaudeAuthStatus({
console.error(`Error checking ${provider} auth status:`, error);
setter({
authenticated: false,
email: null,
loading: false,
@@ -104,69 +110,15 @@ const Onboarding = ({ onComplete }) => {
}
};
const checkCursorAuthStatus = async () => {
try {
const response = await authenticatedFetch('/api/cli/cursor/status');
if (response.ok) {
const data = await response.json();
setCursorAuthStatus({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setCursorAuthStatus({
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status'
});
}
} catch (error) {
console.error('Error checking Cursor auth status:', error);
setCursorAuthStatus({
authenticated: false,
email: null,
loading: false,
error: error.message
});
}
};
const checkCodexAuthStatus = async () => {
try {
const response = await authenticatedFetch('/api/cli/codex/status');
if (response.ok) {
const data = await response.json();
setCodexAuthStatus({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setCodexAuthStatus({
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status'
});
}
} catch (error) {
console.error('Error checking Codex auth status:', error);
setCodexAuthStatus({
authenticated: false,
email: null,
loading: false,
error: error.message
});
}
};
const checkClaudeAuthStatus = () => checkProviderAuthStatus('claude', setClaudeAuthStatus);
const checkCursorAuthStatus = () => checkProviderAuthStatus('cursor', setCursorAuthStatus);
const checkCodexAuthStatus = () => checkProviderAuthStatus('codex', setCodexAuthStatus);
const checkGeminiAuthStatus = () => checkProviderAuthStatus('gemini', setGeminiAuthStatus);
const handleClaudeLogin = () => setActiveLoginProvider('claude');
const handleCursorLogin = () => setActiveLoginProvider('cursor');
const handleCodexLogin = () => setActiveLoginProvider('codex');
const handleGeminiLogin = () => setActiveLoginProvider('gemini');
const handleLoginComplete = (exitCode) => {
if (exitCode === 0) {
@@ -176,6 +128,8 @@ const Onboarding = ({ onComplete }) => {
checkCursorAuthStatus();
} else if (activeLoginProvider === 'codex') {
checkCodexAuthStatus();
} else if (activeLoginProvider === 'gemini') {
checkGeminiAuthStatus();
}
}
};
@@ -339,15 +293,14 @@ const Onboarding = ({ onComplete }) => {
{/* Agent Cards Grid */}
<div className="space-y-3">
{/* Claude */}
<div className={`border rounded-lg p-4 transition-colors ${
claudeAuthStatus.authenticated
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'border-border bg-card'
}`}>
<div className={`border rounded-lg p-4 transition-colors ${claudeAuthStatus.authenticated
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
<ClaudeLogo size={20} />
<SessionProviderLogo provider="claude" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
@@ -356,7 +309,7 @@ const Onboarding = ({ onComplete }) => {
</div>
<div className="text-xs text-muted-foreground">
{claudeAuthStatus.loading ? 'Checking...' :
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
@@ -372,15 +325,14 @@ const Onboarding = ({ onComplete }) => {
</div>
{/* Cursor */}
<div className={`border rounded-lg p-4 transition-colors ${
cursorAuthStatus.authenticated
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
: 'border-border bg-card'
}`}>
<div className={`border rounded-lg p-4 transition-colors ${cursorAuthStatus.authenticated
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<CursorLogo size={20} />
<SessionProviderLogo provider="cursor" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
@@ -389,7 +341,7 @@ const Onboarding = ({ onComplete }) => {
</div>
<div className="text-xs text-muted-foreground">
{cursorAuthStatus.loading ? 'Checking...' :
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
@@ -405,15 +357,14 @@ const Onboarding = ({ onComplete }) => {
</div>
{/* Codex */}
<div className={`border rounded-lg p-4 transition-colors ${
codexAuthStatus.authenticated
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
: 'border-border bg-card'
}`}>
<div className={`border rounded-lg p-4 transition-colors ${codexAuthStatus.authenticated
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<CodexLogo className="w-5 h-5" />
<SessionProviderLogo provider="codex" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
@@ -422,7 +373,7 @@ const Onboarding = ({ onComplete }) => {
</div>
<div className="text-xs text-muted-foreground">
{codexAuthStatus.loading ? 'Checking...' :
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
@@ -436,6 +387,38 @@ const Onboarding = ({ onComplete }) => {
)}
</div>
</div>
{/* Gemini */}
<div className={`border rounded-lg p-4 transition-colors ${geminiAuthStatus.authenticated
? 'bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-teal-100 dark:bg-teal-900/30 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="gemini" className="w-5 h-5 text-teal-600 dark:text-teal-400" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
Gemini
{geminiAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{geminiAuthStatus.loading ? 'Checking...' :
geminiAuthStatus.authenticated ? geminiAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!geminiAuthStatus.authenticated && !geminiAuthStatus.loading && (
<button
onClick={handleGeminiLogin}
className="bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
</div>
<div className="text-center text-sm text-muted-foreground pt-2">
@@ -454,7 +437,7 @@ const Onboarding = ({ onComplete }) => {
case 0:
return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail);
case 1:
return true;
return true;
default:
return false;
}
@@ -470,11 +453,10 @@ const Onboarding = ({ onComplete }) => {
{steps.map((step, index) => (
<React.Fragment key={index}>
<div className="flex flex-col items-center flex-1">
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${
index < currentStep ? 'bg-green-500 border-green-500 text-white' :
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500 border-green-500 text-white' :
index === currentStep ? 'bg-blue-600 border-blue-600 text-white' :
'bg-background border-border text-muted-foreground'
}`}>
'bg-background border-border text-muted-foreground'
}`}>
{index < currentStep ? (
<Check className="w-6 h-6" />
) : typeof step.icon === 'function' ? (
@@ -484,9 +466,8 @@ const Onboarding = ({ onComplete }) => {
)}
</div>
<div className="mt-2 text-center">
<p className={`text-sm font-medium ${
index === currentStep ? 'text-foreground' : 'text-muted-foreground'
}`}>
<p className={`text-sm font-medium ${index === currentStep ? 'text-foreground' : 'text-muted-foreground'
}`}>
{step.title}
</p>
{step.required && (
@@ -495,9 +476,8 @@ const Onboarding = ({ onComplete }) => {
</div>
</div>
{index < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${
index < currentStep ? 'bg-green-500' : 'bg-border'
}`} />
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500' : 'bg-border'
}`} />
)}
</React.Fragment>
))}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,5 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import ClaudeLogo from './ClaudeLogo';
const SetupForm = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -48,7 +46,7 @@ const SetupForm = () => {
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
<img src="/logo.svg" alt="CloudCLI" className="w-16 h-16" />
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome to Claude Code UI</h1>
<p className="text-muted-foreground mt-2">

View File

@@ -1,692 +0,0 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import { useTranslation } from 'react-i18next';
import { IS_PLATFORM } from '../constants/config';
const xtermStyles = `
.xterm .xterm-screen {
outline: none !important;
}
.xterm:focus .xterm-screen {
outline: none !important;
}
.xterm-screen:focus {
outline: none !important;
}
`;
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style');
styleSheet.type = 'text/css';
styleSheet.innerText = xtermStyles;
document.head.appendChild(styleSheet);
}
function fallbackCopyToClipboard(text) {
if (!text || typeof document === 'undefined') return false;
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
let copied = false;
try {
copied = document.execCommand('copy');
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
}
return copied;
}
const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
function isCodexLoginCommand(command) {
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
}
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
const { t } = useTranslation('chat');
const terminalRef = useRef(null);
const terminal = useRef(null);
const fitAddon = useRef(null);
const ws = useRef(null);
const [isConnected, setIsConnected] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const [lastSessionId, setLastSessionId] = useState(null);
const [isConnecting, setIsConnecting] = useState(false);
const [authUrl, setAuthUrl] = useState('');
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle');
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
useEffect(() => {
selectedProjectRef.current = selectedProject;
selectedSessionRef.current = selectedSession;
initialCommandRef.current = initialCommand;
isPlainShellRef.current = isPlainShell;
onProcessCompleteRef.current = onProcessComplete;
});
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) return false;
const popup = window.open(url, '_blank', 'noopener,noreferrer');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) return false;
let copied = false;
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
copied = true;
}
} catch {
copied = false;
}
if (!copied) {
copied = fallbackCopyToClipboard(url);
}
return copied;
}, []);
const connectWebSocket = useCallback(async () => {
if (isConnecting || isConnected) return;
try {
let wsUrl;
if (IS_PLATFORM) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell`;
} else {
const token = localStorage.getItem('auth-token');
if (!token) {
console.error('No authentication token found for Shell WebSocket connection');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
}
ws.current = new WebSocket(wsUrl);
ws.current.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
setTimeout(() => {
if (fitAddon.current && terminal.current) {
fitAddon.current.fit();
ws.current.send(JSON.stringify({
type: 'init',
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'),
cols: terminal.current.cols,
rows: terminal.current.rows,
initialCommand: initialCommandRef.current,
isPlainShell: isPlainShellRef.current
}));
}
}, 100);
};
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'output') {
let output = data.data;
if (isPlainShellRef.current && onProcessCompleteRef.current) {
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
if (cleanOutput.includes('Process exited with code 0')) {
onProcessCompleteRef.current(0);
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
if (exitCode !== 0) {
onProcessCompleteRef.current(exitCode);
}
}
}
if (terminal.current) {
terminal.current.write(output);
}
} else if (data.type === 'auth_url' && data.url) {
authUrlRef.current = data.url;
setAuthUrl(data.url);
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
} else if (data.type === 'url_open') {
if (data.url) {
authUrlRef.current = data.url;
setAuthUrl(data.url);
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}
}
} catch (error) {
console.error('[Shell] Error handling WebSocket message:', error, event.data);
}
};
ws.current.onclose = (event) => {
setIsConnected(false);
setIsConnecting(false);
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H');
}
};
ws.current.onerror = (error) => {
setIsConnected(false);
setIsConnecting(false);
};
} catch (error) {
setIsConnected(false);
setIsConnecting(false);
}
}, [isConnecting, isConnected, openAuthUrlInBrowser]);
const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting) return;
setIsConnecting(true);
connectWebSocket();
}, [isInitialized, isConnected, isConnecting, connectWebSocket]);
const disconnectFromShell = useCallback(() => {
if (ws.current) {
ws.current.close();
ws.current = null;
}
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H');
}
setIsConnected(false);
setIsConnecting(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, []);
const sessionDisplayName = useMemo(() => {
if (!selectedSession) return null;
return selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
}, [selectedSession]);
const sessionDisplayNameShort = useMemo(() => {
if (!sessionDisplayName) return null;
return sessionDisplayName.slice(0, 30);
}, [sessionDisplayName]);
const sessionDisplayNameLong = useMemo(() => {
if (!sessionDisplayName) return null;
return sessionDisplayName.slice(0, 50);
}, [sessionDisplayName]);
const restartShell = () => {
setIsRestarting(true);
if (ws.current) {
ws.current.close();
ws.current = null;
}
if (terminal.current) {
terminal.current.dispose();
terminal.current = null;
fitAddon.current = null;
}
setIsConnected(false);
setIsInitialized(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
setTimeout(() => {
setIsRestarting(false);
}, 200);
};
useEffect(() => {
const currentSessionId = selectedSession?.id || null;
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
disconnectFromShell();
}
setLastSessionId(currentSessionId);
}, [selectedSession?.id, isInitialized, disconnectFromShell]);
useEffect(() => {
if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) {
return;
}
terminal.current = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true,
allowTransparency: false,
convertEol: true,
scrollback: 10000,
tabStopWidth: 4,
windowsMode: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selection: '#264f78',
selectionForeground: '#ffffff',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
extendedAnsi: [
'#000000', '#800000', '#008000', '#808000',
'#000080', '#800080', '#008080', '#c0c0c0',
'#808080', '#ff0000', '#00ff00', '#ffff00',
'#0000ff', '#ff00ff', '#00ffff', '#ffffff'
]
}
});
fitAddon.current = new FitAddon();
const webglAddon = new WebglAddon();
const webLinksAddon = new WebLinksAddon();
terminal.current.loadAddon(fitAddon.current);
// Disable xterm link auto-detection in minimal (login) mode to avoid partial wrapped URL links.
if (!minimal) {
terminal.current.loadAddon(webLinksAddon);
}
// Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler
try {
terminal.current.loadAddon(webglAddon);
} catch (error) {
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
}
terminal.current.open(terminalRef.current);
terminal.current.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
: authUrlRef.current;
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
copyAuthUrlToClipboard(activeAuthUrl).catch(() => {});
}
if (
event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) &&
event.key?.toLowerCase() === 'c' &&
terminal.current.hasSelection()
) {
event.preventDefault();
event.stopPropagation();
document.execCommand('copy');
return false;
}
if (
event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) &&
event.key?.toLowerCase() === 'v'
) {
// Block native browser/xterm paste so clipboard data is only sent after
// the explicit clipboard-read flow resolves (avoids duplicate pastes).
event.preventDefault();
event.stopPropagation();
navigator.clipboard.readText().then(text => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'input',
data: text
}));
}
}).catch(() => {});
return false;
}
return true;
});
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}
}, 100);
setIsInitialized(true);
terminal.current.onData((data) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'input',
data: data
}));
}
});
const resizeObserver = new ResizeObserver(() => {
if (fitAddon.current && terminal.current) {
setTimeout(() => {
fitAddon.current.fit();
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}, 50);
}
});
if (terminalRef.current) {
resizeObserver.observe(terminalRef.current);
}
return () => {
resizeObserver.disconnect();
if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) {
ws.current.close();
}
ws.current = null;
if (terminal.current) {
terminal.current.dispose();
terminal.current = null;
}
};
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting, minimal, copyAuthUrlToClipboard]);
useEffect(() => {
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
connectToShell();
}, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
if (!selectedProject) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">{t('shell.selectProject.title')}</h3>
<p>{t('shell.selectProject.description')}</p>
</div>
</div>
);
}
if (minimal) {
const displayAuthUrl = isCodexLoginCommand(initialCommand)
? CODEX_DEVICE_AUTH_URL
: authUrl;
const hasAuthUrl = Boolean(displayAuthUrl);
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return (
<div className="h-full w-full bg-gray-900 relative">
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{showMobileAuthPanel && (
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
<button
type="button"
onClick={() => setIsAuthPanelHidden(true)}
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
>
Hide
</button>
</div>
<input
type="text"
value={displayAuthUrl}
readOnly
onClick={(event) => event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Authentication URL"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
Open URL
</button>
<button
type="button"
onClick={async () => {
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
>
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
)}
{showMobileAuthPanelToggle && (
<div className="absolute bottom-14 right-3 z-20 md:hidden">
<button
type="button"
onClick={() => setIsAuthPanelHidden(false)}
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
>
Show login URL
</button>
</div>
)}
</div>
);
}
return (
<div className="h-full flex flex-col bg-gray-900 w-full">
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{selectedSession && (
<span className="text-xs text-blue-300">
({sessionDisplayNameShort}...)
</span>
)}
{!selectedSession && (
<span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
)}
{!isInitialized && (
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
)}
{isRestarting && (
<span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
)}
</div>
<div className="flex items-center space-x-3">
{isConnected && (
<button
onClick={disconnectFromShell}
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
title={t('shell.actions.disconnectTitle')}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>{t('shell.actions.disconnect')}</span>
</button>
)}
<button
onClick={restartShell}
disabled={isRestarting || isConnected}
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title={t('shell.actions.restartTitle')}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{t('shell.actions.restart')}</span>
</button>
</div>
</div>
</div>
<div className="flex-1 p-2 overflow-hidden relative">
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">{t('shell.loading')}</div>
</div>
)}
{isInitialized && !isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full">
<button
onClick={connectToShell}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
title={t('shell.actions.connectTitle')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{t('shell.actions.connect')}</span>
</button>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
selectedSession ?
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
t('shell.startSession')
}
</p>
</div>
</div>
)}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full">
<div className="flex items-center justify-center space-x-3 text-yellow-400">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">{t('shell.connecting')}</span>
</div>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
t('shell.startCli', { projectName: selectedProject.displayName })
}
</p>
</div>
</div>
)}
</div>
</div>
);
}
export default Shell;

View File

@@ -1,105 +0,0 @@
import React, { useState, useCallback } from 'react';
import Shell from './Shell.jsx';
/**
* Generic Shell wrapper that can be used in tabs, modals, and other contexts.
* Provides a flexible API for both standalone and session-based usage.
*
* @param {Object} project - Project object with name, fullPath/path, displayName
* @param {Object} session - Session object (optional, for tab usage)
* @param {string} command - Initial command to run (optional)
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
* @param {function} onComplete - Callback when process completes (receives exitCode)
* @param {function} onClose - Callback for close button (optional)
* @param {string} title - Custom header title (optional)
* @param {string} className - Additional CSS classes
* @param {boolean} showHeader - Whether to show custom header (default: true)
* @param {boolean} compact - Use compact layout (default: false)
* @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false)
*/
function StandaloneShell({
project,
session = null,
command = null,
isPlainShell = null,
autoConnect = true,
onComplete = null,
onClose = null,
title = null,
className = "",
showHeader = true,
compact = false,
minimal = false
}) {
const [isCompleted, setIsCompleted] = useState(false);
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
const handleProcessComplete = useCallback((exitCode) => {
setIsCompleted(true);
if (onComplete) {
onComplete(exitCode);
}
}, [onComplete]);
if (!project) {
return (
<div className={`h-full flex items-center justify-center ${className}`}>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">No Project Selected</h3>
<p>A project is required to open a shell</p>
</div>
</div>
);
}
return (
<div className={`h-full w-full flex flex-col ${className}`}>
{/* Optional custom header */}
{!minimal && showHeader && title && (
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<h3 className="text-sm font-medium text-gray-200">{title}</h3>
{isCompleted && (
<span className="text-xs text-green-400">(Completed)</span>
)}
</div>
{onClose && (
<button
onClick={onClose}
className="text-gray-400 hover:text-white"
title="Close"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
)}
{/* Shell component wrapper */}
<div className="flex-1 w-full min-h-0">
<Shell
selectedProject={project}
selectedSession={session}
initialCommand={command}
isPlainShell={shouldUsePlainShell}
onProcessComplete={handleProcessComplete}
minimal={minimal}
autoConnect={minimal ? true : autoConnect}
/>
</div>
</div>
);
}
export default StandaloneShell;

View File

@@ -4,6 +4,7 @@ import { cn } from '../lib/utils';
import TaskIndicator from './TaskIndicator';
import { api } from '../utils/api';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { copyTextToClipboard } from '../utils/clipboard';
const TaskDetail = ({
task,
@@ -79,7 +80,7 @@ const TaskDetail = ({
};
const copyTaskId = () => {
navigator.clipboard.writeText(task.id.toString());
copyTextToClipboard(task.id.toString());
};
const getStatusConfig = (status) => {

View File

@@ -1,10 +1,10 @@
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { Search, Filter, ArrowUpDown, ArrowUp, ArrowDown, List, Grid, ChevronDown, Columns, Plus, Settings, Terminal, FileText, HelpCircle, X } from 'lucide-react';
import { cn } from '../lib/utils';
import TaskCard from './TaskCard';
import CreateTaskModal from './CreateTaskModal';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import Shell from './Shell';
import Shell from './shell/view/Shell';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
@@ -32,6 +32,7 @@ const TaskList = ({
const [showHelpGuide, setShowHelpGuide] = useState(false);
const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
const [showPRDDropdown, setShowPRDDropdown] = useState(false);
const dropdownRef = useRef(null);
const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
const { t } = useTranslation('tasks');
@@ -39,7 +40,11 @@ const TaskList = ({
// Close PRD dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (showPRDDropdown && !event.target.closest('.relative')) {
if (
showPRDDropdown &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target)
) {
setShowPRDDropdown(false);
}
};
@@ -48,6 +53,31 @@ const TaskList = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPRDDropdown]);
const loadPRDOptions = async (prd, closeDropdown = false) => {
if (!currentProject) {
return;
}
try {
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
if (response.ok) {
const prdData = await response.json();
onShowPRDEditor?.({
name: prd.name,
content: prdData.content,
isExisting: true
});
if (closeDropdown) {
setShowPRDDropdown(false);
}
} else {
console.error('Failed to load PRD:', response.statusText);
}
} catch (error) {
console.error('Error loading PRD:', error);
}
};
// Get unique status values from tasks
const statuses = useMemo(() => {
const statusSet = new Set(tasks.map(task => task.status).filter(Boolean));
@@ -309,23 +339,8 @@ const TaskList = ({
{existingPRDs.map((prd) => (
<button
key={prd.name}
onClick={async () => {
try {
// Load the PRD content from the API
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
if (response.ok) {
const prdData = await response.json();
onShowPRDEditor?.({
name: prd.name,
content: prdData.content,
isExisting: true
});
} else {
console.error('Failed to load PRD:', response.statusText);
}
} catch (error) {
console.error('Error loading PRD:', error);
}
onClick={() => {
void loadPRDOptions(prd);
}}
className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
@@ -589,7 +604,7 @@ const TaskList = ({
</button>
{/* PRD Management */}
<div className="relative">
<div ref={dropdownRef} className="relative">
{existingPRDs.length > 0 ? (
// Dropdown when PRDs exist
<div className="relative">
@@ -624,21 +639,8 @@ const TaskList = ({
{existingPRDs.map((prd) => (
<button
key={prd.name}
onClick={async () => {
try {
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
if (response.ok) {
const prdData = await response.json();
onShowPRDEditor?.({
name: prd.name,
content: prdData.content,
isExisting: true
});
setShowPRDDropdown(false);
}
} catch (error) {
console.error('Error loading PRD:', error);
}
onClick={() => {
void loadPRDOptions(prd, true);
}}
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
title={t('prd.modified', { date: new Date(prd.modified).toLocaleDateString() })}
@@ -1050,4 +1052,4 @@ const TaskList = ({
);
};
export default TaskList;
export default TaskList;

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react';
import { cn } from '../lib/utils';
import { api } from '../utils/api';
import { copyTextToClipboard } from '../utils/clipboard';
const TaskMasterSetupWizard = ({
isOpen = true,
@@ -175,7 +176,7 @@ const TaskMasterSetupWizard = ({
}
}
}`;
navigator.clipboard.writeText(mcpConfig);
copyTextToClipboard(mcpConfig);
};
const renderStepContent = () => {

View File

@@ -1,109 +1,3 @@
import { Zap } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next';
import TasksSettingsTab from './settings/view/tabs/tasks-settings/TasksSettingsTab';
function TasksSettings() {
const { t } = useTranslation('settings');
const {
tasksEnabled,
setTasksEnabled,
isTaskMasterInstalled,
isCheckingInstallation
} = useTasksSettings();
return (
<div className="space-y-8">
{/* Installation Status Check */}
{isCheckingInstallation ? (
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="animate-spin w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span className="text-sm text-muted-foreground">{t('tasks.checking')}</span>
</div>
</div>
) : (
<>
{/* TaskMaster Not Installed Warning */}
{!isTaskMasterInstalled && (
<div className="bg-orange-50 dark:bg-orange-950/50 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="flex-1">
<div className="font-medium text-orange-900 dark:text-orange-100 mb-2">
{t('tasks.notInstalled.title')}
</div>
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3">
<p>{t('tasks.notInstalled.description')}</p>
<div className="bg-orange-100 dark:bg-orange-900/50 rounded-lg p-3 font-mono text-sm">
<code>{t('tasks.notInstalled.installCommand')}</code>
</div>
<div>
<a
href="https://github.com/eyaltoledano/claude-task-master"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium text-sm"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
</svg>
{t('tasks.notInstalled.viewOnGitHub')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<div className="space-y-2">
<p className="font-medium">{t('tasks.notInstalled.afterInstallation')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>{t('tasks.notInstalled.steps.restart')}</li>
<li>{t('tasks.notInstalled.steps.autoAvailable')}</li>
<li>{t('tasks.notInstalled.steps.initCommand')}</li>
</ol>
</div>
</div>
</div>
</div>
</div>
)}
{/* TaskMaster Settings */}
{isTaskMasterInstalled && (
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
{t('tasks.settings.enableLabel')}
</div>
<div className="text-sm text-muted-foreground mt-1">
{t('tasks.settings.enableDescription')}
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={tasksEnabled}
onChange={(e) => setTasksEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
</div>
)}
</>
)}
</div>
);
}
export default TasksSettings;
export default TasksSettingsTab;

View File

@@ -106,7 +106,7 @@ export default function AppContent() {
</div>
)}
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
<div className={`flex-1 flex flex-col min-w-0 ${isMobile ? 'pb-mobile-nav' : ''}`}>
<MainContent
selectedProject={selectedProject}
selectedSession={selectedSession}

View File

@@ -41,12 +41,14 @@ interface UseChatComposerStateArgs {
cursorModel: string;
claudeModel: string;
codexModel: string;
geminiModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean;
onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
@@ -92,12 +94,14 @@ export function useChatComposerState({
cursorModel,
claudeModel,
codexModel,
geminiModel,
isLoading,
canAbortSession,
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onSessionProcessing,
onInputFocusChange,
onFileOpen,
onShowSettings,
@@ -271,13 +275,14 @@ export function useChatComposerState({
}, [setChatMessages]);
const executeCommand = useCallback(
async (command: SlashCommand) => {
async (command: SlashCommand, rawInput?: string) => {
if (!command || !selectedProject) {
return;
}
try {
const commandMatch = input.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
const effectiveInput = rawInput ?? input;
const commandMatch = effectiveInput.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
const args =
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
@@ -286,7 +291,7 @@ export function useChatComposerState({
projectName: selectedProject.name,
sessionId: currentSessionId,
provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : claudeModel,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
tokenUsage: tokenBudget,
};
@@ -340,6 +345,7 @@ export function useChatComposerState({
codexModel,
currentSessionId,
cursorModel,
geminiModel,
handleBuiltInCommand,
handleCustomCommand,
input,
@@ -351,6 +357,7 @@ export function useChatComposerState({
);
const {
slashCommands,
slashCommandsCount,
filteredCommands,
frequentCommands,
@@ -473,6 +480,28 @@ export function useChatComposerState({
return;
}
// Intercept slash commands: if input starts with /commandName, execute as command with args
const trimmedInput = currentInput.trim();
if (trimmedInput.startsWith('/')) {
const firstSpace = trimmedInput.indexOf(' ');
const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput;
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
if (matchedCommand) {
executeCommand(matchedCommand, trimmedInput);
setInput('');
inputValueRef.current = '';
setAttachedImages([]);
setUploadingImages(new Map());
setImageErrors(new Map());
resetCommandMenuState();
setIsTextareaExpanded(false);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
return;
}
}
let messageContent = currentInput;
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) {
@@ -545,6 +574,9 @@ export function useChatComposerState({
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
}
onSessionActive?.(sessionToActivate);
if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) {
onSessionProcessing?.(effectiveSessionId);
}
const getToolsSettings = () => {
try {
@@ -552,8 +584,10 @@ export function useChatComposerState({
provider === 'cursor'
? 'cursor-tools-settings'
: provider === 'codex'
? 'codex-settings'
: 'claude-settings';
? 'codex-settings'
: provider === 'gemini'
? 'gemini-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
return JSON.parse(savedSettings);
@@ -601,6 +635,21 @@ export function useChatComposerState({
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
},
});
} else if (provider === 'gemini') {
sendMessage({
type: 'gemini-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: geminiModel,
permissionMode,
toolsSettings,
},
});
} else {
sendMessage({
type: 'claude-command',
@@ -639,8 +688,11 @@ export function useChatComposerState({
codexModel,
currentSessionId,
cursorModel,
executeCommand,
geminiModel,
isLoading,
onSessionActive,
onSessionProcessing,
pendingViewSessionRef,
permissionMode,
provider,
@@ -654,6 +706,7 @@ export function useChatComposerState({
setClaudeStatus,
setIsLoading,
setIsUserScrolledUp,
slashCommands,
thinkingMode,
],
);
@@ -903,8 +956,11 @@ export function useChatComposerState({
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
);
const [isInputFocused, setIsInputFocused] = useState(false);
const handleInputFocusChange = useCallback(
(focused: boolean) => {
setIsInputFocused(focused);
onInputFocusChange?.(focused);
},
[onInputFocusChange],
@@ -953,5 +1009,6 @@ export function useChatComposerState({
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
isInputFocused,
};
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
import type { ProjectSession, SessionProvider } from '../../../types/app';
@@ -23,6 +23,9 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
});
const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;
});
const lastProviderRef = useRef(provider);
@@ -105,6 +108,8 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
setClaudeModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
permissionMode,
setPermissionMode,
pendingPermissionRequests,

View File

@@ -145,6 +145,7 @@ export function useChatRealtimeHandlers({
'claude-error',
'cursor-error',
'codex-error',
'gemini-error',
]);
const isClaudeSystemInit =
@@ -162,8 +163,8 @@ export function useChatRealtimeHandlers({
const systemInitSessionId = isClaudeSystemInit
? structuredMessageData?.session_id
: isCursorSystemInit
? rawStructuredData?.session_id
: null;
? rawStructuredData?.session_id
: null;
const activeViewSessionId =
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
@@ -176,7 +177,8 @@ export function useChatRealtimeHandlers({
!pendingViewSessionRef.current.sessionId &&
(latestMessage.type === 'claude-error' ||
latestMessage.type === 'cursor-error' ||
latestMessage.type === 'codex-error');
latestMessage.type === 'codex-error' ||
latestMessage.type === 'gemini-error');
const handleBackgroundLifecycle = (sessionId?: string) => {
if (!sessionId) {
@@ -225,12 +227,6 @@ export function useChatRealtimeHandlers({
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
console.log(
'Skipping message for different session:',
latestMessage.sessionId,
'current:',
activeViewSessionId,
);
return;
}
}
@@ -297,11 +293,6 @@ export function useChatRealtimeHandlers({
structuredMessageData.session_id !== currentSessionId &&
isSystemInitForView
) {
console.log('Claude CLI session duplication detected:', {
originalSession: currentSessionId,
newSession: structuredMessageData.session_id,
});
setIsSystemSessionChange(true);
onNavigateToSession?.(structuredMessageData.session_id);
return;
@@ -314,10 +305,6 @@ export function useChatRealtimeHandlers({
!currentSessionId &&
isSystemInitForView
) {
console.log('New session init detected:', {
newSession: structuredMessageData.session_id,
});
setIsSystemSessionChange(true);
onNavigateToSession?.(structuredMessageData.session_id);
return;
@@ -331,14 +318,47 @@ export function useChatRealtimeHandlers({
structuredMessageData.session_id === currentSessionId &&
isSystemInitForView
) {
console.log('System init message for current session, ignoring');
return;
}
if (structuredMessageData && Array.isArray(structuredMessageData.content)) {
const parentToolUseId = rawStructuredData?.parentToolUseId;
structuredMessageData.content.forEach((part: any) => {
if (part.type === 'tool_use') {
const toolInput = part.input ? JSON.stringify(part.input, null, 2) : '';
// Check if this is a child tool from a subagent
if (parentToolUseId) {
setChatMessages((previous) =>
previous.map((message) => {
if (message.toolId === parentToolUseId && message.isSubagentContainer) {
const childTool = {
toolId: part.id,
toolName: part.name,
toolInput: part.input,
toolResult: null,
timestamp: new Date(),
};
const existingChildren = message.subagentState?.childTools || [];
return {
...message,
subagentState: {
childTools: [...existingChildren, childTool],
currentToolIndex: existingChildren.length,
isComplete: false,
},
};
}
return message;
}),
);
return;
}
// Check if this is a Task tool (subagent container)
const isSubagentContainer = part.name === 'Task';
setChatMessages((previous) => [
...previous,
{
@@ -350,6 +370,10 @@ export function useChatRealtimeHandlers({
toolInput,
toolId: part.id,
toolResult: null,
isSubagentContainer,
subagentState: isSubagentContainer
? { childTools: [], currentToolIndex: -1, isComplete: false }
: undefined,
},
]);
return;
@@ -382,6 +406,8 @@ export function useChatRealtimeHandlers({
}
if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) {
const parentToolUseId = rawStructuredData?.parentToolUseId;
structuredMessageData.content.forEach((part: any) => {
if (part.type !== 'tool_result') {
return;
@@ -389,8 +415,32 @@ export function useChatRealtimeHandlers({
setChatMessages((previous) =>
previous.map((message) => {
if (message.isToolUse && message.toolId === part.tool_use_id) {
// Handle child tool results (route to parent's subagentState)
if (parentToolUseId && message.toolId === parentToolUseId && message.isSubagentContainer) {
return {
...message,
subagentState: {
...message.subagentState!,
childTools: message.subagentState!.childTools.map((child) => {
if (child.toolId === part.tool_use_id) {
return {
...child,
toolResult: {
content: part.content,
isError: part.is_error,
timestamp: new Date(),
},
};
}
return child;
}),
},
};
}
// Handle normal tool results (including parent Task tool completion)
if (message.isToolUse && message.toolId === part.tool_use_id) {
const result = {
...message,
toolResult: {
content: part.content,
@@ -398,6 +448,14 @@ export function useChatRealtimeHandlers({
timestamp: new Date(),
},
};
// Mark subagent as complete when parent Task receives its result
if (message.isSubagentContainer && message.subagentState) {
result.subagentState = {
...message.subagentState,
isComplete: true,
};
}
return result;
}
return message;
}),
@@ -511,17 +569,12 @@ export function useChatRealtimeHandlers({
}
if (currentSessionId && cursorData.session_id !== currentSessionId) {
console.log('Cursor session switch detected:', {
originalSession: currentSessionId,
newSession: cursorData.session_id,
});
setIsSystemSessionChange(true);
onNavigateToSession?.(cursorData.session_id);
return;
}
if (!currentSessionId) {
console.log('Cursor new session init detected:', { newSession: cursorData.session_id });
setIsSystemSessionChange(true);
onNavigateToSession?.(cursorData.session_id);
return;
@@ -540,9 +593,8 @@ export function useChatRealtimeHandlers({
...previous,
{
type: 'assistant',
content: `Using tool: ${latestMessage.tool} ${
latestMessage.input ? `with ${latestMessage.input}` : ''
}`,
content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''
}`,
timestamp: new Date(),
isToolUse: true,
toolName: latestMessage.tool,
@@ -825,7 +877,6 @@ export function useChatRealtimeHandlers({
onNavigateToSession?.(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId');
console.log('Codex session complete, ID set to:', codexPendingSessionId);
}
if (selectedProject) {
@@ -847,6 +898,91 @@ export function useChatRealtimeHandlers({
]);
break;
case 'gemini-response': {
const geminiData = latestMessage.data;
if (geminiData && geminiData.type === 'message' && typeof geminiData.content === 'string') {
const content = decodeHtmlEntities(geminiData.content);
if (content) {
streamBufferRef.current += streamBufferRef.current ? `\n${content}` : content;
}
if (!geminiData.isPartial) {
// Immediate flush and finalization for the last chunk
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
if (chunk) {
appendStreamingChunk(setChatMessages, chunk, true);
}
finalizeStreamingMessage(setChatMessages);
} else if (!streamTimerRef.current && streamBufferRef.current) {
streamTimerRef.current = window.setTimeout(() => {
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
streamTimerRef.current = null;
if (chunk) {
appendStreamingChunk(setChatMessages, chunk, true);
}
}, 100);
}
}
break;
}
case 'gemini-error':
setIsLoading(false);
setCanAbortSession(false);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: latestMessage.error || 'An error occurred with Gemini',
timestamp: new Date(),
},
]);
break;
case 'gemini-tool-use':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: latestMessage.toolName,
toolInput: latestMessage.parameters ? JSON.stringify(latestMessage.parameters, null, 2) : '',
toolId: latestMessage.toolId,
toolResult: null,
}
]);
break;
case 'gemini-tool-result':
setChatMessages((previous) =>
previous.map((message) => {
if (message.isToolUse && message.toolId === latestMessage.toolId) {
return {
...message,
toolResult: {
content: latestMessage.output || `Status: ${latestMessage.status}`,
isError: latestMessage.status === 'error',
timestamp: new Date(),
},
};
}
return message;
}),
);
break;
case 'session-aborted': {
const pendingSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
@@ -884,12 +1020,26 @@ export function useChatRealtimeHandlers({
case 'session-status': {
const statusSessionId = latestMessage.sessionId;
if (!statusSessionId) {
break;
}
const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (isCurrentSession && latestMessage.isProcessing) {
setIsLoading(true);
setCanAbortSession(true);
if (latestMessage.isProcessing) {
onSessionProcessing?.(statusSessionId);
if (isCurrentSession) {
setIsLoading(true);
setCanAbortSession(true);
}
break;
}
onSessionInactive?.(statusSessionId);
onSessionNotProcessing?.(statusSessionId);
if (isCurrentSession) {
clearLoadingIndicators();
}
break;
}

View File

@@ -22,7 +22,7 @@ interface UseSlashCommandsOptions {
input: string;
setInput: Dispatch<SetStateAction<string>>;
textareaRef: RefObject<HTMLTextAreaElement>;
onExecuteCommand: (command: SlashCommand) => void | Promise<void>;
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
}
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;

View File

@@ -1,7 +1,8 @@
import React, { memo, useMemo, useCallback } from 'react';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent } from './components';
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types';
type DiffLine = {
type: string;
@@ -21,6 +22,12 @@ interface ToolRendererProps {
autoExpandTools?: boolean;
showRawParameters?: boolean;
rawToolInput?: string;
isSubagentContainer?: boolean;
subagentState?: {
childTools: SubagentChildTool[];
currentToolIndex: number;
isComplete: boolean;
};
}
function getToolCategory(toolName: string): string {
@@ -50,8 +57,24 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
selectedProject,
autoExpandTools = false,
showRawParameters = false,
rawToolInput
rawToolInput,
isSubagentContainer,
subagentState
}) => {
// Route subagent containers to dedicated component
if (isSubagentContainer && subagentState) {
if (mode === 'result') {
return null;
}
return (
<SubagentContainer
toolInput={toolInput}
toolResult={toolResult}
subagentState={subagentState}
/>
);
}
const config = getToolConfig(toolName);
const displayConfig: any = mode === 'input' ? config.input : config.result;

View File

@@ -24,7 +24,7 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
}) => {
return (
<details className={`relative group/details ${className}`} open={open}>
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none">
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:bg-background group-open/details:-mx-1 group-open/details:px-1">
<svg
className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0"
fill="none"

View File

@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { copyTextToClipboard } from '../../../../utils/clipboard';
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
interface OneLineDisplayProps {
toolName: string;
icon?: string;
label?: string;
@@ -25,52 +25,6 @@ interface OneLineDisplayProps {
toolId?: string;
}
// Fallback for environments where the async Clipboard API is unavailable or blocked.
const copyWithLegacyExecCommand = (text: string): boolean => {
if (typeof document === 'undefined' || !document.body) {
return false;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, text.length);
let copied = false;
try {
copied = document.execCommand('copy');
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
}
return copied;
};
const copyTextToClipboard = async (text: string): Promise<boolean> => {
if (
typeof navigator !== 'undefined' &&
typeof window !== 'undefined' &&
window.isSecureContext &&
navigator.clipboard?.writeText
) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall back below when writeText is rejected (permissions/insecure contexts/browser limits).
}
}
return copyWithLegacyExecCommand(text);
};
/**
* Unified one-line display for simple tool inputs and results
* Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc.
@@ -92,7 +46,6 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
border: 'border-gray-300 dark:border-gray-600',
icon: 'text-gray-500 dark:text-gray-400'
},
resultId,
toolResult,
toolId
}) => {

View File

@@ -0,0 +1,180 @@
import React from 'react';
import { CollapsibleSection } from './CollapsibleSection';
import type { SubagentChildTool } from '../../types/types';
interface SubagentContainerProps {
toolInput: unknown;
toolResult?: { content?: unknown; isError?: boolean } | null;
subagentState: {
childTools: SubagentChildTool[];
currentToolIndex: number;
isComplete: boolean;
};
}
const getCompactToolDisplay = (toolName: string, toolInput: unknown): string => {
const input = typeof toolInput === 'string' ? (() => {
try { return JSON.parse(toolInput); } catch { return {}; }
})() : (toolInput || {});
switch (toolName) {
case 'Read':
case 'Write':
case 'Edit':
case 'ApplyPatch':
return input.file_path?.split('/').pop() || input.file_path || '';
case 'Grep':
case 'Glob':
return input.pattern || '';
case 'Bash':
const cmd = input.command || '';
return cmd.length > 40 ? `${cmd.slice(0, 40)}...` : cmd;
case 'Task':
return input.description || input.subagent_type || '';
case 'WebFetch':
case 'WebSearch':
return input.url || input.query || '';
default:
return '';
}
};
export const SubagentContainer: React.FC<SubagentContainerProps> = ({
toolInput,
toolResult,
subagentState,
}) => {
const parsedInput = typeof toolInput === 'string' ? (() => {
try { return JSON.parse(toolInput); } catch { return {}; }
})() : (toolInput || {});
const subagentType = parsedInput?.subagent_type || 'Agent';
const description = parsedInput?.description || 'Running task';
const prompt = parsedInput?.prompt || '';
const { childTools, currentToolIndex, isComplete } = subagentState;
const currentTool = currentToolIndex >= 0 ? childTools[currentToolIndex] : null;
const title = `Subagent / ${subagentType}: ${description}`;
return (
<div className="border-l-2 border-l-purple-500 dark:border-l-purple-400 pl-3 py-0.5 my-1">
<CollapsibleSection
title={title}
toolName="Task"
open={false}
>
{/* Prompt/request to the subagent */}
{prompt && (
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2 whitespace-pre-wrap break-words line-clamp-4">
{prompt}
</div>
)}
{/* Current tool indicator (while running) */}
{currentTool && !isComplete && (
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mt-1">
<span className="animate-pulse w-1.5 h-1.5 rounded-full bg-purple-500 dark:bg-purple-400 flex-shrink-0" />
<span className="text-gray-400 dark:text-gray-500">Currently:</span>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span>
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
<>
<span className="text-gray-300 dark:text-gray-600">/</span>
<span className="font-mono truncate text-gray-500 dark:text-gray-400">
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
</span>
</>
)}
</div>
)}
{/* Completion status */}
{isComplete && (
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mt-1">
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span>
</div>
)}
{/* Tool history (collapsed) */}
{childTools.length > 0 && (
<details className="mt-2 group/history">
<summary className="cursor-pointer text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 flex items-center gap-1">
<svg
className="w-2.5 h-2.5 transition-transform duration-150 group-open/history:rotate-90 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>View tool history ({childTools.length})</span>
</summary>
<div className="mt-1 pl-3 border-l border-gray-200 dark:border-gray-700 space-y-0.5">
{childTools.map((child, index) => (
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<span className="text-gray-400 dark:text-gray-500 w-4 text-right flex-shrink-0">{index + 1}.</span>
<span className="font-medium">{child.toolName}</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && (
<span className="font-mono truncate text-gray-400 dark:text-gray-500">
{getCompactToolDisplay(child.toolName, child.toolInput)}
</span>
)}
{child.toolResult?.isError && (
<span className="text-red-500 flex-shrink-0">(error)</span>
)}
</div>
))}
</div>
</details>
)}
{/* Final result */}
{isComplete && toolResult && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
{(() => {
let content = toolResult.content;
// Handle JSON string that needs parsing
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
// Extract text from array format like [{"type":"text","text":"..."}]
const textParts = parsed
.filter((p: any) => p.type === 'text' && p.text)
.map((p: any) => p.text);
if (textParts.length > 0) {
content = textParts.join('\n');
}
}
} catch {
// Not JSON, use as-is
}
} else if (Array.isArray(content)) {
// Direct array format
const textParts = content
.filter((p: any) => p.type === 'text' && p.text)
.map((p: any) => p.text);
if (textParts.length > 0) {
content = textParts.join('\n');
}
}
return typeof content === 'string' ? (
<div className="whitespace-pre-wrap break-words line-clamp-6">
{content}
</div>
) : content ? (
<pre className="whitespace-pre-wrap break-words line-clamp-6 font-mono text-[11px]">
{JSON.stringify(content, null, 2)}
</pre>
) : null;
})()}
</div>
)}
</CollapsibleSection>
</div>
);
};

View File

@@ -2,5 +2,6 @@ export { CollapsibleSection } from './CollapsibleSection';
export { DiffViewer } from './DiffViewer';
export { OneLineDisplay } from './OneLineDisplay';
export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer';
export * from './ContentRenderers';
export * from './InteractiveRenderers';

View File

@@ -383,7 +383,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
const description = input.description || 'Running task';
return `Subagent / ${subagentType}: ${description}`;
},
defaultOpen: true,
defaultOpen: false,
contentType: 'markdown',
getContentProps: (input) => {
// If only prompt exists (and required fields), show just the prompt
@@ -424,14 +424,8 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
},
result: {
type: 'collapsible',
title: (result) => {
// Check if result has content with type array (agent results often have this structure)
if (result && result.content && Array.isArray(result.content)) {
return 'Subagent Response';
}
return 'Subagent Result';
},
defaultOpen: true,
title: 'Subagent result',
defaultOpen: false,
contentType: 'markdown',
getContentProps: (result) => {
// Handle agent results which may have complex structure

View File

@@ -17,6 +17,14 @@ export interface ToolResult {
[key: string]: unknown;
}
export interface SubagentChildTool {
toolId: string;
toolName: string;
toolInput: unknown;
toolResult?: ToolResult | null;
timestamp: Date;
}
export interface ChatMessage {
type: string;
content?: string;
@@ -32,6 +40,12 @@ export interface ChatMessage {
toolResult?: ToolResult | null;
toolId?: string;
toolCallId?: string;
isSubagentContainer?: boolean;
subagentState?: {
childTools: SubagentChildTool[];
currentToolIndex: number;
isComplete: boolean;
};
[key: string]: unknown;
}

View File

@@ -354,7 +354,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
const converted: ChatMessage[] = [];
const toolResults = new Map<
string,
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown }
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] }
>();
rawMessages.forEach((message) => {
@@ -368,6 +368,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
isError: Boolean(part.is_error),
timestamp: new Date(message.timestamp || Date.now()),
toolUseResult: message.toolUseResult || null,
subagentTools: message.subagentTools,
});
});
}
@@ -484,6 +485,22 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
if (part.type === 'tool_use') {
const toolResult = toolResults.get(part.id);
const isSubagentContainer = part.name === 'Task';
// Build child tools from server-provided subagentTools data
const childTools: import('../types/types').SubagentChildTool[] = [];
if (isSubagentContainer && toolResult?.subagentTools && Array.isArray(toolResult.subagentTools)) {
for (const tool of toolResult.subagentTools as any[]) {
childTools.push({
toolId: tool.toolId,
toolName: tool.toolName,
toolInput: tool.toolInput,
toolResult: tool.toolResult || null,
timestamp: new Date(tool.timestamp || Date.now()),
});
}
}
converted.push({
type: 'assistant',
content: '',
@@ -491,6 +508,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
isToolUse: true,
toolName: part.name,
toolInput: normalizeToolInput(part.input),
toolId: part.id,
toolResult: toolResult
? {
content:
@@ -503,6 +521,14 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
: null,
toolError: toolResult?.isError || false,
toolResultTimestamp: toolResult?.timestamp || new Date(),
isSubagentContainer,
subagentState: isSubagentContainer
? {
childTools,
currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1,
isComplete: Boolean(toolResult),
}
: undefined,
});
}
});

View File

@@ -64,6 +64,8 @@ function ChatInterface({
setClaudeModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
@@ -163,6 +165,7 @@ function ChatInterface({
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
isInputFocused,
} = useChatComposerState({
selectedProject,
selectedSession,
@@ -173,12 +176,14 @@ function ChatInterface({
cursorModel,
claudeModel,
codexModel,
geminiModel,
isLoading,
canAbortSession,
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onSessionProcessing,
onInputFocusChange,
onFileOpen,
onShowSettings,
@@ -237,13 +242,6 @@ function ChatInterface({
};
}, [canAbortSession, handleAbortSession, isLoading]);
useEffect(() => {
const processingSessionId = selectedSession?.id || currentSessionId;
if (processingSessionId && isLoading && onSessionProcessing) {
onSessionProcessing(processingSessionId);
}
}, [currentSessionId, isLoading, onSessionProcessing, selectedSession?.id]);
useEffect(() => {
return () => {
resetStreamingState();
@@ -256,7 +254,9 @@ function ChatInterface({
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: t('messageTypes.claude');
: provider === 'gemini'
? t('messageTypes.gemini')
: t('messageTypes.claude');
return (
<div className="flex items-center justify-center h-full">
@@ -292,6 +292,8 @@ function ChatInterface({
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
@@ -373,13 +375,16 @@ function ChatInterface({
onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput}
onInputFocusChange={handleInputFocusChange}
isInputFocused={isInputFocused}
placeholder={t('input.placeholder', {
provider:
provider === 'cursor'
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: t('messageTypes.claude'),
? t('messageTypes.codex')
: provider === 'gemini'
? t('messageTypes.gemini')
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}

View File

@@ -1,5 +1,5 @@
import { SessionProvider } from '../../../../types/app';
import SessionProviderLogo from '../../../SessionProviderLogo';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { Provider } from '../../types/types';
type AssistantThinkingIndicatorProps = {
@@ -16,7 +16,7 @@ export default function AssistantThinkingIndicator({ selectedProvider }: Assista
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" />
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'}
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}
</div>
</div>
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">

View File

@@ -1,6 +1,6 @@
import CommandMenu from '../../../CommandMenu';
import ClaudeStatus from '../../../ClaudeStatus';
import { MicButton } from '../../../MicButton.jsx';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import MicButton from '../../../mic-button/view/MicButton';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
@@ -87,6 +87,7 @@ interface ChatComposerProps {
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
onInputFocusChange?: (focused: boolean) => void;
isInputFocused?: boolean;
placeholder: string;
isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean;
@@ -143,13 +144,13 @@ export default function ChatComposer({
onTextareaScrollSync,
onTextareaInput,
onInputFocusChange,
isInputFocused,
placeholder,
isTextareaExpanded,
sendByCtrlEnter,
onTranscript,
}: ChatComposerProps) {
const { t } = useTranslation('chat');
const AnyCommandMenu = CommandMenu as any;
const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
@@ -162,8 +163,13 @@ export default function ChatComposer({
(r) => r.toolName === 'AskUserQuestion'
);
// On mobile, when input is focused, float the input box at the bottom
const mobileFloatingClass = isInputFocused
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
: '';
return (
<div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6 ${mobileFloatingClass}`}>
{!hasQuestionPanel && (
<div className="flex-1">
<ClaudeStatus
@@ -259,7 +265,7 @@ export default function ChatComposer({
</div>
)}
<AnyCommandMenu
<CommandMenu
commands={filteredCommands}
selectedIndex={selectedCommandIndex}
onSelect={onCommandSelect}

View File

@@ -26,6 +26,8 @@ interface ChatMessagesPaneProps {
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
@@ -70,6 +72,8 @@ export default function ChatMessagesPane({
setCursorModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
@@ -152,6 +156,8 @@ export default function ChatMessagesPane({
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}

View File

@@ -1,12 +1,30 @@
import React, { useState, useEffect } from 'react';
import { cn } from '../lib/utils';
import { useEffect, useState } from 'react';
import { cn } from '../../../../lib/utils';
function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
type ClaudeStatusProps = {
status: {
text?: string;
tokens?: number;
can_interrupt?: boolean;
} | null;
onAbort?: () => void;
isLoading: boolean;
provider?: string;
};
const ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const SPINNER_CHARS = ['*', '+', 'x', '.'];
export default function ClaudeStatus({
status,
onAbort,
isLoading,
provider: _provider = 'claude',
}: ClaudeStatusProps) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
const [fakeTokens, setFakeTokens] = useState(0);
// Update elapsed time every second
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
@@ -15,81 +33,76 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
}
const startTime = Date.now();
// Calculate random token rate once (30-50 tokens per second)
const tokenRate = 30 + Math.random() * 20;
const timer = setInterval(() => {
const timer = window.setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed);
// Simulate token count increasing over time
setFakeTokens(Math.floor(elapsed * tokenRate));
}, 1000);
return () => clearInterval(timer);
return () => window.clearInterval(timer);
}, [isLoading]);
// Animate the status indicator
useEffect(() => {
if (!isLoading) return;
if (!isLoading) {
return;
}
const timer = setInterval(() => {
setAnimationPhase(prev => (prev + 1) % 4);
const timer = window.setInterval(() => {
setAnimationPhase((previous) => (previous + 1) % SPINNER_CHARS.length);
}, 500);
return () => clearInterval(timer);
return () => window.clearInterval(timer);
}, [isLoading]);
// Don't show if loading is false
if (!isLoading) {
return null;
}
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
if (!isLoading) return null;
// Clever action words that cycle
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
// Parse status data
const statusText = status?.text || actionWords[actionIndex];
const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length;
const statusText = status?.text || ACTION_WORDS[actionIndex];
const tokens = status?.tokens || fakeTokens;
const canInterrupt = status?.can_interrupt !== false;
// Animation characters
const spinners = ['✻', '✹', '✸', '✶'];
const currentSpinner = spinners[animationPhase];
const currentSpinner = SPINNER_CHARS[animationPhase];
return (
<div className="w-full mb-3 sm:mb-6 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-800 dark:bg-gray-900 text-white rounded-lg shadow-lg px-2.5 py-2 sm:px-4 sm:py-3 border border-gray-700 dark:border-gray-800">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3">
{/* Animated spinner */}
<span className={cn(
"text-base sm:text-xl transition-all duration-500 flex-shrink-0",
animationPhase % 2 === 0 ? "text-blue-400 scale-110" : "text-blue-300"
)}>
<span
className={cn(
'text-base sm:text-xl transition-all duration-500 flex-shrink-0',
animationPhase % 2 === 0 ? 'text-blue-400 scale-110' : 'text-blue-300',
)}
>
{currentSpinner}
</span>
{/* Status text - compact for mobile */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2">
<span className="font-medium text-xs sm:text-sm truncate">{statusText}...</span>
<span className="text-gray-400 text-xs sm:text-sm flex-shrink-0">({elapsedTime}s)</span>
{tokens > 0 && (
<>
<span className="text-gray-500 hidden sm:inline">·</span>
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0"> {tokens.toLocaleString()}</span>
<span className="text-gray-500 hidden sm:inline">|</span>
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0">
tokens {tokens.toLocaleString()}
</span>
</>
)}
<span className="text-gray-500 hidden sm:inline">·</span>
<span className="text-gray-500 hidden sm:inline">|</span>
<span className="text-gray-400 text-xs sm:text-sm hidden sm:inline">esc to stop</span>
</div>
</div>
</div>
</div>
{/* Interrupt button */}
{canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-2 sm:ml-3 text-xs bg-red-600 hover:bg-red-700 active:bg-red-800 text-white px-2 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1 sm:gap-1.5 flex-shrink-0 font-medium"
>
@@ -103,5 +116,3 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
</div>
);
}
export default ClaudeStatus;

View File

@@ -0,0 +1,224 @@
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
type CommandMenuCommand = {
name: string;
description?: string;
namespace?: string;
path?: string;
type?: string;
metadata?: { type?: string; [key: string]: unknown };
[key: string]: unknown;
};
type CommandMenuProps = {
commands?: CommandMenuCommand[];
selectedIndex?: number;
onSelect?: (command: CommandMenuCommand, index: number, isHover: boolean) => void;
onClose: () => void;
position?: { top: number; left: number; bottom?: number };
isOpen?: boolean;
frequentCommands?: CommandMenuCommand[];
};
const menuBaseStyle: CSSProperties = {
maxHeight: '300px',
overflowY: 'auto',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
zIndex: 1000,
padding: '8px',
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
};
const namespaceLabels: Record<string, string> = {
frequent: 'Frequently Used',
builtin: 'Built-in Commands',
project: 'Project Commands',
user: 'User Commands',
other: 'Other Commands',
};
const namespaceIcons: Record<string, string> = {
frequent: '[*]',
builtin: '[B]',
project: '[P]',
user: '[U]',
other: '[O]',
};
const getCommandKey = (command: CommandMenuCommand) =>
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
const getNamespace = (command: CommandMenuCommand) => command.namespace || command.type || 'other';
const getMenuPosition = (position: { top: number; left: number; bottom?: number }): CSSProperties => {
if (typeof window === 'undefined') {
return { position: 'fixed', top: '16px', left: '16px' };
}
if (window.innerWidth < 640) {
return {
position: 'fixed',
bottom: `${position.bottom ?? 90}px`,
left: '16px',
right: '16px',
width: 'auto',
maxWidth: 'calc(100vw - 32px)',
maxHeight: 'min(50vh, 300px)',
};
}
return {
position: 'fixed',
top: `${Math.max(16, Math.min(position.top, window.innerHeight - 316))}px`,
left: `${position.left}px`,
width: 'min(400px, calc(100vw - 32px))',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '300px',
};
};
export default function CommandMenu({
commands = [],
selectedIndex = -1,
onSelect,
onClose,
position = { top: 0, left: 0 },
isOpen = false,
frequentCommands = [],
}: CommandMenuProps) {
const menuRef = useRef<HTMLDivElement | null>(null);
const selectedItemRef = useRef<HTMLDivElement | null>(null);
const menuPosition = getMenuPosition(position);
useEffect(() => {
if (!isOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (!menuRef.current || !(event.target instanceof Node)) {
return;
}
if (!menuRef.current.contains(event.target)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose]);
useEffect(() => {
if (!selectedItemRef.current || !menuRef.current) {
return;
}
const menuRect = menuRef.current.getBoundingClientRect();
const itemRect = selectedItemRef.current.getBoundingClientRect();
if (itemRect.bottom > menuRect.bottom || itemRect.top < menuRect.top) {
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, [selectedIndex]);
if (!isOpen) {
return null;
}
const hasFrequentCommands = frequentCommands.length > 0;
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
const groupedCommands = commands.reduce<Record<string, CommandMenuCommand[]>>((groups, command) => {
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
return groups;
}
const namespace = getNamespace(command);
if (!groups[namespace]) {
groups[namespace] = [];
}
groups[namespace].push(command);
return groups;
}, {});
if (hasFrequentCommands) {
groupedCommands.frequent = frequentCommands;
}
const preferredOrder = hasFrequentCommands
? ['frequent', 'builtin', 'project', 'user', 'other']
: ['builtin', 'project', 'user', 'other'];
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
const commandIndexByKey = new Map<string, number>();
commands.forEach((command, index) => {
const key = getCommandKey(command);
if (!commandIndexByKey.has(key)) {
commandIndexByKey.set(key, index);
}
});
if (commands.length === 0) {
return (
<div
ref={menuRef}
className="command-menu command-menu-empty border border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"
style={{ ...menuPosition, ...menuBaseStyle, overflowY: 'hidden', padding: '20px', opacity: 1, transform: 'translateY(0)', textAlign: 'center' }}
>
No commands available
</div>
);
}
return (
<div
ref={menuRef}
role="listbox"
aria-label="Available commands"
className="command-menu border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
style={{ ...menuPosition, ...menuBaseStyle, opacity: 1, transform: 'translateY(0)' }}
>
{orderedNamespaces.map((namespace) => (
<div key={namespace} className="command-group">
{orderedNamespaces.length > 1 && (
<div className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{namespaceLabels[namespace] || namespace}
</div>
)}
{(groupedCommands[namespace] || []).map((command) => {
const commandKey = getCommandKey(command);
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
const isSelected = commandIndex === selectedIndex;
return (
<div
key={`${namespace}-${command.name}-${command.path || ''}`}
ref={isSelected ? selectedItemRef : null}
role="option"
aria-selected={isSelected}
className={`command-item mb-0.5 flex cursor-pointer items-start rounded-md px-3 py-2.5 transition-colors ${
isSelected ? 'bg-blue-50 dark:bg-blue-900' : 'bg-transparent'
}`}
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
onMouseDown={(event) => event.preventDefault()}
>
<div className="min-w-0 flex-1">
<div className={`flex items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-300">{namespaceIcons[namespace] || namespaceIcons.other}</span>
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-gray-100">{command.name}</span>
{command.metadata?.type && (
<span className="command-metadata-badge rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300">
{command.metadata.type}
</span>
)}
</div>
{command.description && (
<div className="ml-6 truncate whitespace-nowrap text-[13px] text-gray-500 dark:text-gray-300">
{command.description}
</div>
)}
</div>
{isSelected && <span className="ml-2 text-xs font-semibold text-blue-500 dark:text-blue-300">{'<-'}</span>}
</div>
);
})}
</div>
))}
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard';
type MarkdownProps = {
children: React.ReactNode;
@@ -31,9 +32,8 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
if (shouldInline) {
return (
<code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${
className || ''
}`}
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''
}`}
{...props}
>
{children}
@@ -43,43 +43,6 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text';
const textToCopy = raw;
const handleCopy = () => {
const doSet = () => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => {
const ta = document.createElement('textarea');
ta.value = textToCopy;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {}
document.body.removeChild(ta);
doSet();
});
} else {
const ta = document.createElement('textarea');
ta.value = textToCopy;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {}
document.body.removeChild(ta);
doSet();
}
} catch {}
};
return (
<div className="relative group my-2">
@@ -89,7 +52,14 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
<button
type="button"
onClick={handleCopy}
onClick={() =>
copyTextToClipboard(raw).then((success) => {
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
})
}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}

View File

@@ -1,6 +1,6 @@
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type {
ChatMessage,
ClaudePermissionSuggestion,
@@ -10,6 +10,7 @@ import type {
import { Markdown } from './Markdown';
import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
@@ -45,14 +46,15 @@ type PermissionGrantState = 'idle' | 'granted' | 'error';
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') ||
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') ||
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
const messageRef = React.useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = React.useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = React.useState<PermissionGrantState>('idle');
const [messageCopied, setMessageCopied] = React.useState(false);
React.useEffect(() => {
@@ -100,7 +102,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
{message.type === 'user' ? (
/* User message bubble on the right */
<div className="flex items-end space-x-0 sm:space-x-3 w-full sm:w-auto sm:max-w-[85%] md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial">
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial group">
<div className="text-sm whitespace-pre-wrap break-words">
{message.content}
</div>
@@ -117,8 +119,45 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
))}
</div>
)}
<div className="text-xs text-blue-100 mt-1 text-right">
{formattedTime}
<div className="flex items-center justify-end gap-1 mt-1 text-xs text-blue-100">
<button
type="button"
onClick={() => {
const text = String(message.content || '');
if (!text) return;
copyTextToClipboard(text).then((success) => {
if (!success) return;
setMessageCopied(true);
});
}}
title={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
>
{messageCopied ? (
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
)}
</button>
<span>{formattedTime}</span>
</div>
</div>
{!isGrouped && (
@@ -154,11 +193,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
)}
<div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))}
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}
</div>
</div>
)}
<div className="w-full">
{message.isToolUse ? (
@@ -184,9 +223,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
isSubagentContainer={message.isSubagentContainer}
subagentState={message.subagentState}
/>
)}
{/* Tool Result Section */}
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
message.toolResult.isError ? (
@@ -220,11 +261,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
}
}}
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${
permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
}`}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? t('permissions.added')
@@ -292,7 +332,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
const lines = (message.content || '').split('\n').filter((line) => line.trim());
const questionLine = lines.find((line) => line.includes('?')) || lines[0] || '';
const options: InteractiveOption[] = [];
// Parse the menu options
lines.forEach((line) => {
// Match lines like " 1. Yes" or " 2. No"
@@ -306,31 +346,29 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
});
}
});
return (
<>
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
{questionLine}
</p>
{/* Option buttons */}
<div className="space-y-2 mb-4">
{options.map((option) => (
<button
key={option.number}
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
option.isSelected
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
} cursor-not-allowed opacity-75`}
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${option.isSelected
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
} cursor-not-allowed opacity-75`}
disabled
>
<div className="flex items-center gap-3">
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
option.isSelected
? 'bg-white/20'
: 'bg-amber-100 dark:bg-amber-800/50'
}`}>
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${option.isSelected
? 'bg-white/20'
: 'bg-amber-100 dark:bg-amber-800/50'
}`}>
{option.number}
</span>
<span className="text-sm sm:text-base font-medium flex-1">
@@ -343,7 +381,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</button>
))}
</div>
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
{t('interactive.waiting')}
@@ -397,7 +435,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
// Detect if content is pure JSON (starts with { or [)
const trimmedContent = content.trim();
if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) &&
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
try {
const parsed = JSON.parse(trimmedContent);
const formatted = JSON.stringify(parsed, null, 2);
@@ -437,7 +475,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
})()}
</div>
)}
{!isGrouped && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
{formattedTime}

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import NextTaskBanner from '../../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app';
interface ProviderSelectionEmptyStateProps {
@@ -18,6 +18,8 @@ interface ProviderSelectionEmptyStateProps {
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
@@ -58,17 +60,27 @@ const PROVIDERS: ProviderDef[] = [
ring: 'ring-emerald-600/15',
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
},
{
id: 'gemini',
name: 'Gemini',
infoKey: 'providerSelection.providerInfo.google',
accent: 'border-blue-500 dark:border-blue-400',
ring: 'ring-blue-500/15',
check: 'bg-blue-500 text-white',
},
];
function getModelConfig(p: SessionProvider) {
if (p === 'claude') return CLAUDE_MODELS;
if (p === 'codex') return CODEX_MODELS;
if (p === 'gemini') return GEMINI_MODELS;
return CURSOR_MODELS;
}
function getModelValue(p: SessionProvider, c: string, cu: string, co: string) {
function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string) {
if (p === 'claude') return c;
if (p === 'codex') return co;
if (p === 'gemini') return g;
return cu;
}
@@ -84,6 +96,8 @@ export default function ProviderSelectionEmptyState({
setCursorModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
@@ -101,11 +115,12 @@ export default function ProviderSelectionEmptyState({
const handleModelChange = (value: string) => {
if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); }
else if (provider === 'gemini') { setGeminiModel(value); localStorage.setItem('gemini-model', value); }
else { setCursorModel(value); localStorage.setItem('cursor-model', value); }
};
const modelConfig = getModelConfig(provider);
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel);
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel);
/* ── New session — provider picker ── */
if (!selectedSession && !currentSessionId) {
@@ -123,7 +138,7 @@ export default function ProviderSelectionEmptyState({
</div>
{/* Provider cards — horizontal row, equal width */}
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 mb-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-2.5 mb-6">
{PROVIDERS.map((p) => {
const active = provider === p.id;
return (
@@ -179,13 +194,14 @@ export default function ProviderSelectionEmptyState({
</div>
<p className="text-center text-sm text-muted-foreground/70">
{provider === 'claude'
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
: provider === 'cursor'
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
: provider === 'codex'
? t('providerSelection.readyPrompt.codex', { model: codexModel })
: t('providerSelection.readyPrompt.default')}
{
{
claude: t('providerSelection.readyPrompt.claude', { model: claudeModel }),
cursor: t('providerSelection.readyPrompt.cursor', { model: cursorModel }),
codex: t('providerSelection.readyPrompt.codex', { model: codexModel }),
gemini: t('providerSelection.readyPrompt.gemini', { model: geminiModel }),
}[provider]
}
</p>
</div>

View File

@@ -0,0 +1,17 @@
export const CODE_EDITOR_STORAGE_KEYS = {
theme: 'codeEditorTheme',
wordWrap: 'codeEditorWordWrap',
showMinimap: 'codeEditorShowMinimap',
lineNumbers: 'codeEditorLineNumbers',
fontSize: 'codeEditorFontSize',
} as const;
export const CODE_EDITOR_DEFAULTS = {
isDarkMode: true,
wordWrap: false,
minimapEnabled: true,
showLineNumbers: true,
fontSize: '12',
} as const;
export const CODE_EDITOR_SETTINGS_CHANGED_EVENT = 'codeEditorSettingsChanged';

View File

@@ -0,0 +1,126 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { CodeEditorFile } from '../types/types';
type UseCodeEditorDocumentParams = {
file: CodeEditorFile;
projectPath?: string;
};
const getErrorMessage = (error: unknown) => {
if (error instanceof Error) {
return error.message;
}
return String(error);
};
export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocumentParams) => {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const fileProjectName = file.projectName ?? projectPath;
const filePath = file.path;
const fileName = file.name;
const fileDiffNewString = file.diffInfo?.new_string;
const fileDiffOldString = file.diffInfo?.old_string;
useEffect(() => {
const loadFileContent = async () => {
try {
setLoading(true);
// Diff payload may already include full old/new snapshots, so avoid disk read.
if (file.diffInfo && fileDiffNewString !== undefined && fileDiffOldString !== undefined) {
setContent(fileDiffNewString);
setLoading(false);
return;
}
if (!fileProjectName) {
throw new Error('Missing project identifier');
}
const response = await api.readFile(fileProjectName, filePath);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content);
} catch (error) {
const message = getErrorMessage(error);
console.error('Error loading file:', error);
setContent(`// Error loading file: ${message}\n// File: ${fileName}\n// Path: ${filePath}`);
} finally {
setLoading(false);
}
};
loadFileContent();
}, [fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
const handleSave = useCallback(async () => {
setSaving(true);
setSaveError(null);
try {
if (!fileProjectName) {
throw new Error('Missing project identifier');
}
const response = await api.saveFile(fileProjectName, filePath, content);
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
}
const textError = await response.text();
console.error('Non-JSON error response:', textError);
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
}
await response.json();
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
const message = getErrorMessage(error);
console.error('Error saving file:', error);
setSaveError(message);
} finally {
setSaving(false);
}
}, [content, filePath, fileProjectName]);
const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = file.name;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}, [content, file.name]);
return {
content,
setContent,
loading,
saving,
saveSuccess,
saveError,
handleSave,
handleDownload,
};
};

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import {
CODE_EDITOR_DEFAULTS,
CODE_EDITOR_SETTINGS_CHANGED_EVENT,
CODE_EDITOR_STORAGE_KEYS,
} from '../constants/settings';
const readTheme = () => {
const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
if (!savedTheme) {
return CODE_EDITOR_DEFAULTS.isDarkMode;
}
return savedTheme === 'dark';
};
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
const value = localStorage.getItem(storageKey);
if (value === null) {
return defaultValue;
}
return value !== falseValue;
};
const readWordWrap = () => {
return localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.wordWrap) === 'true';
};
const readFontSize = () => {
const stored = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.fontSize);
return Number(stored ?? CODE_EDITOR_DEFAULTS.fontSize);
};
export const useCodeEditorSettings = () => {
const [isDarkMode, setIsDarkMode] = useState(readTheme);
const [wordWrap, setWordWrap] = useState(readWordWrap);
const [minimapEnabled, setMinimapEnabled] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
));
const [showLineNumbers, setShowLineNumbers] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers)
));
const [fontSize, setFontSize] = useState(readFontSize);
// Keep legacy behavior where the editor writes theme and wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
}, [wordWrap]);
useEffect(() => {
const refreshFromStorage = () => {
setIsDarkMode(readTheme());
setWordWrap(readWordWrap());
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
setFontSize(readFontSize());
};
window.addEventListener('storage', refreshFromStorage);
window.addEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);
return () => {
window.removeEventListener('storage', refreshFromStorage);
window.removeEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);
};
}, []);
return {
isDarkMode,
setIsDarkMode,
wordWrap,
setWordWrap,
minimapEnabled,
setMinimapEnabled,
showLineNumbers,
setShowLineNumbers,
fontSize,
setFontSize,
};
};

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
type UseEditorKeyboardShortcutsParams = {
onSave: () => void;
onClose: () => void;
dependency: string;
};
export const useEditorKeyboardShortcuts = ({
onSave,
onClose,
dependency,
}: UseEditorKeyboardShortcutsParams) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (!(event.ctrlKey || event.metaKey)) {
return;
}
if (event.key.toLowerCase() === 's') {
event.preventDefault();
onSave();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [dependency, onClose, onSave]);
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { Project } from '../../../types/app';
import type { DiffInfo, EditingFile } from '../types/types';
import type { CodeEditorDiffInfo, CodeEditorFile } from '../types/types';
type UseEditorSidebarOptions = {
selectedProject: Project | null;
@@ -9,19 +9,20 @@ type UseEditorSidebarOptions = {
initialWidth?: number;
};
export function useEditorSidebar({
export const useEditorSidebar = ({
selectedProject,
isMobile,
initialWidth = 600,
}: UseEditorSidebarOptions) {
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
}: UseEditorSidebarOptions) => {
const [editingFile, setEditingFile] = useState<CodeEditorFile | null>(null);
const [editorWidth, setEditorWidth] = useState(initialWidth);
const [editorExpanded, setEditorExpanded] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [hasManualWidth, setHasManualWidth] = useState(false);
const resizeHandleRef = useRef<HTMLDivElement | null>(null);
const handleFileOpen = useCallback(
(filePath: string, diffInfo: DiffInfo | null = null) => {
(filePath: string, diffInfo: CodeEditorDiffInfo | null = null) => {
const normalizedPath = filePath.replace(/\\/g, '/');
const fileName = normalizedPath.split('/').pop() || filePath;
@@ -41,7 +42,7 @@ export function useEditorSidebar({
}, []);
const handleToggleEditorExpand = useCallback(() => {
setEditorExpanded((prev) => !prev);
setEditorExpanded((previous) => !previous);
}, []);
const handleResizeStart = useCallback(
@@ -50,6 +51,8 @@ export function useEditorSidebar({
return;
}
// After first drag interaction, the editor width is user-controlled.
setHasManualWidth(true);
setIsResizing(true);
event.preventDefault();
},
@@ -101,10 +104,11 @@ export function useEditorSidebar({
editingFile,
editorWidth,
editorExpanded,
hasManualWidth,
resizeHandleRef,
handleFileOpen,
handleCloseEditor,
handleToggleEditorExpand,
handleResizeStart,
};
}
};

View File

@@ -0,0 +1,21 @@
export type CodeEditorDiffInfo = {
old_string?: string;
new_string?: string;
[key: string]: unknown;
};
export type CodeEditorFile = {
name: string;
path: string;
projectName?: string;
diffInfo?: CodeEditorDiffInfo | null;
[key: string]: unknown;
};
export type CodeEditorSettingsState = {
isDarkMode: boolean;
wordWrap: boolean;
minimapEnabled: boolean;
showLineNumbers: boolean;
fontSize: string;
};

View File

@@ -0,0 +1,141 @@
import { css } from '@codemirror/lang-css';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { StreamLanguage } from '@codemirror/language';
import { markdown } from '@codemirror/lang-markdown';
import { python } from '@codemirror/lang-python';
import { getChunks } from '@codemirror/merge';
import { EditorView, ViewPlugin } from '@codemirror/view';
import { showMinimap } from '@replit/codemirror-minimap';
import type { CodeEditorFile } from '../types/types';
// Lightweight lexer for `.env` files (including `.env.*` variants).
const envLanguage = StreamLanguage.define({
token(stream) {
if (stream.match(/^#.*/)) return 'comment';
if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';
if (stream.match(/^=/)) return 'operator';
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string';
if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special';
if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';
if (stream.match(/^\d+/)) return 'number';
stream.next();
return null;
},
});
export const getLanguageExtensions = (filename: string) => {
const lowerName = filename.toLowerCase();
if (lowerName === '.env' || lowerName.startsWith('.env.')) {
return [envLanguage];
}
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
case 'py':
return [python()];
case 'html':
case 'htm':
return [html()];
case 'css':
case 'scss':
case 'less':
return [css()];
case 'json':
return [json()];
case 'md':
case 'markdown':
return [markdown()];
case 'env':
return [envLanguage];
default:
return [];
}
};
export const createMinimapExtension = ({
file,
showDiff,
minimapEnabled,
isDarkMode,
}: {
file: CodeEditorFile;
showDiff: boolean;
minimapEnabled: boolean;
isDarkMode: boolean;
}) => {
if (!file.diffInfo || !showDiff || !minimapEnabled) {
return [];
}
const gutters: Record<number, string> = {};
return [
showMinimap.compute(['doc'], (state) => {
const chunksData = getChunks(state);
const chunks = chunksData?.chunks || [];
Object.keys(gutters).forEach((key) => {
delete gutters[Number(key)];
});
chunks.forEach((chunk) => {
const fromLine = state.doc.lineAt(chunk.fromB).number;
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
for (let lineNumber = fromLine; lineNumber <= toLine; lineNumber += 1) {
gutters[lineNumber] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
}
});
return {
create: () => ({ dom: document.createElement('div') }),
displayText: 'blocks',
showOverlay: 'always',
gutters: [gutters],
};
}),
];
};
export const createScrollToFirstChunkExtension = ({
file,
showDiff,
}: {
file: CodeEditorFile;
showDiff: boolean;
}) => {
if (!file.diffInfo || !showDiff) {
return [];
}
return [
ViewPlugin.fromClass(class {
constructor(view: EditorView) {
// Wait for merge decorations so the first chunk location is stable.
setTimeout(() => {
const chunksData = getChunks(view.state);
const firstChunk = chunksData?.chunks?.[0];
if (firstChunk) {
view.dispatch({
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' }),
});
}
}, 100);
}
update() {}
destroy() {}
}),
];
};

View File

@@ -0,0 +1,79 @@
export const getEditorLoadingStyles = (isDarkMode: boolean) => {
return `
.code-editor-loading {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-loading:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`;
};
export const getEditorStyles = (isDarkMode: boolean) => {
return `
.cm-deletedChunk {
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
padding-left: 4px !important;
}
.cm-insertedChunk {
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
padding-left: 4px !important;
}
.cm-editor.cm-merge-b .cm-changedText {
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-editor .cm-deletedChunk .cm-changedText {
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-gutter.cm-gutter-minimap {
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
}
.cm-editor-toolbar-panel {
padding: 4px 10px;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
color: ${isDarkMode ? '#d1d5db' : '#374151'};
font-size: 12px;
}
.cm-diff-nav-btn,
.cm-toolbar-btn {
padding: 3px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
transition: background-color 0.2s;
}
.cm-diff-nav-btn:hover,
.cm-toolbar-btn:hover {
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
}
.cm-diff-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
};

View File

@@ -0,0 +1,212 @@
import { getChunks } from '@codemirror/merge';
import { EditorView, showPanel } from '@codemirror/view';
import type { CodeEditorFile } from '../types/types';
type EditorToolbarLabels = {
changes: string;
previousChange: string;
nextChange: string;
hideDiff: string;
showDiff: string;
collapse: string;
expand: string;
};
type CreateEditorToolbarPanelParams = {
file: CodeEditorFile;
showDiff: boolean;
isSidebar: boolean;
isExpanded: boolean;
onToggleDiff: () => void;
onPopOut: (() => void) | null;
onToggleExpand: (() => void) | null;
labels: EditorToolbarLabels;
};
const getDiffVisibilityIcon = (showDiff: boolean) => {
if (showDiff) {
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />';
}
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />';
};
const getExpandIcon = (isExpanded: boolean) => {
if (isExpanded) {
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />';
}
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />';
};
const escapeHtml = (value: string): string => (
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
);
export const createEditorToolbarPanelExtension = ({
file,
showDiff,
isSidebar,
isExpanded,
onToggleDiff,
onPopOut,
onToggleExpand,
labels,
}: CreateEditorToolbarPanelParams) => {
const hasToolbarButtons = Boolean(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));
if (!hasToolbarButtons) {
return [];
}
const createPanel = (view: EditorView) => {
const dom = document.createElement('div');
dom.className = 'cm-editor-toolbar-panel';
let currentIndex = 0;
const updatePanel = () => {
const hasDiff = Boolean(file.diffInfo && showDiff);
const chunksData = hasDiff ? getChunks(view.state) : null;
const chunks = chunksData?.chunks || [];
const chunkCount = chunks.length;
const maxChunkIndex = Math.max(0, chunkCount - 1);
currentIndex = Math.max(0, Math.min(currentIndex, maxChunkIndex));
const escapedLabels = {
changes: escapeHtml(labels.changes),
previousChange: escapeHtml(labels.previousChange),
nextChange: escapeHtml(labels.nextChange),
hideDiff: escapeHtml(labels.hideDiff),
showDiff: escapeHtml(labels.showDiff),
collapse: escapeHtml(labels.collapse),
expand: escapeHtml(labels.expand),
};
// Icons are static SVG path fragments controlled by this module.
const diffVisibilityIcon = getDiffVisibilityIcon(showDiff);
const expandIcon = getExpandIcon(isExpanded);
let toolbarHtml = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
toolbarHtml += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) {
toolbarHtml += `
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${escapedLabels.changes}</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${escapedLabels.previousChange}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${escapedLabels.nextChange}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
`;
}
toolbarHtml += '</div>';
toolbarHtml += '<div style="display: flex; align-items: center; gap: 4px;">';
if (file.diffInfo) {
toolbarHtml += `
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? escapedLabels.hideDiff : escapedLabels.showDiff}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${diffVisibilityIcon}
</svg>
</button>
`;
}
if (isSidebar && onPopOut) {
toolbarHtml += `
<button class="cm-toolbar-btn cm-popout-btn" title="Open in modal">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</button>
`;
}
if (isSidebar && onToggleExpand) {
toolbarHtml += `
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? escapedLabels.collapse : escapedLabels.expand}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${expandIcon}
</svg>
</button>
`;
}
toolbarHtml += '</div>';
toolbarHtml += '</div>';
dom.innerHTML = toolbarHtml;
if (hasDiff) {
const previousButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-prev');
const nextButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-next');
previousButton?.addEventListener('click', () => {
if (chunks.length === 0) {
return;
}
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),
});
}
updatePanel();
});
nextButton?.addEventListener('click', () => {
if (chunks.length === 0) {
return;
}
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),
});
}
updatePanel();
});
}
const toggleDiffButton = dom.querySelector<HTMLButtonElement>('.cm-toggle-diff-btn');
toggleDiffButton?.addEventListener('click', onToggleDiff);
const popOutButton = dom.querySelector<HTMLButtonElement>('.cm-popout-btn');
popOutButton?.addEventListener('click', () => {
onPopOut?.();
});
const expandButton = dom.querySelector<HTMLButtonElement>('.cm-expand-btn');
expandButton?.addEventListener('click', () => {
onToggleExpand?.();
});
};
updatePanel();
return {
top: true,
dom,
update: updatePanel,
};
};
return [showPanel.of(createPanel)];
};

View File

@@ -0,0 +1,234 @@
import { EditorView } from '@codemirror/view';
import { unifiedMergeView } from '@codemirror/merge';
import type { Extension } from '@codemirror/state';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
import type { CodeEditorFile } from '../types/types';
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
import { getEditorStyles } from '../utils/editorStyles';
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
type CodeEditorProps = {
file: CodeEditorFile;
onClose: () => void;
projectPath?: string;
isSidebar?: boolean;
isExpanded?: boolean;
onToggleExpand?: (() => void) | null;
onPopOut?: (() => void) | null;
};
export default function CodeEditor({
file,
onClose,
projectPath,
isSidebar = false,
isExpanded = false,
onToggleExpand = null,
onPopOut = null,
}: CodeEditorProps) {
const { t } = useTranslation('codeEditor');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
const [markdownPreview, setMarkdownPreview] = useState(false);
const {
isDarkMode,
wordWrap,
minimapEnabled,
showLineNumbers,
fontSize,
} = useCodeEditorSettings();
const {
content,
setContent,
loading,
saving,
saveSuccess,
saveError,
handleSave,
handleDownload,
} = useCodeEditorDocument({
file,
projectPath,
});
const isMarkdownFile = useMemo(() => {
const extension = file.name.split('.').pop()?.toLowerCase();
return extension === 'md' || extension === 'markdown';
}, [file.name]);
const minimapExtension = useMemo(
() => (
createMinimapExtension({
file,
showDiff,
minimapEnabled,
isDarkMode,
})
),
[file, isDarkMode, minimapEnabled, showDiff],
);
const scrollToFirstChunkExtension = useMemo(
() => createScrollToFirstChunkExtension({ file, showDiff }),
[file, showDiff],
);
const toolbarPanelExtension = useMemo(
() => (
createEditorToolbarPanelExtension({
file,
showDiff,
isSidebar,
isExpanded,
onToggleDiff: () => setShowDiff((previous) => !previous),
onPopOut,
onToggleExpand,
labels: {
changes: t('toolbar.changes'),
previousChange: t('toolbar.previousChange'),
nextChange: t('toolbar.nextChange'),
hideDiff: t('toolbar.hideDiff'),
showDiff: t('toolbar.showDiff'),
collapse: t('toolbar.collapse'),
expand: t('toolbar.expand'),
},
})
),
[file, isExpanded, isSidebar, onPopOut, onToggleExpand, showDiff, t],
);
const extensions = useMemo(() => {
const allExtensions: Extension[] = [
...getLanguageExtensions(file.name),
...toolbarPanelExtension,
];
if (file.diffInfo && showDiff && file.diffInfo.old_string !== undefined) {
allExtensions.push(
unifiedMergeView({
original: file.diffInfo.old_string,
mergeControls: false,
highlightChanges: true,
syntaxHighlightDeletions: false,
gutter: true,
}),
);
allExtensions.push(...minimapExtension);
allExtensions.push(...scrollToFirstChunkExtension);
}
if (wordWrap) {
allExtensions.push(EditorView.lineWrapping);
}
return allExtensions;
}, [
file.diffInfo,
file.name,
minimapExtension,
scrollToFirstChunkExtension,
showDiff,
toolbarPanelExtension,
wordWrap,
]);
useEditorKeyboardShortcuts({
onSave: handleSave,
onClose,
dependency: content,
});
if (loading) {
return (
<CodeEditorLoadingState
isDarkMode={isDarkMode}
isSidebar={isSidebar}
loadingText={t('loading', { fileName: file.name })}
/>
);
}
const outerContainerClassName = isSidebar
? 'w-full h-full flex flex-col'
: `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`;
const innerContainerClassName = isSidebar
? 'bg-background flex flex-col w-full h-full'
: `bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl${
isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]'
}`;
return (
<>
<style>{getEditorStyles(isDarkMode)}</style>
<div className={outerContainerClassName}>
<div className={innerContainerClassName}>
<CodeEditorHeader
file={file}
isSidebar={isSidebar}
isFullscreen={isFullscreen}
isMarkdownFile={isMarkdownFile}
markdownPreview={markdownPreview}
saving={saving}
saveSuccess={saveSuccess}
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
onOpenSettings={() => window.openSettings?.('appearance')}
onDownload={handleDownload}
onSave={handleSave}
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
onClose={onClose}
labels={{
showingChanges: t('header.showingChanges'),
editMarkdown: t('actions.editMarkdown'),
previewMarkdown: t('actions.previewMarkdown'),
settings: t('toolbar.settings'),
download: t('actions.download'),
save: t('actions.save'),
saving: t('actions.saving'),
saved: t('actions.saved'),
fullscreen: t('actions.fullscreen'),
exitFullscreen: t('actions.exitFullscreen'),
close: t('actions.close'),
}}
/>
{saveError && (
<div className="px-3 py-1.5 text-xs text-red-700 bg-red-50 border-b border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900/40">
{saveError}
</div>
)}
<div className="flex-1 overflow-hidden">
<CodeEditorSurface
content={content}
onChange={setContent}
markdownPreview={markdownPreview}
isMarkdownFile={isMarkdownFile}
isDarkMode={isDarkMode}
fontSize={fontSize}
showLineNumbers={showLineNumbers}
extensions={extensions}
/>
</div>
<CodeEditorFooter
content={content}
linesLabel={t('footer.lines')}
charactersLabel={t('footer.characters')}
shortcutsLabel={t('footer.shortcuts')}
/>
</div>
</div>
</>
);
}

View File

@@ -1,14 +1,28 @@
import { useState } from 'react';
import CodeEditor from '../../../CodeEditor';
import type { EditorSidebarProps } from '../../types/types';
import type { MouseEvent, MutableRefObject } from 'react';
import type { CodeEditorFile } from '../types/types';
import CodeEditor from './CodeEditor';
const AnyCodeEditor = CodeEditor as any;
type EditorSidebarProps = {
editingFile: CodeEditorFile | null;
isMobile: boolean;
editorExpanded: boolean;
editorWidth: number;
hasManualWidth: boolean;
resizeHandleRef: MutableRefObject<HTMLDivElement | null>;
onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
onCloseEditor: () => void;
onToggleEditorExpand: () => void;
projectPath?: string;
fillSpace?: boolean;
};
export default function EditorSidebar({
editingFile,
isMobile,
editorExpanded,
editorWidth,
hasManualWidth,
resizeHandleRef,
onResizeStart,
onCloseEditor,
@@ -24,7 +38,7 @@ export default function EditorSidebar({
if (isMobile || poppedOut) {
return (
<AnyCodeEditor
<CodeEditor
file={editingFile}
onClose={() => {
setPoppedOut(false);
@@ -36,7 +50,8 @@ export default function EditorSidebar({
);
}
const useFlex = editorExpanded || fillSpace;
// In files tab, fill the remaining width unless user has dragged manually.
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
return (
<>
@@ -52,10 +67,10 @@ export default function EditorSidebar({
)}
<div
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlex ? 'flex-1' : ''}`}
style={useFlex ? undefined : { width: `${editorWidth}px` }}
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1' : ''}`}
style={useFlexLayout ? undefined : { width: `${editorWidth}px` }}
>
<AnyCodeEditor
<CodeEditor
file={editingFile}
onClose={onCloseEditor}
projectPath={projectPath}

View File

@@ -0,0 +1,28 @@
type CodeEditorFooterProps = {
content: string;
linesLabel: string;
charactersLabel: string;
shortcutsLabel: string;
};
export default function CodeEditorFooter({
content,
linesLabel,
charactersLabel,
shortcutsLabel,
}: CodeEditorFooterProps) {
return (
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0">
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<span>
{linesLabel} {content.split('\n').length}
</span>
<span>
{charactersLabel} {content.length}
</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{shortcutsLabel}</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
import type { CodeEditorFile } from '../../types/types';
type CodeEditorHeaderProps = {
file: CodeEditorFile;
isSidebar: boolean;
isFullscreen: boolean;
isMarkdownFile: boolean;
markdownPreview: boolean;
saving: boolean;
saveSuccess: boolean;
onToggleMarkdownPreview: () => void;
onOpenSettings: () => void;
onDownload: () => void;
onSave: () => void;
onToggleFullscreen: () => void;
onClose: () => void;
labels: {
showingChanges: string;
editMarkdown: string;
previewMarkdown: string;
settings: string;
download: string;
save: string;
saving: string;
saved: string;
fullscreen: string;
exitFullscreen: string;
close: string;
};
};
export default function CodeEditorHeader({
file,
isSidebar,
isFullscreen,
isMarkdownFile,
markdownPreview,
saving,
saveSuccess,
onToggleMarkdownPreview,
onOpenSettings,
onDownload,
onSave,
onToggleFullscreen,
onClose,
labels,
}: CodeEditorHeaderProps) {
const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;
return (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
{labels.showingChanges}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
{isMarkdownFile && (
<button
type="button"
onClick={onToggleMarkdownPreview}
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
markdownPreview
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={markdownPreview ? labels.editMarkdown : labels.previewMarkdown}
>
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
<button
type="button"
onClick={onOpenSettings}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={labels.settings}
>
<SettingsIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={onDownload}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={labels.download}
>
<Download className="w-4 h-4" />
</button>
<button
type="button"
onClick={onSave}
disabled={saving}
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
saveSuccess
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={saveTitle}
>
{saveSuccess ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<Save className="w-4 h-4" />
)}
</button>
{!isSidebar && (
<button
type="button"
onClick={onToggleFullscreen}
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<button
type="button"
onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={labels.close}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { getEditorLoadingStyles } from '../../utils/editorStyles';
type CodeEditorLoadingStateProps = {
isDarkMode: boolean;
isSidebar: boolean;
loadingText: string;
};
export default function CodeEditorLoadingState({
isDarkMode,
isSidebar,
loadingText,
}: CodeEditorLoadingStateProps) {
return (
<>
<style>{getEditorLoadingStyles(isDarkMode)}</style>
{isSidebar ? (
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
<span className="text-gray-900 dark:text-white">{loadingText}</span>
</div>
</div>
) : (
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
<span className="text-gray-900 dark:text-white">{loadingText}</span>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,62 @@
import CodeMirror from '@uiw/react-codemirror';
import { oneDark } from '@codemirror/theme-one-dark';
import type { Extension } from '@codemirror/state';
import MarkdownPreview from './markdown/MarkdownPreview';
type CodeEditorSurfaceProps = {
content: string;
onChange: (value: string) => void;
markdownPreview: boolean;
isMarkdownFile: boolean;
isDarkMode: boolean;
fontSize: number;
showLineNumbers: boolean;
extensions: Extension[];
};
export default function CodeEditorSurface({
content,
onChange,
markdownPreview,
isMarkdownFile,
isDarkMode,
fontSize,
showLineNumbers,
extensions,
}: CodeEditorSurfaceProps) {
if (markdownPreview && isMarkdownFile) {
return (
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none">
<MarkdownPreview content={content} />
</div>
</div>
);
}
return (
<CodeMirror
value={content}
onChange={onChange}
extensions={extensions}
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: `${fontSize}px`,
height: '100%',
}}
basicSetup={{
lineNumbers: showLineNumbers,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
/>
);
}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import type { ComponentProps } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { copyTextToClipboard } from '../../../../../utils/clipboard';
type MarkdownCodeBlockProps = {
inline?: boolean;
node?: unknown;
} & ComponentProps<'code'>;
export default function MarkdownCodeBlock({
inline,
className,
children,
node: _node,
...props
}: MarkdownCodeBlockProps) {
const [copied, setCopied] = useState(false);
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(rawContent);
const shouldRenderInline = inline || !looksMultiline;
if (shouldRenderInline) {
return (
<code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`}
{...props}
>
{children}
</code>
);
}
const languageMatch = /language-(\w+)/.exec(className || '');
const language = languageMatch ? languageMatch[1] : 'text';
return (
<div className="relative group my-2">
{language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
)}
<button
type="button"
onClick={() =>
copyTextToClipboard(rawContent).then((success) => {
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
})}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<SyntaxHighlighter
language={language}
style={prismOneDark}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
>
{rawContent}
</SyntaxHighlighter>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useMemo } from 'react';
import type { Components } from 'react-markdown';
import ReactMarkdown from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import MarkdownCodeBlock from './MarkdownCodeBlock';
type MarkdownPreviewProps = {
content: string;
};
const markdownPreviewComponents: Components = {
code: MarkdownCodeBlock,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
{children}
</blockquote>
),
a: ({ href, children }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
th: ({ children }) => (
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
),
td: ({ children }) => (
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
),
};
export default function MarkdownPreview({ content }: MarkdownPreviewProps) {
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeKatex], []);
return (
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownPreviewComponents}
>
{content}
</ReactMarkdown>
);
}

View File

@@ -0,0 +1,18 @@
import type { FileTreeViewMode } from '../types/types';
export const FILE_TREE_VIEW_MODE_STORAGE_KEY = 'file-tree-view-mode';
export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
export const IMAGE_FILE_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'svg',
'webp',
'ico',
'bmp',
]);

View File

@@ -0,0 +1,224 @@
import {
Archive,
Binary,
Blocks,
BookOpen,
Box,
Braces,
Code2,
Cog,
Coffee,
Cpu,
Database,
File,
FileCheck,
FileCode,
FileCode2,
FileSpreadsheet,
FileText,
FileType,
Flame,
FlaskConical,
Gem,
Globe,
Hash,
Hexagon,
Image,
Lock,
Music2,
NotebookPen,
Palette,
Scroll,
Settings,
Shield,
SquareFunction,
Terminal,
Video,
Workflow,
} from 'lucide-react';
import type { FileIconData, FileIconMap } from '../types/types';
export const ICON_SIZE_CLASS = 'w-4 h-4 flex-shrink-0';
const FILE_ICON_MAP: FileIconMap = {
js: { icon: FileCode, color: 'text-yellow-500' },
jsx: { icon: FileCode, color: 'text-yellow-500' },
mjs: { icon: FileCode, color: 'text-yellow-500' },
cjs: { icon: FileCode, color: 'text-yellow-500' },
ts: { icon: FileCode2, color: 'text-blue-500' },
tsx: { icon: FileCode2, color: 'text-blue-500' },
mts: { icon: FileCode2, color: 'text-blue-500' },
py: { icon: Code2, color: 'text-emerald-500' },
pyw: { icon: Code2, color: 'text-emerald-500' },
pyi: { icon: Code2, color: 'text-emerald-400' },
ipynb: { icon: NotebookPen, color: 'text-orange-500' },
rs: { icon: Cog, color: 'text-orange-600' },
toml: { icon: Settings, color: 'text-gray-500' },
go: { icon: Hexagon, color: 'text-cyan-500' },
rb: { icon: Gem, color: 'text-red-500' },
erb: { icon: Gem, color: 'text-red-400' },
php: { icon: Blocks, color: 'text-violet-500' },
java: { icon: Coffee, color: 'text-red-600' },
jar: { icon: Coffee, color: 'text-red-500' },
kt: { icon: Hexagon, color: 'text-violet-500' },
kts: { icon: Hexagon, color: 'text-violet-400' },
c: { icon: Cpu, color: 'text-blue-600' },
h: { icon: Cpu, color: 'text-blue-400' },
cpp: { icon: Cpu, color: 'text-blue-700' },
hpp: { icon: Cpu, color: 'text-blue-500' },
cc: { icon: Cpu, color: 'text-blue-700' },
cs: { icon: Hexagon, color: 'text-purple-600' },
swift: { icon: Flame, color: 'text-orange-500' },
lua: { icon: SquareFunction, color: 'text-blue-500' },
r: { icon: FlaskConical, color: 'text-blue-600' },
html: { icon: Globe, color: 'text-orange-600' },
htm: { icon: Globe, color: 'text-orange-600' },
css: { icon: Hash, color: 'text-blue-500' },
scss: { icon: Hash, color: 'text-pink-500' },
sass: { icon: Hash, color: 'text-pink-400' },
less: { icon: Hash, color: 'text-indigo-500' },
vue: { icon: FileCode2, color: 'text-emerald-500' },
svelte: { icon: FileCode2, color: 'text-orange-500' },
json: { icon: Braces, color: 'text-yellow-600' },
jsonc: { icon: Braces, color: 'text-yellow-500' },
json5: { icon: Braces, color: 'text-yellow-500' },
yaml: { icon: Settings, color: 'text-purple-400' },
yml: { icon: Settings, color: 'text-purple-400' },
xml: { icon: FileCode, color: 'text-orange-500' },
csv: { icon: FileSpreadsheet, color: 'text-green-600' },
tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
sql: { icon: Database, color: 'text-blue-500' },
graphql: { icon: Workflow, color: 'text-pink-500' },
gql: { icon: Workflow, color: 'text-pink-500' },
proto: { icon: Box, color: 'text-green-500' },
env: { icon: Shield, color: 'text-yellow-600' },
md: { icon: BookOpen, color: 'text-blue-500' },
mdx: { icon: BookOpen, color: 'text-blue-400' },
txt: { icon: FileText, color: 'text-gray-500' },
doc: { icon: FileText, color: 'text-blue-600' },
docx: { icon: FileText, color: 'text-blue-600' },
pdf: { icon: FileCheck, color: 'text-red-600' },
rtf: { icon: FileText, color: 'text-gray-500' },
tex: { icon: Scroll, color: 'text-teal-600' },
rst: { icon: FileText, color: 'text-gray-400' },
sh: { icon: Terminal, color: 'text-green-500' },
bash: { icon: Terminal, color: 'text-green-500' },
zsh: { icon: Terminal, color: 'text-green-400' },
fish: { icon: Terminal, color: 'text-green-400' },
ps1: { icon: Terminal, color: 'text-blue-400' },
bat: { icon: Terminal, color: 'text-gray-500' },
cmd: { icon: Terminal, color: 'text-gray-500' },
png: { icon: Image, color: 'text-purple-500' },
jpg: { icon: Image, color: 'text-purple-500' },
jpeg: { icon: Image, color: 'text-purple-500' },
gif: { icon: Image, color: 'text-purple-400' },
webp: { icon: Image, color: 'text-purple-400' },
ico: { icon: Image, color: 'text-purple-400' },
bmp: { icon: Image, color: 'text-purple-400' },
tiff: { icon: Image, color: 'text-purple-400' },
svg: { icon: Palette, color: 'text-amber-500' },
mp3: { icon: Music2, color: 'text-pink-500' },
wav: { icon: Music2, color: 'text-pink-500' },
ogg: { icon: Music2, color: 'text-pink-400' },
flac: { icon: Music2, color: 'text-pink-400' },
aac: { icon: Music2, color: 'text-pink-400' },
m4a: { icon: Music2, color: 'text-pink-400' },
mp4: { icon: Video, color: 'text-rose-500' },
mov: { icon: Video, color: 'text-rose-500' },
avi: { icon: Video, color: 'text-rose-500' },
webm: { icon: Video, color: 'text-rose-400' },
mkv: { icon: Video, color: 'text-rose-400' },
ttf: { icon: FileType, color: 'text-red-500' },
otf: { icon: FileType, color: 'text-red-500' },
woff: { icon: FileType, color: 'text-red-400' },
woff2: { icon: FileType, color: 'text-red-400' },
eot: { icon: FileType, color: 'text-red-400' },
zip: { icon: Archive, color: 'text-amber-600' },
tar: { icon: Archive, color: 'text-amber-600' },
gz: { icon: Archive, color: 'text-amber-600' },
bz2: { icon: Archive, color: 'text-amber-600' },
rar: { icon: Archive, color: 'text-amber-500' },
'7z': { icon: Archive, color: 'text-amber-500' },
lock: { icon: Lock, color: 'text-gray-500' },
exe: { icon: Binary, color: 'text-gray-500' },
bin: { icon: Binary, color: 'text-gray-500' },
dll: { icon: Binary, color: 'text-gray-400' },
so: { icon: Binary, color: 'text-gray-400' },
dylib: { icon: Binary, color: 'text-gray-400' },
wasm: { icon: Binary, color: 'text-purple-500' },
ini: { icon: Settings, color: 'text-gray-500' },
cfg: { icon: Settings, color: 'text-gray-500' },
conf: { icon: Settings, color: 'text-gray-500' },
log: { icon: Scroll, color: 'text-gray-400' },
map: { icon: File, color: 'text-gray-400' },
};
const FILENAME_ICON_MAP: FileIconMap = {
Dockerfile: { icon: Box, color: 'text-blue-500' },
'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
'.dockerignore': { icon: Box, color: 'text-gray-500' },
'.gitignore': { icon: Settings, color: 'text-gray-500' },
'.gitmodules': { icon: Settings, color: 'text-gray-500' },
'.gitattributes': { icon: Settings, color: 'text-gray-500' },
'.editorconfig': { icon: Settings, color: 'text-gray-500' },
'.prettierrc': { icon: Settings, color: 'text-pink-400' },
'.prettierignore': { icon: Settings, color: 'text-gray-500' },
'.eslintrc': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
'eslint.config.mjs': { icon: Settings, color: 'text-violet-500' },
'.env': { icon: Shield, color: 'text-yellow-600' },
'.env.local': { icon: Shield, color: 'text-yellow-600' },
'.env.development': { icon: Shield, color: 'text-yellow-500' },
'.env.production': { icon: Shield, color: 'text-yellow-600' },
'.env.example': { icon: Shield, color: 'text-yellow-400' },
'package.json': { icon: Braces, color: 'text-green-500' },
'package-lock.json': { icon: Lock, color: 'text-gray-500' },
'yarn.lock': { icon: Lock, color: 'text-blue-400' },
'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
'bun.lockb': { icon: Lock, color: 'text-gray-400' },
'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
Gemfile: { icon: Gem, color: 'text-red-500' },
'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
Makefile: { icon: Terminal, color: 'text-gray-500' },
'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
'vite.config.js': { icon: Flame, color: 'text-purple-500' },
'webpack.config.js': { icon: Cog, color: 'text-blue-500' },
'tailwind.config.js': { icon: Hash, color: 'text-cyan-500' },
'tailwind.config.ts': { icon: Hash, color: 'text-cyan-500' },
'postcss.config.js': { icon: Cog, color: 'text-red-400' },
'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
'.babelrc': { icon: Settings, color: 'text-yellow-500' },
'README.md': { icon: BookOpen, color: 'text-blue-500' },
LICENSE: { icon: FileCheck, color: 'text-gray-500' },
'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
'go.sum': { icon: Lock, color: 'text-cyan-400' },
};
// Icon resolution is deterministic: exact filename, then .env prefixes, then extension, then fallback.
export function getFileIconData(filename: string): FileIconData {
if (FILENAME_ICON_MAP[filename]) {
return FILENAME_ICON_MAP[filename];
}
if (filename.startsWith('.env')) {
return { icon: Shield, color: 'text-yellow-600' };
}
const extension = filename.split('.').pop()?.toLowerCase();
if (extension && FILE_ICON_MAP[extension]) {
return FILE_ICON_MAP[extension];
}
return { icon: File, color: 'text-muted-foreground' };
}

View File

@@ -0,0 +1,44 @@
import { useCallback, useState } from 'react';
type UseExpandedDirectoriesResult = {
expandedDirs: Set<string>;
toggleDirectory: (path: string) => void;
expandDirectories: (paths: string[]) => void;
};
export function useExpandedDirectories(): UseExpandedDirectoriesResult {
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(() => new Set());
const toggleDirectory = useCallback((path: string) => {
setExpandedDirs((previous) => {
const next = new Set(previous);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const expandDirectories = useCallback((paths: string[]) => {
if (paths.length === 0) {
return;
}
setExpandedDirs((previous) => {
const next = new Set(previous);
paths.forEach((path) => next.add(path));
return next;
});
}, []);
return {
expandedDirs,
toggleDirectory,
expandDirectories,
};
}

View File

@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { Project } from '../../../types/app';
import type { FileTreeNode } from '../types/types';
type UseFileTreeDataResult = {
files: FileTreeNode[];
loading: boolean;
};
export function useFileTreeData(selectedProject: Project | null): UseFileTreeDataResult {
const [files, setFiles] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const projectName = selectedProject?.name;
if (!projectName) {
setFiles([]);
setLoading(false);
return;
}
const abortController = new AbortController();
// Track mount state so aborted or late responses do not enqueue stale state updates.
let isActive = true;
const fetchFiles = async () => {
if (isActive) {
setLoading(true);
}
try {
const response = await api.getFiles(projectName, { signal: abortController.signal });
if (!response.ok) {
const errorText = await response.text();
console.error('File fetch failed:', response.status, errorText);
if (isActive) {
setFiles([]);
}
return;
}
const data = (await response.json()) as FileTreeNode[];
if (isActive) {
setFiles(data);
}
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
return;
}
console.error('Error fetching files:', error);
if (isActive) {
setFiles([]);
}
} finally {
if (isActive) {
setLoading(false);
}
}
};
void fetchFiles();
return () => {
isActive = false;
abortController.abort();
};
}, [selectedProject?.name]);
return {
files,
loading,
};
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { collectExpandedDirectoryPaths, filterFileTree } from '../utils/fileTreeUtils';
import type { FileTreeNode } from '../types/types';
type UseFileTreeSearchArgs = {
files: FileTreeNode[];
expandDirectories: (paths: string[]) => void;
};
type UseFileTreeSearchResult = {
searchQuery: string;
setSearchQuery: (query: string) => void;
filteredFiles: FileTreeNode[];
};
export function useFileTreeSearch({
files,
expandDirectories,
}: UseFileTreeSearchArgs): UseFileTreeSearchResult {
const [searchQuery, setSearchQuery] = useState('');
const [filteredFiles, setFilteredFiles] = useState<FileTreeNode[]>(files);
useEffect(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) {
setFilteredFiles(files);
return;
}
const filtered = filterFileTree(files, query);
setFilteredFiles(filtered);
// Keep search results visible by opening every matching ancestor directory once per query update.
expandDirectories(collectExpandedDirectoryPaths(filtered));
}, [files, searchQuery, expandDirectories]);
return {
searchQuery,
setSearchQuery,
filteredFiles,
};
}

View File

@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from 'react';
import {
FILE_TREE_DEFAULT_VIEW_MODE,
FILE_TREE_VIEW_MODES,
FILE_TREE_VIEW_MODE_STORAGE_KEY,
} from '../constants/constants';
import type { FileTreeViewMode } from '../types/types';
type UseFileTreeViewModeResult = {
viewMode: FileTreeViewMode;
changeViewMode: (mode: FileTreeViewMode) => void;
};
export function useFileTreeViewMode(): UseFileTreeViewModeResult {
const [viewMode, setViewMode] = useState<FileTreeViewMode>(FILE_TREE_DEFAULT_VIEW_MODE);
useEffect(() => {
try {
const savedViewMode = localStorage.getItem(FILE_TREE_VIEW_MODE_STORAGE_KEY);
if (savedViewMode && FILE_TREE_VIEW_MODES.includes(savedViewMode as FileTreeViewMode)) {
setViewMode(savedViewMode as FileTreeViewMode);
}
} catch {
// Keep default view mode when storage is unavailable.
}
}, []);
const changeViewMode = useCallback((mode: FileTreeViewMode) => {
setViewMode(mode);
try {
localStorage.setItem(FILE_TREE_VIEW_MODE_STORAGE_KEY, mode);
} catch {
// Keep runtime state even when persistence fails.
}
}, []);
return {
viewMode,
changeViewMode,
};
}

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