mirror of
https://github.com/cline/cline.git
synced 2026-09-02 07:42:19 +08:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fea5aaa63 | |||
| 8de7294de8 | |||
| 2e4ded7f14 | |||
| ac94eb4cf4 | |||
| 73635cf68f | |||
| 9eb0da6c2b | |||
| 59c10df046 | |||
| db4d9a267a | |||
| 734dc6e57f | |||
| 27befb16f5 | |||
| 1c32b08644 | |||
| 5d81bf0fe2 | |||
| f44771acd5 | |||
| 786728e0d4 | |||
| 9714cf8861 | |||
| dc85299a73 | |||
| 4533ed3ea8 | |||
| 66d7664d1b | |||
| 50aeb7098f | |||
| c2e6eafb45 | |||
| 0220628c39 | |||
| 5052220195 | |||
| abf3081e56 | |||
| 125cb78a31 | |||
| 17539228ea | |||
| c5db44964e | |||
| ed38739d65 | |||
| bdb7cc36fa | |||
| 50bc90835c | |||
| d3e1065707 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add claude 4.5 haiku
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
improve cline command permission validation logic. add cline command permission man page documentation
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: finalize document content during approval flow
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Expose --version in cline cli command
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: prevent duplicate diff errors when parallel tool calling is enabled
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Log Persistence errors to PostHog
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat: add OpenAI Codex (ChatGPT Plus/Pro) provider
|
||||
|
||||
Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
|
||||
|
||||
Models available:
|
||||
- gpt-5.2-codex (default)
|
||||
- gpt-5.1-codex-max
|
||||
- gpt-5.1-codex-mini
|
||||
- gpt-5.2
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix two bugs in DiffViewProvider file editing:
|
||||
|
||||
1. **Line boundary validation**: Add `safelyTruncateDocument()` to prevent out-of-bounds line errors on JetBrains hosts (fixes #8423, #8429). The gRPC protocol strictly validates line numbers, causing "truncateDocument INTERNAL: Wrong line" errors when `truncateDocument()` was called with a line number >= document line count.
|
||||
|
||||
2. **Content concatenation on final update**: When replacing content without a trailing newline, the old content at line N+1 was concatenated to the new content. Fixed by extending the replacement range to cover the entire document on final update.
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
docs: fix outdated Ollama model names in documentation
|
||||
|
||||
Updated recommended Ollama models to use correct identifiers:
|
||||
- Changed qwen3-coder-30b to qwen2.5-coder:32b
|
||||
- Changed devstral-small to codellama:34b-code
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix issue where tool call ids are invalid when switching between models using the chat completion format and the responses api format.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: improve Jupyter notebook diff view and reduce LLM context for notebook edits
|
||||
|
||||
- Restore switchToSpecializedEditor() for Jupyter notebook diff views that was accidentally removed during rebase
|
||||
- Open .ipynb files in Jupyter notebook editor after save instead of leaving stale diff view
|
||||
- Strip notebook outputs from content sent to LLM, reducing context by 95% (196KB → 9KB)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: add chat output on skill use
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding telemetry for background exec terminal
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
This pull request introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness. The feature allows users to seamlessly work with Jupyter notebooks using Cline's AI capabilities while preserving the notebook's JSON structure.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Remove deprecated zai-glm-4.6 model from Cerebras provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Wiring-up "conditionals" for Cline Rules files.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: removes retry message from UI after retry succeeds
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support native tool calling for LM Studio and Ollama provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fixing integration tests from testing framework
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
add claude 4.5 opus into sap provider.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix drag & drop files from SSH remote workspaces into chat
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Limite Vertex and LiteLLM options when they're remote configured
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix crash when the Context Menu has a type but no options
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Updated switch component to adhere to 3:1 contrast accessibility standard for both light and dark vscode themes
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Throttle the remote config fetch
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve history view filter menu
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add git worktree management UI for running parallel Cline sessions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove hooks feature setting and make it always enabled (except on Windows).
|
||||
@@ -46,3 +46,4 @@ test-results
|
||||
/pkg
|
||||
.secrets
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
+15
-2
@@ -1,10 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [3.52.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
|
||||
- Grok models are now moving out of free tier and into paid plans.
|
||||
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
|
||||
|
||||
### Fixed
|
||||
- Bugs in DiffViewProvider for file editing
|
||||
- Ollama's recommended models to use correct identifiers
|
||||
|
||||
|
||||
## [3.51.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Adding OpenAI gpt-5.2-codex model to the model picker
|
||||
- Adding OpenAI gpt-5.2-codex model to the model picker
|
||||
|
||||
## [3.50.0]
|
||||
|
||||
@@ -1737,4 +1750,4 @@ Add Opus 4.1 through Claude Code
|
||||
|
||||
## [0.0.6]
|
||||
|
||||
- Initial release
|
||||
- Initial release
|
||||
|
||||
+32
-10
@@ -28,12 +28,19 @@ npm run install:all
|
||||
npm run protos
|
||||
|
||||
# Build the CLI
|
||||
npm run compile-cli-ts
|
||||
npm run build:cli
|
||||
```
|
||||
|
||||
Or install the CLI globally:
|
||||
|
||||
```bash
|
||||
# Install all dependencies first
|
||||
npm run install:all
|
||||
|
||||
# Ensure protos are generated
|
||||
npm run protos
|
||||
|
||||
# Build and link the CLI globally
|
||||
cd cli-ts
|
||||
npm install
|
||||
npm run link
|
||||
@@ -189,7 +196,6 @@ These options are available for the default command (running a task directly):
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-i, --images <paths...>` | Image file paths to include with the task |
|
||||
| `-v, --verbose` | Show verbose output |
|
||||
| `-c, --cwd <path>` | Working directory |
|
||||
| `--config <path>` | Configuration directory |
|
||||
@@ -197,20 +203,36 @@ These options are available for the default command (running a task directly):
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build and link the package to your terminal
|
||||
npm run link
|
||||
For active development, at the root of this repo:
|
||||
|
||||
# Set your provider (No Cline provider support yet)
|
||||
cline auth
|
||||
1. **Initial setup:**
|
||||
```bash
|
||||
npm run install:all
|
||||
npm run protos
|
||||
```
|
||||
|
||||
# Run a task
|
||||
cline task "Tell me about this codebase"
|
||||
```
|
||||
2. **Make changes to cli-ts:**
|
||||
```bash
|
||||
npm run cli:dev
|
||||
# or
|
||||
cd cli-ts && npm run dev
|
||||
```
|
||||
|
||||
3. **Test your changes:**
|
||||
```bash
|
||||
cline [your-command] # In a new Terminal
|
||||
```
|
||||
|
||||
4. **When done:**
|
||||
```bash
|
||||
npm run cli:unlink
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd cli-ts
|
||||
|
||||
# Development build with source maps
|
||||
npm run build
|
||||
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ const config = {
|
||||
target: "node20",
|
||||
outfile: path.join(__dirname, "dist", "cli.mjs"),
|
||||
// These modules need to load files from the module directory at runtime
|
||||
external: ["@grpc/reflection", "grpc-health-check", "better-sqlite3", "ink", "ink-spinner", "react"],
|
||||
external: ["@grpc/reflection", "grpc-health-check", "better-sqlite3", "ink", "ink-spinner", "react", "aws4fetch"],
|
||||
supported: { "top-level-await": true },
|
||||
banner: {
|
||||
js: `#!/usr/bin/env node
|
||||
|
||||
Generated
+206
-201
@@ -9,13 +9,14 @@
|
||||
"version": "1.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"ink": "^5.0.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ora": "^8.0.1",
|
||||
"prompts": "^2.4.2",
|
||||
"react": "^18.3.0"
|
||||
"react": "^19.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"clinedev": "dist/cli.mjs"
|
||||
@@ -23,7 +24,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "20.x",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^18.3.27",
|
||||
"@types/react": "^19.2.9",
|
||||
"esbuild": "^0.25.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
@@ -32,16 +33,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
|
||||
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.3.tgz",
|
||||
"integrity": "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^4.0.0"
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.13.1"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -517,9 +533,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz",
|
||||
"integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
|
||||
"integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -531,9 +547,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -545,9 +561,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -559,9 +575,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz",
|
||||
"integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
|
||||
"integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -573,9 +589,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -587,9 +603,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz",
|
||||
"integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
|
||||
"integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -601,9 +617,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz",
|
||||
"integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
|
||||
"integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -615,9 +631,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz",
|
||||
"integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
|
||||
"integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -629,9 +645,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -643,9 +659,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -657,9 +673,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -671,9 +687,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -685,9 +701,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -699,9 +715,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -713,9 +729,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -727,9 +743,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -741,9 +757,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -755,9 +771,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -769,9 +785,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -783,9 +799,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz",
|
||||
"integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
|
||||
"integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -797,9 +813,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -811,9 +827,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz",
|
||||
"integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
|
||||
"integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -825,9 +841,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz",
|
||||
"integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
|
||||
"integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -839,9 +855,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -853,9 +869,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz",
|
||||
"integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
|
||||
"integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -899,9 +915,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.29.tgz",
|
||||
"integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==",
|
||||
"version": "20.19.30",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
|
||||
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -919,21 +935,13 @@
|
||||
"kleur": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"devOptional": true,
|
||||
"version": "19.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
|
||||
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
@@ -1109,6 +1117,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/aws4fetch": {
|
||||
"version": "1.0.20",
|
||||
"resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz",
|
||||
"integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -1236,7 +1250,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
@@ -1265,9 +1279,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
@@ -1421,43 +1435,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz",
|
||||
"integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==",
|
||||
"name": "@jrichman/ink",
|
||||
"version": "6.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
|
||||
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.1.3",
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.1",
|
||||
"auto-bind": "^5.0.1",
|
||||
"chalk": "^5.3.0",
|
||||
"chalk": "^5.6.0",
|
||||
"cli-boxes": "^3.0.0",
|
||||
"cli-cursor": "^4.0.0",
|
||||
"cli-truncate": "^4.0.0",
|
||||
"code-excerpt": "^4.0.0",
|
||||
"es-toolkit": "^1.22.0",
|
||||
"es-toolkit": "^1.39.10",
|
||||
"indent-string": "^5.0.0",
|
||||
"is-in-ci": "^1.0.0",
|
||||
"is-in-ci": "^2.0.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"patch-console": "^2.0.0",
|
||||
"react-reconciler": "^0.29.0",
|
||||
"scheduler": "^0.23.0",
|
||||
"react-reconciler": "^0.32.0",
|
||||
"signal-exit": "^3.0.7",
|
||||
"slice-ansi": "^7.1.0",
|
||||
"stack-utils": "^2.0.6",
|
||||
"string-width": "^7.2.0",
|
||||
"string-width": "^8.1.0",
|
||||
"type-fest": "^4.27.0",
|
||||
"widest-line": "^5.0.0",
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"yoga-layout": "~3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"react": ">=18.0.0",
|
||||
"react-devtools-core": "^4.19.1"
|
||||
"@types/react": ">=19.0.0",
|
||||
"react": ">=19.0.0",
|
||||
"react-devtools-core": "^6.1.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -1502,6 +1516,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/string-width": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
|
||||
"integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
|
||||
@@ -1515,15 +1545,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/is-in-ci": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz",
|
||||
"integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
|
||||
"integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-in-ci": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
@@ -1553,12 +1583,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -1596,18 +1620,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
@@ -1675,6 +1687,15 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.40.3",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -1694,6 +1715,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -1904,31 +1931,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
|
||||
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.32.0.tgz",
|
||||
"integrity": "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
@@ -1968,9 +1991,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
|
||||
"integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
|
||||
"integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1984,42 +2007,39 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.55.2",
|
||||
"@rollup/rollup-android-arm64": "4.55.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.55.2",
|
||||
"@rollup/rollup-darwin-x64": "4.55.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.55.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.55.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.55.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.55.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.55.2",
|
||||
"@rollup/rollup-openbsd-x64": "4.55.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.55.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.55.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.55.2",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.55.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.55.2",
|
||||
"@rollup/rollup-android-arm-eabi": "4.56.0",
|
||||
"@rollup/rollup-android-arm64": "4.56.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.56.0",
|
||||
"@rollup/rollup-darwin-x64": "4.56.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.56.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.56.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.56.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.56.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.56.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.56.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.56.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.56.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.56.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.56.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
@@ -2882,21 +2902,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/widest-line": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
|
||||
"integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"string-width": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
|
||||
+7
-5
@@ -4,17 +4,18 @@
|
||||
"description": "Cline CLI - TypeScript implementation that reuses core Cline functionality",
|
||||
"main": "dist/cli.mjs",
|
||||
"bin": {
|
||||
"clinedev": "./dist/cli.mjs"
|
||||
"cline": "./dist/cli.mjs"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"build:production": "node esbuild.mjs --production",
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"dev": "npm run watch",
|
||||
"dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"link": "npm run build && npm link",
|
||||
"unlink": "npm unlink -g @cline/cli && npm unlink -g cline",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
@@ -29,7 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "20.x",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^18.3.27",
|
||||
"@types/react": "^19.2.9",
|
||||
"esbuild": "^0.25.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
@@ -37,12 +38,13 @@
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"ink": "^5.0.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ora": "^8.0.1",
|
||||
"prompts": "^2.4.2",
|
||||
"react": "^18.3.0"
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ function formatBalance(balance: number | null): string {
|
||||
return `$${(balance / 1000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
export const AccountInfoView: React.FC<AccountInfoViewProps> = ({ controller }) => {
|
||||
export const AccountInfoView: React.FC<AccountInfoViewProps> = React.memo(({ controller }) => {
|
||||
const [provider, setProvider] = useState<string | null>(null)
|
||||
const [balance, setBalance] = useState<number | null>(null)
|
||||
const [organization, setOrganization] = useState<ClineAccountOrganization | null>(null)
|
||||
@@ -191,4 +191,4 @@ export const AccountInfoView: React.FC<AccountInfoViewProps> = ({ controller })
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
|
||||
import { Box } from "ink"
|
||||
import React, { ReactNode, useCallback, useState } from "react"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { TaskContextProvider } from "../context/TaskContext"
|
||||
import { AuthView } from "./AuthView"
|
||||
import { ChatView } from "./ChatView"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
import { HistoryView } from "./HistoryView"
|
||||
import { TaskJsonView } from "./TaskJsonView"
|
||||
import { TaskView } from "./TaskView"
|
||||
import { WelcomeView } from "./WelcomeView"
|
||||
|
||||
export type ViewType = "task" | "history" | "config" | "auth" | "welcome"
|
||||
|
||||
@@ -42,8 +44,11 @@ interface SkillInfo {
|
||||
interface AppProps {
|
||||
view: ViewType
|
||||
taskId?: string
|
||||
verbose?: boolean
|
||||
controller?: any
|
||||
// Output Style
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
// Status Callbacks
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
// For history view
|
||||
@@ -86,12 +91,17 @@ interface AppProps {
|
||||
// For welcome view
|
||||
onWelcomeSubmit?: (prompt: string, imagePaths: string[]) => void
|
||||
onWelcomeExit?: () => void
|
||||
// Stdin support
|
||||
isRawModeSupported?: boolean
|
||||
// Robot position (calculated before Ink mounts)
|
||||
robotTopRow?: number
|
||||
}
|
||||
|
||||
export const App: React.FC<AppProps> = ({
|
||||
view: initialView,
|
||||
taskId,
|
||||
verbose = false,
|
||||
jsonOutput = false,
|
||||
controller,
|
||||
onComplete,
|
||||
onError,
|
||||
@@ -126,6 +136,8 @@ export const App: React.FC<AppProps> = ({
|
||||
authQuickSetup,
|
||||
onWelcomeSubmit,
|
||||
onWelcomeExit,
|
||||
isRawModeSupported = true,
|
||||
robotTopRow,
|
||||
}) => {
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView)
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
|
||||
@@ -140,7 +152,7 @@ export const App: React.FC<AppProps> = ({
|
||||
}, [])
|
||||
|
||||
// Handle welcome submit when navigating internally (e.g., from auth -> welcome)
|
||||
const handleInternalWelcomeSubmit = useCallback(
|
||||
const _handleInternalWelcomeSubmit = useCallback(
|
||||
async (prompt: string, imagePaths: string[]) => {
|
||||
if (onWelcomeSubmit) {
|
||||
// If external handler provided, use it
|
||||
@@ -179,7 +191,11 @@ export const App: React.FC<AppProps> = ({
|
||||
case "task":
|
||||
content = (
|
||||
<TaskContextProvider controller={controller}>
|
||||
<TaskView onComplete={onComplete} onError={onError} taskId={selectedTaskId} verbose={verbose} />
|
||||
{jsonOutput ? (
|
||||
<TaskJsonView onComplete={onComplete} onError={onError} taskId={selectedTaskId} verbose={verbose} />
|
||||
) : (
|
||||
<TaskView onComplete={onComplete} onError={onError} taskId={selectedTaskId} verbose={verbose} />
|
||||
)}
|
||||
</TaskContextProvider>
|
||||
)
|
||||
break
|
||||
@@ -237,12 +253,26 @@ export const App: React.FC<AppProps> = ({
|
||||
break
|
||||
|
||||
case "welcome":
|
||||
content = <WelcomeView controller={controller} onExit={onWelcomeExit} onSubmit={handleInternalWelcomeSubmit} />
|
||||
content = (
|
||||
<TaskContextProvider controller={controller}>
|
||||
<ChatView
|
||||
controller={controller}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
onExit={onWelcomeExit}
|
||||
robotTopRow={robotTopRow}
|
||||
/>
|
||||
</TaskContextProvider>
|
||||
)
|
||||
break
|
||||
|
||||
default:
|
||||
content = null
|
||||
}
|
||||
|
||||
return <Box>{content}</Box>
|
||||
return (
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<Box>{content}</Box>
|
||||
</StdinProvider>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
import type { ClineAsk } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTaskController } from "../context/TaskContext"
|
||||
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
@@ -60,7 +61,7 @@ function getPromptType(ask: ClineAsk, text: string): PromptType {
|
||||
|
||||
export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
const { exit } = useApp()
|
||||
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const controller = useTaskController()
|
||||
const lastAskMessage = useLastCompletedAskMessage()
|
||||
const [textInput, setTextInput] = useState("")
|
||||
@@ -195,7 +196,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: !!lastAskMessage && !responded },
|
||||
{ isActive: isRawModeSupported && !!lastAskMessage && !responded },
|
||||
)
|
||||
|
||||
if (!lastAskMessage || responded) {
|
||||
|
||||
@@ -7,9 +7,10 @@ import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { API_PROVIDERS_LIST } from "@/shared/api"
|
||||
import { API_PROVIDERS_LIST, openRouterDefaultModelId } from "@/shared/api"
|
||||
import { ProviderToApiKeyMap } from "@/shared/storage"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import { ProviderToApiKeyMap } from "../utils/provider-map"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { LoadingSpinner } from "./Spinner"
|
||||
|
||||
type AuthStep = "menu" | "provider" | "apikey" | "modelid" | "baseurl" | "saving" | "success" | "error" | "cline_auth"
|
||||
@@ -58,17 +59,21 @@ const Select: React.FC<{
|
||||
onSelect: (value: string) => void
|
||||
label?: string
|
||||
}> = ({ items, onSelect, label }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
onSelect(items[selectedIndex].value)
|
||||
}
|
||||
})
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
onSelect(items[selectedIndex].value)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -103,15 +108,20 @@ const TextInput: React.FC<{
|
||||
placeholder?: string
|
||||
isPassword?: boolean
|
||||
}> = ({ value, onChange, onSubmit, label, placeholder, isPassword }) => {
|
||||
useInput((input, key) => {
|
||||
if (key.return) {
|
||||
onSubmit(value)
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
})
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.return) {
|
||||
onSubmit(value)
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const displayValue = isPassword ? "•".repeat(value.length) : value
|
||||
|
||||
@@ -218,18 +228,19 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
if (authState.user && authState.user.email) {
|
||||
// Auth succeeded - save configuration and transition to success
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const modelIdKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: "cline",
|
||||
planModeApiProvider: "cline",
|
||||
actModeApiModelId: "anthropic/claude-sonnet-4.5",
|
||||
planModeApiModelId: "anthropic/claude-sonnet-4.5",
|
||||
apiProvider: "cline",
|
||||
[providerKey]: "cline",
|
||||
[modelIdKey]: openRouterDefaultModelId,
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
stateManager.flushPendingState()
|
||||
|
||||
setSelectedProvider("cline")
|
||||
setModelId("anthropic/claude-sonnet-4.5")
|
||||
setModelId(config[modelIdKey])
|
||||
setStep("success")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Claude Code style chat message component
|
||||
* Renders messages with:
|
||||
* - ❯ for user messages
|
||||
* - ⏺ for assistant messages and tool calls
|
||||
* - ⎿ for tool results (indented)
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
|
||||
/**
|
||||
* Render inline markdown: **bold**, *italic*, `code`
|
||||
* Returns array of React nodes with appropriate styling
|
||||
*/
|
||||
function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = []
|
||||
// Match **bold**, *italic*, or `code` - order matters (** before *)
|
||||
const regex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g
|
||||
let lastIndex = 0
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Add text before match
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index))
|
||||
}
|
||||
|
||||
const fullMatch = match[0]
|
||||
const key = `md-${match.index}`
|
||||
|
||||
if (fullMatch.startsWith("**") && fullMatch.endsWith("**")) {
|
||||
// Bold
|
||||
nodes.push(
|
||||
<Text bold key={key}>
|
||||
{fullMatch.slice(2, -2)}
|
||||
</Text>,
|
||||
)
|
||||
} else if (fullMatch.startsWith("*") && fullMatch.endsWith("*")) {
|
||||
// Italic
|
||||
nodes.push(
|
||||
<Text italic key={key}>
|
||||
{fullMatch.slice(1, -1)}
|
||||
</Text>,
|
||||
)
|
||||
} else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) {
|
||||
// Inline code
|
||||
nodes.push(
|
||||
<Text dimColor key={key}>
|
||||
{fullMatch.slice(1, -1)}
|
||||
</Text>,
|
||||
)
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex))
|
||||
}
|
||||
|
||||
return nodes.length > 0 ? nodes : [text]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render text with inline markdown support
|
||||
*/
|
||||
const MarkdownText: React.FC<{ children: string; color?: string }> = ({ children, color }) => {
|
||||
const nodes = renderInlineMarkdown(children)
|
||||
return <Text color={color}>{nodes}</Text>
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ClineMessage
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
// Tool name display mapping - convert internal names to user-friendly names
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
read_file: "Read",
|
||||
write_to_file: "Write",
|
||||
replace_in_file: "Edit",
|
||||
execute_command: "Bash",
|
||||
search_files: "Search",
|
||||
list_files: "List",
|
||||
list_code_definition_names: "Definitions",
|
||||
browser_action: "Browser",
|
||||
use_mcp_tool: "MCP",
|
||||
access_mcp_resource: "MCP Resource",
|
||||
ask_followup_question: "Question",
|
||||
attempt_completion: "Complete",
|
||||
web_fetch: "Fetch",
|
||||
web_search: "WebSearch",
|
||||
new_task: "Task",
|
||||
focus_chain: "Todo",
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool call in Claude Code style
|
||||
* e.g., Read(src/components/App.tsx)
|
||||
*/
|
||||
function formatToolCall(toolName: string, args: Record<string, any>): string {
|
||||
const displayName = TOOL_DISPLAY_NAMES[toolName] || toolName
|
||||
|
||||
// Extract the most relevant argument for display
|
||||
let mainArg = ""
|
||||
if (args.path) {
|
||||
mainArg = args.path
|
||||
} else if (args.file_path) {
|
||||
mainArg = args.file_path
|
||||
} else if (args.command) {
|
||||
mainArg = args.command.length > 50 ? args.command.substring(0, 47) + "..." : args.command
|
||||
} else if (args.regex) {
|
||||
mainArg = `pattern: "${args.regex}"`
|
||||
if (args.file_pattern) {
|
||||
mainArg += `, path: "${args.file_pattern}"`
|
||||
}
|
||||
} else if (args.url) {
|
||||
mainArg = args.url
|
||||
} else if (args.query) {
|
||||
mainArg = args.query
|
||||
} else if (args.question) {
|
||||
mainArg = args.question.length > 40 ? args.question.substring(0, 37) + "..." : args.question
|
||||
} else if (args.result) {
|
||||
mainArg = args.result.length > 40 ? args.result.substring(0, 37) + "..." : args.result
|
||||
}
|
||||
|
||||
return mainArg ? `${displayName}(${mainArg})` : displayName
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool message to extract tool info
|
||||
*/
|
||||
function parseToolMessage(text: string): { toolName: string; args: Record<string, any>; result?: string } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (parsed.tool) {
|
||||
return {
|
||||
toolName: parsed.tool,
|
||||
args: parsed,
|
||||
result: parsed.content || parsed.output,
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result for display
|
||||
*/
|
||||
function formatToolResult(result: string, maxLines: number = 5): string[] {
|
||||
const lines = result.split("\n")
|
||||
if (lines.length <= maxLines) {
|
||||
return lines
|
||||
}
|
||||
const displayLines = lines.slice(0, maxLines)
|
||||
displayLines.push(`... ${lines.length - maxLines} more lines`)
|
||||
return displayLines
|
||||
}
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, isStreaming }) => {
|
||||
const { type, ask, say, text } = message
|
||||
|
||||
// User messages (task, user_feedback) - bubble style with background and >
|
||||
if (say === "task" || say === "user_feedback") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box backgroundColor="gray" paddingLeft={1} paddingRight={1} width="100%">
|
||||
<Text color="white">> {text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Assistant text response
|
||||
if (say === "text") {
|
||||
if (!text?.trim()) return null
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text>⏺ </Text>
|
||||
<MarkdownText>{text}</MarkdownText>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Reasoning/thinking
|
||||
if (say === "reasoning") {
|
||||
if (!text?.trim()) return null
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="yellow" italic>
|
||||
⏺ {truncate(text, 200)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Tool calls (ask for permission)
|
||||
if (type === "ask" && ask === "tool" && text) {
|
||||
const toolInfo = parseToolMessage(text)
|
||||
if (toolInfo) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="blue">⏺ {formatToolCall(toolInfo.toolName, toolInfo.args)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tool results (say tool)
|
||||
if (say === "tool" && text) {
|
||||
const toolInfo = parseToolMessage(text)
|
||||
if (toolInfo) {
|
||||
const hasResult = toolInfo.result && toolInfo.result.trim()
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="green">⏺ {formatToolCall(toolInfo.toolName, toolInfo.args)}</Text>
|
||||
</Box>
|
||||
{hasResult && (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{formatToolResult(toolInfo.result!, 5).map((line, idx) => (
|
||||
<Box key={idx}>
|
||||
<Text dimColor>
|
||||
{idx === 0 ? "⎿ " : " "}
|
||||
{line}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
// Fallback for unparseable tool messages
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="green">⏺ {truncate(text, 100)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Command execution ask
|
||||
if (type === "ask" && ask === "command" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text>⏺ Bash({truncate(text, 60)})</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Command say (before execution)
|
||||
if (say === "command" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text>⏺ Bash({truncate(text, 60)})</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Command output
|
||||
if (say === "command_output" && text) {
|
||||
const lines = formatToolResult(text, 8)
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{lines.map((line, idx) => (
|
||||
<Box key={idx}>
|
||||
<Text dimColor>
|
||||
{idx === 0 ? "⎿ " : " "}
|
||||
{line}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Error messages
|
||||
if (say === "error") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="red">⏺ Error: {text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Completion result
|
||||
if (say === "completion_result" || (type === "ask" && ask === "completion_result")) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="green">⏺ Task completed</Text>
|
||||
</Box>
|
||||
{text && (
|
||||
<Box marginLeft={2}>
|
||||
<MarkdownText color="green">{text}</MarkdownText>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// API request info (show cost/tokens inline)
|
||||
if (say === "api_req_started" && text) {
|
||||
// Skip showing these - they're summarized in the status bar
|
||||
return null
|
||||
}
|
||||
|
||||
// Browser actions
|
||||
if (say === "browser_action" || say === "browser_action_launch") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text>⏺ Browser({truncate(text || "", 50)})</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// MCP server
|
||||
if (say === "mcp_server_request_started") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text>⏺ MCP({truncate(text || "", 50)})</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Info messages
|
||||
if (say === "info") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="gray">⏺ {text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Followup questions from assistant
|
||||
if (type === "ask" && ask === "followup" && text) {
|
||||
const parsed = jsonParseSafe(text, { question: undefined as string | undefined })
|
||||
if (parsed.question) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text>⏺ </Text>
|
||||
<MarkdownText>{parsed.question}</MarkdownText>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Plan mode response
|
||||
if (type === "ask" && ask === "plan_mode_respond" && text) {
|
||||
const parsed = jsonParseSafe(text, { response: undefined as string | undefined })
|
||||
if (parsed.response) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="yellow">⏺ </Text>
|
||||
<MarkdownText color="yellow">{parsed.response}</MarkdownText>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip other message types
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a list of messages in Claude Code style
|
||||
*/
|
||||
interface ChatMessageListProps {
|
||||
messages: ClineMessage[]
|
||||
maxMessages?: number
|
||||
}
|
||||
|
||||
export const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, maxMessages }) => {
|
||||
// Filter out messages we don't want to display
|
||||
const displayMessages = messages.filter((m) => {
|
||||
// Skip api_req_finished, they're just markers
|
||||
if (m.say === "api_req_finished") return false
|
||||
// Skip empty text messages
|
||||
if (m.say === "text" && !m.text?.trim()) return false
|
||||
// Skip checkpoint messages
|
||||
if (m.say === "checkpoint_created") return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Optionally limit number of messages shown
|
||||
const messagesToShow = maxMessages ? displayMessages.slice(-maxMessages) : displayMessages
|
||||
|
||||
// Check if last message is streaming
|
||||
const lastMessage = messagesToShow[messagesToShow.length - 1]
|
||||
const isLastStreaming = lastMessage?.partial === true
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{messagesToShow.map((msg, idx) => (
|
||||
<ChatMessage isStreaming={idx === messagesToShow.length - 1 && isLastStreaming} key={msg.ts} message={msg} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
/**
|
||||
* Unified Chat View component
|
||||
* Combines the welcome screen layout with task message display
|
||||
* Messages appear above the input field, input stays at bottom
|
||||
*
|
||||
* IMPORTANT: Ink Rendering and the "Flicker Problem"
|
||||
* ==================================================
|
||||
*
|
||||
* Problem:
|
||||
* When using Ink (React for CLI), we encountered severe flickering once the
|
||||
* output grew to fill the terminal height. The top line would repeat endlessly,
|
||||
* making the UI unusable.
|
||||
*
|
||||
* Root Cause:
|
||||
* Ink uses two different rendering strategies based on output height:
|
||||
*
|
||||
* 1. When outputHeight < terminal rows: Ink uses efficient line-erasing
|
||||
* (ansiEscapes.eraseLines) to update content in place. This is smooth.
|
||||
*
|
||||
* 2. When outputHeight >= terminal rows: Ink switches to clearTerminal() +
|
||||
* full redraw on EVERY re-render. This causes visible flicker because:
|
||||
* - The entire screen clears
|
||||
* - Content redraws from scratch
|
||||
* - This happens on every state change (typing, streaming, spinner, etc.)
|
||||
*
|
||||
* This is in Ink's onRender() method in ink.js:
|
||||
* if (outputHeight >= this.options.stdout.rows) {
|
||||
* this.options.stdout.write(ansiEscapes.clearTerminal + output);
|
||||
* }
|
||||
*
|
||||
* The underlying issue is Ink's use of ansi-escapes clearTerminal which does
|
||||
* ^[2J^[3J^[H (erase screen + erase scrollback + move home) instead of more
|
||||
* efficient alternatives like ^[H^[0J (move home + erase to bottom).
|
||||
*
|
||||
* See: https://github.com/vadimdemedes/ink/issues/359
|
||||
*
|
||||
* Why Other CLIs (Claude Code, Gemini CLI) Don't Flicker:
|
||||
* =======================================================
|
||||
*
|
||||
* They keep the dynamic region small by either:
|
||||
* - Printing history as static output (logs that scroll up)
|
||||
* - Using alternate buffer with internal scrolling
|
||||
* - Never letting dynamic content reach terminal height
|
||||
*
|
||||
* Alternate Buffer Mode (What We Tried):
|
||||
* Gemini CLI uses alternate screen buffer (\x1b[?1049h) which isolates the app
|
||||
* in a separate screen (like vim/less). This eliminates flicker but has a major
|
||||
* drawback: scroll wheel doesn't work without implementing custom mouse event
|
||||
* handling. Gemini CLI built MouseProvider + ScrollProvider infrastructure to
|
||||
* capture mouse events and handle scrolling manually.
|
||||
*
|
||||
* From Gemini CLI PR #13623:
|
||||
* "We're setting the default for useAlternateBuffer back to false for now.
|
||||
* We'll plan to re-enable once we support selection without ctrl-S, speed up
|
||||
* scrolling on terminals that report fewer scroll events than ideal, and
|
||||
* optimize performance issues related to very large tool messages."
|
||||
*
|
||||
* Our Solution: Static + Dynamic Split
|
||||
* ====================================
|
||||
*
|
||||
* We use Ink's <Static> component to split content into two regions:
|
||||
*
|
||||
* 1. Static Region (header + completed messages):
|
||||
* - Rendered ONCE when items are added
|
||||
* - Stays above the dynamic region
|
||||
* - Scrolls up like terminal logs
|
||||
* - Never re-renders, so no flicker contribution
|
||||
* - IMPORTANT: Content in Static cannot update after initial render
|
||||
* (e.g., AccountInfoView showing "Loading..." would stay that way forever)
|
||||
*
|
||||
* 2. Dynamic Region (current streaming message + input + status):
|
||||
* - Only contains actively changing content
|
||||
* - Stays small (well under terminal height)
|
||||
* - Uses efficient line-erasing, not clearTerminal
|
||||
*
|
||||
* Implementation Details:
|
||||
* - loggedMessageTs: Set<number> tracks which messages have been rendered to Static
|
||||
* - headerLogged: boolean tracks if the header has been rendered
|
||||
* - staticItems: array of items to render in Static (header + new completed messages)
|
||||
* - completedMessages: messages that are done (not partial/streaming)
|
||||
* - currentMessage: the single message currently streaming (if any)
|
||||
*
|
||||
* When a message completes (partial becomes false), it moves from the dynamic
|
||||
* region to Static. The dynamic region only ever contains 0-1 messages plus
|
||||
* the input UI, keeping it well under terminal height.
|
||||
*
|
||||
* Other Important Settings:
|
||||
* - patchConsole: false in render() options - prevents Ink from interfering with console
|
||||
* - Console suppression in utils/console.ts - prevents core debug output from breaking Ink
|
||||
*
|
||||
* Centering in Static:
|
||||
* Ink's Box centering (justifyContent, alignItems) doesn't work reliably inside
|
||||
* Static. We use manual centering via centerText() which pads strings based on
|
||||
* process.stdout.columns.
|
||||
*
|
||||
* References:
|
||||
* - Ink flicker issue: https://github.com/vadimdemedes/ink/issues/359
|
||||
* - Gemini CLI (uses same @jrichman/ink fork): https://github.com/google-gemini/gemini-cli
|
||||
* - Gemini CLI alternate buffer PR: https://github.com/google-gemini/gemini-cli/pull/13623
|
||||
* - Ink source: node_modules/ink/build/ink.js (onRender method)
|
||||
* - log-update: node_modules/ink/build/log-update.js (eraseLines logic)
|
||||
*/
|
||||
|
||||
import type { ClineAsk } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { execSync } from "child_process"
|
||||
import { Box, Static, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { useTaskContext, useTaskState } from "../context/TaskContext"
|
||||
import { useIsSpinnerActive } from "../hooks/useStateSubscriber"
|
||||
import {
|
||||
checkAndWarnRipgrepMissing,
|
||||
extractMentionQuery,
|
||||
type FileSearchResult,
|
||||
getRipgrepInstallInstructions,
|
||||
insertMention,
|
||||
searchWorkspaceFiles,
|
||||
} from "../utils/file-search"
|
||||
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
|
||||
import { AsciiMotionCli, StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { ChatMessage } from "./ChatMessage"
|
||||
import { FileMentionMenu } from "./FileMentionMenu"
|
||||
import { ThinkingIndicator } from "./ThinkingIndicator"
|
||||
|
||||
interface ChatViewProps {
|
||||
controller?: any
|
||||
onExit?: () => void
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
robotTopRow?: number
|
||||
}
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 150
|
||||
const RIPGREP_WARNING_DURATION_MS = 5000
|
||||
const MAX_SEARCH_RESULTS = 15
|
||||
const DEFAULT_CONTEXT_WINDOW = 200000
|
||||
|
||||
/**
|
||||
* Get current git branch name
|
||||
*/
|
||||
function getGitBranch(cwd?: string): string | null {
|
||||
try {
|
||||
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: cwd || process.cwd(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
return branch
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a progress bar for context window usage
|
||||
* Returns { filled, empty } strings to allow different coloring
|
||||
*/
|
||||
function createContextBar(used: number, total: number, width: number = 8): { filled: string; empty: string } {
|
||||
const ratio = Math.min(used / total, 1)
|
||||
// Use ceil so any usage > 0 shows at least one bar
|
||||
const filledCount = used > 0 ? Math.max(1, Math.ceil(ratio * width)) : 0
|
||||
const emptyCount = width - filledCount
|
||||
return { filled: "█".repeat(filledCount), empty: "█".repeat(emptyCount) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Center text by padding with spaces
|
||||
*/
|
||||
function centerText(text: string, terminalWidth?: number): string {
|
||||
const width = terminalWidth || process.stdout.columns || 80
|
||||
const padding = Math.max(0, Math.floor((width - text.length) / 2))
|
||||
return " ".repeat(padding) + text
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of prompt needed for an ask message
|
||||
*/
|
||||
function getAskPromptType(ask: ClineAsk, text: string): "confirmation" | "text" | "options" | "none" {
|
||||
switch (ask) {
|
||||
case "followup":
|
||||
case "plan_mode_respond": {
|
||||
const parts = jsonParseSafe(text, { options: undefined as string[] | undefined })
|
||||
if (parts.options && parts.options.length > 0) {
|
||||
return "options"
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
case "completion_result":
|
||||
return "text"
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
case "command":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
return "confirmation"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse options from an ask message
|
||||
*/
|
||||
function parseAskOptions(text: string): string[] {
|
||||
const parts = jsonParseSafe(text, { options: [] as string[] })
|
||||
return parts.options || []
|
||||
}
|
||||
|
||||
export const ChatView: React.FC<ChatViewProps> = ({ controller, onExit, onComplete: _onComplete, onError, robotTopRow }) => {
|
||||
// Get task state from context
|
||||
const taskState = useTaskState()
|
||||
const { controller: taskController } = useTaskContext()
|
||||
const { isActive: isSpinnerActive, startTime: spinnerStartTime } = useIsSpinnerActive()
|
||||
|
||||
// Input state
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showRipgrepWarning, setShowRipgrepWarning] = useState(false)
|
||||
const [escPressedOnce, setEscPressedOnce] = useState(false)
|
||||
const [respondedToAsk, setRespondedToAsk] = useState<number | null>(null)
|
||||
|
||||
// Track which messages have been rendered to Static (by timestamp)
|
||||
// Using refs instead of state to avoid extra renders during streaming->static transition
|
||||
const loggedMessageTsRef = useRef<Set<number>>(new Set())
|
||||
const headerLoggedRef = useRef(false)
|
||||
const [gitBranch, setGitBranch] = useState<string | null>(null)
|
||||
|
||||
// Mode state
|
||||
const [mode, setMode] = useState<Mode>(() => {
|
||||
const stateManager = StateManager.get()
|
||||
return stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
})
|
||||
|
||||
const yolo = useMemo(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled"), [])
|
||||
|
||||
// Get model ID based on current mode
|
||||
const modelId = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
return (stateManager.getGlobalSettingsKey(modelKey) as string) || "claude-sonnet-4-20250514"
|
||||
}, [mode])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const newMode: Mode = mode === "act" ? "plan" : "act"
|
||||
setMode(newMode)
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("mode", newMode)
|
||||
}, [mode])
|
||||
|
||||
const refs = useRef({
|
||||
searchTimeout: null as NodeJS.Timeout | null,
|
||||
lastQuery: "",
|
||||
hasCheckedRipgrep: false,
|
||||
})
|
||||
|
||||
const { prompt, imagePaths } = parseImagesFromInput(textInput)
|
||||
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
|
||||
|
||||
const workspacePath = useMemo(() => {
|
||||
try {
|
||||
const ctrl = controller || taskController
|
||||
const root = ctrl?.getWorkspaceManagerSync?.()?.getPrimaryRoot?.()
|
||||
if (root?.path) {
|
||||
return root.path
|
||||
}
|
||||
} catch {
|
||||
// Fallback to cwd
|
||||
}
|
||||
return process.cwd()
|
||||
}, [controller, taskController])
|
||||
|
||||
// Get git branch on mount
|
||||
useEffect(() => {
|
||||
setGitBranch(getGitBranch(workspacePath))
|
||||
}, [workspacePath])
|
||||
|
||||
const messages = taskState.clineMessages || []
|
||||
|
||||
// Filter messages we want to display
|
||||
const displayMessages = useMemo(() => {
|
||||
return messages.filter((m) => {
|
||||
if (m.say === "api_req_finished") return false
|
||||
if (m.say === "text" && !m.text?.trim()) return false
|
||||
if (m.say === "checkpoint_created") return false
|
||||
if (m.say === "api_req_started") return false
|
||||
return true
|
||||
})
|
||||
}, [messages])
|
||||
|
||||
// Split messages into completed (for Static) and current (for dynamic region)
|
||||
const { completedMessages, currentMessage } = useMemo(() => {
|
||||
const completed: typeof displayMessages = []
|
||||
let current: (typeof displayMessages)[0] | null = null
|
||||
|
||||
// Tool types that should skip dynamic rendering entirely.
|
||||
// Plan and completion text tend to be very long, and because this typically
|
||||
// exceeds the height of the user's terminal, it causes flashing issues
|
||||
// (Ink uses clearTerminal when output >= terminal rows).
|
||||
// These messages wait until complete before showing directly in static.
|
||||
const skipDynamicTypes = new Set(["completion_result", "plan_mode_respond"])
|
||||
|
||||
for (let i = 0; i < displayMessages.length; i++) {
|
||||
const msg = displayMessages[i]
|
||||
const isLast = i === displayMessages.length - 1
|
||||
|
||||
// Check if this message type should skip dynamic rendering
|
||||
const shouldSkipDynamic =
|
||||
skipDynamicTypes.has(msg.say || "") || (msg.type === "ask" && skipDynamicTypes.has(msg.ask || ""))
|
||||
|
||||
if (msg.partial) {
|
||||
// Message is still streaming
|
||||
if (isLast && !shouldSkipDynamic) {
|
||||
// Show in dynamic region (normal streaming)
|
||||
current = msg
|
||||
}
|
||||
// If shouldSkipDynamic and partial: don't show anywhere, wait for complete
|
||||
} else {
|
||||
// Message is complete, add to static
|
||||
completed.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return { completedMessages: completed, currentMessage: current }
|
||||
}, [displayMessages])
|
||||
|
||||
// Determine if we're in welcome state (no messages yet)
|
||||
const isWelcomeState = displayMessages.length === 0
|
||||
|
||||
// Build Static items - each item is rendered once and stays above dynamic content
|
||||
const staticItems = useMemo(() => {
|
||||
const items: Array<{ type: "header" } | { type: "message"; message: (typeof displayMessages)[0] }> = []
|
||||
|
||||
// Add header as first item ONLY after messages start (so animated robot shows first)
|
||||
// Once messages exist, add header to static so it scrolls up with history
|
||||
if (!headerLoggedRef.current && displayMessages.length > 0) {
|
||||
items.push({ type: "header" })
|
||||
headerLoggedRef.current = true
|
||||
}
|
||||
|
||||
// Add completed messages that haven't been logged yet
|
||||
for (const msg of completedMessages) {
|
||||
if (!loggedMessageTsRef.current.has(msg.ts)) {
|
||||
items.push({ type: "message", message: msg })
|
||||
loggedMessageTsRef.current.add(msg.ts)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}, [completedMessages, displayMessages.length])
|
||||
|
||||
// Check for pending ask message
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const pendingAsk =
|
||||
lastMessage?.type === "ask" && !lastMessage.partial && respondedToAsk !== lastMessage.ts ? lastMessage : null
|
||||
const askType = pendingAsk ? getAskPromptType(pendingAsk.ask as ClineAsk, pendingAsk.text || "") : "none"
|
||||
const askOptions = pendingAsk && askType === "options" ? parseAskOptions(pendingAsk.text || "") : []
|
||||
|
||||
// Send response to ask message
|
||||
const sendAskResponse = useCallback(
|
||||
async (responseType: string, text?: string) => {
|
||||
const ctrl = controller || taskController
|
||||
if (!ctrl?.task || !pendingAsk) return
|
||||
|
||||
setRespondedToAsk(pendingAsk.ts)
|
||||
setTextInput("")
|
||||
|
||||
try {
|
||||
await ctrl.task.handleWebviewAskResponse(responseType, text)
|
||||
} catch {
|
||||
// Controller may be disposed
|
||||
}
|
||||
},
|
||||
[controller, taskController, pendingAsk],
|
||||
)
|
||||
|
||||
// Handle task submission (new task)
|
||||
const handleSubmit = useCallback(
|
||||
async (text: string, images: string[]) => {
|
||||
const ctrl = controller || taskController
|
||||
if (!ctrl || !text.trim()) return
|
||||
|
||||
setTextInput("")
|
||||
|
||||
try {
|
||||
// Convert image paths to data URLs if needed
|
||||
const imageDataUrls =
|
||||
images.length > 0
|
||||
? await Promise.all(
|
||||
images.map(async (p) => {
|
||||
try {
|
||||
const fs = await import("fs/promises")
|
||||
const path = await import("path")
|
||||
const data = await fs.readFile(p)
|
||||
const ext = path.extname(p).toLowerCase().slice(1)
|
||||
const mimeType = ext === "jpg" ? "jpeg" : ext
|
||||
return `data:image/${mimeType};base64,${data.toString("base64")}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
: []
|
||||
const validImages = imageDataUrls.filter((img): img is string => img !== null)
|
||||
await ctrl.initTask(text.trim(), validImages.length > 0 ? validImages : undefined)
|
||||
} catch (_error) {
|
||||
onError?.()
|
||||
}
|
||||
},
|
||||
[controller, taskController, onError],
|
||||
)
|
||||
|
||||
// Search for files when in mention mode
|
||||
useEffect(() => {
|
||||
const { current: r } = refs
|
||||
|
||||
if (!mentionInfo.inMentionMode) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
r.searchTimeout = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for ripgrep on first mention trigger
|
||||
if (!r.hasCheckedRipgrep) {
|
||||
r.hasCheckedRipgrep = true
|
||||
if (checkAndWarnRipgrepMissing()) {
|
||||
setShowRipgrepWarning(true)
|
||||
setTimeout(() => setShowRipgrepWarning(false), RIPGREP_WARNING_DURATION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const { query } = mentionInfo
|
||||
if (query === r.lastQuery) {
|
||||
return
|
||||
}
|
||||
r.lastQuery = query
|
||||
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
}
|
||||
setIsSearching(true)
|
||||
|
||||
r.searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const results = await searchWorkspaceFiles(query, workspacePath, MAX_SEARCH_RESULTS)
|
||||
setFileResults(results)
|
||||
setSelectedIndex(0)
|
||||
} catch {
|
||||
setFileResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
}
|
||||
}
|
||||
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
// Filter out mouse escape sequences from AsciiMotionCli's mouse tracking
|
||||
// Mouse events look like: [<35;46;17M or contain escape characters
|
||||
if (input.includes("\x1b") || input.includes("[<") || /\d+;\d+[Mm]/.test(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
|
||||
|
||||
// Menu navigation
|
||||
if (inMenu) {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
if (key.tab || key.return) {
|
||||
const file = fileResults[selectedIndex]
|
||||
if (file) {
|
||||
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ask responses
|
||||
if (pendingAsk && !yolo) {
|
||||
if (askType === "confirmation") {
|
||||
// y/n confirmation
|
||||
if (input.toLowerCase() === "y") {
|
||||
sendAskResponse("yesButtonClicked")
|
||||
return
|
||||
} else if (input.toLowerCase() === "n") {
|
||||
if (pendingAsk.ask === "resume_task" || pendingAsk.ask === "resume_completed_task") {
|
||||
onExit?.()
|
||||
return
|
||||
}
|
||||
sendAskResponse("noButtonClicked")
|
||||
return
|
||||
}
|
||||
} else if (askType === "options") {
|
||||
// Number selection for options, or free text
|
||||
if (key.return && textInput.trim()) {
|
||||
sendAskResponse("messageResponse", textInput.trim())
|
||||
return
|
||||
}
|
||||
// Check if it's a number for option selection (only when no text typed yet)
|
||||
const num = parseInt(input, 10)
|
||||
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= askOptions.length) {
|
||||
const selectedOption = askOptions[num - 1]
|
||||
sendAskResponse("optionSelected", selectedOption)
|
||||
return
|
||||
}
|
||||
} else if (askType === "text") {
|
||||
// Text input mode
|
||||
if (key.return && textInput.trim()) {
|
||||
sendAskResponse("messageResponse", textInput.trim())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
if (key.tab && !mentionInfo.inMentionMode) {
|
||||
toggleMode()
|
||||
return
|
||||
}
|
||||
if (key.return && !mentionInfo.inMentionMode && !pendingAsk) {
|
||||
if (prompt.trim() || imagePaths.length > 0) {
|
||||
handleSubmit(prompt.trim(), imagePaths)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape && !mentionInfo.inMentionMode) {
|
||||
if (escPressedOnce) {
|
||||
onExit?.()
|
||||
} else {
|
||||
setEscPressedOnce(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
setEscPressedOnce(false)
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
setTextInput((prev) => prev + input)
|
||||
setEscPressedOnce(false)
|
||||
}
|
||||
})
|
||||
|
||||
const borderColor = mode === "act" ? "blueBright" : "yellow"
|
||||
const metrics = getApiMetrics(messages)
|
||||
|
||||
// Determine input placeholder/prompt text
|
||||
let inputPrompt = ""
|
||||
if (pendingAsk && !yolo) {
|
||||
if (askType === "confirmation") {
|
||||
inputPrompt = "(y/n)"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Static content - rendered once, stays above dynamic region */}
|
||||
<Static items={staticItems}>
|
||||
{(item) => {
|
||||
if (item.type === "header") {
|
||||
// Show static robot frame in header (first frame, looking straight ahead)
|
||||
return (
|
||||
<Box flexDirection="column" key="header">
|
||||
<StaticRobotFrame />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{centerText("What can I do for you?")}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Completed message
|
||||
return <ChatMessage key={item.message.ts} message={item.message} />
|
||||
}}
|
||||
</Static>
|
||||
|
||||
{/* Dynamic region - only current streaming message + input */}
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Animated robot and welcome text - only shown before messages start */}
|
||||
{isWelcomeState && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<AsciiMotionCli robotTopRow={robotTopRow} />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{centerText("What can I do for you?")}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Current streaming message */}
|
||||
{currentMessage && <ChatMessage isStreaming message={currentMessage} />}
|
||||
|
||||
{/* Ripgrep warning if needed */}
|
||||
{showRipgrepWarning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">Warning: ripgrep not found - file search will be slower. </Text>
|
||||
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Options list for ask prompts */}
|
||||
{pendingAsk && askType === "options" && askOptions.length > 0 && !yolo && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{askOptions.map((opt, idx) => (
|
||||
<Text color="gray" key={idx}>
|
||||
{idx + 1}. {opt}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Thinking indicator when processing */}
|
||||
{isSpinnerActive && !pendingAsk && (
|
||||
<Box marginBottom={1}>
|
||||
<ThinkingIndicator mode={mode} startTime={spinnerStartTime} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Input field with border - ALWAYS shown */}
|
||||
<Box
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width="100%">
|
||||
<Box>
|
||||
{inputPrompt && <Text color="yellow">{inputPrompt} </Text>}
|
||||
<Text>{textInput}</Text>
|
||||
<Text color="gray">▌</Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
↵ send
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* File mention menu - below input */}
|
||||
{mentionInfo.inMentionMode && (
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
<FileMentionMenu
|
||||
isLoading={isSearching}
|
||||
query={mentionInfo.query}
|
||||
results={fileResults}
|
||||
selectedIndex={selectedIndex}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Attached images */}
|
||||
{imagePaths.length > 0 && (
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
<Text color="magenta">
|
||||
{imagePaths.length} image{imagePaths.length > 1 ? "s" : ""} attached
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Row 1: Instructions (left, can wrap) | Plan/Act toggle (right, no wrap) */}
|
||||
<Box justifyContent="space-between" paddingLeft={1} paddingRight={1} width="100%">
|
||||
<Box flexShrink={1} flexWrap="wrap">
|
||||
<Text color="gray" dimColor>
|
||||
@ for files · / for commands ·{" "}
|
||||
</Text>
|
||||
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"} dimColor={!escPressedOnce}>
|
||||
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0} gap={1}>
|
||||
<Box>
|
||||
<Text bold={mode === "plan"} color={mode === "plan" ? "yellow" : "gray"}>
|
||||
{mode === "plan" ? "●" : "○"} Plan
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold={mode === "act"} color={mode === "act" ? "blueBright" : "gray"}>
|
||||
{mode === "act" ? "●" : "○"} Act
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Tab)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 2: Model/context/tokens/cost */}
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
<Text>
|
||||
<Text color="gray">{modelId.length > 20 ? modelId.substring(0, 17) + "..." : modelId}</Text> {(() => {
|
||||
const bar = createContextBar(metrics.totalTokensIn + metrics.totalTokensOut, DEFAULT_CONTEXT_WINDOW)
|
||||
return (
|
||||
<>
|
||||
<Text color="gray">{bar.filled}</Text>
|
||||
<Text color="gray" dimColor>
|
||||
{bar.empty}
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<Text color="gray"> ({(metrics.totalTokensIn + metrics.totalTokensOut).toLocaleString()})</Text>
|
||||
<Text color="gray"> | </Text>
|
||||
<Text color="gray">${metrics.totalCost.toFixed(3)}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Row 3: Repo/branch */}
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">
|
||||
{workspacePath.split("/").pop() || workspacePath}
|
||||
{gitBranch && (
|
||||
<Text color="gray">
|
||||
{" "}
|
||||
(<Text color="gray">{gitBranch}</Text>)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
@@ -78,52 +79,56 @@ const RESTORE_TYPE_OPTIONS: { type: RestoreType; label: string; description: str
|
||||
]
|
||||
|
||||
export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSelect, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const checkpoints = getCheckpointOptions(messages)
|
||||
const [selectedCheckpoint, setSelectedCheckpoint] = useState(0)
|
||||
const [selectedRestoreType, setSelectedRestoreType] = useState(0)
|
||||
const [stage, setStage] = useState<"checkpoint" | "restoreType">("checkpoint")
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
if (stage === "restoreType") {
|
||||
setStage("checkpoint")
|
||||
} else {
|
||||
onCancel()
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
if (stage === "restoreType") {
|
||||
setStage("checkpoint")
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === "checkpoint") {
|
||||
if (key.upArrow) {
|
||||
setSelectedCheckpoint((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
|
||||
} else if (key.return && checkpoints.length > 0) {
|
||||
setStage("restoreType")
|
||||
}
|
||||
} else if (stage === "restoreType") {
|
||||
if (key.upArrow) {
|
||||
setSelectedRestoreType((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
|
||||
} else if (key.return) {
|
||||
const checkpoint = checkpoints[selectedCheckpoint]
|
||||
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
|
||||
if (checkpoint && restoreType) {
|
||||
onSelect(checkpoint.ts, restoreType.type)
|
||||
if (stage === "checkpoint") {
|
||||
if (key.upArrow) {
|
||||
setSelectedCheckpoint((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
|
||||
} else if (key.return && checkpoints.length > 0) {
|
||||
setStage("restoreType")
|
||||
}
|
||||
} else if (stage === "restoreType") {
|
||||
if (key.upArrow) {
|
||||
setSelectedRestoreType((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
|
||||
} else if (key.return) {
|
||||
const checkpoint = checkpoints[selectedCheckpoint]
|
||||
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
|
||||
if (checkpoint && restoreType) {
|
||||
onSelect(checkpoint.ts, restoreType.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick number selection for checkpoints
|
||||
if (stage === "checkpoint") {
|
||||
const num = parseInt(input, 10)
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
|
||||
setSelectedCheckpoint(num - 1)
|
||||
setStage("restoreType")
|
||||
// Quick number selection for checkpoints
|
||||
if (stage === "checkpoint") {
|
||||
const num = parseInt(input, 10)
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
|
||||
setSelectedCheckpoint(num - 1)
|
||||
setStage("restoreType")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
if (checkpoints.length === 0) {
|
||||
return (
|
||||
@@ -200,7 +205,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Text color="gray" dimColor marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
(↑/↓ to select, Enter to confirm, Escape to go back)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@shared/storage/state-keys"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
BooleanSelect,
|
||||
buildConfigEntries,
|
||||
@@ -99,6 +100,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
onOpenFolder,
|
||||
}) => {
|
||||
const { exit } = useApp()
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [currentTab, setCurrentTab] = useState<TabView>("settings")
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
@@ -253,10 +255,10 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// List navigation
|
||||
if (key.upArrow) {
|
||||
// List navigation (arrow keys and vim-style j/k)
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
|
||||
} else if (key.downArrow) {
|
||||
} else if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
|
||||
}
|
||||
|
||||
@@ -289,7 +291,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
onOpenFolder(currentTab as "rules" | "workflows" | "hooks" | "skills", isGlobal)
|
||||
}
|
||||
},
|
||||
{ isActive: !isEditing },
|
||||
{ isActive: isRawModeSupported && !isEditing },
|
||||
)
|
||||
|
||||
// Scrolling window
|
||||
@@ -505,7 +507,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
|
||||
// Help text based on current tab
|
||||
const getHelpText = () => {
|
||||
const base = "↑/↓ Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
|
||||
const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
|
||||
if (currentTab === "settings") {
|
||||
return `${base} • Enter/e Edit • r Reset`
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
// ============================================================================
|
||||
// Types & Constants
|
||||
@@ -192,17 +193,22 @@ interface TextInputProps {
|
||||
}
|
||||
|
||||
export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel, onSubmit, type, value }) => {
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
onSubmit(value)
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
})
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
onSubmit(value)
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
@@ -228,17 +234,21 @@ interface BooleanSelectProps {
|
||||
}
|
||||
|
||||
export const BooleanSelect: React.FC<BooleanSelectProps> = ({ label, onCancel, onSelect, value }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selected, setSelected] = useState(value)
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
onSelect(selected)
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
setSelected((prev) => !prev)
|
||||
}
|
||||
})
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
onSelect(selected)
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
setSelected((prev) => !prev)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import React, { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
|
||||
interface HookInfo {
|
||||
@@ -39,6 +40,7 @@ interface ConfigViewWrapperProps {
|
||||
workspaceState: Record<string, unknown>
|
||||
hooksEnabled: boolean
|
||||
skillsEnabled: boolean
|
||||
isRawModeSupported?: boolean
|
||||
}
|
||||
|
||||
export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
|
||||
@@ -48,6 +50,7 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
|
||||
workspaceState: initialWorkspaceState,
|
||||
hooksEnabled,
|
||||
skillsEnabled,
|
||||
isRawModeSupported = true,
|
||||
}) => {
|
||||
// Settings state (managed locally for UI updates)
|
||||
const [globalStateLocal, setGlobalStateLocal] = useState<Record<string, unknown>>(initialGlobalState)
|
||||
@@ -267,30 +270,32 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<ConfigView
|
||||
dataDir={dataDir}
|
||||
globalClineRulesToggles={globalClineRulesToggles}
|
||||
globalHooks={globalHooks}
|
||||
globalSkills={globalSkills}
|
||||
globalState={globalStateLocal}
|
||||
globalWorkflowToggles={globalWorkflowToggles}
|
||||
hooksEnabled={hooksEnabled}
|
||||
localAgentsRulesToggles={localAgentsRulesToggles}
|
||||
localClineRulesToggles={localClineRulesToggles}
|
||||
localCursorRulesToggles={localCursorRulesToggles}
|
||||
localSkills={localSkills}
|
||||
localWindsurfRulesToggles={localWindsurfRulesToggles}
|
||||
localWorkflowToggles={localWorkflowToggles}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onToggleHook={handleToggleHook}
|
||||
onToggleRule={handleToggleRule}
|
||||
onToggleSkill={handleToggleSkill}
|
||||
onToggleWorkflow={handleToggleWorkflow}
|
||||
onUpdateGlobal={handleUpdateGlobal}
|
||||
onUpdateWorkspace={handleUpdateWorkspace}
|
||||
skillsEnabled={skillsEnabled}
|
||||
workspaceHooks={workspaceHooksState}
|
||||
workspaceState={workspaceStateLocal}
|
||||
/>
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<ConfigView
|
||||
dataDir={dataDir}
|
||||
globalClineRulesToggles={globalClineRulesToggles}
|
||||
globalHooks={globalHooks}
|
||||
globalSkills={globalSkills}
|
||||
globalState={globalStateLocal}
|
||||
globalWorkflowToggles={globalWorkflowToggles}
|
||||
hooksEnabled={hooksEnabled}
|
||||
localAgentsRulesToggles={localAgentsRulesToggles}
|
||||
localClineRulesToggles={localClineRulesToggles}
|
||||
localCursorRulesToggles={localCursorRulesToggles}
|
||||
localSkills={localSkills}
|
||||
localWindsurfRulesToggles={localWindsurfRulesToggles}
|
||||
localWorkflowToggles={localWorkflowToggles}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onToggleHook={handleToggleHook}
|
||||
onToggleRule={handleToggleRule}
|
||||
onToggleSkill={handleToggleSkill}
|
||||
onToggleWorkflow={handleToggleWorkflow}
|
||||
onUpdateGlobal={handleUpdateGlobal}
|
||||
onUpdateWorkspace={handleUpdateWorkspace}
|
||||
skillsEnabled={skillsEnabled}
|
||||
workspaceHooks={workspaceHooksState}
|
||||
workspaceState={workspaceStateLocal}
|
||||
/>
|
||||
</StdinProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, { useCallback, useState } from "react"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
id: string
|
||||
@@ -51,6 +52,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
onPageChange,
|
||||
allItems,
|
||||
}) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1)
|
||||
const { stdout } = useStdout()
|
||||
@@ -103,23 +105,26 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
[useInternalPagination, onPageChange],
|
||||
)
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => Math.min(pageItems.length - 1, prev + 1))
|
||||
} else if (key.return && pageItems[selectedIndex]) {
|
||||
onSelect(pageItems[selectedIndex])
|
||||
} else if (key.leftArrow && hasPrevPage) {
|
||||
handlePageChange(currentPage - 1)
|
||||
} else if (key.rightArrow && hasNextPage) {
|
||||
handlePageChange(currentPage + 1)
|
||||
} else if (input === "n" && hasNextPage) {
|
||||
handlePageChange(currentPage + 1)
|
||||
} else if (input === "p" && hasPrevPage) {
|
||||
handlePageChange(currentPage - 1)
|
||||
}
|
||||
})
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((prev) => Math.min(pageItems.length - 1, prev + 1))
|
||||
} else if (key.return && pageItems[selectedIndex]) {
|
||||
onSelect(pageItems[selectedIndex])
|
||||
} else if (key.leftArrow && hasPrevPage) {
|
||||
handlePageChange(currentPage - 1)
|
||||
} else if (key.rightArrow && hasNextPage) {
|
||||
handlePageChange(currentPage + 1)
|
||||
} else if (input === "n" && hasNextPage) {
|
||||
handlePageChange(currentPage + 1)
|
||||
} else if (input === "p" && hasPrevPage) {
|
||||
handlePageChange(currentPage - 1)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
// Calculate visible window around selected item
|
||||
const halfVisible = Math.floor(effectiveVisibleCount / 2)
|
||||
@@ -139,7 +144,8 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
<Text bold color="white">
|
||||
{"📜 Task History (" + totalCount + " total)"}
|
||||
</Text>
|
||||
<Text dimColor>Use ↑↓ to navigate, Enter to select</Text>
|
||||
<Text dimColor>Use ↑↓/j/k to navigate, Enter to select</Text>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
@@ -191,7 +197,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{showDownIndicator && <Text dimColor>{" ↓ " + (items.length - endIndex) + " more below"}</Text>}
|
||||
{showDownIndicator && <Text dimColor>{" ↓ " + (pageItems.length - endIndex) + " more below"}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,14 +7,11 @@ import Spinner from "ink-spinner"
|
||||
import React from "react"
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string
|
||||
mode?: "act" | "plan"
|
||||
}
|
||||
|
||||
const LOADING_TEXT_IDEAS = ["Thinking", "Loading", "Processing", "Working", "Calculating", "Analyzing", "Exploring"]
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
message = LOADING_TEXT_IDEAS[Math.floor(Math.random() * LOADING_TEXT_IDEAS.length)],
|
||||
}) => {
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ mode = "act" }) => {
|
||||
const message = mode === "plan" ? "Planning" : "Thinking"
|
||||
return (
|
||||
<Box>
|
||||
<Text color="cyan">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Status bar component
|
||||
* Shows git branch, model, context window usage, token count, and cost
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process"
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useEffect, useState } from "react"
|
||||
|
||||
interface StatusBarProps {
|
||||
modelId: string
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
totalCost?: number
|
||||
contextWindowSize?: number
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current git branch name
|
||||
*/
|
||||
function getGitBranch(cwd?: string): string | null {
|
||||
try {
|
||||
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: cwd || process.cwd(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
return branch
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory basename
|
||||
*/
|
||||
function getDirName(cwd?: string): string {
|
||||
const path = cwd || process.cwd()
|
||||
return path.split("/").pop() || path
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
*/
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a progress bar for context window usage
|
||||
*/
|
||||
function createContextBar(used: number, total: number, width: number = 8): string {
|
||||
const ratio = Math.min(used / total, 1)
|
||||
const filled = Math.round(ratio * width)
|
||||
const empty = width - filled
|
||||
return "█".repeat(filled) + "░".repeat(empty)
|
||||
}
|
||||
|
||||
export const StatusBar: React.FC<StatusBarProps> = ({
|
||||
modelId,
|
||||
tokensIn = 0,
|
||||
tokensOut = 0,
|
||||
totalCost = 0,
|
||||
contextWindowSize = 200000, // Default Claude context window
|
||||
cwd,
|
||||
}) => {
|
||||
const [branch, setBranch] = useState<string | null>(null)
|
||||
const dirName = getDirName(cwd)
|
||||
|
||||
useEffect(() => {
|
||||
setBranch(getGitBranch(cwd))
|
||||
}, [cwd])
|
||||
|
||||
const totalTokens = tokensIn + tokensOut
|
||||
const contextBar = createContextBar(totalTokens, contextWindowSize)
|
||||
|
||||
// Format model ID for display (shorten if needed)
|
||||
const displayModel = modelId.length > 20 ? modelId.substring(0, 17) + "..." : modelId
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box gap={1}>
|
||||
{/* Directory and branch */}
|
||||
<Text color="gray">
|
||||
{dirName}
|
||||
{branch && (
|
||||
<Text color="gray">
|
||||
{" "}
|
||||
(<Text color="cyan">{branch}</Text>)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text color="gray">|</Text>
|
||||
|
||||
{/* Model and context bar */}
|
||||
<Text color="white">{displayModel}</Text>
|
||||
<Text color="blue">{contextBar}</Text>
|
||||
<Text color="gray">({formatNumber(totalTokens)})</Text>
|
||||
<Text color="gray">|</Text>
|
||||
|
||||
{/* Cost */}
|
||||
<Text color="green">${totalCost.toFixed(4)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* JSON Task view component
|
||||
* Outputs task messages as JSON instead of rich styled text
|
||||
*/
|
||||
|
||||
import { Box } from "ink"
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { useTaskContext, useTaskState } from "../context/TaskContext"
|
||||
import { useCompletionSignals } from "../hooks/useStateSubscriber"
|
||||
import { originalConsoleLog } from "../utils/console"
|
||||
|
||||
interface TaskJsonViewProps {
|
||||
taskId?: string
|
||||
verbose?: boolean
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a JSON line to stdout
|
||||
*/
|
||||
function outputJson(data: object) {
|
||||
originalConsoleLog(JSON.stringify(data))
|
||||
}
|
||||
|
||||
export const TaskJsonView: React.FC<TaskJsonViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
|
||||
const state = useTaskState()
|
||||
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
|
||||
const { setIsComplete } = useTaskContext()
|
||||
// Track outputted messages by timestamp (don't re-output on updates)
|
||||
const outputtedMessages = useRef<Set<number>>(new Set())
|
||||
const hasOutputtedCompletion = useRef(false)
|
||||
|
||||
// Determine the role for a message
|
||||
const getRole = (message: { type: string; ask?: string; say?: string }, index: number): "user" | "assistant" | "system" => {
|
||||
// User feedback messages
|
||||
if (message.say === "user_feedback" || message.say === "user_feedback_diff") {
|
||||
return "user"
|
||||
}
|
||||
// First text message is the user's task
|
||||
if (message.say === "text" && index === 0) {
|
||||
return "user"
|
||||
}
|
||||
// System messages
|
||||
if (message.say === "api_req_started" || message.say === "api_req_finished") {
|
||||
return "system"
|
||||
}
|
||||
// Default: assistant
|
||||
return "assistant"
|
||||
}
|
||||
|
||||
// Output messages as JSON when they arrive
|
||||
useEffect(() => {
|
||||
const messages = state.clineMessages || []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
// Skip partial messages - wait for complete message
|
||||
if (message.partial) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if we already outputted this timestamp
|
||||
if (outputtedMessages.current.has(message.ts)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter out noisy messages in non-verbose mode
|
||||
if (!verbose) {
|
||||
if (message.say === "api_req_started" || message.say === "api_req_finished") {
|
||||
outputtedMessages.current.add(message.ts)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const role = getRole(message, i)
|
||||
|
||||
// Output the message as JSON
|
||||
outputJson({
|
||||
type: "message",
|
||||
timestamp: message.ts,
|
||||
role,
|
||||
messageType: message.type,
|
||||
...(message.ask && { ask: message.ask }),
|
||||
...(message.say && { say: message.say }),
|
||||
...(message.text && { text: message.text }),
|
||||
...(message.reasoning && { reasoning: message.reasoning }),
|
||||
...(message.images && message.images.length > 0 && { images: message.images }),
|
||||
...(message.files && message.files.length > 0 && { files: message.files }),
|
||||
})
|
||||
|
||||
outputtedMessages.current.add(message.ts)
|
||||
}
|
||||
}, [state.clineMessages, verbose])
|
||||
|
||||
// Handle task completion
|
||||
useEffect(() => {
|
||||
if (isTaskComplete() && !hasOutputtedCompletion.current) {
|
||||
hasOutputtedCompletion.current = true
|
||||
setIsComplete(true)
|
||||
|
||||
const completionMsg = getCompletionMessage()
|
||||
const isError = completionMsg?.say === "error" || completionMsg?.ask === "api_req_failed"
|
||||
|
||||
// Output completion status
|
||||
outputJson({
|
||||
type: "completion",
|
||||
status: isError ? "error" : "success",
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
onError?.()
|
||||
} else {
|
||||
onComplete?.()
|
||||
}
|
||||
|
||||
// Don't exit automatically - let the parent handle cleanup
|
||||
}
|
||||
}, [isTaskComplete, setIsComplete, onComplete, onError, getCompletionMessage])
|
||||
|
||||
// Render nothing visible - all output goes to stdout as JSON
|
||||
return <Box />
|
||||
}
|
||||
@@ -5,10 +5,12 @@
|
||||
|
||||
import { exit } from "node:process"
|
||||
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { checkpointRestore } from "@/core/controller/checkpoints/checkpointRestore"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTaskContext, useTaskState } from "../context/TaskContext"
|
||||
import { useCompletionSignals, useIsSpinnerActive } from "../hooks/useStateSubscriber"
|
||||
import { AskPrompt } from "./AskPrompt"
|
||||
@@ -32,10 +34,12 @@ function formatSeparator(char: string = "═", width: number = 60): string {
|
||||
}
|
||||
|
||||
export const TaskView: React.FC<TaskViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const state = useTaskState()
|
||||
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
|
||||
const isSpinnerActive = useIsSpinnerActive()
|
||||
const { setIsComplete, lastError, controller } = useTaskContext()
|
||||
const { isComplete, setIsComplete, lastError, controller } = useTaskContext()
|
||||
const mode = useMemo<Mode>(() => StateManager.get().getGlobalSettingsKey("mode") || "act", [])
|
||||
const [showCheckpointMenu, setShowCheckpointMenu] = useState(false)
|
||||
const [restoreStatus, setRestoreStatus] = useState<"idle" | "restoring" | "success" | "error">("idle")
|
||||
const [restoreMessage, setRestoreMessage] = useState<string | null>(null)
|
||||
@@ -105,7 +109,7 @@ export const TaskView: React.FC<TaskViewProps> = ({ taskId: _taskId, verbose = f
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: !showCheckpointMenu },
|
||||
{ isActive: isRawModeSupported && !showCheckpointMenu },
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -170,8 +174,8 @@ export const TaskView: React.FC<TaskViewProps> = ({ taskId: _taskId, verbose = f
|
||||
{/* Messages list */}
|
||||
<MessageList verbose={verbose} />
|
||||
|
||||
{/* Loading spinner */}
|
||||
{isSpinnerActive && (
|
||||
{/* Loading spinner - hide when task is complete/exiting */}
|
||||
{isSpinnerActive && !isComplete && (
|
||||
<Box marginTop={1}>
|
||||
<LoadingSpinner />
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Thinking/Planning indicator with shimmer effect and elapsed time
|
||||
*
|
||||
* Creates a shimmer effect by cycling a "bright spot" through the text,
|
||||
* similar to skeleton loading animations in web UIs.
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
|
||||
interface ThinkingIndicatorProps {
|
||||
mode?: "act" | "plan"
|
||||
startTime?: number // Unix timestamp when thinking started
|
||||
}
|
||||
|
||||
// Shimmer gradient - how much to blend towards white (0 = base color, 1 = white)
|
||||
// Index 0 = no brightening (far from shimmer), higher = more white (near shimmer center)
|
||||
const SHIMMER_GRADIENT = [
|
||||
0.0, // base color
|
||||
0.15,
|
||||
0.3,
|
||||
0.5,
|
||||
0.7,
|
||||
0.9, // almost white (shimmer center)
|
||||
]
|
||||
|
||||
const SHIMMER_RADIUS = SHIMMER_GRADIENT.length - 1
|
||||
|
||||
// Spinner frames (dots style from ink-spinner)
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
/**
|
||||
* Format elapsed time as "1m 5s" or "45s"
|
||||
*/
|
||||
function formatElapsedTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Blend base color towards white based on factor (0 = base color, 1 = white)
|
||||
*/
|
||||
function blendTowardsWhite(baseColor: string, factor: number): string {
|
||||
// Base colors - bright and vivid to match Ink's blueBright/yellow
|
||||
const colors: Record<string, { r: number; g: number; b: number }> = {
|
||||
blueBright: { r: 140, g: 170, b: 255 }, // Light purple-blue
|
||||
yellow: { r: 255, g: 255, b: 0 }, // Bright yellow
|
||||
}
|
||||
const base = colors[baseColor] || colors.blueBright
|
||||
const t = Math.max(0, Math.min(1, factor))
|
||||
|
||||
// Lerp from base color towards white (255, 255, 255)
|
||||
const r = Math.round(base.r + (255 - base.r) * t)
|
||||
const g = Math.round(base.g + (255 - base.g) * t)
|
||||
const b = Math.round(base.b + (255 - base.b) * t)
|
||||
|
||||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply shimmer effect to a string by coloring each character
|
||||
* based on its position relative to the shimmer center
|
||||
*/
|
||||
function ShimmerText({
|
||||
text,
|
||||
shimmerPos,
|
||||
baseColor,
|
||||
}: {
|
||||
text: string
|
||||
shimmerPos: number
|
||||
baseColor: string
|
||||
}): React.ReactElement {
|
||||
const chars = text.split("")
|
||||
|
||||
return (
|
||||
<>
|
||||
{chars.map((char, i) => {
|
||||
// Calculate distance from shimmer center position
|
||||
// shimmerPos moves from -SHIMMER_RADIUS to textLen + SHIMMER_RADIUS
|
||||
const dist = Math.abs(i - shimmerPos)
|
||||
|
||||
// Map distance to whiteness: closer to shimmer = more white
|
||||
const gradientIdx = Math.min(dist, SHIMMER_RADIUS)
|
||||
const whiteness = SHIMMER_GRADIENT[SHIMMER_RADIUS - gradientIdx] ?? SHIMMER_GRADIENT[0]
|
||||
|
||||
const color = blendTowardsWhite(baseColor, whiteness)
|
||||
|
||||
return (
|
||||
<Text color={color} key={i}>
|
||||
{char}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "act", startTime }) => {
|
||||
const message = mode === "plan" ? "Planning" : "Thinking"
|
||||
const baseColor = mode === "plan" ? "yellow" : "blueBright"
|
||||
|
||||
// Spinner frame index
|
||||
const [spinnerFrame, setSpinnerFrame] = useState(0)
|
||||
|
||||
// Shimmer animation position (moves left to right across the text)
|
||||
const [shimmerPos, setShimmerPos] = useState(-SHIMMER_RADIUS)
|
||||
|
||||
// Elapsed time state
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
|
||||
// Full text including spinner
|
||||
const spinnerChar = SPINNER_FRAMES[spinnerFrame]
|
||||
const fullText = `${spinnerChar} ${message}…`
|
||||
|
||||
// Animate spinner
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length)
|
||||
}, 80)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Animate shimmer: move from left edge to right edge, then wrap
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setShimmerPos((prev) => {
|
||||
const next = prev + 1
|
||||
if (next > fullText.length + SHIMMER_RADIUS) {
|
||||
return -SHIMMER_RADIUS
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, 60) // Speed of shimmer movement
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [fullText.length])
|
||||
|
||||
// Update elapsed time
|
||||
useEffect(() => {
|
||||
if (!startTime) return
|
||||
|
||||
const updateElapsed = () => {
|
||||
setElapsedMs(Date.now() - startTime)
|
||||
}
|
||||
|
||||
updateElapsed() // Initial update
|
||||
const interval = setInterval(updateElapsed, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [startTime])
|
||||
|
||||
const elapsedStr = useMemo(() => {
|
||||
if (!startTime || elapsedMs < 1000) return null
|
||||
return formatElapsedTime(elapsedMs)
|
||||
}, [startTime, elapsedMs])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<ShimmerText baseColor={baseColor} shimmerPos={shimmerPos} text={fullText} />
|
||||
{elapsedStr && (
|
||||
<Text color="gray">
|
||||
{" "}
|
||||
({elapsedStr} · <Text dimColor>esc to interrupt</Text>)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -4,10 +4,11 @@
|
||||
* Supports file mentions with @
|
||||
*/
|
||||
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getProviderDefaultModelId, Mode } from "@/shared/storage"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
checkAndWarnRipgrepMissing,
|
||||
extractMentionQuery,
|
||||
@@ -51,6 +52,7 @@ const RIPGREP_WARNING_DURATION_MS = 5000
|
||||
const MAX_SEARCH_RESULTS = 15
|
||||
|
||||
export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, controller }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
@@ -62,12 +64,20 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
|
||||
return stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
})
|
||||
|
||||
const provider = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
|
||||
return currentProvider || "cline"
|
||||
}, [controller])
|
||||
|
||||
// Get model ID based on current mode
|
||||
const modelId = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
return (stateManager.getGlobalSettingsKey(modelKey) as string) || "claude-sonnet-4-20250514"
|
||||
}, [mode])
|
||||
return (stateManager.getGlobalSettingsKey(modelKey) as string) || getProviderDefaultModelId(provider)
|
||||
}, [mode, provider])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const newMode: Mode = mode === "act" ? "plan" : "act"
|
||||
@@ -151,64 +161,67 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
|
||||
}
|
||||
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
|
||||
|
||||
useInput((input, key) => {
|
||||
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
|
||||
useInput(
|
||||
(input, key) => {
|
||||
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
|
||||
|
||||
// Menu navigation
|
||||
if (inMenu) {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
if (key.tab || key.return) {
|
||||
const file = fileResults[selectedIndex]
|
||||
if (file) {
|
||||
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
|
||||
// Menu navigation
|
||||
if (inMenu) {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
if (key.tab || key.return) {
|
||||
const file = fileResults[selectedIndex]
|
||||
if (file) {
|
||||
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
if (key.tab && !mentionInfo.inMentionMode) {
|
||||
toggleMode()
|
||||
return
|
||||
}
|
||||
if (key.return && !mentionInfo.inMentionMode) {
|
||||
if (prompt.trim() || imagePaths.length > 0) {
|
||||
onSubmit(prompt.trim(), imagePaths)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
if (key.escape && !mentionInfo.inMentionMode) {
|
||||
if (escPressedOnce) {
|
||||
onExit?.()
|
||||
} else {
|
||||
setEscPressedOnce(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
if (key.tab && !mentionInfo.inMentionMode) {
|
||||
toggleMode()
|
||||
return
|
||||
}
|
||||
if (key.return && !mentionInfo.inMentionMode) {
|
||||
if (prompt.trim() || imagePaths.length > 0) {
|
||||
onSubmit(prompt.trim(), imagePaths)
|
||||
if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
setEscPressedOnce(false)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape && !mentionInfo.inMentionMode) {
|
||||
if (escPressedOnce) {
|
||||
onExit?.()
|
||||
} else {
|
||||
setEscPressedOnce(true)
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
setTextInput((prev) => prev + input)
|
||||
setEscPressedOnce(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
setEscPressedOnce(false)
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
setTextInput((prev) => prev + input)
|
||||
setEscPressedOnce(false)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const borderColor = mode === "act" ? "blue" : "yellow"
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Context for tracking stdin raw mode support
|
||||
* Used to conditionally disable input handling when stdin doesn't support raw mode
|
||||
* (e.g., when input is piped: echo "..." | clinedev)
|
||||
*/
|
||||
|
||||
import React, { createContext, type ReactNode, useContext } from "react"
|
||||
|
||||
interface StdinContextValue {
|
||||
/**
|
||||
* Whether stdin supports raw mode (keyboard input handling)
|
||||
* Will be false when input is piped or stdin is not a TTY
|
||||
*/
|
||||
isRawModeSupported: boolean
|
||||
}
|
||||
|
||||
const StdinContext = createContext<StdinContextValue>({ isRawModeSupported: true })
|
||||
|
||||
export const useStdinContext = () => useContext(StdinContext)
|
||||
|
||||
interface StdinProviderProps {
|
||||
children: ReactNode
|
||||
isRawModeSupported: boolean
|
||||
}
|
||||
|
||||
export const StdinProvider: React.FC<StdinProviderProps> = ({ children, isRawModeSupported }) => {
|
||||
return <StdinContext.Provider value={{ isRawModeSupported }}>{children}</StdinContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if stdin supports raw mode
|
||||
* Returns false when input is piped or stdin is not a TTY
|
||||
*/
|
||||
export function checkRawModeSupport(): boolean {
|
||||
return Boolean(process.stdin.isTTY && typeof process.stdin.setRawMode === "function")
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
} from "@generated/hosts/host-bridge-client-types"
|
||||
import type { HostBridgeClientProvider, StreamingCallbacks } from "@hosts/host-provider-types"
|
||||
import * as proto from "@shared/proto/index"
|
||||
import { ClineClient } from "@/shared/clients"
|
||||
import { CLI_VERSION } from ".."
|
||||
import { printError, printInfo, printWarning } from "../utils/display"
|
||||
|
||||
/**
|
||||
@@ -90,8 +92,9 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
|
||||
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
|
||||
return proto.host.GetHostVersionResponse.create({
|
||||
version: "1.0.0",
|
||||
platform: "Cline CLI",
|
||||
version: CLI_VERSION,
|
||||
platform: "Cline CLI - Node.js",
|
||||
clineType: ClineClient.Cli,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -258,6 +261,13 @@ export class CliWorkspaceServiceClient implements WorkspaceServiceClientInterfac
|
||||
printInfo(`⚙️ Executing: ${request.command}`)
|
||||
return proto.host.ExecuteCommandInTerminalResponse.create({})
|
||||
}
|
||||
|
||||
async openFolder(request: proto.host.OpenFolderRequest): Promise<proto.host.OpenFolderResponse> {
|
||||
const path = request.path || ""
|
||||
this.workspacePath = path
|
||||
printInfo(`📂 Opening folder: ${path}`)
|
||||
return proto.host.OpenFolderResponse.create({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+59
-17
@@ -16,17 +16,20 @@ import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { initializeDistinctId } from "@/services/logging/distinctId"
|
||||
import { getProviderModelIdKey } from "@/shared/storage"
|
||||
import { App } from "./components/App"
|
||||
import { checkRawModeSupport } from "./context/StdinContext"
|
||||
import { createCliHostBridgeProvider } from "./controllers"
|
||||
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
|
||||
import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { restoreConsole } from "./utils/console"
|
||||
import { calculateRobotTopRow, queryCursorPos } from "./utils/cursor-position"
|
||||
import { print, printError, printInfo, printWarning, separator } from "./utils/display"
|
||||
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
|
||||
import { getProviderModelIdKey } from "./utils/provider-map"
|
||||
import { readStdinIfPiped } from "./utils/piped"
|
||||
import { initializeCliContext } from "./vscode-context"
|
||||
|
||||
const VERSION = "0.0.0"
|
||||
export const CLI_VERSION = "0.0.0"
|
||||
|
||||
// Track active context for graceful shutdown
|
||||
let activeContext: CliContext | null = null
|
||||
@@ -39,6 +42,7 @@ function setupSignalHandlers() {
|
||||
process.exit(1)
|
||||
}
|
||||
isShuttingDown = true
|
||||
|
||||
printWarning(`\n${signal} received, shutting down...`)
|
||||
|
||||
try {
|
||||
@@ -92,7 +96,8 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
}
|
||||
|
||||
const logToChannel = options.verbose ? (message: string) => printInfo(message) : () => {}
|
||||
// TODO: Wire up with proper logging system
|
||||
const logToChannel = () => {}
|
||||
|
||||
HostProvider.initialize(
|
||||
() => new CliWebviewProvider(extensionContext),
|
||||
@@ -124,7 +129,7 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
* Run an Ink app with proper cleanup handling
|
||||
*/
|
||||
async function runInkApp(element: React.ReactElement, cleanup: () => Promise<void>): Promise<void> {
|
||||
const { waitUntilExit, unmount } = render(element)
|
||||
const { waitUntilExit, unmount } = render(element, { incrementalRendering: true })
|
||||
|
||||
try {
|
||||
await waitUntilExit()
|
||||
@@ -175,6 +180,7 @@ async function runTask(
|
||||
thinking?: boolean
|
||||
yolo?: boolean
|
||||
images?: string[]
|
||||
json?: boolean
|
||||
},
|
||||
existingContext?: CliContext,
|
||||
) {
|
||||
@@ -228,12 +234,15 @@ async function runTask(
|
||||
|
||||
await StateManager.get().flushPendingState()
|
||||
|
||||
printInfo(`Starting Cline task...`)
|
||||
printInfo(`Working directory: ${ctx.workspacePath}`)
|
||||
if (imageDataUrls.length > 0) {
|
||||
printInfo(`Images attached: ${imageDataUrls.length}`)
|
||||
// Skip info messages in JSON mode
|
||||
if (!options.json) {
|
||||
printInfo(`Starting Cline task...`)
|
||||
printInfo(`Working directory: ${ctx.workspacePath}`)
|
||||
if (imageDataUrls.length > 0) {
|
||||
printInfo(`Images attached: ${imageDataUrls.length}`)
|
||||
}
|
||||
print(separator())
|
||||
}
|
||||
print(separator())
|
||||
|
||||
let isComplete = false
|
||||
let taskError = false
|
||||
@@ -244,6 +253,8 @@ async function runTask(
|
||||
taskId: taskPrompt.substring(0, 30),
|
||||
verbose: options.verbose,
|
||||
controller: ctx.controller,
|
||||
jsonOutput: options.json,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onComplete: () => {
|
||||
isComplete = true
|
||||
},
|
||||
@@ -316,6 +327,7 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
|
||||
historyAllItems: sortedHistory,
|
||||
controller: ctx.controller,
|
||||
historyPagination: { page: initialPage, totalPages, totalCount, limit },
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
@@ -337,7 +349,6 @@ async function showConfig(options: { config?: string }) {
|
||||
const { ConfigViewWrapper } = await import("./components/ConfigViewWrapper")
|
||||
|
||||
// Check feature flags
|
||||
const hooksEnabled = stateManager.getGlobalSettingsKey("hooksEnabled") ?? false
|
||||
const skillsEnabled = stateManager.getGlobalSettingsKey("skillsEnabled") ?? false
|
||||
|
||||
await runInkApp(
|
||||
@@ -346,8 +357,9 @@ async function showConfig(options: { config?: string }) {
|
||||
dataDir: ctx.dataDir,
|
||||
globalState: stateManager.getAllGlobalStateEntries(),
|
||||
workspaceState: stateManager.getAllWorkspaceStateEntries(),
|
||||
hooksEnabled,
|
||||
hooksEnabled: true,
|
||||
skillsEnabled,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
@@ -383,6 +395,7 @@ async function runAuth(options: {
|
||||
React.createElement(App, {
|
||||
view: "auth",
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onComplete: () => {
|
||||
exit(0)
|
||||
},
|
||||
@@ -407,7 +420,7 @@ async function runAuth(options: {
|
||||
// Setup CLI commands
|
||||
const program = new Command()
|
||||
|
||||
program.name("cline").description("Cline CLI - AI coding assistant in your terminal").version(VERSION)
|
||||
program.name("cline").description("Cline CLI - AI coding assistant in your terminal").version(CLI_VERSION)
|
||||
|
||||
// Enable positional options to avoid conflicts between root and subcommand options with the same name
|
||||
program.enablePositionalOptions()
|
||||
@@ -426,6 +439,7 @@ program
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.option("--thinking", "Enable extended thinking (1024 token budget)")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.action((prompt, options) => runTask(prompt, options))
|
||||
|
||||
program
|
||||
@@ -458,13 +472,18 @@ program
|
||||
program
|
||||
.command("version")
|
||||
.description("Show Cline CLI version number")
|
||||
.action(() => printInfo(`Cline CLI version: ${VERSION}`))
|
||||
.action(() => printInfo(`Cline CLI version: ${CLI_VERSION}`))
|
||||
/**
|
||||
* Show welcome prompt and run task with user input
|
||||
*/
|
||||
async function showWelcome(options: { verbose?: boolean; cwd?: string; config?: string; thinking?: boolean }) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
|
||||
// Query cursor position BEFORE Ink mounts to know where robot will render
|
||||
const cursorPos = await queryCursorPos(process.stdin, process.stdout)
|
||||
const terminalRows = process.stdout.rows ?? 24
|
||||
const robotTopRow = calculateRobotTopRow(cursorPos, terminalRows)
|
||||
|
||||
let submittedPrompt: string | null = null
|
||||
let submittedImagePaths: string[] = []
|
||||
|
||||
@@ -472,6 +491,8 @@ async function showWelcome(options: { verbose?: boolean; cwd?: string; config?:
|
||||
React.createElement(App, {
|
||||
view: "welcome",
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
robotTopRow,
|
||||
onWelcomeSubmit: (prompt: string, imagePaths: string[]) => {
|
||||
submittedPrompt = prompt
|
||||
submittedImagePaths = imagePaths
|
||||
@@ -482,6 +503,9 @@ async function showWelcome(options: { verbose?: boolean; cwd?: string; config?:
|
||||
exit(0)
|
||||
},
|
||||
}),
|
||||
{
|
||||
patchConsole: false,
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -490,13 +514,12 @@ async function showWelcome(options: { verbose?: boolean; cwd?: string; config?:
|
||||
// App unmounted after prompt submission
|
||||
}
|
||||
|
||||
restoreConsole()
|
||||
|
||||
if (submittedPrompt || submittedImagePaths.length > 0) {
|
||||
// Run the task with the submitted prompt and images, reusing the existing context
|
||||
await runTask(submittedPrompt || "", { ...options, images: submittedImagePaths }, ctx)
|
||||
} else {
|
||||
// User exited without submitting - clean up and exit
|
||||
restoreConsole()
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
@@ -507,14 +530,33 @@ async function showWelcome(options: { verbose?: boolean; cwd?: string; config?:
|
||||
// Interactive mode (default when no command given)
|
||||
program
|
||||
.argument("[prompt]", "Task prompt (starts task immediately)")
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-i, --images <paths...>", "Image file paths to include with the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking (1024 token budget)")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.action(async (prompt, options) => {
|
||||
if (prompt) {
|
||||
await runTask(prompt, options)
|
||||
// Always check for piped stdin content
|
||||
const stdinInput = await readStdinIfPiped()
|
||||
|
||||
// Combine stdin content with prompt argument
|
||||
let effectivePrompt = prompt
|
||||
if (stdinInput) {
|
||||
if (effectivePrompt) {
|
||||
// Prepend stdin content to the prompt
|
||||
effectivePrompt = `${stdinInput}\n\n${effectivePrompt}`
|
||||
} else {
|
||||
effectivePrompt = stdinInput
|
||||
}
|
||||
}
|
||||
|
||||
if (effectivePrompt) {
|
||||
await runTask(effectivePrompt, options)
|
||||
} else {
|
||||
// Show welcome prompt if no prompt given
|
||||
await showWelcome(options)
|
||||
|
||||
@@ -21,6 +21,7 @@ if (!isVerbose) {
|
||||
console.warn = () => {}
|
||||
console.error = () => {}
|
||||
console.debug = () => {}
|
||||
console.info = () => {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Query terminal cursor position before Ink mounts
|
||||
* Must be called BEFORE render() to avoid escape sequence leaking into Ink's input handling
|
||||
*/
|
||||
|
||||
export async function queryCursorPos(
|
||||
stdin: NodeJS.ReadStream,
|
||||
stdout: NodeJS.WriteStream,
|
||||
{ timeoutMs = 75 } = {},
|
||||
): Promise<{ row: number; col: number } | null> {
|
||||
if (!stdin.isTTY || !stdout.isTTY || typeof (stdin as any).setRawMode !== "function") return null
|
||||
|
||||
const ttyIn = stdin as any as { isRaw?: boolean; setRawMode: (b: boolean) => void; on: any; off: any }
|
||||
const wasRaw = !!ttyIn.isRaw
|
||||
|
||||
ttyIn.setRawMode(true)
|
||||
stdin.resume()
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let buf = ""
|
||||
const onData = (chunk: Buffer) => {
|
||||
buf += chunk.toString("utf8")
|
||||
const m = buf.match(/\x1b\[(\d+);(\d+)R/)
|
||||
if (!m) return
|
||||
cleanup()
|
||||
resolve({ row: Number(m[1]), col: Number(m[2]) })
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
stdin.off("data", onData)
|
||||
try {
|
||||
ttyIn.setRawMode(wasRaw)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
|
||||
stdin.on("data", onData)
|
||||
stdout.write("\x1b[6n") // DSR: cursor position
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate where the robot will be rendered on screen
|
||||
*/
|
||||
export function calculateRobotTopRow(cursorPos: { row: number } | null, terminalRows: number): number {
|
||||
const robotHeight = 12
|
||||
const firstFrameHeight = robotHeight + 8 // robot + welcome text + margins + input + footer
|
||||
|
||||
const startRow = cursorPos?.row ?? 1
|
||||
// If content doesn't fit below cursor, terminal scrolls
|
||||
return Math.max(1, Math.min(startRow, terminalRows - firstFrameHeight + 1))
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Read piped input from stdin (non-blocking)
|
||||
*/
|
||||
export async function readStdinIfPiped(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (interactive) or piped
|
||||
if (process.stdin.isTTY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let data = ""
|
||||
process.stdin.setEncoding("utf8")
|
||||
|
||||
// Set a short timeout in case stdin is not actually providing data
|
||||
const timeout = setTimeout(() => {
|
||||
process.stdin.removeAllListeners()
|
||||
resolve(data.trim() || null)
|
||||
}, 100)
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(data.trim() || null)
|
||||
})
|
||||
|
||||
process.stdin.on("error", () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
// Resume stdin in case it's paused
|
||||
process.stdin.resume()
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import os from "os"
|
||||
import path from "path"
|
||||
import type { Memento, SecretStorage } from "vscode"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { ClineClient, ClineExtensionContext } from "@/shared/clients"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { ClineExtensionContext } from "@/shared/clients"
|
||||
import { ExtensionKind, ExtensionMode, URI } from "./vscode-shim"
|
||||
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
@@ -48,7 +49,7 @@ class MementoStore implements Memento {
|
||||
this.data = JSON.parse(content)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load state from ${this.filePath}:`, error)
|
||||
Logger.error(`Failed to load state from ${this.filePath}:`, error)
|
||||
this.data = {}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +59,7 @@ class MementoStore implements Memento {
|
||||
mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2))
|
||||
} catch (error) {
|
||||
console.error(`Failed to save state to ${this.filePath}:`, error)
|
||||
Logger.error(`Failed to save state to ${this.filePath}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +131,7 @@ class SecretStore implements SecretStorage {
|
||||
mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2))
|
||||
} catch (error) {
|
||||
console.error(`Failed to save secrets:`, error)
|
||||
Logger.error(`Failed to save secrets:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,8 +244,6 @@ export function initializeCliContext(config: CliContextConfig = {}) {
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
|
||||
|
||||
console.log(`[CLI] Using data directory: ${DATA_DIR}`)
|
||||
|
||||
// For CLI, extension dir is the root of the project (parent of cli-ts)
|
||||
const EXTENSION_DIR = path.resolve(__dirname, "..", "..")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
@@ -261,8 +260,6 @@ export function initializeCliContext(config: CliContextConfig = {}) {
|
||||
}
|
||||
|
||||
const extensionContext: ClineExtensionContext = {
|
||||
name: ClineClient.Cli,
|
||||
|
||||
extension: extension,
|
||||
extensionMode: EXTENSION_MODE,
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Provides minimal stubs for VSCode types and enums used by the codebase
|
||||
*/
|
||||
|
||||
import { printError, printInfo, printWarning } from "./utils/display"
|
||||
|
||||
// Re-export common types from vscode-uri for URI handling
|
||||
export { URI } from "vscode-uri"
|
||||
|
||||
@@ -246,20 +248,20 @@ export const workspace = {
|
||||
// Minimal window namespace
|
||||
export const window = {
|
||||
showInformationMessage: async (message: string) => {
|
||||
console.log(`[INFO] ${message}`)
|
||||
printInfo(`[INFO] ${message}`)
|
||||
return undefined
|
||||
},
|
||||
showWarningMessage: async (message: string) => {
|
||||
console.warn(`[WARN] ${message}`)
|
||||
printWarning(`[WARN] ${message}`)
|
||||
return undefined
|
||||
},
|
||||
showErrorMessage: async (message: string) => {
|
||||
console.error(`[ERROR] ${message}`)
|
||||
printError(`[ERROR] ${message}`)
|
||||
return undefined
|
||||
},
|
||||
createOutputChannel: (_name: string) => ({
|
||||
appendLine: (line: string) => console.log(line),
|
||||
append: (text: string) => process.stdout.write(text),
|
||||
appendLine: (line: string) => printInfo(`[${new Date().toISOString()}] ${line}`),
|
||||
append: (text: string) => printInfo(`[${new Date().toISOString()}] ${text}`),
|
||||
clear: () => {},
|
||||
show: () => {},
|
||||
hide: () => {},
|
||||
@@ -270,7 +272,7 @@ export const window = {
|
||||
createTerminal: (_options?: any) => ({
|
||||
name: "CLI Terminal",
|
||||
processId: Promise.resolve(process.pid),
|
||||
sendText: (text: string) => console.log(`[Terminal] ${text}`),
|
||||
sendText: (text: string) => printInfo(`[${new Date().toISOString()}] [Terminal] ${text}`),
|
||||
show: () => {},
|
||||
hide: () => {},
|
||||
dispose: () => {},
|
||||
|
||||
@@ -231,7 +231,7 @@ func promptForInitialTask(ctx context.Context, modeFlag string, workspaces []str
|
||||
|
||||
prompt, err := output.PromptForInitialTask(
|
||||
"Start a new Cline task",
|
||||
"/plan or /act to switch modes\nctrl+e to open editor\ntab to autocomplete commands",
|
||||
"/plan or /act to switch modes\ntab to autocomplete commands\nctrl+e to open editor\nctrl+c to exit",
|
||||
modeFlag,
|
||||
slash.NewRegistry(ctx),
|
||||
)
|
||||
|
||||
@@ -298,6 +298,64 @@ cline task view
|
||||
\f[I]# Start interactive chat with this task\f[R]
|
||||
cline task chat
|
||||
.EE
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
\f[B]CLINE_COMMAND_PERMISSIONS\f[R]
|
||||
JSON configuration for restricting which shell commands Cline can
|
||||
execute.
|
||||
When set, commands are validated against allow/deny patterns before
|
||||
execution.
|
||||
When not set, all commands are allowed (backward compatibility).
|
||||
.RS
|
||||
.PP
|
||||
Format:
|
||||
\f[CR]{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}\f[R]
|
||||
.PP
|
||||
\f[B]Fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]allow\f[R] (array of strings): Glob patterns for allowed commands.
|
||||
If specified, only matching commands are permitted.
|
||||
Uses \f[CR]*\f[R] to match any characters and \f[CR]?\f[R] to match a
|
||||
single character.
|
||||
.IP \(bu 2
|
||||
\f[B]deny\f[R] (array of strings): Glob patterns for denied commands.
|
||||
Deny rules take precedence over allow rules.
|
||||
.IP \(bu 2
|
||||
\f[B]allowRedirects\f[R] (boolean): Whether to allow shell redirects
|
||||
(\f[CR]>\f[R], \f[CR]>>\f[R], \f[CR]<\f[R], etc.).
|
||||
Defaults to false.
|
||||
.PP
|
||||
\f[B]Rule evaluation:\f[R]
|
||||
.IP "1." 3
|
||||
Check for dangerous characters (backticks outside single quotes,
|
||||
unquoted newlines)
|
||||
.IP "2." 3
|
||||
Parse command into segments split by operators (\f[CR]&&\f[R],
|
||||
\f[CR]||\f[R], \f[CR]|\f[R], \f[CR];\f[R])
|
||||
.IP "3." 3
|
||||
If redirects detected and \f[CR]allowRedirects\f[R] is not true, command
|
||||
is denied
|
||||
.IP "4." 3
|
||||
Each segment is validated against deny rules first, then allow rules
|
||||
.IP "5." 3
|
||||
Subshell contents (\f[CR]$(...)\f[R] and \f[CR](...)\f[R]) are
|
||||
recursively validated
|
||||
.IP "6." 3
|
||||
All segments must pass for the command to be allowed
|
||||
.PP
|
||||
\f[B]Examples:\f[R]
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Allow only npm and git commands\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["npm *", "git *"]}\(aq
|
||||
|
||||
\f[I]# Allow development commands but deny dangerous ones\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["npm *", "git *", "node *"], "deny": ["rm \-rf *", "sudo *"]}\(aq
|
||||
|
||||
\f[I]# Allow file operations with redirects\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["cat *", "echo *"], "allowRedirects": true}\(aq
|
||||
.EE
|
||||
.RE
|
||||
.SH ARCHITECTURE
|
||||
Cline operates on a three\-layer architecture:
|
||||
.TP
|
||||
|
||||
@@ -323,6 +323,42 @@ cline task view
|
||||
cline task chat
|
||||
```
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
**CLINE_COMMAND_PERMISSIONS**
|
||||
|
||||
: JSON configuration for restricting which shell commands Cline can execute. When set, commands are validated against allow/deny patterns before execution. When not set, all commands are allowed.
|
||||
|
||||
Format: `{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}`
|
||||
|
||||
**Fields:**
|
||||
|
||||
- **allow** (array of strings): Glob patterns for allowed commands. If specified, only matching commands are permitted. Uses `*` to match any characters and `?` to match a single character. Setting allow on anything will deny all others.
|
||||
- **deny** (array of strings): Glob patterns for denied commands. Deny rules take precedence over allow rules.
|
||||
- **allowRedirects** (boolean): Whether to allow shell redirects (`>`, `>>`, `<`, etc.). Defaults to false.
|
||||
|
||||
**Rule evaluation:**
|
||||
|
||||
1. Check for dangerous characters (backticks outside single quotes, unquoted newlines)
|
||||
2. Parse command into segments split by operators (`&&`, `||`, `|`, `;`)
|
||||
3. If redirects detected and `allowRedirects` is not true, command is denied
|
||||
4. Each segment is validated against deny rules first, then allow rules
|
||||
5. Subshell contents (`$(...)` and `(...)`) are recursively validated
|
||||
6. All segments must pass for the command to be allowed
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Allow only npm and git commands.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
|
||||
|
||||
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
|
||||
|
||||
# Allow file operations with redirects
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
|
||||
```
|
||||
|
||||
# ARCHITECTURE
|
||||
|
||||
Cline operates on a three-layer architecture:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "cline-core.js",
|
||||
"bin": {
|
||||
|
||||
@@ -110,7 +110,6 @@ func (m *Manager) ListSettings(ctx context.Context) error {
|
||||
"dictationSettings",
|
||||
"autoCondenseThreshold",
|
||||
"autoApprovalSettings",
|
||||
"hooksEnabled",
|
||||
}
|
||||
|
||||
// Render each field using the renderer
|
||||
|
||||
@@ -79,7 +79,7 @@ func RenderField(key string, value interface{}, censor bool) error {
|
||||
"planActSeparateModelsSetting", "enableCheckpointsSetting",
|
||||
"terminalReuseEnabled", "mcpResponsesCollapsed", "strictPlanModeEnabled",
|
||||
"useAutoCondense", "yoloModeToggled", "shellIntegrationTimeout",
|
||||
"terminalOutputLineLimit", "autoCondenseThreshold", "hooksEnabled":
|
||||
"terminalOutputLineLimit", "autoCondenseThreshold":
|
||||
fmt.Printf("%s: %s\n", camelToKebab(key), formatValue(value, key, censor))
|
||||
return nil
|
||||
|
||||
|
||||
@@ -348,7 +348,7 @@ func (m *InputModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return ChangeInputTypeMsg{
|
||||
InputType: InputTypeFeedback,
|
||||
Title: "Your feedback",
|
||||
Placeholder: "/plan or /act to switch modes\nctrl+e to open editor",
|
||||
Placeholder: "/plan or /act to switch modes\nctrl+e to open editor\nctrl+c to exit",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error
|
||||
model := output.NewInputModelWithRegistry(
|
||||
output.InputTypeMessage,
|
||||
"Cline is ready for your message...",
|
||||
"/plan or /act to switch modes\nctrl+e to open editor\ntab to autocomplete commands",
|
||||
"/plan or /act to switch modes\ntab to autocomplete commands\nctrl+e to open editor\nctrl+c to exit",
|
||||
currentMode,
|
||||
ih.slashCommandRegistry,
|
||||
)
|
||||
|
||||
@@ -290,12 +290,6 @@ func setSimpleField(settings *cline.Settings, key, value string) error {
|
||||
return err
|
||||
}
|
||||
settings.ActModeAwsBedrockCustomSelected = boolPtr(val)
|
||||
case "hooks_enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.HooksEnabled = boolPtr(val)
|
||||
case "azure_identity":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.2 MiB |
@@ -148,6 +148,7 @@
|
||||
"features/hooks/samples"
|
||||
]
|
||||
},
|
||||
"features/jupyter-notebooks",
|
||||
"features/multiroot-workspace",
|
||||
"features/plan-and-act",
|
||||
"features/skills",
|
||||
@@ -198,6 +199,7 @@
|
||||
"provider-config/anthropic",
|
||||
"provider-config/claude-code",
|
||||
"provider-config/openai",
|
||||
"provider-config/openai-codex",
|
||||
"provider-config/openrouter",
|
||||
"provider-config/cerebras",
|
||||
"provider-config/deepseek",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: "Jupyter Notebooks"
|
||||
description: "AI-assisted editing of Jupyter notebooks with cell-level context awareness"
|
||||
---
|
||||
|
||||
# Jupyter Notebooks
|
||||
|
||||
Cline provides comprehensive support for Jupyter notebooks (`.ipynb` files), enabling AI-assisted editing with full cell-level context awareness. This feature was developed in collaboration with Amazon to bring AI coding assistance to data science workflows.
|
||||
|
||||
## Getting started
|
||||
|
||||
Jupyter notebook support is a built-in feature of Cline. To use it, you just need to have the Jupyter notebook extension enabled in VS Code. Once you open any `.ipynb` file, you'll see AI-assisted buttons in your notebook interface.
|
||||
|
||||
## How to use
|
||||
|
||||
### Generate Cell
|
||||
|
||||
Click the sparkle icon (✨) in the notebook toolbar to generate new cells with AI assistance.
|
||||
|
||||

|
||||
|
||||
**How it works:** The AI receives context from surrounding cells, so it understands the variables and imports already in scope. This means you can reference existing DataFrames, functions, and other objects without re-explaining them.
|
||||
|
||||
**Example prompt:** "Create a visualization showing the correlation matrix of numeric columns with a heatmap"
|
||||
|
||||
The cell is inserted with proper notebook JSON structure, preserving metadata and ready to execute.
|
||||
|
||||
### Explain Cell
|
||||
|
||||
Click the Explain button in any cell's title bar to get a detailed explanation of what the cell does.
|
||||
|
||||
This is useful for:
|
||||
|
||||
- Revisiting old notebooks
|
||||
- Onboarding to a teammate's analysis
|
||||
- Understanding complex transformations
|
||||
|
||||
**How it works:** Cline extracts the full cell context, including outputs, so explanations can reference actual results like column names, row counts, and computed values.
|
||||
|
||||
### Improve Cell
|
||||
|
||||
Click the Improve button in any cell's title bar to enhance existing cells with AI suggestions.
|
||||
|
||||

|
||||
|
||||
Use this to:
|
||||
|
||||
- Optimize slow pandas operations
|
||||
- Add error handling
|
||||
- Refactor for clarity
|
||||
- Convert loops to vectorized operations
|
||||
|
||||
**How it works:** Cline suggests improvements while preserving the cell's position and metadata in the notebook structure. The AI explains what was changed and why.
|
||||
|
||||
## How cell context works
|
||||
|
||||
Unlike traditional file editing, Jupyter notebooks are JSON documents containing arrays of cells. Each cell has its own type, source content, metadata, execution count, and outputs.
|
||||
|
||||
When you use a Jupyter command, Cline extracts structured context that includes:
|
||||
|
||||
- **Cell type** (code, markdown, or raw)
|
||||
- **Source content** as an array of lines
|
||||
- **Cell metadata** and configuration
|
||||
- **Execution count** for code cells
|
||||
- **Outputs** including data, text, and error traces
|
||||
|
||||
This structured representation allows the AI to understand not just the code, but its context within the notebook and its actual output.
|
||||
|
||||
### JSON structure preservation
|
||||
|
||||
Cline is designed to work carefully with the cell JSON structure, aiming to:
|
||||
|
||||
- Keep cell boundaries intact
|
||||
- Preserve execution counts
|
||||
- Maintain cell metadata
|
||||
- Keep outputs associated with their source cells
|
||||
|
||||
The AI is specifically prompted to preserve notebook structure, though you should always review changes to ensure your notebook format remains correct.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
You can bind any of these commands to keyboard shortcuts for faster access:
|
||||
|
||||
1. Open VS Code keyboard shortcuts (Cmd/Ctrl + K, Cmd/Ctrl + S)
|
||||
2. Search for `cline.jupyterGenerateCell`, `cline.jupyterExplainCell`, or `cline.jupyterImproveCell`
|
||||
3. Assign your preferred key combinations
|
||||
|
||||
## Tips for best results
|
||||
|
||||
**For Generate Cell:**
|
||||
- Be specific about what you want the cell to do
|
||||
- Reference existing variables by name (the AI can see them)
|
||||
- Mention preferred libraries if you have a preference (e.g., "use seaborn" or "use plotly")
|
||||
|
||||
**For Explain Cell:**
|
||||
- Works best on cells that have been executed (outputs provide additional context)
|
||||
- Good for complex chained operations like pandas groupby/merge sequences
|
||||
|
||||
**For Improve Cell:**
|
||||
- Mention what aspect you want to improve (performance, readability, error handling)
|
||||
- The AI will explain the changes it suggests
|
||||
|
||||
## Limitations
|
||||
|
||||
- Notebook support requires the Jupyter notebook extension to be enabled in VS Code
|
||||
- Cell context extraction depends on VS Code's notebook API
|
||||
- Very large notebooks may require more context than some models can handle efficiently
|
||||
|
||||
## Related
|
||||
|
||||
- [Cline Tools Guide](/exploring-clines-tools/cline-tools-guide) -- Overview of all Cline commands
|
||||
- [Model Selection Guide](/core-features/model-selection-guide) -- Choosing the right model for your workflow
|
||||
@@ -25,11 +25,28 @@ The easiest way to get started is with **Cline** as your provider:
|
||||
|
||||
## Alternative: Use Another Provider
|
||||
|
||||
If you prefer to use your own API keys, you can select from providers like:
|
||||
If you prefer to use your own accounts or API keys, you have several options:
|
||||
|
||||
### Sign in with OpenAI (Recommended)
|
||||
|
||||
The easiest way to use OpenAI models is with **OpenAI Codex**—no API keys needed:
|
||||
|
||||
1. Select **"OpenAI Codex"** from the API Provider dropdown
|
||||
2. Click **"Sign in with OpenAI"**
|
||||
3. Authorize Cline in your browser
|
||||
4. Choose your model
|
||||
|
||||
<Card title="OpenAI Codex Setup Guide" icon="key" href="/provider-config/openai-codex">
|
||||
See the full setup guide with screenshots and troubleshooting tips.
|
||||
</Card>
|
||||
|
||||
### Other Providers
|
||||
|
||||
You can also use API keys with these providers:
|
||||
|
||||
- **OpenRouter** - Great value, multiple models
|
||||
- **Anthropic** - Direct access to Claude models
|
||||
- **OpenAI** - Access to GPT models
|
||||
- **OpenAI** - Access to GPT models via API key
|
||||
- **Google Gemini** - Google's AI models
|
||||
- **Ollama** - Run models locally on your computer
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ Cerebras delivers the world's fastest AI inference through their revolutionary w
|
||||
|
||||
Cline supports the following Cerebras models:
|
||||
|
||||
- `zai-glm-4.6` - Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.
|
||||
- `zai-glm-4.7` - Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.
|
||||
- `qwen-3-235b-a22b-instruct-2507` - Advanced instruction-following model
|
||||
- `qwen-3-235b-a22b-thinking-2507` - Reasoning model with step-by-step thinking
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: "OpenAI Codex"
|
||||
sidebarTitle: "OpenAI Codex"
|
||||
description: "Connect your OpenAI account to Cline via OAuth for seamless access to OpenAI models."
|
||||
---
|
||||
|
||||
OpenAI Codex lets you connect your OpenAI account directly to Cline using OAuth, meaning you don't need to manage API keys. Simply sign in with your OpenAI account through a one-click browser authentication, and you'll automatically have access to all the models available on your OpenAI plan. No additional configuration required.
|
||||
|
||||
<Tip>
|
||||
If you prefer to use API keys instead, see the [OpenAI (API Key)](/provider-config/openai) provider configuration.
|
||||
</Tip>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An [OpenAI account](https://platform.openai.com/signup)
|
||||
- An active OpenAI subscription or API access plan
|
||||
|
||||
<Note>
|
||||
The models available to you depend on your OpenAI account's subscription tier. See [OpenAI's pricing page](https://openai.com/pricing) for details on what's included in each plan.
|
||||
</Note>
|
||||
|
||||
## Connecting Your OpenAI Account
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Cline Settings">
|
||||
Click the settings gear icon in the top-right corner of the Cline extension.
|
||||
</Step>
|
||||
<Step title="Select OpenAI Codex Provider">
|
||||
Choose **"OpenAI Codex"** from the "API Provider" dropdown menu.
|
||||
</Step>
|
||||
<Step title="Sign In with OpenAI">
|
||||
Click the **"Sign in with OpenAI"** button. This will open a browser window for authentication.
|
||||
</Step>
|
||||
<Step title="Authorize Cline">
|
||||
In the browser window that opens:
|
||||
1. Sign in to your OpenAI account (if not already signed in)
|
||||
2. Review the permissions Cline is requesting
|
||||
3. Click **"Authorize"** to grant access
|
||||
</Step>
|
||||
<Step title="Select Your Model">
|
||||
Once authorized, you'll be returned to Cline. Choose your desired model from the **"Model"** dropdown.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
If a model doesn't appear in your dropdown, it may not be available on your OpenAI subscription tier.
|
||||
</Note>
|
||||
|
||||
For the most current list of available models and their capabilities, see the official [OpenAI Models documentation](https://platform.openai.com/docs/models).
|
||||
|
||||
## Managing Your Connection
|
||||
|
||||
### Disconnecting Your Account
|
||||
|
||||
To disconnect your OpenAI account from Cline:
|
||||
|
||||
1. Open Cline Settings
|
||||
2. With OpenAI Codex selected as the provider, click **"Sign out"**
|
||||
3. Confirm the disconnection
|
||||
|
||||
This removes the OAuth connection. You can reconnect at any time by signing in again.
|
||||
|
||||
### Token Refresh
|
||||
|
||||
OAuth tokens are automatically refreshed by Cline. If you encounter authentication errors:
|
||||
|
||||
1. Try disconnecting and reconnecting your account
|
||||
2. Ensure your OpenAI account is in good standing
|
||||
3. Check that your subscription is active
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Authentication failed" error
|
||||
|
||||
- Ensure you're signing in with the correct OpenAI account
|
||||
- Check that your OpenAI subscription is active
|
||||
- Try clearing your browser cache and signing in again
|
||||
|
||||
### Models not appearing
|
||||
|
||||
- The available models depend on your OpenAI subscription tier
|
||||
- Some models may require specific plan upgrades
|
||||
- Check [OpenAI's pricing page](https://openai.com/pricing) for model availability by plan
|
||||
|
||||
### OAuth window doesn't open
|
||||
|
||||
- Check if pop-ups are blocked in your browser
|
||||
- Try using a different browser
|
||||
- Ensure you have a stable internet connection
|
||||
|
||||
### Connection keeps expiring
|
||||
|
||||
- This is rare but can happen if your OpenAI account session expired
|
||||
- Disconnect and reconnect to refresh your authentication
|
||||
|
||||
## Good to Know
|
||||
|
||||
Cline only requests the permissions necessary to make API calls on your behalf. Your OpenAI credentials are never stored by Cline.
|
||||
|
||||
When using OpenAI Codex, Cline accesses models through your OpenAI/ChatGPT subscription. There is no separate per-token API billing or consumption of OpenAI API credits for this provider; usage and limits are governed by your ChatGPT (or OpenAI account) subscription plan, not by Cline.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [OpenAI (API Key)](/provider-config/openai): Alternative configuration using API keys
|
||||
- [Model Selection Guide](/core-features/model-selection-guide): Help choosing the right model
|
||||
Generated
+48
-65
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.51.0",
|
||||
"version": "3.52.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.51.0",
|
||||
"version": "3.52.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
@@ -1184,6 +1184,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -2646,6 +2647,7 @@
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.9.15",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.7.8",
|
||||
"@types/node": ">=12.12.47"
|
||||
@@ -3229,6 +3231,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -3297,6 +3300,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -4912,8 +4916,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.52.4",
|
||||
@@ -4926,8 +4929,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.52.4",
|
||||
@@ -4940,8 +4942,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.52.4",
|
||||
@@ -4954,8 +4955,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.52.4",
|
||||
@@ -4968,8 +4968,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.52.4",
|
||||
@@ -4982,8 +4981,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.52.4",
|
||||
@@ -4996,8 +4994,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.52.4",
|
||||
@@ -5010,8 +5007,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5024,8 +5020,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.52.4",
|
||||
@@ -5038,8 +5033,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5052,8 +5046,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5066,8 +5059,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5080,8 +5072,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.52.4",
|
||||
@@ -5094,8 +5085,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5108,8 +5098,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5122,8 +5111,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.52.4",
|
||||
@@ -5136,8 +5124,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.52.4",
|
||||
@@ -5150,8 +5137,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.52.4",
|
||||
@@ -5164,8 +5150,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.52.4",
|
||||
@@ -5178,8 +5163,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5192,8 +5176,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.52.4",
|
||||
@@ -5206,8 +5189,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/ai-api": {
|
||||
"version": "2.1.0",
|
||||
@@ -6770,8 +6752,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/get-folder-size": {
|
||||
"version": "3.0.4",
|
||||
@@ -6803,6 +6784,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz",
|
||||
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -7519,6 +7501,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -8320,6 +8303,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@@ -8654,9 +8638,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio/node_modules/undici": {
|
||||
"version": "6.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
|
||||
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
@@ -9525,7 +9509,8 @@
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1342118",
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "5.2.0",
|
||||
@@ -12476,6 +12461,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
@@ -12709,6 +12695,7 @@
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
@@ -14860,9 +14847,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/open-graph-scraper/node_modules/undici": {
|
||||
"version": "6.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
|
||||
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
@@ -15430,6 +15417,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15611,7 +15599,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -15632,7 +15619,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -16322,7 +16308,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
|
||||
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -17800,7 +17785,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
@@ -17817,7 +17801,6 @@
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
@@ -18178,6 +18161,7 @@
|
||||
"version": "5.5.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -18250,9 +18234,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
|
||||
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
|
||||
"version": "7.19.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.19.0.tgz",
|
||||
"integrity": "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -18430,7 +18414,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -18505,7 +18488,6 @@
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
@@ -19193,6 +19175,7 @@
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
+9
-2
@@ -2,8 +2,12 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.51.0",
|
||||
"version": "3.52.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
"cli-ts",
|
||||
"webview-ui"
|
||||
],
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
},
|
||||
@@ -441,7 +445,10 @@
|
||||
"docs:check-links": "cd docs && npm run check",
|
||||
"docs:rename-file": "cd docs && npm run rename",
|
||||
"report-issue": "node scripts/report-issue.js",
|
||||
"storybook": "cd webview-ui && npm run storybook"
|
||||
"storybook": "cd webview-ui && npm run storybook",
|
||||
"cli:dev": "cd cli-ts && npm run dev",
|
||||
"cli:unlink": "cd cli-ts && npm run unlink",
|
||||
"build:cli": "npm run install:all && npm run protos && cd cli-ts && npm run build"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/shared/storage/state-keys.ts": [
|
||||
|
||||
@@ -106,6 +106,8 @@ message Secrets {
|
||||
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
|
||||
// script to regenerate this list.
|
||||
message Settings {
|
||||
reserved 152; // was hooks_enabled (removed - now always enabled on macOS/Linux)
|
||||
|
||||
optional string lite_llm_base_url = 1;
|
||||
optional bool lite_llm_use_prompt_cache = 2;
|
||||
optional string anthropic_base_url = 4;
|
||||
@@ -256,7 +258,6 @@ message Settings {
|
||||
optional FocusChainSettings focus_chain_settings = 149;
|
||||
optional string custom_prompt = 150;
|
||||
optional double auto_condense_threshold = 151;
|
||||
optional bool hooks_enabled = 152;
|
||||
optional bool subagents_enabled = 153;
|
||||
optional bool enable_parallel_tool_calling = 154;
|
||||
optional bool background_edit_enabled = 155;
|
||||
@@ -384,6 +385,8 @@ message UpdateTaskSettingsRequest {
|
||||
|
||||
// Message for updating settings
|
||||
message UpdateSettingsRequest {
|
||||
reserved 26; // was hooks_enabled (removed - now always enabled on macOS/Linux)
|
||||
|
||||
Metadata metadata = 1;
|
||||
optional ModelsApiConfiguration api_configuration = 2;
|
||||
optional string telemetry_setting = 3;
|
||||
@@ -408,7 +411,6 @@ message UpdateSettingsRequest {
|
||||
optional DictationSettings dictation_settings = 23;
|
||||
optional double auto_condense_threshold = 24;
|
||||
optional bool multi_root_enabled = 25;
|
||||
optional bool hooks_enabled = 26;
|
||||
optional string vscode_terminal_execution_mode = 27;
|
||||
optional int32 max_consecutive_mistakes = 28;
|
||||
optional bool subagents_enabled = 29;
|
||||
|
||||
@@ -70,6 +70,7 @@ enum ClineSay {
|
||||
HOOK_STATUS = 30;
|
||||
HOOK_OUTPUT_STREAM = 31;
|
||||
COMMAND_PERMISSION_DENIED = 32;
|
||||
CONDITIONAL_RULES_APPLIED = 33;
|
||||
}
|
||||
|
||||
// Enum for ClineSayTool tool types
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as vscode from "vscode"
|
||||
import {
|
||||
cleanupMcpMarketplaceCatalogFromGlobalState,
|
||||
migrateCustomInstructionsToGlobalRules,
|
||||
migrateHooksEnabledToBoolean,
|
||||
migrateTaskHistoryToFile,
|
||||
migrateWelcomeViewCompleted,
|
||||
migrateWorkspaceToGlobalStorage,
|
||||
@@ -70,9 +69,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
// Ensure taskHistory.json exists and migrate legacy state (runs once)
|
||||
await migrateTaskHistoryToFile(context)
|
||||
|
||||
// Migrate hooksEnabled from ClineFeatureSetting to boolean (one-time cleanup)
|
||||
await migrateHooksEnabledToBoolean(context)
|
||||
|
||||
// Clean up MCP marketplace catalog from global state (moved to disk cache)
|
||||
await cleanupMcpMarketplaceCatalogFromGlobalState(context)
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { describe, it } from "mocha"
|
||||
import { constructNewFileContent as cnfc } from "./diff"
|
||||
|
||||
async function cnfc2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
return cnfc(diffContent, originalContent, isFinal, "v2")
|
||||
const result = await cnfc(diffContent, originalContent, isFinal, "v2")
|
||||
return result.newContent
|
||||
}
|
||||
|
||||
describe("constructNewFileContent", () => {
|
||||
@@ -217,13 +218,13 @@ replaced
|
||||
} else {
|
||||
const result1 = await cnfc(diff, original, isFinal ?? true)
|
||||
const result2 = await cnfc2(diff, original, isFinal ?? true)
|
||||
const _equal = result1 === result2
|
||||
const _equal2 = result1 === expected
|
||||
const _equal = result1.newContent === result2
|
||||
const _equal2 = result1.newContent === expected
|
||||
// Verify both implementations produce same result
|
||||
expect(result1).to.equal(result2)
|
||||
expect(result1.newContent).to.equal(result2)
|
||||
|
||||
// Verify result matches expected
|
||||
expect(result1).to.equal(expected)
|
||||
expect(result1.newContent).to.equal(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -264,7 +265,7 @@ replaced`
|
||||
// Should still work and replace line2 with "replaced"
|
||||
const expected = "line1\nreplaced\nline3"
|
||||
|
||||
expect(result1).to.equal(expected)
|
||||
expect(result1.newContent).to.equal(expected)
|
||||
})
|
||||
|
||||
it("should handle missing final REPLACE marker with multiple lines of replacement", async () => {
|
||||
@@ -281,7 +282,7 @@ replaced`
|
||||
const result1 = await cnfc(diff, original, true) // isFinal = true
|
||||
const expected = "function test() {\n\tconst a = 42;\n\tconsole.log('updated');\n\treturn a;\n}"
|
||||
|
||||
expect(result1).to.equal(expected)
|
||||
expect(result1.newContent).to.equal(expected)
|
||||
})
|
||||
|
||||
// it("should NOT process incomplete replacement when isFinal is false", async () => {
|
||||
@@ -319,7 +320,7 @@ new second
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "first\nnew second\nthird\nnew fourth\n"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle multiple out-of-order replacements", async () => {
|
||||
@@ -342,7 +343,7 @@ fifth
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "one\nsecond\nthree\nfourth\nfifth\n"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle out-of-order replacements with indentation", async () => {
|
||||
@@ -360,7 +361,7 @@ fifth
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "function test() {\n\tconst a = 10;\n\tconst b = 2;\n\tconst c = 30;\n\n}"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle out-of-order replacements with empty lines", async () => {
|
||||
@@ -380,6 +381,6 @@ new body content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "header\nnew body content\nnew footer\n"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,17 @@ const SEARCH_BLOCK_START = "------- SEARCH"
|
||||
const SEARCH_BLOCK_END = "======="
|
||||
const REPLACE_BLOCK_END = "+++++++ REPLACE"
|
||||
|
||||
/**
|
||||
* Converts a character index in a string to a 1-based line number.
|
||||
* @param content - The full content string
|
||||
* @param charIndex - The character index in the content
|
||||
* @returns The 1-based line number where charIndex falls
|
||||
*/
|
||||
export function getLineNumberFromCharIndex(content: string, charIndex: number): number {
|
||||
if (charIndex <= 0) return 1
|
||||
return content.substring(0, charIndex).split("\n").length
|
||||
}
|
||||
|
||||
const SEARCH_BLOCK_CHAR = "-"
|
||||
const REPLACE_BLOCK_CHAR = "+"
|
||||
const LEGACY_SEARCH_BLOCK_CHAR = "<"
|
||||
@@ -236,7 +247,7 @@ export async function constructNewFileContent(
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
version: "v1" | "v2" = "v1",
|
||||
): Promise<string> {
|
||||
): Promise<{ newContent: string; matchIndices: number[] }> {
|
||||
const constructor = constructNewFileContentVersionMapping[version]
|
||||
if (!constructor) {
|
||||
throw new Error(`Invalid version '${version}' for file content constructor`)
|
||||
@@ -246,13 +257,17 @@ export async function constructNewFileContent(
|
||||
|
||||
const constructNewFileContentVersionMapping: Record<
|
||||
string,
|
||||
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<string>
|
||||
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<{ newContent: string; matchIndices: number[] }>
|
||||
> = {
|
||||
v1: constructNewFileContentV1,
|
||||
v2: constructNewFileContentV2,
|
||||
} as const
|
||||
|
||||
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
async function constructNewFileContentV1(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
): Promise<{ newContent: string; matchIndices: number[] }> {
|
||||
let result = ""
|
||||
let lastProcessedIndex = 0
|
||||
|
||||
@@ -470,7 +485,9 @@ async function constructNewFileContentV1(diffContent: string, originalContent: s
|
||||
result += originalContent.slice(currentPos)
|
||||
}
|
||||
|
||||
return result
|
||||
// Return all match indices from the replacements
|
||||
// This is used to determine the line numbers for each SEARCH/REPLACE block in the UI
|
||||
return { newContent: result, matchIndices: replacements.map((r) => r.start) }
|
||||
}
|
||||
|
||||
enum ProcessingState {
|
||||
@@ -566,7 +583,7 @@ class NewFileContentConstructor {
|
||||
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
|
||||
}
|
||||
|
||||
public getResult() {
|
||||
public getResult(): { newContent: string; matchIndices: number[] } {
|
||||
// If this is the final chunk, append any remaining original content
|
||||
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex)
|
||||
@@ -574,7 +591,10 @@ class NewFileContentConstructor {
|
||||
if (this.isFinal && this.state !== ProcessingState.Idle) {
|
||||
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
|
||||
}
|
||||
return this.result
|
||||
// Note: V2 implementation doesn't currently track match indices
|
||||
// For now, return empty array. If V2 becomes the default and we need line numbers,
|
||||
// we should add state to track all match indices.
|
||||
return { newContent: this.result, matchIndices: [] }
|
||||
}
|
||||
|
||||
private internalProcessLine(
|
||||
@@ -800,7 +820,11 @@ class NewFileContentConstructor {
|
||||
}
|
||||
}
|
||||
|
||||
export async function constructNewFileContentV2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
export async function constructNewFileContentV2(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
): Promise<{ newContent: string; matchIndices: number[] }> {
|
||||
const newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
|
||||
|
||||
const lines = diffContent.split("\n")
|
||||
|
||||
@@ -3,7 +3,8 @@ import { describe, it } from "mocha"
|
||||
import { constructNewFileContent as cnfc } from "./diff"
|
||||
|
||||
async function cnfc2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
return cnfc(diffContent, originalContent, isFinal, "v2")
|
||||
const result = await cnfc(diffContent, originalContent, isFinal, "v2")
|
||||
return result.newContent
|
||||
}
|
||||
|
||||
describe("Diff Format Edge Cases", () => {
|
||||
@@ -18,7 +19,7 @@ new content
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
@@ -33,7 +34,7 @@ new content
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
@@ -48,7 +49,7 @@ new content
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
@@ -62,7 +63,7 @@ new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
expect(result1).to.equal("before\nnew content\nafter")
|
||||
expect(result1.newContent).to.equal("before\nnew content\nafter")
|
||||
expect(result2).to.equal("before\nnew content\nafter")
|
||||
})
|
||||
|
||||
@@ -77,7 +78,7 @@ new content
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
@@ -92,7 +93,7 @@ new content
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
@@ -112,7 +113,7 @@ second new content
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nfirst new content\nafter\nsecond new content\nend"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
@@ -132,7 +133,7 @@ second new content
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nfirst new content\nafter\nsecond new content\nend"
|
||||
expect(result1).to.equal(expectedResult)
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { extractPathLikeStrings, RuleEvaluationContext, toWorkspaceRelativePosixPath } from "./rule-conditionals"
|
||||
|
||||
type WorkspaceRoot = { path: string }
|
||||
type WorkspaceManagerLike = { getRoots(): WorkspaceRoot[] }
|
||||
|
||||
type ClineMessageLike = {
|
||||
type: string
|
||||
ask?: string
|
||||
say?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
type MessageStateHandlerLike = {
|
||||
getClineMessages(): ClineMessageLike[]
|
||||
}
|
||||
|
||||
export type RuleContextBuilderDeps = {
|
||||
cwd: string
|
||||
messageStateHandler: MessageStateHandlerLike
|
||||
workspaceManager?: WorkspaceManagerLike
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the evaluation context used for conditional Cline Rules (e.g. YAML frontmatter `paths:`).
|
||||
*
|
||||
* Kept in the user-instructions domain so Task remains orchestration-focused.
|
||||
*
|
||||
* Path context is gathered from multiple sources in clineMessages:
|
||||
* - User messages (task, user_feedback)
|
||||
* - Visible/open tabs
|
||||
* - Tool results (say="tool") - completed operations
|
||||
* - Tool requests (ask="tool") - pending operations (captures intent before execution)
|
||||
*/
|
||||
export class RuleContextBuilder {
|
||||
/**
|
||||
* Maximum number of path candidates to consider for rule activation.
|
||||
* This cap prevents performance degradation in long-running tasks with many file operations.
|
||||
*/
|
||||
static readonly MAX_RULE_PATH_CANDIDATES = 100
|
||||
|
||||
static async buildEvaluationContext(deps: RuleContextBuilderDeps): Promise<RuleEvaluationContext> {
|
||||
return {
|
||||
paths: await RuleContextBuilder.getRulePathContext(deps),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse apply_patch input to extract target file paths from patch headers.
|
||||
* Matches lines like: *** Add File: path/to/file.ts
|
||||
*/
|
||||
private static extractPathsFromApplyPatch(input: string): string[] {
|
||||
if (typeof input !== "string" || !input) return []
|
||||
|
||||
const paths: string[] = []
|
||||
const fileHeaderRegex = /^\*\*\* (?:Add|Update|Delete) File: (.+?)(?:\n|$)/gm
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = fileHeaderRegex.exec(input))) {
|
||||
const filePath = (m[1] || "").trim()
|
||||
if (filePath) {
|
||||
paths.push(filePath)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
private static async getRulePathContext(deps: RuleContextBuilderDeps): Promise<string[]> {
|
||||
const candidates: string[] = []
|
||||
const clineMessages = deps.messageStateHandler.getClineMessages()
|
||||
|
||||
// (1) Current-turn user message evidence:
|
||||
// Use the most recent user-authored text (initial task or subsequent feedback).
|
||||
// NOTE: We intentionally prefer the latest user_feedback over the original task to
|
||||
// support first-turn activation on later turns.
|
||||
const lastUserMsg = [...clineMessages]
|
||||
.reverse()
|
||||
.find((m) => m.type === "say" && (m.say === "user_feedback" || m.say === "task") && typeof m.text === "string")
|
||||
if (lastUserMsg?.text) {
|
||||
candidates.push(...extractPathLikeStrings(lastUserMsg.text))
|
||||
}
|
||||
|
||||
// (2) Visible + open tabs
|
||||
const roots = deps.workspaceManager?.getRoots().map((r) => r.path) ?? [deps.cwd]
|
||||
const rawVisiblePaths = (await HostProvider.window.getVisibleTabs({}))?.paths ?? []
|
||||
const rawOpenTabPaths = (await HostProvider.window.getOpenTabs({}))?.paths ?? []
|
||||
for (const abs of [...rawVisiblePaths, ...rawOpenTabPaths]) {
|
||||
for (const root of roots) {
|
||||
const rel = toWorkspaceRelativePosixPath(abs, root)
|
||||
if (rel) {
|
||||
candidates.push(rel)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Files edited by Cline during this task (completed operations):
|
||||
// Parse say="tool" messages for tool results indicating file operations.
|
||||
for (const msg of clineMessages) {
|
||||
if (msg.type !== "say" || msg.say !== "tool" || !msg.text) continue
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as { tool?: string; path?: string }
|
||||
if (
|
||||
(tool.tool === "editedExistingFile" || tool.tool === "newFileCreated" || tool.tool === "fileDeleted") &&
|
||||
tool.path
|
||||
) {
|
||||
candidates.push(tool.path)
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// (4) Tool requests (pending operations):
|
||||
// Parse ask="tool" messages to capture the assistant's intent BEFORE tool execution.
|
||||
// This enables rule activation even when:
|
||||
// - The tool hasn't completed yet
|
||||
// - The tool fails (intent was still expressed)
|
||||
// - Files don't exist yet (new file creation)
|
||||
for (const msg of clineMessages) {
|
||||
if (msg.type !== "ask" || msg.ask !== "tool" || !msg.text) continue
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as {
|
||||
tool?: string
|
||||
path?: string
|
||||
content?: string // apply_patch stores patch content here
|
||||
}
|
||||
|
||||
// Extract path from standard file tools
|
||||
if (tool.path) {
|
||||
candidates.push(tool.path)
|
||||
}
|
||||
|
||||
// Handle apply_patch specially: parse patch headers for file paths
|
||||
if (tool.tool === "applyPatch" && tool.content) {
|
||||
candidates.push(...RuleContextBuilder.extractPathsFromApplyPatch(tool.content))
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize/dedupe/cap
|
||||
const seen = new Set<string>()
|
||||
const normalized: string[] = []
|
||||
for (const c of candidates) {
|
||||
const posix = c.replace(/\\/g, "/").replace(/^\//, "")
|
||||
if (!posix || posix === "/") continue
|
||||
if (seen.has(posix)) continue
|
||||
seen.add(posix)
|
||||
normalized.push(posix)
|
||||
if (normalized.length >= RuleContextBuilder.MAX_RULE_PATH_CANDIDATES) break
|
||||
}
|
||||
return normalized.sort()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
import { RuleContextBuilder, RuleContextBuilderDeps } from "../RuleContextBuilder"
|
||||
|
||||
// Mock HostProvider to avoid actual VSCode API calls
|
||||
const mockHostProvider = {
|
||||
window: {
|
||||
getVisibleTabs: sinon.stub().resolves({ paths: [] }),
|
||||
getOpenTabs: sinon.stub().resolves({ paths: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
describe("RuleContextBuilder", () => {
|
||||
let hostProviderStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
// Stub HostProvider to use mock
|
||||
hostProviderStub = sinon.stub(require("@/hosts/host-provider"), "HostProvider").value(mockHostProvider)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("getRulePathContext from ask='tool' messages", () => {
|
||||
it("extracts path from ask='tool' message with write_to_file", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src/components/Button.tsx",
|
||||
content: "// new file",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/components/Button.tsx")
|
||||
})
|
||||
|
||||
it("extracts paths from multiple sequential tool requests", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src/utils/helper.ts",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "replace_in_file",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/utils/helper.ts")
|
||||
expect(context.paths).to.include("src/index.ts")
|
||||
})
|
||||
|
||||
it("handles malformed JSON gracefully", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: "not valid json {{{",
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "valid/path.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// Should not throw and should extract the valid path
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("valid/path.ts")
|
||||
})
|
||||
|
||||
it("extracts paths from apply_patch tool request", async () => {
|
||||
const patchContent = `*** Add File: src/new-feature.ts
|
||||
+const x = 1
|
||||
|
||||
*** Update File: src/existing.ts
|
||||
---
|
||||
+++
|
||||
@@ 1,1 @@
|
||||
-const y = 2
|
||||
+const y = 3`
|
||||
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: patchContent,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/new-feature.ts")
|
||||
expect(context.paths).to.include("src/existing.ts")
|
||||
})
|
||||
|
||||
it("deduplicates paths from multiple sources", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
text: "Update src/index.ts",
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "editedExistingFile",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
// Should only appear once despite being in 3 messages
|
||||
const indexCount = (context.paths ?? []).filter((p) => p === "src/index.ts").length
|
||||
expect(indexCount).to.equal(1)
|
||||
})
|
||||
|
||||
it("normalizes Windows-style paths to POSIX", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src\\components\\Button.tsx",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/components/Button.tsx")
|
||||
})
|
||||
|
||||
it("respects MAX_RULE_PATH_CANDIDATES limit", async () => {
|
||||
// Create more messages than the limit
|
||||
const messages: Array<{ type: string; ask: string; text: string }> = []
|
||||
for (let i = 0; i < RuleContextBuilder.MAX_RULE_PATH_CANDIDATES + 50; i++) {
|
||||
messages.push({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: `src/file${i}.ts`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => messages,
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect((context.paths ?? []).length).to.be.at.most(RuleContextBuilder.MAX_RULE_PATH_CANDIDATES)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractPathsFromApplyPatch", () => {
|
||||
it("extracts paths from Add File headers", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: "*** Add File: src/new.ts\n+content",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/new.ts")
|
||||
})
|
||||
|
||||
it("extracts paths from Update File headers", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: "*** Update File: src/existing.ts\n--- \n+++ \n@@ 1,1 @@\n-old\n+new",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/existing.ts")
|
||||
})
|
||||
|
||||
it("extracts paths from Delete File headers", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: "*** Delete File: src/old.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/old.ts")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -50,5 +50,24 @@ describe("rule-conditionals", () => {
|
||||
const res = extractPathLikeStrings("see https://example.com/a/b and edit src/index.ts")
|
||||
expect(res).to.deep.equal(["src/index.ts"])
|
||||
})
|
||||
|
||||
it("ignores fenced code blocks", () => {
|
||||
const text =
|
||||
"Please update src/index.ts\n\n```ts\n// example code\nconst p = 'apps/web/src/App.tsx'\n// also: packages/foo/src\n```\n\nThanks!"
|
||||
const res = extractPathLikeStrings(text)
|
||||
expect(res).to.deep.equal(["src/index.ts"])
|
||||
})
|
||||
|
||||
it("does not extract URLs inside code fences", () => {
|
||||
const text = "```\nSee https://example.com/a/b and src/index.ts\n```\nBut edit docs/readme.md"
|
||||
const res = extractPathLikeStrings(text)
|
||||
expect(res).to.deep.equal(["docs/readme.md"])
|
||||
})
|
||||
|
||||
it("extracts paths from stack traces (outside code fences)", () => {
|
||||
const text = "Error: boom\n at foo (src/index.ts:12:3)\n at bar (apps/web/src/App.tsx:5:1)"
|
||||
const res = extractPathLikeStrings(text)
|
||||
expect(res).to.deep.equal(["src/index.ts", "apps/web/src/App.tsx"])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("rule loading with paths frontmatter", () => {
|
||||
expect(res1.content).to.contain("universal.md")
|
||||
expect(res1.content).to.contain("scoped.md")
|
||||
expect(res1.content).to.not.contain("paths:")
|
||||
expect(res1.activatedConditionalRules.map((r) => r.name)).to.include("scoped.md")
|
||||
expect(res1.activatedConditionalRules.map((r) => r.name)).to.include("global:scoped.md")
|
||||
|
||||
const res2 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["docs/readme.md"] },
|
||||
@@ -109,7 +109,7 @@ describe("rule loading with paths frontmatter", () => {
|
||||
evaluationContext: { paths: ["src/index.ts"] },
|
||||
})
|
||||
|
||||
expect(res.activatedConditionalRules.map((r) => r.name)).to.deep.equal(files)
|
||||
expect(res.activatedConditionalRules.map((r) => r.name)).to.deep.equal(files.map((f) => `global:${f}`))
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { getRuleFilesTotalContent, synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import {
|
||||
ActivatedConditionalRule,
|
||||
getRemoteRulesTotalContentWithMetadata,
|
||||
getRuleFilesTotalContentWithMetadata,
|
||||
RULE_SOURCE_PREFIX,
|
||||
RuleLoadResultWithInstructions,
|
||||
synchronizeRuleToggles,
|
||||
} from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import { StateManager } from "@core/storage/StateManager"
|
||||
@@ -7,18 +14,35 @@ import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { parseYamlFrontmatter } from "./frontmatter"
|
||||
import { evaluateRuleConditionals, type RuleEvaluationContext } from "./rule-conditionals"
|
||||
|
||||
export const getGlobalClineRules = async (globalClineRulesFilePath: string, toggles: ClineRulesToggles) => {
|
||||
export const getGlobalClineRules = async (
|
||||
globalClineRulesFilePath: string,
|
||||
toggles: ClineRulesToggles,
|
||||
opts?: { evaluationContext?: RuleEvaluationContext },
|
||||
): Promise<RuleLoadResultWithInstructions> => {
|
||||
let combinedContent = ""
|
||||
const activatedConditionalRules: ActivatedConditionalRule[] = []
|
||||
|
||||
// 1. Get file-based rules
|
||||
if (await fileExistsAtPath(globalClineRulesFilePath)) {
|
||||
if (await isDirectory(globalClineRulesFilePath)) {
|
||||
try {
|
||||
const rulesFilePaths = await readDirectory(globalClineRulesFilePath)
|
||||
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, globalClineRulesFilePath, toggles)
|
||||
if (rulesFilesTotalContent) {
|
||||
combinedContent = rulesFilesTotalContent
|
||||
// Note: ruleNamePrefix explicitly set to "global" for clarity (matches the default)
|
||||
const rulesFilesTotal = await getRuleFilesTotalContentWithMetadata(
|
||||
rulesFilePaths,
|
||||
globalClineRulesFilePath,
|
||||
toggles,
|
||||
{
|
||||
evaluationContext: opts?.evaluationContext,
|
||||
ruleNamePrefix: "global",
|
||||
},
|
||||
)
|
||||
if (rulesFilesTotal.content) {
|
||||
combinedContent = rulesFilesTotal.content
|
||||
activatedConditionalRules.push(...rulesFilesTotal.activatedConditionalRules)
|
||||
}
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules directory at ${globalClineRulesFilePath}`)
|
||||
@@ -33,31 +57,35 @@ export const getGlobalClineRules = async (globalClineRulesFilePath: string, togg
|
||||
const remoteConfigSettings = stateManager.getRemoteConfigSettings()
|
||||
const remoteRules = remoteConfigSettings.remoteGlobalRules || []
|
||||
const remoteToggles = stateManager.getGlobalStateKey("remoteRulesToggles") || {}
|
||||
|
||||
for (const rule of remoteRules) {
|
||||
// If alwaysEnabled, always include; otherwise check toggle
|
||||
const isEnabled = rule.alwaysEnabled || remoteToggles[rule.name] !== false
|
||||
|
||||
if (isEnabled) {
|
||||
if (combinedContent) {
|
||||
combinedContent += "\n\n"
|
||||
}
|
||||
combinedContent += `${rule.name}\n${rule.contents}`
|
||||
}
|
||||
const remoteResult = getRemoteRulesTotalContentWithMetadata(remoteRules, remoteToggles, {
|
||||
evaluationContext: opts?.evaluationContext,
|
||||
})
|
||||
if (remoteResult.content) {
|
||||
if (combinedContent) combinedContent += "\n\n"
|
||||
combinedContent += remoteResult.content
|
||||
activatedConditionalRules.push(...remoteResult.activatedConditionalRules)
|
||||
}
|
||||
|
||||
// 3. Return formatted instructions
|
||||
if (combinedContent) {
|
||||
return formatResponse.clineRulesGlobalDirectoryInstructions(globalClineRulesFilePath, combinedContent)
|
||||
if (!combinedContent) {
|
||||
return { instructions: undefined, activatedConditionalRules: [] }
|
||||
}
|
||||
|
||||
return undefined
|
||||
return {
|
||||
instructions: formatResponse.clineRulesGlobalDirectoryInstructions(globalClineRulesFilePath, combinedContent),
|
||||
activatedConditionalRules,
|
||||
}
|
||||
}
|
||||
|
||||
export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles) => {
|
||||
export const getLocalClineRules = async (
|
||||
cwd: string,
|
||||
toggles: ClineRulesToggles,
|
||||
opts?: { evaluationContext?: RuleEvaluationContext },
|
||||
): Promise<RuleLoadResultWithInstructions> => {
|
||||
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
|
||||
|
||||
let clineRulesFileInstructions: string | undefined
|
||||
let instructions: string | undefined
|
||||
const activatedConditionalRules: ActivatedConditionalRule[] = []
|
||||
|
||||
if (await fileExistsAtPath(clineRulesFilePath)) {
|
||||
if (await isDirectory(clineRulesFilePath)) {
|
||||
@@ -68,9 +96,13 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
|
||||
[".clinerules", "skills"],
|
||||
])
|
||||
|
||||
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, cwd, toggles)
|
||||
if (rulesFilesTotalContent) {
|
||||
clineRulesFileInstructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotalContent)
|
||||
const rulesFilesTotal = await getRuleFilesTotalContentWithMetadata(rulesFilePaths, cwd, toggles, {
|
||||
evaluationContext: opts?.evaluationContext,
|
||||
ruleNamePrefix: "workspace",
|
||||
})
|
||||
if (rulesFilesTotal.content) {
|
||||
instructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotal.content)
|
||||
activatedConditionalRules.push(...rulesFilesTotal.activatedConditionalRules)
|
||||
}
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
|
||||
@@ -78,9 +110,30 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
|
||||
} else {
|
||||
try {
|
||||
if (clineRulesFilePath in toggles && toggles[clineRulesFilePath] !== false) {
|
||||
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
|
||||
if (ruleFileContent) {
|
||||
clineRulesFileInstructions = formatResponse.clineRulesLocalFileInstructions(cwd, ruleFileContent)
|
||||
const raw = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
|
||||
if (raw) {
|
||||
// Keep single-file .clinerules behavior consistent with directory/remote rules:
|
||||
// - Parse YAML frontmatter (fail-open on parse errors)
|
||||
// - Evaluate conditionals against the request's evaluation context
|
||||
const parsed = parseYamlFrontmatter(raw)
|
||||
if (parsed.hadFrontmatter && parsed.parseError) {
|
||||
// Fail-open: preserve the raw contents so the LLM can still see the author's intent.
|
||||
instructions = formatResponse.clineRulesLocalFileInstructions(cwd, raw)
|
||||
} else {
|
||||
const { passed, matchedConditions } = evaluateRuleConditionals(
|
||||
parsed.data,
|
||||
opts?.evaluationContext ?? {},
|
||||
)
|
||||
if (passed) {
|
||||
instructions = formatResponse.clineRulesLocalFileInstructions(cwd, parsed.body.trim())
|
||||
if (parsed.hadFrontmatter && Object.keys(matchedConditions).length > 0) {
|
||||
activatedConditionalRules.push({
|
||||
name: `${RULE_SOURCE_PREFIX.workspace}:${GlobalFileNames.clineRules}`,
|
||||
matchedConditions,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -89,7 +142,7 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
|
||||
}
|
||||
}
|
||||
|
||||
return clineRulesFileInstructions
|
||||
return { instructions, activatedConditionalRules }
|
||||
}
|
||||
|
||||
export async function refreshClineRulesToggles(
|
||||
|
||||
@@ -46,6 +46,7 @@ const evaluatePathsConditional: ConditionalEvaluatorWithMatch = (frontmatterValu
|
||||
}
|
||||
|
||||
const patterns = frontmatterValue.map((p) => p.trim()).filter(Boolean)
|
||||
|
||||
// Policy:
|
||||
// - `paths` omitted => universal (because this evaluator is never invoked)
|
||||
// - `paths: []` (or `paths` that trims to no usable patterns) => match nothing (fail-closed)
|
||||
@@ -111,8 +112,13 @@ export function evaluateRuleConditionals(
|
||||
export function extractPathLikeStrings(text: string): string[] {
|
||||
if (!text) return []
|
||||
|
||||
// 0) Strip fenced code blocks to avoid extracting paths from pasted code.
|
||||
// This dramatically reduces false positives from snippets containing `foo/bar` or `a.b.c`.
|
||||
// Note: We intentionally keep this simple and fail-open (if fences are unbalanced, we do nothing special).
|
||||
const withoutCodeFences = text.replace(/```[\s\S]*?```/g, " ")
|
||||
|
||||
// 1) Remove URLs to avoid false positives.
|
||||
const withoutUrls = text.replace(/\b\w+:\/\/[^\s]+/g, " ")
|
||||
const withoutUrls = withoutCodeFences.replace(/\b\w+:\/\/[^\s]+/g, " ")
|
||||
|
||||
// 2) Match tokens that look like paths.
|
||||
// - Either contain at least one slash (e.g. src/index.ts)
|
||||
|
||||
@@ -153,25 +153,43 @@ export type ActivatedConditionalRule = {
|
||||
matchedConditions: Record<string, string[]>
|
||||
}
|
||||
|
||||
// Prefixes used to make activated conditional rule identifiers self-explanatory in the UI.
|
||||
// NOTE: These are display identifiers (not toggle keys).
|
||||
export const RULE_SOURCE_PREFIX = {
|
||||
workspace: "workspace",
|
||||
global: "global",
|
||||
remote: "remote",
|
||||
} as const
|
||||
|
||||
export type RuleLoadResult = {
|
||||
content: string
|
||||
activatedConditionalRules: ActivatedConditionalRule[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for rule loading functions that return formatted instructions.
|
||||
* Used by getGlobalClineRules and getLocalClineRules.
|
||||
*/
|
||||
export type RuleLoadResultWithInstructions = {
|
||||
instructions?: string
|
||||
activatedConditionalRules: ActivatedConditionalRule[]
|
||||
}
|
||||
|
||||
export const getRuleFilesTotalContentWithMetadata = async (
|
||||
rulesFilePaths: string[],
|
||||
basePath: string,
|
||||
toggles: ClineRulesToggles,
|
||||
opts?: { evaluationContext?: RuleEvaluationContext },
|
||||
opts?: { evaluationContext?: RuleEvaluationContext; ruleNamePrefix?: keyof typeof RULE_SOURCE_PREFIX },
|
||||
): Promise<RuleLoadResult> => {
|
||||
const evaluationContext = opts?.evaluationContext ?? {}
|
||||
const prefix = RULE_SOURCE_PREFIX[opts?.ruleNamePrefix ?? "global"]
|
||||
|
||||
type RuleLoadPart = {
|
||||
contentPart: string | null
|
||||
activatedRule: ActivatedConditionalRule | null
|
||||
}
|
||||
|
||||
const parts: RuleLoadPart[] = await Promise.all(
|
||||
const parts = await Promise.all(
|
||||
rulesFilePaths.map(async (filePath) => {
|
||||
const ruleFilePath = path.resolve(basePath, filePath)
|
||||
const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
|
||||
@@ -199,7 +217,7 @@ export const getRuleFilesTotalContentWithMetadata = async (
|
||||
}
|
||||
const activatedRule =
|
||||
hadFrontmatter && Object.keys(matchedConditions).length > 0
|
||||
? { name: ruleFilePathRelative, matchedConditions }
|
||||
? { name: `${prefix}:${ruleFilePathRelative}`, matchedConditions }
|
||||
: null
|
||||
|
||||
return { contentPart: `${ruleFilePathRelative}\n${body.trim()}`, activatedRule }
|
||||
@@ -245,7 +263,7 @@ export function getRemoteRulesTotalContentWithMetadata(
|
||||
if (!passed) continue
|
||||
|
||||
if (hadFrontmatter && Object.keys(matchedConditions).length > 0) {
|
||||
activatedConditionalRules.push({ name: rule.name, matchedConditions })
|
||||
activatedConditionalRules.push({ name: `${RULE_SOURCE_PREFIX.remote}:${rule.name}`, matchedConditions })
|
||||
}
|
||||
|
||||
if (combinedContent) combinedContent += "\n\n"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { buildApiHandler } from "@core/api"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { tryAcquireTaskLockWithRetry } from "@core/task/TaskLockUtils"
|
||||
import { detectWorkspaceRoots } from "@core/workspace/detection"
|
||||
import { setupWorkspaceManager } from "@core/workspace/setup"
|
||||
@@ -953,7 +954,7 @@ export class Controller {
|
||||
user: this.stateManager.getGlobalSettingsKey("worktreesEnabled"),
|
||||
featureFlag: featureFlagsService.getWorktreesEnabled(),
|
||||
},
|
||||
hooksEnabled: this.stateManager.getGlobalSettingsKey("hooksEnabled"),
|
||||
hooksEnabled: getHooksEnabledSafe(),
|
||||
lastDismissedInfoBannerVersion,
|
||||
lastDismissedModelBannerVersion,
|
||||
remoteConfigSettings: this.stateManager.getRemoteConfigSettings(),
|
||||
|
||||
@@ -339,17 +339,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("multiRootEnabled", !!request.multiRootEnabled)
|
||||
}
|
||||
|
||||
if (request.hooksEnabled !== undefined) {
|
||||
const isEnabled = !!request.hooksEnabled
|
||||
|
||||
// Platform validation: Only allow enabling hooks on macOS and Linux
|
||||
if (isEnabled && process.platform === "win32") {
|
||||
throw new Error("Hooks are not yet supported on Windows")
|
||||
}
|
||||
|
||||
controller.stateManager.setGlobalState("hooksEnabled", isEnabled)
|
||||
}
|
||||
|
||||
if (request.subagentsEnabled !== undefined) {
|
||||
const currentSettings = controller.stateManager.getGlobalSettingsKey("subagentsEnabled")
|
||||
const wasEnabled = currentSettings ?? false
|
||||
|
||||
@@ -80,18 +80,6 @@ export async function newTask(controller: Controller, request: NewTaskRequest):
|
||||
...(request.taskSettings?.actModeApiProvider !== undefined && {
|
||||
actModeApiProvider: convertProtoToApiProvider(request.taskSettings.actModeApiProvider),
|
||||
}),
|
||||
...(request.taskSettings?.hooksEnabled !== undefined && {
|
||||
hooksEnabled: (() => {
|
||||
const isEnabled = !!request.taskSettings.hooksEnabled
|
||||
|
||||
// Platform validation: Only allow enabling hooks on macOS and Linux
|
||||
if (isEnabled && process.platform === "win32") {
|
||||
throw new Error("Hooks are not yet supported on Windows")
|
||||
}
|
||||
|
||||
return isEnabled
|
||||
})(),
|
||||
}),
|
||||
}).filter(([_, value]) => value !== undefined),
|
||||
)
|
||||
|
||||
|
||||
@@ -33,18 +33,8 @@ describe("hooks-utils", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("should return false when user setting is true", () => {
|
||||
const result = getHooksEnabledSafe(true)
|
||||
result.should.be.false()
|
||||
})
|
||||
|
||||
it("should return false when user setting is false", () => {
|
||||
const result = getHooksEnabledSafe(false)
|
||||
result.should.be.false()
|
||||
})
|
||||
|
||||
it("should return false when user setting is undefined", () => {
|
||||
const result = getHooksEnabledSafe(undefined)
|
||||
it("should return false", () => {
|
||||
const result = getHooksEnabledSafe()
|
||||
result.should.be.false()
|
||||
})
|
||||
})
|
||||
@@ -63,18 +53,13 @@ describe("hooks-utils", () => {
|
||||
})
|
||||
|
||||
it("should return true when user setting is true", () => {
|
||||
const result = getHooksEnabledSafe(true)
|
||||
const result = getHooksEnabledSafe()
|
||||
result.should.be.true()
|
||||
})
|
||||
|
||||
it("should return false when user setting is false", () => {
|
||||
const result = getHooksEnabledSafe(false)
|
||||
result.should.be.false()
|
||||
})
|
||||
|
||||
it("should return false when user setting is undefined (default)", () => {
|
||||
const result = getHooksEnabledSafe(undefined)
|
||||
result.should.be.false()
|
||||
it("should return true", () => {
|
||||
const result = getHooksEnabledSafe()
|
||||
result.should.be.true()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -88,10 +73,7 @@ describe("hooks-utils", () => {
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// macOS should respect user setting
|
||||
getHooksEnabledSafe(true).should.be.true()
|
||||
getHooksEnabledSafe(false).should.be.false()
|
||||
getHooksEnabledSafe(undefined).should.be.false()
|
||||
getHooksEnabledSafe().should.be.true()
|
||||
})
|
||||
|
||||
it("should handle Linux platform correctly", () => {
|
||||
@@ -101,10 +83,7 @@ describe("hooks-utils", () => {
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Linux should respect user setting
|
||||
getHooksEnabledSafe(true).should.be.true()
|
||||
getHooksEnabledSafe(false).should.be.false()
|
||||
getHooksEnabledSafe(undefined).should.be.false()
|
||||
getHooksEnabledSafe().should.be.true()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,14 +4,21 @@
|
||||
* Hooks are not yet supported on Windows, so this function ensures they
|
||||
* remain disabled on that platform regardless of user settings.
|
||||
*
|
||||
* @param userSetting The user's hooks enabled setting from global state (may be undefined)
|
||||
* @returns true if hooks are enabled and supported on this platform, false otherwise
|
||||
*/
|
||||
export function getHooksEnabledSafe(userSetting: boolean | undefined): boolean {
|
||||
// Handle legacy object format: {user: boolean, featureFlag: boolean}, which
|
||||
// can occur if the migration hasn't run yet or if reading from an old state.
|
||||
const booleanValue = Boolean((userSetting as any)?.user ?? userSetting)
|
||||
export function getHooksEnabledSafe(): boolean {
|
||||
// Hooks are not yet supported on Windows.
|
||||
//
|
||||
// NOTE: This function is the single choke point used by the task runtime and
|
||||
// webview state to determine the *effective* hooks setting. Hard-coding here
|
||||
// ensures hooks are always enabled everywhere (TaskStart/Resume/Cancel,
|
||||
// PreToolUse/PostToolUse, UI grouping) without having to override multiple
|
||||
// call sites.
|
||||
if (process.platform === "win32") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Force hooks to false on Windows (not yet supported)
|
||||
return process.platform === "win32" ? false : booleanValue
|
||||
// Hard-coded: always enable hooks on supported platforms (macOS/Linux),
|
||||
// regardless of persisted user setting.
|
||||
return true
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,9 @@
|
||||
import { ParseEntry, parse } from "shell-quote"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { COMMAND_PERMISSIONS_ENV_VAR, CommandPermissionConfig, PermissionValidationResult, ShellOperatorMatch } from "./types"
|
||||
|
||||
const OPERATOR_DESCRIPTIONS: Record<string, string> = {
|
||||
";": "command chaining (semicolon)",
|
||||
"&&": "command chaining (AND)",
|
||||
"||": "command chaining (OR)",
|
||||
"|": "pipe",
|
||||
">": "output redirection",
|
||||
">>": "append redirection",
|
||||
"<": "input redirection",
|
||||
">&": "file descriptor redirection",
|
||||
"<&": "file descriptor duplication",
|
||||
"|&": "pipe with stderr",
|
||||
}
|
||||
const REDIRECT_OPERATORS = new Set([">", ">>", "<", ">&", "<&", "|&", "<(", ">("])
|
||||
const COMMAND_SEPARATOR_OPERATORS = new Set(["&&", "||", "|", ";"])
|
||||
|
||||
const LINE_SEPARATOR_REGEX = /[\n\r\u2028\u2029\u0085]/
|
||||
const LINE_SEPARATOR_DESCRIPTIONS: Record<string, ShellOperatorMatch> = {
|
||||
@@ -23,19 +14,29 @@ const LINE_SEPARATOR_DESCRIPTIONS: Record<string, ShellOperatorMatch> = {
|
||||
"\u0085": { operator: "U+0085", description: "unicode next line" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing a command into segments (recursive structure)
|
||||
*/
|
||||
interface ParsedCommand {
|
||||
segments: string[] // Individual commands between operators
|
||||
subshells: ParsedCommand[] // Recursively parsed contents of (...) and $(...)
|
||||
hasRedirects: boolean // Whether redirect operators (>, >>, <, etc.) were found
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls command execution permissions based on environment variable configuration.
|
||||
* Uses glob pattern matching to allow/deny specific commands.
|
||||
*
|
||||
* Configuration is read from the CLINE_COMMAND_PERMISSIONS environment variable.
|
||||
* Format: {"allow": ["pattern1", "pattern2"], "deny": ["pattern3"]}
|
||||
* Format: {"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}
|
||||
*
|
||||
* Rule evaluation:
|
||||
* 1. If shell operators are detected outside quotes → DENIED (security)
|
||||
* 2. If deny rules are defined and command matches a deny pattern → DENIED
|
||||
* 3. If allow rules are defined and command matches an allow pattern → ALLOWED
|
||||
* 4. If allow rules are defined but command doesn't match any → DENIED (deny by default)
|
||||
* 5. If no rules are defined (env var not set) → ALLOWED (backward compatibility)
|
||||
* Rule evaluation for chained commands (e.g., "cd /tmp && npm test"):
|
||||
* 1. Parse command into segments split by operators (&&, ||, |, ;)
|
||||
* 2. Check for dangerous characters (backticks outside single quotes, newlines outside quotes)
|
||||
* 3. If redirects detected and allowRedirects !== true → DENIED
|
||||
* 4. Validate EACH segment against allow/deny rules - ALL must pass
|
||||
* 5. Recursively validate any subshell contents
|
||||
* 6. If no rules are defined (env var not set) → ALLOWED (backward compatibility)
|
||||
*/
|
||||
export class CommandPermissionController {
|
||||
private config: CommandPermissionConfig | null = null
|
||||
@@ -59,7 +60,7 @@ export class CommandPermissionController {
|
||||
return {
|
||||
allow: Array.isArray(parsed.allow) ? parsed.allow : undefined,
|
||||
deny: Array.isArray(parsed.deny) ? parsed.deny : undefined,
|
||||
allowOperators: Array.isArray(parsed.allowOperators) ? parsed.allowOperators : undefined,
|
||||
allowRedirects: typeof parsed.allowRedirects === "boolean" ? parsed.allowRedirects : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse ${COMMAND_PERMISSIONS_ENV_VAR}:`, error)
|
||||
@@ -68,16 +69,9 @@ export class CommandPermissionController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operator is in the allowOperators list
|
||||
* @param operator - The operator to check
|
||||
* @returns true if the operator is allowed
|
||||
*/
|
||||
private isOperatorAllowed(operator: string): boolean {
|
||||
return Boolean(this.config?.allowOperators?.includes(operator))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a command is allowed to execute based on configured permissions
|
||||
* Validate if a command is allowed to execute based on configured permissions.
|
||||
* For chained commands (using &&, ||, |, ;), each segment is validated separately.
|
||||
*
|
||||
* @param command - The command string to validate
|
||||
* @returns PermissionValidationResult indicating if command is allowed and why
|
||||
*/
|
||||
@@ -87,18 +81,89 @@ export class CommandPermissionController {
|
||||
return { allowed: true, reason: "no_config" }
|
||||
}
|
||||
|
||||
// Check for shell operators FIRST (security check)
|
||||
const shellOperator = this.detectShellOperator(command)
|
||||
if (shellOperator) {
|
||||
// Check for dangerous characters first (backticks in double quotes, newlines outside quotes)
|
||||
const dangerousChar = this.detectDangerousCharsOutsideQuotes(command)
|
||||
if (dangerousChar) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "shell_operator_detected",
|
||||
detectedOperator: shellOperator.operator,
|
||||
detectedOperator: dangerousChar.operator,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the command into segments recursively
|
||||
const parseResult = this.parseCommandSegments(command)
|
||||
if (!parseResult) {
|
||||
// Parsing failed - be conservative and block
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "shell_operator_detected",
|
||||
detectedOperator: "parse_error",
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the parsed command structure
|
||||
const result = this.validateParsedCommand(parseResult, command)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively validate a parsed command structure
|
||||
* @param parsed - The parsed command with segments and subshells
|
||||
* @param fullCommand - The full original command (for error messages)
|
||||
* @returns PermissionValidationResult
|
||||
*/
|
||||
private validateParsedCommand(parsed: ParsedCommand, fullCommand: string): PermissionValidationResult {
|
||||
// Check if redirects are allowed
|
||||
if (parsed.hasRedirects && !this.config?.allowRedirects) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "redirect_detected",
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each command segment
|
||||
const isMultiSegment = parsed.segments.length > 1 || parsed.subshells.length > 0
|
||||
for (const segment of parsed.segments) {
|
||||
const result = this.validateSingleCommand(segment)
|
||||
if (!result.allowed) {
|
||||
// Only use segment-specific reasons for multi-segment commands
|
||||
if (isMultiSegment) {
|
||||
return {
|
||||
...result,
|
||||
failedSegment: segment,
|
||||
reason:
|
||||
result.reason === "denied"
|
||||
? "segment_denied"
|
||||
: result.reason === "no_match_deny_default"
|
||||
? "segment_no_match"
|
||||
: result.reason,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively validate subshell contents
|
||||
for (const subshell of parsed.subshells) {
|
||||
const result = this.validateParsedCommand(subshell, fullCommand)
|
||||
if (!result.allowed) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, reason: "allowed" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single command (no operators) against allow/deny rules.
|
||||
*
|
||||
* @param command - A single command without shell operators
|
||||
* @returns PermissionValidationResult for this command
|
||||
*/
|
||||
private validateSingleCommand(command: string): PermissionValidationResult {
|
||||
// Check deny rules first (deny takes precedence)
|
||||
if (this.config.deny) {
|
||||
if (this.config?.deny) {
|
||||
for (const pattern of this.config.deny) {
|
||||
if (this.matchesPattern(command, pattern)) {
|
||||
return { allowed: false, matchedPattern: pattern, reason: "denied" }
|
||||
@@ -107,7 +172,7 @@ export class CommandPermissionController {
|
||||
}
|
||||
|
||||
// Check allow rules
|
||||
if (this.config.allow && this.config.allow.length > 0) {
|
||||
if (this.config?.allow && this.config.allow.length > 0) {
|
||||
for (const pattern of this.config.allow) {
|
||||
if (this.matchesPattern(command, pattern)) {
|
||||
return { allowed: true, matchedPattern: pattern, reason: "allowed" }
|
||||
@@ -121,6 +186,100 @@ export class CommandPermissionController {
|
||||
return { allowed: true, reason: "no_config" }
|
||||
}
|
||||
|
||||
private parseCommandSegments(input: string): ParsedCommand {
|
||||
let tokens: ParseEntry[] = []
|
||||
try {
|
||||
tokens = parse(input)
|
||||
} catch (err) {
|
||||
Logger.error("Error parsing command: " + err.message)
|
||||
return { segments: [], subshells: [], hasRedirects: false }
|
||||
}
|
||||
|
||||
function process(tokenList: ParseEntry[]): ParsedCommand {
|
||||
const result: ParsedCommand = {
|
||||
segments: [],
|
||||
subshells: [],
|
||||
hasRedirects: false,
|
||||
}
|
||||
|
||||
let currentSegmentParts: string[] = []
|
||||
|
||||
const flushSegment = () => {
|
||||
if (currentSegmentParts.length > 0) {
|
||||
result.segments.push(currentSegmentParts.join(" "))
|
||||
currentSegmentParts = []
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < tokenList.length; i++) {
|
||||
const token = tokenList[i]
|
||||
|
||||
// 1. Handle Subshells: ( ... )
|
||||
if (typeof token === "object" && "op" in token && token.op === "(") {
|
||||
flushSegment()
|
||||
|
||||
let balance = 1
|
||||
let j = i + 1
|
||||
const subTokens: ParseEntry[] = []
|
||||
|
||||
while (j < tokenList.length && balance > 0) {
|
||||
const subToken = tokenList[j]
|
||||
if (typeof subToken === "object" && "op" in subToken) {
|
||||
if (subToken.op === "(") {
|
||||
balance++
|
||||
}
|
||||
if (subToken.op === ")") {
|
||||
balance--
|
||||
}
|
||||
}
|
||||
|
||||
if (balance > 0) {
|
||||
subTokens.push(subToken)
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
result.subshells.push(process(subTokens))
|
||||
i = j - 1 // Skip processed tokens
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Handle Logic Separators: &&, ||, ;, |
|
||||
if (typeof token === "object" && "op" in token && COMMAND_SEPARATOR_OPERATORS.has(token.op as string)) {
|
||||
flushSegment()
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. Handle Redirect Operators: >, >>, <, etc.
|
||||
if (typeof token === "object" && "op" in token && REDIRECT_OPERATORS.has(token.op as string)) {
|
||||
result.hasRedirects = true
|
||||
continue
|
||||
}
|
||||
|
||||
// 4. Handle Strings (Commands and Arguments)
|
||||
if (typeof token === "string") {
|
||||
// Preserve the '$' for subshell interpolation $(...) in the segment
|
||||
// so that "echo $(whoami)" becomes segment "echo $" which matches "echo *"
|
||||
const nextToken = tokenList[i + 1]
|
||||
if (token === "$" && typeof nextToken === "object" && "op" in nextToken && nextToken.op === "(") {
|
||||
currentSegmentParts.push(token)
|
||||
continue
|
||||
}
|
||||
currentSegmentParts.push(token)
|
||||
}
|
||||
// 5. Handle Glob/Pattern objects
|
||||
else if (typeof token === "object" && "pattern" in token) {
|
||||
currentSegmentParts.push(token.pattern)
|
||||
}
|
||||
}
|
||||
|
||||
flushSegment()
|
||||
return result
|
||||
}
|
||||
|
||||
return process(tokens)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command matches a wildcard pattern.
|
||||
*
|
||||
@@ -151,50 +310,6 @@ export class CommandPermissionController {
|
||||
return regex.test(command)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect shell operators using shell-quote parser.
|
||||
* This prevents command chaining/injection attacks like:
|
||||
* gh pr view 123; rm -rf /
|
||||
* gh pr view 123 && malicious_command
|
||||
* gh pr view $(malicious_command)
|
||||
*
|
||||
* Operators inside quotes are allowed (they're literal characters):
|
||||
* echo "hello; world" # OK - semicolon is inside quotes
|
||||
*
|
||||
* @param command - The command string to check
|
||||
* @returns ShellOperatorMatch if an operator is found outside quotes, null otherwise
|
||||
*/
|
||||
private detectShellOperator(command: string): ShellOperatorMatch | null {
|
||||
const dangerousCharMatch = this.detectDangerousCharsOutsideQuotes(command)
|
||||
if (dangerousCharMatch) {
|
||||
return dangerousCharMatch
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the command using shell-quote
|
||||
// shell-quote returns an array where:
|
||||
// - strings are regular arguments
|
||||
// - objects with 'op' key are shell operators
|
||||
// - objects with 'comment' key are comments
|
||||
// - objects with 'pattern' key are glob patterns (we allow these)
|
||||
const parsed = parse(command, (varName: string) => `$${varName}`)
|
||||
|
||||
// Check each parsed element for operators
|
||||
for (const entry of parsed) {
|
||||
const operatorMatch = this.checkParsedEntry(entry)
|
||||
if (operatorMatch) {
|
||||
return operatorMatch
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
// If parsing fails, be conservative and block the command
|
||||
// This could indicate malformed shell syntax being used for injection
|
||||
return { operator: "parse_error", description: "command parsing failed (potential injection)" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect dangerous characters outside of quoted strings.
|
||||
* This includes newlines, carriage returns, unicode line separators, and backticks.
|
||||
@@ -266,27 +381,4 @@ export class CommandPermissionController {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a parsed entry from shell-quote for dangerous operators.
|
||||
*
|
||||
* @param entry - A parsed entry from shell-quote
|
||||
* @returns ShellOperatorMatch if dangerous operator found, null otherwise
|
||||
*/
|
||||
private checkParsedEntry(entry: ParseEntry): ShellOperatorMatch | null {
|
||||
// null entries, string entries, glob patterns, and comments are safe
|
||||
if (!entry || typeof entry === "string" || "pattern" in entry || "comment" in entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof entry.op === "string") {
|
||||
if (this.isOperatorAllowed(entry.op)) {
|
||||
return null
|
||||
}
|
||||
const description = OPERATOR_DESCRIPTIONS[entry.op] || `shell operator (${entry.op})`
|
||||
return { operator: entry.op, description }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
export interface CommandPermissionConfig {
|
||||
allow?: string[] // Glob patterns for allowed commands
|
||||
deny?: string[] // Glob patterns for denied commands
|
||||
allowOperators?: string[] // Shell operators to allow (e.g., [">", ">>"] to allow file writing)
|
||||
allowRedirects?: boolean // Whether to allow shell redirects (>, >>, <, etc.) - defaults to false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -13,8 +13,17 @@ export interface CommandPermissionConfig {
|
||||
export interface PermissionValidationResult {
|
||||
allowed: boolean
|
||||
matchedPattern?: string // The pattern that matched (for error messages)
|
||||
reason: "no_config" | "allowed" | "denied" | "no_match_deny_default" | "shell_operator_detected"
|
||||
reason:
|
||||
| "no_config"
|
||||
| "allowed"
|
||||
| "denied"
|
||||
| "no_match_deny_default"
|
||||
| "shell_operator_detected"
|
||||
| "redirect_detected" // Redirect operators (>, >>, <) were used but not allowed
|
||||
| "segment_denied" // A segment in a chained command matched a deny pattern
|
||||
| "segment_no_match" // A segment in a chained command didn't match any allow pattern
|
||||
detectedOperator?: string // The shell operator that was detected (for error messages)
|
||||
failedSegment?: string // The command segment that failed validation (for chained commands)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user