Compare commits

..

62 Commits

Author SHA1 Message Date
Arafatkatze bcc12090a2 Fix typescript compilation error 2025-09-27 13:06:24 -07:00
Igor Tceglevskii 58b14c69b1 CLINE_ACTIVE usage (#6471) 2025-09-27 10:02:05 -07:00
Saoud Rizwan b831100f20 feat: preserve reasoning traces for cline/openrouter/anthropic providers to maintain conversation integrity (#6495)
* feat: preserve reasoning traces for cline/openrouter/anthropic providers to maintain conversation integrity

* Add reasoning trace preservation for providers

This change introduces a feature to preserve reasoning traces for specific providers, enhancing conversation integrity.

* Update src/core/api/transform/openai-format.ts

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-27 06:17:10 -07:00
Saoud Rizwan 23fa305eac Enable thinking by default for models that support it to reduce text verbosity in chat (#6493)
* Enable thinking by default for models that support it to reduce text verbosity in chat

* Refactor
2025-09-27 05:42:57 -07:00
Saoud Rizwan 0762e6406d fix: sending message during interactive command would show double checkpoints 2025-09-27 00:55:15 -07:00
Sarah Fortune 94da9f6669 Only show the info banner messages about the right sidebar in VSCode (#6492)
Add a `type` field to the PlatConfig with type of the IDE: VSCode, standalone, etc.
Make the info banner conditional on the type.
Quiet the gRPC logs on startup.
2025-09-26 19:37:58 -07:00
Saoud Rizwan fdeef9cece fix: uses homedir instead of hardcoded tilde for MCP path 2025-09-26 17:51:54 -07:00
lcs-bdr dd055b3327 fix: add retry logic to SAP AI Core provider (#6453)
* Use retry behavior for SAP AI Core provider

* add changeset
2025-09-26 12:47:08 -07:00
Igor Tceglevskii e852c6953b force no proxy for hostbridge connection (#6474) 2025-09-26 12:06:02 -07:00
github-actions[bot] aef27eb1c4 v3.32.0 Release Notes (#6487)
* Added the new code-supernova-1-million stealth model, available for free and delivering a 1 million token context window
* Changes to inform Cline about commands that are available on your system

Updated version to 3.32.0 

Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2025-09-26 10:32:37 -07:00
Ara 863572031f Empty PR to bump changeset (#6485) 2025-09-26 09:45:12 -07:00
pashpashpash 65eee1ac6a add code-supernova-1m (#6458)
* add code-supernova-1m

* Update src/core/api/providers/cline.ts

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

* fixing cache price

* wording

* Update webview-ui/src/components/chat/Announcement.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Fix announcement content

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-09-26 08:19:39 -07:00
Bee 8213b0b910 Update E2E Test for Chat input (#6470)
* Update E2E Test for Chat input

* update

* typo
2025-09-26 10:00:32 +08:00
Bee f5fc3fed6f test: skip API request failure check to prevent Windows timeout (#6468)
Remove API request failure assertions that cause test timeouts on Windows due to longer API request failure times, while preserving other test functionality
2025-09-25 17:51:33 -07:00
celestial-vault 42df03177f feat: add HeroTooltip to conversation history button (#6466)
- Rename OpenDiskTaskHistoryButton to OpenDiskConversationHistoryButton
- Add HeroTooltip for consistent UI experience
- Update protobuf service and backend handler
2025-09-25 17:22:43 -07:00
Bee 648ae1b1fd feat(chat): add scroll to top functionality (#6423)
* feat(chat): add scroll to top functionality

- Add scroll to top button when action buttons are not visible
- Pass virtuosoRef to ActionButtons component for scroll control
- Enhance scroll button logic to handle both up and down directions

* Update aria-label
2025-09-25 17:10:25 -07:00
Saoud Rizwan d3cff6ac47 fix: search tool long regex string causing overflow in chatview 2025-09-24 23:38:25 -07:00
github-actions[bot] 13af435103 v3.31.1 Release Notes (#6451)
* changeset version bump

* Updating CHANGELOG.md format

* Update changelog for version 3.31.1

Added details about installed CLI tools and renamed MCP tab.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-09-24 23:08:59 -07:00
Saoud Rizwan 3b1477bc24 Rename MCP tab 'Installed' to 'Configure' (#5966)
* Rename MCP tab 'Installed' to 'Configure'

* Fix component name

* Fix errors; update docs

* Create green-wasps-brush.md
2025-09-24 23:05:23 -07:00
Saoud Rizwan 688f93db6d Show ToS update for cline account users 2025-09-24 23:00:41 -07:00
Saoud Rizwan a702270e85 Fix import linter error 2025-09-24 21:57:30 -07:00
Saoud Rizwan 03acada1b9 Fix padding in checkpoints error 2025-09-24 21:56:43 -07:00
adam jones 95cca05f5e Add CLI tools detection to environment details (#5471)
- Auto-detect available CLI tools in system PATH
- Add detected tools to environment context for AI models
- Include comprehensive list of common developer tools (gh, docker, aws, etc.)
- Cross-platform support using 'which' on Unix and 'where' on Windows

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-09-24 21:49:25 -07:00
Saoud Rizwan 33d77eb095 Copy changes 2025-09-24 21:16:25 -07:00
github-actions[bot] 3e0c39acbb v3.31.0 Release Notes (#6379)
* changeset version bump

* Updating CHANGELOG.md format

* Update package.json

* Update changelog for version 3.31.0

Updated version to 3.31.0 and added new features and improvements.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-09-24 18:33:47 -07:00
Saoud Rizwan 366d8a5411 Fix failing integration test due to duplicate test id 2025-09-24 18:25:13 -07:00
Saoud Rizwan d22596e5c2 Update announcement banner content (#6448) 2025-09-24 18:06:25 -07:00
Saoud Rizwan 7cc612dbce Fix right sidebar banner and docs (#6446) 2025-09-24 18:02:44 -07:00
Ara f0cab63a43 Fixing icons and positioning for UI for voice mode (#6445)
* Minor UX fixes for voice mode

* Minor UX fixes for voice mode

* Minor UX fixes for voice mode

* Fix alignment of stop icon

---------

Co-authored-by: frostbournesb <frostbournesb@protonmail.com>
2025-09-24 16:20:27 -07:00
Saoud Rizwan 3c1b670b73 fix: cline not knowing plan/act mode if compact mode is enabled (#6443) 2025-09-24 15:31:42 -07:00
Ara 5de05d68ef Restricting voice mode to mac os only (#6442) 2025-09-24 15:15:44 -07:00
Saoud Rizwan 9b6b1c376c Revert focus chain design changes (#6441)
This reverts commit e5e26f45fa.

Revert "Update colors and width of focus chain (#6437)"

This reverts commit b794583b7a.

Revert "Update task header and focus chain UI design (#6415)"

This reverts commit 011b19225e.

Fix

Fix
2025-09-24 14:50:30 -07:00
Saoud Rizwan e5e26f45fa Decrease focus chain height 2025-09-24 13:55:31 -07:00
Saoud Rizwan b794583b7a Update colors and width of focus chain (#6437) 2025-09-24 13:44:54 -07:00
celestial-vault ca96bd8f62 add infobanner (#6294)
* add infobanner

* fix type and update docs url

* restructure test to not include brittle elements; it is not clear why this banner was even added to auth tests

* move update info banner logic into rpc
2025-09-24 13:37:40 -07:00
Bee 0ecdf8d0cc update CompactTaskButton tooltip content and styling (#6424) 2025-09-24 13:10:44 -07:00
Jose Castelli 5be6ba68a3 Testing Platform - Support partial response validation via meta.expected (#6399)
Testing Platform - Support partial response validation via meta.expected
2025-09-24 20:03:32 +02:00
celestial-vault 1bdd1e943f Add docs for opening cline in right sidebar (#6292)
* Add docs for opening cline in right sidebar

* use frame tags

* change to gifs

* docs(customization): refactor sidebar instructions with Steps component

Updated opening-cline-in-sidebar.mdx to replace numbered lists with structured <Steps> components for improved readability. Also updated image sources and added a link for Cursor alignment guidance.

---------

Co-authored-by: Juan Pablo <juan@cline.bot>
2025-09-24 09:22:02 -07:00
Saoud Rizwan c85a4abeb4 Fix bg color of API configuration in settings (#6422) 2025-09-24 02:13:29 -07:00
Saoud Rizwan 011b19225e Update task header and focus chain UI design (#6415)
* Fix chatfield buttons positioning

* Fix task header icons being cut off at bottom

* Fix copy for focus chain

* Fix focus chain text overflowing and being hidden

* Remove transition animation

* Fix token stats

* Tweak task header styles

* Fix focus chain progress bar direction

* Hide checkpoint text unless hovered

* Make copy button smaller and muted

* Use new focus chain design

* Make timeline blocks circles

* Move focus chain down and fix pencil icon positioning
2025-09-24 02:06:06 -07:00
Sarah Fortune a3945dce7f Replace vscode context.globalStoragePath with HostProvider.globalStorageFsPath (#6419) 2025-09-24 01:19:06 -07:00
celestial-vault 8daca03996 add a script to reconstruct taskHistory (#6403)
* add a script to reconstruct taskHistory

* remove format setting unrelated
2025-09-23 23:23:44 -07:00
Bee 777b8576f2 fix: update fontsource import path to resolve 401 errors (#6414)
* fix: update fontsource import path to resolve 401 errors

Replace specific weight imports with node_modules path reference to fix accessibility issues with @fontsource/azeret-mono font files in webview

* update path
2025-09-23 18:47:56 -07:00
Ara 880755ec89 feat: add multi-root workspace support for auto-approve file reads (#6412)
* feat: add multi-root workspace support for auto-approve file reads

Add logic to handle auto-approval of file read operations in multi-root workspace scenarios. When multi-root is enabled and multiple workspaces are present, the system now checks if a file is located in any workspace rather than just the current working directory. This ensures proper auto-approval behavior across all workspace roots while maintaining backward compatibility for single-root workspaces.

* Fixing pulsing border
2025-09-23 17:57:12 -07:00
Bee 1c70089521 initialize compact mode to prevent UI glitching on mount (#6413)
- Set isCompactMode initial state to true instead of false
- Replace CSS variable with Tailwind class for settings title
- Prevents layout shifts and UI glitching during component initialization
2025-09-23 17:05:48 -07:00
Bee fe2a8a9477 dev: disable auto-condense threshold configuration in task header (#6407)
Remove autoCondenseThreshold prop and hardcode useAutoCondense to false in TaskHeader component to temporarily disable the configurable auto-condense threshold from UI

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-09-23 16:41:57 -07:00
nihar-oracle eb2550ec3d fix(oca): add auth guard, unify axios config, and rotate default IDCS client id (#6410)
- refreshOcaModels:

  - Add explicit auth guard: show a user-facing error if no OCA access token is present and return a typed error via OcaCompatibleModelInfo.
  - Switch axios invocation to use getAxiosSettings() (fetch adapter) instead of proxy agents.
  - Replace getProxyAgents import with getAxiosSettings.

- OcaAuthProvider:

  - Migrate all axios calls (discovery + token POSTs) to use getAxiosSettings().
  - Add explicit error when no id_token is returned from OCA during the auth code exchange to improve diagnostics.

- utils/constants:
  - Rotate DEFAULT_IDCS_CLIENT_ID to a new value.

- utils/utils:

  - Create getAxiosSettings() helper (uses axios fetch adapter) and remove proxy agent–specific helpers.

  - Revise createOcaHeaders to avoid direct vscode + package.json coupling:

    - Use HostProvider.env.getHostVersion for host/IDE details.
    - Use ExtensionRegistryInfo.version for the extension version.
    - Set headers: client=Cline, client-version, client-ide, client-ide-version, opc-request-id.

  - Note: import of HttpsProxyAgent remains but is now unused; consider removing to avoid lint/TS warnings.

Rationale

- Reliability/UX: Users now receive a clear error when attempting to refresh OCA models without being authenticated.
- Portability: Replacing direct vscode and package.json usage in headers with HostProvider + ExtensionRegistryInfo reduces coupling and makes code host-agnostic.
- Network config simplification: Standardize axios setup through a single getAxiosSettings() helper and the fetch adapter.
- Auth robustness: Explicitly surface the absence of id_token to speed up troubleshooting OIDC flows.

Potential behavior changes

- Proxy handling: getProxyAgents() was removed in favor of the axios fetch adapter via getAxiosSettings(). If explicit HTTP(S)_PROXY env-based proxying is required, follow-up work may be needed to reintroduce agent support or configure fetch-compatible proxying.

Files touched

- src/core/controller/models/refreshOcaModels.ts
- src/services/auth/oca/providers/OcaAuthProvider.ts
- src/services/auth/oca/utils/constants.ts
- src/services/auth/oca/utils/utils.ts
2025-09-23 15:36:31 -07:00
Bee 9c3ac14cab feat: add multi-root workspace setting with feature flag support (#6409)
* feat: add multi-root workspace setting with feature flag support

Add user-configurable multi-root workspace setting that works in conjunction with feature flags. Includes proto definition, state management, and UI toggle in settings panel.

- Add multi_root_enabled field to UpdateSettingsRequest proto
- Implement multiRootSetting with user preference and feature flag state
- Add ClineFeatureSetting interface for feature flag + user setting pattern
- Create settings UI toggle with feature flag override indication
- Update state helpers to properly handle boolean conversion

* remove docs

* remove env vars
2025-09-23 15:34:50 -07:00
canvrno 6a8f900d75 REmove VCS requirement from multiroot checkpoints (#6405)
Co-authored-by: Kevin Bond <kevin@Kevins-MacBook-Pro.local>
2025-09-23 13:09:46 -07:00
Bee 407e472322 fix: poll feature flags for all users (#6404)
* fix: poll feature flags for all users instead of just authenticated users

Move feature flags polling outside the authenticated user check to ensure all users (logged in and anonymous) receive up-to-date feature flags. Reset flags only for authenticated users to maintain proper user-specific configuration.

* add changeset
2025-09-23 12:59:55 -07:00
Saoud Rizwan 580b2e35e2 Update CODEOWNERS to remove dcbartlett (#6408) 2025-09-23 12:54:50 -07:00
Sarah Fortune 30c121509f Add a workflow that will trigger the Jetbrains tests for PRs in the cline repo (#6402)
Changes in the cline repo have the potential to cause breakages in the JetBrains repo. The creator of the PR might not found out until some time later that they have made a change that causes problems for JetBrains.

This workflow will trigger the workflow .github/workflows/test-changes-from-cline-repo.yml in the JetBrains repo, that runs the JetBrains integration tests. When the tests complete, the workflow will leave a comment on the PR with the results of the tests.
2025-09-23 10:13:38 -07:00
Bee b3aee3857c feat: new Task Header UI with configurable auto condense threshold (#6049)
* Update TaskHeader UI

- Create new FocusChainContainer component for todo list management
- Add onSendMessage prop to TaskHeader and TaskSection components
- Refactor TaskHeader to use new FocusChainContainer
- Update tooltip styling and Tailwind configuration
- Improve task progress visualization and interaction handling

* use tailwind

* Remove edit thread btn

* Add interactive auto-compact marker to context window bar

- Add state management for auto-compact marker position (default 75%)
- Make context window bar clickable to reposition marker
- Wrap entire bar in tooltip instead of just marker
- Add click handler to calculate percentage from mouse position
- Fix cost display conditional and remove redundant CSS property

* Prevent event bubbling in task header button clicks

Add preventDefault and stopPropagation to all task header button click handlers to prevent unintended parent element interactions. Also standardize styling by replacing inline styles with Tailwind classes and update text color class for consistency.

* Clean up

* Add CheckpointError component and clean up task header UI

- Add new CheckpointError component for error handling
- Remove commented code from FocusChainContainer
- Add rounded corners to focus chain container
- Refactor formatLargeNumber function with default parameter handling
- Clean up token display formatting in TaskHeader

* Add configurable auto-condense threshold for context window management

- Add autoCondenseThreshold setting to control when context window compaction occurs
- Update ContextManager to accept threshold percentage parameter (0-100%)
- Add ContextWindowDetails component to display context usage in task header
- Extend protobuf schema and state management for new threshold setting
- Default threshold set to 75% when auto-condense is enabled

* v2 style

* Dynamic marker & remove HeroTooltip

* Update styles

* feat: enhance task header UX with expanded progress bar hitbox smart boundary detection timeline block hover dimming effects optimized spacing and standard confirmation dialog styling

* Extract TaskHeader styles to CSS module

Move inline styles from TaskHeader component to external CSS module file for better maintainability and separation of concerns.

* migrate to tailwind

* migrate inline styes to tailwind for FocusChain

* fix(ui): fix useEffect cleanup and add key prop to InfoRow

- Return cleanup function from useEffect instead of nested setTimeout
- Add key prop to InfoRow component to ensure proper re-rendering
- Improves component lifecycle management and prevents memory leaks

* price tag and warning positioning

* clean up & persist expand state

* debounce

* add keyboard navigation for auto condense threshold slider

Add arrow key controls to adjust auto condense threshold with 5% steps (10% with Shift). Include focus management, accessibility attributes, and click-outside handling for improved UX.

* feat: small vertical positioning adjustment

* add changeset

* Update Task Action Button Text

* clean up

* Update storybook

* Apply styling feedback

* clean up

* feat: accordian style metadata for context window bar tooltip

* fix: notch slide fix

* fix: add cleanup for animation frames and timeouts in AutoCondenseMarker

Add proper cleanup functions to useEffect hooks to prevent memory leaks by canceling animation frames and clearing timeouts when component unmounts or dependencies change.

* Fix animation on mount

* remove handleBlur

* fix: change the order of the instructional text

* fix: remove the color from the tooltip percentage value

* clean up

* simplify

* useAutoCondense

* remove highlights

* set maxAllowedSize

* Auto Compact

* remove fork button

* feat: add configurable auto-condense threshold setting

Add auto_condense_threshold parameter to control when context window compaction occurs. The threshold is configurable as a percentage (0-1 range) of the total context window size, allowing users to customize when automatic condensing triggers instead of using a fixed maximum size.

Changes:
- Add autoCondenseThreshold field to protobuf UpdateSettingsRequest
- Update ApiProviderInfo interface to include autoCondenseThreshold
- Modify shouldCompactContextWindow to accept threshold percentage parameter
- Add threshold validation and state management in updateSettings
- Include autoCondenseThreshold in controller state and UI data flow

* autoCondenseThreshold

* fix package

---------

Co-authored-by: Jose R. Perez <trupix@gmail.com>
2025-09-23 09:21:07 -07:00
Bee 10cbbb2c6b feat: Cline Auth Provider (#6131)
* Add ClineAuthProvider and integrate with AuthService

- Add new ClineAuthProvider class for Cline-specific authentication
- Update AuthService to support both Firebase and Cline auth providers
- Switch default provider from Firebase to Cline
- Add dynamic auth URL fetching for Cline provider
- Update type definitions to support multiple provider types

* Add API auth URL configuration and update Cline auth flow

- Add apiAuthUrl to environment configs for all environments
- Update ClineAuthProvider to use new token exchange API endpoint
- Add shared Cline API utilities and types
- Refactor auth service to handle access tokens with expiration
- Update mock auth service and test fixtures for new auth flow

* add changeset

* wip

* refactor authentication service and improve token handling

- Add null check for auth token in ClineAccountService
- Update authorization header format to use 'workos:' prefix
- Replace hardcoded API endpoints with CLINE_API_ENDPOINT constants
- Refactor ClineAuthInfo interface to use accessToken terminology
- Remove Firebase auth provider dependency
- Simplify auth callback handling and token storage
- Improve error handling for missing authentication tokens

* refactor(auth): standardize workos token prefix handling

Centralize workos: prefix application in AuthService.getAuthToken() method instead of duplicating across multiple API call sites. This ensures consistent authentication token formatting and simplifies maintenance by having a single source of truth for token prefixing.

* Remove unused code

* update mock responses

* Set Firebase Auth Provider as default

* Update src/shared/cline/api.ts

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>

* remove docs

* refactor(auth): replace type union with IAuthProvider interface

- Replace AvailableAuthProvider type union with IAuthProvider interface for better extensibility
- Add ServiceConfig type for provider configuration
- Remove hardcoded providerName field in favor of provider.name property
- Update method signatures to use IAuthProvider interface
- Improve error messages and code comments for clarity
- Add TODO for mock auth provider implementation

---------

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2025-09-23 08:23:22 -07:00
Bee 08365b3e0b feat: add configurable auto-condense threshold setting (#6391)
* feat: add configurable auto-condense threshold setting

Add auto_condense_threshold parameter to control when context window compaction occurs. The threshold is configurable as a percentage (0-1 range) of the total context window size, allowing users to customize when automatic condensing triggers instead of using a fixed maximum size.

Changes:
- Add autoCondenseThreshold field to protobuf UpdateSettingsRequest
- Update ApiProviderInfo interface to include autoCondenseThreshold
- Modify shouldCompactContextWindow to accept threshold percentage parameter
- Add threshold validation and state management in updateSettings
- Include autoCondenseThreshold in controller state and UI data flow

* updateSettings
2025-09-23 06:48:12 -07:00
canvrno cb4c61b1ca Dependency changes (#6393)
Co-authored-by: Kevin Bond <kevin@Kevins-MacBook-Pro.local>
2025-09-22 23:36:50 -07:00
canvrno 2d9ff863b7 Update dependencies (#6390)
Co-authored-by: Kevin Bond <kevin@Kevins-MacBook-Pro.local>
2025-09-22 20:16:33 -07:00
Ara ae9b20a12b Adding Search tool for multi root workspace (#6288)
* Adding multi search

* Adding multi search

* Adding multi search

* Adding multi search

* feat: Cleanup

* feat: Cleanup

* feat: Cleanup

* feat: Cleanup

* Fixing search and workspace path stuff
2025-09-22 19:17:40 -07:00
Ara c16e271c14 Adding voice mode to Cline (#6208)
* Adding voice mode
2025-09-22 18:50:56 -07:00
Bee b940cef0e4 refactor: append stealth models on readOpenRouterModels & refreshOpenRouterModels (#6384)
* refactor: extract stealth models to reusable function

Move hardcoded stealth model addition from inline code to a dedicated `appendStealthModels` function. This improves code organization by centralizing stealth model management and ensures consistent application across both fresh API responses and cached model data.

* refactor(controller): integrate stealth models into OpenRouter handling

- Added import and integrated appendClineStealthModels in readOpenRouterModels
- Improved error handling with try-catch in readOpenRouterModels
- Refactored refreshOpenRouterModels to use controller method and renamed functions
- Renamed STEALTH_MODELS to CLINE_STEALTH_MODELS and made appendClineStealthModels exportable

* await cached models for immediate UI availability

Changed the initialization to synchronously await and post last cached OpenRouter models, improving UI responsiveness by making them available as soon as possible instead of relying on a promise chain.
2025-09-22 18:47:02 -07:00
tjandy98 bc228de20e Update SAP AI Core Provider Anthropic input token calculation (#6363)
* Update anthropic input token usage calculation

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

* changeset

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

---------

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>
2025-09-22 18:41:21 -07:00
Ara a9cac3206a feat: Fixing dictation settings (#6385) 2025-09-22 17:38:31 -07:00
168 changed files with 9211 additions and 9128 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Checkpoints multiroot pt.1: Accept array of workspaces when initializting checkpoints
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: automatically retry on rate limit errors with SAP AI Core provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Run Testing platform within Test workflow
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
feat: preserve reasoning traces for cline/openrouter/anthropic providers to maintain conversation integrity
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
remove temperature settings in z.ai models
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Empty Pr to bump changeset
+2 -2
View File
@@ -1,3 +1,3 @@
/docs/
/.github/ @saoudrizwan @dcbartlett
/README.md @saoudrizwan @nickbaumann98
/.github/ @saoudrizwan
/README.md @saoudrizwan @nickbaumann98
@@ -0,0 +1,51 @@
name: Trigger Jetbrains Plugin <-> Cline Tests
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: jetbrains-trigger-${{ github.event.number }}
cancel-in-progress: true
jobs:
trigger-integration-test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: 1998650
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
owner: cline
repositories: intellij-plugin
- name: Trigger IntelliJ Plugin Integration Test
run: |
curl -X POST \
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "User-Agent: cline-pr-trigger" \
-H "Content-Type: application/json" \
https://api.github.com/repos/cline/intellij-plugin/dispatches \
-d '{
"event_type": "cline-pr-check",
"client_payload": {
"pr_number": "${{ github.event.number }}",
"branch_name": "${{ github.head_ref }}",
"action": "${{ github.event.action }}",
"sha": "${{ github.event.pull_request.head.sha }}",
"pr_title": "${{ github.event.pull_request.title }}",
"pr_url": "${{ github.event.pull_request.html_url }}"
}
}'
- name: Log trigger details
run: |
echo "Triggered IntelliJ Plugin integration test for:"
echo " PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}"
echo " Branch: ${{ github.head_ref }}"
echo " Action: ${{ github.event.action }}"
echo " SHA: ${{ github.event.pull_request.head.sha }}"
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [3.32.0]
- Added the new code-supernova-1-million stealth model, available for free and delivering a 1 million token context window
- Changes to inform Cline about commands that are available on your system
## [3.31.1]
- Version bump
## [3.31.0]
- UI Improvements: New task header and focus chain design to take up less space for a cleaner experience
- Voice Mode: Experimental feature that must be enabled in settings for hands-free coding
- YOLO Mode: Enable in settings to let Cline approve all actions and automatically switch between plan/act mode
- Fix Oracle Code Assist provider issues
## [3.30.3]
- Add Oracle Code Assist provider
+8
View File
@@ -91,6 +91,7 @@
"features/focus-chain",
"features/auto-compact",
"features/editing-messages",
"features/dictation",
{
"group": "@ Mentions",
"pages": [
@@ -121,6 +122,13 @@
"features/commands-and-shortcuts/git-integration",
"features/commands-and-shortcuts/keyboard-shortcuts"
]
},
{
"group": "Customization",
"pages": [
"features/customization/opening-cline-in-sidebar",
"features/customization/disable-terminal-pagers"
]
}
]
},
@@ -0,0 +1,79 @@
---
title: "Disable Terminal Pagers During Cline Sessions"
description: "Make CLI output non-interactive when Cline runs commands by detecting the CLINE_ACTIVE environment variable and disabling pagers like less."
---
Many CLI tools (like Git) use a pager such as `less` for interactive, scrollable output. When Cline runs commands in your terminal, that interactivity gets in the way — the pager can pause on the first page and block progress. You can configure your shell so that when a terminal is spawned by Cline, pagers are disabled and output streams through normally.
## How it works
Cline sets an environment variable for terminals it opens to run commands:
- `CLINE_ACTIVE` — non-empty when the shell is running under Cline
You can detect this variable in your shell startup file and adjust environment variables or aliases only for Cline-run sessions. This keeps your normal interactive terminals unchanged.
## Quick setup (Zsh/Bash)
Add the following to your `~/.zshrc`, `~/.bashrc`, or `~/.bash_profile`:
```bash
# Disable pagers when the terminal is launched by Cline
if [[ -n "$CLINE_ACTIVE" ]]; then
export PAGER=cat
export GIT_PAGER=cat
export SYSTEMD_PAGER=cat
export LESS="-FRX"
fi
```
<Note>
- `PAGER=cat` ensures generic pager-aware tools print directly to stdout
- `GIT_PAGER=cat` prevents Git from invoking `less`
- `SYSTEMD_PAGER=cat` disables paging in systemd tools (if present)
- `LESS="-FRX"` makes `less` behave more like streaming output if a tool still calls it
</Note>
This configuration only applies when `CLINE_ACTIVE` is set, so your normal terminals keep their usual interactive behavior.
## Verify
- Open a task in Cline that runs terminal commands and check:
- `echo "$CLINE_ACTIVE"` prints a non-empty value
- `git log` or other long outputs should stream without pausing
- If changes don't take effect:
- Make sure you updated the correct startup file for your shell
- Restart VS Code/Cursor so integrated terminals reload your shell config
- Confirm your terminal profile sources your `~/.zshrc` or `~/.bashrc`
## Optional tweaks
- Prefer command-line options when you don't want to rely on env vars:
```bash
# One-off usage (no aliases)
git --no-pager log -n 50 --decorate --oneline
systemctl --no-pager status nginx
journalctl --no-pager -u nginx -n 200
less -FRX README.md
```
- You can also override paging via shell aliases scoped to Cline sessions using options rather than env vars:
```bash
if [[ -n "$CLINE_ACTIVE" ]]; then
# Make 'less' non-interactive by default
alias less='less -FRX'
# Disable paging for common tools via CLI flags
alias git='command git --no-pager'
alias systemctl='command systemctl --no-pager'
alias journalctl='command journalctl --no-pager'
fi
```
- If you prefer environment variables, many CLIs also respect a generic or tool-specific pager variable:
- Git: `GIT_PAGER=cat`
- Systemd: `SYSTEMD_PAGER=cat`
- Man pages: `MANPAGER=cat` (not typically needed for Cline-driven commands)
- Aliases affect the current interactive shell, while environment variables propagate to child processes. Choose the approach that best fits your workflow.
@@ -0,0 +1,60 @@
---
title: "Opening Cline in the Right Sidebar"
description: "Learn how to open Cline in the right sidebar in VS Code and Cursor"
---
By default, when you first install Cline, it appears in VS Code's left sidebar alongside your file explorer and other extensions. However, for a better coding experience, we recommend moving Cline to the right sidebar. This allows you to keep your project files visible in the left sidebar while chatting with Cline on the right, giving you full visibility of your codebase as Cline works on your project.
## VS Code
To open Cline in the right sidebar:
<Steps>
<Step title="Align Extension View">
Make sure your extension view is aligned vertically to the left
</Step>
<Step title="Open Right Side View">
Click the button that opens the right side panel in VS Code (typically used to open GitHub Copilot chat). Optionally use the `Option + CMD/Ctrl + B` shortcut.
</Step>
<Step title="Drag Cline Icon">
Drag the Cline icon over to the nav panel at the top of that right view
</Step>
</Steps>
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/vscode_right_view.gif"
alt="VS Code Right Sidebar Setup"
/>
</Frame>
## Cursor
To open Cline in the right sidebar:
<Steps>
<Step title="Align Extensions">
Cursor uses a horizontal activity bar by default to optimize space for the AI chat interface ([see here for details](https://cursor.com/docs/configuration/migrations/vscode#activity-bar-orientation)). To switch to vertical:
1. Open the Command Palette (`CMD/Ctrl + Shift + P`)
2. Search for "Preferences: Open Settings (UI)"
3. Search for `workbench.activityBar.orientation`
4. Set the value to `vertical`
5. Restart Cursor for the changes to take effect
</Step>
<Step title="Open Agent Panel">
Click the Cursor cube icon button that opens Cursor's agent (right side view panel)
</Step>
<Step title="Drag to Three Dots">
Drag the Cline icon directly onto the three dots button - it doesn't work if you just drag it to the top, it has to be the three dots
</Step>
</Steps>
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/cursor-side-bar.gif"
alt="Cursor Right Sidebar Setup"
/>
</Frame>
Once set up, Cline will load on the right side and you can use it as normal.
+60
View File
@@ -0,0 +1,60 @@
---
title: Dictation
description:
---
Cline lets you transcribe speech to text in an easy, built-in service
## Get Started
1. **Enable Dictation** in Feature Settings.
2. **Click the microphone** in the chat input area.
3. **Speak** - the button turns red while recording.
4. **Click Stop Recording** when done.
5. **Wait for transcription** - "[Transcribing...]" will appear and then the finished transcription will appear.
## Settings
Enable or disable dictation in Feature Settings by toggling "Enable Dictation." You can also change the transcription language to one of the supported languages.
## Requirements
Cline uses FFmpeg to capture your voice across all platforms:
- **macOS**: FFmpeg (via Homebrew: `brew install ffmpeg`)
- **Linux**: FFmpeg (via apt: `sudo apt-get install ffmpeg`)
- **Windows**: FFmpeg (via winget: `winget install Gyan.FFmpeg`)
If you don't have FFmpeg installed, Cline will automatically detect this and prompt you to install it with a single click.
## Technical Details
### Independent from Chat Provider
The voice transcription feature works completely independently from whatever chat provider you're using. You can use Claude, GPT-4, or any other model for your conversations, and voice transcription will always use Cline's own transcription service. As long as you have a valid Cline account with enough credits, dictation will work regardless of your chat model choice.
### Audio Format
Voice recordings are captured in WebM format using the Opus codec for optimal compression. The system records in mono (single channel) at a 16kHz sample rate, which is specifically optimized for voice recognition. The bitrate is set to 32kbps to keep file sizes efficient while maintaining good audio quality.
### Privacy & Security
Your audio is recorded locally on your machine and only the audio file itself is sent to Cline's transcription service for processing. No audio is stored anywhere after transcription is complete, and all temporary files are automatically cleaned up to protect your privacy.
## Troubleshooting
`Failed to start recording` - Audio recording tools aren't installed. Cline will prompt you to install FFmpeg - just follow the chat instructions.
`Invalid audio format or request data` - Usually an audio recording issue. Make sure FFmpeg is properly installed and working.
`Authentication failed` - You need to reauthenticate your Cline account. Sign out and back in, then check your internet connection.
`Insufficient credits for transcription service` - Your Cline account doesn't have enough credits. Check your balance and purchase more if needed.
`Cannot connect to transcription service` - Connection issue. Check your internet connection and firewall settings aren't blocking Cline's servers.
## API Usage
Voice transcription uses Cline's transcription service, which requires credits from your Cline account. Currently, voice transcription is billed at $0.006 per minute of audio.
**Note:** We are still experimenting with this feature and pricing may change in the future.
+2 -2
View File
@@ -7,7 +7,7 @@ title: "Configuring MCP Servers"
Utilizing MCP servers will increase your token usage. Cline offers the ability to restrict or disable MCP server functionality as desired.
1. Click the "MCP Servers" icon in the top navigation bar of the Cline extension.
2. Select the "Installed" tab, and then Click the "Advanced MCP Settings" link at the bottom of that pane.
2. Select the "Configure" tab, and then Click the "Advanced MCP Settings" link at the bottom of that pane.
3. Cline will open a new settings window. find `Cline>Mcp:Mode` and make your selection from the dropdown menu.
<Frame>
@@ -56,7 +56,7 @@ To set the maximum time to wait for a response after a tool call to the MCP serv
Settings for all installed MCP servers are located in the `cline_mcp_settings.json` file:
1. Click the MCP Servers icon at the top navigation bar of the Cline pane.
2. Select the "Installed" tab.
2. Select the "Configure" tab.
3. Click the "Configure MCP Servers" button at the bottom of the pane.
The file uses a JSON format with a `mcpServers` object containing named server configurations:
+2 -2
View File
@@ -54,7 +54,7 @@ Please note: Smithery is maintained independently and is not affiliated with our
### Managing Installed MCP Servers
Once added, your MCP servers appear in the "Installed" tab where you can:
Once added, your MCP servers appear in the "Configure" tab where you can:
#### View Server Status
@@ -98,7 +98,7 @@ If a server fails to connect:
For advanced users, Cline stores MCP server configurations in a JSON file that can be modified:
1. In the "Installed" tab, click "Configure MCP Servers" to access the settings file
1. In the "Configure" tab, click "Configure MCP Servers" to access the settings file
2. The configuration for each server follows this format:
```json
+4 -4
View File
@@ -1386,7 +1386,7 @@
"integrity": "sha512-/uR4hAwpcJW9+zbmZL48kKFnWLkOxhIqoGWvZzjg0CniVhR4emtQJAps80WqLAhz0iJgCQxg/axtA7leaznDzQ==",
"license": "Elastic-2.0",
"dependencies": {
"axios": "^1.8.3",
"axios": "^1.12.0",
"openapi-types": "^12.0.0"
},
"engines": {
@@ -2688,9 +2688,9 @@
}
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
+20 -14
View File
@@ -6,15 +6,12 @@ import * as esbuild from "esbuild"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const production = process.argv.includes("--production")
const production = process.argv.includes("--production") || process.env["IS_DEBUG_BUILD"] === "false"
const watch = process.argv.includes("--watch")
const standalone = process.argv.includes("--standalone")
const e2eBuild = process.argv.includes("--e2e-build")
const destDir = standalone ? "dist-standalone" : "dist"
// Read package.json to get version for build-time injection
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
/**
* @type {import('esbuild').Plugin}
*/
@@ -126,15 +123,29 @@ const copyWasmFiles = {
},
}
const buildEnvVars = { "import.meta.url": "_importMetaUrl" }
if (production) {
// IS_DEV is always disable in production builds.
buildEnvVars["process.env.IS_DEV"] = "false"
}
// Set the environment and telemetry env vars. The API key env vars need to be populated in the GitHub
// workflows from the secrets.
if (process.env.CLINE_ENVIRONMENT) {
buildEnvVars["process.env.CLINE_ENVIRONMENT"] = JSON.stringify(process.env.CLINE_ENVIRONMENT)
}
if (process.env.TELEMETRY_SERVICE_API_KEY) {
buildEnvVars["process.env.TELEMETRY_SERVICE_API_KEY"] = JSON.stringify(process.env.TELEMETRY_SERVICE_API_KEY)
}
if (process.env.ERROR_SERVICE_API_KEY) {
buildEnvVars["process.env.ERROR_SERVICE_API_KEY"] = JSON.stringify(process.env.ERROR_SERVICE_API_KEY)
}
// Base configuration shared between extension and standalone builds
const baseConfig = {
bundle: true,
minify: production,
sourcemap: !production,
logLevel: "silent",
define: production
? { "import.meta.url": "_importMetaUrl", "process.env.IS_DEV": JSON.stringify(!production) }
: { "import.meta.url": "_importMetaUrl" },
define: buildEnvVars,
tsconfig: path.resolve(__dirname, "tsconfig.json"),
plugins: [
copyWasmFiles,
@@ -163,14 +174,9 @@ const standaloneConfig = {
...baseConfig,
entryPoints: ["src/standalone/cline-core.ts"],
outfile: `${destDir}/cline-core.js`,
// These gRPC protos need to load files from the module directory at runtime,
// so they cannot be bundled. better-sqlite3 is a native module that also cannot be bundled.
// These modules need to load files from the module directory at runtime,
// so they cannot be bundled.
external: ["vscode", "@grpc/reflection", "grpc-health-check", "better-sqlite3"],
// Inject version at build time for standalone builds
define: {
...baseConfig.define,
"process.env.CLINE_VERSION": JSON.stringify(packageJson.version),
},
}
// E2E build script configuration
+10 -9
View File
@@ -9,7 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"axios": "^1.8.2",
"axios": "^1.12.0",
"better-sqlite3": "^11.10.0",
"chalk": "5.6.2",
"commander": "^9.4.1",
@@ -200,12 +200,13 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -1667,12 +1668,12 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"requires": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
+1 -1
View File
@@ -19,7 +19,7 @@
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.8.2",
"axios": "^1.12.0",
"better-sqlite3": "^11.10.0",
"chalk": "5.6.2",
"dotenv": "^16.5.0",
+25 -33
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.30.3",
"version": "3.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.30.3",
"version": "3.32.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
@@ -35,8 +35,7 @@
"@types/uuid": "^10.0.0",
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"axios": "^1.8.2",
"better-sqlite3": "^12.4.1",
"axios": "^1.12.0",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
"chrome-launcher": "^1.1.2",
@@ -6280,20 +6279,6 @@
"node": ">=4"
}
},
"node_modules/better-sqlite3": {
"version": "12.4.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x"
}
},
"node_modules/big-integer": {
"version": "1.6.52",
"license": "Unlicense",
@@ -6341,15 +6326,6 @@
"url": "https://bevry.me/fund"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bluebird": {
"version": "3.4.7",
"license": "MIT"
@@ -7302,6 +7278,7 @@
},
"node_modules/decompress-response": {
"version": "6.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
@@ -7315,6 +7292,7 @@
},
"node_modules/decompress-response/node_modules/mimic-response": {
"version": "3.1.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -7336,6 +7314,7 @@
},
"node_modules/deep-extend": {
"version": "0.6.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4.0.0"
@@ -8287,6 +8266,7 @@
},
"node_modules/expand-template": {
"version": "2.0.3",
"dev": true,
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
@@ -8555,12 +8535,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-keys": {
"version": "1.0.2",
"dev": true,
@@ -9130,6 +9104,7 @@
},
"node_modules/github-from-package": {
"version": "0.0.0",
"dev": true,
"license": "MIT"
},
"node_modules/glob": {
@@ -9566,6 +9541,7 @@
},
"node_modules/ini": {
"version": "1.3.8",
"dev": true,
"license": "ISC"
},
"node_modules/internal-slot": {
@@ -11153,6 +11129,7 @@
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"dev": true,
"license": "MIT"
},
"node_modules/mocha": {
@@ -11380,6 +11357,7 @@
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"dev": true,
"license": "MIT"
},
"node_modules/negotiator": {
@@ -11442,6 +11420,7 @@
},
"node_modules/node-abi": {
"version": "3.75.0",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
@@ -12580,6 +12559,7 @@
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"dev": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
@@ -12604,6 +12584,7 @@
},
"node_modules/prebuild-install/node_modules/bl": {
"version": "4.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
@@ -12613,6 +12594,7 @@
},
"node_modules/prebuild-install/node_modules/buffer": {
"version": "5.7.1",
"dev": true,
"funding": [
{
"type": "github",
@@ -12635,10 +12617,12 @@
},
"node_modules/prebuild-install/node_modules/chownr": {
"version": "1.1.4",
"dev": true,
"license": "ISC"
},
"node_modules/prebuild-install/node_modules/detect-libc": {
"version": "2.0.4",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -12646,6 +12630,7 @@
},
"node_modules/prebuild-install/node_modules/readable-stream": {
"version": "3.6.2",
"dev": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
@@ -12658,6 +12643,7 @@
},
"node_modules/prebuild-install/node_modules/tar-fs": {
"version": "2.1.3",
"dev": true,
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
@@ -12668,6 +12654,7 @@
},
"node_modules/prebuild-install/node_modules/tar-stream": {
"version": "2.2.0",
"dev": true,
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
@@ -12912,6 +12899,7 @@
},
"node_modules/rc": {
"version": "1.2.8",
"dev": true,
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
@@ -12936,6 +12924,7 @@
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13815,6 +13804,7 @@
},
"node_modules/simple-concat": {
"version": "1.0.1",
"dev": true,
"funding": [
{
"type": "github",
@@ -13833,6 +13823,7 @@
},
"node_modules/simple-get": {
"version": "4.0.1",
"dev": true,
"funding": [
{
"type": "github",
@@ -14750,6 +14741,7 @@
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
+7 -3
View File
@@ -2,7 +2,7 @@
"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.30.3",
"version": "3.32.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -200,6 +200,11 @@
"command": "cline.openWalkthrough",
"title": "Open Walkthrough",
"category": "Cline"
},
{
"command": "cline.reconstructTaskHistory",
"title": "Reconstruct Task History",
"category": "Cline"
}
],
"keybindings": [
@@ -452,8 +457,7 @@
"@types/uuid": "^10.0.0",
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"axios": "^1.8.2",
"better-sqlite3": "^12.4.1",
"axios": "^1.12.0",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
"chrome-launcher": "^1.1.2",
+41
View File
@@ -0,0 +1,41 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
service DictationService {
rpc startRecording(EmptyRequest) returns (RecordingResult);
rpc stopRecording(EmptyRequest) returns (RecordedAudio);
rpc cancelRecording(EmptyRequest) returns (RecordingResult);
rpc getRecordingStatus(EmptyRequest) returns (RecordingStatus);
rpc transcribeAudio(TranscribeAudioRequest) returns (Transcription);
}
message TranscribeAudioRequest {
string audio_base64 = 2;
string language = 3;
}
message RecordingResult {
bool success = 1;
string error = 2;
}
message RecordedAudio {
bool success = 1;
string audio_base64 = 2;
string error = 3;
}
message RecordingStatus {
bool is_recording = 1;
double duration_seconds = 2;
string error = 3;
}
message Transcription {
string text = 1;
string error = 2;
}
+1 -1
View File
@@ -50,7 +50,7 @@ service FileService {
rpc refreshRules(EmptyRequest) returns (RefreshedRules);
// Opens a task's conversation history file on disk
rpc openTaskHistory(StringRequest) returns (Empty);
rpc openDiskConversationHistory(StringRequest) returns (Empty);
// Toggles a workflow on or off
rpc toggleWorkflow(ToggleWorkflowRequest) returns (ClineRulesToggles);
+62 -39
View File
@@ -1,15 +1,14 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
import "cline/models.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
option go_package = "github.com/cline/grpc-go/cline";
service StateService {
rpc getLatestState(EmptyRequest) returns (State);
rpc updateTerminalConnectionTimeout(UpdateTerminalConnectionTimeoutRequest) returns (UpdateTerminalConnectionTimeoutResponse);
rpc updateTerminalReuseEnabled(BooleanRequest) returns (Empty);
rpc updateDefaultTerminalProfile(StringRequest) returns (TerminalProfileUpdateResponse);
rpc getAvailableTerminalProfiles(EmptyRequest) returns (TerminalProfiles);
rpc subscribeToState(EmptyRequest) returns (stream State);
rpc toggleFavoriteModel(StringRequest) returns (Empty);
@@ -19,9 +18,13 @@ service StateService {
rpc updateSettings(UpdateSettingsRequest) returns (Empty);
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
rpc getProcessInfo(EmptyRequest) returns (ProcessInfo);
rpc updateInfoBannerVersion(Int64Request) returns (Empty);
}
message DictationSettings {
bool feature_enabled = 1;
bool dictation_enabled = 2;
string dictation_language = 3;
}
message State {
string state_json = 1;
}
@@ -58,6 +61,7 @@ enum OpenaiReasoningEffort {
LOW = 0;
MEDIUM = 1;
HIGH = 2;
MINIMAL = 3;
}
enum McpDisplayMode {
@@ -108,6 +112,16 @@ message TelemetrySettingRequest {
TelemetrySettingEnum setting = 2;
}
// Browser settings for UpdateSettingsRequest
message BrowserSettingsUpdate {
optional Viewport viewport = 1;
optional string remote_browser_host = 2;
optional bool remote_browser_enabled = 3;
optional string chrome_executable_path = 4;
optional bool disable_tool_use = 5;
optional string custom_args = 6;
}
// Message for updating settings
message UpdateSettingsRequest {
Metadata metadata = 1;
@@ -128,6 +142,12 @@ message UpdateSettingsRequest {
optional FocusChainSettings focus_chain_settings = 17;
optional bool use_auto_condense = 18;
optional string custom_prompt = 19;
optional BrowserSettingsUpdate browser_settings = 20;
optional string default_terminal_profile = 21;
optional bool yolo_mode_toggled = 22;
optional DictationSettings dictation_settings = 23;
optional int32 auto_condense_threshold = 24;
optional bool multi_root_enabled = 25;
}
// Complete API Configuration message
@@ -139,10 +159,10 @@ message ApiConfiguration {
optional string lite_llm_base_url = 4;
optional string lite_llm_api_key = 5;
optional bool lite_llm_use_prompt_cache = 6;
optional string openai_headers = 7; // JSON string
map<string, string> open_ai_headers = 7;
optional string anthropic_base_url = 8;
optional string openrouter_api_key = 9;
optional string openrouter_provider_sorting = 10;
optional string open_router_api_key = 9;
optional string open_router_provider_sorting = 10;
optional string aws_access_key = 11;
optional string aws_secret_key = 12;
optional string aws_session_token = 13;
@@ -155,14 +175,14 @@ message ApiConfiguration {
optional string claude_code_path = 20;
optional string vertex_project_id = 21;
optional string vertex_region = 22;
optional string openai_base_url = 23;
optional string openai_api_key = 24;
optional string open_ai_base_url = 23;
optional string open_ai_api_key = 24;
optional string ollama_base_url = 25;
optional string ollama_api_options_ctx_num = 26;
optional string lm_studio_base_url = 27;
optional string gemini_api_key = 28;
optional string gemini_base_url = 29;
optional string openai_native_api_key = 30;
optional string open_ai_native_api_key = 30;
optional string deep_seek_api_key = 31;
optional string requesty_api_key = 32;
optional string requesty_base_url = 33;
@@ -198,61 +218,65 @@ message ApiConfiguration {
optional string qwen_code_oauth_path = 63;
optional string dify_api_key = 64;
optional string dify_base_url = 65;
optional string oca_base_url = 66;
optional string oca_api_key = 67;
optional string oca_refresh_token = 68;
// Plan mode configurations
optional string plan_mode_api_provider = 100;
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int32 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
optional string plan_mode_vscode_lm_model_selector = 104; // JSON string
optional LanguageModelChatSelector plan_mode_vs_code_lm_model_selector = 104;
optional bool plan_mode_aws_bedrock_custom_selected = 105;
optional string plan_mode_aws_bedrock_custom_model_base_id = 106;
optional string plan_mode_openrouter_model_id = 107;
optional string plan_mode_openrouter_model_info = 108; // JSON string
optional string plan_mode_openai_model_id = 109;
optional string plan_mode_openai_model_info = 110; // JSON string
optional string plan_mode_open_router_model_id = 107;
optional OpenRouterModelInfo plan_mode_open_router_model_info = 108;
optional string plan_mode_open_ai_model_id = 109;
optional OpenAiCompatibleModelInfo plan_mode_open_ai_model_info = 110;
optional string plan_mode_ollama_model_id = 111;
optional string plan_mode_lm_studio_model_id = 112;
optional string plan_mode_lite_llm_model_id = 113;
optional string plan_mode_lite_llm_model_info = 114; // JSON string
optional LiteLLMModelInfo plan_mode_lite_llm_model_info = 114;
optional string plan_mode_requesty_model_id = 115;
optional string plan_mode_requesty_model_info = 116; // JSON string
optional OpenRouterModelInfo plan_mode_requesty_model_info = 116;
optional string plan_mode_together_model_id = 117;
optional string plan_mode_fireworks_model_id = 118;
optional string plan_mode_sap_ai_core_model_id = 119;
optional string plan_mode_huawei_cloud_maas_model_id = 120;
optional string plan_mode_huawei_cloud_maas_model_info = 121;
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 121;
optional string plan_mode_vercel_ai_gateway_model_id = 122;
optional string plan_mode_vercel_ai_gateway_model_info = 123;
optional OpenRouterModelInfo plan_mode_vercel_ai_gateway_model_info = 123;
optional string plan_mode_oca_model_id = 124;
optional OcaModelInfo plan_mode_oca_model_info = 125;
// Act mode configurations
optional string act_mode_api_provider = 200;
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int32 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
optional string act_mode_vscode_lm_model_selector = 204; // JSON string
optional LanguageModelChatSelector act_mode_vs_code_lm_model_selector = 204;
optional bool act_mode_aws_bedrock_custom_selected = 205;
optional string act_mode_aws_bedrock_custom_model_base_id = 206;
optional string act_mode_openrouter_model_id = 207;
optional string act_mode_openrouter_model_info = 208; // JSON string
optional string act_mode_openai_model_id = 209;
optional string act_mode_openai_model_info = 210; // JSON string
optional string act_mode_open_router_model_id = 207;
optional OpenRouterModelInfo act_mode_open_router_model_info = 208;
optional string act_mode_open_ai_model_id = 209;
optional OpenAiCompatibleModelInfo act_mode_open_ai_model_info = 210;
optional string act_mode_ollama_model_id = 211;
optional string act_mode_lm_studio_model_id = 212;
optional string act_mode_lite_llm_model_id = 213;
optional string act_mode_lite_llm_model_info = 214; // JSON string
optional LiteLLMModelInfo act_mode_lite_llm_model_info = 214;
optional string act_mode_requesty_model_id = 215;
optional string act_mode_requesty_model_info = 216; // JSON string
optional OpenRouterModelInfo act_mode_requesty_model_info = 216;
optional string act_mode_together_model_id = 217;
optional string act_mode_fireworks_model_id = 218;
optional string act_mode_sap_ai_core_model_id = 219;
optional string act_mode_huawei_cloud_maas_model_id = 220;
optional string act_mode_huawei_cloud_maas_model_info = 221;
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 221;
optional string act_mode_vercel_ai_gateway_model_id = 222;
optional string act_mode_vercel_ai_gateway_model_info = 223;
// Favorited model IDs
repeated string favorited_model_ids = 300;
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 223;
optional string act_mode_oca_model_id = 224;
optional OcaModelInfo act_mode_oca_model_info = 225;
// Extension fields for Bedrock Api Keys
optional string aws_authentication = 301;
@@ -270,12 +294,11 @@ message FocusChainSettings {
int32 remind_cline_interval = 2;
}
message Viewport {
int32 width = 1;
int32 height = 2;
}
message UpdateTerminalConnectionTimeoutResponse {
optional int32 timeout_ms = 1;
}
message ProcessInfo {
int32 process_id = 1;
optional string version = 2;
optional int64 uptime_ms = 3;
}
+4
View File
@@ -13,6 +13,7 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
import { HostProvider } from "@/hosts/host-provider"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { ExtensionRegistryInfo } from "./registry"
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
import { initializeDistinctId } from "./services/logging/distinctId"
@@ -99,6 +100,9 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
* Performs cleanup when Cline is deactivated that is common to all platforms.
*/
export async function tearDown(): Promise<void> {
// Clean up audio recording service to ensure no orphaned processes
audioRecordingService.cleanup()
PostHogClientProvider.getInstance().dispose()
telemetryService.dispose()
ErrorService.get().dispose()
+1 -1
View File
@@ -4,7 +4,7 @@ export enum Environment {
local = "local",
}
interface EnvironmentConfig {
export interface EnvironmentConfig {
appBaseUrl: string
apiBaseUrl: string
mcpBaseUrl: string
+1
View File
@@ -58,6 +58,7 @@ export interface ApiProviderInfo {
providerId: string
model: ApiHandlerModel
customPrompt?: string // "compact"
autoCondenseThreshold?: number // 0-1 range
}
export interface SingleCompletionHandler {
+29 -6
View File
@@ -150,6 +150,8 @@ export class AnthropicHandler implements ApiHandler {
}
}
let thinkingDeltaAccumulator = ""
for await (const chunk of stream) {
switch (chunk?.type) {
case "message_start":
@@ -182,14 +184,26 @@ export class AnthropicHandler implements ApiHandler {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
const thinking = chunk.content_block.thinking
const signature = chunk.content_block.signature
if (thinking && signature) {
yield {
type: "ant_thinking",
thinking,
signature,
}
}
break
case "redacted_thinking":
// Handle redacted thinking blocks - we still mark it as reasoning
// but note that the content is encrypted
// Content is encrypted, and we don't to pass placeholder text back to the API
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
yield {
type: "ant_redacted_thinking",
data: chunk.content_block.data,
}
break
case "text":
// we may receive multiple text blocks, in which case just insert a line break between them
@@ -209,10 +223,23 @@ export class AnthropicHandler implements ApiHandler {
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
// 'reasoning' type just displays in the UI, but ant_thinking will be used to send the thinking traces back to the API
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
thinkingDeltaAccumulator += chunk.delta.thinking
break
case "signature_delta":
// It's used when sending the thinking block back to the API
// API expects this in completed form, not as array of deltas
if (thinkingDeltaAccumulator && chunk.delta.signature) {
yield {
type: "ant_thinking",
thinking: thinkingDeltaAccumulator,
signature: chunk.delta.signature,
}
}
break
case "text_delta":
yield {
@@ -220,10 +247,6 @@ export class AnthropicHandler implements ApiHandler {
text: chunk.delta.text,
}
break
case "signature_delta":
// We don't need to do anything with the signature in the client
// It's used when sending the thinking block back to the API
break
}
break
case "content_block_stop":
+2 -3
View File
@@ -162,7 +162,7 @@ export class ClineHandler implements ApiHandler {
// @ts-ignore-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
if (this.getModel().id === "cline/code-supernova") {
if (this.getModel().id === "cline/code-supernova-1-million") {
totalCost = 0
}
@@ -200,14 +200,13 @@ export class ClineHandler implements ApiHandler {
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
try {
// TODO: replace this with firebase auth
// TODO: use global API Host
const clineAccountAuthToken = await this._authService.getAuthToken()
if (!clineAccountAuthToken) {
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
}
const response = await axios.get(`${this.clineAccountService.baseUrl}/generation?id=${this.lastGenerationId}`, {
headers: {
// Align with backend auth expectations
Authorization: `Bearer ${clineAccountAuthToken}`,
},
timeout: 15_000, // this request hangs sometimes
+15
View File
@@ -122,6 +122,21 @@ export class OpenRouterHandler implements ApiHandler {
}
}
// OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model
// See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
if (
"reasoning_details" in delta &&
delta.reasoning_details &&
// @ts-ignore-next-line
delta.reasoning_details.length && // exists and non-0
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning_details",
reasoning_details: delta.reasoning_details,
}
}
if (!didOutputUsage && chunk.usage) {
yield {
type: "usage",
+5 -6
View File
@@ -9,6 +9,7 @@ import { ModelInfo, SapAiCoreModelId, sapAiCoreDefaultModelId, sapAiCoreModels }
import axios from "axios"
import OpenAI from "openai"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@@ -454,6 +455,7 @@ export class SapAiCoreHandler implements ApiHandler {
return this.deployments?.some((d) => d.name.split(":")[0].toLowerCase() === modelId.split(":")[0].toLowerCase()) ?? false
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
if (this.options.sapAiCoreUseOrchestrationMode) {
yield* this.createMessageWithOrchestration(systemPrompt, messages)
@@ -823,16 +825,13 @@ export class SapAiCoreHandler implements ApiHandler {
// Handle metadata (token usage)
if (data.metadata?.usage) {
// inputTokens does not include cached write/read tokens
let inputTokens = data.metadata.usage.inputTokens || 0
const outputTokens = data.metadata.usage.outputTokens || 0
// calibrate input token
const totalTokens = data.metadata.usage.totalTokens || 0
const cacheReadInputTokens = data.metadata.usage.cacheReadInputTokens || 0
const cacheWriteOutputTokens = data.metadata.usage.cacheWriteOutputTokens || 0
if (inputTokens + outputTokens + cacheReadInputTokens + cacheWriteOutputTokens !== totalTokens) {
inputTokens = totalTokens - outputTokens - cacheReadInputTokens - cacheWriteOutputTokens
}
const cacheWriteInputTokens = data.metadata.usage.cacheWriteInputTokens || 0
inputTokens = inputTokens + cacheReadInputTokens + cacheWriteInputTokens
yield {
type: "usage",
+10
View File
@@ -115,7 +115,15 @@ export function convertToOpenAiMessages(
// Process non-tool messages
let content: string | undefined
const reasoningDetails: any[] = []
if (nonToolMessages.length > 0) {
nonToolMessages.forEach((part) => {
// @ts-ignore-next-line
if (part.type === "text" && part.reasoning_details) {
// @ts-ignore-next-line
reasoningDetails.push(part.reasoning_details)
}
})
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
@@ -142,6 +150,8 @@ export function convertToOpenAiMessages(
content,
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
// @ts-ignore-next-line
reasoning_details: reasoningDetails,
})
}
}
+23 -1
View File
@@ -1,5 +1,11 @@
export type ApiStream = AsyncGenerator<ApiStreamChunk>
export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamReasoningChunk | ApiStreamUsageChunk
export type ApiStreamChunk =
| ApiStreamTextChunk
| ApiStreamReasoningChunk
| ApiStreamReasoningDetailsChunk
| ApiStreamAnthropicThinkingChunk
| ApiStreamAnthropicRedactedThinkingChunk
| ApiStreamUsageChunk
export interface ApiStreamTextChunk {
type: "text"
@@ -11,6 +17,22 @@ export interface ApiStreamReasoningChunk {
reasoning: string
}
export interface ApiStreamReasoningDetailsChunk {
type: "reasoning_details"
reasoning_details: any // openrouter has various properties that we can pass back unmodified in api requests to preserve reasoning traces
}
export interface ApiStreamAnthropicThinkingChunk {
type: "ant_thinking"
thinking: string
signature: string
}
export interface ApiStreamAnthropicRedactedThinkingChunk {
type: "ant_redacted_thinking"
data: string
}
export interface ApiStreamUsageChunk {
type: "usage"
inputTokens: number
+286
View File
@@ -0,0 +1,286 @@
import {
ensureTaskDirectoryExists,
getSavedClineMessages,
getTaskMetadata,
readTaskHistoryFromState,
writeTaskHistoryToState,
} from "@core/storage/disk"
import { HostProvider } from "@hosts/host-provider"
import { ClineMessage } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { ShowMessageType } from "@shared/proto/host/window"
import { fileExistsAtPath } from "@utils/fs"
import * as path from "path"
import { ulid } from "ulid"
import * as vscode from "vscode"
interface TaskReconstructionResult {
totalTasks: number
reconstructedTasks: number
skippedTasks: number
errors: string[]
}
/**
* Reconstructs task history from existing task folders
*/
export async function reconstructTaskHistory(context: vscode.ExtensionContext): Promise<void> {
try {
// Show confirmation dialog using HostProvider
const proceed = await HostProvider.window.showMessage({
type: ShowMessageType.WARNING,
message:
"This will rebuild your task history from existing task data. This operation will backup your current task history and attempt to reconstruct it from task folders. Continue?",
options: {
items: ["Yes, Reconstruct", "Cancel"],
},
})
if (proceed?.selectedOption !== "Yes, Reconstruct") {
return
}
// Show initial progress message
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Reconstructing task history...",
})
const result = await performTaskHistoryReconstruction(context)
// Show results
if (result.errors.length > 0) {
const errorMessage = `Reconstruction completed with warnings:\n- Reconstructed: ${result.reconstructedTasks} tasks\n- Skipped: ${result.skippedTasks} tasks\n- Errors: ${result.errors.length}\n\nFirst few errors:\n${result.errors.slice(0, 3).join("\n")}`
HostProvider.window.showMessage({
type: ShowMessageType.WARNING,
message: errorMessage,
})
} else {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: `Task history successfully reconstructed! Found and restored ${result.reconstructedTasks} tasks.`,
})
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `Failed to reconstruct task history: ${errorMessage}`,
})
}
}
async function performTaskHistoryReconstruction(context: vscode.ExtensionContext): Promise<TaskReconstructionResult> {
const result: TaskReconstructionResult = {
totalTasks: 0,
reconstructedTasks: 0,
skippedTasks: 0,
errors: [],
}
// Backup existing task history
await backupExistingTaskHistory(context)
// Get tasks directory
const globalStoragePath = context.globalStorageUri.fsPath
const tasksDir = path.join(globalStoragePath, "tasks")
// Check if tasks directory exists
if (!(await fileExistsAtPath(tasksDir))) {
throw new Error("No tasks directory found. Nothing to reconstruct.")
}
// Scan for task directories
const taskIds = await scanTaskDirectories(tasksDir)
result.totalTasks = taskIds.length
if (taskIds.length === 0) {
throw new Error("No task directories found. Nothing to reconstruct.")
}
// Process each task
const reconstructedItems: HistoryItem[] = []
for (const taskId of taskIds) {
try {
const historyItem = await reconstructTaskHistoryItem(context, taskId)
if (historyItem) {
reconstructedItems.push(historyItem)
result.reconstructedTasks++
} else {
result.skippedTasks++
}
} catch (error) {
result.skippedTasks++
const errorMsg = error instanceof Error ? error.message : String(error)
result.errors.push(`Task ${taskId}: ${errorMsg}`)
}
}
// Sort by timestamp (newest first)
reconstructedItems.sort((a, b) => b.ts - a.ts)
// Write reconstructed history
await writeTaskHistoryToState(reconstructedItems)
return result
}
async function backupExistingTaskHistory(context: vscode.ExtensionContext): Promise<void> {
try {
const existingHistory = await readTaskHistoryFromState()
if (existingHistory.length > 0) {
const backupPath = path.join(context.globalStorageUri.fsPath, "state", `taskHistory.backup.${Date.now()}.json`)
// Ensure state directory exists
const fs = await import("fs/promises")
await fs.mkdir(path.dirname(backupPath), { recursive: true })
await fs.writeFile(backupPath, JSON.stringify(existingHistory, null, 2))
}
} catch (error) {
// Non-fatal error, just log it
console.warn("Failed to backup existing task history:", error)
}
}
async function scanTaskDirectories(tasksDir: string): Promise<string[]> {
const fs = await import("fs/promises")
try {
const entries = await fs.readdir(tasksDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => /^\d+$/.test(name)) // Only numeric task IDs
} catch (error) {
throw new Error(`Failed to scan tasks directory: ${error}`)
}
}
async function reconstructTaskHistoryItem(context: vscode.ExtensionContext, taskId: string): Promise<HistoryItem | null> {
try {
// Get task directory
const taskDir = await ensureTaskDirectoryExists(context, taskId)
// Load UI messages to extract task info
const clineMessages = await getSavedClineMessages(context, taskId)
if (clineMessages.length === 0) {
return null // Skip empty tasks
}
// Load task metadata for token usage
const metadata = await getTaskMetadata(context, taskId)
// Extract task information
const taskInfo = extractTaskInformation(clineMessages, metadata)
// Create HistoryItem
const historyItem: HistoryItem = {
id: taskId,
ulid: taskInfo.ulid || ulid(), // Generate new ULID if missing
ts: taskInfo.timestamp,
task: taskInfo.taskDescription,
tokensIn: taskInfo.tokensIn,
tokensOut: taskInfo.tokensOut,
cacheWrites: taskInfo.cacheWrites,
cacheReads: taskInfo.cacheReads,
totalCost: taskInfo.totalCost,
size: taskInfo.size,
isFavorited: taskInfo.isFavorited,
conversationHistoryDeletedRange: taskInfo.conversationHistoryDeletedRange,
}
return historyItem
} catch (error) {
throw new Error(`Failed to reconstruct task ${taskId}: ${error}`)
}
}
interface TaskInfo {
ulid?: string
timestamp: number
taskDescription: string
tokensIn: number
tokensOut: number
cacheWrites?: number
cacheReads?: number
totalCost: number
size?: number
isFavorited?: boolean
conversationHistoryDeletedRange?: [number, number]
}
function extractTaskInformation(clineMessages: ClineMessage[], metadata: any): TaskInfo {
// Find the first user message (task description)
const firstUserMessage = clineMessages.find((msg) => msg.type === "say" && msg.say === "text" && msg.text)
// Extract timestamp from first message or use task ID as fallback
const timestamp = clineMessages.length > 0 ? clineMessages[0].ts : Date.now()
// Extract task description
let taskDescription = "Untitled Task"
if (firstUserMessage?.text) {
// Clean up the task description
const cleanText = firstUserMessage.text
.replace(/<task>\s*/g, "")
.replace(/\s*<\/task>/g, "")
.trim()
const firstLine = cleanText.split("\n")[0]
if (firstLine) {
taskDescription = firstLine.substring(0, 100) // Limit length
}
}
// Calculate token usage from API request messages
let tokensIn = 0
let tokensOut = 0
let cacheWrites = 0
let cacheReads = 0
let totalCost = 0
// Look for api_req_started messages with token info
const apiReqMessages = clineMessages.filter((msg) => msg.type === "say" && msg.say === "api_req_started" && msg.text)
for (const msg of apiReqMessages) {
try {
if (msg.text) {
const apiInfo = JSON.parse(msg.text)
if (apiInfo.tokensIn) tokensIn += apiInfo.tokensIn
if (apiInfo.tokensOut) tokensOut += apiInfo.tokensOut
if (apiInfo.cacheWrites) cacheWrites += apiInfo.cacheWrites
if (apiInfo.cacheReads) cacheReads += apiInfo.cacheReads
if (apiInfo.cost) totalCost += apiInfo.cost
}
} catch {
// Ignore parsing errors
}
}
// Use metadata if available and no tokens found in messages
if (tokensIn === 0 && tokensOut === 0 && metadata.model_usage) {
for (const usage of metadata.model_usage) {
tokensIn += usage.tokensIn || 0
tokensOut += usage.tokensOut || 0
cacheWrites += usage.cacheWrites || 0
cacheReads += usage.cacheReads || 0
totalCost += usage.totalCost || 0
}
}
// Calculate approximate size (rough estimate)
const messageSize = JSON.stringify(clineMessages).length
const size = Math.floor(messageSize / 1024) // KB
return {
timestamp,
taskDescription,
tokensIn,
tokensOut,
cacheWrites: cacheWrites > 0 ? cacheWrites : undefined,
cacheReads: cacheReads > 0 ? cacheReads : undefined,
totalCost,
size,
}
}
@@ -108,15 +108,22 @@ export class ContextManager {
/**
* Determine whether we should compact context window, based on token counts
*/
shouldCompactContextWindow(clineMessages: ClineMessage[], api: ApiHandler, previousApiReqIndex: number): boolean {
shouldCompactContextWindow(
clineMessages: ClineMessage[],
api: ApiHandler,
previousApiReqIndex: number,
thresholdPercentage?: number,
): boolean {
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { maxAllowedSize } = getContextWindowInfo(api)
return totalTokens >= maxAllowedSize
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
const roundedThreshold = thresholdPercentage ? Math.floor(contextWindow * thresholdPercentage) : maxAllowedSize
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
return totalTokens >= thresholdTokens
}
}
return false
@@ -285,7 +285,7 @@ export class FileContextTracker {
static async cleanupOrphanedWarnings(context: vscode.ExtensionContext): Promise<void> {
const startTime = Date.now()
try {
const taskHistory = await readTaskHistoryFromState(context)
const taskHistory = await readTaskHistoryFromState()
const existingTaskIds = new Set(taskHistory.map((task) => task.id))
const allStateKeys = context.workspaceState.keys()
const pendingWarningKeys = allStateKeys.filter((key) => key.startsWith("pendingFileContextWarning_"))
@@ -0,0 +1,32 @@
import { RecordingResult } from "@shared/proto/cline/dictation"
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Cancels audio recording without saving or transcribing the audio
* @param controller The controller instance
* @returns RecordingResult indicating success or failure
*/
export const cancelRecording = async (controller: Controller): Promise<RecordingResult> => {
const taskId = controller.task?.taskId
const recordingStatus = audioRecordingService.getRecordingStatus()
const recordingDuration = recordingStatus.durationSeconds * 1000 // Convert to milliseconds
let errorMessage = ""
let isSuccess = true
try {
const result = await audioRecordingService.cancelRecording()
isSuccess = !!result?.success
errorMessage = result?.error ?? ""
} catch (error) {
console.error("Error canceling recording:", error)
isSuccess = false
errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
}
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, false, process.platform)
return RecordingResult.create({
success: isSuccess,
error: errorMessage ?? "",
})
}
@@ -0,0 +1,25 @@
import { RecordingStatus } from "@shared/proto/cline/dictation"
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
/**
* Gets the current recording status
* @returns RecordingStatus with current status
*/
export const getRecordingStatus = async (): Promise<RecordingStatus> => {
try {
const status = audioRecordingService.getRecordingStatus()
return RecordingStatus.create({
isRecording: status.isRecording,
durationSeconds: status.durationSeconds,
error: status.error ?? "",
})
} catch (error) {
console.error("Error getting recording status:", error)
return RecordingStatus.create({
isRecording: false,
durationSeconds: 0,
error: error instanceof Error ? error.message : "Unknown error occurred",
})
}
}
@@ -0,0 +1,165 @@
import { RecordingResult } from "@shared/proto/cline/dictation"
import * as os from "os"
import { HostProvider } from "@/hosts/host-provider"
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
import { telemetryService } from "@/services/telemetry"
import { AUDIO_PROGRAM_CONFIG } from "@/shared/audioProgramConstants"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Controller } from ".."
/**
* Handles the installation of missing dependencies with Cline
*/
async function handleInstallWithCline(
controller: Controller,
dependencyName: string,
installCommand: string,
platform: string,
): Promise<void> {
const platformName = platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : "Linux"
const installTask = `Please install ${dependencyName} for voice recording on ${platformName}.\n\nRun this command:\n\`\`\`bash\n${installCommand}\n\`\`\`\n\nThis will enable voice recording functionality in Cline.`
// Clear any existing task and start the installation task
await controller.clearTask()
await controller.postStateToWebview()
await controller.initTask(installTask)
HostProvider.get().logToChannel(`Started task to install ${dependencyName}`)
}
/**
* Handles copying the installation command to clipboard
*/
async function handleCopyCommand(installCommand: string): Promise<void> {
const vscode = await import("vscode")
await vscode.env.clipboard.writeText(installCommand)
await HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: `Installation command copied to clipboard: ${installCommand}`,
options: { items: [] },
})
}
/**
* Handles missing dependency notification and user action
*/
async function handleMissingDependency(
controller: Controller,
platform: string,
config: (typeof AUDIO_PROGRAM_CONFIG)[keyof typeof AUDIO_PROGRAM_CONFIG],
): Promise<void> {
const installWithCline = "Install with Cline"
const installManually = "Copy Command"
const dismiss = "Dismiss"
const action = await HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: `${config.dependencyName} is required for voice recording. ${config.installDescription}`,
options: { items: [installWithCline, installManually, dismiss] },
})
if (action.selectedOption === installWithCline) {
await handleInstallWithCline(controller, config.dependencyName, config.installCommand, platform)
} else if (action.selectedOption === installManually) {
await handleCopyCommand(config.installCommand)
}
// If dismiss, do nothing
}
/**
* Handles sign-in errors for dictation
*/
async function handleSignInError(controller: Controller, errorMessage: string): Promise<void> {
const signInAction = "Sign in to Cline"
const action = await HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `Voice recording error: ${errorMessage}`,
options: { items: [signInAction] },
})
if (action.selectedOption === signInAction) {
await controller.authService.createAuthRequest()
}
}
/**
* Shows a generic error message
*/
async function showGenericError(errorMessage: string): Promise<void> {
await HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `Voice recording error: ${errorMessage}`,
options: { items: [] },
})
}
/**
* Checks if the recording error is due to missing dependencies
*/
function isMissingDependencyError(
error: string | undefined,
config: (typeof AUDIO_PROGRAM_CONFIG)[keyof typeof AUDIO_PROGRAM_CONFIG] | undefined,
): boolean {
return !!(error && config && error.includes(config.error))
}
/**
* Starts audio recording using the Extension Host
* @param controller The controller instance
* @returns RecordingResult with success status
*/
export const startRecording = async (controller: Controller): Promise<RecordingResult> => {
const taskId = controller.task?.taskId
try {
// Verify user authentication
const userInfo = controller.authService.getInfo()
if (!userInfo?.user?.uid) {
throw new Error("Please sign in to your Cline Account to use Dictation.")
}
// Attempt to start recording
const result = await audioRecordingService.startRecording()
// Handle successful recording start
if (result.success) {
telemetryService.captureVoiceRecordingStarted(taskId, process.platform)
return RecordingResult.create({
success: true,
error: "",
})
}
// Check if the error is due to missing dependencies
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
const config = AUDIO_PROGRAM_CONFIG[platform]
if (isMissingDependencyError(result.error, config)) {
// Don't await - show dialog asynchronously so frontend gets immediate response
handleMissingDependency(controller, platform, config)
}
return RecordingResult.create({
success: false,
error: result.error || "",
})
} catch (error) {
console.error("Error starting recording:", error)
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
// Handle different error types
if (errorMessage.includes("sign in")) {
// Don't await - show dialog asynchronously so frontend gets immediate response
handleSignInError(controller, errorMessage)
} else {
// Don't await - show dialog asynchronously so frontend gets immediate response
showGenericError(errorMessage)
}
return RecordingResult.create({
success: false,
error: errorMessage,
})
}
}
@@ -0,0 +1,37 @@
import { RecordedAudio } from "@shared/proto/cline/dictation"
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Stops audio recording and returns the recorded audio
* @param controller The controller instance
* @returns RecordedAudio with audio data
*/
export const stopRecording = async (controller: Controller): Promise<RecordedAudio> => {
const taskId = controller.task?.taskId
const recordingStatus = audioRecordingService.getRecordingStatus()
const recordingDuration = recordingStatus.durationSeconds * 1000 // Convert to milliseconds
try {
const result = await audioRecordingService.stopRecording()
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, result.success, process.platform)
return RecordedAudio.create({
success: result.success,
audioBase64: result.audioBase64 ?? "",
error: result.error ?? "",
})
} catch (error) {
console.error("Error stopping recording:", error)
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, false, process.platform)
return RecordedAudio.create({
success: false,
audioBase64: "",
error: error instanceof Error ? error.message : "Unknown error occurred",
})
}
}
@@ -0,0 +1,73 @@
import { TranscribeAudioRequest, Transcription } from "@shared/proto/cline/dictation"
import { HostProvider } from "@/hosts/host-provider"
import { getVoiceTranscriptionService } from "@/services/dictation/VoiceTranscriptionService"
import { telemetryService } from "@/services/telemetry"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Controller } from ".."
/**
* Transcribes audio using Cline transcription service
* @param controller The controller instance
* @param request TranscribeAudioRequest containing base64 audio data
* @returns Transcription with transcribed text or error
*/
export const transcribeAudio = async (controller: Controller, request: TranscribeAudioRequest): Promise<Transcription> => {
const taskId = controller.task?.taskId
const startTime = Date.now()
// Capture telemetry for transcription start
telemetryService.captureVoiceTranscriptionStarted(taskId, request.language ?? "en")
try {
// Transcribe the audio
const result = await getVoiceTranscriptionService().transcribeAudio(request.audioBase64, request.language ?? "en")
const durationMs = Date.now() - startTime
if (result.error) {
let errorType = "api_error"
if (result.error.includes("Authentication failed")) {
errorType = "invalid_jwt_token"
} else if (result.error.includes("Insufficient credits")) {
errorType = "insufficient_credits"
} else if (result.error.includes("Invalid audio format")) {
errorType = "invalid_audio_format"
} else if (result.error.includes("No internet connection")) {
errorType = "no_internet"
} else if (result.error.includes("Cannot connect")) {
errorType = "connection_error"
} else if (result.error.includes("Connection timed out")) {
errorType = "timeout_error"
} else if (result.error.includes("Network error")) {
errorType = "network_error"
}
telemetryService.captureVoiceTranscriptionError(taskId, errorType, result.error, durationMs)
// Use the error message directly from the service as it's already user-friendly
const errorMessage = result.error
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: errorMessage,
})
} else if (result.text) {
telemetryService.captureVoiceTranscriptionCompleted(taskId, result.text.length, durationMs, request.language ?? "en")
}
return Transcription.create({
text: result.text ?? "",
error: result.error ?? "",
})
} catch (error) {
console.error("Error transcribing audio:", error)
const durationMs = Date.now() - startTime
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
telemetryService.captureVoiceTranscriptionError(taskId, "unexpected_error", errorMessage, durationMs)
return Transcription.create({
text: "",
error: errorMessage,
})
}
}
@@ -9,11 +9,11 @@ import { Controller } from ".."
* @param request The request message containing the file path in the 'value' field
* @returns Empty response
*/
export async function openTaskHistory(_controller: Controller, request: StringRequest): Promise<Empty> {
export async function openDiskConversationHistory(_controller: Controller, request: StringRequest): Promise<Empty> {
const globalStoragePath = HostProvider.get().globalStorageFsPath
const taskHistoryPath = path.join(globalStoragePath, "tasks", request.value, "api_conversation_history.json")
const taskConversationHistoryPath = path.join(globalStoragePath, "tasks", request.value, "api_conversation_history.json")
if (request.value) {
openFileIntegration(taskHistoryPath)
openFileIntegration(taskConversationHistoryPath)
}
return Empty.create()
}
+27 -5
View File
@@ -26,6 +26,7 @@ import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { AuthService } from "@/services/auth/AuthService"
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { telemetryService } from "@/services/telemetry"
import { ShowMessageType } from "@/shared/proto/host/window"
@@ -41,6 +42,7 @@ import {
import { PersistenceErrorEvent, StateManager } from "../storage/StateManager"
import { Task } from "../task"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
import { appendClineStealthModels } from "./models/refreshOpenRouterModels"
import { sendStateUpdate } from "./state/subscribeToState"
/*
@@ -197,7 +199,6 @@ export class Controller {
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
const shellIntegrationTimeout = this.stateManager.getGlobalSettingsKey("shellIntegrationTimeout")
const terminalReuseEnabled = this.stateManager.getGlobalStateKey("terminalReuseEnabled")
const terminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
@@ -599,10 +600,15 @@ export class Controller {
// Read OpenRouter models from disk cache
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
if (fileExists) {
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
return JSON.parse(fileContents)
try {
if (await fileExistsAtPath(openRouterModelsFilePath)) {
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
const models = JSON.parse(fileContents)
// Append stealth models
return appendClineStealthModels(models)
}
} catch (error) {
console.error("Error reading cached OpenRouter models:", error)
}
return undefined
}
@@ -687,6 +693,7 @@ export class Controller {
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
const dictationSettings = this.stateManager.getGlobalSettingsKey("dictationSettings")
const preferredLanguage = this.stateManager.getGlobalSettingsKey("preferredLanguage")
const openaiReasoningEffort = this.stateManager.getGlobalSettingsKey("openaiReasoningEffort")
const mode = this.stateManager.getGlobalSettingsKey("mode")
@@ -712,11 +719,13 @@ export class Controller {
const mcpResponsesCollapsed = this.stateManager.getGlobalStateKey("mcpResponsesCollapsed")
const terminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
const favoritedModelIds = this.stateManager.getGlobalStateKey("favoritedModelIds")
const lastDismissedInfoBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedInfoBannerVersion") || 0
const localClineRulesToggles = this.stateManager.getWorkspaceStateKey("localClineRulesToggles")
const localWindsurfRulesToggles = this.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
const clineMessages = this.task?.messageStateHandler.getClineMessages() || []
@@ -733,6 +742,12 @@ export class Controller {
const distinctId = getDistinctId()
const version = ExtensionRegistryInfo.version
// Set feature flag in dictation settings based on platform
const updatedDictationSettings = {
...dictationSettings,
featureEnabled: process.platform === "darwin", // Enable dictation only on macOS
}
return {
version,
apiConfiguration,
@@ -743,6 +758,7 @@ export class Controller {
autoApprovalSettings,
browserSettings,
focusChainSettings,
dictationSettings: updatedDictationSettings,
preferredLanguage,
openaiReasoningEffort,
mode,
@@ -774,10 +790,16 @@ export class Controller {
platform,
shouldShowAnnouncement,
favoritedModelIds,
autoCondenseThreshold,
// NEW: Add workspace information
workspaceRoots: this.workspaceManager?.getRoots() ?? [],
primaryRootIndex: this.workspaceManager?.getPrimaryIndex() ?? 0,
isMultiRootWorkspace: (this.workspaceManager?.getRoots().length ?? 0) > 1,
multiRootSetting: {
user: this.stateManager.getGlobalStateKey("multiRootEnabled"),
featureFlag: featureFlagsService.getMultiRootEnabled(),
},
lastDismissedInfoBannerVersion,
}
}
@@ -4,7 +4,7 @@ import axios from "axios"
import { HostProvider } from "@/hosts/host-provider"
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
import { DEFAULT_OCA_BASE_URL } from "@/services/auth/oca/utils/constants"
import { createOcaHeaders, getProxyAgents } from "@/services/auth/oca/utils/utils"
import { createOcaHeaders, getAxiosSettings } from "@/services/auth/oca/utils/utils"
import { Logger } from "@/services/logging/Logger"
import { ShowMessageType } from "@/shared/proto/index.host"
import { Controller } from ".."
@@ -25,12 +25,19 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
const models: Record<string, OcaModelInfo> = {}
let defaultModelId: string | undefined
const ocaAccessToken = await OcaAuthService.getInstance().getAuthToken()
if (!ocaAccessToken) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Not authenticated with OCA. Please sign in first.",
})
return OcaCompatibleModelInfo.create({ error: "Not authenticated with OCA" })
}
const baseUrl = request.value || DEFAULT_OCA_BASE_URL
const modelsUrl = `${baseUrl}/v1/model/info`
const headers = await createOcaHeaders(ocaAccessToken!, "models-refresh")
try {
Logger.log(`Making refresh oca model request with customer opc-request-id: ${headers["opc-request-id"]}`)
const response = await axios.get(modelsUrl, { headers, ...getProxyAgents() })
const response = await axios.get(modelsUrl, { headers, ...getAxiosSettings() })
if (response.data?.data) {
if (response.data.data.length === 0) {
HostProvider.window.showMessage({
@@ -1,7 +1,6 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { EmptyRequest } from "@shared/proto/cline/common"
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "@shared/proto/cline/models"
import { fileExistsAtPath } from "@utils/fs"
import axios from "axios"
import cloneDeep from "clone-deep"
import fs from "fs/promises"
@@ -79,7 +78,7 @@ export async function refreshOpenRouterModels(
): Promise<OpenRouterCompatibleModelInfo> {
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
let models: Record<string, OpenRouterModelInfo> = {}
const models: Record<string, OpenRouterModelInfo> = {}
try {
const response = await axios.get("https://openrouter.ai/api/v1/models")
@@ -221,22 +220,6 @@ export async function refreshOpenRouterModels(
models[openRouterClaudeSonnet41mModelId] = claudeSonnet41mModelInfo
}
}
// Add hardcoded stealth model
models["cline/code-supernova"] = OpenRouterModelInfo.create({
maxTokens: clineCodeSupernovaModelInfo.maxTokens ?? 0,
contextWindow: clineCodeSupernovaModelInfo.contextWindow ?? 0,
supportsImages: clineCodeSupernovaModelInfo.supportsImages ?? false,
supportsPromptCache: clineCodeSupernovaModelInfo.supportsPromptCache ?? false,
inputPrice: clineCodeSupernovaModelInfo.inputPrice ?? 0,
outputPrice: clineCodeSupernovaModelInfo.outputPrice ?? 0,
cacheWritesPrice: clineCodeSupernovaModelInfo.cacheWritesPrice ?? 0,
cacheReadsPrice: clineCodeSupernovaModelInfo.cacheReadsPrice ?? 0,
description: clineCodeSupernovaModelInfo.description ?? "",
thinkingConfig: clineCodeSupernovaModelInfo.thinkingConfig ?? undefined,
supportsGlobalEndpoint: clineCodeSupernovaModelInfo.supportsGlobalEndpoint ?? undefined,
tiers: clineCodeSupernovaModelInfo.tiers ?? [],
})
} else {
console.error("Invalid response from OpenRouter API")
}
@@ -246,29 +229,45 @@ export async function refreshOpenRouterModels(
console.error("Error fetching OpenRouter models:", error)
// If we failed to fetch models, try to read cached models
const cachedModels = await readOpenRouterModels(controller)
const cachedModels = await controller.readOpenRouterModels()
if (cachedModels) {
models = cachedModels
return OpenRouterCompatibleModelInfo.create({ models: cachedModels })
}
}
return OpenRouterCompatibleModelInfo.create({ models })
// Append stealth models if any
return OpenRouterCompatibleModelInfo.create({ models: appendClineStealthModels(models) })
}
/**
* Reads cached OpenRouter models from disk
* Stealth models are models that are compatible with the OpenRouter API but not listed on the OpenRouter website or API.
*/
async function readOpenRouterModels(controller: Controller): Promise<Record<string, OpenRouterModelInfo> | undefined> {
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
if (fileExists) {
try {
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
return JSON.parse(fileContents)
} catch (error) {
console.error("Error reading cached OpenRouter models:", error)
return undefined
const CLINE_STEALTH_MODELS: Record<string, OpenRouterModelInfo> = {
"cline/code-supernova-1-million": OpenRouterModelInfo.create({
maxTokens: clineCodeSupernovaModelInfo.maxTokens ?? 0,
contextWindow: clineCodeSupernovaModelInfo.contextWindow ?? 0,
supportsImages: clineCodeSupernovaModelInfo.supportsImages ?? false,
supportsPromptCache: clineCodeSupernovaModelInfo.supportsPromptCache ?? false,
inputPrice: clineCodeSupernovaModelInfo.inputPrice ?? 0,
outputPrice: clineCodeSupernovaModelInfo.outputPrice ?? 0,
cacheWritesPrice: clineCodeSupernovaModelInfo.cacheWritesPrice ?? 0,
cacheReadsPrice: clineCodeSupernovaModelInfo.cacheReadsPrice ?? 0,
description: clineCodeSupernovaModelInfo.description ?? "",
thinkingConfig: clineCodeSupernovaModelInfo.thinkingConfig ?? undefined,
supportsGlobalEndpoint: clineCodeSupernovaModelInfo.supportsGlobalEndpoint ?? undefined,
tiers: clineCodeSupernovaModelInfo.tiers ?? [],
}),
// Add more stealth models here as needed
}
export function appendClineStealthModels(
currentModels: Record<string, OpenRouterModelInfo>,
): Record<string, OpenRouterModelInfo> {
// Create a shallow clone of the current models to avoid mutating the original object
const cloned = { ...currentModels }
for (const [modelId, modelInfo] of Object.entries(CLINE_STEALTH_MODELS)) {
if (!cloned[modelId]) {
cloned[modelId] = modelInfo
}
}
return undefined
return cloned
}
@@ -1,20 +0,0 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { ProcessInfo } from "@shared/proto/cline/state"
import { Controller } from ".."
/**
* Gets process information including PID, version, and uptime
* @param controller The controller instance
* @param request Empty request
* @returns ProcessInfo with process details
*/
export async function getProcessInfo(controller: Controller, request: EmptyRequest): Promise<ProcessInfo> {
// Get the current state to access the version (same source as webview)
const state = await controller.getStateToPostToWebview()
return ProcessInfo.create({
processId: process.pid,
version: state.version || "unknown",
uptimeMs: Math.floor(process.uptime() * 1000), // Convert seconds to milliseconds
})
}
@@ -0,0 +1,17 @@
import { Empty, Int64Request } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Updates the info banner version to track which version the user has dismissed
* @param controller The controller instance
* @param request The request containing the version number
* @returns Empty response
*/
export async function updateInfoBannerVersion(controller: Controller, request: Int64Request): Promise<Empty> {
const version = Number(request.value)
controller.stateManager.setGlobalState("lastDismissedInfoBannerVersion", version)
await controller.postStateToWebview()
return Empty.create()
}
+19 -1
View File
@@ -1,4 +1,5 @@
import { buildApiHandler } from "@core/api"
import { Empty } from "@shared/proto/cline/common"
import {
PlanActMode,
@@ -147,7 +148,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
if (request.strictPlanModeEnabled !== undefined) {
controller.stateManager.setGlobalState("strictPlanModeEnabled", request.strictPlanModeEnabled)
}
// Update yolo mode setting
if (request.yoloModeToggled !== undefined) {
if (controller.task) {
@@ -156,6 +156,15 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("yoloModeToggled", request.yoloModeToggled)
}
if (request.dictationSettings !== undefined) {
// Convert from protobuf format (snake_case) to TypeScript format (camelCase)
const dictationSettings = {
featureEnabled: request.dictationSettings.featureEnabled ?? true,
dictationEnabled: request.dictationSettings.dictationEnabled ?? true,
dictationLanguage: request.dictationSettings.dictationLanguage ?? "en",
}
controller.stateManager.setGlobalState("dictationSettings", dictationSettings)
}
// Update auto-condense setting
if (request.useAutoCondense !== undefined) {
if (controller.task) {
@@ -273,6 +282,15 @@ export async function updateSettings(controller: Controller, request: UpdateSett
}
}
if (request.autoCondenseThreshold !== undefined) {
const threshold = Math.min(1, Math.max(0, request.autoCondenseThreshold)) // Clamp to 0-1 range
controller.stateManager.setGlobalState("autoCondenseThreshold", threshold)
}
if (request.multiRootEnabled !== undefined) {
controller.stateManager.setGlobalState("multiRootEnabled", !!request.multiRootEnabled)
}
// Post updated state to webview
await controller.postStateToWebview()
+5 -6
View File
@@ -18,12 +18,11 @@ import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels
*/
export async function initializeWebview(controller: Controller, _request: EmptyRequest): Promise<Empty> {
try {
// Post last cached models in case the call to endpoint fails
controller.readOpenRouterModels().then((openRouterModels) => {
if (openRouterModels) {
sendOpenRouterModelsEvent(OpenRouterCompatibleModelInfo.create({ models: openRouterModels }))
}
})
// Post last cached models as soon as possible for immediate availability in the UI
const lastCachedModels = await controller.readOpenRouterModels()
if (lastCachedModels) {
sendOpenRouterModelsEvent(OpenRouterCompatibleModelInfo.create({ models: lastCachedModels }))
}
// Refresh OpenRouter models from API
refreshOpenRouterModels(controller, EmptyRequest.create()).then(async (response) => {
-229
View File
@@ -1,229 +0,0 @@
import Database from "better-sqlite3"
import { existsSync, mkdirSync, unlinkSync } from "fs"
import * as path from "path"
import type { InstanceLockData, SqliteLockManagerOptions } from "./types"
export class SqliteLockManager {
private db!: Database.Database
private instanceAddress: string
private dbPath: string
private readonly STALE_LOCK_TIMEOUT = 1 * 60 * 1000 // 1 minute in milliseconds
constructor(options: SqliteLockManagerOptions) {
this.instanceAddress = options.instanceAddress
this.dbPath = options.dbPath
// Ensure the directory exists before creating the database
const dbDir = path.dirname(this.dbPath)
try {
mkdirSync(dbDir, { recursive: true })
} catch (error) {
console.error(`CRITICAL ERROR: Failed to create SQLite database directory ${dbDir}:`, error)
throw new Error(`Failed to create SQLite database directory: ${error}`)
}
try {
this.initializeDatabaseWithLockSync()
} catch (error) {
console.error(`CRITICAL ERROR: Failed to initialize SQLite database at ${this.dbPath}:`, error)
throw new Error(`Failed to initialize SQLite database: ${error}`)
}
}
private initializeDatabaseWithLockSync(): void {
const lockFile = `${this.dbPath}.lock`
// Clean up stale lock files first
this.cleanupStaleLockSync(lockFile)
try {
// Try to acquire exclusive file lock for database creation
const fs = require("fs")
let fd: number | null = null
try {
fd = fs.openSync(lockFile, "wx") // Exclusive creation - fails if file exists
// Write timestamp to lock file for stale lock detection
fs.writeFileSync(fd, Date.now().toString())
// Check if database already exists
const dbExists = existsSync(this.dbPath)
if (!dbExists) {
// Database doesn't exist, create it
this.db = new Database(this.dbPath)
this.initializeDatabase()
} else {
// Database exists, just open it
this.db = new Database(this.dbPath)
}
} finally {
// Always clean up the lock file
if (fd !== null) {
fs.closeSync(fd)
}
try {
unlinkSync(lockFile)
} catch {} // Ignore errors if file was already deleted
}
} catch (error: any) {
if (error.code === "EEXIST") {
// Another process is initializing the database, wait and retry
const delay = 100 + Math.random() * 100 // Add jitter
this.sleepSync(delay)
this.initializeDatabaseWithLockSync()
return
}
throw error
}
}
private sleepSync(ms: number) {
// Non-spinning, synchronous sleep using Atomics.wait
// Works in Node main thread (since v12.16+) and worker threads.
const sab = new SharedArrayBuffer(4)
const ia = new Int32Array(sab)
Atomics.wait(ia, 0, 0, Math.max(0, Math.floor(ms)))
}
private cleanupStaleLockSync(lockFile: string): void {
try {
if (!existsSync(lockFile)) {
return // Lock file doesn't exist, nothing to clean up
}
const fs = require("fs")
try {
const timestampStr = fs.readFileSync(lockFile, "utf8").trim()
const timestamp = parseInt(timestampStr, 10)
if (isNaN(timestamp) || Date.now() - timestamp > this.STALE_LOCK_TIMEOUT) {
// Stale lock, remove it
unlinkSync(lockFile)
console.warn(`Removed stale database lock file: ${lockFile}`)
}
} catch (readError) {
// If we can't read the timestamp, assume it's stale
unlinkSync(lockFile)
console.warn(`Removed unreadable database lock file: ${lockFile}`)
}
} catch (error: any) {
if (error.code !== "ENOENT") {
// Lock file doesn't exist, which is fine
console.warn(`Error checking lock file ${lockFile}:`, error)
}
}
}
private initializeDatabase() {
// Create the locks table with the unified schema (matches cli/pkg/common/schema.go)
this.db.exec(`
CREATE TABLE IF NOT EXISTS locks (
id INTEGER PRIMARY KEY,
held_by TEXT NOT NULL,
lock_type TEXT NOT NULL CHECK (lock_type IN ('file', 'instance', 'folder')),
lock_target TEXT NOT NULL,
locked_at INTEGER NOT NULL,
UNIQUE(lock_type, lock_target)
);
`)
// Create indexes for performance (matches cli/pkg/common/schema.go)
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_locks_held_by ON locks(held_by);
CREATE INDEX IF NOT EXISTS idx_locks_type ON locks(lock_type);
CREATE INDEX IF NOT EXISTS idx_locks_target ON locks(lock_target);
`)
}
/**
* Register this instance in the locks table
*/
async registerInstance(data: {
corePort: number
hostPort: number
version?: string
status?: InstanceLockData["status"]
}): Promise<void> {
const now = Date.now()
const hostAddress = `localhost:${data.hostPort}`
// Create instance lock entry
const insertLock = this.db.prepare(`
INSERT OR REPLACE INTO locks (held_by, lock_type, lock_target, locked_at)
VALUES (?, 'instance', ?, ?)
`)
insertLock.run(this.instanceAddress, hostAddress, now)
}
/**
* Update the timestamp for this instance (touch)
*/
touchInstance(): void {
const now = Date.now()
const updateLock = this.db.prepare(`
UPDATE locks
SET locked_at = ?
WHERE held_by = ? AND lock_type = 'instance'
`)
updateLock.run(now, this.instanceAddress)
}
/**
* Remove this instance from the locks table
*/
unregisterInstance(): void {
const deleteLock = this.db.prepare(`
DELETE FROM locks
WHERE held_by = ? AND lock_type = 'instance'
`)
deleteLock.run(this.instanceAddress)
}
/**
* Query the registry for any instance registered on the given port
*/
getInstanceByPort(port: number): { instanceAddress: string; hostAddress: string } | null {
const query = this.db.prepare(`
SELECT held_by, lock_target
FROM locks
WHERE lock_type = 'instance'
AND (held_by LIKE '%:' || ? OR lock_target LIKE '%:' || ?)
`)
const result = query.get(port, port) as { held_by: string; lock_target: string } | undefined
if (result) {
return {
instanceAddress: result.held_by,
hostAddress: result.lock_target,
}
}
return null
}
/**
* Remove a specific instance entry from the registry
*/
removeInstanceByAddress(instanceAddress: string): void {
const deleteLock = this.db.prepare(`
DELETE FROM locks
WHERE held_by = ? AND lock_type = 'instance'
`)
deleteLock.run(instanceAddress)
}
/**
* Close the database connection
*/
close(): void {
this.db.close()
}
}
-28
View File
@@ -1,28 +0,0 @@
export type LockType = "file" | "instance" | "folder"
export type LockStatus = "starting" | "healthy" | "unhealthy"
export interface LockRow {
id: number
held_by: string // address:port of instance holding the lock
lock_type: LockType
lock_target: string // varies by type: file path, host address, or folder path
locked_at: number // timestamp when lock was acquired
}
export interface InstanceLockData {
address: string
core_port: number
host_port: number
status: LockStatus
last_seen: string
process_pid: number
version?: string
created_at: string
metadata?: Record<string, any>
}
export interface SqliteLockManagerOptions {
dbPath: string
instanceAddress: string // host:port format
}
@@ -20,7 +20,6 @@ export async function getToolUseToolsSection(variant: PromptVariant, context: Sy
// Define multi-root hint based on feature flag
const multiRootHint = context.isMultiRootEnabled ? MULTI_ROOT_HINT : ""
return new TemplateEngine().resolve(template, context, {
TASK_PROGRESS: shouldIncludeTaskProgress ? TASK_PROGRESS : "",
FOCUS_CHAIN_ATTEMPT: shouldIncludeTaskProgress ? FOCUS_CHAIN_ATTEMPT : "",
@@ -22,6 +22,7 @@ const generic: ClineToolSpec = {
name: "diff",
required: true,
instruction: `One or more SEARCH/REPLACE blocks following this exact format:
\`\`\`
------- SEARCH
[exact content to find]
@@ -30,7 +30,7 @@ const generic: ClineToolSpec = {
{
name: "path",
required: true,
instruction: `The path of the directory to search in (relative to the current working directory {{CWD}}). This directory will be recursively searched.`,
instruction: `The path of the directory to search in (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}. This directory will be recursively searched.`,
usage: "Directory path here",
},
{
+3 -4
View File
@@ -24,7 +24,6 @@ import {
SettingsKey,
} from "./state-keys"
import { readGlobalStateFromDisk, readSecretsFromDisk, readWorkspaceStateFromDisk } from "./utils/state-helpers"
export interface PersistenceErrorEvent {
error: Error
}
@@ -276,7 +275,7 @@ export class StateManager {
*/
private async setupTaskHistoryWatcher(): Promise<void> {
try {
const historyFile = await getTaskHistoryStateFilePath(this.context)
const historyFile = await getTaskHistoryStateFilePath()
// Close any existing watcher before creating a new one
if (this.taskHistoryWatcher) {
@@ -296,7 +295,7 @@ export class StateManager {
if (!this.isInitialized) {
return
}
const onDisk = await readTaskHistoryFromState(this.context)
const onDisk = await readTaskHistoryFromState()
const cached = this.globalStateCache["taskHistory"]
if (JSON.stringify(onDisk) !== JSON.stringify(cached)) {
this.globalStateCache["taskHistory"] = onDisk
@@ -765,7 +764,7 @@ export class StateManager {
Array.from(keys).map((key) => {
if (key === "taskHistory") {
// Route task history persistence to file, not VS Code globalState
return writeTaskHistoryToState(this.context, this.globalStateCache[key])
return writeTaskHistoryToState(this.globalStateCache[key])
}
return this.context.globalState.update(key, this.globalStateCache[key])
}),
+11 -11
View File
@@ -99,7 +99,7 @@ export async function ensureMcpServersDirectoryExists(): Promise<string> {
try {
await fs.mkdir(mcpServersDir, { recursive: true })
} catch (_error) {
return "~/Documents/Cline/MCP" // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
return path.join(os.homedir(), "Documents", "Cline", "MCP") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
}
return mcpServersDir
}
@@ -184,8 +184,8 @@ export async function saveTaskMetadata(context: vscode.ExtensionContext, taskId:
}
}
export async function ensureStateDirectoryExists(context: vscode.ExtensionContext): Promise<string> {
const stateDir = path.join(context.globalStorageUri.fsPath, "state")
export async function ensureStateDirectoryExists(): Promise<string> {
const stateDir = path.join(HostProvider.get().globalStorageFsPath, "state")
await fs.mkdir(stateDir, { recursive: true })
return stateDir
}
@@ -194,18 +194,18 @@ export async function ensureCacheDirectoryExists(): Promise<string> {
return HostProvider.getGlobalStorageDir("cache")
}
export async function getTaskHistoryStateFilePath(context: vscode.ExtensionContext): Promise<string> {
return path.join(await ensureStateDirectoryExists(context), "taskHistory.json")
export async function getTaskHistoryStateFilePath(): Promise<string> {
return path.join(await ensureStateDirectoryExists(), "taskHistory.json")
}
export async function taskHistoryStateFileExists(context: vscode.ExtensionContext): Promise<boolean> {
const filePath = await getTaskHistoryStateFilePath(context)
export async function taskHistoryStateFileExists(): Promise<boolean> {
const filePath = await getTaskHistoryStateFilePath()
return fileExistsAtPath(filePath)
}
export async function readTaskHistoryFromState(context: vscode.ExtensionContext): Promise<HistoryItem[]> {
export async function readTaskHistoryFromState(): Promise<HistoryItem[]> {
try {
const filePath = await getTaskHistoryStateFilePath(context)
const filePath = await getTaskHistoryStateFilePath()
if (await fileExistsAtPath(filePath)) {
const contents = await fs.readFile(filePath, "utf8")
try {
@@ -222,9 +222,9 @@ export async function readTaskHistoryFromState(context: vscode.ExtensionContext)
}
}
export async function writeTaskHistoryToState(context: vscode.ExtensionContext, items: HistoryItem[]): Promise<void> {
export async function writeTaskHistoryToState(items: HistoryItem[]): Promise<void> {
try {
const filePath = await getTaskHistoryStateFilePath(context)
const filePath = await getTaskHistoryStateFilePath()
// Always create the file; if items is empty, write [] to ensure presence on first startup
await fs.writeFile(filePath, JSON.stringify(items))
} catch (error) {
+4 -1
View File
@@ -5,13 +5,13 @@ import { WorkspaceRoot } from "@/core/workspace/WorkspaceRoot"
import { AutoApprovalSettings } from "@/shared/AutoApprovalSettings"
import { BrowserSettings } from "@/shared/BrowserSettings"
import { ClineRulesToggles } from "@/shared/cline-rules"
import { DictationSettings } from "@/shared/DictationSettings"
import { HistoryItem } from "@/shared/HistoryItem"
import { McpDisplayMode } from "@/shared/McpDisplayMode"
import { McpMarketplaceCatalog } from "@/shared/mcp"
import { Mode, OpenaiReasoningEffort } from "@/shared/storage/types"
import { TelemetrySetting } from "@/shared/TelemetrySetting"
import { UserInfo } from "@/shared/UserInfo"
export type SecretKey = keyof Secrets
export type GlobalStateKey = keyof GlobalState
@@ -40,6 +40,7 @@ export interface GlobalState {
workspaceRoots: WorkspaceRoot[] | undefined
primaryRootIndex: number
multiRootEnabled: boolean
lastDismissedInfoBannerVersion: number
}
export interface Settings {
@@ -94,9 +95,11 @@ export interface Settings {
preferredLanguage: string
openaiReasoningEffort: OpenaiReasoningEffort
mode: Mode
dictationSettings: DictationSettings
focusChainSettings: FocusChainSettings
customPrompt: "compact" | undefined
difyBaseUrl: string | undefined
autoCondenseThreshold: number | undefined // number from 0 to 1
ocaBaseUrl: string | undefined
// Plan mode configurations
+3 -3
View File
@@ -84,7 +84,7 @@ export async function migrateTaskHistoryToFile(context: vscode.ExtensionContext)
let finalData: HistoryItem[]
let migrationAction: string
const newLocationData = await readTaskHistoryFromState(context)
const newLocationData = await readTaskHistoryFromState()
if (newLocationData.length === 0) {
// Move old data to new location
@@ -97,9 +97,9 @@ export async function migrateTaskHistoryToFile(context: vscode.ExtensionContext)
}
// Perform migration operations sequentially - only clear old data if write succeeds
await writeTaskHistoryToState(context, finalData)
await writeTaskHistoryToState(finalData)
const successfullyWrittenData = await readTaskHistoryFromState(context)
const successfullyWrittenData = await readTaskHistoryFromState()
if (!Array.isArray(successfullyWrittenData)) {
console.error("[Storage Migration] Failed to write taskHistory to file: Written data is not an array")
+18 -7
View File
@@ -1,15 +1,15 @@
import { ApiProvider, fireworksDefaultModelId, type OcaModelInfo } from "@shared/api"
import { ANTHROPIC_MIN_THINKING_BUDGET, ApiProvider, fireworksDefaultModelId, type OcaModelInfo } from "@shared/api"
import { ExtensionContext } from "vscode"
import { Controller } from "@/core/controller"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@/shared/AutoApprovalSettings"
import { DEFAULT_BROWSER_SETTINGS } from "@/shared/BrowserSettings"
import { ClineRulesToggles } from "@/shared/cline-rules"
import { DEFAULT_DICTATION_SETTINGS, DictationSettings } from "@/shared/DictationSettings"
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@/shared/FocusChainSettings"
import { DEFAULT_MCP_DISPLAY_MODE } from "@/shared/McpDisplayMode"
import { OpenaiReasoningEffort } from "@/shared/storage/types"
import { readTaskHistoryFromState } from "../disk"
import { GlobalStateAndSettings, LocalState, SecretKey, Secrets } from "../state-keys"
export async function readSecretsFromDisk(context: ExtensionContext): Promise<Secrets> {
const [
apiKey,
@@ -230,12 +230,18 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
context.globalState.get<GlobalStateAndSettings["openaiReasoningEffort"]>("openaiReasoningEffort")
const preferredLanguage = context.globalState.get<GlobalStateAndSettings["preferredLanguage"]>("preferredLanguage")
const focusChainSettings = context.globalState.get<GlobalStateAndSettings["focusChainSettings"]>("focusChainSettings")
const dictationSettings = context.globalState.get<GlobalStateAndSettings["dictationSettings"]>("dictationSettings") as
| DictationSettings
| undefined
const mcpMarketplaceCatalog =
context.globalState.get<GlobalStateAndSettings["mcpMarketplaceCatalog"]>("mcpMarketplaceCatalog")
const lastDismissedInfoBannerVersion =
context.globalState.get<GlobalStateAndSettings["lastDismissedInfoBannerVersion"]>("lastDismissedInfoBannerVersion")
const qwenCodeOauthPath = context.globalState.get<GlobalStateAndSettings["qwenCodeOauthPath"]>("qwenCodeOauthPath")
const customPrompt = context.globalState.get<GlobalStateAndSettings["customPrompt"]>("customPrompt")
const autoCondenseThreshold =
context.globalState.get<GlobalStateAndSettings["autoCondenseThreshold"]>("autoCondenseThreshold") // number from 0 to 1
// Get mode-related configurations
const mode = context.globalState.get<GlobalStateAndSettings["mode"]>("mode")
@@ -399,7 +405,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
}
}
const taskHistory = await readTaskHistoryFromState(context)
const taskHistory = await readTaskHistoryFromState()
// Multi-root workspace support
const workspaceRoots = context.globalState.get<GlobalStateAndSettings["workspaceRoots"]>("workspaceRoots")
@@ -455,7 +461,9 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
// Plan mode configurations
planModeApiProvider: planModeApiProvider || apiProvider,
planModeApiModelId,
planModeThinkingBudgetTokens,
// undefined means it was never modified, 0 means it was turned off
// (having this on by default ensures that <thinking> text does not pollute the user's chat and is instead rendered as reasoning)
planModeThinkingBudgetTokens: planModeThinkingBudgetTokens ?? ANTHROPIC_MIN_THINKING_BUDGET,
planModeReasoningEffort,
planModeVsCodeLmModelSelector,
planModeAwsBedrockCustomSelected,
@@ -489,7 +497,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
// Act mode configurations
actModeApiProvider: actModeApiProvider || apiProvider,
actModeApiModelId,
actModeThinkingBudgetTokens,
actModeThinkingBudgetTokens: actModeThinkingBudgetTokens ?? ANTHROPIC_MIN_THINKING_BUDGET,
actModeReasoningEffort,
actModeVsCodeLmModelSelector,
actModeAwsBedrockCustomSelected,
@@ -523,6 +531,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
// Other global fields
focusChainSettings: focusChainSettings || DEFAULT_FOCUS_CHAIN_SETTINGS,
dictationSettings: { ...DEFAULT_DICTATION_SETTINGS, ...dictationSettings },
strictPlanModeEnabled: strictPlanModeEnabled ?? true,
yoloModeToggled: yoloModeToggled ?? false,
useAutoCondense: useAutoCondense ?? false,
@@ -551,12 +560,14 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
mcpMarketplaceCatalog,
qwenCodeOauthPath,
customPrompt,
autoCondenseThreshold: autoCondenseThreshold || 0.75, // default to 0.75 if not set
lastDismissedInfoBannerVersion: lastDismissedInfoBannerVersion ?? 0,
// Multi-root workspace support
workspaceRoots,
primaryRootIndex: primaryRootIndex ?? 0,
// Feature flag - defaults to false
// For now, always return false to disable multi-root support by default
multiRootEnabled: multiRootEnabled ?? false,
multiRootEnabled: !!multiRootEnabled,
}
} catch (error) {
console.error("[StateHelpers] Failed to read global state:", error)
+2 -2
View File
@@ -17,12 +17,12 @@ export function getFocusChainFilePath(taskDir: string, taskId: string): string {
export function createFocusChainMarkdownContent(taskId: string, focusChainList: string): string {
return `# Focus Chain List for Task ${taskId}
<!-- Edit this markdown file to update your focus chain focusChain list -->
<!-- Edit this markdown file to update your focus chain list -->
<!-- Use the format: - [ ] for incomplete items and - [x] for completed items -->
${focusChainList}
<!-- Save this file and the focusChain list will be updated in the task -->`
<!-- Save this file and the focus chain list will be updated in the task -->`
}
/**
+51 -11
View File
@@ -1,5 +1,6 @@
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { Anthropic } from "@anthropic-ai/sdk"
import type { RedactedThinkingBlock, TextBlock, ThinkingBlock } from "@anthropic-ai/sdk/resources"
import { ApiHandler, ApiProviderInfo, buildApiHandler } from "@core/api"
import { ApiStream } from "@core/api/transform/stream"
import { parseAssistantMessageV2 } from "@core/assistant-message"
@@ -56,7 +57,7 @@ import { convertClineMessageToProto } from "@shared/proto-conversions/cline-mess
import { ClineDefaultTool } from "@shared/tools"
import { ClineAskResponse } from "@shared/WebviewMessage"
import { getGitRemoteUrls, getLatestGitCommitHash } from "@utils/git"
import { isNextGenModelFamily } from "@utils/model-utils"
import { isLocalModel, isNextGenModelFamily } from "@utils/model-utils"
import { arePathsEqual, getDesktopDir } from "@utils/path"
import cloneDeep from "clone-deep"
import { execa } from "execa"
@@ -80,7 +81,7 @@ import { FocusChainManager } from "./focus-chain"
import { MessageStateHandler } from "./message-state"
import { TaskState } from "./TaskState"
import { ToolExecutor } from "./ToolExecutor"
import { updateApiReqMsg } from "./utils"
import { detectAvailableCliTools, updateApiReqMsg } from "./utils"
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.ContentBlockParam>
@@ -1218,7 +1219,6 @@ export class Task {
if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
await this.checkpointManager?.saveCheckpoint()
let fileContentString = ""
if (userFeedback.files && userFeedback.files.length > 0) {
@@ -1807,10 +1807,14 @@ export class Task {
}
}
} else {
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as
| number
| undefined
shouldCompact = this.contextManager.shouldCompactContextWindow(
this.messageStateHandler.getClineMessages(),
this.api,
previousApiReqIndex,
autoCondenseThreshold,
)
// There is an edge case where the summarize_task tool call completes but the user cancels the next request before it finishes
@@ -1857,14 +1861,11 @@ export class Task {
"Issue with processing the /newrule command. Double check that, if '.clinerules' already exists, it's a directory and not a file. Otherwise there was an issue referencing this file/directory.",
)
}
// Compact prompt is tailored for models with small context window where environment details would often
// overflow the context window
const useCompactPrompt = customPrompt === "compact"
userContent = parsedUserContent
// add environment details as its own text block, separate from tool results
// do not add environment details to the message which we are compacting the context window
if (!shouldCompact && !useCompactPrompt) {
if (!shouldCompact) {
userContent.push({ type: "text", text: environmentDetails })
}
@@ -1875,10 +1876,11 @@ export class Task {
})
}
} else {
const useCompactPrompt = customPrompt === "compact" && isLocalModel(this.getCurrentProviderInfo())
const [parsedUserContent, environmentDetails, clinerulesError] = await this.loadContext(
userContent,
includeFileDetails,
customPrompt === "compact",
useCompactPrompt,
)
if (clinerulesError === true) {
@@ -1997,6 +1999,8 @@ export class Task {
const stream = this.attemptApiRequest(previousApiReqIndex) // yields only if the first chunk is successful, otherwise will allow the user to retry the request (most likely due to rate limit error, which gets thrown on the first chunk)
let assistantMessage = ""
let reasoningMessage = ""
const reasoningDetails = []
const antThinkingContent: (Anthropic.Messages.RedactedThinkingBlock | Anthropic.Messages.ThinkingBlock)[] = []
this.taskState.isStreaming = true
let didReceiveUsageChunk = false
try {
@@ -2021,6 +2025,24 @@ export class Task {
await this.say("reasoning", reasoningMessage, undefined, undefined, true)
}
break
// for cline/openrouter providers
case "reasoning_details":
reasoningDetails.push(chunk.reasoning_details)
break
// for anthropic providers
case "ant_thinking":
antThinkingContent.push({
type: "thinking",
thinking: chunk.thinking,
signature: chunk.signature,
})
break
case "ant_redacted_thinking":
antThinkingContent.push({
type: "redacted_thinking",
data: chunk.data,
})
break
case "text": {
if (reasoningMessage && assistantMessage.length === 0) {
// complete reasoning message
@@ -2150,7 +2172,19 @@ export class Task {
await this.messageStateHandler.addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: assistantMessage }],
content: [
// This is critical for maintaining the models reasoning flow and conversation integrity.
// "When providing thinking blocks, the entire sequence of consecutive thinking blocks must match the outputs generated by the model during the original request; you cannot rearrange or modify the sequence of these blocks." The signature_delta is used to verify that the thinking was generated by Claude, and the thinking blocks will be ignored if it's incorrect or missing.
// https://docs.claude.com/en/docs/build-with-claude/extended-thinking#preserving-thinking-blocks
...antThinkingContent,
{
type: "text",
text: assistantMessage,
// reasoning_details only exists for cline/openrouter providers
// @ts-ignore-next-line
reasoning_details: reasoningDetails.length > 0 ? reasoningDetails : undefined,
},
] as Array<RedactedThinkingBlock | ThinkingBlock | TextBlock>,
})
// NOTE: this comment is here for future reference - this was a workaround for userMessageContent not getting set to true. It was due to it not recursively calling for partial blocks when didRejectTool, so it would get stuck waiting for a partial block to complete before it could continue.
@@ -2198,8 +2232,8 @@ export class Task {
})
const baseErrorMessage =
"Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output."
const errorText = reqId ? `${baseErrorMessage} (reqId: ${reqId})` : baseErrorMessage
"Invalid API Response: The provider returned an empty or unparsable response. This is a provider-side issue where the model failed to generate valid output or returned tool calls that Cline cannot process. Retrying the request may help resolve this issue."
const errorText = reqId ? `${baseErrorMessage} (Request ID: ${reqId})` : baseErrorMessage
await this.say("error", errorText)
await this.messageStateHandler.addToApiConversationHistory({
@@ -2527,6 +2561,12 @@ export class Task {
if (latestGitHash) {
details += `\n\n# Latest Git Commit Hash\n${latestGitHash}`
}
// Add detected CLI tools
const availableCliTools = await detectAvailableCliTools()
if (availableCliTools.length > 0) {
details += `\n\n# Detected CLI Tools\nThese are some of the tools on the user's machine, and may be useful if needed to accomplish the task: ${availableCliTools.join(", ")}. This list is not exhaustive, and other tools may be available.`
}
}
// Add context window usage information
+45 -9
View File
@@ -1,15 +1,42 @@
import { resolveWorkspacePath } from "@core/workspace"
import { ClineDefaultTool } from "@shared/tools"
import { StateManager } from "@/core/storage/StateManager"
import { getCwd, getDesktopDir, isLocatedInPath } from "@/utils/path"
import { HostProvider } from "@/hosts/host-provider"
import { featureFlagsService } from "@/services/feature-flags"
import { getCwd, getDesktopDir, isLocatedInPath, isLocatedInWorkspace } from "@/utils/path"
export class AutoApprove {
private stateManager: StateManager
// Cache for workspace paths - populated on first access and reused for the task lifetime
// NOTE: This assumes that the task has a fixed set of workspace roots(which is currently true).
private workspacePathsCache: { paths: string[] } | null = null
private isMultiRootScenarioCache: boolean | null = null
constructor(stateManager: StateManager) {
this.stateManager = stateManager
}
/**
* Get workspace information with caching to avoid repeated API calls
* Cache is task-scoped since each task gets a new AutoApprove instance
*/
private async getWorkspaceInfo(): Promise<{
workspacePaths: { paths: string[] }
isMultiRootScenario: boolean
}> {
// Check if we already have cached values
if (this.workspacePathsCache === null || this.isMultiRootScenarioCache === null) {
// First time - fetch and cache for the lifetime of this task
this.workspacePathsCache = await HostProvider.workspace.getWorkspacePaths({})
this.isMultiRootScenarioCache = featureFlagsService.getMultiRootEnabled() && this.workspacePathsCache.paths.length > 1
}
return {
workspacePaths: this.workspacePathsCache,
isMultiRootScenario: this.isMultiRootScenarioCache,
}
}
// Check if the tool should be auto-approved based on the settings
// Returns bool for most tools, and tuple for tools with nested settings
shouldAutoApproveTool(toolName: ClineDefaultTool): boolean | [boolean, boolean] {
@@ -76,14 +103,23 @@ export class AutoApprove {
let isLocalRead: boolean = false
if (autoApproveActionpath) {
const cwd = await getCwd(getDesktopDir())
// When called with a string cwd, resolveWorkspacePath returns a string
const absolutePath = resolveWorkspacePath(
cwd,
autoApproveActionpath,
"AutoApprove.shouldAutoApproveToolWithPath",
) as string
isLocalRead = isLocatedInPath(cwd, absolutePath)
// Use cached workspace info instead of fetching every time
const { isMultiRootScenario } = await this.getWorkspaceInfo()
if (isMultiRootScenario) {
// Multi-root: check if file is in ANY workspace
isLocalRead = await isLocatedInWorkspace(autoApproveActionpath)
} else {
// Single-root: use existing logic
const cwd = await getCwd(getDesktopDir())
// When called with a string cwd, resolveWorkspacePath returns a string
const absolutePath = resolveWorkspacePath(
cwd,
autoApproveActionpath,
"AutoApprove.shouldAutoApproveToolWithPath",
) as string
isLocalRead = isLocatedInPath(cwd, absolutePath)
}
} else {
// If we do not get a path for some reason, default to a (safer) false return
isLocalRead = false
@@ -1,7 +1,10 @@
import type { ToolUse } from "@core/assistant-message"
import { regexSearchFiles } from "@services/ripgrep"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import * as path from "path"
import { formatResponse } from "@/core/prompts/responses"
import { parseWorkspaceInlinePath } from "@/core/workspace/utils/parseWorkspaceInlinePath"
import { WorkspacePathAdapter } from "@/core/workspace/WorkspacePathAdapter"
import { resolveWorkspacePath } from "@/core/workspace/WorkspaceResolver"
import { telemetryService } from "@/services/telemetry"
import { ClineSayTool } from "@/shared/ExtensionMessage"
@@ -25,6 +28,150 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
}]`
}
/**
* Determines which paths to search based on workspace configuration and hints
*/
private determineSearchPaths(
config: TaskConfig,
parsedPath: string,
workspaceHint: string | undefined,
originalPath: string,
): Array<{ absolutePath: string; workspaceName?: string; workspaceRoot?: string }> {
if (config.isMultiRootEnabled && config.workspaceManager) {
const adapter = new WorkspacePathAdapter({
cwd: config.cwd,
isMultiRootEnabled: true,
workspaceManager: config.workspaceManager,
})
if (workspaceHint) {
// Search only in the specified workspace
const absolutePath = adapter.resolvePath(parsedPath, workspaceHint)
const workspaceRoots = adapter.getWorkspaceRoots()
const root = workspaceRoots.find((r) => r.name === workspaceHint)
return [{ absolutePath, workspaceName: workspaceHint, workspaceRoot: root?.path }]
} else {
// As a fallback, perform the search across all available workspaces.
// Typically, models should provide explicit hints to target specific workspaces for searching.
const allPaths = adapter.getAllPossiblePaths(parsedPath)
const workspaceRoots = adapter.getWorkspaceRoots()
return allPaths.map((absPath, index) => ({
absolutePath: absPath,
workspaceName: workspaceRoots[index]?.name || path.basename(workspaceRoots[index]?.path || absPath),
workspaceRoot: workspaceRoots[index]?.path,
}))
}
} else {
// Single-workspace mode (backward compatible)
const pathResult = resolveWorkspacePath(config, originalPath, "SearchFilesTool.execute")
const absolutePath = typeof pathResult === "string" ? pathResult : pathResult.absolutePath
return [{ absolutePath, workspaceRoot: config.cwd }]
}
}
/**
* Executes a single search operation in a workspace
*/
private async executeSearch(
config: TaskConfig,
absolutePath: string,
workspaceName: string | undefined,
workspaceRoot: string | undefined,
regex: string,
filePattern: string | undefined,
) {
try {
// Use workspace root for relative path calculation, fallback to cwd
const basePathForRelative = workspaceRoot || config.cwd
const workspaceResults = await regexSearchFiles(
basePathForRelative,
absolutePath,
regex,
filePattern,
config.services.clineIgnoreController,
)
// Parse the result count from the first line
const firstLine = workspaceResults.split("\n")[0]
const resultMatch = firstLine.match(/Found (\d+) result/)
const resultCount = resultMatch ? parseInt(resultMatch[1], 10) : 0
return {
workspaceName,
workspaceResults,
resultCount,
success: true,
}
} catch (error) {
// If search fails in one workspace, return error info
console.error(`Search failed in ${absolutePath}:`, error)
return {
workspaceName,
workspaceResults: "",
resultCount: 0,
success: false,
}
}
}
/**
* Formats search results based on workspace configuration
*/
private formatSearchResults(
config: TaskConfig,
searchResults: Array<{
workspaceName?: string
workspaceResults: string
resultCount: number
success: boolean
}>,
searchPaths: Array<{ absolutePath: string; workspaceName?: string }>,
): string {
const allResults: string[] = []
let totalResultCount = 0
for (const { workspaceName, workspaceResults, resultCount, success } of searchResults) {
if (!success || !workspaceResults) {
continue
}
totalResultCount += resultCount
// If multi-workspace and we have results, annotate with workspace name
if (config.isMultiRootEnabled && searchPaths.length > 1 && workspaceName) {
// Check if this workspace has results (resultCount > 0)
if (resultCount > 0) {
// Skip the "Found X results" line and add workspace annotation
const lines = workspaceResults.split("\n")
// Skip first two lines (count and empty line) if they exist
const resultsWithoutHeader = lines.length > 2 ? lines.slice(2).join("\n") : workspaceResults
if (resultsWithoutHeader.trim()) {
allResults.push(`## Workspace: ${workspaceName}\n${resultsWithoutHeader}`)
}
}
// Don't add anything for workspaces with 0 results in multi-workspace mode
} else if (!config.isMultiRootEnabled || searchPaths.length === 1) {
// Single workspace mode or single workspace search
allResults.push(workspaceResults)
}
}
// Combine results
if (config.isMultiRootEnabled && searchPaths.length > 1) {
// Multi-workspace search result
if (totalResultCount === 0) {
return "Found 0 results."
} else {
return `Found ${totalResultCount === 1 ? "1 result" : `${totalResultCount.toLocaleString()} results`} across ${searchPaths.length} workspace${searchPaths.length > 1 ? "s" : ""}.\n\n${allResults.join("\n\n")}`
}
} else {
// Single workspace result
return allResults[0] || "Found 0 results."
}
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const relPath = block.params.path
const regex = block.params.regex
@@ -74,25 +221,30 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
config.taskState.consecutiveMistakeCount = 0
// Resolve the absolute path based on multi-workspace configuration
const pathResult = resolveWorkspacePath(config, relDirPath!, "SearchFilesTool.execute")
const absolutePath = typeof pathResult === "string" ? pathResult : pathResult.absolutePath
// Parse workspace hint from the path
const { workspaceHint, relPath: parsedPath } = parseWorkspaceInlinePath(relDirPath!)
// Execute the actual regex search operation
const results = await regexSearchFiles(
config.cwd,
absolutePath,
regex,
filePattern,
config.services.clineIgnoreController,
// Determine which paths to search
const searchPaths = this.determineSearchPaths(config, parsedPath, workspaceHint, relDirPath!)
// Execute searches in all relevant workspaces in parallel
const searchPromises = searchPaths.map(({ absolutePath, workspaceName, workspaceRoot }) =>
this.executeSearch(config, absolutePath, workspaceName, workspaceRoot, regex, filePattern),
)
// Wait for all searches to complete
const searchResults = await Promise.all(searchPromises)
// Format and combine results
const results = this.formatSearchResults(config, searchResults, searchPaths)
const sharedMessageProps = {
tool: "searchFiles",
path: getReadablePath(config.cwd, relDirPath!),
content: results,
regex: regex,
filePattern: filePattern,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relDirPath!),
operationIsLocatedInWorkspace: await isLocatedInWorkspace(parsedPath),
} satisfies ClineSayTool
const completeMessage = JSON.stringify(sharedMessageProps)
+73
View File
@@ -1,4 +1,5 @@
import { ApiHandler } from "@core/api"
import { execSync } from "child_process"
import { showSystemNotification } from "@/integrations/notifications"
import { ClineApiReqCancelReason, ClineApiReqInfo } from "@/shared/ExtensionMessage"
import { calculateApiCostAnthropic } from "@/utils/cost"
@@ -59,3 +60,75 @@ export const updateApiReqMsg = async (params: UpdateApiReqMsgParams) => {
} satisfies ClineApiReqInfo),
})
}
/**
* Common CLI tools that developers frequently use
*/
const CLI_TOOLS = [
"gh",
"git",
"docker",
"podman",
"kubectl",
"aws",
"gcloud",
"az",
"terraform",
"pulumi",
"npm",
"yarn",
"pnpm",
"pip",
"cargo",
"go",
"curl",
"jq",
"make",
"cmake",
"python",
"node",
"psql",
"mysql",
"redis-cli",
"sqlite3",
"mongosh",
"code",
"grep",
"sed",
"awk",
"brew",
"apt",
"yum",
"gradle",
"mvn",
"bundle",
"dotnet",
"helm",
"ansible",
"wget",
]
/**
* Detect which CLI tools are available in the system PATH
* Uses 'which' command on Unix-like systems and 'where' on Windows
*/
export async function detectAvailableCliTools(): Promise<string[]> {
const availableCommands: string[] = []
const isWindows = process.platform === "win32"
const checkCommand = isWindows ? "where" : "which"
for (const command of CLI_TOOLS) {
try {
// Use execSync to check if the command exists
execSync(`${checkCommand} ${command}`, {
stdio: "ignore", // Don't output to console
timeout: 1000, // 1 second timeout to avoid hanging
})
availableCommands.push(command)
} catch (error) {
// Command not found, skip it
}
}
return availableCommands
}
-29
View File
@@ -1,29 +0,0 @@
/**
* TypeScript equivalent of the Go common.RetryOperation utility
* Performs an operation with retry logic and timeout handling
*/
export async function retryOperation<T>(maxRetries: number, timeoutPerAttempt: number, operation: () => Promise<T>): Promise<T> {
let lastError: Error | undefined
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Create a timeout promise
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Operation timeout")), timeoutPerAttempt),
)
// Race the operation against timeout
const result = await Promise.race([operation(), timeoutPromise])
return result // Success - return result
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
if (attempt < maxRetries) {
// Brief delay before retry
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
}
throw new Error(`Operation failed after ${maxRetries} attempts: ${lastError?.message}`)
}
@@ -59,6 +59,10 @@ export class WorkspacePathAdapter {
}
if (root) {
// If no relative path specified, return the workspace root itself
if (!relativePath) {
return root.path
}
return path.join(root.path, relativePath)
}
@@ -68,6 +72,10 @@ export class WorkspacePathAdapter {
// Default to primary workspace
const primaryRoot = manager.getPrimaryRoot()
if (primaryRoot) {
// If no relative path specified, return the workspace root itself
if (!relativePath) {
return primaryRoot.path
}
return path.join(primaryRoot.path, relativePath)
}
+9
View File
@@ -487,6 +487,15 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)
// Register the reconstructTaskHistory command handler
context.subscriptions.push(
vscode.commands.registerCommand(commands.ReconstructTaskHistory, async () => {
const { reconstructTaskHistory } = await import("./core/commands/reconstructTaskHistory")
await reconstructTaskHistory(context)
telemetryService.captureButtonClick("command_reconstructTaskHistory")
}),
)
// Register the generateGitCommitMessage command handler
context.subscriptions.push(
vscode.commands.registerCommand(commands.GenerateCommit, async (scm) => {
+2 -1
View File
@@ -60,7 +60,8 @@ export abstract class BaseGrpcClient<TClient> {
protected getClient(): TClient {
if (!this.client || !this.channel) {
this.channel = createChannel(this.address)
const channelOptions = { "grpc.enable_http_proxy": 0 }
this.channel = createChannel(this.address, undefined, channelOptions)
this.client = this.createClient(this.channel)
}
return this.client
@@ -50,7 +50,6 @@ export class ServiceRegistry {
}
this.methodMetadata[methodName] = { isStreaming, ...metadata }
console.log(`Registered ${this.serviceName} method: ${methodName}${isStreaming ? " (streaming)" : ""}`)
}
/**
@@ -23,7 +23,7 @@
import { MessageStateHandler } from "@core/task/message-state"
import { showChangedFilesDiff } from "@core/task/multifile-diff"
import { VcsType, WorkspaceRootManager } from "@core/workspace"
import { WorkspaceRootManager } from "@core/workspace"
import { telemetryService } from "@services/telemetry"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
@@ -35,7 +35,7 @@ import { ICheckpointManager } from "./types"
* Only created when multiple roots are detected and feature flag is enabled.
*
* This implementation follows Option B: Simple All-Workspace Approach
* - Checkpoints all Git-enabled workspaces every time
* - Creates checkpoints instance for each input workspace root
* - Commits run in parallel in the background (non-blocking)
* - Maintains backward compatibility with single-root expectations
*/
@@ -52,7 +52,7 @@ export class MultiRootCheckpointManager implements ICheckpointManager {
) {}
/**
* Initialize checkpoint trackers for all Git-enabled roots
* Initialize checkpoint trackers for all workspace roots
* This is called separately to avoid blocking the Task constructor
*/
async initialize(): Promise<void> {
@@ -78,13 +78,10 @@ export class MultiRootCheckpointManager implements ICheckpointManager {
const startTime = performance.now()
const roots = this.workspaceManager.getRoots()
const gitRoots = roots.filter((root) => root.vcs === VcsType.Git)
console.log(
`[MultiRootCheckpointManager] Initializing for ${roots.length} workspace roots (${gitRoots.length} Git-enabled)`,
)
console.log(`[MultiRootCheckpointManager] Initializing for ${roots.length} workspace roots`)
// Initialize all Git-enabled roots in parallel
const initPromises = gitRoots.map(async (root) => {
// Initialize all workspace roots in parallel
const initPromises = roots.map(async (root) => {
try {
console.log(`[MultiRootCheckpointManager] Creating tracker for ${root.name} at ${root.path}`)
const tracker = await CheckpointTracker.create(this.taskId, this.enableCheckpoints, root.path)
@@ -112,7 +109,7 @@ export class MultiRootCheckpointManager implements ICheckpointManager {
telemetryService.captureMultiRootCheckpoint(
this.taskId,
"initialized",
gitRoots.length,
roots.length,
successCount,
failureCount,
performance.now() - startTime,
+1
View File
@@ -25,6 +25,7 @@ const ClineCommands = {
Walkthrough: prefix + ".openWalkthrough",
GenerateCommit: prefix + ".generateGitCommitMessage",
AbortCommit: prefix + ".abortGitCommitMessage",
ReconstructTaskHistory: prefix + ".reconstructTaskHistory",
}
/**
+26 -5
View File
@@ -8,6 +8,7 @@ import type {
} from "@shared/ClineAccount"
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
import { clineEnvConfig } from "@/config"
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
import { AuthService } from "../auth/AuthService"
export class ClineAccountService {
@@ -46,10 +47,12 @@ export class ClineAccountService {
* @throws Error if the API key is not found or the request fails
*/
private async authenticatedRequest<T>(endpoint: string, config: AxiosRequestConfig = {}): Promise<T> {
const url = `${this._baseUrl}${endpoint}`
const url = new URL(endpoint, this._baseUrl).toString() // Validate URL
// IMPORTANT: Prefixed with 'workos:' so backend can route verification to WorkOS provider
const clineAccountAuthToken = await this._authService.getAuthToken()
if (!clineAccountAuthToken) {
throw new Error("No Cline account auth token found")
}
const requestConfig: AxiosRequestConfig = {
...config,
headers: {
@@ -145,7 +148,7 @@ export class ClineAccountService {
*/
async fetchMe(): Promise<UserResponse | undefined> {
try {
const data = await this.authenticatedRequest<UserResponse>(`/api/v1/users/me`)
const data = await this.authenticatedRequest<UserResponse>(CLINE_API_ENDPOINT.USER_INFO)
return data
} catch (error) {
console.error("Failed to fetch user data (RPC):", error)
@@ -223,7 +226,7 @@ export class ClineAccountService {
// Call API to switch account
try {
// make XHR request to switch account
const _response = await this.authenticatedRequest<string>(`/api/v1/users/active-account`, {
const _response = await this.authenticatedRequest<string>(CLINE_API_ENDPOINT.ACTIVE_ACCOUNT, {
method: "PUT",
headers: {
"Content-Type": "application/json",
@@ -240,4 +243,22 @@ export class ClineAccountService {
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
}
}
/**
* Transcribes audio using the Cline transcription service
* @param audioBase64 - Base64 encoded audio data
* @param language - Optional language hint for transcription
* @returns Promise with transcribed text or error
*/
async transcribeAudio(audioBase64: string, language = "en"): Promise<{ text: string }> {
const response = await this.authenticatedRequest<{ text: string }>(`/api/v1/chat/transcriptions`, {
method: "POST",
data: {
audioData: audioBase64,
language: language,
},
})
return response
}
}
+102 -93
View File
@@ -7,23 +7,29 @@ import { HostProvider } from "@/hosts/host-provider"
import { telemetryService } from "@/services/telemetry"
import { openExternal } from "@/utils/env"
import { featureFlagsService } from "../feature-flags"
import { ClineAuthProvider } from "./providers/ClineAuthProvider"
import { FirebaseAuthProvider } from "./providers/FirebaseAuthProvider"
const DefaultClineAccountURI = `${clineEnvConfig.appBaseUrl}/auth`
let authProviders: any[] = []
import { IAuthProvider } from "./providers/IAuthProvider"
export type ServiceConfig = {
URI?: string
[key: string]: any
}
const availableAuthProviders = {
firebase: FirebaseAuthProvider,
// Add other providers here as needed
}
export interface ClineAuthInfo {
/**
* accessToken
*/
idToken: string
/**
* Short-lived refresh token
*/
refreshToken?: string
/**
* Access token expiration time
* When expired, the access token needs to be refreshed using the refresh token.
*/
expiresAt?: number
userInfo: ClineAccountUserInfo
}
@@ -37,6 +43,10 @@ export interface ClineAccountUserInfo {
* Cline app base URL, used for webview UI and other client-side operations
*/
appBaseUrl?: string
/**
* WorkOS IDP ID if user logged in via SSO
*/
subject?: string
}
export interface ClineAccountOrganization {
@@ -47,15 +57,13 @@ export interface ClineAccountOrganization {
roles: string[]
}
// TODO: Add logic to handle multiple webviews getting auth updates.
export class AuthService {
protected static instance: AuthService | null = null
protected _config: ServiceConfig
protected _authenticated: boolean = false
protected _clineAuthInfo: ClineAuthInfo | null = null
protected _provider: { provider: FirebaseAuthProvider } | null = null
protected _activeAuthStatusUpdateSubscriptions = new Set<[Controller, StreamingResponseHandler<AuthState>]>()
protected _provider: IAuthProvider | null = null
protected _activeAuthStatusUpdateHandlers = new Set<StreamingResponseHandler<AuthState>>()
protected _handlerToController = new Map<StreamingResponseHandler<AuthState>, Controller>()
protected _controller: Controller
/**
@@ -63,36 +71,8 @@ export class AuthService {
* @param controller - Optional reference to the Controller instance.
*/
protected constructor(controller: Controller) {
const providerName = "firebase"
this._config = { URI: DefaultClineAccountURI }
// Fetch AuthProviders
// TODO: Deliver this config from the backend securely
// ex. https://app.cline.bot/api/v1/auth/providers
const authProvidersConfigs = [
{
name: "firebase",
config: clineEnvConfig.firebase,
},
]
// Merge authProviders with availableAuthProviders
authProviders = authProvidersConfigs.map((provider) => {
const providerName = provider.name
const ProviderClass = availableAuthProviders[providerName as keyof typeof availableAuthProviders]
if (!ProviderClass) {
throw new Error(`Auth provider "${providerName}" is not available`)
}
return {
name: providerName,
config: provider.config,
provider: new ProviderClass(provider.config),
}
})
this._setProvider(authProviders.find((authProvider) => authProvider.name === providerName).name)
// Default to firebase for now
this._setProvider("firebase")
this._controller = controller
}
@@ -126,7 +106,7 @@ export class AuthService {
this._controller = controller
}
get authProvider(): any {
get authProvider(): IAuthProvider | null {
return this._provider
}
@@ -134,29 +114,52 @@ export class AuthService {
this._setProvider(providerName)
}
/**
* Returns the current authentication token with the appropriate prefix.
* Refreshing it if necessary.
*/
async getAuthToken(): Promise<string | null> {
if (!this._clineAuthInfo) {
return null
}
const idToken = this._clineAuthInfo.idToken
const shouldRefreshIdToken = await this._provider?.provider.shouldRefreshIdToken(idToken)
if (shouldRefreshIdToken) {
// Retrieves the stored id token and refreshes it, then updates this._clineAuthInfo
await this.restoreRefreshTokenAndRetrieveAuthInfo()
if (!this._clineAuthInfo) {
try {
const clineAccountAuthToken = this._clineAuthInfo?.idToken
if (!this._clineAuthInfo || !clineAccountAuthToken) {
// Not authenticated
return null
}
// Check if token has expired
if (await this._provider?.shouldRefreshIdToken(clineAccountAuthToken, this._clineAuthInfo.expiresAt)) {
console.log("Provider indicates token needs refresh")
const updatedAuthInfo = await this._provider?.retrieveClineAuthInfo(this._controller)
if (updatedAuthInfo) {
this._clineAuthInfo = updatedAuthInfo
this._authenticated = true
} else {
this._clineAuthInfo = null
this._authenticated = false
}
await this.sendAuthStatusUpdate()
}
// IMPORTANT: Prefix with 'workos:' so backend can route verification to WorkOS provider
const prefix = this._provider?.name === "cline" ? "workos:" : ""
return clineAccountAuthToken ? `${prefix}${clineAccountAuthToken}` : null
} catch (error) {
console.error("Error getting auth token:", error)
return null
}
return this._clineAuthInfo.idToken
}
protected _setProvider(providerName: string): void {
const providerConfig = authProviders.find((provider) => provider.name === providerName)
if (!providerConfig) {
throw new Error(`Auth provider "${providerName}" not found`)
// Only ClineAuthProvider is supported going forward
// Keeping the providerName param for forward compatibility/telemetrye
switch (providerName) {
case "cline":
this._provider = new ClineAuthProvider(clineEnvConfig)
break
case "firebase":
default:
this._provider = new FirebaseAuthProvider(clineEnvConfig)
break
}
this._provider = providerConfig
}
getInfo(): AuthState {
@@ -187,17 +190,14 @@ export class AuthService {
return String.create({ value: "Already authenticated" })
}
if (!this._config.URI) {
throw new Error("Authentication URI is not configured")
if (!this._provider) {
return String.create({ value: "Authentication provider is not configured" })
}
const callbackHost = await HostProvider.get().getCallbackUrl()
const callbackUrl = `${callbackHost}/auth`
// Use URL object for more graceful query construction
const authUrl = new URL(this._config.URI)
authUrl.searchParams.set("callback_url", callbackUrl)
const authUrl = await this._provider.getAuthRequest(callbackUrl)
const authUrlString = authUrl.toString()
await openExternal(authUrlString)
@@ -219,14 +219,14 @@ export class AuthService {
}
}
async handleAuthCallback(token: string, provider: string): Promise<void> {
async handleAuthCallback(authorizationCode: string, provider: string): Promise<void> {
if (!this._provider) {
throw new Error("Auth provider is not set")
}
try {
this._clineAuthInfo = await this._provider.provider.signIn(this._controller, token, provider)
this._authenticated = true
this._clineAuthInfo = await this._provider.signIn(this._controller, authorizationCode, provider)
this._authenticated = this._clineAuthInfo?.idToken !== undefined
await this.sendAuthStatusUpdate()
} catch (error) {
@@ -244,16 +244,16 @@ export class AuthService {
}
/**
* Restores the authentication token from the extension's storage.
* Restores the authentication data from the extension's storage.
* This is typically called when the extension is activated.
*/
async restoreRefreshTokenAndRetrieveAuthInfo(): Promise<void> {
if (!this._provider || !this._provider.provider) {
if (!this._provider) {
throw new Error("Auth provider is not set")
}
try {
this._clineAuthInfo = await this._provider.provider.retrieveClineAuthInfo(this._controller)
this._clineAuthInfo = await this._provider.retrieveClineAuthInfo(this._controller)
if (this._clineAuthInfo) {
this._authenticated = true
await this.sendAuthStatusUpdate()
@@ -286,10 +286,12 @@ export class AuthService {
console.log("Subscribing to authStatusUpdate")
// Add this subscription to the active subscriptions
this._activeAuthStatusUpdateSubscriptions.add([controller, responseStream])
this._activeAuthStatusUpdateHandlers.add(responseStream)
this._handlerToController.set(responseStream, controller)
// Register cleanup when the connection is closed
const cleanup = () => {
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
this._activeAuthStatusUpdateHandlers.delete(responseStream)
this._handlerToController.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
@@ -302,7 +304,8 @@ export class AuthService {
} catch (error) {
console.error("Error sending initial auth status:", error)
// Remove the subscription if there was an error
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
this._activeAuthStatusUpdateHandlers.delete(responseStream)
this._handlerToController.delete(responseStream)
}
}
@@ -310,35 +313,41 @@ export class AuthService {
* Send an authStatusUpdate event to all active subscribers
*/
async sendAuthStatusUpdate(): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(this._activeAuthStatusUpdateSubscriptions).map(async ([controller, responseStream]) => {
try {
const authInfo: AuthState = this.getInfo()
// Compute once per broadcast
const authInfo: AuthState = this.getInfo()
const uniqueControllers = new Set<Controller>()
// Send the event to all active subscribers
const streamSends = Array.from(this._activeAuthStatusUpdateHandlers).map(async (responseStream) => {
const controller = this._handlerToController.get(responseStream)
if (controller) {
uniqueControllers.add(controller)
}
try {
await responseStream(
authInfo,
false, // Not the last message
)
// Identify the user in telemetry if available
// Fetch the feature flags for the user
if (this._clineAuthInfo?.userInfo?.id) {
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
featureFlagsService.reset()
await featureFlagsService.poll()
}
// Update the state in the webview
if (controller) {
await controller.postStateToWebview()
}
} catch (error) {
console.error("Error sending authStatusUpdate event:", error)
// Remove the subscription if there was an error
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
this._activeAuthStatusUpdateHandlers.delete(responseStream)
this._handlerToController.delete(responseStream)
}
})
await Promise.all(promises)
await Promise.all(streamSends)
// Identify the user in telemetry if available
if (this._clineAuthInfo?.userInfo?.id) {
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
// Reset feature flags to ensure they are fetched for the new/logged in user
featureFlagsService.reset()
}
// Poll feature flags to ensure they are up to date for all users
await featureFlagsService.poll()
// Update state in webviews once per unique controller
await Promise.all(Array.from(uniqueControllers).map((c) => c.postStateToWebview()))
}
}
+33 -25
View File
@@ -2,9 +2,10 @@ import { String } from "@shared/proto/cline/common"
import { clineEnvConfig } from "@/config"
import { Controller } from "@/core/controller"
import { WebviewProvider } from "@/core/webview"
import type { UserResponse } from "@/shared/ClineAccount"
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
import { AuthService } from "./AuthService"
// TODO: Consider adding a mock auth provider implementing IAuthProvider for more realistic testing
export class AuthServiceMock extends AuthService {
protected constructor(controller: Controller) {
super(controller)
@@ -13,8 +14,9 @@ export class AuthServiceMock extends AuthService {
throw new Error("AuthServiceMock should only be used in local environment for testing purposes.")
}
this._config = { URI: clineEnvConfig.apiBaseUrl }
this._setProvider("firebase")
// Support both auth providers, default to firebase for compatibility
const authProvider = process.env.E2E_TEST_AUTH_PROVIDER || "firebase"
this._setProvider(authProvider)
this._controller = controller
}
@@ -53,16 +55,20 @@ export class AuthServiceMock extends AuthService {
}
try {
// Fetch user data from mock server
const meUri = new URL("/api/v1/users/me", clineEnvConfig.apiBaseUrl)
// Use token exchange endpoint like ClineAuthProvider
const tokenExchangeUri = new URL(CLINE_API_ENDPOINT.TOKEN_EXCHANGE, clineEnvConfig.apiBaseUrl)
const tokenType = "personal"
const testToken = `test-${tokenType}-token`
const response = await fetch(meUri, {
method: "GET",
const testCode = `test-${tokenType}-token`
const response = await fetch(tokenExchangeUri, {
method: "POST",
headers: {
Authorization: `Bearer ${testToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
code: testCode,
grantType: "authorization_code",
}),
})
if (!response.ok) {
@@ -75,30 +81,32 @@ export class AuthServiceMock extends AuthService {
throw new Error("Invalid response from mock server")
}
const userData = responseData.data as UserResponse
const authData = responseData.data
// Convert UserResponse to ClineAuthInfo format
// Convert to ClineAuthInfo format matching ClineAuthProvider
this._clineAuthInfo = {
idToken: testToken,
idToken: authData.accessToken,
refreshToken: authData.refreshToken,
expiresAt: new Date(authData.expiresAt).getTime() / 1000,
userInfo: {
id: userData.id,
email: userData.email,
displayName: userData.displayName,
createdAt: userData.createdAt,
organizations: userData.organizations.map((org) => ({
active: org.active,
memberId: org.memberId,
name: org.name,
organizationId: org.organizationId,
roles: org.roles,
})),
id: authData.userInfo.clineUserId || authData.userInfo.subject,
email: authData.userInfo.email,
displayName: authData.userInfo.name,
createdAt: new Date().toISOString(),
organizations: authData.organizations,
appBaseUrl: clineEnvConfig.appBaseUrl,
subject: authData.userInfo.subject,
},
}
console.log(`Successfully authenticated with mock server as ${userData.displayName} (${userData.email})`)
console.log(`Successfully authenticated with mock server as ${authData.userInfo.name} (${authData.userInfo.email})`)
const visibleWebview = WebviewProvider.getVisibleInstance()
await visibleWebview?.controller.handleAuthCallback(testToken, "mock")
// Use appropriate provider name for callback
const providerName = this._provider?.name || "mock"
// Simulate handling the auth callback as if from a real provider
await visibleWebview?.controller.handleAuthCallback(authData.accessToken, providerName)
} catch (error) {
console.error("Error signing in with mock server:", error)
this._authenticated = false
@@ -2,7 +2,7 @@ import { OcaAuthState, OcaUserInfo } from "@shared/proto/cline/oca_account"
import axios from "axios"
import { jwtDecode } from "jwt-decode"
import { Controller } from "@/core/controller"
import { getProxyAgents } from "@/services/auth/oca/utils/utils"
import { getAxiosSettings } from "@/services/auth/oca/utils/utils"
import { generateCodeVerifier, generateRandomString, pkceChallengeFromVerifier } from "../utils/utils"
@@ -93,7 +93,7 @@ export class OcaAuthProvider {
}
try {
const { idcs_url, client_id } = this._config
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getProxyAgents() })
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getAxiosSettings() })
const tokenEndpoint = discovery.data.token_endpoint
const params: any = {
grant_type: "refresh_token",
@@ -102,7 +102,7 @@ export class OcaAuthProvider {
}
const tokenResponse = await axios.post(tokenEndpoint, new URLSearchParams(params), {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
...getProxyAgents(),
...getAxiosSettings(),
})
const accessToken = tokenResponse.data.access_token
const userInfo: OcaUserInfo = await this.getUserAccountInfo(accessToken)
@@ -159,7 +159,7 @@ export class OcaAuthProvider {
}
const { code_verifier, nonce, redirect_uri } = entry
OcaAuthProvider.pkceStateMap.delete(state)
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getProxyAgents() })
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getAxiosSettings() })
const tokenEndpoint = discovery.data.token_endpoint
const params: any = {
grant_type: "authorization_code",
@@ -170,7 +170,7 @@ export class OcaAuthProvider {
}
const tokenResponse = await axios.post(tokenEndpoint, new URLSearchParams(params), {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
...getProxyAgents(),
...getAxiosSettings(),
})
// Step 1: Nonce validation
const idToken = tokenResponse.data.id_token
@@ -179,6 +179,8 @@ export class OcaAuthProvider {
if (decoded.nonce !== nonce) {
throw new Error("OIDC nonce verification failed")
}
} else {
throw new Error("No ID token received from OCA")
}
// Step 2: Get access_token (this is what you'll use for APIs)
+12 -20
View File
@@ -1,5 +1,8 @@
import crypto from "crypto"
import fs from "fs"
import { type JwtPayload, jwtDecode } from "jwt-decode"
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import {
DEFAULT_IDCS_CLIENT_ID,
DEFAULT_IDCS_PORT_CANDIDATES,
@@ -70,11 +73,6 @@ export function pkceChallengeFromVerifier(verifier: string): string {
.replace(/=+$/, "")
}
import { HttpsProxyAgent } from "https-proxy-agent"
import { type JwtPayload, jwtDecode } from "jwt-decode"
import * as vscode from "vscode"
import { name, version } from "../../../../../package.json"
/**
* Generates a compliant customer opc-request-id segment.
*
@@ -117,32 +115,26 @@ export async function generateOpcRequestId(taskId: string, token: string): Promi
export async function createOcaHeaders(accessToken: string, taskId: string): Promise<Record<string, string>> {
const opcRequestId = await generateOpcRequestId(taskId, accessToken)
const host = await HostProvider.env.getHostVersion({})
const clineVersion = ExtensionRegistryInfo.version
return {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
client: "Cline",
"client-version": `${name}-${version}`,
"client-ide": vscode.env.appName,
"client-ide-version": vscode.version,
"client-version": `${clineVersion}`,
"client-ide": host.platform || "unknown",
"client-ide-version": host.version || "unknown",
"opc-request-id": opcRequestId,
}
}
/**
* Proxy helpers for HTTPS/HTTP proxies via environment variables.
* - Prioritizes HTTPS_PROXY over HTTP_PROXY
* - Returns axios-compatible agent options when a proxy is configured
*
* @returns Axios settings including fetch adapter for compatibility
*/
export function getProxyUrl(): string | undefined {
return process.env.HTTPS_PROXY || process.env.HTTP_PROXY
}
export function getProxyAgents(): { httpAgent?: any; httpsAgent?: any } {
const proxyUrl = getProxyUrl()
if (!proxyUrl) return {}
const agent = new HttpsProxyAgent(proxyUrl)
return { httpAgent: agent as any, httpsAgent: agent as any }
export function getAxiosSettings(): { adapter?: any } {
return { adapter: "fetch" as any }
}
/**
@@ -0,0 +1,313 @@
import { clineEnvConfig, EnvironmentConfig } from "@/config"
import { Controller } from "@/core/controller"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/services/logging/Logger"
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
import type { ClineAuthInfo } from "../AuthService"
import { IAuthProvider } from "./IAuthProvider"
interface ClineAuthApiUser {
subject: string | null
email: string
name: string
clineUserId: string | null
accounts: string[] | null
}
// Unified API response data shape for token exchange/refresh
interface ClineAuthResponseData {
/**
* Auth token to be used for authenticated requests
*/
accessToken: string
/**
* Refresh token to be used for refreshing the access token
*/
refreshToken?: string
/**
* Token type
* E.g. "Bearer"
*/
tokenType: string
/**
* Access token expiration time in ISO 8601 format
* E.g. "2025-09-17T04:32:24.842636548Z"
*/
expiresAt: string
/**
* User information associated with the token
*/
userInfo: ClineAuthApiUser
}
export interface ClineAuthApiTokenExchangeResponse {
success: boolean
data: ClineAuthResponseData
}
export interface ClineAuthApiTokenRefreshResponse {
success: boolean
data: ClineAuthResponseData
}
export class ClineAuthProvider implements IAuthProvider {
readonly name = "cline"
private _config
constructor(config: EnvironmentConfig) {
this._config = config
}
get config(): any {
return this._config
}
set config(value: any) {
this._config = value
}
/**
* Checks if the access token needs to be refreshed (expired or about to expire).
* Since the new flow doesn't support refresh tokens, this will return true if token is expired.
* @param _refreshToken - The existing refresh token to check.
* @returns {Promise<boolean>} True if the token is expired or about to expire.
*/
async shouldRefreshIdToken(_refreshToken: string, expiresAt?: number): Promise<boolean> {
try {
// expiresAt is in seconds
const expirationTime = expiresAt || 0
const currentTime = Date.now() / 1000
const next5Min = currentTime + 5 * 60
// Check if token is expired or will expire in the next 5 minutes
return expirationTime < next5Min // Access token is expired or about to expire
} catch (error) {
Logger.error("Error checking token expiration:", error)
return true // If we can't decode the token, assume it needs refresh
}
}
/**
* Retrieves Cline auth info using the stored access token.
* @param controller - The controller instance to access stored secrets.
* @returns {Promise<ClineAuthInfo | null>} A promise that resolves with the auth info or null.
*/
async retrieveClineAuthInfo(controller: Controller): Promise<ClineAuthInfo | null> {
try {
// Get the stored auth data from secure storage
const storedAuthDataString = controller.stateManager.getSecretKey("clineAccountId")
if (!storedAuthDataString) {
Logger.debug("No stored authentication data found")
return null
}
// Parse the stored auth data
let storedAuthData: ClineAuthInfo
try {
storedAuthData = JSON.parse(storedAuthDataString)
} catch (e) {
console.error("Failed to parse stored auth data:", e)
controller.stateManager.setSecret("clineAccountId", undefined)
return null
}
if (!storedAuthData.refreshToken || !storedAuthData?.idToken) {
console.error("No valid token found in stored authentication data")
controller.stateManager.setSecret("clineAccountId", undefined)
return null
}
if (await this.shouldRefreshIdToken(storedAuthData.refreshToken, storedAuthData.expiresAt)) {
// Try to refresh the token using the refresh token
const authInfo = await this.refreshToken(storedAuthData.refreshToken)
return authInfo || null
}
// Is the token valid?
if (storedAuthData.idToken && storedAuthData.refreshToken && storedAuthData.userInfo.id) {
return storedAuthData
}
// Verify the token structure
const tokenParts = storedAuthData.idToken.split(".")
if (tokenParts.length !== 3) {
throw new Error("Invalid token format")
}
// Decode the token to verify it's a valid JWT
const payload = JSON.parse(Buffer.from(tokenParts[1], "base64").toString("utf-8"))
if (payload.external_id) {
storedAuthData.userInfo.id = payload.external_id
}
console.log("Successfully retrieved and validated stored auth token")
return storedAuthData
} catch (error) {
console.error("Error retrieving stored authentication credential:", error)
return null
}
}
/**
* Refreshes an access token using a refresh token.
* @param refreshToken - The refresh token.
* @returns {Promise<ClineAuthInfo>} The new access token and user info.
*/
async refreshToken(refreshToken: string): Promise<ClineAuthInfo> {
try {
// Get the callback URL that was used during the initial auth request
const endpoint = new URL(CLINE_API_ENDPOINT.REFRESH_TOKEN, this._config.apiBaseUrl)
const response = await fetch(endpoint.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
refreshToken, // short_lived_auth_code
grantType: "refresh_token", // must be "authorization_code"
}),
})
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json().catch(() => ({}))
const errorMessage = errorData?.error || "Invalid or expired authorization code"
throw new Error(errorMessage)
}
throw new Error(`HTTP error! status: ${response.status}`)
}
const data: ClineAuthApiTokenExchangeResponse = await response.json()
if (!data.success || !data.data.refreshToken || !data.data.accessToken) {
throw new Error("Failed to exchange authorization code for access token")
}
return {
idToken: data.data.accessToken,
// data.data.expiresAt example: "2025-09-17T03:43:57Z"; store in seconds
expiresAt: new Date(data.data.expiresAt).getTime() / 1000,
refreshToken: data.data.refreshToken || refreshToken,
userInfo: {
createdAt: new Date().toISOString(),
email: data.data.userInfo.email || "",
id: data.data.userInfo.clineUserId || "",
displayName: data.data.userInfo.name || "",
organizations: [],
appBaseUrl: this._config.appBaseUrl,
subject: data.data.userInfo.subject || "",
},
}
} catch (error: any) {
throw error
}
}
async getAuthRequest(callbackUrl: string): Promise<string> {
const authUrl = new URL(CLINE_API_ENDPOINT.AUTH, clineEnvConfig.apiBaseUrl)
authUrl.searchParams.set("client_type", "extension")
authUrl.searchParams.set("callback_url", callbackUrl)
// Ensure the redirect_uri is properly encoded and included
authUrl.searchParams.set("redirect_uri", callbackUrl)
// The server will respond with a 302 redirect to the OAuth provider
// We need to follow the redirect and get the final URL
let response: Response
try {
// Set redirect: 'manual' to handle the redirect manually
response = await fetch(authUrl.toString(), {
method: "GET",
redirect: "manual",
credentials: "include", // Important for cookies if needed
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
})
// If we get a redirect status (3xx), get the Location header
if (response.status >= 300 && response.status < 400) {
const redirectUrl = response.headers.get("Location")
if (!redirectUrl) {
throw new Error("No redirect URL found in the response")
}
return redirectUrl
}
// If we didn't get a redirect, try to parse the response as JSON
const responseData = await response.json()
if (responseData.redirect_url) {
return responseData.redirect_url
}
throw new Error("Unexpected response from auth server")
} catch (error) {
console.error("Error during authentication request:", error)
throw new Error(`Authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`)
}
}
async signIn(controller: Controller, authorizationCode: string, provider: string): Promise<ClineAuthInfo | null> {
try {
// Get the callback URL that was used during the initial auth request
const callbackHost = await HostProvider.get().getCallbackUrl()
const callbackUrl = `${callbackHost}/auth`
// Exchange the authorization code for tokens
const tokenUrl = new URL(CLINE_API_ENDPOINT.TOKEN_EXCHANGE, clineEnvConfig.apiBaseUrl)
const response = await fetch(tokenUrl.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
code: authorizationCode,
client_type: "extension",
redirect_uri: callbackUrl,
provider: provider,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error_description || "Failed to exchange authorization code for tokens")
}
const responseJSON = await response.json()
console.log("Token data received:", responseJSON)
const responseType: ClineAuthApiTokenExchangeResponse = responseJSON
const tokenData = responseType.data
if (!tokenData.accessToken || !tokenData.refreshToken || !tokenData.userInfo) {
throw new Error("Invalid token response from server")
}
// Store the tokens and user info
const clineAuthInfo = {
idToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken,
userInfo: {
id: tokenData.userInfo.clineUserId || "",
email: tokenData.userInfo.email || "",
displayName: tokenData.userInfo.name || "",
createdAt: new Date().toISOString(),
organizations: [],
},
expiresAt: new Date(tokenData.expiresAt).getTime() / 1000, // "2025-09-17T04:32:24.842636548Z"
}
controller.stateManager.setSecret("clineAccountId", JSON.stringify(clineAuthInfo))
return clineAuthInfo
} catch (error) {
console.error("Error handling auth callback:", error)
throw error
}
}
}
@@ -2,15 +2,19 @@ import axios from "axios"
import { initializeApp } from "firebase/app"
import { GithubAuthProvider, GoogleAuthProvider, getAuth, type OAuthCredential, signInWithCredential, User } from "firebase/auth"
import { jwtDecode } from "jwt-decode"
import { clineEnvConfig } from "@/config"
import { clineEnvConfig, EnvironmentConfig } from "@/config"
import { Controller } from "@/core/controller"
import { ErrorService } from "@/services/error"
import type { ClineAccountUserInfo, ClineAuthInfo } from "../AuthService"
import { IAuthProvider } from "./IAuthProvider"
export class FirebaseAuthProvider {
private _config: any
export class FirebaseAuthProvider implements IAuthProvider {
readonly name = "firebase"
readonly callbackEndpoint = "/auth"
constructor(config: any) {
private _config: EnvironmentConfig
constructor(config: EnvironmentConfig) {
this._config = config || {}
}
@@ -22,7 +26,7 @@ export class FirebaseAuthProvider {
this._config = value
}
async shouldRefreshIdToken(existingIdToken: string): Promise<boolean> {
async shouldRefreshIdToken(existingIdToken: string, _expiresAt?: number): Promise<boolean> {
const decodedToken = jwtDecode(existingIdToken)
const exp = decodedToken.exp || 0 // 1752297633
const expirationTime = exp * 1000
@@ -48,23 +52,11 @@ export class FirebaseAuthProvider {
}
try {
// Exchange refresh token for new access token using Firebase's secure token endpoint
// https://stackoverflow.com/questions/38233687/how-to-use-the-firebase-refreshtoken-to-reauthenticate/57119131#57119131
const firebaseApiKey = this._config.apiKey
const googleAccessTokenResponse = await axios.post(
`https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`,
`grant_type=refresh_token&refresh_token=${encodeURIComponent(userRefreshToken)}`,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
},
)
const { idToken } = await this.refreshToken(userRefreshToken)
// console.log("googleAccessTokenResponse", googleAccessTokenResponse)
// This returns an object with access_token, expires_in (3600), id_token (can be used as bearer token to authenticate requests, we'll use this in the future instead of firebase but need to be aware of how we use firebase sdk for e.g. user info like the profile image), project_id, refresh_token, token_type (always Bearer), and user_id
const idToken = googleAccessTokenResponse.data.id_token
// const idTokenExpirationDate = new Date(Date.now() + googleAccessTokenResponse.data.expires_in * 1000)
if (!idToken) {
throw new Error("No ID token received from refresh token exchange")
}
// Now retrieve the user info from the backend (this was an easy solution to keep providing user profile details like name and email, but we should move to using the fetchMe() function instead)
// Fetch user info from Cline API
@@ -79,26 +71,39 @@ export class FirebaseAuthProvider {
const userInfo: ClineAccountUserInfo = userResponse.data.data
return { idToken, userInfo }
// let userObject = JSON.parse(credentialJSON)
// let user = User.
// userObject = User.constructor._fromJSON(auth, user2);
// const credentialData: AuthCredential = OAuthCredential.fromJSON(credentialJSON) as AuthCredential
// const userCredential = await this._signInWithCredential(context, credentialData)
// return userCredential.user
} catch (error) {
console.error("Firebase restore token error", error)
ErrorService.get().logMessage("Firebase restore token error", "error")
ErrorService.get().logException(error)
throw error
}
}
/**
* Signs in the user using Firebase authentication with a custom token.
* @returns {Promise<User>} A promise that resolves with the authenticated user.
* @throws {Error} Throws an error if the sign-in fails.
*/
async refreshToken(userRefreshToken: string): Promise<Partial<ClineAuthInfo>> {
// Exchange refresh token for new access token using Firebase's secure token endpoint
// https://stackoverflow.com/questions/38233687/how-to-use-the-firebase-refreshtoken-to-reauthenticate/57119131#57119131
const firebaseApiKey = this._config.firebase.apiKey
const googleAccessTokenResponse = await axios.post(
`https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`,
`grant_type=refresh_token&refresh_token=${encodeURIComponent(userRefreshToken)}`,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
},
)
// This returns an object with access_token, expires_in (3600), id_token (can be used as bearer token to authenticate requests, we'll use this in the future instead of firebase but need to be aware of how we use firebase sdk for e.g. user info like the profile image), project_id, refresh_token, token_type (always Bearer), and user_id
// Store user data
return { idToken: googleAccessTokenResponse.data.id_token }
}
getAuthRequest(callbackUrl: string): Promise<string> {
// Use URL object for more graceful query construction
const authUrl = new URL(`${clineEnvConfig.appBaseUrl}/auth`)
authUrl.searchParams.set("callback_url", callbackUrl)
return Promise.resolve(authUrl.toString())
}
async signIn(controller: Controller, token: string, provider: string): Promise<ClineAuthInfo | null> {
try {
let credential: OAuthCredential
@@ -113,7 +118,7 @@ export class FirebaseAuthProvider {
throw new Error(`Unsupported provider: ${provider}`)
}
// we've received the short-lived tokens from google/github, now we need to sign in to firebase with them
const firebaseConfig = Object.assign({}, this._config)
const firebaseConfig = Object.assign({}, this._config.firebase)
const app = initializeApp(firebaseConfig)
const auth = getAuth(app)
// this signs the user into firebase sdk internally
@@ -0,0 +1,13 @@
import { EnvironmentConfig } from "@/config"
import { Controller } from "@/core/controller"
import { ClineAuthInfo } from "../AuthService"
export interface IAuthProvider {
readonly name: string
config: EnvironmentConfig
shouldRefreshIdToken(token: string, expiresAt?: number): Promise<boolean>
retrieveClineAuthInfo(controller: Controller): Promise<ClineAuthInfo | null>
refreshToken(refreshToken: string): Promise<Partial<ClineAuthInfo>>
getAuthRequest(callbackUrl: string): Promise<string>
signIn(controller: Controller, authorizationCode: string, provider: string): Promise<ClineAuthInfo | null>
}
@@ -0,0 +1,283 @@
import { ChildProcess, spawn } from "node:child_process"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { Logger } from "@services/logging/Logger"
import { AUDIO_PROGRAM_CONFIG } from "@/shared/audioProgramConstants"
function isExecutable(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK)
return true
} catch {
return false
}
}
export class AudioRecordingService {
private recordingProcess: ChildProcess | null = null
private startTime: number = 0
private outputFile: string = ""
constructor() {}
/**
* Determines if recording is currently active by checking process state
*/
private get isRecording(): boolean {
return this.recordingProcess !== null && !this.recordingProcess.killed && this.recordingProcess.exitCode === null
}
/**
* Resets the recording state variables
*/
private resetRecordingState(): void {
this.recordingProcess = null
this.startTime = 0
}
/**
* Cleans up the temporary audio file
*/
private async cleanupTempFile(): Promise<void> {
if (this.outputFile && fs.existsSync(this.outputFile)) {
try {
fs.unlinkSync(this.outputFile)
Logger.info("Temporary audio file cleaned up")
} catch (error) {
Logger.warn("Failed to cleanup temporary audio file: " + (error instanceof Error ? error.message : String(error)))
} finally {
this.outputFile = ""
}
}
}
/**
* Terminates the recording process gracefully
*/
private async terminateProcess(): Promise<void> {
if (!this.recordingProcess) {
return
}
Logger.info("Terminating recording process...")
this.recordingProcess.kill("SIGINT")
// Wait for the process to finish with timeout
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(() => {
Logger.warn("Process termination timed out after 5 seconds")
resolve()
}, 5000)
this.recordingProcess?.on("exit", (code) => {
clearTimeout(timeoutId)
Logger.info(`Recording process exited with code: ${code}`)
resolve()
})
})
}
/**
* Performs comprehensive cleanup of recording resources
* @param options - Cleanup options
* @param options.keepFile - If true, preserves the temporary file
*/
private async performCleanup(options?: { keepFile?: boolean }): Promise<void> {
await this.terminateProcess()
this.resetRecordingState()
if (!options?.keepFile) {
await this.cleanupTempFile()
}
}
async startRecording(): Promise<{ success: boolean; error?: string }> {
try {
// Defensive cleanup before starting - ensures clean state
if (this.recordingProcess || this.outputFile) {
Logger.info("Performing pre-recording cleanup of stale resources...")
await this.performCleanup()
}
if (this.isRecording) {
return { success: false, error: "Already recording" }
}
// Check if recording software is available
const checkResult = this.checkRecordingDependencies()
if (!checkResult.available) {
return { success: false, error: checkResult.error }
}
// Create temporary file for audio output
const tempDir = os.tmpdir()
this.outputFile = path.join(tempDir, `cline_recording_${Date.now()}.webm`)
Logger.info("Starting audio recording...")
// Get the recording program path
const recordProgram = this.getRecordProgram()
if (!recordProgram) {
return { success: false, error: "Recording program not found" }
}
Logger.info(`Using recording program: ${recordProgram.path}`)
// Set up recording arguments
const args = recordProgram.getArgs(this.outputFile)
// Spawn the recording process
this.recordingProcess = spawn(recordProgram.path, args)
this.startTime = Date.now()
// Handle process errors
this.recordingProcess.on("error", (error) => {
Logger.error(`Recording process error: ${error.message}`)
this.resetRecordingState()
})
// Handle process exit
this.recordingProcess.on("exit", (code) => {
if (code !== 0 && code !== null) {
Logger.warn(`Recording process exited with code: ${code}`)
}
})
this.recordingProcess.stderr?.on("data", (data) => {
const message = data.toString().trim()
if (message && !message.includes("In:") && !message.includes("Out:")) {
Logger.info(`Recording stderr: ${message}`)
}
})
Logger.info("Audio recording started successfully")
return { success: true }
} catch (error) {
await this.performCleanup()
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error("Failed to start audio recording: " + errorMessage)
return { success: false, error: `Failed to start recording: ${errorMessage}` }
}
}
async stopRecording(): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
try {
if (!this.isRecording) {
return { success: false, error: "Not currently recording" }
}
Logger.info("Stopping audio recording...")
// Terminate the process but keep the file for reading
await this.terminateProcess()
this.resetRecordingState()
// Wait a moment for file to be fully written
await new Promise((resolve) => setTimeout(resolve, 500))
// Read the audio file and convert to base64
if (!fs.existsSync(this.outputFile)) {
return { success: false, error: "Recording file not found" }
}
const audioBuffer = fs.readFileSync(this.outputFile)
const audioBase64 = audioBuffer.toString("base64")
// Clean up temporary file after reading
await this.cleanupTempFile()
Logger.info("Audio recording stopped and converted to base64")
return { success: true, audioBase64 }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error("Failed to stop audio recording: " + errorMessage)
// Ensure cleanup happens even on error
await this.performCleanup()
return { success: false, error: `Failed to stop recording: ${errorMessage}` }
}
}
async cancelRecording(): Promise<{ success: boolean; error?: string }> {
try {
if (!this.isRecording) {
return { success: false, error: "Not currently recording" }
}
Logger.info("Canceling audio recording...")
// Perform full cleanup including file deletion
await this.performCleanup()
Logger.info("Audio recording canceled successfully")
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error("Failed to cancel audio recording: " + errorMessage)
// Ensure cleanup happens even on error
await this.performCleanup()
return { success: false, error: `Failed to cancel recording: ${errorMessage}` }
}
}
getRecordingStatus(): { isRecording: boolean; durationSeconds: number; error?: string } {
const durationSeconds = this.isRecording ? (Date.now() - this.startTime) / 1000 : 0
return {
isRecording: this.isRecording,
durationSeconds,
}
}
private checkRecordingDependencies(): { available: boolean; error?: string } {
const program = this.getRecordProgram()
if (!program) {
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
const config = AUDIO_PROGRAM_CONFIG[platform]
const error = config ? config.error : `Audio recording is not supported on platform: ${platform}`
return { available: false, error }
}
return { available: true }
}
private getRecordProgram(): { path: string; getArgs: (outputFile: string) => string[] } | undefined {
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
const config = AUDIO_PROGRAM_CONFIG[platform]
if (!config) {
return undefined
}
// 1. Check if the command is in the system's PATH
const pathDirs = (process.env.PATH || "").split(path.delimiter)
for (const dir of pathDirs) {
const fullPath = path.join(dir, config.command)
if (fs.existsSync(fullPath) && isExecutable(fullPath)) {
return { path: fullPath, getArgs: config.getArgs }
}
}
// 2. Check fallback paths if not in PATH
for (const p of config.fallbackPaths) {
if (fs.existsSync(p) && isExecutable(p)) {
return { path: p, getArgs: config.getArgs }
}
}
return undefined
}
/**
* Public cleanup method for service shutdown
*/
cleanup(): void {
// Use async cleanup but don't await since this is often called in sync contexts
this.performCleanup().catch((error) => {
Logger.error("Error during cleanup: " + (error instanceof Error ? error.message : String(error)))
})
}
}
export const audioRecordingService = new AudioRecordingService()
@@ -0,0 +1,141 @@
import { Logger } from "@services/logging/Logger"
import axios from "axios"
import { ClineAccountService } from "@/services/account/ClineAccountService"
// Network error matchers using Map for O(1) lookup
const NETWORK_ERROR_MAP = new Map<string, string>([
["enotfound", "No internet connection. Please check your network and try again."],
["econnrefused", "Cannot connect to transcription service. Please check your internet connection."],
["etimedout", "Connection timed out. Please check your internet connection and try again."],
["econnreset", "Connection timed out. Please check your internet connection and try again."],
["network error", "Network error. Please check your internet connection."],
])
// HTTP status code error messages using Map for O(1) lookup
const STATUS_ERROR_MAP = new Map<number, string>([
[401, "Authentication failed. Please reauthenticate your Cline account"],
[402, "Insufficient credits for transcription service."],
[500, "Transcription server error. Please try again later."],
])
// Special 400 error patterns that need custom handling
const BAD_REQUEST_ERROR_PATTERNS = [
{
patterns: ["insufficient balance", "insufficient credits"],
message: "Insufficient credits for transcription service.",
},
{
patterns: ["invalid audio", "invalid format"],
message: "Invalid audio format. Please try recording again.",
},
]
export class VoiceTranscriptionService {
private readonly clineAccountService: ClineAccountService
constructor() {
this.clineAccountService = ClineAccountService.getInstance()
}
/**
* Parses transcription errors and returns user-friendly error messages
* @param error The error object from the transcription attempt
* @returns An object with the error message
*/
private parseTranscriptionError(error: unknown): { error: string } {
// Handle axios errors with proper status code mapping
if (axios.isAxiosError(error)) {
const status = error.response?.status
// Extract error message from server response - check both 'error' and 'message' fields
const rawMessage = error.response?.data?.error || error.response?.data?.message || error.message
const lowerMessage = rawMessage.toLowerCase()
// Check for network errors using the Map (these don't have status codes)
for (const [keyword, response] of NETWORK_ERROR_MAP) {
if (lowerMessage.includes(keyword)) {
return { error: response }
}
}
// Check if we have a simple status code mapping
if (status && STATUS_ERROR_MAP.has(status)) {
return { error: STATUS_ERROR_MAP.get(status)! }
}
// Handle special 400 errors with pattern matching
if (status === 400) {
// Check for specific error patterns
for (const { patterns, message } of BAD_REQUEST_ERROR_PATTERNS) {
if (patterns.some((pattern) => lowerMessage.includes(pattern))) {
return { error: message }
}
}
// Check for limit exceeded messages (preserve original message)
if (lowerMessage.includes("exceeds") && lowerMessage.includes("limit")) {
return { error: rawMessage }
}
// For other 400 errors, show the server's message if available, otherwise use generic
return { error: rawMessage || "Invalid audio format or request data." }
}
// Default case for unhandled status codes
return {
error: "Transcription failed. Please try again later or raise an issue on https://github.com/cline/cline/issues",
}
}
// Handle non-axios errors (general network errors)
const errorMessage = error instanceof Error ? error.message : String(error)
const lowerErrorMessage = errorMessage.toLowerCase()
// Check network errors using the Map
for (const [keyword, response] of NETWORK_ERROR_MAP) {
if (lowerErrorMessage.includes(keyword)) {
return { error: response }
}
}
return { error: `Network error: ${errorMessage}` }
}
async transcribeAudio(audioBase64: string, language?: string): Promise<{ text?: string; error?: string }> {
try {
Logger.info("Transcribing audio with Cline transcription service...")
// Check if using organization account for telemetry
const userInfo = await this.clineAccountService.fetchMe()
const activeOrg = userInfo?.organizations?.find((org) => org.active)
const isOrgAccount = !!activeOrg
const result = await this.clineAccountService.transcribeAudio(audioBase64, language)
Logger.info("Transcription successful")
// Capture telemetry with account type - use dynamic import to avoid circular dependency
const { telemetryService } = await import("@/services/telemetry")
telemetryService.captureVoiceTranscriptionCompleted(
undefined, // taskId
result.text?.length,
undefined, // duration
language,
isOrgAccount,
)
return { text: result.text }
} catch (error) {
Logger.error("Voice transcription error:", error)
return this.parseTranscriptionError(error)
}
}
}
// Lazily construct the service to avoid circular import initialization issues
let _voiceTranscriptionServiceInstance: VoiceTranscriptionService | null = null
export function getVoiceTranscriptionService(): VoiceTranscriptionService {
if (!_voiceTranscriptionServiceInstance) {
_voiceTranscriptionServiceInstance = new VoiceTranscriptionService()
}
return _voiceTranscriptionServiceInstance
}
+134 -1
View File
@@ -16,7 +16,7 @@ import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
* When adding a new category, add it both here and to the initial values in telemetryCategoryEnabled
* Ensure `if (!this.isCategoryEnabled('<category_name>')` is added to the capture method
*/
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain"
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain" | "dictation"
/**
* Enum for terminal output failure reasons
@@ -76,6 +76,7 @@ export class TelemetryService {
private telemetryCategoryEnabled: Map<TelemetryCategory, boolean> = new Map([
["checkpoints", true], // Checkpoints telemetry enabled
["browser", true], // Browser telemetry enabled
["dictation", true], // Dictation telemetry enabled
["focus_chain", true], // Focus Chain telemetry enabled
])
@@ -88,6 +89,19 @@ export class TelemetryService {
TELEMETRY_ENABLED: "user.telemetry_enabled",
EXTENSION_ACTIVATED: "user.extension_activated",
},
DICTATION: {
// Tracks when voice recording is started
RECORDING_STARTED: "voice.recording_started",
// Tracks when voice recording is stopped
RECORDING_STOPPED: "voice.recording_stopped",
// Tracks when voice transcription is started
TRANSCRIPTION_STARTED: "voice.transcription_started",
// Tracks when voice transcription is completed successfully
TRANSCRIPTION_COMPLETED: "voice.transcription_completed",
// Tracks when voice transcription fails
TRANSCRIPTION_ERROR: "voice.transcription_error",
// Tracks when voice feature is enabled or disabled in settings
},
// Workspace-related events for multi-root support
WORKSPACE: {
// Track workspace initialization
@@ -285,7 +299,126 @@ export class TelemetryService {
setDistinctId(userInfo.id)
}
}
// Dictation events
/**
* Records when voice recording is started
* @param taskId Optional task identifier if recording was started during a task
* @param platform The platform where recording is happening (macOS, Windows, Linux)
*/
public captureVoiceRecordingStarted(taskId?: string, platform?: string) {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.RECORDING_STARTED,
properties: {
taskId,
platform: platform ?? process.platform,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when voice recording is stopped
* @param taskId Optional task identifier if recording was stopped during a task
* @param durationMs Duration of the recording in milliseconds
* @param success Whether the recording was successful
* @param platform The platform where recording happened
*/
public captureVoiceRecordingStopped(taskId?: string, durationMs?: number, success?: boolean, platform?: string) {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.RECORDING_STOPPED,
properties: {
taskId,
durationMs,
success,
platform: platform ?? process.platform,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when voice transcription is started
* @param taskId Optional task identifier if transcription was started during a task
* @param language Language hint provided for transcription
*/
public captureVoiceTranscriptionStarted(taskId?: string, language?: string) {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_STARTED,
properties: {
taskId,
language,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when voice transcription is completed successfully
* @param taskId Optional task identifier if transcription was completed during a task
* @param transcriptionLength Length of the transcribed text
* @param durationMs Time taken for transcription in milliseconds
* @param language Language used for transcription
* @param isOrgAccount Whether the transcription was done using an organization account
*/
public captureVoiceTranscriptionCompleted(
taskId?: string,
transcriptionLength?: number,
durationMs?: number,
language?: string,
isOrgAccount?: boolean,
) {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_COMPLETED,
properties: {
taskId,
transcriptionLength,
durationMs,
language,
accountType: isOrgAccount ? "organization" : "personal",
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when voice transcription fails
* @param taskId Optional task identifier if transcription failed during a task
* @param errorType Type of error that occurred (e.g., "no_openai_key", "api_error", "network_error")
* @param errorMessage The error message
* @param durationMs Time taken before failure in milliseconds
*/
public captureVoiceTranscriptionError(taskId?: string, errorType?: string, errorMessage?: string, durationMs?: number) {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_ERROR,
properties: {
taskId,
errorType,
errorMessage,
durationMs,
timestamp: new Date().toISOString(),
},
})
}
// Task events
/**
* Records when a new task/conversation is started
+21 -1
View File
@@ -2,6 +2,8 @@ import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
import { WebviewProvider } from "@/core/webview"
import { ErrorService } from "../error"
import { Logger } from "../logging/Logger"
import { SharedUriHandler } from "./SharedUriHandler"
describe("SharedUriHandler", () => {
@@ -9,9 +11,27 @@ describe("SharedUriHandler", () => {
let handleOpenRouterCallbackStub: sinon.SinonStub
let handleAuthCallbackStub: sinon.SinonStub
beforeEach(() => {
beforeEach(async () => {
sandbox = sinon.createSandbox()
// Mock Logger methods to avoid HostProvider dependency
sandbox.stub(Logger, "info").returns()
sandbox.stub(Logger, "error").returns()
// Mock ErrorService to avoid telemetry dependency
const mockErrorService = {
logMessage: sandbox.stub(),
logException: sandbox.stub(),
toClineError: sandbox.stub(),
isEnabled: sandbox.stub().returns(false),
getSettings: sandbox.stub().returns({ enabled: false, hostEnabled: false }),
getProvider: sandbox.stub(),
dispose: sandbox.stub().resolves(),
}
sandbox.stub(ErrorService, "initialize").resolves(mockErrorService as any)
sandbox.stub(ErrorService, "get").returns(mockErrorService as any)
await ErrorService.initialize()
handleOpenRouterCallbackStub = sandbox.stub().resolves()
handleAuthCallbackStub = sandbox.stub().resolves()
const mockWebviewProvider = {
+15 -11
View File
@@ -1,4 +1,5 @@
import { WebviewProvider } from "@/core/webview"
import { Logger } from "../logging/Logger"
/**
* Shared URI handler that processes both VSCode URI events and HTTP server callbacks
@@ -18,16 +19,19 @@ export class SharedUriHandler {
const queryString = parsedUrl.search.slice(1) // Remove leading '?'
const query = new URLSearchParams(queryString.replace(/\+/g, "%2B"))
console.log("SharedUriHandler: Processing URI:", {
path: path,
query: query,
scheme: parsedUrl.protocol,
})
Logger.info(
"SharedUriHandler: Processing URI:" +
JSON.stringify({
path: path,
query: query,
scheme: parsedUrl.protocol,
}),
)
const visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview) {
console.warn("SharedUriHandler: No visible webview found")
Logger.warn("SharedUriHandler: No visible webview found")
return false
}
@@ -44,15 +48,15 @@ export class SharedUriHandler {
}
case "/auth": {
const provider = query.get("provider")
const token = query.get("idToken")
console.log("SharedUriHandler: Auth callback received:", { path: path, provider: provider })
Logger.info(`SharedUriHandler - Auth callback received for ${provider} - ${path}`)
const token = query.get("refreshToken") || query.get("idToken") || query.get("code")
if (token) {
await visibleWebview.controller.handleAuthCallback(token, provider)
return true
}
console.warn("SharedUriHandler: Missing idToken parameter for auth callback")
Logger.warn("SharedUriHandler: Missing idToken parameter for auth callback")
return false
}
case "/auth/oca": {
@@ -69,11 +73,11 @@ export class SharedUriHandler {
return false
}
default:
console.warn(`SharedUriHandler: Unknown path: ${path}`)
Logger.warn(`SharedUriHandler: Unknown path: ${path}`)
return false
}
} catch (error) {
console.error("SharedUriHandler: Error processing URI:", error)
Logger.error("SharedUriHandler: Error processing URI:", error)
return false
}
}
+6
View File
@@ -0,0 +1,6 @@
export interface ClineFeatureSetting {
// Setting is enabled or disabled by user
user: boolean
// Setting is enabled or disabled by feature flag
featureFlag: boolean
}
+76
View File
@@ -0,0 +1,76 @@
export interface DictationSettings {
featureEnabled: boolean // Feature flag - whether dictation feature is available
dictationEnabled: boolean // User preference - whether user has enabled dictation
dictationLanguage: string
}
export const DEFAULT_DICTATION_SETTINGS: DictationSettings = {
featureEnabled: false, // Feature flag, will be set by the extension based on platform
dictationEnabled: false, // Default is false while this service is in Experimental status
dictationLanguage: "en",
}
export interface LanguageItem {
name: string
code: string
}
export const SUPPORTED_DICTATION_LANGUAGES: LanguageItem[] = [
{ name: "English", code: "en" },
{ name: "Spanish (Español)", code: "es" },
{ name: "Chinese (中文)", code: "zh" },
{ name: "Japanese (日本語)", code: "ja" },
{ name: "Afrikaans", code: "af" },
{ name: "Arabic (العربية)", code: "ar" },
{ name: "Armenian (Հայերեն)", code: "hy" },
{ name: "Azerbaijani (Azərbaycan)", code: "az" },
{ name: "Belarusian (Беларуская)", code: "be" },
{ name: "Bosnian (Bosanski)", code: "bs" },
{ name: "Bulgarian (Български)", code: "bg" },
{ name: "Catalan (Català)", code: "ca" },
{ name: "Croatian (Hrvatski)", code: "hr" },
{ name: "Czech (Čeština)", code: "cs" },
{ name: "Danish (Dansk)", code: "da" },
{ name: "Dutch (Nederlands)", code: "nl" },
{ name: "Estonian (Eesti)", code: "et" },
{ name: "Finnish (Suomi)", code: "fi" },
{ name: "French (Français)", code: "fr" },
{ name: "Galician (Galego)", code: "gl" },
{ name: "German (Deutsch)", code: "de" },
{ name: "Greek (Ελληνικά)", code: "el" },
{ name: "Hebrew (עברית)", code: "he" },
{ name: "Hindi (हिन्दी)", code: "hi" },
{ name: "Hungarian (Magyar)", code: "hu" },
{ name: "Icelandic (Íslenska)", code: "is" },
{ name: "Indonesian (Bahasa Indonesia)", code: "id" },
{ name: "Italian (Italiano)", code: "it" },
{ name: "Kannada (ಕನ್ನಡ)", code: "kn" },
{ name: "Kazakh (Қазақша)", code: "kk" },
{ name: "Korean (한국어)", code: "ko" },
{ name: "Latvian (Latviešu)", code: "lv" },
{ name: "Lithuanian (Lietuvių)", code: "lt" },
{ name: "Macedonian (Македонски)", code: "mk" },
{ name: "Malay (Bahasa Melayu)", code: "ms" },
{ name: "Marathi (मराठी)", code: "mr" },
{ name: "Maori (Te Reo Māori)", code: "mi" },
{ name: "Nepali (नेपाली)", code: "ne" },
{ name: "Norwegian (Norsk)", code: "no" },
{ name: "Persian (فارسی)", code: "fa" },
{ name: "Polish (Polski)", code: "pl" },
{ name: "Portuguese (Português)", code: "pt" },
{ name: "Romanian (Română)", code: "ro" },
{ name: "Russian (Русский)", code: "ru" },
{ name: "Serbian (Српски)", code: "sr" },
{ name: "Slovak (Slovenčina)", code: "sk" },
{ name: "Slovenian (Slovenščina)", code: "sl" },
{ name: "Swahili (Kiswahili)", code: "sw" },
{ name: "Swedish (Svenska)", code: "sv" },
{ name: "Tagalog", code: "tl" },
{ name: "Tamil (தமிழ்)", code: "ta" },
{ name: "Thai (ไทย)", code: "th" },
{ name: "Turkish (Türkçe)", code: "tr" },
{ name: "Ukrainian (Українська)", code: "uk" },
{ name: "Urdu (اردو)", code: "ur" },
{ name: "Vietnamese (Tiếng Việt)", code: "vi" },
{ name: "Welsh (Cymraeg)", code: "cy" },
]
+6 -1
View File
@@ -4,14 +4,15 @@ import { WorkspaceRoot } from "../core/workspace"
import { AutoApprovalSettings } from "./AutoApprovalSettings"
import { ApiConfiguration } from "./api"
import { BrowserSettings } from "./BrowserSettings"
import { ClineFeatureSetting } from "./ClineFeatureSetting"
import { ClineRulesToggles } from "./cline-rules"
import { DictationSettings } from "./DictationSettings"
import { FocusChainSettings } from "./FocusChainSettings"
import { HistoryItem } from "./HistoryItem"
import { McpDisplayMode } from "./McpDisplayMode"
import { Mode, OpenaiReasoningEffort } from "./storage/types"
import { TelemetrySetting } from "./TelemetrySetting"
import { UserInfo } from "./UserInfo"
// webview will hold state
export interface ExtensionMessage {
type: "grpc_response" // New type for gRPC responses
@@ -70,12 +71,16 @@ export interface ExtensionState {
yoloModeToggled?: boolean
useAutoCondense?: boolean
focusChainSettings: FocusChainSettings
dictationSettings: DictationSettings
customPrompt?: string
autoCondenseThreshold?: number
favoritedModelIds: string[]
// NEW: Add workspace information
workspaceRoots: WorkspaceRoot[]
primaryRootIndex: number
isMultiRootWorkspace: boolean
multiRootSetting: ClineFeatureSetting
lastDismissedInfoBannerVersion: number
}
export interface ClineMessage {
+12 -1
View File
@@ -262,6 +262,7 @@ export const CLAUDE_SONNET_4_1M_TIERS = [
// https://docs.anthropic.com/en/docs/about-claude/models // prices updated 2025-01-02
export type AnthropicModelId = keyof typeof anthropicModels
export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-20250514"
export const ANTHROPIC_MIN_THINKING_BUDGET = 1_024
export const anthropicModels = {
"claude-sonnet-4-20250514:1m": {
maxTokens: 8192,
@@ -596,7 +597,7 @@ export const openRouterDefaultModelInfo: ModelInfo = {
// Cline custom model - code-supernova
export const clineCodeSupernovaModelInfo: ModelInfo = {
contextWindow: 200000,
contextWindow: 1000000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0,
@@ -2509,6 +2510,16 @@ export const nebiusDefaultModelId = "Qwen/Qwen2.5-32B-Instruct-fast" satisfies N
export type XAIModelId = keyof typeof xaiModels
export const xaiDefaultModelId: XAIModelId = "grok-4"
export const xaiModels = {
"grok-4-fast-reasoning": {
maxTokens: 30000,
contextWindow: 2000000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.2,
cacheReadsPrice: 0.05,
outputPrice: 0.5,
description: "xAI's Grok 4 Fast (free) multimodal model with 2M context.",
},
"grok-4": {
maxTokens: 8192,
contextWindow: 262144,
+81
View File
@@ -0,0 +1,81 @@
export const AUDIO_PROGRAM_CONFIG = {
darwin: {
command: "ffmpeg",
fallbackPaths: ["/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"],
getArgs: (outputFile: string) => [
"-f",
"avfoundation",
"-i",
":default",
"-c:a",
"libopus",
"-b:a",
"32k",
"-application",
"voip",
"-ar",
"16000",
"-ac",
"1",
outputFile,
],
dependencyName: "FFmpeg",
installCommand: "brew install ffmpeg",
error: "FFmpeg is required for voice recording but is not installed on your system.",
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
},
linux: {
command: "ffmpeg",
fallbackPaths: ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/snap/bin/ffmpeg"],
getArgs: (outputFile: string) => [
"-f",
"alsa",
"-i",
"default",
"-c:a",
"libopus",
"-b:a",
"32k",
"-application",
"voip",
"-ar",
"16000",
"-ac",
"1",
outputFile,
],
dependencyName: "FFmpeg",
installCommand: "sudo apt-get update && sudo apt-get install -y ffmpeg",
error: "FFmpeg is required for voice recording but is not installed on your system.",
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
},
win32: {
command: "ffmpeg",
fallbackPaths: [
"C:\\ffmpeg\\bin\\ffmpeg.exe",
"C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe",
"C:\\Program Files (x86)\\ffmpeg\\bin\\ffmpeg.exe",
],
getArgs: (outputFile: string) => [
"-f",
"wasapi",
"-i",
"audio=default",
"-c:a",
"libopus",
"-b:a",
"32k",
"-application",
"voip",
"-ar",
"16000",
"-ac",
"1",
outputFile,
],
dependencyName: "FFmpeg",
installCommand: "winget install Gyan.FFmpeg",
error: "FFmpeg is required for voice recording but is not installed on your system.",
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
},
}
+15
View File
@@ -0,0 +1,15 @@
enum CLINE_API_AUTH_ENDPOINTS {
AUTH = "/api/v1/auth/authorize",
REFRESH_TOKEN = "/api/v1/auth/refresh",
}
enum CLINE_API_ENDPOINT_V1 {
TOKEN_EXCHANGE = "/api/v1/auth/token",
USER_INFO = "/api/v1/users/me",
ACTIVE_ACCOUNT = "/api/v1/users/active-account",
}
export const CLINE_API_ENDPOINT = {
...CLINE_API_AUTH_ENDPOINTS,
...CLINE_API_ENDPOINT_V1,
}
+1 -1
View File
@@ -111,4 +111,4 @@ export interface McpDownloadResponse {
requiresApiKey: boolean
}
export type McpViewTab = "marketplace" | "addRemote" | "installed"
export type McpViewTab = "marketplace" | "addRemote" | "configure"
@@ -1,6 +1,7 @@
export enum FeatureFlag {
CUSTOM_INSTRUCTIONS = "custom-instructions",
DEV_ENV_POSTHOG = "dev-env-posthog",
DICTATION = "dictation",
FOCUS_CHAIN_CHECKLIST = "focus_chain_checklist",
MULTI_ROOT_WORKSPACE = "multi_root_workspace",
}
+30 -234
View File
@@ -2,211 +2,66 @@ import { ExternalDiffViewProvider } from "@hosts/external/ExternalDiffviewProvid
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
import { ExternalHostBridgeClientManager } from "@hosts/external/host-bridge-client-manager"
import { WebviewProviderType } from "@shared/webview/types"
import { retryOperation } from "@utils/retry"
import os from "os"
import * as path from "path"
import { initialize, tearDown } from "@/common"
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
import { WebviewProvider } from "@/core/webview"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
import { checkPortAvailability } from "./port-checker"
import { startProtobusService, waitForHostBridgeReady } from "./protobus-service"
import { log, SETTINGS_SUBFOLDER } from "./utils"
import { createExtensionContext } from "./vscode-context"
// Default ports
export const DEFAULT_PROTOBUS_PORT = 26040
export const DEFAULT_HOSTBRIDGE_PORT = 26041
// Parse command line arguments
interface CliArgs {
port?: number
hostBridgePort?: number
config?: string
help?: boolean
}
function parseArgs(): CliArgs {
const args: CliArgs = {}
const argv = process.argv.slice(2)
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
switch (arg) {
case "--port":
case "-p":
args.port = parseInt(argv[++i], 10)
break
case "--host-bridge-port":
args.hostBridgePort = parseInt(argv[++i], 10)
break
case "--config":
case "-c":
args.config = argv[++i]
break
case "--help":
case "-h":
args.help = true
break
}
}
return args
}
function showHelp() {
console.log(`
Cline Core - Standalone Server
Usage: node cline-core.js [options]
Options:
-p, --port <port> Port for the main gRPC service (default: ${DEFAULT_PROTOBUS_PORT})
--host-bridge-port <port> Port for the host bridge service (default: ${DEFAULT_HOSTBRIDGE_PORT})
-c, --config <path> Directory for Cline data storage (default: ~/.cline)
-h, --help Show this help message
Environment Variables:
PROTOBUS_ADDRESS Override the main service address (format: host:port)
HOSTBRIDGE_ADDRESS Override the host bridge address (format: host:port)
`)
}
import { waitForHostBridgeReady } from "./hostbridge-client"
import { startProtobusService } from "./protobus-service"
import { log } from "./utils"
import { DATA_DIR, EXTENSION_DIR, extensionContext } from "./vscode-context"
async function main() {
// Parse command line arguments
const args = parseArgs()
// Show help if requested
if (args.help) {
showHelp()
process.exit(0)
}
// Configure ports from arguments
let protobusPort = DEFAULT_PROTOBUS_PORT
let hostBridgePort = DEFAULT_HOSTBRIDGE_PORT
if (args.port) {
protobusPort = args.port
// If only port is specified, calculate hostbridge port as port + 1000
if (!args.hostBridgePort) {
hostBridgePort = protobusPort + 1000
}
}
if (args.hostBridgePort) {
hostBridgePort = args.hostBridgePort
}
// Set environment variables for the services to use
if (!process.env.PROTOBUS_ADDRESS) {
process.env.PROTOBUS_ADDRESS = `localhost:${protobusPort}`
}
if (!process.env.HOSTBRIDGE_ADDRESS) {
process.env.HOSTBRIDGE_ADDRESS = `localhost:${hostBridgePort}`
}
// Configure Cline directory from arguments
const clineDir = args.config || `${os.homedir()}/.cline`
log("\n\n\nStarting cline-core service...\n\n\n")
log(`Using Protobus port: ${protobusPort}`)
log(`Using Host Bridge port: ${hostBridgePort}`)
log(`Using Cline directory: ${clineDir}`)
// Initialize SQLite lock manager for instance registration
const dbPath = `${clineDir}/${SETTINGS_SUBFOLDER}/locks.db`
// Use host:port everywhere (no scheme)
const fullAddress = `localhost:${protobusPort}`
let lockManager: SqliteLockManager | undefined
try {
lockManager = new SqliteLockManager({
dbPath,
instanceAddress: fullAddress,
})
await waitForHostBridgeReady()
// Check port availability before proceeding
log(`Checking port availability for ${protobusPort}...`)
const portCheck = await checkPortAvailability(protobusPort, lockManager)
if (!portCheck.canProceed) {
log(`STARTUP BLOCKED: ${portCheck.error}`)
lockManager.close()
process.exit(1)
}
await lockManager.registerInstance({
corePort: protobusPort,
hostPort: hostBridgePort,
version: process.env.CLINE_VERSION,
status: "starting",
})
log(`Registered instance in SQLite locks: ${fullAddress}`)
} catch (err) {
log(`CRITICAL ERROR: Failed to register instance in SQLite locks: ${String(err)}`)
log(`This is a fatal error - cline-core cannot start without proper instance registration`)
if (lockManager) {
try {
lockManager.close()
} catch {}
}
process.exit(1)
}
try {
await waitForHostBridgeReady()
log("HostBridge is serving; continuing startup")
} catch (err) {
log(`ERROR: HostBridge error: ${String(err)}`)
// Cleanup lock manager entry if startup fails
if (lockManager) {
try {
lockManager.unregisterInstance()
lockManager.close()
} catch {}
}
process.exit(1)
}
// Create extension context with the configured directory
const extensionContext = createExtensionContext(clineDir)
setupHostProvider(extensionContext)
// The host bridge should be available before creating the host provider because it depends on the host bridge.
setupHostProvider()
// Set up global error handlers to prevent process crashes
setupGlobalErrorHandlers(lockManager)
setupGlobalErrorHandlers()
const webviewProvider = await initialize(extensionContext)
// Enable the localhost HTTP server that handles auth redirects.
AuthHandler.getInstance().setEnabled(true)
startProtobusService(webviewProvider.controller)
// Mark instance healthy after services are up
try {
lockManager?.touchInstance()
} catch {}
}
function setupHostProvider(extensionContext: any) {
function setupHostProvider() {
const createWebview = (_: WebviewProviderType): WebviewProvider => {
return new ExternalWebviewProvider(extensionContext, WebviewProviderType.SIDEBAR)
}
const createDiffView = (): DiffViewProvider => {
return new ExternalDiffViewProvider()
}
const getCallbackUri = (): Promise<string> => {
return AuthHandler.getInstance().getCallbackUri()
const getCallbackUrl = (): Promise<string> => {
return AuthHandler.getInstance().getCallbackUrl()
}
// cline-core expects the binaries to be unpacked in the directory where it is running.
const getBinaryLocation = async (name: string): Promise<string> => path.join(process.cwd(), name)
HostProvider.initialize(createWebview, createDiffView, new ExternalHostBridgeClientManager(), log, getCallbackUri)
HostProvider.initialize(
createWebview,
createDiffView,
new ExternalHostBridgeClientManager(),
log,
getCallbackUrl,
getBinaryLocation,
EXTENSION_DIR,
DATA_DIR,
)
}
/**
* Sets up global error handlers to prevent the process from crashing
* on unhandled exceptions and promise rejections
*/
function setupGlobalErrorHandlers(lockManager?: SqliteLockManager) {
function setupGlobalErrorHandlers() {
// Handle unhandled exceptions
process.on("uncaughtException", (error: Error) => {
log(`ERROR: Uncaught exception: ${error.message}`)
@@ -231,74 +86,15 @@ function setupGlobalErrorHandlers(lockManager?: SqliteLockManager) {
// Graceful shutdown handlers
process.on("SIGINT", () => {
log("Received SIGINT, shutting down gracefully...")
shutdownGracefully(lockManager)
process.exit(0)
})
process.on("SIGTERM", () => {
log("Received SIGTERM, shutting down gracefully...")
shutdownGracefully(lockManager)
tearDown()
process.exit(0)
})
}
/**
* Request host bridge shutdown with retry logic and timeout handling.
* Uses best-effort approach - logs failures but doesn't block shutdown.
*/
async function requestHostBridgeShutdown(): Promise<void> {
try {
await retryOperation(3, 2000, async () => {
await HostProvider.env.shutdown({})
})
log("Host bridge shutdown requested successfully")
} catch (error) {
log(`Warning: Failed to request host bridge shutdown: ${error}`)
log("Proceeding with cleanup")
}
}
/**
* Gracefully shutdown the cline-core process by:
* 1. Calling shutdown RPC on the paired host bridge
* 2. Cleaning up the lock manager entry
* 3. Tearing down services
* 4. Exiting the process
*/
async function shutdownGracefully(lockManager?: SqliteLockManager) {
try {
// Step 1: Tell the paired host bridge to shut down
log("Requesting host bridge shutdown...")
if (HostProvider.isInitialized()) {
await requestHostBridgeShutdown()
} else {
log("Warning: HostProvider not initialized, cannot request shutdown")
}
// Step 2: Clean up lock manager entry
log("Cleaning up lock manager entry...")
try {
lockManager?.unregisterInstance()
lockManager?.close()
log("Lock manager entry cleaned up successfully")
} catch (error) {
log(`Warning: Failed to clean up lock manager: ${error}`)
}
// Step 3: Tear down services
log("Tearing down services...")
try {
tearDown()
log("Services torn down successfully")
} catch (error) {
log(`Warning: Failed to tear down services: ${error}`)
}
log("Graceful shutdown completed")
} catch (error) {
log(`Error during graceful shutdown: ${error}`)
} finally {
// Step 4: Exit the process
process.exit(0)
}
}
main()
+2 -1
View File
@@ -32,7 +32,8 @@ function createHealthClient(address: string) {
const healthDef = protoLoader.loadSync(health.protoPath)
const grpcObj = grpc.loadPackageDefinition(healthDef) as unknown as any
const Health = grpcObj.grpc.health.v1.Health
return new Health(address, grpc.credentials.createInsecure())
const opts: grpc.ChannelOptions = { "grpc.enable_http_proxy": 0 }
return new Health(address, grpc.credentials.createInsecure(), opts)
}
async function checkHealthOnce(client: any): Promise<boolean> {
-166
View File
@@ -1,166 +0,0 @@
import * as grpc from "@grpc/grpc-js"
import * as protoLoader from "@grpc/proto-loader"
import * as health from "grpc-health-check"
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
import { log } from "./utils"
const SERVING_STATUS = 1
interface PortCheckResult {
canProceed: boolean
error?: string
}
/**
* Creates a gRPC health client for the given address
*/
function createHealthClient(address: string): any {
const healthDef = protoLoader.loadSync(health.protoPath)
const grpcObj = grpc.loadPackageDefinition(healthDef) as unknown as any
const Health = grpcObj.grpc.health.v1.Health
return new Health(address, grpc.credentials.createInsecure())
}
/**
* Performs a single health check on the given address
*/
async function checkHealthOnce(address: string): Promise<{ success: boolean; status?: number; error?: Error }> {
const client = createHealthClient(address)
return new Promise((resolve) => {
const timeout = setTimeout(() => {
try {
client.close?.()
} catch {}
resolve({ success: false, error: new Error("Health check timeout") })
}, 5000) // 5 second timeout
client.check({ service: "" }, (err: unknown, resp: any) => {
clearTimeout(timeout)
try {
client.close?.()
} catch {}
if (err) {
resolve({ success: false, error: err as Error })
} else {
resolve({ success: true, status: resp?.status })
}
})
})
}
/**
* Attempts to shut down a host bridge instance
*/
async function shutdownHostBridge(hostAddress: string): Promise<boolean> {
try {
log(`Attempting to shutdown host bridge at ${hostAddress}`)
// This would need to be implemented - we need a way to send shutdown to a specific host
// For now, we'll just log that we would do this
log(`Would send shutdown command to host bridge at ${hostAddress}`)
return true
} catch (error) {
log(`Failed to shutdown host bridge at ${hostAddress}: ${error}`)
return false
}
}
/**
* Checks if a port is available for binding, following the registry-first approach
*/
export async function checkPortAvailability(port: number, lockManager: SqliteLockManager): Promise<PortCheckResult> {
log(`Checking port availability for port ${port}`)
// Step 1: Check registry first
const registryEntry = lockManager.getInstanceByPort(port)
if (!registryEntry) {
log(`No registry entry found for port ${port}, free to bind`)
return { canProceed: true }
}
log(`Found registry entry for port ${port}: instance=${registryEntry.instanceAddress}, host=${registryEntry.hostAddress}`)
// Step 2: Perform health check on the registered instance
const coreAddress = registryEntry.instanceAddress
const performHealthCheck = async (): Promise<{ success: boolean; status?: number; error?: Error }> => {
return await checkHealthOnce(coreAddress)
}
// First health check attempt
let healthResult = await performHealthCheck()
if (!healthResult.success) {
// Health check ERROR - not our process
log(`Health check failed for ${coreAddress}: ${healthResult.error?.message}`)
log(`This indicates a non-Cline process is using port ${port}`)
// Attempt to shutdown the registered host bridge
const shutdownSuccess = await shutdownHostBridge(registryEntry.hostAddress)
if (shutdownSuccess) {
log(`Successfully requested shutdown of host bridge ${registryEntry.hostAddress}`)
}
// Remove from registry
lockManager.removeInstanceByAddress(registryEntry.instanceAddress)
log(`Removed stale registry entry for ${registryEntry.instanceAddress}`)
return {
canProceed: false,
error: `Port ${port} is occupied by a non-Cline process. Registry has been cleaned up.`,
}
}
// Health check succeeded - it's our process
if (healthResult.status === SERVING_STATUS) {
// Healthy Cline instance already running
log(`Healthy Cline instance already running on port ${port}`)
return {
canProceed: false,
error: `A healthy Cline instance is already running on port ${port}`,
}
}
// Health check succeeded but status is not SERVING - unhealthy Cline instance
log(`Unhealthy Cline instance detected on port ${port} (status: ${healthResult.status}), retrying in 1 second`)
// Wait 1 second and retry
await new Promise((resolve) => setTimeout(resolve, 1000))
// Second health check attempt
healthResult = await performHealthCheck()
if (!healthResult.success) {
// Now it's erroring - something changed
log(`Health check now failing after retry for ${coreAddress}: ${healthResult.error?.message}`)
// Clean up registry since the instance is no longer responding
lockManager.removeInstanceByAddress(registryEntry.instanceAddress)
log(`Removed non-responsive registry entry for ${registryEntry.instanceAddress}`)
return {
canProceed: false,
error: `Port ${port} had an unhealthy Cline instance that is now non-responsive. Registry cleaned up.`,
}
}
if (healthResult.status === SERVING_STATUS) {
// Instance recovered
log(`Cline instance on port ${port} has recovered and is now healthy`)
return {
canProceed: false,
error: `Cline instance on port ${port} has recovered and is now serving`,
}
}
// Still unhealthy after retry
log(`Cline instance on port ${port} remains unhealthy after retry (status: ${healthResult.status})`)
return {
canProceed: false,
error: `Cline instance on port ${port} is unhealthy and did not recover after retry`,
}
}
+1 -5
View File
@@ -3,9 +3,6 @@ import * as fs from "fs"
import * as health from "grpc-health-check"
import { StreamingCallbacks } from "@/hosts/host-provider-types"
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
const SETTINGS_SUBFOLDER = "data"
const log = (...args: unknown[]) => {
const now = new Date()
const year = now.getFullYear()
@@ -23,7 +20,6 @@ const log = (...args: unknown[]) => {
function getPackageDefinition() {
// Load service definitions.
// When running as standalone, the descriptor set is in the same directory as the standalone.js file
const descriptorSet = fs.readFileSync("proto/descriptor_set.pb")
const options = { longs: Number } // Encode int64 fields as numbers
const descriptorDefs = protoLoader.loadFileDescriptorSetFromBuffer(descriptorSet, options)
@@ -55,4 +51,4 @@ async function asyncIteratorToCallbacks<T>(stream: AsyncIterable<T>, callbacks:
}
}
export { getPackageDefinition, log, asyncIteratorToCallbacks, SETTINGS_SUBFOLDER }
export { getPackageDefinition, log, asyncIteratorToCallbacks }
+50 -70
View File
@@ -1,89 +1,69 @@
import { mkdirSync } from "fs"
import { mkdirSync } from "node:fs"
import os from "os"
import path from "path"
import type { Extension, ExtensionContext } from "vscode"
import { ExtensionKind, ExtensionMode } from "vscode"
import { URI } from "vscode-uri"
// @ts-ignore
import { StandaloneTerminalManager } from "../../standalone/runtime-files/vscode/enhanced-terminal"
import { ExtensionRegistryInfo } from "@/registry"
import { log } from "./utils"
import { EnvironmentVariableCollection, MementoStore, SecretStore } from "./vscode-context-utils"
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
function getPackageVersion(): string {
// Use build-time injected version (only method)
return process.env.CLINE_VERSION || "unknown"
log("Running standalone cline", ExtensionRegistryInfo.version)
log(`CLINE_ENVIRONMENT: ${process.env.CLINE_ENVIRONMENT}`)
export const CLINE_DIR = process.env.CLINE_DIR || `${os.homedir()}/.cline`
export const DATA_DIR = path.join(CLINE_DIR, "data")
const INSTALL_DIR = process.env.INSTALL_DIR || __dirname
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspace")
mkdirSync(DATA_DIR, { recursive: true })
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
log("Using settings dir:", DATA_DIR)
export const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
const extension: Extension<void> = {
id: ExtensionRegistryInfo.id,
isActive: true,
extensionPath: EXTENSION_DIR,
extensionUri: URI.file(EXTENSION_DIR),
packageJSON: readJson(path.join(EXTENSION_DIR, "package.json")),
exports: undefined, // There are no API exports in the standalone version.
activate: async () => {},
extensionKind: ExtensionKind.UI,
}
const VERSION = getPackageVersion()
log("Running standalone cline ", VERSION)
const extensionContext: ExtensionContext = {
extension: extension,
extensionMode: EXTENSION_MODE,
function createExtensionContext(clineDir: string): ExtensionContext {
const DATA_DIR = path.join(clineDir, "data")
const INSTALL_DIR = process.env.INSTALL_DIR || path.join(clineDir, "core", VERSION)
mkdirSync(DATA_DIR, { recursive: true })
log("Using settings dir:", DATA_DIR)
// Set up KV stores.
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
// Set up URIs.
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
storagePath: WORKSPACE_STORAGE_DIR, // Deprecated, not used in cline.
globalStorageUri: URI.file(DATA_DIR),
globalStoragePath: DATA_DIR, // Deprecated, not used in cline.
// Static package.json data for extension context (no filesystem reading)
function getExtensionPackageJson(): any {
return {
name: "claude-dev",
displayName: "Cline",
version: VERSION,
publisher: "saoudrizwan",
}
}
// Logs are global per extension, not per workspace.
logUri: URI.file(DATA_DIR),
logPath: DATA_DIR, // Deprecated, not used in cline.
const extension: Extension<void> = {
id: "saoudrizwan.claude-dev",
isActive: true,
extensionPath: EXTENSION_DIR,
extensionUri: URI.file(EXTENSION_DIR),
packageJSON: getExtensionPackageJson(),
exports: undefined, // There are no API exports in the standalone version.
activate: async () => {},
extensionKind: ExtensionKind.UI,
}
extensionUri: URI.file(EXTENSION_DIR),
extensionPath: EXTENSION_DIR, // Deprecated, not used in cline.
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
const extensionContext: ExtensionContext = {
extension: extension,
extensionMode: EXTENSION_MODE,
subscriptions: [], // These need to be destroyed when the extension is deactivated.
// Set up KV stores.
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
environmentVariableCollection: new EnvironmentVariableCollection(),
// Set up URIs.
storageUri: URI.file(DATA_DIR),
storagePath: DATA_DIR, // Deprecated, not used in cline.
globalStorageUri: URI.file(DATA_DIR),
globalStoragePath: DATA_DIR, // Deprecated, not used in cline.
logUri: URI.file(DATA_DIR),
logPath: DATA_DIR, // Deprecated, not used in cline.
extensionUri: URI.file(EXTENSION_DIR),
extensionPath: EXTENSION_DIR, // Deprecated, not used in cline.
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
subscriptions: [], // These need to be destroyed when the extension is deactivated.
environmentVariableCollection: new EnvironmentVariableCollection(),
// TODO(sjf): Workspace state needs to be per project/workspace.
workspaceState: new MementoStore(path.join(DATA_DIR, "workspaceState.json")),
}
return extensionContext
// Workspace state is per project/workspace when WORKSPACE_STORAGE_DIR is provided by the host.
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
}
// Initialize the standalone terminal manager for use by Task instances
const standaloneTerminalManager = new StandaloneTerminalManager()
// Set it as a global so Task constructor can access it
;(global as any).standaloneTerminalManager = standaloneTerminalManager
console.log("Finished loading vscode context...")
export { createExtensionContext }
export { extensionContext }
-14
View File
@@ -48,19 +48,6 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
const chatInputBox = sidebar.getByTestId("chat-input")
await expect(chatInputBox).toBeVisible()
// Verify the help improve banner is visible and can be closed.
const telemetryBanner = sidebar.getByText("Help Improve Cline")
await expect(telemetryBanner).toBeVisible()
await sidebar.getByText("settings").click() // Click on the settings link in the banner
await expect(sidebar.getByText("General Settings")).toBeVisible() // Default view should be set to General tab
await sidebar.getByTestId("tab-api-config").click()
await expect(sidebar.locator("h4").getByText("API Configuration")).toBeVisible()
await sidebar.getByTestId("tab-about").click()
await expect(sidebar.getByRole("heading", { name: "About" }).locator("div").first()).toBeVisible()
// Exit the Settings view by clicking the Done button
await sidebar.getByRole("button", { name: "Done" }).click()
// Verify the release banner is visible for new installs and can be closed.
const releaseBanner = sidebar.getByRole("heading", {
name: /^🎉 New in v\d/,
@@ -68,5 +55,4 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
await expect(releaseBanner).toBeVisible()
await sidebar.getByTestId("close-button").locator("span").first().click()
await expect(releaseBanner).not.toBeVisible()
await expect(telemetryBanner).not.toBeVisible()
})
+48 -72
View File
@@ -1,89 +1,65 @@
import { expect } from "@playwright/test"
import { E2E_WORKSPACE_TYPES, e2e } from "./utils/helpers"
import { e2e } from "./utils/helpers"
e2e.describe("Chat - can send messages and switch between modes", () => {
E2E_WORKSPACE_TYPES.forEach(({ title, workspaceType }) => {
e2e.extend({
workspaceType,
})(title, async ({ helper, sidebar, page }) => {
// Sign in
await helper.signin(sidebar)
e2e("Chat - can send messages and switch between modes", async ({ helper, sidebar, page }) => {
// Sign in
await helper.signin(sidebar)
// Submit a message
const inputbox = sidebar.getByTestId("chat-input")
await expect(inputbox).toBeVisible()
await inputbox.fill("Hello, Cline!")
await expect(inputbox).toHaveValue("Hello, Cline!")
await sidebar.getByTestId("send-button").click({ delay: 100 })
await expect(inputbox).toHaveValue("")
// Submit a message
const inputbox = sidebar.getByTestId("chat-input")
await expect(inputbox).toBeVisible()
await inputbox.fill("Hello, Cline!")
await expect(inputbox).toHaveValue("Hello, Cline!")
await sidebar.getByTestId("send-button").click({ delay: 100 })
await expect(inputbox).toHaveValue("")
// Loading State initially
await expect(sidebar.getByText("API Request...")).toBeVisible()
// Loading State initially
await expect(sidebar.getByText("API Request...")).toBeVisible()
// The request should eventually fail
await expect(sidebar.getByText("API Request Failed")).toBeVisible()
// Starting a new task should clear the current chat view and show the recent tasks
await sidebar.getByRole("button", { name: "New Task" }).click()
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible()
await expect(inputbox).toBeVisible()
// Makes sure the act and plan switches are working correctly
// Aria-checked state should be true for Act and false for Plan
const actButton = sidebar.getByRole("switch", { name: "Act" })
const planButton = sidebar.getByRole("switch", { name: "Plan" })
await expect(sidebar.getByRole("button", { name: "Retry" })).toBeVisible()
await expect(sidebar.getByRole("button", { name: "Start New Task" })).toBeVisible()
await expect(actButton).toBeChecked()
await expect(planButton).not.toBeChecked()
// Starting a new task should clear the current chat view and show the recent tasks
await sidebar.getByRole("button", { name: "Start New Task" }).click()
await expect(sidebar.getByText("API Request Failed")).not.toBeVisible()
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible()
await actButton.click()
await expect(actButton).not.toBeChecked()
await expect(planButton).toBeChecked()
// Makes sure the act and plan switches are working correctly
// Aria-checked state should be true for Act and false for Plan
const actButton = sidebar.getByRole("switch", { name: "Act" })
const planButton = sidebar.getByRole("switch", { name: "Plan" })
// === slash commands preserve following text ===
await expect(inputbox).toHaveValue("")
// Type partial slash command to trigger menu
await inputbox.pressSequentially("/new", { delay: 100 })
await expect(actButton).toBeChecked()
await expect(planButton).not.toBeChecked()
// Wait for menu to be visible and select first option with Tab
await inputbox.press("Tab")
await expect(inputbox).toHaveValue("/newtask ")
await actButton.click()
await expect(actButton).not.toBeChecked()
await expect(planButton).toBeChecked()
// Add following text to verify it works correctly
await inputbox.pressSequentially("following text should be preserved")
await expect(inputbox).toHaveValue("/newtask following text should be preserved")
await inputbox.fill("Plan mode submission")
await sidebar.getByTestId("send-button").click()
// === @ mentions preserve following text ===
await inputbox.fill("")
await expect(inputbox).toHaveValue("")
await expect(sidebar.getByText("API Request Failed")).toBeVisible()
// Type partial @ mention to trigger menu
await inputbox.pressSequentially("@prob")
// === slash commands preserve following text ===
await inputbox.fill("")
await expect(inputbox).toHaveValue("")
await inputbox.focus()
// Wait for menu to be visible and select first option with Tab
await inputbox.press("Tab")
await expect(inputbox).toHaveValue("@problems ")
// Type partial slash command to trigger menu
await inputbox.pressSequentially("/new")
// Add following text to verify it works correctly
await inputbox.pressSequentially("following text should be preserved")
await expect(inputbox).toHaveValue("@problems following text should be preserved")
// Wait for menu to be visible and select first option with Tab
await inputbox.press("Tab")
await expect(inputbox).toHaveValue("/newtask ")
// Add following text to verify it works correctly
await inputbox.pressSequentially("following text should be preserved")
await expect(inputbox).toHaveValue("/newtask following text should be preserved")
// === @ mentions preserve following text ===
await inputbox.fill("")
await expect(inputbox).toHaveValue("")
await inputbox.focus()
// Type partial @ mention to trigger menu
await inputbox.pressSequentially("@prob")
// Wait for menu to be visible and select first option with Tab
await inputbox.press("Tab")
await expect(inputbox).toHaveValue("@problems ")
// Add following text to verify it works correctly
await inputbox.pressSequentially("following text should be preserved")
await expect(inputbox).toHaveValue("@problems following text should be preserved")
await page.close()
})
})
await page.close()
})
+1 -1
View File
@@ -9,7 +9,7 @@ export const E2E_REGISTERED_MOCK_ENDPOINTS = {
"/users/{userId}/usages",
"/users/{userId}/payments",
],
POST: ["/chat/completions"],
POST: ["/chat/completions", "/auth/token"],
PUT: ["/users/active-account"],
},
"/.test": {

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