Compare commits

...

55 Commits

Author SHA1 Message Date
viper151
9cd1b5811a Merge branch 'main' into fix/session-streamed-to-another-chat 2026-01-20 12:59:42 +01:00
Haileyesus Dessie
ee43adb311 Merge pull request #312 from EricBlanquer/feat/folder-browser-wizard
Add folder browser to ProjectCreationWizard
2026-01-20 12:01:02 +03:00
Eric Blanquer​
e1f2af1a34 feat: add folder browser to ProjectCreationWizard
- Add folder browser modal to navigate and select project folders
- Sort folders alphabetically (case-insensitive)
- Add toggle to show/hide hidden folders (hidden by default)
- Auto-advance to confirmation step when selecting a folder
- Place "Use this folder" button next to Cancel
2026-01-18 06:05:34 +01:00
Haileyesus Dessie
ddb26c7652 fix: resolve issue with redirecting to original session after response completion 2026-01-16 14:04:37 +03:00
Haileyesus Dessie
b3c6e95971 fix: don't stream response to another session 2026-01-16 14:04:12 +03:00
Haileyesus Dessie
f8d1ec7b9e Merge pull request #250 from ZhenhongDu/main
feat: Add codeblock highlight support in ChatInterface
2026-01-15 21:10:06 +03:00
Haileyesus Dessie
15e4db386f Merge pull request #296 from amacsmith/fix/filter-git-from-autocomplete
fix: filter VCS directories (.git, .svn, .hg) from file autocomplete
2026-01-14 15:32:22 +03:00
amacsmith
66e85fb2c1 fix: filter VCS directories from file autocomplete
Excludes .git, .svn, and .hg directories from the file tree returned
by getFileTree(). This prevents VCS internal files from appearing
in the @ file autocomplete dropdown.

The fix is applied server-side which:
- Reduces data transferred to the client
- Benefits all features using getFileTree()
- Provides consistent filtering across the application

Fixes #290

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:43:58 -05:00
viper151
42b2d5e1d9 Merge pull request #289 from siteboon/feat/show-grant-permission-button-in-chat-for-claude
Add inline permission grant for Claude tool errors
2026-01-12 15:12:04 +01:00
viper151
d3c4821258 Merge branch 'main' into feat/show-grant-permission-button-in-chat-for-claude 2026-01-12 12:53:58 +01:00
Haileyesus Dessie
72c4b0749e Merge pull request #277 from whittlelabs/feature/drag-sidebar-handle
feat: add draggable Quick Settings sidebar handle
2026-01-12 12:31:26 +03:00
Haileyesus Dessie
35e140b941 add a clarification comment about crypto.randomUUID() 2026-01-10 15:00:46 +03:00
Haileyesus Dessie
b70728254b fix: move safeJsonParse function to utils.js 2026-01-10 14:36:58 +03:00
Haileyesus Dessie
64ebbaf387 feat: setup canUseTool for claude messages 2026-01-10 14:35:51 +03:00
Haileyesus Dessie
cdaff9d146 Merge branch 'feat/show-grant-permission-button-in-chat-for-claude' of https://github.com/siteboon/claudecodeui into feat/show-grant-permission-button-in-chat-for-claude 2026-01-08 12:21:34 +03:00
Haileyesus Dessie
3f66179e72 fix: remove regex for tool permission extraction 2026-01-08 12:21:28 +03:00
viper151
c654f489af Merge branch 'main' into feat/show-grant-permission-button-in-chat-for-claude 2026-01-07 22:13:48 +01:00
viper151
97ebef016a Merge pull request #288 from siteboon/fix/move-to-correct-scroll-position-in-long-messages-chat
fix: normalize file path handling and improve scroll position restoration
2026-01-07 22:11:55 +01:00
Haileyesus Dessie
ef44942767 feat: add Bash command approval handling in Claude tool permissions 2026-01-07 22:31:17 +03:00
Haileyesus Dessie
7b63a68e7e feat: add grant permission for Claude tools in ChatInterface 2026-01-07 21:49:05 +03:00
Haileyesus Dessie
005033136b fix: normalize file path handling and improve scroll position restoration in ChatInterface 2026-01-07 20:59:41 +03:00
viper151
ee3917b3f9 Merge branch 'main' into main 2026-01-06 12:43:35 +01:00
viper151
8fb43d358c Merge pull request #283 from siteboon/fix/server-crash-when-opening-settings 2026-01-05 18:59:05 +01:00
Haileyesus Dessie
4c40a33255 fix: improve error handling and response structure in MCP CLI routes for codex 2026-01-05 20:54:26 +03:00
viper151
4086fdaa4e Merge pull request #275 from siteboon/fix/navigate-to-correct-session-id-using-codex
fix: navigate to the correct session ID when updating session state
2026-01-05 17:00:23 +01:00
viper151
124c1ac600 Merge branch 'main' into fix/navigate-to-correct-session-id-using-codex 2026-01-05 16:59:24 +01:00
Haileyesus Dessie
9efe433d99 fix: get codex sessions in windows; improve message counting logic; fix session navigation in ChatInterface 2026-01-05 16:35:20 +03:00
Haileyesus Dessie
189a1b174c Merge pull request #244 from ybalbert001/main
[FixBug] The Desktop version's "New Project" button is always hidden
2026-01-01 14:53:28 +03:00
Haileyesus Dessie
04a0ff311e Merge branch 'main' into main 2026-01-01 14:49:30 +03:00
Haileyesus Dessie
efae890e34 Update button title for creating new project 2026-01-01 14:46:09 +03:00
Keith Morris
ea33810a4f fix: add error handling and cleanup for draggable handle
- Add try-catch for localStorage JSON.parse to handle corrupted data
- Remove invalid localStorage key when parsing fails
- Add cleanup effect to reset body styles if component unmounts while dragging
2025-12-31 17:41:52 -05:00
Keith Morris
4fe6cc4272 feat: add draggable Quick Settings sidebar handle
Add ability to reposition the Quick Settings sidebar handle by dragging it vertically on both desktop and mobile devices. The handle combines toggle and drag functionality in a single compact button.

Key features:
- Drag handle vertically to reposition on screen
- Click to toggle sidebar open/closed
- Smart gesture detection (5px threshold distinguishes drag from click)
- Visual feedback with blue grip icon during drag
- Position persists in localStorage across sessions
- Constrained to 10-90% of viewport height
- Full touch support with proper scroll prevention on mobile
- Responsive positioning (top-based on desktop, bottom-based on mobile)
2025-12-31 14:54:43 -05:00
Haileyesus Dessie
ba70ad8e81 fix: navigate to the correct session ID when updating session state 2025-12-31 19:10:33 +03:00
simosmik
b066ec4c01 fix: change codex login for platform mode 2025-12-31 10:47:55 +00:00
simosmik
104e4260a7 Release 1.13.6 2025-12-31 08:00:36 +00:00
simosmik
8af982e706 feat: add update command to CLI for checking and installing the latest version 2025-12-31 07:59:13 +00:00
viper151
81c0773358 Merge branch 'main' into main 2025-12-31 08:54:50 +01:00
viper151
29783f609f Merge branch 'main' into main 2025-12-31 08:53:45 +01:00
simosmik
ea19bd9a00 Release 1.13.5 2025-12-31 07:52:43 +00:00
viper151
6d4e5017d0 Merge pull request #257 from panta82/main
Fix issue: Broken pasted image upload
2025-12-31 08:49:03 +01:00
viper151
9b217ada0d Merge branch 'main' into main 2025-12-31 08:48:30 +01:00
simosmik
04efaa41f6 feat: add custom port and database path options to CLI commands 2025-12-31 07:42:01 +00:00
simosmik
5aef9c683a Release 1.13.3 2025-12-31 07:20:41 +00:00
simosmik
724cb5bb5c fix: adding shared folder to npm build 2025-12-31 07:17:39 +00:00
simosmik
4e163c8c10 Release 1.13.2 2025-12-30 18:10:21 +00:00
simosmik
b315360f8a fix: replace HOME env variable with os.homedir() to support windows 2025-12-30 18:07:04 +00:00
viper151
04821b8ad5 Merge pull request #273 from siteboon/fix/npmignore
Fix/npmignore
2025-12-30 18:53:22 +01:00
simosmik
00278a13d8 Release 1.13.1 2025-12-30 17:51:32 +00:00
simosmik
676d2415a0 adding npmignore 2025-12-30 17:49:30 +00:00
simosmik
babe96eedd fix: API would be stringified twice. That is now fixed. 2025-12-29 23:18:38 +00:00
simosmik
60c8bda755 fix: pass model parameter to Claude and Codex SDKs
Previously, the model parameter was accepted by the /api/agent endpoint
and extracted from requests, but was never passed through to the Claude
SDK or Codex SDK, causing all requests to use default models regardless
of user selection.

Changes:
- Add model parameter to queryClaudeSDK() options in routes/agent.js
- Add model to threadOptions in openai-codex.js
- Remove unused /cost slash command and PRICING constants
- Centralize all model definitions in shared/modelConstants.js
- Update API documentation to dynamically load models from constants
2025-12-29 16:19:09 +00:00
Ivan Pantic
19bb741af0 Fix issue: Broken pasted image upload 2025-12-12 00:27:09 +01:00
Zhenhong Du
89c9aec5b7 feat: add codeblock highlight in ChatInterface 2025-12-01 12:01:30 +08:00
Zhenhong Du
e74a813093 add packages for code highlight in chatui 2025-12-01 11:57:44 +08:00
Yuanbo Li
73a0b5bebd [FixBug] The Desktop version's "New Project" button is wrapped by the conditional logic projects.length > 0, causing it to not display when there are no projects, preventing users from creating new projects. 2025-11-26 11:45:01 +08:00
23 changed files with 2073 additions and 416 deletions

57
.npmignore Normal file
View File

@@ -0,0 +1,57 @@
*.md
!README.md
.env*
.gitignore
.nvmrc
.release-it.json
release.sh
postcss.config.js
vite.config.js
tailwind.config.js
# Database files
authdb/
*.db
*.sqlite
*.sqlite3
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# AI specific
.claude/
.cursor/
.roo/
.taskmaster/
.cline/
.windsurf/
.serena/
CLAUDE.md
.mcp.json
# Task files
tasks.json
tasks/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

View File

@@ -1,6 +1,6 @@
<div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<h1>Claude Code UI</h1>
<h1>Cloud CLI (aka Claude Code UI)</h1>
</div>
@@ -88,32 +88,31 @@ claude-code-ui
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
### CLI Commands
**To update**:
```bash
cloudcli update
```
### CLI Usage
After global installation, you have access to both `claude-code-ui` and `cloudcli` commands:
| Command / Option | Short | Description |
|------------------|-------|-------------|
| `cloudcli` or `claude-code-ui` | | Start the server (default) |
| `cloudcli start` | | Start the server explicitly |
| `cloudcli status` | | Show configuration and data locations |
| `cloudcli update` | | Update to the latest version |
| `cloudcli help` | | Show help information |
| `cloudcli version` | | Show version information |
| `--port <port>` | `-p` | Set server port (default: 3001) |
| `--database-path <path>` | | Set custom database location |
**Examples:**
```bash
# Start the server (default command)
claude-code-ui
cloudcli start
# Show configuration and data locations
cloudcli status
# Show help information
cloudcli help
# Show version
cloudcli version
```
**The `cloudcli status` command shows you:**
- Installation directory location
- Database location (where credentials are stored)
- Current configuration (PORT, DATABASE_PATH, etc.)
- Claude projects folder location
- Configuration file location
cloudcli # Start with defaults
cloudcli -p 8080 # Start on custom port
cloudcli status # Show current configuration
```
### Run as Background Service (Recommended for Production)
@@ -134,6 +133,9 @@ pm2 start claude-code-ui --name "claude-code-ui"
# Or using the shorter alias
pm2 start cloudcli --name "claude-code-ui"
# Start on a custom port
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
```

272
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.13.0",
"version": "1.13.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.13.0",
"version": "1.13.6",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
@@ -51,6 +51,7 @@
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
@@ -5057,6 +5058,19 @@
"reusify": "^1.0.4"
}
},
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
@@ -5136,6 +5150,14 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5756,6 +5778,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6569,6 +6606,20 @@
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
"license": "MIT",
"dependencies": {
"fault": "^1.0.0",
"highlight.js": "~10.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8840,6 +8891,15 @@
"node": ">=10"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/proc-log": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
@@ -9161,6 +9221,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-syntax-highlighter": {
"version": "15.6.6",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
"integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.3.1",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^3.6.0"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -9197,6 +9274,197 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/refractor": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz",
"integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==",
"license": "MIT",
"dependencies": {
"hastscript": "^6.0.0",
"parse-entities": "^2.0.0",
"prismjs": "~1.27.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/@types/hast": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz",
"integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2"
}
},
"node_modules/refractor/node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/refractor/node_modules/character-entities": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
"integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/character-entities-legacy": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
"integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/character-reference-invalid": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz",
"integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/comma-separated-tokens": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
"integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/hast-util-parse-selector": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
"integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/refractor/node_modules/hastscript": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz",
"integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0",
"comma-separated-tokens": "^1.0.0",
"hast-util-parse-selector": "^2.0.0",
"property-information": "^5.0.0",
"space-separated-tokens": "^1.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/refractor/node_modules/is-alphabetical": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
"integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-alphanumerical": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz",
"integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==",
"license": "MIT",
"dependencies": {
"is-alphabetical": "^1.0.0",
"is-decimal": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-decimal": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
"integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-hexadecimal": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz",
"integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/parse-entities": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz",
"integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==",
"license": "MIT",
"dependencies": {
"character-entities": "^1.0.0",
"character-entities-legacy": "^1.0.0",
"character-reference-invalid": "^1.0.0",
"is-alphanumerical": "^1.0.0",
"is-decimal": "^1.0.0",
"is-hexadecimal": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/prismjs": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
"integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/refractor/node_modules/property-information": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz",
"integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/space-separated-tokens": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz",
"integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/rehype-katex": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.13.0",
"version": "1.13.6",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
@@ -10,6 +10,7 @@
},
"files": [
"server/",
"shared/",
"dist/",
"README.md"
],
@@ -82,6 +83,7 @@
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",

View File

@@ -489,7 +489,7 @@
<span class="endpoint-path"><span class="api-url">http://localhost:3001</span>/api/agent</span>
</div>
<p>Trigger an AI agent (Claude or Cursor) to work on a project.</p>
<p>Trigger an AI agent (Claude, Cursor, or Codex) to work on a project.</p>
<h4>Request Body Parameters</h4>
<table>
@@ -524,7 +524,7 @@
<td><code>provider</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td><code>claude</code> or <code>cursor</code> (default: <code>claude</code>)</td>
<td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>
</tr>
<tr>
<td><code>stream</code></td>
@@ -536,7 +536,9 @@
<td><code>model</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Model to use (for Cursor)</td>
<td id="model-options-cell">
Model identifier for the AI provider (loading from constants...)
</td>
</tr>
<tr>
<td><code>cleanup</code></td>
@@ -818,31 +820,51 @@ data: {"type":"done"}</code></pre>
</div>
</div>
<script>
<script type="module">
// Import model constants
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';
// Dynamic URL replacement
const apiUrl = window.location.origin;
document.querySelectorAll('.api-url').forEach(el => {
el.textContent = apiUrl;
});
// Dynamically populate model documentation
window.addEventListener('DOMContentLoaded', () => {
const modelCell = document.getElementById('model-options-cell');
if (modelCell) {
const claudeModels = CLAUDE_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
const cursorModels = CURSOR_MODELS.OPTIONS.slice(0, 8).map(m => `<code>${m.value}</code>`).join(', ');
const codexModels = CODEX_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
modelCell.innerHTML = `
Model identifier for the AI provider:<br><br>
<strong>Claude:</strong> ${claudeModels} (default: <code>${CLAUDE_MODELS.DEFAULT}</code>)<br><br>
<strong>Cursor:</strong> ${cursorModels}, and more (default: <code>${CURSOR_MODELS.DEFAULT}</code>)<br><br>
<strong>Codex:</strong> ${codexModels} (default: <code>${CODEX_MODELS.DEFAULT}</code>)
`;
}
});
// Tab switching
function showTab(tabName) {
window.showTab = function(tabName) {
const parentBlock = event.target.closest('.example-block');
if (!parentBlock) return;
parentBlock.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
parentBlock.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
const targetTab = parentBlock.querySelector('#' + tabName);
if (targetTab) {
targetTab.classList.add('active');
event.target.classList.add('active');
}
}
};
</script>
<!-- Prism.js -->

View File

@@ -13,12 +13,134 @@
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
// Used to mint unique approval request IDs when randomUUID is not available.
// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
import crypto from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
// Session tracking: Map of session IDs to active query instances
const activeSessions = new Map();
// In-memory registry of pending tool approvals keyed by requestId.
// This does not persist approvals or share across processes; it exists so the
// SDK can pause tool execution while the UI decides what to do.
const pendingToolApprovals = new Map();
// Default approval timeout kept under the SDK's 60s control timeout.
// This does not change SDK limits; it only defines how long we wait for the UI,
// introduced to avoid hanging the run when no decision arrives.
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
// Generate a stable request ID for UI approval flows.
// This does not encode tool details or get shown to users; it exists so the UI
// can respond to the correct pending request without collisions.
function createRequestId() {
// if clause is used because randomUUID is not available in older Node.js versions
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString('hex');
}
// Wait for a UI approval decision, honoring SDK cancellation.
// This does not auto-approve or auto-deny; it only resolves with UI input,
// and it cleans up the pending map to avoid leaks, introduced to prevent
// replying after the SDK cancels the control request.
function waitForToolApproval(requestId, options = {}) {
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
return new Promise(resolve => {
let settled = false;
const finalize = (decision) => {
if (settled) return;
settled = true;
cleanup();
resolve(decision);
};
const cleanup = () => {
pendingToolApprovals.delete(requestId);
clearTimeout(timeout);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
};
// Timeout is local to this process; it does not override SDK timing.
// It exists to prevent the UI prompt from lingering indefinitely.
const timeout = setTimeout(() => {
onCancel?.('timeout');
finalize(null);
}, timeoutMs);
const abortHandler = () => {
// If the SDK cancels the control request, stop waiting to avoid
// replying after the process is no longer ready for writes.
onCancel?.('cancelled');
finalize({ cancelled: true });
};
if (signal) {
if (signal.aborted) {
onCancel?.('cancelled');
finalize({ cancelled: true });
return;
}
signal.addEventListener('abort', abortHandler, { once: true });
}
pendingToolApprovals.set(requestId, (decision) => {
finalize(decision);
});
});
}
// Resolve a pending approval. This does not validate the decision payload;
// validation and tool matching remain in canUseTool, which keeps this as a
// lightweight WebSocket -> SDK relay.
function resolveToolApproval(requestId, decision) {
const resolver = pendingToolApprovals.get(requestId);
if (resolver) {
resolver(decision);
}
}
// Match stored permission entries against a tool + input combo.
// This only supports exact tool names and the Bash(command:*) shorthand
// used by the UI; it intentionally does not implement full glob semantics,
// introduced to stay consistent with the UI's "Allow rule" format.
function matchesToolPermission(entry, toolName, input) {
if (!entry || !toolName) {
return false;
}
if (entry === toolName) {
return true;
}
const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
if (toolName === 'Bash' && bashMatch) {
const allowedPrefix = bashMatch[1];
let command = '';
if (typeof input === 'string') {
command = input.trim();
} else if (input && typeof input === 'object' && typeof input.command === 'string') {
command = input.command.trim();
}
if (!command) {
return false;
}
return command.startsWith(allowedPrefix);
}
return false;
}
/**
* Maps CLI options to SDK-compatible options format
@@ -51,33 +173,32 @@ function mapCliOptionsToSDK(options = {}) {
if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions';
} else {
// Map allowed tools
let allowedTools = [...(settings.allowedTools || [])];
}
// Add plan mode default tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
}
// Map allowed tools (always set to avoid implicit "allow all" defaults).
// This does not grant permissions by itself; it just configures the SDK,
// introduced because leaving it undefined made the SDK treat it as "all tools allowed."
let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode default tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
}
}
if (allowedTools.length > 0) {
sdkOptions.allowedTools = allowedTools;
}
// Map disallowed tools
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
sdkOptions.disallowedTools = settings.disallowedTools;
}
}
sdkOptions.allowedTools = allowedTools;
// Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
// This does not override allowlists; it only feeds the canUseTool gate.
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || 'sonnet';
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
console.log(`Using model: ${sdkOptions.model}`);
// Map system prompt configuration
@@ -369,6 +490,76 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir;
// Gate tool usage with explicit UI approval when not auto-approved.
// This does not render UI or persist permissions; it only bridges to the UI
// via WebSocket and waits for the response, introduced so tool calls pause
// instead of auto-running when the allowlist is empty.
sdkOptions.canUseTool = async (toolName, input, context) => {
if (sdkOptions.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input };
}
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isDisallowed) {
return { behavior: 'deny', message: 'Tool disallowed by settings' };
}
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isAllowed) {
return { behavior: 'allow', updatedInput: input };
}
const requestId = createRequestId();
ws.send({
type: 'claude-permission-request',
requestId,
toolName,
input,
sessionId: capturedSessionId || sessionId || null
});
// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
// This does not retry or resurface the prompt; it just reflects the cancellation.
const decision = await waitForToolApproval(requestId, {
signal: context?.signal,
onCancel: (reason) => {
ws.send({
type: 'claude-permission-cancelled',
requestId,
reason,
sessionId: capturedSessionId || sessionId || null
});
}
});
if (!decision) {
return { behavior: 'deny', message: 'Permission request timed out' };
}
if (decision.cancelled) {
return { behavior: 'deny', message: 'Permission request cancelled' };
}
if (decision.allow) {
// rememberEntry only updates this run's in-memory allowlist to prevent
// repeated prompts in the same session; persistence is handled by the UI.
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
sdkOptions.allowedTools.push(decision.rememberEntry);
}
if (Array.isArray(sdkOptions.disallowedTools)) {
sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
}
}
return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
}
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
};
// Create SDK query instance
const queryInstance = query({
prompt: finalCommand,
@@ -397,10 +588,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(JSON.stringify({
ws.send({
type: 'session-created',
sessionId: capturedSessionId
}));
});
} else {
console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
}
@@ -410,20 +601,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Transform and send message to WebSocket
const transformedMessage = transformMessage(message);
ws.send(JSON.stringify({
ws.send({
type: 'claude-response',
data: transformedMessage
}));
data: transformedMessage,
sessionId: capturedSessionId || sessionId || null
});
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const tokenBudget = extractTokenBudget(message);
if (tokenBudget) {
console.log('Token budget from modelUsage:', tokenBudget);
ws.send(JSON.stringify({
ws.send({
type: 'token-budget',
data: tokenBudget
}));
data: tokenBudget,
sessionId: capturedSessionId || sessionId || null
});
}
}
}
@@ -438,12 +631,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Send completion event
console.log('Streaming complete, sending claude-complete event');
ws.send(JSON.stringify({
ws.send({
type: 'claude-complete',
sessionId: capturedSessionId,
exitCode: 0,
isNewSession: !sessionId && !!command
}));
});
console.log('claude-complete event sent');
} catch (error) {
@@ -458,10 +651,11 @@ async function queryClaudeSDK(command, options = {}, ws) {
await cleanupTempFiles(tempImagePaths, tempDir);
// Send error to WebSocket
ws.send(JSON.stringify({
ws.send({
type: 'claude-error',
error: error.message
}));
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
throw error;
}
@@ -525,5 +719,6 @@ export {
queryClaudeSDK,
abortClaudeSDKSession,
isClaudeSDKSessionActive,
getActiveClaudeSDKSessions
getActiveClaudeSDKSessions,
resolveToolApproval
};

View File

@@ -14,6 +14,7 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
@@ -115,7 +116,7 @@ function showStatus() {
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
// Claude projects folder
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
const projectsExists = fs.existsSync(claudeProjectsPath);
console.log(`\n${c.info('[INFO]')} Claude Projects Folder:`);
console.log(` ${c.dim(claudeProjectsPath)}`);
@@ -130,10 +131,10 @@ function showStatus() {
console.log('\n' + c.dim('═'.repeat(60)));
console.log(`\n${c.tip('[TIP]')} Hints:`);
console.log(` ${c.dim('>')} Set DATABASE_PATH env variable to use a custom database location`);
console.log(` ${c.dim('>')} Create .env file in installation directory for persistent config`);
console.log(` ${c.dim('>')} Run "claude-code-ui" or "cloudcli start" to start the server`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:3001 (or custom PORT)\n`);
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`);
}
// Show help
@@ -144,19 +145,28 @@ function showHelp() {
╚═══════════════════════════════════════════════════════════════╝
Usage:
claude-code-ui [command]
cloudcli [command]
claude-code-ui [command] [options]
cloudcli [command] [options]
Commands:
start Start the Claude Code UI server (default)
status Show configuration and data locations
update Update to the latest version
help Show this help information
version Show version information
Options:
-p, --port <port> Set server port (default: 3001)
--database-path <path> Set custom database location
-h, --help Show this help information
-v, --version Show version information
Examples:
$ claude-code-ui # Start the server
$ cloudcli status # Show configuration
$ cloudcli help # Show help
$ cloudcli # Start with defaults
$ cloudcli --port 8080 # Start on port 8080
$ cloudcli -p 3000 # Short form for port
$ cloudcli start --port 4000 # Explicit start command
$ cloudcli status # Show configuration
Environment Variables:
PORT Set server port (default: 3001)
@@ -164,11 +174,6 @@ Environment Variables:
CLAUDE_CLI_PATH Set custom Claude CLI path
CONTEXT_WINDOW Set context window size (default: 160000)
Configuration:
Create a .env file in the installation directory to set
persistent environment variables. Use 'cloudcli status' to
see the installation directory path.
Documentation:
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
@@ -182,16 +187,110 @@ function showVersion() {
console.log(`${packageJson.version}`);
}
// Compare semver versions, returns true if v1 > v2
function isNewerVersion(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (parts1[i] > parts2[i]) return true;
if (parts1[i] < parts2[i]) return false;
}
return false;
}
// Check for updates
async function checkForUpdates(silent = false) {
try {
const { execSync } = await import('child_process');
const latestVersion = execSync('npm show @siteboon/claude-code-ui version', { encoding: 'utf8' }).trim();
const currentVersion = packageJson.version;
if (isNewerVersion(latestVersion, currentVersion)) {
console.log(`\n${c.warn('[UPDATE]')} New version available: ${c.bright(latestVersion)} (current: ${currentVersion})`);
console.log(` Run ${c.bright('cloudcli update')} to update\n`);
return { hasUpdate: true, latestVersion, currentVersion };
} else if (!silent) {
console.log(`${c.ok('[OK]')} You are on the latest version (${currentVersion})`);
}
return { hasUpdate: false, latestVersion, currentVersion };
} catch (e) {
if (!silent) {
console.log(`${c.warn('[WARN]')} Could not check for updates`);
}
return { hasUpdate: false, error: e.message };
}
}
// Update the package
async function updatePackage() {
try {
const { execSync } = await import('child_process');
console.log(`${c.info('[INFO]')} Checking for updates...`);
const { hasUpdate, latestVersion, currentVersion } = await checkForUpdates(true);
if (!hasUpdate) {
console.log(`${c.ok('[OK]')} Already on the latest version (${currentVersion})`);
return;
}
console.log(`${c.info('[INFO]')} Updating from ${currentVersion} to ${latestVersion}...`);
execSync('npm update -g @siteboon/claude-code-ui', { stdio: 'inherit' });
console.log(`${c.ok('[OK]')} Update complete! Restart cloudcli to use the new version.`);
} catch (e) {
console.error(`${c.error('[ERROR]')} Update failed: ${e.message}`);
console.log(`${c.tip('[TIP]')} Try running manually: npm update -g @siteboon/claude-code-ui`);
}
}
// Start the server
async function startServer() {
// Check for updates silently on startup
checkForUpdates(true);
// Import and run the server
await import('./index.js');
}
// Parse CLI arguments
function parseArgs(args) {
const parsed = { command: 'start', options: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--port' || arg === '-p') {
parsed.options.port = args[++i];
} else if (arg.startsWith('--port=')) {
parsed.options.port = arg.split('=')[1];
} else if (arg === '--database-path') {
parsed.options.databasePath = args[++i];
} else if (arg.startsWith('--database-path=')) {
parsed.options.databasePath = arg.split('=')[1];
} else if (arg === '--help' || arg === '-h') {
parsed.command = 'help';
} else if (arg === '--version' || arg === '-v') {
parsed.command = 'version';
} else if (!arg.startsWith('-')) {
parsed.command = arg;
}
}
return parsed;
}
// Main CLI handler
async function main() {
const args = process.argv.slice(2);
const command = args[0] || 'start';
const { command, options } = parseArgs(args);
// Apply CLI options to environment variables
if (options.port) {
process.env.PORT = options.port;
}
if (options.databasePath) {
process.env.DATABASE_PATH = options.databasePath;
}
switch (command) {
case 'start':
@@ -211,6 +310,9 @@ async function main() {
case '--version':
showVersion();
break;
case 'update':
await updatePackage();
break;
default:
console.error(`\n❌ Unknown command: ${command}`);
console.log(' Run "cloudcli help" for usage information.\n');

View File

@@ -102,29 +102,31 @@ async function spawnCursor(command, options = {}, ws) {
// Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(JSON.stringify({
ws.send({
type: 'session-created',
sessionId: capturedSessionId,
model: response.model,
cwd: response.cwd
}));
});
}
}
// Send system info to frontend
ws.send(JSON.stringify({
ws.send({
type: 'cursor-system',
data: response
}));
data: response,
sessionId: capturedSessionId || sessionId || null
});
}
break;
case 'user':
// Forward user message
ws.send(JSON.stringify({
ws.send({
type: 'cursor-user',
data: response
}));
data: response,
sessionId: capturedSessionId || sessionId || null
});
break;
case 'assistant':
@@ -134,7 +136,7 @@ async function spawnCursor(command, options = {}, ws) {
messageBuffer += textContent;
// Send as Claude-compatible format for frontend
ws.send(JSON.stringify({
ws.send({
type: 'claude-response',
data: {
type: 'content_block_delta',
@@ -142,8 +144,9 @@ async function spawnCursor(command, options = {}, ws) {
type: 'text_delta',
text: textContent
}
}
}));
},
sessionId: capturedSessionId || sessionId || null
});
}
break;
@@ -153,37 +156,40 @@ async function spawnCursor(command, options = {}, ws) {
// Send final message if we have buffered content
if (messageBuffer) {
ws.send(JSON.stringify({
ws.send({
type: 'claude-response',
data: {
type: 'content_block_stop'
}
}));
},
sessionId: capturedSessionId || sessionId || null
});
}
// Send completion event
ws.send(JSON.stringify({
ws.send({
type: 'cursor-result',
sessionId: capturedSessionId || sessionId,
data: response,
success: response.subtype === 'success'
}));
});
break;
default:
// Forward any other message types
ws.send(JSON.stringify({
ws.send({
type: 'cursor-response',
data: response
}));
data: response,
sessionId: capturedSessionId || sessionId || null
});
}
} catch (parseError) {
console.log('📄 Non-JSON response:', line);
// If not JSON, send as raw text
ws.send(JSON.stringify({
ws.send({
type: 'cursor-output',
data: line
}));
data: line,
sessionId: capturedSessionId || sessionId || null
});
}
}
});
@@ -191,10 +197,11 @@ async function spawnCursor(command, options = {}, ws) {
// Handle stderr
cursorProcess.stderr.on('data', (data) => {
console.error('Cursor CLI stderr:', data.toString());
ws.send(JSON.stringify({
ws.send({
type: 'cursor-error',
error: data.toString()
}));
error: data.toString(),
sessionId: capturedSessionId || sessionId || null
});
});
// Handle process completion
@@ -205,12 +212,12 @@ async function spawnCursor(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send(JSON.stringify({
ws.send({
type: 'claude-complete',
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
}));
});
if (code === 0) {
resolve();
@@ -226,12 +233,13 @@ async function spawnCursor(command, options = {}, ws) {
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send(JSON.stringify({
ws.send({
type: 'cursor-error',
error: error.message
}));
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
reject(error);
});
@@ -264,4 +272,4 @@ export {
abortCursorSession,
isCursorSessionActive,
getActiveCursorSessions
};
};

View File

@@ -58,7 +58,7 @@ import fetch from 'node-fetch';
import mime from 'mime-types';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
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 gitRoutes from './routes/git.js';
@@ -84,7 +84,7 @@ const connectedClients = new Set();
// Setup file system watcher for Claude projects folder using chokidar
async function setupProjectsWatcher() {
const chokidar = (await import('chokidar')).default;
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
if (projectsWatcher) {
projectsWatcher.close();
@@ -462,7 +462,7 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
});
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
try {
const { path: dirPath } = req.query;
@@ -496,7 +496,13 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
name: item.name,
type: 'directory'
}))
.slice(0, 20); // Limit results
.sort((a, b) => {
const aHidden = a.name.startsWith('.');
const bHidden = b.name.startsWith('.');
if (aHidden && !bHidden) return 1;
if (!aHidden && bHidden) return -1;
return a.name.localeCompare(b.name);
});
// Add common directories if browsing home directory
const suggestions = [];
@@ -510,9 +516,9 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
suggestions.push(...directories);
}
res.json({
res.json({
path: targetPath,
suggestions: suggestions
suggestions: suggestions
});
} catch (error) {
@@ -717,6 +723,32 @@ 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));
}
}
setSessionId(sessionId) {
this.sessionId = sessionId;
}
getSessionId() {
return this.sessionId;
}
}
// Handle chat WebSocket connections
function handleChatConnection(ws) {
console.log('[INFO] Chat WebSocket connected');
@@ -724,6 +756,9 @@ function handleChatConnection(ws) {
// Add to connected clients for project updates
connectedClients.add(ws);
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
const writer = new WebSocketWriter(ws);
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
@@ -734,19 +769,19 @@ function handleChatConnection(ws) {
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
// Use Claude Agents SDK
await queryClaudeSDK(data.command, data.options, ws);
await queryClaudeSDK(data.command, data.options, writer);
} else if (data.type === 'cursor-command') {
console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.cwd || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
console.log('🤖 Model:', data.options?.model || 'default');
await spawnCursor(data.command, data.options, ws);
await spawnCursor(data.command, data.options, writer);
} else if (data.type === 'codex-command') {
console.log('[DEBUG] Codex 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 queryCodex(data.command, data.options, ws);
await queryCodex(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);
@@ -754,7 +789,7 @@ function handleChatConnection(ws) {
sessionId: data.sessionId,
resume: true,
cwd: data.options?.cwd
}, ws);
}, writer);
} else if (data.type === 'abort-session') {
console.log('[DEBUG] Abort session request:', data.sessionId);
const provider = data.provider || 'claude';
@@ -769,21 +804,33 @@ function handleChatConnection(ws) {
success = await abortClaudeSDKSession(data.sessionId);
}
ws.send(JSON.stringify({
writer.send({
type: 'session-aborted',
sessionId: data.sessionId,
provider,
success
}));
});
} else if (data.type === 'claude-permission-response') {
// Relay UI approval decisions back into the SDK control flow.
// This does not persist permissions; it only resolves the in-flight request,
// introduced so the SDK can resume once the user clicks Allow/Deny.
if (data.requestId) {
resolveToolApproval(data.requestId, {
allow: Boolean(data.allow),
updatedInput: data.updatedInput,
message: data.message,
rememberEntry: data.rememberEntry
});
}
} else if (data.type === 'cursor-abort') {
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
const success = abortCursorSession(data.sessionId);
ws.send(JSON.stringify({
writer.send({
type: 'session-aborted',
sessionId: data.sessionId,
provider: 'cursor',
success
}));
});
} else if (data.type === 'check-session-status') {
// Check if a specific session is currently processing
const provider = data.provider || 'claude';
@@ -799,12 +846,12 @@ function handleChatConnection(ws) {
isActive = isClaudeSDKSessionActive(sessionId);
}
ws.send(JSON.stringify({
writer.send({
type: 'session-status',
sessionId,
provider,
isProcessing: isActive
}));
});
} else if (data.type === 'get-active-sessions') {
// Get all currently active sessions
const activeSessions = {
@@ -812,17 +859,17 @@ function handleChatConnection(ws) {
cursor: getActiveCursorSessions(),
codex: getActiveCodexSessions()
};
ws.send(JSON.stringify({
writer.send({
type: 'active-sessions',
sessions: activeSessions
}));
});
}
} catch (error) {
console.error('[ERROR] Chat WebSocket error:', error.message);
ws.send(JSON.stringify({
writer.send({
type: 'error',
error: error.message
}));
});
}
});
@@ -986,7 +1033,7 @@ function handleShellConnection(ws) {
name: 'xterm-256color',
cols: termCols,
rows: termRows,
cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
cwd: os.homedir(),
env: {
...process.env,
TERM: 'xterm-256color',
@@ -1596,10 +1643,13 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
// Debug: log all entries including hidden files
// Skip only heavy build directories
// Skip heavy build directories and VCS directories
if (entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build') continue;
entry.name === 'build' ||
entry.name === '.git' ||
entry.name === '.svn' ||
entry.name === '.hg') continue;
const itemPath = path.join(dirPath, entry.name);
const item = {

View File

@@ -213,7 +213,8 @@ export async function queryCodex(command, options = {}, ws) {
workingDirectory,
skipGitRepoCheck: true,
sandboxMode,
approvalPolicy
approvalPolicy,
model
};
// Start or resume thread
@@ -271,7 +272,8 @@ export async function queryCodex(command, options = {}, ws) {
data: {
used: totalTokens,
total: 200000 // Default context window for Codex models
}
},
sessionId: currentSessionId
});
}
}
@@ -279,7 +281,8 @@ export async function queryCodex(command, options = {}, ws) {
// Send completion event
sendMessage(ws, {
type: 'codex-complete',
sessionId: currentSessionId
sessionId: currentSessionId,
actualSessionId: thread.id
});
} catch (error) {
@@ -359,12 +362,12 @@ export function getActiveCodexSessions() {
*/
function sendMessage(ws, data) {
try {
if (typeof ws.send === 'function') {
// WebSocket
if (ws.isSSEStreamWriter || ws.isWebSocketWriter) {
// Writer handles stringification (SSEStreamWriter or WebSocketWriter)
ws.send(data);
} else if (typeof ws.send === 'function') {
// Raw WebSocket - stringify here
ws.send(JSON.stringify(data));
} else if (typeof ws.write === 'function') {
// SSE writer (for agent API)
ws.write(`data: ${JSON.stringify(data)}\n\n`);
}
} catch (error) {
console.error('[Codex] Error sending message:', error);

View File

@@ -204,7 +204,7 @@ function clearProjectDirectoryCache() {
// Load project configuration file
async function loadProjectConfig() {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
const configPath = path.join(os.homedir(), '.claude', 'project-config.json');
try {
const configData = await fs.readFile(configPath, 'utf8');
return JSON.parse(configData);
@@ -216,7 +216,7 @@ async function loadProjectConfig() {
// Save project configuration file
async function saveProjectConfig(config) {
const claudeDir = path.join(process.env.HOME, '.claude');
const claudeDir = path.join(os.homedir(), '.claude');
const configPath = path.join(claudeDir, 'project-config.json');
// Ensure the .claude directory exists
@@ -276,7 +276,7 @@ async function extractProjectDirectory(projectName) {
return originalPath;
}
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
const cwdCounts = new Map();
let latestTimestamp = 0;
let latestCwd = null;
@@ -380,7 +380,7 @@ async function extractProjectDirectory(projectName) {
}
async function getProjects() {
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
const config = await loadProjectConfig();
const projects = [];
const existingProjects = new Set();
@@ -546,7 +546,7 @@ async function getProjects() {
}
async function getSessions(projectName, limit = 5, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
@@ -828,7 +828,7 @@ async function parseJsonlSessions(filePath) {
// Get messages for a specific session with pagination support
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
@@ -917,7 +917,7 @@ async function renameProject(projectName, newDisplayName) {
// Delete a session from a project
async function deleteSession(projectName, sessionId) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
@@ -980,7 +980,7 @@ async function isProjectEmpty(projectName) {
// Delete an empty project
async function deleteProject(projectName) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
// First check if the project is empty
@@ -1020,7 +1020,7 @@ async function addProjectManually(projectPath, displayName = null) {
// Check if project already exists in config
const config = await loadProjectConfig();
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
if (config[projectName]) {
throw new Error(`Project already configured for path: ${absolutePath}`);
@@ -1206,7 +1206,12 @@ async function getCodexSessions(projectPath) {
const sessionData = await parseCodexSessionFile(filePath);
// Check if this session matches the project path
if (sessionData && sessionData.cwd === projectPath) {
// Handle Windows long paths with \\?\ prefix
const sessionCwd = sessionData?.cwd || '';
const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd;
const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath;
if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) {
sessions.push({
id: sessionData.id,
summary: sessionData.summary || 'Codex Session',
@@ -1273,12 +1278,12 @@ async function parseCodexSessionFile(filePath) {
// Count messages and extract user messages for summary
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
messageCount++;
if (entry.payload.text) {
lastUserMessage = entry.payload.text;
if (entry.payload.message) {
lastUserMessage = entry.payload.message;
}
}
if (entry.type === 'response_item' && entry.payload?.type === 'message') {
if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
messageCount++;
}

View File

@@ -10,6 +10,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
import { Octokit } from '@octokit/rest';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
const router = express.Router();
@@ -450,6 +451,7 @@ class SSEStreamWriter {
constructor(res) {
this.res = res;
this.sessionId = null;
this.isSSEStreamWriter = true; // Marker for transport detection
}
send(data) {
@@ -457,7 +459,7 @@ class SSEStreamWriter {
return;
}
// Format as SSE
// Format as SSE - providers send raw objects, we stringify
this.res.write(`data: ${JSON.stringify(data)}\n\n`);
}
@@ -634,9 +636,14 @@ class ResponseCollector {
* - true: Returns text/event-stream with incremental updates
* - false: Returns complete JSON response after completion
*
* @param {string} model - (Optional) Model identifier for Cursor provider.
* Only applicable when provider='cursor'.
* Examples: 'gpt-4', 'claude-3-opus', etc.
* @param {string} model - (Optional) Model identifier for providers.
*
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
* 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
* Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
*
* @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
* Default: true
@@ -939,6 +946,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: null, // New session
model: model,
permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
}, writer);
@@ -959,7 +967,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: null,
model: model || 'gpt-5.2',
model: model || CODEX_MODELS.DEFAULT,
permissionMode: 'bypassPermissions'
}, writer);
}

View File

@@ -8,6 +8,17 @@ import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '.
const router = express.Router();
function createCliResponder(res) {
let responded = false;
return (status, payload) => {
if (responded || res.headersSent) {
return;
}
responded = true;
res.status(status).json(payload);
};
}
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
@@ -88,24 +99,30 @@ router.delete('/sessions/:sessionId', async (req, res) => {
router.get('/mcp/cli/list', async (req, res) => {
try {
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) });
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} else {
res.status(500).json({ error: 'Codex CLI command failed', details: stderr });
respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
@@ -133,24 +150,30 @@ router.post('/mcp/cli/add', async (req, res) => {
cliArgs.push(...args);
}
const respond = createCliResponder(res);
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr });
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
@@ -161,24 +184,30 @@ router.delete('/mcp/cli/remove/:name', async (req, res) => {
try {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr });
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
@@ -189,24 +218,30 @@ router.get('/mcp/cli/get/:name', async (req, res) => {
try {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) });
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} else {
res.status(404).json({ error: 'Codex CLI command failed', details: stderr });
respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });

View File

@@ -4,6 +4,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import os from 'os';
import matter from 'gray-matter';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -182,23 +183,15 @@ Custom commands can be created in:
},
'/model': async (args, context) => {
// Read available models from config or defaults
// Read available models from centralized constants
const availableModels = {
claude: [
'claude-sonnet-4.5',
'claude-sonnet-4',
'claude-opus-4',
'claude-sonnet-3.5'
],
cursor: [
'gpt-5',
'sonnet-4',
'opus-4.1'
]
claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),
cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),
codex: CODEX_MODELS.OPTIONS.map(o => o.value)
};
const currentProvider = context?.provider || 'claude';
const currentModel = context?.model || 'claude-sonnet-4.5';
const currentModel = context?.model || CLAUDE_MODELS.DEFAULT;
return {
type: 'builtin',
@@ -216,50 +209,6 @@ Custom commands can be created in:
};
},
'/cost': async (args, context) => {
// Calculate token usage and cost
const sessionId = context?.sessionId;
const tokenUsage = context?.tokenUsage || { used: 0, total: 200000 };
const costPerMillion = {
'claude-sonnet-4.5': { input: 3, output: 15 },
'claude-sonnet-4': { input: 3, output: 15 },
'claude-opus-4': { input: 15, output: 75 },
'gpt-5': { input: 5, output: 15 }
};
const model = context?.model || 'claude-sonnet-4.5';
const rates = costPerMillion[model] || costPerMillion['claude-sonnet-4.5'];
// Estimate 70% input, 30% output
const estimatedInputTokens = Math.floor(tokenUsage.used * 0.7);
const estimatedOutputTokens = Math.floor(tokenUsage.used * 0.3);
const inputCost = (estimatedInputTokens / 1000000) * rates.input;
const outputCost = (estimatedOutputTokens / 1000000) * rates.output;
const totalCost = inputCost + outputCost;
return {
type: 'builtin',
action: 'cost',
data: {
tokenUsage: {
used: tokenUsage.used,
total: tokenUsage.total,
percentage: ((tokenUsage.used / tokenUsage.total) * 100).toFixed(1)
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
currency: 'USD'
},
model,
rates
}
};
},
'/status': async (args, context) => {
// Read version from package.json
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');

View File

@@ -6,6 +6,7 @@ import { spawn } from 'child_process';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import crypto from 'crypto';
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
const router = express.Router();
@@ -33,7 +34,7 @@ router.get('/config', async (req, res) => {
config: {
version: 1,
model: {
modelId: "gpt-5",
modelId: CURSOR_MODELS.DEFAULT,
displayName: "GPT-5"
},
permissions: {

65
shared/modelConstants.js Normal file
View File

@@ -0,0 +1,65 @@
/**
* Centralized Model Definitions
* Single source of truth for all supported AI models
*/
/**
* Claude (Anthropic) Models
*
* Note: Claude uses two different formats:
* - SDK format ('sonnet', 'opus') - used by the UI and claude-sdk.js
* - API format ('claude-sonnet-4.5') - used by slash commands for display
*/
export const CLAUDE_MODELS = {
// Models in SDK format (what the actual SDK accepts)
OPTIONS: [
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'opus', label: 'Opus' },
{ value: 'haiku', label: 'Haiku' },
{ value: 'opusplan', label: 'Opus Plan' },
{ value: 'sonnet[1m]', label: 'Sonnet [1M]' }
],
DEFAULT: 'sonnet'
};
/**
* Cursor Models
*/
export const CURSOR_MODELS = {
OPTIONS: [
{ value: 'gpt-5.2-high', label: 'GPT-5.2 High' },
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
{ value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.1', label: 'GPT-5.1' },
{ value: 'gpt-5.1-high', label: 'GPT-5.1 High' },
{ value: 'composer-1', label: 'Composer 1' },
{ value: 'auto', label: 'Auto' },
{ value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' },
{ value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' },
{ value: 'opus-4.5', label: 'Claude 4.5 Opus' },
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-codex-high', label: 'GPT-5.1 Codex High' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' },
{ value: 'opus-4.1', label: 'Claude 4.1 Opus' },
{ value: 'grok', label: 'Grok' }
],
DEFAULT: 'gpt-5'
};
/**
* Codex (OpenAI) Models
*/
export const CODEX_MODELS = {
OPTIONS: [
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'o3', label: 'O3' },
{ value: 'o4-mini', label: 'O4-mini' }
],
DEFAULT: 'gpt-5.2'
};

View File

@@ -177,7 +177,9 @@ function AppContent() {
// If so, and the session is not active, trigger a message reload in ChatInterface
if (latestMessage.changedFile && selectedSession && selectedProject) {
// Extract session ID from changedFile (format: "project-name/session-id.jsonl")
const changedFileParts = latestMessage.changedFile.split('/');
const normalized = latestMessage.changedFile.replace(/\\/g, '/');
const changedFileParts = normalized.split('/');
if (changedFileParts.length >= 2) {
const filename = changedFileParts[changedFileParts.length - 1];
const changedSessionId = filename.replace('.jsonl', '');

View File

@@ -16,11 +16,13 @@
* This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates.
*/
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect, memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useDropzone } from 'react-dropzone';
import TodoList from './TodoList';
import ClaudeLogo from './ClaudeLogo.jsx';
@@ -35,7 +37,9 @@ import { MicButton } from './MicButton.jsx';
import { api, authenticatedFetch } from '../utils/api';
import Fuse from 'fuse.js';
import CommandMenu from './CommandMenu';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants';
import { safeJsonParse } from '../lib/utils.js';
// Helper function to decode HTML entities in text
function decodeHtmlEntities(text) {
@@ -235,6 +239,102 @@ const safeLocalStorage = {
}
};
const CLAUDE_SETTINGS_KEY = 'claude-settings';
function getClaudeSettings() {
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
if (!raw) {
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
projectSortOrder: 'name'
};
}
try {
const parsed = JSON.parse(raw);
return {
...parsed,
allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [],
disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [],
skipPermissions: Boolean(parsed.skipPermissions),
projectSortOrder: parsed.projectSortOrder || 'name'
};
} catch {
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
projectSortOrder: 'name'
};
}
}
function buildClaudeToolPermissionEntry(toolName, toolInput) {
if (!toolName) return null;
if (toolName !== 'Bash') return toolName;
const parsed = safeJsonParse(toolInput);
const command = typeof parsed?.command === 'string' ? parsed.command.trim() : '';
if (!command) return toolName;
const tokens = command.split(/\s+/);
if (tokens.length === 0) return toolName;
// For Bash, allow the command family instead of every Bash invocation.
if (tokens[0] === 'git' && tokens[1]) {
return `Bash(${tokens[0]} ${tokens[1]}:*)`;
}
return `Bash(${tokens[0]}:*)`;
}
// Normalize tool inputs for display in the permission banner.
// This does not sanitize/redact secrets; it is strictly formatting so users
// can see the raw input that triggered the permission prompt.
function formatToolInputForDisplay(input) {
if (input === undefined || input === null) return '';
if (typeof input === 'string') return input;
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
function getClaudePermissionSuggestion(message, provider) {
if (provider !== 'claude') return null;
if (!message?.toolResult?.isError) return null;
const toolName = message?.toolName;
const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
if (!entry) return null;
const settings = getClaudeSettings();
const isAllowed = settings.allowedTools.includes(entry);
return { toolName, entry, isAllowed };
}
function grantClaudeToolPermission(entry) {
if (!entry) return { success: false };
const settings = getClaudeSettings();
const alreadyAllowed = settings.allowedTools.includes(entry);
const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry];
const nextDisallowed = settings.disallowedTools.filter(tool => tool !== entry);
const updatedSettings = {
...settings,
allowedTools: nextAllowed,
disallowedTools: nextDisallowed,
lastUpdated: new Date().toISOString()
};
safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings));
return { success: true, alreadyAllowed, updatedSettings };
}
// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.)
const markdownComponents = {
code: ({ node, inline, className, children, ...props }) => {
@@ -243,6 +343,8 @@ const markdownComponents = {
const looksMultiline = /[\r\n]/.test(raw);
const inlineDetected = inline || (node && node.type === 'inlineCode');
const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line
// Inline code rendering
if (shouldInline) {
return (
<code
@@ -255,6 +357,10 @@ const markdownComponents = {
</code>
);
}
// Extract language from className (format: language-xxx)
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text';
const textToCopy = raw;
const handleCopy = () => {
@@ -290,8 +396,17 @@ const markdownComponents = {
} catch {}
};
// Code block with syntax highlighting
return (
<div className="relative group my-2">
{/* Language label */}
{language && language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">
{language}
</div>
)}
{/* Copy button */}
<button
type="button"
onClick={handleCopy}
@@ -316,11 +431,25 @@ const markdownComponents = {
</span>
)}
</button>
<pre className="bg-gray-900 dark:bg-gray-900 border border-gray-700/40 rounded-lg p-3 overflow-x-auto">
<code className={`text-gray-100 dark:text-gray-100 text-sm font-mono ${className || ''}`} {...props}>
{children}
</code>
</pre>
{/* Syntax highlighted code */}
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
codeTagProps={{
style: {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
}
}}
>
{raw}
</SyntaxHighlighter>
</div>
);
},
@@ -355,7 +484,7 @@ const markdownComponents = {
};
// Memoized message component to prevent unnecessary re-renders
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking, selectedProject }) => {
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => {
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') ||
@@ -363,6 +492,13 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
(prevMessage.type === 'error'));
const messageRef = React.useRef(null);
const [isExpanded, setIsExpanded] = React.useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = React.useState('idle');
React.useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
React.useEffect(() => {
if (!autoExpandTools || !messageRef.current || !message.isToolUse) return;
@@ -1357,6 +1493,59 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</Markdown>
);
})()}
{permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 dark:border-red-800/60 pt-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
if (!onGrantToolPermission) return;
const result = onGrantToolPermission(permissionSuggestion);
if (result?.success) {
setPermissionGrantState('granted');
} else {
setPermissionGrantState('error');
}
}}
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'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'Permission added'
: `Grant permission for ${permissionSuggestion.toolName}`}
</button>
{onShowSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onShowSettings();
}}
className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100"
>
Open settings
</button>
)}
</div>
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
Adds <span className="font-mono">{permissionSuggestion.entry}</span> to Allowed Tools.
</div>
{permissionGrantState === 'error' && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
Unable to update permissions. Please try again.
</div>
)}
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
Permission saved. Retry the request to use the tool.
</div>
)}
</div>
)}
</div>
</div>
);
@@ -1687,6 +1876,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const MESSAGES_PER_PAGE = 20;
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
const [permissionMode, setPermissionMode] = useState('default');
// In-memory queue of tool permission prompts for the current UI view.
// These are not persisted and do not survive a page refresh; introduced so
// the UI can present pending approvals while the SDK waits.
const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]);
const [attachedImages, setAttachedImages] = useState([]);
const [uploadingImages, setUploadingImages] = useState(new Map());
const [imageErrors, setImageErrors] = useState(new Map());
@@ -1695,9 +1888,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const inputContainerRef = useRef(null);
const scrollContainerRef = useRef(null);
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
const isLoadingMoreRef = useRef(false);
const topLoadLockRef = useRef(false);
const pendingScrollRestoreRef = useRef(null);
// Streaming throttle buffers
const streamBufferRef = useRef('');
const streamTimerRef = useRef(null);
// Track the session that this view expects when starting a brandnew chat
// (prevents background sessions from streaming into a different view).
const pendingViewSessionRef = useRef(null);
const commandQueryTimerRef = useRef(null);
const [debouncedInput, setDebouncedInput] = useState('');
const [showFileDropdown, setShowFileDropdown] = useState(false);
@@ -1723,14 +1922,25 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return localStorage.getItem('selected-provider') || 'claude';
});
const [cursorModel, setCursorModel] = useState(() => {
return localStorage.getItem('cursor-model') || 'gpt-5';
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
});
const [claudeModel, setClaudeModel] = useState(() => {
return localStorage.getItem('claude-model') || 'sonnet';
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
});
const [codexModel, setCodexModel] = useState(() => {
return localStorage.getItem('codex-model') || 'gpt-5.2';
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
});
// Track provider transitions so we only clear approvals when provider truly changes.
// This does not sync with the backend; it just prevents UI prompts from disappearing.
const lastProviderRef = useRef(provider);
const resetStreamingState = useCallback(() => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
streamBufferRef.current = '';
}, []);
// Load permission mode for the current session
useEffect(() => {
if (selectedSession?.id) {
@@ -1750,6 +1960,23 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
localStorage.setItem('selected-provider', selectedSession.__provider);
}
}, [selectedSession]);
// Clear pending permission prompts when switching providers; filter when switching sessions.
// This does not preserve prompts across provider changes; it exists to keep the
// Claude approval flow intact while preventing prompts from a different provider.
useEffect(() => {
if (lastProviderRef.current !== provider) {
setPendingPermissionRequests([]);
lastProviderRef.current = provider;
}
}, [provider]);
// When the selected session changes, drop prompts that belong to other sessions.
// This does not attempt to migrate prompts across sessions; it only filters,
// introduced so the UI does not show approvals for a session the user is no longer viewing.
useEffect(() => {
setPendingPermissionRequests(prev => prev.filter(req => !req.sessionId || req.sessionId === selectedSession?.id));
}, [selectedSession?.id]);
// Load Cursor default model from config
useEffect(() => {
@@ -1758,17 +1985,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
.then(res => res.json())
.then(data => {
if (data.success && data.config?.model?.modelId) {
// Map Cursor model IDs to our simplified names
const modelMap = {
'gpt-5': 'gpt-5',
'claude-4-sonnet': 'sonnet-4',
'sonnet-4': 'sonnet-4',
'claude-4-opus': 'opus-4.1',
'opus-4.1': 'opus-4.1'
};
const mappedModel = modelMap[data.config.model.modelId] || data.config.model.modelId;
// Use the model from config directly
const modelId = data.config.model.modelId;
if (!localStorage.getItem('cursor-model')) {
setCursorModel(mappedModel);
setCursorModel(modelId);
}
}
})
@@ -2716,6 +2936,39 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return scrollHeight - scrollTop - clientHeight < 50;
}, []);
const loadOlderMessages = useCallback(async (container) => {
if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) return false;
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider === 'cursor') return false;
isLoadingMoreRef.current = true;
const previousScrollHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
try {
const moreMessages = await loadSessionMessages(
selectedProject.name,
selectedSession.id,
true,
sessionProvider
);
if (moreMessages.length > 0) {
pendingScrollRestoreRef.current = {
height: previousScrollHeight,
top: previousScrollTop
};
// Prepend new messages to the existing ones
setSessionMessages(prev => [...moreMessages, ...prev]);
}
return true;
} finally {
isLoadingMoreRef.current = false;
}
}, [hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]);
// Handle scroll events to detect when user manually scrolls up and load more messages
const handleScroll = useCallback(async () => {
if (scrollContainerRef.current) {
@@ -2725,32 +2978,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Check if we should load more messages (scrolled near top)
const scrolledNearTop = container.scrollTop < 100;
const provider = localStorage.getItem('selected-provider') || 'claude';
if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') {
// Save current scroll position
const previousScrollHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
// Load more messages
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true, selectedSession.__provider || 'claude');
if (moreMessages.length > 0) {
// Prepend new messages to the existing ones
setSessionMessages(prev => [...moreMessages, ...prev]);
// Restore scroll position after DOM update
setTimeout(() => {
if (scrollContainerRef.current) {
const newScrollHeight = scrollContainerRef.current.scrollHeight;
const scrollDiff = newScrollHeight - previousScrollHeight;
scrollContainerRef.current.scrollTop = previousScrollTop + scrollDiff;
}
}, 0);
if (!scrolledNearTop) {
topLoadLockRef.current = false;
} else if (!topLoadLockRef.current) {
const didLoad = await loadOlderMessages(container);
if (didLoad) {
topLoadLockRef.current = true;
}
}
}
}, [isNearBottom, hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]);
}, [isNearBottom, loadOlderMessages]);
// Restore scroll position after paginated messages render
useLayoutEffect(() => {
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
const { height, top } = pendingScrollRestoreRef.current;
const container = scrollContainerRef.current;
const newScrollHeight = container.scrollHeight;
const scrollDiff = newScrollHeight - height;
container.scrollTop = top + Math.max(scrollDiff, 0);
pendingScrollRestoreRef.current = null;
}, [chatMessages.length]);
useEffect(() => {
// Load session messages when session changes
@@ -2765,6 +3015,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
if (sessionChanged) {
if (!isSystemSessionChange) {
// Clear any streaming leftovers from the previous session
resetStreamingState();
pendingViewSessionRef.current = null;
setChatMessages([]);
setSessionMessages([]);
setClaudeStatus(null);
setCanAbortSession(false);
}
// Reset pagination state when switching sessions
setMessagesOffset(0);
setHasMoreMessages(false);
@@ -2834,17 +3093,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}
} else {
// Only clear messages if this is NOT a system-initiated session change AND we're not loading
// During system session changes or while loading, preserve the chat messages
if (!isSystemSessionChange && !isLoading) {
// New session view (no selected session) - always reset UI state
if (!isSystemSessionChange) {
resetStreamingState();
pendingViewSessionRef.current = null;
setChatMessages([]);
setSessionMessages([]);
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
}
setCurrentSessionId(null);
sessionStorage.removeItem('cursorSessionId');
setMessagesOffset(0);
setHasMoreMessages(false);
setTotalMessages(0);
setTokenBudget(null);
}
// Mark loading as complete after messages are set
@@ -2855,7 +3119,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
};
loadMessages();
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]);
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange, resetStreamingState]);
// External Message Update Handler: Reload messages when external CLI modifies current session
// This triggers when App.jsx detects a JSONL file change for the currently-viewed session
@@ -2879,7 +3143,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// convertedMessages will be automatically updated via useMemo
// Smart scroll behavior: only auto-scroll if user is near bottom
if (isNearBottom && autoScrollToBottom) {
const shouldAutoScroll = autoScrollToBottom && isNearBottom();
if (shouldAutoScroll) {
setTimeout(() => scrollToBottom(), 200);
}
// If user scrolled up, preserve their position (they're reading history)
@@ -2893,6 +3158,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]);
// When the user navigates to a specific session, clear any pending "new session" marker.
useEffect(() => {
if (selectedSession?.id) {
pendingViewSessionRef.current = null;
}
}, [selectedSession?.id]);
// Update chatMessages when convertedMessages changes
useEffect(() => {
if (sessionMessages.length > 0) {
@@ -2957,17 +3229,77 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Handle WebSocket messages
if (messages.length > 0) {
const latestMessage = messages[messages.length - 1];
const messageData = latestMessage.data?.message || latestMessage.data;
// Filter messages by session ID to prevent cross-session interference
// Skip filtering for global messages that apply to all sessions
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete', 'codex-complete'];
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
const isGlobalMessage = globalMessageTypes.includes(latestMessage.type);
const lifecycleMessageTypes = new Set([
'claude-complete',
'codex-complete',
'cursor-result',
'session-aborted',
'claude-error',
'cursor-error',
'codex-error'
]);
// For new sessions (currentSessionId is null), allow messages through
if (!isGlobalMessage && latestMessage.sessionId && currentSessionId && latestMessage.sessionId !== currentSessionId) {
// Message is for a different session, ignore it
console.log('⏭️ Skipping message for different session:', latestMessage.sessionId, 'current:', currentSessionId);
return;
const isClaudeSystemInit = latestMessage.type === 'claude-response' &&
messageData &&
messageData.type === 'system' &&
messageData.subtype === 'init';
const isCursorSystemInit = latestMessage.type === 'cursor-system' &&
latestMessage.data &&
latestMessage.data.type === 'system' &&
latestMessage.data.subtype === 'init';
const systemInitSessionId = isClaudeSystemInit
? messageData?.session_id
: isCursorSystemInit
? latestMessage.data?.session_id
: null;
const activeViewSessionId = selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
const isSystemInitForView = systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
const shouldBypassSessionFilter = isGlobalMessage || isSystemInitForView;
const isUnscopedError = !latestMessage.sessionId &&
pendingViewSessionRef.current &&
!pendingViewSessionRef.current.sessionId &&
(latestMessage.type === 'claude-error' || latestMessage.type === 'cursor-error' || latestMessage.type === 'codex-error');
const handleBackgroundLifecycle = (sessionId) => {
if (!sessionId) return;
if (onSessionInactive) {
onSessionInactive(sessionId);
}
if (onSessionNotProcessing) {
onSessionNotProcessing(sessionId);
}
};
if (!shouldBypassSessionFilter) {
if (!activeViewSessionId) {
// No session in view; ignore session-scoped traffic.
if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
if (!isUnscopedError) {
return;
}
}
if (!latestMessage.sessionId && !isUnscopedError) {
// Drop unscoped messages to prevent cross-session bleed.
return;
}
if (latestMessage.sessionId !== activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
// Message is for a different session, ignore it
console.log('??-?,? Skipping message for different session:', latestMessage.sessionId, 'current:', activeViewSessionId);
return;
}
}
switch (latestMessage.type) {
@@ -2976,6 +3308,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Store it temporarily until conversation completes (prevents premature session association)
if (latestMessage.sessionId && !currentSessionId) {
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
pendingViewSessionRef.current.sessionId = latestMessage.sessionId;
}
// Mark as system change to prevent clearing messages when session ID updates
setIsSystemSessionChange(true);
// Session Protection: Replace temporary "new-session-*" identifier with real session ID
// This maintains protection continuity - no gap between temp ID and real ID
@@ -2983,6 +3321,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (onReplaceTemporarySession) {
onReplaceTemporarySession(latestMessage.sessionId);
}
// Attach the real session ID to any pending permission requests so they
// do not disappear during the "new-session -> real-session" transition.
// This does not create or auto-approve requests; it only keeps UI state aligned.
setPendingPermissionRequests(prev => prev.map(req => (
req.sessionId ? req : { ...req, sessionId: latestMessage.sessionId }
)));
}
break;
@@ -2994,7 +3339,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
break;
case 'claude-response':
const messageData = latestMessage.data.message || latestMessage.data;
// Handle Cursor streaming format (content_block_delta / content_block_stop)
if (messageData && typeof messageData === 'object' && messageData.type) {
@@ -3063,7 +3407,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
latestMessage.data.subtype === 'init' &&
latestMessage.data.session_id &&
currentSessionId &&
latestMessage.data.session_id !== currentSessionId) {
latestMessage.data.session_id !== currentSessionId &&
isSystemInitForView) {
console.log('🔄 Claude CLI session duplication detected:', {
originalSession: currentSessionId,
@@ -3086,7 +3431,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (latestMessage.data.type === 'system' &&
latestMessage.data.subtype === 'init' &&
latestMessage.data.session_id &&
!currentSessionId) {
!currentSessionId &&
isSystemInitForView) {
console.log('🔄 New session init detected:', {
newSession: latestMessage.data.session_id
@@ -3107,7 +3453,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
latestMessage.data.subtype === 'init' &&
latestMessage.data.session_id &&
currentSessionId &&
latestMessage.data.session_id === currentSessionId) {
latestMessage.data.session_id === currentSessionId &&
isSystemInitForView) {
console.log('🔄 System init message for current session, ignoring');
return; // Don't process the message further
}
@@ -3213,6 +3560,55 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}]);
break;
case 'claude-permission-request': {
// Receive a tool approval request from the backend and surface it in the UI.
// This does not approve anything automatically; it only queues a prompt,
// introduced so the user can decide before the SDK continues.
if (provider !== 'claude' || !latestMessage.requestId) {
break;
}
setPendingPermissionRequests(prev => {
if (prev.some(req => req.requestId === latestMessage.requestId)) {
return prev;
}
return [
...prev,
{
requestId: latestMessage.requestId,
toolName: latestMessage.toolName || 'UnknownTool',
input: latestMessage.input,
context: latestMessage.context,
sessionId: latestMessage.sessionId || null,
receivedAt: new Date()
}
];
});
// Keep the session in a "waiting" state while approval is pending.
// This does not resume the run; it only updates the UI status so the
// user knows Claude is blocked on a decision.
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Waiting for permission',
tokens: 0,
can_interrupt: true
});
break;
}
case 'claude-permission-cancelled': {
// Backend cancelled the approval (timeout or SDK cancel); remove the banner.
// We currently do not show a user-facing warning here; this is intentional
// to avoid noisy alerts when the SDK cancels in the background.
if (!latestMessage.requestId) {
break;
}
setPendingPermissionRequests(prev => prev.filter(req => req.requestId !== latestMessage.requestId));
break;
}
case 'claude-error':
setChatMessages(prev => [...prev, {
type: 'error',
@@ -3226,6 +3622,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
try {
const cdata = latestMessage.data;
if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) {
if (!isSystemInitForView) {
return;
}
// If we already have a session and this differs, switch (duplication/redirect)
if (currentSessionId && cdata.session_id !== currentSessionId) {
console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id });
@@ -3409,6 +3808,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (selectedProject && latestMessage.exitCode === 0) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
// Conversation finished; clear any stale permission prompts.
// This does not remove saved permissions; it only resets transient UI state.
setPendingPermissionRequests([]);
break;
case 'codex-response':
@@ -3536,8 +3938,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
if (codexPendingSessionId && !currentSessionId) {
setCurrentSessionId(codexPendingSessionId);
setCurrentSessionId(codexActualSessionId);
setIsSystemSessionChange(true);
if (onNavigateToSession) {
onNavigateToSession(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId');
console.log('Codex session complete, ID set to:', codexPendingSessionId);
}
@@ -3579,6 +3986,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}
// Abort ends the run; clear permission prompts to avoid dangling UI state.
// This does not change allowlists; it only clears the current banner.
setPendingPermissionRequests([]);
setChatMessages(prev => [...prev, {
type: 'assistant',
content: 'Session interrupted by user.',
@@ -4005,6 +4416,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Session Protection: Mark session as active to prevent automatic project updates during conversation
// Use existing session if available; otherwise a temporary placeholder until backend provides real ID
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
if (!effectiveSessionId && !selectedSession?.id) {
// We are starting a brand-new session in this view. Track it so we only
// accept streaming updates for this run.
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
}
if (onSessionActive) {
onSessionActive(sessionToActivate);
}
@@ -4097,6 +4513,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
const handleGrantToolPermission = useCallback((suggestion) => {
if (!suggestion || provider !== 'claude') {
return { success: false };
}
return grantClaudeToolPermission(suggestion.entry);
}, [provider]);
// Send a UI decision back to the server (single or batched request IDs).
// This does not validate tool inputs or permissions; the backend enforces rules.
// It exists so "Allow & remember" can resolve multiple queued prompts at once.
const handlePermissionDecision = useCallback((requestIds, decision) => {
const ids = Array.isArray(requestIds) ? requestIds : [requestIds];
const validIds = ids.filter(Boolean);
if (validIds.length === 0) {
return;
}
validIds.forEach((requestId) => {
sendMessage({
type: 'claude-permission-response',
requestId,
allow: Boolean(decision?.allow),
updatedInput: decision?.updatedInput,
message: decision?.message,
rememberEntry: decision?.rememberEntry
});
});
setPendingPermissionRequests(prev => {
const next = prev.filter(req => !validIds.includes(req.requestId));
if (next.length === 0) {
setClaudeStatus(null);
}
return next;
});
}, [sendMessage]);
// Store handleSubmit in ref so handleCustomCommand can access it
useEffect(() => {
handleSubmitRef.current = handleSubmit;
@@ -4416,6 +4869,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Messages Area - Scrollable Middle Section */}
<div
ref={scrollContainerRef}
onWheel={handleScroll}
onTouchMove={handleScroll}
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
>
{isLoadingSessionMessages && chatMessages.length === 0 ? (
@@ -4547,11 +5002,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}}
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="haiku">Haiku</option>
<option value="opusplan">Opus Plan</option>
<option value="sonnet[1m]">Sonnet [1M]</option>
{CLAUDE_MODELS.OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
) : provider === 'codex' ? (
<select
@@ -4563,10 +5016,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}}
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500 min-w-[140px]"
>
<option value="gpt-5.2">GPT-5.2</option>
<option value="gpt-5.1-codex-max">GPT-5.1 Codex Max</option>
<option value="o3">O3</option>
<option value="o4-mini">O4-mini</option>
{CODEX_MODELS.OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
) : (
<select
@@ -4579,23 +5031,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
disabled={provider !== 'cursor'}
>
<option value="gpt-5.2-high">GPT-5.2 High</option>
<option value="gemini-3-pro">Gemini 3 Pro</option>
<option value="opus-4.5-thinking">Claude 4.5 Opus (Thinking)</option>
<option value="gpt-5.2">GPT-5.2</option>
<option value="gpt-5.1">GPT-5.1</option>
<option value="gpt-5.1-high">GPT-5.1 High</option>
<option value="composer-1">Composer 1</option>
<option value="auto">Auto</option>
<option value="sonnet-4.5">Claude 4.5 Sonnet</option>
<option value="sonnet-4.5-thinking">Claude 4.5 Sonnet (Thinking)</option>
<option value="opus-4.5">Claude 4.5 Opus</option>
<option value="gpt-5.1-codex">GPT-5.1 Codex</option>
<option value="gpt-5.1-codex-high">GPT-5.1 Codex High</option>
<option value="gpt-5.1-codex-max">GPT-5.1 Codex Max</option>
<option value="gpt-5.1-codex-max-high">GPT-5.1 Codex Max High</option>
<option value="opus-4.1">Claude 4.1 Opus</option>
<option value="grok">Grok</option>
{CURSOR_MODELS.OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
)}
</div>
@@ -4690,10 +5128,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={handleGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
provider={provider}
/>
);
})}
@@ -4748,6 +5188,101 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div>
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
<div ref={inputContainerRef} className="max-w-4xl mx-auto mb-3">
{pendingPermissionRequests.length > 0 && (
// Permission banner for tool approvals. This renders the input, allows
// "allow once" or "allow & remember", and supports batching similar requests.
// It does not persist permissions by itself; persistence is handled by
// the existing localStorage-based settings helpers, introduced to surface
// approvals before tool execution resumes.
<div className="mb-3 space-y-2">
{pendingPermissionRequests.map((request) => {
const rawInput = formatToolInputForDisplay(request.input);
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
const settings = getClaudeSettings();
const alreadyAllowed = permissionEntry
? settings.allowedTools.includes(permissionEntry)
: false;
const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember';
// Group pending prompts that resolve to the same allow rule so
// a single "Allow & remember" can clear them in one click.
// This does not attempt fuzzy matching; it only batches identical rules.
const matchingRequestIds = permissionEntry
? pendingPermissionRequests
.filter(item => buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry)
.map(item => item.requestId)
: [request.requestId];
return (
<div
key={request.requestId}
className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">
Permission required
</div>
<div className="text-xs text-amber-800 dark:text-amber-200">
Tool: <span className="font-mono">{request.toolName}</span>
</div>
</div>
{permissionEntry && (
<div className="text-xs text-amber-700 dark:text-amber-300">
Allow rule: <span className="font-mono">{permissionEntry}</span>
</div>
)}
</div>
{rawInput && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-amber-800 dark:text-amber-200 hover:text-amber-900 dark:hover:text-amber-100">
View tool input
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-md bg-white/80 dark:bg-gray-900/60 border border-amber-200/60 dark:border-amber-800/60 p-2 text-xs text-amber-900 dark:text-amber-100 whitespace-pre-wrap">
{rawInput}
</pre>
</details>
)}
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors"
>
Allow once
</button>
<button
type="button"
onClick={() => {
if (permissionEntry && !alreadyAllowed) {
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
}
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
}}
className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${
permissionEntry
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
: 'border-gray-300 text-gray-400 cursor-not-allowed'
}`}
disabled={!permissionEntry}
>
{rememberLabel}
</button>
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors"
>
Deny
</button>
</div>
</div>
);
})}
</div>
)}
<div className="flex items-center justify-center gap-3">
<button
type="button"

View File

@@ -25,13 +25,15 @@ function LoginModal({
const getCommand = () => {
if (customCommand) return customCommand;
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
switch (provider) {
case 'claude':
return 'claude setup-token --dangerously-skip-permissions';
case 'cursor':
return 'cursor-agent login';
case 'codex':
return 'codex login';
return isPlatform ? 'codex login --device-auth' : 'codex login';
default:
return 'claude setup-token --dangerously-skip-permissions';
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle } from 'lucide-react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { api } from '../utils/api';
@@ -7,7 +7,7 @@ import { api } from '../utils/api';
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
// Wizard state
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
const [workspaceType, setWorkspaceType] = useState(null); // 'existing' or 'new'
const [workspaceType, setWorkspaceType] = useState('existing'); // 'existing' or 'new' - default to 'existing'
// Form state
const [workspacePath, setWorkspacePath] = useState('');
@@ -23,6 +23,11 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const [loadingTokens, setLoadingTokens] = useState(false);
const [pathSuggestions, setPathSuggestions] = useState([]);
const [showPathDropdown, setShowPathDropdown] = useState(false);
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
const [browserCurrentPath, setBrowserCurrentPath] = useState('~');
const [browserFolders, setBrowserFolders] = useState([]);
const [loadingFolders, setLoadingFolders] = useState(false);
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
// Load available GitHub tokens when needed
useEffect(() => {
@@ -155,6 +160,37 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
setShowPathDropdown(false);
};
const openFolderBrowser = async () => {
setShowFolderBrowser(true);
await loadBrowserFolders('~');
};
const loadBrowserFolders = async (path) => {
try {
setLoadingFolders(true);
setBrowserCurrentPath(path);
const response = await api.browseFilesystem(path);
const data = await response.json();
setBrowserFolders(data.suggestions || []);
} catch (error) {
console.error('Error loading folders:', error);
} finally {
setLoadingFolders(false);
}
};
const selectFolder = (folderPath, advanceToConfirm = false) => {
setWorkspacePath(folderPath);
setShowFolderBrowser(false);
if (advanceToConfirm) {
setStep(3);
}
};
const navigateToFolder = async (folderPath) => {
await loadBrowserFolders(folderPath);
};
return (
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
@@ -290,28 +326,39 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{workspaceType === 'existing' ? 'Workspace Path' : 'Where should the workspace be created?'}
</label>
<div className="relative">
<Input
type="text"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
className="w-full"
/>
{showPathDropdown && pathSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{pathSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => selectPathSuggestion(suggestion)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
>
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
</button>
))}
</div>
)}
<div className="relative flex gap-2">
<div className="flex-1 relative">
<Input
type="text"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
className="w-full"
/>
{showPathDropdown && pathSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{pathSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => selectPathSuggestion(suggestion)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
>
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
</button>
))}
</div>
)}
</div>
<Button
type="button"
variant="outline"
onClick={openFolderBrowser}
className="px-3"
title="Browse folders"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{workspaceType === 'existing'
@@ -563,6 +610,121 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</Button>
</div>
</div>
{/* Folder Browser Modal */}
{showFolderBrowser && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] border border-gray-200 dark:border-gray-700 flex flex-col">
{/* Browser Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<FolderOpen className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Select Folder
</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHiddenFolders(!showHiddenFolders)}
className={`p-2 rounded-md transition-colors ${
showHiddenFolders
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={showHiddenFolders ? 'Hide hidden folders' : 'Show hidden folders'}
>
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
</button>
<button
onClick={() => setShowFolderBrowser(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Folder List */}
<div className="flex-1 overflow-y-auto p-4">
{loadingFolders ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : browserFolders.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No folders found
</div>
) : (
<div className="space-y-1">
{/* Parent Directory */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && (
<button
onClick={() => {
const parentPath = browserCurrentPath.substring(0, browserCurrentPath.lastIndexOf('/')) || '/';
navigateToFolder(parentPath);
}}
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderOpen className="w-5 h-5 text-gray-400" />
<span className="font-medium text-gray-700 dark:text-gray-300">..</span>
</button>
)}
{/* Folders */}
{browserFolders
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
.map((folder, index) => (
<div key={index} className="flex items-center gap-2">
<button
onClick={() => navigateToFolder(folder.path)}
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderPlus className="w-5 h-5 text-blue-500" />
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => selectFolder(folder.path, true)}
className="text-xs px-3"
>
Select
</Button>
</div>
))}
</div>
)}
</div>
{/* Browser Footer with Current Path */}
<div className="border-t border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/50 flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Path:</span>
<code className="text-sm font-mono text-gray-900 dark:text-white flex-1 truncate">
{browserCurrentPath}
</code>
</div>
<div className="flex items-center justify-end gap-2 p-4">
<Button
variant="outline"
onClick={() => setShowFolderBrowser(false)}
>
Cancel
</Button>
<Button
variant="outline"
onClick={() => selectFolder(browserCurrentPath, true)}
>
Use this folder
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react';
import {
ChevronLeft,
ChevronRight,
Maximize2,
Eye,
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
ChevronLeft,
ChevronRight,
Maximize2,
Eye,
Settings2,
Moon,
Sun,
@@ -12,7 +12,8 @@ import {
Brain,
Sparkles,
FileText,
Languages
Languages,
GripVertical
} from 'lucide-react';
import DarkModeToggle from './DarkModeToggle';
import { useTheme } from '../contexts/ThemeContext';
@@ -38,11 +39,170 @@ const QuickSettingsPanel = ({
});
const { isDarkMode } = useTheme();
// Draggable handle state
const [handlePosition, setHandlePosition] = useState(() => {
const saved = localStorage.getItem('quickSettingsHandlePosition');
if (saved) {
try {
const parsed = JSON.parse(saved);
return parsed.y ?? 50;
} catch {
// Remove corrupted data
localStorage.removeItem('quickSettingsHandlePosition');
return 50;
}
}
return 50; // Default to 50% (middle of screen)
});
const [isDragging, setIsDragging] = useState(false);
const [dragStartY, setDragStartY] = useState(0);
const [dragStartPosition, setDragStartPosition] = useState(0);
const [hasMoved, setHasMoved] = useState(false); // Track if user has moved during drag
const handleRef = useRef(null);
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
const dragThreshold = 5; // Pixels to move before it's considered a drag
useEffect(() => {
setLocalIsOpen(isOpen);
}, [isOpen]);
const handleToggle = () => {
// Save handle position to localStorage when it changes
useEffect(() => {
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
}, [handlePosition]);
// Calculate position from percentage
const getPositionStyle = useCallback(() => {
if (isMobile) {
// On mobile, convert percentage to pixels from bottom
const bottomPixels = (window.innerHeight * handlePosition) / 100;
return { bottom: `${bottomPixels}px` };
} else {
// On desktop, use top with percentage
return { top: `${handlePosition}%`, transform: 'translateY(-50%)' };
}
}, [handlePosition, isMobile]);
// Handle mouse/touch start
const handleDragStart = useCallback((e) => {
// Don't prevent default yet - we want to allow click if no drag happens
e.stopPropagation();
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
setDragStartY(clientY);
setDragStartPosition(handlePosition);
setHasMoved(false);
setIsDragging(false); // Don't set dragging until threshold is passed
}, [handlePosition]);
// Handle mouse/touch move
const handleDragMove = useCallback((e) => {
if (dragStartY === 0) return; // Not in a potential drag
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
const deltaY = Math.abs(clientY - dragStartY);
// Check if we've moved past threshold
if (!isDragging && deltaY > dragThreshold) {
setIsDragging(true);
setHasMoved(true);
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
// Prevent body scroll on mobile during drag
if (e.type.includes('touch')) {
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
}
if (!isDragging) return;
// Prevent scrolling on touch move
if (e.type.includes('touch')) {
e.preventDefault();
}
const actualDeltaY = clientY - dragStartY;
// For top-based positioning (desktop), moving down increases top percentage
// For bottom-based positioning (mobile), we need to invert
let percentageDelta;
if (isMobile) {
// On mobile, moving down should decrease bottom position (increase percentage from top)
percentageDelta = -(actualDeltaY / window.innerHeight) * 100;
} else {
// On desktop, moving down should increase top position
percentageDelta = (actualDeltaY / window.innerHeight) * 100;
}
let newPosition = dragStartPosition + percentageDelta;
// Apply constraints
newPosition = Math.max(constraintsRef.current.min, Math.min(constraintsRef.current.max, newPosition));
setHandlePosition(newPosition);
}, [isDragging, dragStartY, dragStartPosition, isMobile, dragThreshold]);
// Handle mouse/touch end
const handleDragEnd = useCallback(() => {
setIsDragging(false);
setDragStartY(0);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Restore body scroll on mobile
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}, []);
// Cleanup body styles on unmount in case component unmounts while dragging
useEffect(() => {
return () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
};
}, []);
// Set up global event listeners for drag
useEffect(() => {
if (dragStartY !== 0) {
// Mouse events
const handleMouseMove = (e) => handleDragMove(e);
const handleMouseUp = () => handleDragEnd();
// Touch events
const handleTouchMove = (e) => handleDragMove(e);
const handleTouchEnd = () => handleDragEnd();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
}, [dragStartY, handleDragMove, handleDragEnd]);
const handleToggle = (e) => {
// Don't toggle if user was dragging
if (hasMoved) {
e.preventDefault();
setHasMoved(false);
return;
}
const newState = !localIsOpen;
setLocalIsOpen(newState);
onToggle(newState);
@@ -50,24 +210,37 @@ const QuickSettingsPanel = ({
return (
<>
{/* Pull Tab */}
<div
className={`fixed ${isMobile ? 'bottom-44' : 'top-1/2 -translate-y-1/2'} ${
{/* Pull Tab - Combined drag handle and toggle button */}
<button
ref={handleRef}
onClick={handleToggle}
onMouseDown={(e) => {
// Start drag on mousedown
handleDragStart(e);
}}
onTouchStart={(e) => {
// Start drag on touchstart
handleDragStart(e);
}}
className={`fixed ${
localIsOpen ? 'right-64' : 'right-0'
} z-50 transition-all duration-150 ease-out`}
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
} touch-none`}
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
aria-label={isDragging ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'}
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'}
>
<button
onClick={handleToggle}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg"
aria-label={localIsOpen ? 'Close settings panel' : 'Open settings panel'}
>
{localIsOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
</div>
{isDragging ? (
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
) : localIsOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
{/* Panel */}
<div

View File

@@ -595,9 +595,44 @@ function Sidebar({
</div>
</div>
{/* Search Filter and Actions */}
{/* Action Buttons - Desktop only - Always show when not loading */}
{!isLoading && !isMobile && (
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title="Create new project"
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
</div>
)}
{/* Search Filter - Only show when there are projects */}
{projects.length > 0 && !isLoading && (
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
@@ -616,39 +651,6 @@ function Sidebar({
</button>
)}
</div>
{/* Action Buttons - Desktop only */}
{!isMobile && (
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
)}
</div>
)}
@@ -1392,4 +1394,4 @@ function Sidebar({
);
}
export default Sidebar;
export default Sidebar;

View File

@@ -3,4 +3,13 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}
}
export function safeJsonParse(value) {
if (!value || typeof value !== 'string') return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}