Compare commits

...

30 Commits

Author SHA1 Message Date
abeatrix 1fea5aaa63 json mode support and model ID fix 2026-01-23 04:28:12 -08:00
Saoud Rizwan 8de7294de8 fix(cli): adjust thinking indicator colors and use proper ellipsis
- Update blueBright to light purple-blue (#8CAAFF) to match terminal
- Update yellow to pure bright yellow (#FFFF00)
- Use proper ellipsis character (…) instead of three dots (...)
2026-01-23 03:16:52 -08:00
Saoud Rizwan 2e4ded7f14 feat(cli-ts): enhance thinking indicator with shimmer animation and elapsed time
- Add ThinkingIndicator component with shimmer effect that cycles through text
- Display different colors for act mode (blue) vs plan mode (yellow)
- Show elapsed time after 1 second with 'esc to interrupt' hint
- Extend useSpinnerState hook to return start time alongside active status
- Replace basic LoadingSpinner with enhanced ThinkingIndicator in ChatView and TaskView
- Maintain backward compatibility with useIsSpinnerActive hook
2026-01-23 03:10:45 -08:00
Saoud Rizwan ac94eb4cf4 fix(cli): query cursor position before Ink mounts for accurate robot tracking
Query terminal cursor position before render() to determine where the
robot will be displayed on screen. This fixes the issue where the robot's
gaze tracking threshold was incorrect when CLI was started partway down
the terminal.

- Add cursor-position utility to query position before Ink takes over stdin
- Pass robotTopRow through App -> ChatView -> AsciiMotionCli
- Add StaticRobotFrame component for header after messages start
- Remove cursor position query from inside component (was leaking to input)
2026-01-23 02:55:16 -08:00
Saoud Rizwan 73635cf68f Fix frames 2026-01-23 02:53:56 -08:00
Saoud Rizwan 9eb0da6c2b feat(cli): add animated robot that follows cursor
Replace static Cline logo with animated 3D robot head that tracks
mouse cursor position. Uses 192 pre-rendered frames with smooth
interpolation for real-time cursor following.

- Add AsciiMotionCli component from ink-playground
- Show animated robot in dynamic region during welcome state
- Filter mouse escape sequences from text input
- Robot looks straight ahead when cursor is above, follows when below
2026-01-23 02:53:56 -08:00
Saoud Rizwan 59c10df046 feat(cli): improve UI styling and fix flicker for long outputs
- User messages now have gray background bubble with > prefix
- Completion result text is green
- Progress bar uses gray shades instead of white
- Act mode uses blueBright color for border and text
- Thinking/Planning spinner with mode-aware text
- Skip dynamic rendering for completion_result and plan_mode_respond
  (these tend to be very long and cause flicker when exceeding terminal height)
- Progress bar shows at least 1 bar when any tokens used
- Always show default hint text (@ for files, / for commands)
2026-01-23 02:53:56 -08:00
Saoud Rizwan db4d9a267a fix(cli): use refs instead of state for Static tracking to prevent flicker
When messages transition from streaming (dynamic) to complete (Static),
useState was causing extra render cycles. Using useRef instead updates
synchronously without triggering re-renders.
2026-01-23 02:53:56 -08:00
Saoud Rizwan 734dc6e57f chore(cli): upgrade to @jrichman/ink fork with React 19 2026-01-23 02:53:56 -08:00
Saoud Rizwan 27befb16f5 chore: gitignore tsbuildinfo files 2026-01-23 02:53:56 -08:00
Saoud Rizwan 1c32b08644 fix(cli): improve chat message rendering
- Fix extra spacing between bullet and text by combining Text elements
- Change assistant message color from magenta to default foreground
- Show ⎿ only on first line of tool results, use spaces for alignment
- Add inline markdown rendering for **bold**, *italic*, and `code`

The inline markdown parser handles common formatting in model output
without the complexity of full block-level markdown parsing.
2026-01-23 02:53:56 -08:00
Saoud Rizwan 5d81bf0fe2 chore(cli): upgrade to forked Ink package for better rendering
- Switch ink to @jrichman/ink@6.4.7 (same fork Gemini CLI uses)
- Upgrade React to 19.2.3
- Add aws4fetch dependency
- Add aws4fetch to esbuild external list
2026-01-23 02:53:55 -08:00
Saoud Rizwan f44771acd5 feat(cli): add flicker-free ChatView with Static/Dynamic split
Replace the old welcome view with a unified ChatView component that
uses Ink's Static component to prevent terminal flickering.

The Problem:
When Ink output height >= terminal rows, it switches from efficient
line-erasing to clearTerminal() + full redraw on every re-render,
causing severe flickering.

The Solution:
- Split content into Static region (rendered once) and Dynamic region
- Completed messages move to Static, only current streaming + input
  stays dynamic
- This keeps dynamic height small, avoiding clearTerminal path

Also includes:
- ChatMessage component with Claude Code-style tool call rendering
- StatusBar component for model/tokens/cost display
- Manual centering via centerText() since Box centering breaks in Static
- Console suppression for console.info to prevent Ink corruption
- React.memo on AccountInfoView

We explored alternate screen buffer (like Gemini CLI) but disabled it
because scroll wheel doesn't work without custom mouse event handling.

See ChatView.tsx header comment for full documentation.
2026-01-23 02:53:55 -08:00
abeatrix 786728e0d4 fix removed hooks settings 2026-01-22 15:02:58 -08:00
abeatrix 9714cf8861 Merge branch 'main' into bee/cli-ts-ink-poc 2026-01-22 14:00:59 -08:00
Seb Duerr dc85299a73 Remove deprecated zai-glm-4.6 model from Cerebras provider (#8717) 2026-01-22 12:58:07 -08:00
ClineXDiego 4533ed3ea8 fix: support drag & drop files from SSH remote workspaces (#8804)
Add vscode-remote: scheme to the valid URI filter for drag & drop operations.
This allows files from SSH Remote workspaces to be dropped into the chat.

Fixes #7606
2026-01-22 12:37:11 -08:00
Max 66d7664d1b improve cline command permission parsing logic (#8544)
- cline command permission flag can now parse subshells correctly and
validate that subshells don't contain disallowed commands.

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-01-22 11:11:53 -08:00
dependabot[bot] 50aeb7098f chore(deps): bump undici (#8783)
Bumps [undici](https://github.com/nodejs/undici) to 6.23.0 and updates ancestor dependency . These dependencies need to be updated together.


Updates `undici` from 6.22.0 to 6.23.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.22.0...v6.23.0)

Updates `undici` from 7.16.0 to 7.19.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.22.0...v6.23.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.23.0
  dependency-type: indirect
- dependency-name: undici
  dependency-version: 7.19.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ClineXDiego <diego@cline.bot>
2026-01-22 10:50:49 -08:00
github-actions[bot] c2e6eafb45 v3.52.0 Release Notes (#8633)
- 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.

- Bugs in DiffViewProvider for file editing
- Ollama's recommended models to use correct identifiers

Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-01-22 10:48:32 -08:00
Juan Pablo Flores 0220628c39 Adds Open AI Codex docs (#8791)
* feat(docs): add OpenAI Codex provider setup instructions and update model selection guidance

* fix(docs): improve clarity and formatting in OpenAI Codex documentation

* Update docs/provider-config/openai-codex.mdx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update docs/provider-config/openai-codex.mdx

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tony Loehr <turingxo@gmail.com>
2026-01-22 09:30:12 -08:00
CandiedUniverse 5052220195 feat(hooks): Make hooks always enabled and remove its feature setting. [CLINE-1179] (#8777)
* feat(hooks): Standardize on calling getHooksEnabledSafe().

* feat(hooks): Hard-code getHookEnabledSafe() to return true unless on Windows.

* feat(hooks): Remove hooks setting from the CLI.

* feat(hooks): Remove hooks toggle from the Feature Settings UI.

* feat(hooks): Remove hooksEnabled toggles from settings/task APIs.

* feat(hooks): Stop using hooksEnabled setting.

* feat(hooks): npm run changeset

* feat(hooks): Simplify getHooksEnabledSafe() function signature.

* feat(hooks): Remove hooks setting migration.

feat(hooks): Remove hooksEnabled from updateSettingsCli() conversion.

feat(hooks): Use 'reserved' for removed fields in UpdateSettingsRequest protobuf.
2026-01-22 09:26:16 -08:00
CandiedUniverse abf3081e56 Rules: Wire up conditional rules functionality [ENG-1470] (#8669)
* feat(rules): Write technical design / implementation plan doc.

* update frontmatter plan

* feat(rules): Initial implementation based on plan doc.

* feat(rules): Add tool-call path harvesting for path-scoped Cline Rules.

* chore(rules): exclude internal paths-frontmatter plan doc from PR

* fix(rules): use latest user message for paths frontmatter context

* feat(rules): Implement conditional_rules_applied say type.

* feat(rules): changes as per Cline's code review feedback

* feat(rules): npm run changeset

* feat(rules): Changes as per ellipsis-dev feedback.

* feat(rules): Changes as per code review feedback (i.e. don't bloat the task context).

* feat(rules): Fix failing unit tests.
2026-01-22 09:25:59 -08:00
Michael Gutin 125cb78a31 Switch background element now has a 3:1 contrast ration with thumb and rule row background in vs code light and dark themes (#8747) 2026-01-22 12:16:51 -05:00
CandiedUniverse 17539228ea feat(cli): Add note about ctrl+c to exit the CLI. [CLINE-1162] (#8796)
* feat(cli): Add note about ctrl+c to exit the CLI.

* feat(cli): npm run changeset
2026-01-22 09:01:57 -08:00
abeatrix c5db44964e Supports Piped 2026-01-22 01:13:37 -08:00
abeatrix ed38739d65 store metadata with task history 2026-01-22 00:49:47 -08:00
Ara bdb7cc36fa feat(DiffEditRow): add button to open file in editor (#8564)
* refactor(diff): return result object with line tracking metadata

Change constructNewFileContent to return an object containing newContent
and line number information instead of just the string content. Add
charIndexToLineNumber helper function to support tracking where changes
occur in the file.

Update all callers and tests to access the newContent property from
the result object.

* minor fix

* fix: hide line numbers when not available from backend

* chore: add changeset

* feat: add startLineNumbers support to ApplyPatchHandler

* fix: split V4A @@ chunks into separate Patch objects for proper line numbers

* fix(DiffEditRow): preserve +/- prefix in diff line display for backwards compatibility

* fix line numbers
2026-01-21 21:38:45 -08:00
abeatrix 50bc90835c log 2026-01-21 18:53:49 -08:00
Tony Loehr d3e1065707 added jupyter docs (#8742)
* added jupyter docs

* fix jupyter docs and add gifs

* fix jupyter docs 2

* fix jupyter gif placement
2026-01-21 16:15:26 -08:00
127 changed files with 338015 additions and 1642 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add claude 4.5 haiku
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
improve cline command permission validation logic. add cline command permission man page documentation
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: finalize document content during approval flow
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Expose --version in cline cli command
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: prevent duplicate diff errors when parallel tool calling is enabled
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Log Persistence errors to PostHog
-13
View File
@@ -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.
-9
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
-5
View File
@@ -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.
-10
View File
@@ -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)
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: add chat output on skill use
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Adding telemetry for background exec terminal
-5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Remove deprecated zai-glm-4.6 model from Cerebras provider
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Wiring-up "conditionals" for Cline Rules files.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: removes retry message from UI after retry succeeds
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Support native tool calling for LM Studio and Ollama provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fixing integration tests from testing framework
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
add claude 4.5 opus into sap provider.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix drag & drop files from SSH remote workspaces into chat
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Limite Vertex and LiteLLM options when they're remote configured
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix crash when the Context Menu has a type but no options
+5
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Throttle the remote config fetch
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Improve history view filter menu
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add git worktree management UI for running parallel Cline sessions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Remove hooks feature setting and make it always enabled (except on Windows).
+1
View File
@@ -46,3 +46,4 @@ test-results
/pkg
.secrets
*.tsbuildinfo
+15 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+206 -201
View File
@@ -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
View File
@@ -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"
}
}
+2 -2
View File
@@ -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>
)
}
})
+36 -6
View File
@@ -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
+3 -2
View File
@@ -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) {
+36 -25
View File
@@ -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")
}
}
+441
View File
@@ -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">&gt; {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>
)
}
+752
View File
@@ -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>
)
}
+41 -36
View File
@@ -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>
+7 -5
View File
@@ -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`
}
+30 -20
View File
@@ -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}>
+30 -25
View File
@@ -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>
)
}
+25 -19
View File
@@ -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>
)}
+3 -6
View File
@@ -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">
+107
View File
@@ -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>
)
}
+125
View File
@@ -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 />
}
+8 -4
View File
@@ -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>
+175
View File
@@ -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>
)
}
+64 -51
View File
@@ -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"
+36
View File
@@ -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")
}
+12 -2
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -21,6 +21,7 @@ if (!isVerbose) {
console.warn = () => {}
console.error = () => {}
console.debug = () => {}
console.info = () => {}
}
/**
+57
View File
@@ -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))
}
+37
View File
@@ -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()
})
}
+5 -8
View File
@@ -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,
+8 -6
View File
@@ -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: () => {},
+1 -1
View File
@@ -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),
)
+58
View File
@@ -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
+36
View File
@@ -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
View File
@@ -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": {
-1
View File
@@ -110,7 +110,6 @@ func (m *Manager) ListSettings(ctx context.Context) error {
"dictationSettings",
"autoCondenseThreshold",
"autoApprovalSettings",
"hooksEnabled",
}
// Render each field using the renderer
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
}
}
}
+1 -1
View File
@@ -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,
)
-6
View File
@@ -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

+2
View File
@@ -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",
+112
View File
@@ -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.
![Generate Cell demo](../assets/jupyter-generate-cell.gif)
**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.
![Explain and Improve Cell demo](../assets/jupyter-explain-improve-cell.gif)
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
+19 -2
View File
@@ -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
-1
View File
@@ -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
+105
View File
@@ -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
+48 -65
View File
@@ -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
View File
@@ -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": [
+4 -2
View File
@@ -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;
+1
View File
@@ -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
-4
View File
@@ -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)
+12 -11
View File
@@ -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)
})
})
+31 -7
View File
@@ -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"
+2 -1
View File
@@ -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
-12
View File
@@ -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),
)
+8 -29
View File
@@ -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()
})
})
})
+14 -7
View File
@@ -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
}
}
+11 -2
View File
@@ -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