Merge branch 'main' into pe/apply-fixes-pt2

This commit is contained in:
Patrick Erichsen 2025-05-16 15:06:51 -07:00
commit 890c9b3e7b
81 changed files with 1533 additions and 335 deletions

View File

@ -0,0 +1,30 @@
name: unit-testing-rules
version: 0.0.1
schema: v1
rules:
- name: unit-testing-rules
rule: >-
For unit testing in this project:
1. The project uses Jest as the testing framework
2. Run tests using `npm test` from within the specific package/module
directory
3. Command structure: `cd [directory] && npm test -- [test file path]`
4. The test script uses experimental VM modules via NODE_OPTIONS flag
5. Test files follow the pattern `*.test.ts`
6. Tests must import Jest with `import { jest } from "@jest/globals";`
7. Run tests from within the specific package directory (e.g., `cd core`
for core module tests)
8. Write tests as top-level `test()` functions - DO NOT use `describe()`
blocks
9. Include the function name being tested in the test description for
clarity

29
.github/workflows/cla.yaml vendored Normal file
View File

@ -0,0 +1,29 @@
name: "CLA Assistant"
on:
issue_comment:
types: [ created ]
pull_request_target:
types: [ opened, closed, synchronize ]
permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs:
CLAAssistant:
runs-on: ubuntu-latest
# Only run this workflow on the main repository (continuedev/continue)
if: github.repository == 'continuedev/continue'
steps:
- name: "CLA Assistant"
if: (contains(github.event.comment.body, 'recheck') || contains(github.event.comment.body, 'I have read the CLA Document and I hereby sign the CLA')) || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
path-to-signatures: "signatures/version1/cla.json"
path-to-document: "https://github.com/continuedev/continue/blob/main/docs/docs/CLA.md"
branch: cla-signatures
allowlist: dependabot[bot]

View File

@ -63,7 +63,7 @@ jobs:
- name: Bump version in gradle.properties
run: |
cd extensions/intelllij
cd extensions/intellij
awk '/pluginVersion=/{split($0,a,"="); split(a[2],b,"."); b[3]+=1; printf "%s=%s.%s.%s\n",a[1],b[1],b[2],b[3];next}1' gradle.properties > tmp && mv tmp gradle.properties
rm -rf tmp
NEW_VERSION=$(grep 'pluginVersion=' gradle.properties | cut -d'=' -f2)

View File

@ -235,10 +235,11 @@ jobs:
cd packages/config-yaml
npm run build
- name: Type check
- name: Type check and lint
run: |
cd gui
npx tsc --noEmit
npm run lint
binary-checks:
needs: [install-root, install-core, install-config-yaml]

3
.gitignore vendored
View File

@ -162,5 +162,8 @@ extensions/.continue-debug/
*.vsix
# intellij module library files
*.iml
.continuerules
**/.continue/assistants/

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<option name="fix-on-save" value="true" />
</component>
</project>

View File

@ -2,5 +2,6 @@
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" />
</component>
</project>

View File

@ -0,0 +1,22 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="config-yaml tests" type="JavaScriptTestRunnerJest">
<config-file value="$PROJECT_DIR$/packages/config-yaml/jest.config.mjs" />
<node-interpreter value="project" />
<node-options value="--experimental-vm-modules" />
<jest-package value="$PROJECT_DIR$/binary/node_modules/jest" />
<working-dir value="$PROJECT_DIR$/packages/config-yaml" />
<envs />
<scope-kind value="ALL" />
<method v="2">
<option name="NpmBeforeRunTask" enabled="true">
<package-json value="$PROJECT_DIR$/packages/config-yaml/package.json" />
<command value="run" />
<scripts>
<script value="build" />
</scripts>
<node-interpreter value="project" />
<envs />
</option>
</method>
</configuration>
</component>

View File

@ -0,0 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="core tests" type="JavaScriptTestRunnerJest">
<config-file value="$PROJECT_DIR$/core/jest.config.js" />
<node-interpreter value="project" />
<node-options value="--experimental-vm-modules" />
<jest-package value="$PROJECT_DIR$/binary/node_modules/jest" />
<working-dir value="$PROJECT_DIR$/core" />
<envs />
<scope-kind value="ALL" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="openai-adapters configuration" type="JavaScriptTestRunnerJest">
<node-interpreter value="project" />
<node-options value="--experimental-vm-modules" />
<jest-package value="$PROJECT_DIR$/binary/node_modules/jest" />
<working-dir value="$PROJECT_DIR$/packages/openai-adapters" />
<envs />
<scope-kind value="SUITE" />
<test-file value="$PROJECT_DIR$/packages/openai-adapters/src/test/main.test.ts" />
<test-names>
<test-name value="openai configuration" />
</test-names>
<method v="2">
<option name="NpmBeforeRunTask" enabled="true">
<package-json value="$PROJECT_DIR$/packages/openai-adapters/package.json" />
<command value="run" />
<scripts>
<script value="build" />
</scripts>
<node-interpreter value="project" />
<envs />
</option>
</method>
</configuration>
</component>

View File

@ -3,44 +3,49 @@
## Table of Contents
- [Contributing to Continue](#contributing-to-continue)
- [Table of Contents](#table-of-contents)
- [Table of Contents](#table-of-contents)
- [❤️ Ways to Contribute](#-ways-to-contribute)
- [👋 Continue Contribution Ideas](#-continue-contribution-ideas)
- [🐛 Report Bugs](#-report-bugs)
- [✨ Suggest Enhancements](#-suggest-enhancements)
- [📖 Updating / Improving Documentation](#-updating--improving-documentation)
- [Running the Documentation Server Locally](#running-the-documentation-server-locally)
- [Method 1: NPM Script](#method-1-npm-script)
- [Method 2: VS Code Task](#method-2-vs-code-task)
- [🧑‍💻 Contributing Code](#-contributing-code)
- [Environment Setup](#environment-setup)
- [Pre-requisites](#pre-requisites)
- [Fork the Continue Repository](#fork-the-continue-repository)
- [VS Code](#vs-code)
- [Debugging](#debugging)
- [JetBrains](#jetbrains)
- [Our Git Workflow](#our-git-workflow)
- [Development workflow](#development-workflow)
- [Formatting](#formatting)
- [Testing](#testing)
- [Review Process](#review-process)
- [Getting help](#getting-help)
- [Contribtuing new LLM Providers/Models](#contribtuing-new-llm-providersmodels)
- [Adding an LLM Provider](#adding-an-llm-provider)
- [Adding Models](#adding-models)
- [📐 Continue Architecture](#-continue-architecture)
- [Continue VS Code Extension](#continue-vs-code-extension)
- [Continue JetBrains Extension](#continue-jetbrains-extension)
- [👋 Continue Contribution Ideas](#-continue-contribution-ideas)
- [🐛 Report Bugs](#-report-bugs)
- [✨ Suggest Enhancements](#-suggest-enhancements)
- [📖 Updating / Improving Documentation](#-updating--improving-documentation)
- [Running the Documentation Server Locally](#running-the-documentation-server-locally)
- [Method 1: NPM Script](#method-1-npm-script)
- [Method 2: VS Code Task](#method-2-vs-code-task)
- [🧑‍💻 Contributing Code](#-contributing-code)
- [Environment Setup](#environment-setup)
- [Pre-requisites](#pre-requisites)
- [Fork the Continue Repository](#fork-the-continue-repository)
- [VS Code](#vs-code)
- [Debugging](#debugging)
- [JetBrains](#jetbrains)
- [Our Git Workflow](#our-git-workflow)
- [Development workflow](#development-workflow)
- [Formatting](#formatting)
- [Testing](#testing)
- [Review Process](#review-process)
- [Getting help](#getting-help)
- [Contribtuing new LLM Providers/Models](#contribtuing-new-llm-providersmodels)
- [Adding an LLM Provider](#adding-an-llm-provider)
- [Adding Models](#adding-models)
- [📐 Continue Architecture](#-continue-architecture)
- [Continue VS Code Extension](#continue-vs-code-extension)
- [Continue JetBrains Extension](#continue-jetbrains-extension)
- [Contibutor License Agreement](#contributor-license-agreement-cla)
# ❤️ Ways to Contribute
## 👋 Continue Contribution Ideas
[This GitHub project board](https://github.com/orgs/continuedev/projects/2) is a list of ideas for how you can contribute to Continue. These aren't the only ways, but are a great starting point if you are new to the project. You can also browse the list of [good first issues](https://github.com/continuedev/continue/issues?q=is:issue%20state:open%20label:good-first-issue).
[This GitHub project board](https://github.com/orgs/continuedev/projects/2) is a list of ideas for how you can
contribute to Continue. These aren't the only ways, but are a great starting point if you are new to the project. You
can also browse the list
of [good first issues](https://github.com/continuedev/continue/issues?q=is:issue%20state:open%20label:good-first-issue).
## 🐛 Report Bugs
If you find a bug, please [create an issue](https://github.com/continuedev/continue/issues) to report it! A great bug report includes:
If you find a bug, please [create an issue](https://github.com/continuedev/continue/issues) to report it! A great bug
report includes:
- A description of the bug
- Steps to reproduce
@ -50,19 +55,22 @@ If you find a bug, please [create an issue](https://github.com/continuedev/conti
## ✨ Suggest Enhancements
Continue is quickly adding features, and we'd love to hear which are the most important to you. The best ways to suggest an enhancement are:
Continue is quickly adding features, and we'd love to hear which are the most important to you. The best ways to suggest
an enhancement are:
- Create an issue
- First, check whether a similar proposal has already been made
- If not, [create an issue](https://github.com/continuedev/continue/issues)
- Please describe the enhancement in as much detail as you can, and why it would be useful
- First, check whether a similar proposal has already been made
- If not, [create an issue](https://github.com/continuedev/continue/issues)
- Please describe the enhancement in as much detail as you can, and why it would be useful
- Join the [Continue Discord](https://discord.gg/NWtdYexhMs) and tell us about your idea in the `#feedback` channel
## 📖 Updating / Improving Documentation
Continue is continuously improving, but a feature isn't complete until it is reflected in the documentation! If you see something out-of-date or missing, you can help by clicking "Edit this page" at the bottom of any page on [docs.continue.dev](https://docs.continue.dev).
Continue is continuously improving, but a feature isn't complete until it is reflected in the documentation! If you see
something out-of-date or missing, you can help by clicking "Edit this page" at the bottom of any page
on [docs.continue.dev](https://docs.continue.dev).
### Running the Documentation Server Locally
@ -70,7 +78,8 @@ You can run the documentation server locally using either of the following metho
#### Method 1: NPM Script
1. Open your terminal and navigate to the `docs` subdirectory of the project. The `docusaurus.config.js` file you'll see there is a sign you're in the right place.
1. Open your terminal and navigate to the `docs` subdirectory of the project. The `docusaurus.config.js` file you'll see
there is a sign you're in the right place.
2. Run the following command to install the necessary dependencies for the documentation server:
@ -92,17 +101,22 @@ You can run the documentation server locally using either of the following metho
3. Look for the `docs:start` task and select it.
This will start a local server and you can see the documentation rendered in your default browser, typically accessible at `http://localhost:3000`.
This will start a local server and you can see the documentation rendered in your default browser, typically accessible
at `http://localhost:3000`.
## 🧑‍💻 Contributing Code
We welcome contributions from developers of all experience levels - from first-time contributors to seasoned open source maintainers. While we aim to maintain high standards for reliability and maintainability, our goal is to keep the process as welcoming and straightforward as possible.
We welcome contributions from developers of all experience levels - from first-time contributors to seasoned open source
maintainers. While we aim to maintain high standards for reliability and maintainability, our goal is to keep the
process as welcoming and straightforward as possible.
### Environment Setup
#### Pre-requisites
You should have Node.js version 20.19.0 (LTS) or higher installed. You can get it on [nodejs.org](https://nodejs.org/en/download) or, if you are using NVM (Node Version Manager), you can set the correct version of Node.js for this project by running the following command in the root of the project:
You should have Node.js version 20.19.0 (LTS) or higher installed. You can get it
on [nodejs.org](https://nodejs.org/en/download) or, if you are using NVM (Node Version Manager), you can set the correct
version of Node.js for this project by running the following command in the root of the project:
```bash
nvm use
@ -114,32 +128,39 @@ nvm use
2. Clone your forked repository to your local machine. Use: `git clone https://github.com/YOUR_USERNAME/continue.git`
3. Navigate to the cloned directory and make sure you are on the main branch. Create your feature/fix branch from there, like so: `git checkout -b 123-my-feature-branch`
3. Navigate to the cloned directory and make sure you are on the main branch. Create your feature/fix branch from there,
like so: `git checkout -b 123-my-feature-branch`
4. Send your pull request to the main branch.
#### VS Code
1. Open the VS Code command pallet (`cmd/ctrl+shift+p`) and select `Tasks: Run Task` and then select `install-all-dependencies`
1. Open the VS Code command pallet (`cmd/ctrl+shift+p`) and select `Tasks: Run Task` and then select
`install-all-dependencies`
2. Start debugging:
1. Switch to Run and Debug view
2. Select `Launch extension` from drop down
3. Hit play button
4. This will start the extension in debug mode and open a new VS Code window with it installed
1. The new VS Code window with the extension is referred to as the _Host VS Code_
2. The window you started debugging from is referred to as the _Main VS Code_
1. Switch to Run and Debug view
2. Select `Launch extension` from drop down
3. Hit play button
4. This will start the extension in debug mode and open a new VS Code window with it installed
1. The new VS Code window with the extension is referred to as the _Host VS Code_
2. The window you started debugging from is referred to as the _Main VS Code_
3. To package the extension, run `npm run package` in the `extensions/vscode` directory, select `Tasks: Run Task` and then select `vscode-extension:package`. This will generate `extensions/vscode/build/continue-{VERSION}.vsix`, which you can install by right-clicking and selecting "Install Extension VSIX".
3. To package the extension, run `npm run package` in the `extensions/vscode` directory, select `Tasks: Run Task` and
then select `vscode-extension:package`. This will generate `extensions/vscode/build/continue-{VERSION}.vsix`, which
you can install by right-clicking and selecting "Install Extension VSIX".
##### Debugging
**Breakpoints** can be used in both the `core` and `extensions/vscode` folders while debugging, but are not currently supported inside of `gui` code.
**Breakpoints** can be used in both the `core` and `extensions/vscode` folders while debugging, but are not currently
supported inside of `gui` code.
**Hot-reloading** is enabled with Vite, so if you make any changes to the `gui`, they should be automatically reflected without rebuilding. In some cases, you may need to refresh the _Host VS Code_ window to see the changes.
**Hot-reloading** is enabled with Vite, so if you make any changes to the `gui`, they should be automatically reflected
without rebuilding. In some cases, you may need to refresh the _Host VS Code_ window to see the changes.
Similarly, any changes to `core` or `extensions/vscode` will be automatically included by just reloading the _Host VS Code_ window with cmd/ctrl+shift+p "Reload Window".
Similarly, any changes to `core` or `extensions/vscode` will be automatically included by just reloading the _Host VS
Code_ window with cmd/ctrl+shift+p "Reload Window".
#### JetBrains
@ -147,13 +168,20 @@ See [`intellij/CONTRIBUTING.md`](./extensions/intellij/CONTRIBUTING.md) for the
### Our Git Workflow
We keep a single permanent branch: `main`. When we are ready to create a "pre-release" version, we create a tag on the `main` branch titled `v1.1.x-vscode`, which automatically triggers the workflow in [preview.yaml](./.github/workflows/preview.yaml), which builds and releases a version of the VS Code extension. When a release has been sufficiently tested, we will create a new release titled `v1.0x-vscode`, triggering a similar workflow in [main.yaml](./.github/workflows/main.yaml), which will build and release a main release of the VS Code extension. Any hotfixes can be made by creating a feature branch from the tag for the release in question. This workflow is well explained by <http://releaseflow.org>.
We keep a single permanent branch: `main`. When we are ready to create a "pre-release" version, we create a tag on the
`main` branch titled `v1.1.x-vscode`, which automatically triggers the workflow
in [preview.yaml](./.github/workflows/preview.yaml), which builds and releases a version of the VS Code extension. When
a release has been sufficiently tested, we will create a new release titled `v1.0x-vscode`, triggering a similar
workflow in [main.yaml](./.github/workflows/main.yaml), which will build and release a main release of the VS Code
extension. Any hotfixes can be made by creating a feature branch from the tag for the release in question. This workflow
is well explained by <http://releaseflow.org>.
### What makes a good PR?
To keep the Continue codebase clean and maintainable, we expect the following from our own team and all contributors:
- Open a new issue or comment on an existing one before writing code. This ensures your proposed changes are aligned with the project direction
- Open a new issue or comment on an existing one before writing code. This ensures your proposed changes are aligned
with the project direction
- Keep changes focused. Multiple unrelated fixes should be opened as separate PRs
- Write or update tests for new functionality
- Update relevant documentation in the `docs` folder
@ -161,18 +189,22 @@ To keep the Continue codebase clean and maintainable, we expect the following fr
### Formatting
Continue uses [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to format JavaScript/TypeScript. Please install the Prettier extension in VS Code and enable "Format on Save" in your settings.
Continue uses [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to format
JavaScript/TypeScript. Please install the Prettier extension in VS Code and enable "Format on Save" in your settings.
### Testing
We have a mix of unit, functional, and e2e test suites, with a primary focus on functional testing. These tests run on each pull request. If your PR causes one of these tests to fail, we will ask that you to resolve the issue before we merge.
We have a mix of unit, functional, and e2e test suites, with a primary focus on functional testing. These tests run on
each pull request. If your PR causes one of these tests to fail, we will ask that you to resolve the issue before we
merge.
When contributing, please update or create the appropriate tests to help verify the correctness of your implementaiton.
### Review Process
- **Initial Review** - A maintainer will be assigned as primary reviewer
- **Feedback Loop** - The reviewer may request changes. We value your work, but also want to ensure the code is maintainable and follows our patterns.
- **Feedback Loop** - The reviewer may request changes. We value your work, but also want to ensure the code is
maintainable and follows our patterns.
- **Approval & Merge** - Once the PR is approved, it will be merged into the `main` branch.
### Getting help
@ -183,19 +215,53 @@ Join [#contribute on Discord](https://discord.gg/vapESyrFmJ) to engage with main
### Adding an LLM Provider
Continue has support for more than a dozen different LLM "providers", making it easy to use models running on OpenAI, Ollama, Together, LM Studio, Msty, and more. You can find all of the existing providers [here](https://github.com/continuedev/continue/tree/main/core/llm/llms), and if you see one missing, you can add it with the following steps:
Continue has support for more than a dozen different LLM "providers", making it easy to use models running on OpenAI,
Ollama, Together, LM Studio, Msty, and more. You can find all of the existing
providers [here](https://github.com/continuedev/continue/tree/main/core/llm/llms), and if you see one missing, you can
add it with the following steps:
1. Create a new file in the `core/llm/llms` directory. The name of the file should be the name of the provider, and it should export a class that extends `BaseLLM`. This class should contain the following minimal implementation. We recommend viewing pre-existing providers for more details. The [LlamaCpp Provider](./core/llm/llms/LlamaCpp.ts) is a good simple example.
1. Create a new file in the `core/llm/llms` directory. The name of the file should be the name of the provider, and it
should export a class that extends `BaseLLM`. This class should contain the following minimal implementation. We
recommend viewing pre-existing providers for more details. The [LlamaCpp Provider](./core/llm/llms/LlamaCpp.ts) is a
good simple example.
2. Add your provider to the `LLMs` array in [core/llm/llms/index.ts](./core/llm/llms/index.ts).
3. If your provider supports images, add it to the `PROVIDER_SUPPORTS_IMAGES` array in [core/llm/autodetect.ts](./core/llm/autodetect.ts).
4. Add a documentation page for your provider in [`docs/docs/customize/model-providers/more`](./docs/docs/customize/model-providers/more). This should show an example of configuring your provider in `config.yaml` and explain what options are available.
3. If your provider supports images, add it to the `PROVIDER_SUPPORTS_IMAGES` array
in [core/llm/autodetect.ts](./core/llm/autodetect.ts).
4. Add a documentation page for your provider in [
`docs/docs/customize/model-providers/more`](./docs/docs/customize/model-providers/more). This should show an example
of configuring your provider in `config.yaml` and explain what options are available.
### Adding Models
While any model that works with a supported provider can be used with Continue, we keep a list of recommended models that can be automatically configured from the UI or `config.json`. The following files should be updated when adding a model:
While any model that works with a supported provider can be used with Continue, we keep a list of recommended models
that can be automatically configured from the UI or `config.json`. The following files should be updated when adding a
model:
- [AddNewModel page](./gui/src/pages/AddNewModel/configs/) - This directory defines which model options are shown in the side bar model selection UI. To add a new model:
1. Add a `ModelPackage` entry for the model into [configs/models.ts](./gui/src/pages/AddNewModel/configs/models.ts), following the lead of the many examples near the top of the file
2. Add the model within its provider's array to [configs/providers.ts](./gui/src/pages/AddNewModel/configs/providers.ts) (add provider if needed)
- LLM Providers: Since many providers use their own custom strings to identify models, you'll have to add the translation from Continue's model name (the one you added to `index.d.ts`) and the model string for each of these providers: [Ollama](./core/llm/llms/Ollama.ts), [Together](./core/llm/llms/Together.ts), and [Replicate](./core/llm/llms/Replicate.ts). You can find their full model lists here: [Ollama](https://ollama.ai/library), [Together](https://docs.together.ai/docs/inference-models), [Replicate](https://replicate.com/collections/streaming-language-models).
- [Prompt Templates](./core/llm/autodetect.ts) - In this file you'll find the `autodetectTemplateType` function. Make sure that for the model name you just added, this function returns the correct template type. This is assuming that the chat template for that model is already built in Continue. If not, you will have to add the template type and corresponding edit and chat templates.
- [AddNewModel page](./gui/src/pages/AddNewModel/configs/) - This directory defines which model options are shown in the
side bar model selection UI. To add a new model:
1. Add a `ModelPackage` entry for the model into [configs/models.ts](./gui/src/pages/AddNewModel/configs/models.ts),
following the lead of the many examples near the top of the file
2. Add the model within its provider's array
to [configs/providers.ts](./gui/src/pages/AddNewModel/configs/providers.ts) (add provider if needed)
- LLM Providers: Since many providers use their own custom strings to identify models, you'll have to add the
translation from Continue's model name (the one you added to `index.d.ts`) and the model string for each of these
providers: [Ollama](./core/llm/llms/Ollama.ts), [Together](./core/llm/llms/Together.ts),
and [Replicate](./core/llm/llms/Replicate.ts). You can find their full model lists
here: [Ollama](https://ollama.ai/library), [Together](https://docs.together.ai/docs/inference-models), [Replicate](https://replicate.com/collections/streaming-language-models).
- [Prompt Templates](./core/llm/autodetect.ts) - In this file you'll find the `autodetectTemplateType` function. Make
sure that for the model name you just added, this function returns the correct template type. This is assuming that
the chat template for that model is already built in Continue. If not, you will have to add the template type and
corresponding edit and chat templates.
## Contributor License Agreement (CLA)
We require all contributors to accept the CLA and have made it as easy as commenting on your PR:
1. Open your pull request.
2. Paste the following comment (or reply `recheck` if youve signed before):
```text
I have read the CLA Document and I hereby sign the CLA
```
3. The CLAAssistant bot records your signature in the repo and marks the status check as passed.

View File

@ -33,6 +33,7 @@
"@types/jest": "^29.5.12",
"@types/uuid": "^9.0.8",
"@vercel/ncc": "^0.38.1",
"cross-env": "^7.0.3",
"esbuild": "0.19.11",
"jest": "^29.7.0",
"pkg": "^5.8.1",
@ -3196,6 +3197,25 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",

View File

@ -33,6 +33,7 @@
"@types/jest": "^29.5.12",
"@types/uuid": "^9.0.8",
"@vercel/ncc": "^0.38.1",
"cross-env": "^7.0.3",
"esbuild": "0.19.11",
"jest": "^29.7.0",
"pkg": "^5.8.1",

View File

@ -2,6 +2,7 @@ import { jest } from "@jest/globals";
import * as lineStream from "./lineStream";
// eslint-disable-next-line max-lines-per-function
describe("lineStream", () => {
let mockFullStop: jest.Mock;
@ -204,6 +205,33 @@ describe("lineStream", () => {
});
describe("filterCodeBlockLines", () => {
it("should handle unfenced code", async () => {
const linesGenerator = await getLineGenerator(["const x = 5;"]);
const result = lineStream.filterCodeBlockLines(linesGenerator);
const filteredLines = await getFilteredLines(result);
expect(filteredLines).toEqual(["const x = 5;"]);
});
it("should handle unfenced code with a code block", async () => {
const linesGenerator = await getLineGenerator(["const x = 5;","```bash","ls -al","```"]);
const result = lineStream.filterCodeBlockLines(linesGenerator);
const filteredLines = await getFilteredLines(result);
expect(filteredLines).toEqual(["const x = 5;","```bash","ls -al","```"]);
});
it("should handle unfenced code with two code blocks", async () => {
const linesGenerator = await getLineGenerator(["const x = 5;","```bash","ls -al","```","```bash","ls -al","```"]);
const result = lineStream.filterCodeBlockLines(linesGenerator);
const filteredLines = await getFilteredLines(result);
expect(filteredLines).toEqual(["const x = 5;","```bash","ls -al","```","```bash","ls -al","```"]);
});
it("should remove lines before the first valid line", async () => {
const linesGenerator = await getLineGenerator(["```ts", "const x = 5;"]);
@ -213,7 +241,59 @@ describe("lineStream", () => {
expect(filteredLines).toEqual(["const x = 5;"]);
});
it.todo("Need some sample inputs to properly test this");
it("should remove outer blocks", async () => {
const linesGenerator = await getLineGenerator(["```ts", "const x = 5;","```"]);
const result = lineStream.filterCodeBlockLines(linesGenerator);
const filteredLines = await getFilteredLines(result);
expect(filteredLines).toEqual(["const x = 5;"]);
});
it("should leave inner blocks intact", async () => {
const linesGenerator = await getLineGenerator(["```md", "const x = 5;", "```bash","ls -al","```","```"]);
const result = lineStream.filterCodeBlockLines(linesGenerator);
const filteredLines = await getFilteredLines(result);
expect(filteredLines).toEqual(["const x = 5;","```bash","ls -al","```"]);
});
it("should handle included inner ticks", async () => {
const linesGenerator = await getLineGenerator(["```md", "const x = 5;", "```bash","echo ```test```","```","```"]);
const result = lineStream.filterCodeBlockLines(linesGenerator);
const filteredLines = await getFilteredLines(result);
expect(filteredLines).toEqual(["const x = 5;","```bash","echo ```test```","```"]);
});
it("should leave single inner blocks intact but not return trailing text", async () => {
const linesGenerator = await getLineGenerator(["```md", "const x = 5;", "```bash","ls -al","```","```","trailing text"]);
const result = lineStream.filterCodeBlockLines(linesGenerator);
const filteredLines = await getFilteredLines(result);
expect(filteredLines).toEqual(["const x = 5;","```bash","ls -al","```"]);
});
it("should leave double inner blocks intact but not return trailing text", async () => {
const linesGenerator = await getLineGenerator(["```md", "const x = 5;", "```bash","ls -al","```","const y = 10;","```sh","echo `hello world`","```","```","trailing text"]);
const result = lineStream.filterCodeBlockLines(linesGenerator);
const filteredLines = await getFilteredLines(result);
expect(filteredLines).toEqual(["const x = 5;","```bash","ls -al","```","const y = 10;","```sh","echo `hello world`","```"]);
});
it("should leave inner blocks intact but not return trailing or leading text", async () => {
const linesGenerator = await getLineGenerator(["[CODE]", "const x = 5;", "```bash","ls -al","```","[/CODE]","trailing text"]);
const result = lineStream.filterCodeBlockLines(linesGenerator);
const filteredLines = await getFilteredLines(result);
expect(filteredLines).toEqual(["const x = 5;","```bash","ls -al","```"]);
});
});
describe("filterEnglishLinesAtStart", () => {

View File

@ -55,8 +55,8 @@ function shouldChangeLineAndStop(line: string): string | undefined {
return line;
}
if (line.includes(CODE_START_BLOCK)) {
return line.split(CODE_START_BLOCK)[0].trimEnd();
if (line.includes(CODE_STOP_BLOCK)) {
return line.split(CODE_STOP_BLOCK)[0].trimEnd();
}
return undefined;
@ -71,9 +71,9 @@ function isUselessLine(line: string): boolean {
return hasUselessLine || trimmed.startsWith("// end");
}
export const USELESS_LINES = ["", "```"];
export const USELESS_LINES = [""];
export const CODE_KEYWORDS_ENDING_IN_SEMICOLON = ["def"];
export const CODE_START_BLOCK = "[/CODE]";
export const CODE_STOP_BLOCK = "[/CODE]";
export const BRACKET_ENDING_CHARS = [")", "]", "}", ";"];
export const PREFIXES_TO_SKIP = ["<COMPLETION>"];
export const LINES_TO_STOP_AT = ["# End of file.", "<STOP EDITING HERE"];
@ -343,34 +343,48 @@ export async function* removeTrailingWhitespace(
* 4. Yields processed lines that are part of the actual code block content.
*/
export async function* filterCodeBlockLines(rawLines: LineStream): LineStream {
let seenValidLine = false;
let waitingToSeeIfLineIsLast = undefined;
let seenFirstFence = false;
// nestCount is set to 1 when the entire code block is wrapped with ``` or START blocks. It's then incremented
// when an inner code block is discovered to avoid exiting the function prematurly. The function will exit early
// when all blocks are matched. When no outer fence is discovered the function will always continue to the end.
let nestCount = 0;
for await (const line of rawLines) {
// Filter out starting ```
if (!seenValidLine) {
if (!seenFirstFence) {
if (shouldRemoveLineBeforeStart(line)) {
continue;
// Filter out starting ``` or START block
continue
}
seenValidLine = true;
// Regardless of a fence or START block start tracking the nesting level
seenFirstFence = true;
nestCount = 1;
}
// Filter out ending ```
if (typeof waitingToSeeIfLineIsLast !== "undefined") {
yield waitingToSeeIfLineIsLast;
waitingToSeeIfLineIsLast = undefined;
}
const changedEndLine = shouldChangeLineAndStop(line);
if (typeof changedEndLine === "string") {
yield changedEndLine;
return;
}
if (line.startsWith("```")) {
waitingToSeeIfLineIsLast = line;
} else {
yield line;
if (nestCount > 0) {
// Inside a block including the outer block
const changedEndLine = shouldChangeLineAndStop(line);
if (typeof changedEndLine === "string") {
// Ending a block with just backticks (```) or STOP
nestCount--;
if (nestCount === 0) {
// if we are closing the outer block then exit early
// only exit early if the outer block was started with a block
// it it was text, we will never exit early
return;
} else {
// otherwise just yield the line
yield line;
}
} else if (line.startsWith("```")) {
// Going into a nested codeblock
nestCount++;
yield line;
} else {
// otherwise just yield the line
yield line;
}
}
}
}

View File

@ -71,6 +71,21 @@ const qwenCoderFimTemplate: AutocompleteTemplate = {
},
};
const seedCoderFimTemplate: AutocompleteTemplate = {
template: "<[fim-prefix]>{{{prefix}}}<[fim-suffix]>{{{suffix}}}<[fim-middle]>",
completionOptions: {
stop: [
"<[end▁of▁sentence]>",
"<[fim-prefix]>",
"<[fim-middle]>",
"<[fim-suffix]>",
"<[PAD▁TOKEN]>",
"<[SEP▁TOKEN]>",
"<[begin▁of▁sentence]>",
],
},
};
const codestralFimTemplate: AutocompleteTemplate = {
template: "[SUFFIX]{{{suffix}}}[PREFIX]{{{prefix}}}",
completionOptions: {
@ -426,6 +441,10 @@ export function getTemplateForModel(model: string): AutocompleteTemplate {
return qwenCoderFimTemplate;
}
if (lowerCaseModel.includes("seed") && lowerCaseModel.includes("coder")) {
return seedCoderFimTemplate;
}
if (
lowerCaseModel.includes("starcoder") ||
lowerCaseModel.includes("star-coder") ||

View File

@ -255,7 +255,7 @@ export class ConfigHandler {
if (currentProfile) {
this.globalContext.update("lastSelectedProfileForWorkspace", {
...selectedProfiles,
[profileKey]: selectedProfiles.id ?? null,
[profileKey]: currentProfile.profileDescription.id,
});
}

21
core/index.d.ts vendored
View File

@ -458,6 +458,7 @@ export interface ChatHistoryItem {
toolCallState?: ToolCallState;
isGatheringContext?: boolean;
reasoning?: Reasoning;
appliedRules?: RuleWithSource[];
}
export interface LLMFullCompletionOptions extends BaseCompletionOptions {
@ -1220,7 +1221,7 @@ export type ApplyStateStatus =
| "done" // All changes have been applied, awaiting user to accept/reject
| "closed"; // All changes have been applied. Note that for new files, we immediately set the status to "closed"
export interface UpdateApplyStatePayload {
export interface ApplyState {
streamId: string;
status?: ApplyStateStatus;
numDiffs?: number;
@ -1518,17 +1519,19 @@ export interface TerminalOptions {
waitForCompletion?: boolean;
}
export type RuleSource =
| "default-chat"
| "default-agent"
| "model-chat-options"
| "model-agent-options"
| "rules-block"
| "json-systemMessage"
| ".continuerules";
export interface RuleWithSource {
name?: string;
slug?: string;
source:
| "default-chat"
| "default-agent"
| "model-chat-options"
| "model-agent-options"
| "rules-block"
| "json-systemMessage"
| ".continuerules";
source: RuleSource;
globs?: string | string[];
rule: string;
description?: string;

View File

@ -7,7 +7,7 @@ import {
getQueryForFile,
} from "../util/treeSitter";
import { DatabaseConnection, SqliteDb, tagToString } from "./refreshIndex";
import { DatabaseConnection, SqliteDb } from "./refreshIndex";
import {
IndexResultType,
MarkCompleteCallback,
@ -29,6 +29,7 @@ import {
getLastNUriRelativePathParts,
getUriPathBasename,
} from "../util/uri";
import { tagToString } from "./utils";
type SnippetChunk = ChunkWithoutID & { title: string; signature: string };

View File

@ -3,13 +3,14 @@ import { RETRIEVAL_PARAMS } from "../util/parameters";
import { getUriPathBasename } from "../util/uri";
import { ChunkCodebaseIndex } from "./chunk/ChunkCodebaseIndex";
import { DatabaseConnection, SqliteDb, tagToString } from "./refreshIndex";
import { DatabaseConnection, SqliteDb } from "./refreshIndex";
import {
IndexResultType,
MarkCompleteCallback,
RefreshIndexResults,
type CodebaseIndex,
} from "./types";
import { tagToString } from "./utils";
export interface RetrieveConfig {
tags: BranchAndDir[];
@ -97,11 +98,14 @@ export class FullTextSearchCodebaseIndex implements CodebaseIndex {
// Delete
for (const item of results.del) {
await db.run(`
await db.run(
`
DELETE FROM fts WHERE rowid IN (
SELECT id FROM fts_metadata WHERE path = ? AND cacheKey = ?
)
`,[item.path, item.cacheKey]);
`,
[item.path, item.cacheKey],
);
await db.run("DELETE FROM fts_metadata WHERE path = ? AND cacheKey = ?", [
item.path,
item.cacheKey,

View File

@ -14,7 +14,7 @@ import { getUriPathBasename } from "../util/uri";
import { basicChunker } from "./chunk/basic.js";
import { chunkDocument, shouldChunk } from "./chunk/chunk.js";
import { DatabaseConnection, SqliteDb, tagToString } from "./refreshIndex.js";
import { DatabaseConnection, SqliteDb } from "./refreshIndex.js";
import {
CodebaseIndex,
IndexResultType,
@ -24,6 +24,7 @@ import {
} from "./types";
import type * as LanceType from "vectordb";
import { tagToString } from "./utils";
interface LanceDbRow {
uuid: string;

View File

@ -4,7 +4,7 @@ import { RunResult } from "sqlite3";
import { IContinueServerClient } from "../../continueServer/interface.js";
import { Chunk, IndexTag, IndexingProgressUpdate } from "../../index.js";
import { DatabaseConnection, SqliteDb, tagToString } from "../refreshIndex.js";
import { DatabaseConnection, SqliteDb } from "../refreshIndex.js";
import {
IndexResultType,
MarkCompleteCallback,
@ -13,8 +13,9 @@ import {
type CodebaseIndex,
} from "../types.js";
import { chunkDocument, shouldChunk } from "./chunk.js";
import { getUriPathBasename } from "../../util/uri.js";
import { tagToString } from "../utils.js";
import { chunkDocument, shouldChunk } from "./chunk.js";
export class ChunkCodebaseIndex implements CodebaseIndex {
relativeExpectedTime: number = 1;

View File

@ -18,10 +18,6 @@ import {
export type DatabaseConnection = Database<sqlite3.Database>;
export function tagToString(tag: IndexTag): string {
return `${tag.directory}::${tag.branch}::${tag.artifactId}`;
}
export class SqliteDb {
static db: DatabaseConnection | null = null;

View File

@ -3,11 +3,11 @@ import { jest } from "@jest/globals";
import { IndexTag } from "../..";
import { IContinueServerClient } from "../../continueServer/interface";
import { ChunkCodebaseIndex } from "../chunk/ChunkCodebaseIndex";
import { tagToString } from "../refreshIndex";
import { CodebaseIndex, RefreshIndexResults } from "../types";
import { testIde } from "../../test/fixtures";
import { addToTestDir, TEST_DIR } from "../../test/testDir";
import { tagToString } from "../utils";
export const mockFilename = "test.py";
export const mockPathAndCacheKey = {

View File

@ -0,0 +1,58 @@
import { IndexTag } from "..";
import { tagToString } from "./utils";
test("tagToString returns full tag string when under length limit", () => {
const tag: IndexTag = {
directory: "/normal/path/to/repo",
branch: "main",
artifactId: "12345",
};
expect(tagToString(tag)).toBe("/normal/path/to/repo::main::12345");
});
test("tagToString truncates beginning of directory when path is too long", () => {
// Create a very long directory path that exceeds MAX_DIR_LENGTH (200)
const longPrefix = "/very/long/path/that/will/be/truncated/";
const importantSuffix = "/user/important-project/src/feature";
const longPath = longPrefix + "x".repeat(200) + importantSuffix;
const tag: IndexTag = {
directory: longPath,
branch: "feature-branch",
artifactId: "67890",
};
const result = tagToString(tag);
// The result should keep the important suffix part
expect(result).toContain(importantSuffix);
// The result should NOT contain the beginning of the path
expect(result).not.toContain(longPrefix);
// The result should include the branch and artifactId
expect(result).toContain("::feature-branch::67890");
// The result should be within the MAX_TABLE_NAME_LENGTH limit (240)
expect(result.length).toBeLessThanOrEqual(240);
});
test("tagToString preserves branch and artifactId exactly, even when truncating", () => {
const longPath = "/a".repeat(300); // Much longer than MAX_DIR_LENGTH
const tag: IndexTag = {
directory: longPath,
branch: "release-v2.0",
artifactId: "build-123",
};
const result = tagToString(tag);
// Should contain the exact branch and artifactId
expect(result).toContain("::release-v2.0::build-123");
// Should contain the end of the path
expect(result).toContain("/a/a/a");
// Should not contain the full original path (it should be truncated)
expect(result.length).toBeLessThan(
longPath.length + "::release-v2.0::build-123".length,
);
// The result should be within the MAX_TABLE_NAME_LENGTH limit
expect(result.length).toBeLessThanOrEqual(240);
});

45
core/indexing/utils.ts Normal file
View File

@ -0,0 +1,45 @@
import { IndexTag } from "..";
// Maximum length for table names to stay under OS filename limits
const MAX_TABLE_NAME_LENGTH = 240;
// Leave room for branch and artifactId
const MAX_DIR_LENGTH = 200;
/**
* Converts an IndexTag to a string representation, safely handling long paths.
*
* The string is used as a table name and identifier in various places, so it needs
* to stay under OS filename length limits (typically 255 chars). This is especially
* important for dev containers where the directory path can be very long due to
* containing container configuration.
*
* The format is: "{directory}::{branch}::{artifactId}"
*
* To handle long paths:
* 1. First tries the full string - most backwards compatible
* 2. If too long, truncates directory from the beginning to maintain uniqueness
* (since final parts of paths are more unique than prefixes)
* 3. Finally ensures entire string stays under MAX_TABLE_NAME_LENGTH for OS compatibility
*
* @param tag The tag containing directory, branch, and artifactId
* @returns A string representation safe for use as a table name
*/
export function tagToString(tag: IndexTag): string {
const result = `${tag.directory}::${tag.branch}::${tag.artifactId}`;
if (result.length <= MAX_TABLE_NAME_LENGTH) {
return result;
}
// Truncate from the beginning of directory path to preserve the more unique end parts
const dir =
tag.directory.length > MAX_DIR_LENGTH
? tag.directory.slice(tag.directory.length - MAX_DIR_LENGTH)
: tag.directory;
return `${dir}::${tag.branch}::${tag.artifactId}`.slice(
0,
MAX_TABLE_NAME_LENGTH,
);
}

View File

@ -230,6 +230,7 @@ describe("LLM", () => {
testFim: true,
skip: false,
testToolCall: true,
timeout: 60000,
},
);
testLLM(

View File

@ -23,6 +23,29 @@ const matchesGlobs = (
return false;
};
/**
* Filters rules that apply to the given message
*/
export const getApplicableRules = (
userMessage: UserChatMessage | ToolResultChatMessage | undefined,
rules: RuleWithSource[],
): RuleWithSource[] => {
const filePathsFromMessage = userMessage
? extractPathsFromCodeBlocks(renderChatMessage(userMessage))
: [];
return rules.filter((rule) => {
// A rule is active if it has no globs (applies to all files)
// or if at least one file path matches its globs
const hasNoGlobs = !rule.globs;
const matchesAnyFilePath = filePathsFromMessage.some((path) =>
matchesGlobs(path, rule.globs),
);
return hasNoGlobs || matchesAnyFilePath;
});
};
export const getSystemMessageWithRules = ({
baseSystemMessage,
userMessage,
@ -32,23 +55,11 @@ export const getSystemMessageWithRules = ({
userMessage: UserChatMessage | ToolResultChatMessage | undefined;
rules: RuleWithSource[];
}) => {
const filePathsFromMessage = userMessage
? extractPathsFromCodeBlocks(renderChatMessage(userMessage))
: [];
const applicableRules = getApplicableRules(userMessage, rules);
let systemMessage = baseSystemMessage ?? "";
for (const rule of rules) {
// A rule is active if it has no globs (applies to all files)
// or if at least one file path matches its globs
const hasNoGlobs = !rule.globs;
const matchesAnyFilePath = filePathsFromMessage.some((path) =>
matchesGlobs(path, rule.globs),
);
if (hasNoGlobs || matchesAnyFilePath) {
systemMessage += `\n\n${rule.rule}`;
}
for (const rule of applicableRules) {
systemMessage += `\n\n${rule.rule}`;
}
return systemMessage;

View File

@ -165,4 +165,71 @@ export const PROVIDER_TOOL_SUPPORT: Record<
)
return true;
},
openrouter: (model) => {
// https://openrouter.ai/models?fmt=cards&supported_parameters=tools
if (
["vision", "math", "guard", "mistrallite", "mistral-openorca"].some(
(part) => model.toLowerCase().includes(part),
)
) {
return false;
}
const supportedPrefixes = [
"openai/gpt-3.5",
"openai/gpt-4",
"openai/o1",
"openai/o3",
"openai/o4",
"anthropic/claude-3",
"microsoft/phi-3",
"google/gemini-flash-1.5",
"google/gemini-2",
"google/gemini-pro",
"x-ai/grok",
"qwen/qwen3",
"qwen/qwen-",
"cohere/command-r",
"ai21/jamba-1.6",
"mistralai/mistral",
"mistralai/ministral",
"mistralai/codestral",
"mistralai/mixtral",
"mistral/ministral",
"mistralai/pixtral",
"meta-llama/llama-3.3",
"amazon/nova",
"deepseek/deepseek-r1",
"deepseek/deepseek-chat",
"meta-llama/llama-4",
"all-hands/openhands-lm-32b",
];
for (const prefix of supportedPrefixes) {
if (model.toLowerCase().startsWith(prefix)) {
return true;
}
}
const specificModels = [
"qwen/qwq-32b",
"qwen/qwen-2.5-72b-instruct",
"meta-llama/llama-3.2-3b-instruct",
"meta-llama/llama-3-8b-instruct",
"meta-llama/llama-3-70b-instruct",
"arcee-ai/caller-large",
"nousresearch/hermes-3-llama-3.1-70b",
];
for (const model of specificModels) {
if (model.toLowerCase() === model) {
return true;
}
}
const supportedContains = ["llama-3.1"];
for (const model of supportedContains) {
if (model.toLowerCase().includes(model)) {
return true;
}
}
},
};

View File

@ -15266,9 +15266,9 @@
}
},
"node_modules/undici": {
"version": "6.21.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
"integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==",
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"license": "MIT",
"engines": {
"node": ">=18.17"

View File

@ -2,7 +2,7 @@ import { ToIdeFromWebviewOrCoreProtocol } from "./ide";
import { ToWebviewFromIdeOrCoreProtocol } from "./webview";
import {
UpdateApplyStatePayload,
ApplyState,
SetCodeToEditPayload,
HighlightedCodePayload,
MessageContent,
@ -81,7 +81,7 @@ export type ToWebviewFromIdeProtocol = ToWebviewFromIdeOrCoreProtocol & {
incrementFtc: [undefined, void];
openOnboardingCard: [undefined, void];
applyCodeFromChat: [undefined, void];
updateApplyState: [UpdateApplyStatePayload, void];
updateApplyState: [ApplyState, void];
exitEditMode: [undefined, void];
focusEdit: [undefined, void];
};

View File

@ -29,11 +29,12 @@ async function callHttpTool(
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(`Failed to call tool: ${url}`);
throw new Error(`Failed to call tool at ${url}:\n${JSON.stringify(data)}`);
}
const data = await response.json();
return data.output;
}
@ -84,7 +85,7 @@ async function callToolFromUri(
});
if (response.isError === true) {
throw new Error(`Failed to call tool: ${toolName}`);
throw new Error(JSON.stringify(response.content));
}
const contextItems: ContextItem[] = [];
@ -130,6 +131,37 @@ async function callToolFromUri(
}
}
async function callBuiltInTool(
functionName: string,
args: any,
extras: ToolExtras,
): Promise<ContextItem[]> {
switch (functionName) {
case BuiltInToolNames.ReadFile:
return await readFileImpl(args, extras);
case BuiltInToolNames.CreateNewFile:
return await createNewFileImpl(args, extras);
case BuiltInToolNames.GrepSearch:
return await grepSearchImpl(args, extras);
case BuiltInToolNames.FileGlobSearch:
return await fileGlobSearchImpl(args, extras);
case BuiltInToolNames.RunTerminalCommand:
return await runTerminalCommandImpl(args, extras);
case BuiltInToolNames.SearchWeb:
return await searchWebImpl(args, extras);
case BuiltInToolNames.ViewDiff:
return await viewDiffImpl(args, extras);
case BuiltInToolNames.LSTool:
return await lsToolImpl(args, extras);
case BuiltInToolNames.ReadCurrentlyOpenFile:
return await readCurrentlyOpenFileImpl(args, extras);
case BuiltInToolNames.CreateRuleBlock:
return await createRuleBlockImpl(args, extras);
default:
throw new Error(`Tool "${functionName}" not found`);
}
}
// Handles calls for core/non-client tools
// Returns an error context item if the tool call fails
// Note: Edit tool is handled on client
@ -141,44 +173,11 @@ export async function callTool(
contextItems: ContextItem[];
errorMessage: string | undefined;
}> {
const args = JSON.parse(callArgs || "{}");
const uri = tool.uri ?? tool.function.name;
try {
let contextItems: ContextItem[] = [];
switch (uri) {
case BuiltInToolNames.ReadFile:
contextItems = await readFileImpl(args, extras);
break;
case BuiltInToolNames.CreateNewFile:
contextItems = await createNewFileImpl(args, extras);
break;
case BuiltInToolNames.GrepSearch:
contextItems = await grepSearchImpl(args, extras);
break;
case BuiltInToolNames.FileGlobSearch:
contextItems = await fileGlobSearchImpl(args, extras);
break;
case BuiltInToolNames.RunTerminalCommand:
contextItems = await runTerminalCommandImpl(args, extras);
break;
case BuiltInToolNames.SearchWeb:
contextItems = await searchWebImpl(args, extras);
break;
case BuiltInToolNames.ViewDiff:
contextItems = await viewDiffImpl(args, extras);
break;
case BuiltInToolNames.LSTool:
contextItems = await lsToolImpl(args, extras);
break;
case BuiltInToolNames.ReadCurrentlyOpenFile:
contextItems = await readCurrentlyOpenFileImpl(args, extras);
break;
case BuiltInToolNames.CreateRuleBlock:
contextItems = await createRuleBlockImpl(args, extras);
break;
default:
contextItems = await callToolFromUri(uri, args, extras);
}
const args = JSON.parse(callArgs || "{}");
const contextItems = tool.uri
? await callToolFromUri(tool.uri, args, extras)
: await callBuiltInTool(tool.function.name, args, extras);
if (tool.faviconUrl) {
contextItems.forEach((item) => {
item.icon = tool.faviconUrl;

48
docs/docs/CLA.md Normal file
View File

@ -0,0 +1,48 @@
# Individual Contributor License Agreement (v1.0, Continue)
_Based on the Apache Software Foundation Individual CLA v 2.2._
By commenting **“I have read the CLA Document and I hereby sign the CLA”**
on a Pull Request, **you (“Contributor”) agree to the following terms** for any
past and future “Contributions” submitted to **Continue (the “Project”)**.
---
## 1. Definitions
- **“Contribution”** any original work of authorship submitted to the Project
(code, documentation, designs, etc.).
- **“You” / “Your”** the individual (or legal entity) posting the acceptance
comment.
## 2. Copyright License
You grant **Continue Dev, Inc.** and all recipients of software distributed by the
Project a perpetual, worldwide, nonexclusive, royaltyfree, irrevocable
license to reproduce, prepare derivative works of, publicly display, publicly
perform, sublicense, and distribute Your Contributions and derivative works.
## 3. Patent License
You grant **Continue Dev, Inc.** and all recipients of the Project a perpetual,
worldwide, nonexclusive, royaltyfree, irrevocable (except as below) patent
license to make, have made, use, sell, offer to sell, import, and otherwise
transfer Your Contributions alone or in combination with the Project.
If any entity brings patent litigation alleging that the Project or a
Contribution infringes a patent, the patent licenses granted by You to that
entity under this CLA terminate.
## 4. Representations
1. You are legally entitled to grant the licenses above.
2. Each Contribution is either Your original creation or You have authority to
submit it under this CLA.
3. Your Contributions are provided **“AS IS”** without warranties of any kind.
4. You will notify the Project if any statement above becomes inaccurate.
## 5. Miscellany
This Agreement is governed by the laws of the **State of California**, USA,
excluding its conflictoflaws rules. If any provision is held unenforceable,
the remaining provisions remain in force.

View File

@ -81,6 +81,12 @@ Blocks:
You can find many examples of each of these block types on
the [Continue Explore Page](https://hub.continue.dev/explore/models)
:::info
Local blocks utilizing mustache notation for secrets (`${{ secrets.SECRET_NAME }}`) can read secret values:
- globally, from a `.env` located in the global `.continue` folder (`~/.continue/.env`)
- per-workspace, from a `.env` file located at the root of the current workspace.
:::
### Inputs
Blocks can be passed user inputs, including hub secrets and raw text values. To create a block that has an input, use
@ -107,7 +113,7 @@ models:
TEMP: 0.9
```
Note that hub secrets can be passed as inputs, using the a similar mustache format: `secrets.SECRET_NAME`.
Note that hub secrets can be passed as inputs, using a similar mustache format: `secrets.SECRET_NAME`.
### Overrides
@ -200,7 +206,6 @@ chat, editing, and summarizing.
- `topP`: The cumulative probability for nucleus sampling.
- `topK`: Maximum number of tokens considered at each step.
- `stop`: An array of stop tokens that will terminate the completion.
- `n`: Number of completions to generate.
- `reasoning`: Boolean to enable thinking/reasoning for Anthropic Claude 3.7+ models.
- `reasoningBudgetTokens`: Budget tokens for thinking/reasoning in Anthropic Claude 3.7+ models.
- `requestOptions`: HTTP request options specific to the model.

View File

@ -7,8 +7,6 @@ keywords: [reload, delete, manually, logs, server, console]
import TabItem from "@theme/TabItem";
import Tabs from "@theme/Tabs";
The Continue VS Code extension is currently in beta, and the JetBrains extension is in Alpha. If you are having trouble, please follow the steps below.
1. [Check the logs](#check-the-logs)
2. [Try the latest pre-release](#download-the-latest-pre-release)
3. [Download an older version](#download-an-older-version)

View File

@ -65,6 +65,10 @@ const config = {
],
],
scripts: [
'!function(){var e,t,n;e="7aa28ed11570734",t=function(){Reo.init({clientID:"7aa28ed11570734"})},(n=document.createElement("script")).src="https://static.reo.dev/"+e+"/reo.js",n.defer=!0,n.onload=t,document.head.appendChild(n)}();',
],
themeConfig:
/** @type {import("@docusaurus/preset-classic").ThemeConfig} */
({

View File

@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start Core Dev Server (CE)" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="cross-env 'PROJECT_DIR=&quot;$PROJECT_DIR$&quot;' node core-dev-server.js" />
<option name="SCRIPT_TEXT" value="npx cross-env 'PROJECT_DIR=&quot;$PROJECT_DIR$&quot;' node core-dev-server.js" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="" />
<option name="SCRIPT_OPTIONS" value="" />

View File

@ -3,13 +3,13 @@ pluginGroup=com.github.continuedev.continueintellijextension
pluginName=continue-intellij-extension
pluginRepositoryUrl=https://github.com/continuedev/continue
# SemVer format -> https://semver.org
pluginVersion=1.0.17
pluginVersion=1.0.18
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild=223
# IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension
platformType=IC
platformVersion=2022.3.3
#org.gradle.java.home=/opt/homebrew/opt/openjdk@17
# org.gradle.java.home=/opt/homebrew/opt/openjdk@17
#platformVersion = LATEST-EAP-SNAPSHOT
# Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html
# Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22

View File

@ -1,6 +1,5 @@
package com.github.continuedev.continueintellijextension.activities
import IntelliJIDE
import com.intellij.openapi.fileEditor.FileEditorManagerListener
import com.github.continuedev.continueintellijextension.auth.AuthListener
import com.github.continuedev.continueintellijextension.auth.ContinueAuthService

View File

@ -1,5 +1,7 @@
package com.github.continuedev.continueintellijextension.`continue`
import com.github.continuedev.continueintellijextension.ApplyState
import com.github.continuedev.continueintellijextension.ApplyStateStatus
import com.github.continuedev.continueintellijextension.IDE
import com.github.continuedev.continueintellijextension.ToastType
import com.github.continuedev.continueintellijextension.editor.DiffStreamHandler
@ -27,26 +29,54 @@ class ApplyToFileHandler(
) {
suspend fun handleApplyToFile() {
// Notify webview that we're starting to stream
notifyStreamStarted()
if (editorUtils == null) {
ide.showToast(ToastType.ERROR, "No active editor to apply edits to")
notifyStreamClosed()
return
}
if (editorUtils.isDocumentEmpty()) {
editorUtils.insertTextIntoEmptyDocument(params.text)
notifyStreamClosed()
return
}
// Get the LLM configuration for applying edits
val llm = fetchApplyLLMConfig() ?: run {
ide.showToast(ToastType.ERROR, "Failed to fetch model configuration")
notifyStreamClosed()
return
}
setupAndStreamDiffs(editorUtils, llm)
}
private fun notifyStreamStarted() {
sendApplyStateUpdate(ApplyStateStatus.STREAMING)
}
private fun notifyStreamClosed(numDiffs: Int? = 0) {
sendApplyStateUpdate(ApplyStateStatus.CLOSED, numDiffs)
}
private fun sendApplyStateUpdate(
status: ApplyStateStatus,
numDiffs: Int? = null
) {
val payload = ApplyState(
streamId = params.streamId,
status = status,
numDiffs = numDiffs,
filepath = params.filepath,
fileContent = params.text,
toolCallId = params.toolCallId.toString()
)
continuePluginService.sendToWebview("updateApplyState", payload)
}
private suspend fun fetchApplyLLMConfig(): Any? {
return try {

View File

@ -62,7 +62,7 @@ class DiffManager(private val project: Project) : DumbAware {
file.createNewFile()
}
file.writeText(replacement)
openDiffWindow(URI(filepath).toString(), file.toURI().toString(), stepIndex)
openDiffWindow(UriUtils.parseUri(filepath).toString(), file.toURI().toString(), stepIndex)
}
private fun cleanUpFile(file2: String) {
@ -80,7 +80,8 @@ class DiffManager(private val project: Project) : DumbAware {
val diffInfo = diffInfoMap[file] ?: return
// Write contents to original file
val virtualFile = LocalFileSystem.getInstance().findFileByPath(URI(diffInfo.originalFilepath).path) ?: return
val virtualFile =
LocalFileSystem.getInstance().findFileByPath(UriUtils.parseUri(diffInfo.originalFilepath).path) ?: return
val document = FileDocumentManager.getInstance().getDocument(virtualFile) ?: return
WriteCommandAction.runWriteCommandAction(project) {
document.setText(File(file).readText())
@ -119,8 +120,8 @@ class DiffManager(private val project: Project) : DumbAware {
lastFile2 = file2
// Create a DiffContent for each of the texts you want to compare
val content1: DiffContent = DiffContentFactory.getInstance().create(File(URI(file1)).readText())
val content2: DiffContent = DiffContentFactory.getInstance().create(File(URI(file2)).readText())
val content1: DiffContent = DiffContentFactory.getInstance().create(UriUtils.uriToFile(file1).readText())
val content2: DiffContent = DiffContentFactory.getInstance().create(UriUtils.uriToFile(file2).readText())
// Create a SimpleDiffRequest and populate it with the DiffContents and titles
val diffRequest = SimpleDiffRequest("Continue Diff", content1, content2, "Old", "New")

View File

@ -7,9 +7,7 @@ import com.intellij.openapi.project.guessProjectDir
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.net.URI
class GitService(
private val project: Project,
@ -52,7 +50,7 @@ class GitService(
} else {
ProcessBuilder("git", "diff", "--cached")
}
builder.directory(File(URI(workspaceDir)))
builder.directory(UriUtils.uriToFile(workspaceDir))
val process = withContext(Dispatchers.IO) {
builder.start()
}

View File

@ -1,6 +1,5 @@
package com.github.continuedev.continueintellijextension.`continue`
import IntelliJIDE
import com.github.continuedev.continueintellijextension.*
import com.github.continuedev.continueintellijextension.activities.ContinuePluginDisposable
import com.github.continuedev.continueintellijextension.activities.showTutorial
@ -42,6 +41,7 @@ class IdeProtocolClient(
*
* See this thread for details: https://github.com/continuedev/continue/issues/4098#issuecomment-2854865310
*/
@OptIn(ExperimentalCoroutinesApi::class)
private val limitedDispatcher = Dispatchers.IO.limitedParallelism(4)
init {

View File

@ -1,3 +1,5 @@
package com.github.continuedev.continueintellijextension.`continue`
import com.github.continuedev.continueintellijextension.*
import com.github.continuedev.continueintellijextension.constants.getContinueGlobalPath
import com.github.continuedev.continueintellijextension.constants.ContinueConstants
@ -167,7 +169,7 @@ class IntelliJIDE(
// Find any .continuerc.json files
for (file in contents) {
if (file.endsWith(".continuerc.json")) {
val fileContent = File(URI(file)).readText()
val fileContent = UriUtils.uriToFile(file).readText()
configs.add(fileContent)
}
}
@ -178,12 +180,12 @@ class IntelliJIDE(
}
override suspend fun fileExists(filepath: String): Boolean {
val file = File(URI(filepath))
val file = UriUtils.uriToFile(filepath)
return file.exists()
}
override suspend fun writeFile(path: String, contents: String) {
val file = File(URI(path))
val file = UriUtils.uriToFile(path)
file.parentFile?.mkdirs()
file.writeText(contents)
}
@ -201,7 +203,7 @@ class IntelliJIDE(
override suspend fun openFile(path: String) {
// Convert URI path to absolute file path
val filePath = File(URI(path)).absolutePath
val filePath = UriUtils.uriToFile(path).absolutePath
// Find the file using the absolute path
val file = withContext(Dispatchers.IO) {
LocalFileSystem.getInstance().refreshAndFindFileByPath(filePath)
@ -216,7 +218,7 @@ class IntelliJIDE(
override suspend fun openUrl(url: String) {
withContext(Dispatchers.IO) {
Desktop.browse(java.net.URI(url))
Desktop.browse(URI(url))
}
}
@ -226,7 +228,8 @@ class IntelliJIDE(
override suspend fun saveFile(filepath: String) {
ApplicationManager.getApplication().invokeLater {
val file = LocalFileSystem.getInstance().findFileByPath(URI(filepath).path) ?: return@invokeLater
val file =
LocalFileSystem.getInstance().findFileByPath(UriUtils.parseUri(filepath).path) ?: return@invokeLater
val fileDocumentManager = FileDocumentManager.getInstance()
val document = fileDocumentManager.getDocument(file)
@ -239,7 +242,7 @@ class IntelliJIDE(
override suspend fun readFile(filepath: String): String {
return try {
val content = ApplicationManager.getApplication().runReadAction<String?> {
val virtualFile = LocalFileSystem.getInstance().findFileByPath(URI(filepath).path)
val virtualFile = LocalFileSystem.getInstance().findFileByPath(UriUtils.parseUri(filepath).path)
if (virtualFile != null && FileDocumentManager.getInstance().isFileModified(virtualFile)) {
return@runReadAction FileDocumentManager.getInstance().getDocument(virtualFile)?.text
}
@ -249,7 +252,7 @@ class IntelliJIDE(
if (content != null) {
content
} else {
val file = File(URI(filepath))
val file = UriUtils.uriToFile(filepath)
if (!file.exists() || file.isDirectory) return ""
withContext(Dispatchers.IO) {
FileInputStream(file).use { fis ->
@ -455,7 +458,7 @@ class IntelliJIDE(
return withContext(Dispatchers.IO) {
try {
val builder = ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")
builder.directory(File(URI(dir)))
builder.directory(UriUtils.uriToFile(dir))
val process = builder.start()
val reader = BufferedReader(InputStreamReader(process.inputStream))
val output = reader.readLine()
@ -485,7 +488,7 @@ class IntelliJIDE(
override suspend fun getRepoName(dir: String): String? {
return withContext(Dispatchers.IO) {
val directory = File(URI(dir))
val directory = UriUtils.uriToFile(dir)
val targetDir = if (directory.isFile) directory.parentFile else directory
val builder = ProcessBuilder("git", "config", "--get", "remote.origin.url")
builder.directory(targetDir)
@ -549,7 +552,7 @@ class IntelliJIDE(
override suspend fun getGitRootPath(dir: String): String? {
return withContext(Dispatchers.IO) {
val builder = ProcessBuilder("git", "rev-parse", "--show-toplevel")
builder.directory(File(URI(dir)))
builder.directory(UriUtils.uriToFile(dir))
val process = builder.start()
val reader = BufferedReader(InputStreamReader(process.inputStream))
@ -560,7 +563,7 @@ class IntelliJIDE(
}
override suspend fun listDir(dir: String): List<List<Any>> {
val files = File(URI(dir)).listFiles()?.map {
val files = UriUtils.uriToFile(dir).listFiles()?.map {
listOf(it.name, if (it.isDirectory) FileType.DIRECTORY.value else FileType.FILE.value)
} ?: emptyList()
@ -569,7 +572,7 @@ class IntelliJIDE(
override suspend fun getFileStats(files: List<String>): Map<String, FileStats> {
return files.associateWith { file ->
FileStats(File(URI(file)).lastModified(), File(URI(file)).length())
FileStats(UriUtils.uriToFile(file).lastModified(), UriUtils.uriToFile(file).length())
}
}
@ -587,7 +590,7 @@ class IntelliJIDE(
}
private fun setFileOpen(filepath: String, open: Boolean = true) {
val file = LocalFileSystem.getInstance().findFileByPath(URI(filepath).path)
val file = LocalFileSystem.getInstance().findFileByPath(UriUtils.uriToFile(filepath).path)
file?.let {
if (open) {

View File

@ -0,0 +1,39 @@
package com.github.continuedev.continueintellijextension.`continue`
import java.io.File
import java.net.URI
/**
* Utility class for URI operations
*/
object UriUtils {
/**
* Parses a URI string into a URI object, handling special cases for Windows file paths
*/
fun parseUri(uri: String): URI {
try {
// Remove query parameters if present
val uriStr = uri.substringBefore("?")
// Handle Windows file paths with authority component
if (uriStr.startsWith("file://") && !uriStr.startsWith("file:///")) {
val path = uriStr.substringAfter("file://")
return URI("file:///$path")
}
// Standard URI handling for other cases
val uriWithoutQuery = URI(uriStr)
return uriWithoutQuery
} catch (e: Exception) {
println("Error parsing URI: $uri ${e.message}")
throw Exception("Invalid URI: $uri ${e.message}")
}
}
/**
* Converts a URI string to a File object
*/
fun uriToFile(uri: String): File {
return File(parseUri(uri))
}
}

View File

@ -1,6 +1,5 @@
package com.github.continuedev.continueintellijextension.services
import IntelliJIDE
import com.github.continuedev.continueintellijextension.`continue`.CoreMessenger
import com.github.continuedev.continueintellijextension.`continue`.CoreMessengerManager
import com.github.continuedev.continueintellijextension.`continue`.DiffManager

View File

@ -1,6 +1,5 @@
package com.github.continuedev.continueintellijextension
import com.github.continuedev.continueintellijextension.editor.RangeInFileWithContents
import com.google.gson.JsonElement
enum class ToastType(val value: String) {
@ -57,6 +56,12 @@ data class RangeInFile(
val range: Range
)
data class RangeInFileWithContents(
val filepath: String,
val range: Range,
val contents: String
)
data class ControlPlaneSessionInfo(
val accessToken: String,
val account: Account
@ -84,6 +89,7 @@ data class ContinueRcJson(
val mergeBehavior: ConfigMergeType
)
interface IDE {
suspend fun getIdeInfo(): IdeInfo
@ -208,17 +214,25 @@ data class AcceptRejectDiff(val accepted: Boolean, val stepIndex: Int)
data class DeleteAtIndex(val index: Int)
enum class ApplyStateStatus(val status: String) {
NOT_STARTED("not-started"),
STREAMING("streaming"),
DONE("done"),
CLOSED("closed");
enum class ApplyStateStatus {
NOT_STARTED, // Apply state created but not necessarily streaming
STREAMING, // Changes are being applied to the file
DONE, // All changes have been applied, awaiting user to accept/reject
CLOSED; // All changes have been applied. Note that for new files, we immediately set the status to "closed"
companion object {
fun toString(status: ApplyStateStatus): String = when (status) {
NOT_STARTED -> "not-started"
STREAMING -> "streaming"
DONE -> "done"
CLOSED -> "closed"
}
}
}
data class UpdateApplyStatePayload(
data class ApplyState(
val streamId: String,
val status: String,
val status: ApplyStateStatus? = null,
val numDiffs: Int? = null,
val filepath: String? = null,
val fileContent: String? = null,
@ -231,17 +245,3 @@ data class HighlightedCodePayload(
val shouldRun: Boolean? = null
)
data class StreamDiffLinesPayload(
val prefix: String,
val highlighted: String,
val suffix: String,
val input: String,
val language: String?,
val modelTitle: String?,
val includeRulesInSystemMessage: Boolean
)
data class AcceptOrRejectDiffPayload(
val filepath: String,
val streamId: String? = null
)

View File

@ -2,7 +2,7 @@ package com.github.continuedev.continueintellijextension.unit
import com.github.continuedev.continueintellijextension.ApplyStateStatus
import com.github.continuedev.continueintellijextension.IDE
import com.github.continuedev.continueintellijextension.UpdateApplyStatePayload
import com.github.continuedev.continueintellijextension.ApplyState
import com.github.continuedev.continueintellijextension.`continue`.ApplyToFileHandler
import com.github.continuedev.continueintellijextension.`continue`.CoreMessenger
import com.github.continuedev.continueintellijextension.editor.DiffStreamService
@ -26,6 +26,7 @@ class ApplyToFileHandlerTest {
private val mockEditorUtils = mockk<EditorUtils>(relaxed = true)
private val mockDiffStreamService = mockk<DiffStreamService>(relaxed = true)
private val mockEditor = mockk<Editor>(relaxed = true)
private val mockCoreMessenger = mockk<CoreMessenger>(relaxed = true)
// Test subject
private lateinit var handler: ApplyToFileHandler
@ -42,6 +43,7 @@ class ApplyToFileHandlerTest {
fun setUp() {
// Common setup
every { mockEditorUtils.editor } returns mockEditor
every { mockContinuePluginService.coreMessenger } returns mockCoreMessenger
// Create the handler with mocked dependencies
handler = ApplyToFileHandler(
@ -65,5 +67,28 @@ class ApplyToFileHandlerTest {
// Then
verify { mockEditorUtils.insertTextIntoEmptyDocument(testParams.text) }
verify(exactly = 0) { mockDiffStreamService.register(any(), any()) } // Ensure no diff streaming happened
// Verify notifications sent
verify {
mockContinuePluginService.sendToWebview(
eq("updateApplyState"),
withArg { payload ->
assert(payload is ApplyState)
assert((payload as ApplyState).status == ApplyStateStatus.STREAMING)
},
any()
)
}
verify {
mockContinuePluginService.sendToWebview(
eq("updateApplyState"),
withArg { payload ->
assert(payload is ApplyState)
assert((payload as ApplyState).status == ApplyStateStatus.CLOSED)
},
any()
)
}
}
}

View File

@ -0,0 +1,66 @@
package com.github.continuedev.continueintellijextension.unit
import com.github.continuedev.continueintellijextension.`continue`.UriUtils
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import java.io.File
import kotlin.test.assertEquals
class UriUtilsTest {
@Test
fun testUriToFile() {
val uri = "file:///path/to/file"
val file = UriUtils.uriToFile(uri)
assertEquals(File("/path/to/file"), file)
}
@Disabled("Not working")
@Test
fun testUriToFileWithWindowsPath() {
val uri = "file:///C:/path/to/file"
val file = UriUtils.uriToFile(uri)
assertEquals(File("C:/path/to/file"), file)
}
@Test
fun shouldHandleAuthorityComponent() {
val uri = "file://C:/path/to/file"
val file = UriUtils.uriToFile(uri)
assertEquals(File("/C:/path/to/file"), file)
}
@Test
fun testUriToFileWithSpaces() {
val uri = "file:///path/to/file%20with%20spaces"
val file = UriUtils.uriToFile(uri)
assertEquals(File("/path/to/file with spaces"), file)
}
@Test
fun testUriToFileWithSpecialCharacters() {
val uri = "file:///path/to/file%23with%25special%26chars"
val file = UriUtils.uriToFile(uri)
assertEquals(File("/path/to/file#with%special&chars"), file)
}
@Test
fun testUriToFileWithQueryParams() {
val uri = "file:///path/to/file?param=value"
val file = UriUtils.uriToFile(uri)
assertEquals(File("/path/to/file"), file)
}
@Test
fun testUriToFileWithWSLPath() {
val uri = "file:///wsl$/Ubuntu/home/user/file.txt"
val file = UriUtils.uriToFile(uri)
assertEquals(File("/wsl$/Ubuntu/home/user/file.txt"), file)
}
@Test
fun testUriToFileWithWSLLocalhostPath() {
val uri = "file:///wsl.localhost/Ubuntu/home/user/file.txt"
val file = UriUtils.uriToFile(uri)
assertEquals(File("/wsl.localhost/Ubuntu/home/user/file.txt"), file)
}
}

View File

@ -46,7 +46,7 @@ export class GUISelectors {
}
public static getToolCallStatusMessage(view: WebView) {
return SelectorUtils.getElementByDataTestId(view, "toggle-div-title");
return SelectorUtils.getElementByDataTestId(view, "tool-call-title");
}
public static getToolButton(view: WebView) {
@ -89,6 +89,14 @@ export class GUISelectors {
);
}
public static getRulesPeek(view: WebView) {
return SelectorUtils.getElementByDataTestId(view, "rules-peek");
}
public static getFirstRulesPeekItem(view: WebView) {
return SelectorUtils.getElementByDataTestId(view, "rules-peek-item");
}
public static getNthHistoryTableRow(view: WebView, index: number) {
return SelectorUtils.getElementByDataTestId(view, `history-row-${index}`);
}

View File

@ -245,6 +245,47 @@ describe("GUI Test", () => {
await GUIActions.selectModeFromDropdown(view, "Agent");
});
it("should display rules peek and show rule details", async () => {
// Send a message to trigger the model response
const [messageInput] = await GUISelectors.getMessageInputFields(view);
await messageInput.sendKeys("Hello");
await messageInput.sendKeys(Key.ENTER);
// Wait for the response to appear
await TestUtils.waitForSuccess(() =>
GUISelectors.getThreadMessageByText(view, "I'm going to call a tool:"),
);
// Verify that "1 rule" text appears
const rulesPeek = await TestUtils.waitForSuccess(() =>
GUISelectors.getRulesPeek(view),
);
const rulesPeekText = await rulesPeek.getText();
expect(rulesPeekText).to.include("1 rule");
// Click on the rules peek to expand it
await rulesPeek.click();
// Wait for the rule details to appear
const ruleItem = await TestUtils.waitForSuccess(() =>
GUISelectors.getFirstRulesPeekItem(view),
);
await TestUtils.waitForSuccess(async () => {
const text = await ruleItem.getText();
if (!text || text.trim() === "") {
throw new Error("Rule item text is empty");
}
return ruleItem;
});
// Verify the rule content
const ruleItemText = await ruleItem.getText();
expect(ruleItemText).to.include("Assistant rule");
expect(ruleItemText).to.include("Always applied");
expect(ruleItemText).to.include("TEST_SYS_MSG");
}).timeout(DEFAULT_TIMEOUT.MD);
it("should render tool call", async () => {
const [messageInput] = await GUISelectors.getMessageInputFields(view);
await messageInput.sendKeys("Hello");
@ -258,7 +299,9 @@ describe("GUI Test", () => {
expect(await statusMessage.getText()).contain(
"Continue viewed the git diff",
);
}).timeout(DEFAULT_TIMEOUT.MD);
// wait for 30 seconds, promise
await new Promise((resolve) => setTimeout(resolve, 30000));
}).timeout(DEFAULT_TIMEOUT.MD * 100);
it("should call tool after approval", async () => {
await GUIActions.toggleToolPolicy(view, "builtin_view_diff", 2);

View File

@ -53,7 +53,7 @@
"svg-builder": "^2.0.0",
"systeminformation": "^5.23.7",
"tailwindcss": "^3.3.2",
"undici": "^6.2.0",
"undici": "^6.21.3",
"uuid": "^9.0.1",
"uuidv4": "^6.2.13",
"vectordb": "^0.4.20",
@ -13095,9 +13095,9 @@
"dev": true
},
"node_modules/undici": {
"version": "6.21.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
"integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==",
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"license": "MIT",
"engines": {
"node": ">=18.17"

View File

@ -2,7 +2,7 @@
"name": "continue",
"icon": "media/icon.png",
"author": "Continue Dev, Inc",
"version": "1.1.34",
"version": "1.1.35",
"repository": {
"type": "git",
"url": "https://github.com/continuedev/continue"
@ -704,7 +704,7 @@
"svg-builder": "^2.0.0",
"systeminformation": "^5.23.7",
"tailwindcss": "^3.3.2",
"undici": "^6.2.0",
"undici": "^6.21.3",
"uuid": "^9.0.1",
"uuidv4": "^6.2.13",
"vectordb": "^0.4.20",

View File

@ -9,16 +9,16 @@ import {
indexDecorationType,
} from "./decorations";
import type { UpdateApplyStatePayload, DiffLine } from "core";
import type { ApplyState, DiffLine } from "core";
import type { VerticalDiffCodeLens } from "./manager";
export interface VerticalDiffHandlerOptions {
input?: string;
instant?: boolean;
onStatusUpdate: (
status?: UpdateApplyStatePayload["status"],
numDiffs?: UpdateApplyStatePayload["numDiffs"],
fileContent?: UpdateApplyStatePayload["fileContent"],
status?: ApplyState["status"],
numDiffs?: ApplyState["numDiffs"],
fileContent?: ApplyState["fileContent"],
) => void;
}

View File

@ -3,7 +3,7 @@
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "eslint-plugin-react"],
"parserOptions": {
"project": "gui/tsconfig.json"
"project": ["tsconfig.json", "tsconfig.node.json"]
},
"extends": [
// "eslint:recommended",

View File

@ -12,7 +12,8 @@
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:watch": "vitest"
"test:watch": "vitest",
"lint": "eslint --ext ts"
},
"dependencies": {
"@continuedev/config-yaml": "file:../packages/config-yaml",

View File

@ -1,22 +1,18 @@
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { UpdateApplyStatePayload } from "core";
import { ToIdeFromWebviewProtocol } from "core/protocol/ideWebview";
import { ApplyState } from "core";
import { useContext } from "react";
import { IdeMessengerContext } from "../context/IdeMessenger";
import { getMetaKeyLabel } from "../util";
import { useFontSize } from "./ui/font";
export interface AcceptRejectAllButtonsProps {
applyStates: UpdateApplyStatePayload[];
applyStates: ApplyState[];
onAcceptOrReject?: (outcome: AcceptOrRejectOutcome) => void;
}
export type AcceptOrRejectOutcome = keyof Pick<
ToIdeFromWebviewProtocol,
"acceptDiff" | "rejectDiff"
>;
export type AcceptOrRejectOutcome = "acceptDiff" | "rejectDiff";
export default function AcceptRejectDiffButtons({
export default function AcceptRejectAllButtons({
applyStates,
onAcceptOrReject,
}: AcceptRejectAllButtonsProps) {
@ -28,7 +24,6 @@ export default function AcceptRejectDiffButtons({
const tinyFont = useFontSize(-3);
async function handleAcceptOrReject(status: AcceptOrRejectOutcome) {
debugger;
for (const { filepath = "", streamId } of pendingApplyStates) {
ideMessenger.post(status, {
filepath,
@ -58,6 +53,7 @@ export default function AcceptRejectDiffButtons({
<div className="flex flex-row items-center gap-1">
<XMarkIcon className="h-4 w-4 text-red-600" />
<span>Reject</span>
<span className="xs:inline-block hidden">All</span>
</div>
<span
@ -77,6 +73,7 @@ export default function AcceptRejectDiffButtons({
<div className="flex flex-row items-center gap-1">
<CheckIcon className="h-4 w-4 text-green-600" />
<span>Accept</span>
<span className="xs:inline-block hidden">All</span>
</div>
<span
className="xs:inline-block hidden text-gray-400"

View File

@ -1,5 +1,5 @@
import { CheckIcon, PlayIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { UpdateApplyStatePayload } from "core";
import { ApplyState } from "core";
import { useEffect, useState } from "react";
import { getMetaKeyLabel } from "../../../util";
import Spinner from "../../gui/Spinner";
@ -9,7 +9,7 @@ import { ToolbarButtonWithTooltip } from "./ToolbarButtonWithTooltip";
interface ApplyActionsProps {
disableManualApply?: boolean;
applyState?: UpdateApplyStatePayload;
applyState?: ApplyState;
onClickAccept: () => void;
onClickReject: () => void;
onClickApply: () => void;

View File

@ -243,7 +243,7 @@ export function StepContainerPreToolbar({
const plural = numLines === 1 ? "" : "s";
if (isGeneratingCodeBlock) {
return (
<span className="text-lightgray inline-flex w-min items-center gap-2">
<span className="text-lightgray inline-flex items-center gap-2 text-right">
{!isExpanded ? `${numLines} line${plural}` : "Generating"}{" "}
<div>
<Spinner />
@ -252,7 +252,7 @@ export function StepContainerPreToolbar({
);
} else {
return (
<span className="text-lightgray inline-flex w-min items-center gap-2">
<span className="text-lightgray inline-flex items-center gap-2 text-right">
{`${numLines} line${plural} pending`}
</span>
);

View File

@ -0,0 +1,65 @@
import { headerIsMarkdown } from './headerIsMarkdown';
describe('headerIsMarkdown', () => {
// Test exact match with common Markdown identifiers
it('should identify exact matches with common Markdown identifiers', () => {
expect(headerIsMarkdown('md')).toBe(true);
expect(headerIsMarkdown('markdown')).toBe(true);
expect(headerIsMarkdown('gfm')).toBe(true);
expect(headerIsMarkdown('github-markdown')).toBe(true);
});
// Test identifiers preceded by a space
it('should identify identifiers preceded by a space', () => {
expect(headerIsMarkdown('lang md')).toBe(true);
expect(headerIsMarkdown('something markdown')).toBe(true);
expect(headerIsMarkdown('language gfm')).toBe(true);
expect(headerIsMarkdown('spec github-markdown')).toBe(true);
});
// Test file extensions
it('should identify file names with markdown extensions', () => {
expect(headerIsMarkdown('example.md')).toBe(true);
expect(headerIsMarkdown('document.markdown')).toBe(true);
expect(headerIsMarkdown('readme.gfm')).toBe(true);
});
// Test more complex cases with extensions
it('should identify file names with markdown extensions followed by other text', () => {
expect(headerIsMarkdown('example.md additional text')).toBe(true);
expect(headerIsMarkdown('document.markdown with some description')).toBe(true);
expect(headerIsMarkdown('readme.gfm v2.0')).toBe(true);
});
// Test non-markdown cases
it('should not identify non-markdown headers', () => {
expect(headerIsMarkdown('javascript')).toBe(false);
expect(headerIsMarkdown('typescript')).toBe(false);
expect(headerIsMarkdown('plain')).toBe(false);
expect(headerIsMarkdown('python')).toBe(false);
});
// Test edge cases
it('should handle edge cases correctly', () => {
expect(headerIsMarkdown('')).toBe(false);
expect(headerIsMarkdown('mdx')).toBe(false); // Similar but not exactly "md"
expect(headerIsMarkdown('readme md')).toBe(true);
expect(headerIsMarkdown('md.js')).toBe(false); // "md" is not the extension
});
// Test case sensitivity
it('should respect case sensitivity', () => {
expect(headerIsMarkdown('MD')).toBe(false);
expect(headerIsMarkdown('MARKDOWN')).toBe(false);
expect(headerIsMarkdown('example.MD')).toBe(false);
expect(headerIsMarkdown('lang MD')).toBe(false);
});
// Test with special characters and spacing
it('should handle special characters and spacing correctly', () => {
expect(headerIsMarkdown(' md')).toBe(true); // Space before "md"
expect(headerIsMarkdown('md ')).toBe(true); // Space after "md"
expect(headerIsMarkdown('hello-md')).toBe(false); // "md" with hyphen prefix
expect(headerIsMarkdown('markdown:')).toBe(false); // "markdown" with suffix
});
});

View File

@ -0,0 +1,26 @@
/**
* Determines if a given header string represents Markdown content.
*
* The function checks various patterns to identify Markdown headers:
* - Exact match with common Markdown identifiers (md, markdown, gfm, github-markdown)
* - Contains these identifiers preceded by a space
* - First word has a file extension of md, markdown, or gfm
*
* @param header - The string to check for Markdown indicators
* @returns True if the header represents Markdown content, false otherwise
*/
export function headerIsMarkdown(header: string): boolean {
return (
header === "md" ||
header === "markdown" ||
header === "gfm" ||
header === "github-markdown" ||
header.includes(" md") ||
header.includes(" markdown") ||
header.includes(" gfm") ||
header.includes(" github-markdown") ||
(header.split(" ")[0]?.split(".").pop() === "md") ||
(header.split(" ")[0]?.split(".").pop() === "markdown") ||
(header.split(" ")[0]?.split(".").pop() === "gfm")
);
}

View File

@ -0,0 +1,105 @@
import { patchNestedMarkdown } from './patchNestedMarkdown';
describe('patchNestedMarkdown', () => {
it('should return unchanged content when no markdown codeblocks are present', () => {
const source = 'Regular text\n```javascript\nconsole.log("hello");\n```';
expect(patchNestedMarkdown(source)).toBe(source);
});
it('should replace backticks with tildes for markdown codeblocks', () => {
const source = '```markdown\n# Header\nSome text\n```';
const expected = '~~~markdown\n# Header\nSome text\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
it('should handle nested codeblocks within markdown blocks', () => {
const source = '```markdown\n# Example\n```js\nconsole.log("nested");\n```\n```';
const expected = '~~~markdown\n# Example\n```js\nconsole.log("nested");\n```\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
it('should handle .md file extension', () => {
const source = '```test.md\nContent\n```';
const expected = '~~~test.md\nContent\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
it('should handle multiple levels of nesting', () => {
const source = '```markdown\n# Doc\n```js\nlet x = "```nested```";\n```\n```';
const expected = '~~~markdown\n# Doc\n```js\nlet x = "```nested```";\n```\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
it('should handle blocks that start with md', () => {
const source = '```md\n# Header\nSome text\n```';
const expected = '~~~md\n# Header\nSome text\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
it('should handle blocks with language specifier followed by md', () => {
const source = '```lang md\n# Header\nSome text\n```';
const expected = '~~~lang md\n# Header\nSome text\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
it('should handle blocks with language specifier followed by markdown', () => {
const source = '```lang markdown\n# Header\nSome text\n```';
const expected = '~~~lang markdown\n# Header\nSome text\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
it('should not replace backticks for non-markdown file extensions', () => {
const source = '```test.js\nContent\n```';
expect(patchNestedMarkdown(source)).toBe(source);
});
it('should check the file extension branch when extension is not md/markdown/gfm', () => {
// This tests the extension check branch with an unrecognized extension
const source = '```test.txt\nContent with md keyword\n```';
expect(patchNestedMarkdown(source)).toBe(source);
});
it('should handle empty file name in code block header', () => {
// This covers the branch where file is empty or undefined
const source = '``` \nSome content\n```';
expect(patchNestedMarkdown(source)).toBe(source);
});
it('should handle file names with no extension', () => {
// This covers the branch where ext might be undefined
const source = '```filename\nContent\n```';
expect(patchNestedMarkdown(source)).toBe(source);
});
it('should correctly identify .markdown extension', () => {
// This specifically tests the ext === "markdown" condition in the extension check
const source = '```example.markdown\n# Some markdown content\n```';
const expected = '~~~example.markdown\n# Some markdown content\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
// GitHub-specific tests
it('should handle gfm language identifier', () => {
const source = '```gfm\n# Header\nSome text\n```';
const expected = '~~~gfm\n# Header\nSome text\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
it('should handle github-markdown language identifier', () => {
const source = '```github-markdown\n# Header\nSome text\n```';
const expected = '~~~github-markdown\n# Header\nSome text\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
it('should handle language specifier followed by gfm', () => {
const source = '```lang gfm\n# Header\nSome text\n```';
const expected = '~~~lang gfm\n# Header\nSome text\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
it('should handle .gfm file extension', () => {
const source = '```example.gfm\n# Some GitHub markdown content\n```';
const expected = '~~~example.gfm\n# Some GitHub markdown content\n~~~';
expect(patchNestedMarkdown(source)).toBe(expected);
});
});

View File

@ -1,47 +1,52 @@
/*
This is a patch for outputing markdown code that contains codeblocks
It notices markdown blocks, keeps track of when that specific block is closed,
It notices markdown blocks (including GitHub-specific variants),
keeps track of when that specific block is closed,
and uses ~~~ instead of ``` for that block
Note, this was benchmarked at sub-millisecond
// TODO support github-specific markdown as well, edge case
*/
import { headerIsMarkdown } from './headerIsMarkdown';
export const patchNestedMarkdown = (source: string): string => {
if (!source.match(/```(\w+\.(md|markdown))/)) return source; // For performance
// const start = Date.now();
// Early return if no markdown codeblock pattern is found (including GitHub variants)
if (!source.match(/```(\w*|.*)(md|markdown|gfm|github-markdown)/)) return source;
let nestCount = 0;
const lines = source.split("\n");
const trimmedLines = lines.map((l) => l.trim());
for (let i = 0; i < trimmedLines.length; i++) {
const line = trimmedLines[i];
if (nestCount) {
if (nestCount > 0) {
// Inside a markdown block
if (line.match(/^`+$/)) {
// Ending a block
if (nestCount === 1) lines[i] = "~~~"; // End of markdown block
// Ending a block with just backticks (```)
nestCount--;
if (nestCount === 0) {
lines[i] = "~~~"; // End of markdown block
}
} else if (line.startsWith("```")) {
// Going into a nested codeblock
nestCount++;
}
} else {
// Enter the markdown block, start tracking nesting
// Not inside a markdown block yet
if (line.startsWith("```")) {
const header = line.replaceAll("`", "");
const file = header.split(" ")[0];
if (file) {
const ext = file.split(".").at(-1);
if (ext === "md" || ext === "markdown") {
nestCount = 1;
lines[i] = lines[i].replaceAll("`", "~"); // Replace backticks with tildes
}
// Check if this is a markdown codeblock using a consolidated approach (including GitHub-specific variants)
const isMarkdown = headerIsMarkdown(header);
if (isMarkdown) {
nestCount = 1;
lines[i] = lines[i].replaceAll("`", "~");
}
}
}
}
const out = lines.join("\n");
// console.log(`patched in ${Date.now() - start}ms`);
return out;
return lines.join("\n");
};

View File

@ -6,15 +6,21 @@ interface ToggleProps {
children: React.ReactNode;
title: React.ReactNode;
icon?: ComponentType<React.SVGProps<SVGSVGElement>>;
testId?: string;
}
function ToggleDiv({ children, title, icon: Icon }: ToggleProps) {
function ToggleDiv({
children,
title,
icon: Icon,
testId = "context-items-peek",
}: ToggleProps) {
const [open, setOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
return (
<div
className={`pl-2 pt-2`}
className={`pl-2`}
style={{
backgroundColor: vscBackground,
}}
@ -24,7 +30,7 @@ function ToggleDiv({ children, title, icon: Icon }: ToggleProps) {
onClick={() => setOpen((prev) => !prev)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
data-testid="context-items-peek"
data-testid={testId}
>
<div className="relative mr-1 h-4 w-4">
{Icon && !isHovered && !open ? (
@ -44,10 +50,7 @@ function ToggleDiv({ children, title, icon: Icon }: ToggleProps) {
</>
)}
</div>
<span
className="ml-1 text-xs text-gray-400 transition-colors duration-200"
data-testid="toggle-div-title"
>
<span className="ml-1 text-xs text-gray-400 transition-colors duration-200">
{title}
</span>
</div>

View File

@ -1,11 +1,12 @@
import { Editor, JSONContent } from "@tiptap/react";
import { ContextItemWithId, InputModifiers } from "core";
import { ContextItemWithId, InputModifiers, RuleWithSource } from "core";
import { useMemo } from "react";
import styled, { keyframes } from "styled-components";
import { defaultBorderRadius, vscBackground } from "..";
import { useAppSelector } from "../../redux/hooks";
import { selectSlashCommandComboBoxInputs } from "../../redux/selectors";
import { ContextItemsPeek } from "./belowMainInput/ContextItemsPeek";
import { RulesPeek } from "./belowMainInput/RulesPeek";
import { ToolbarOptions } from "./InputToolbar";
import { Lump } from "./Lump";
import { TipTapEditor } from "./TipTapEditor";
@ -20,6 +21,7 @@ interface ContinueInputBoxProps {
) => void;
editorState?: JSONContent;
contextItems?: ContextItemWithId[];
appliedRules?: RuleWithSource[];
hidden?: boolean;
inputId: string; // used to keep track of things per input in redux
}
@ -116,6 +118,8 @@ function ContinueInputBox(props: ContinueInputBoxProps) {
}
: {};
const { appliedRules = [], contextItems = [] } = props;
return (
<div
className={`${props.hidden ? "hidden" : ""}`}
@ -143,10 +147,15 @@ function ContinueInputBox(props: ContinueInputBoxProps) {
/>
</GradientBorder>
</div>
<ContextItemsPeek
contextItems={props.contextItems}
isCurrentContextPeek={props.isLastUserInput}
/>
{(appliedRules.length > 0 || contextItems.length > 0) && (
<div className="mt-2 flex flex-col">
<RulesPeek appliedRules={props.appliedRules} />
<ContextItemsPeek
contextItems={props.contextItems}
isCurrentContextPeek={props.isLastUserInput}
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,122 @@
import { DocumentTextIcon, GlobeAltIcon } from "@heroicons/react/24/outline";
import { RuleSource, RuleWithSource } from "core";
import { ComponentType, useMemo, useState } from "react";
import ToggleDiv from "../../ToggleDiv";
interface RulesPeekProps {
appliedRules?: RuleWithSource[];
icon?: ComponentType<React.SVGProps<SVGSVGElement>>;
}
interface RulesPeekItemProps {
rule: RuleWithSource;
}
// Convert technical source to user-friendly text
const getSourceLabel = (source: RuleSource): string => {
switch (source) {
case "default-chat":
return "Default Chat";
case "default-agent":
return "Default Agent";
case "model-chat-options":
return "Model Chat Options";
case "model-agent-options":
return "Model Agent Options";
case "rules-block":
return "Rules Block";
case "json-systemMessage":
return "System Message";
case ".continuerules":
return "Project Rules";
default:
return source;
}
};
export function RulesPeekItem({ rule }: RulesPeekItemProps) {
const isGlobal = !rule.globs;
const [expanded, setExpanded] = useState(false);
// Define maximum length for rule text display
const maxRuleLength = 100;
const isRuleLong = rule.rule.length > maxRuleLength;
// Get the displayed rule text based on expanded state
const displayedRule =
isRuleLong && !expanded
? `${rule.rule.slice(0, maxRuleLength)}...`
: rule.rule;
const toggleExpand = () => {
if (isRuleLong) {
setExpanded(!expanded);
}
};
return (
<div
className={`group mr-2 flex flex-col overflow-hidden rounded px-1.5 py-1 text-xs hover:bg-white/10 ${isRuleLong ? "cursor-pointer hover:text-gray-200" : ""}`}
data-testid="rules-peek-item"
onClick={toggleExpand}
>
<div className="flex w-full items-center">
{isGlobal ? (
<GlobeAltIcon className="mr-2 h-4 w-4 flex-shrink-0 text-gray-400" />
) : (
<DocumentTextIcon className="mr-2 h-4 w-4 flex-shrink-0 text-gray-400" />
)}
<div className="flex min-w-0 flex-1 gap-2 text-xs">
<div className="max-w-[50%] flex-shrink-0 truncate font-medium">
{rule.name || "Assistant rule"}
</div>
<div className="min-w-0 flex-1 overflow-hidden truncate whitespace-nowrap text-xs text-gray-500">
{isGlobal
? "Always applied"
: `Pattern: ${typeof rule.globs === "string" ? rule.globs : Array.isArray(rule.globs) ? rule.globs.join(", ") : ""}`}
</div>
</div>
</div>
<div
className={`mt-1 whitespace-pre-line pl-6 pr-2 text-xs italic text-gray-300`}
title={
isRuleLong ? (expanded ? "Click to collapse" : "Click to expand") : ""
}
>
{displayedRule}
{isRuleLong && (
<span className="ml-1 text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
{expanded ? "(collapse)" : "(expand)"}
</span>
)}
</div>
<div className="mt-1 pl-6 pr-2 text-xs text-gray-500">
Source: {getSourceLabel(rule.source)}
</div>
</div>
);
}
export function RulesPeek({ appliedRules, icon }: RulesPeekProps) {
const rules = useMemo(() => {
return appliedRules ?? [];
}, [appliedRules]);
if (!rules || rules.length === 0) {
return null;
}
return (
<ToggleDiv
icon={icon}
title={`${rules.length} rule${rules.length > 1 ? "s" : ""}`}
testId="rules-peek"
>
{rules.map((rule, idx) => (
<RulesPeekItem key={`rule-${idx}`} rule={rule} />
))}
</ToggleDiv>
);
}

View File

@ -173,17 +173,24 @@ function ModeSelect() {
{mode === "chat" && <CheckIcon className="ml-auto h-3 w-3" />}
</ListboxOption>
<ListboxOption value="agent" disabled={!agentModeSupported}>
<ListboxOption
value="agent"
disabled={!agentModeSupported}
className={"gap-1"}
>
<div className="flex flex-row items-center gap-1.5">
<SparklesIcon className="h-3 w-3" />
<span className="">Agent</span>
</div>
{mode === "agent" && <CheckIcon className="ml-auto h-3 w-3" />}
{!agentModeSupported && <span> (Not supported)</span>}
{agentModeSupported ? (
mode === "agent" && <CheckIcon className="ml-auto h-3 w-3" />
) : (
<span>(Not supported)</span>
)}
</ListboxOption>
<div className="text-lightgray px-2 py-1">
{metaKeyLabel}. for next mode
{`${metaKeyLabel} . for next mode`}
</div>
</ListboxOptions>
</div>

View File

@ -166,7 +166,11 @@ function ModelSelect() {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "'" && isMetaEquivalentKeyPressed(event as any)) {
if (
event.key === "'" &&
isMetaEquivalentKeyPressed(event as any) &&
!event.shiftKey // To prevent collisions w/ assistant toggle logic
) {
if (!selectedProfile) {
return;
}
@ -179,7 +183,7 @@ function ModelSelect() {
if (nextIndex < 0) nextIndex = options.length - 1;
const newModelTitle = options[nextIndex].value;
dispatch(
void dispatch(
updateSelectedModelByRole({
selectedProfile,
role: "chat",
@ -219,7 +223,7 @@ function ModelSelect() {
<Listbox
onChange={async (val: string) => {
if (val === selectedModel?.title) return;
dispatch(
void dispatch(
updateSelectedModelByRole({
selectedProfile,
role: mode === "edit" ? "edit" : "chat",

View File

@ -19,7 +19,6 @@ export const LocalStorageProvider: React.FC<{ children: React.ReactNode }> = ({
const [values, setValues] = useState<LocalStorageType>(DEFAULT_LOCAL_STORAGE);
// TODO setvalue
useEffect(() => {
const isJetbrains = getLocalStorage("ide") === "jetbrains";
let fontSize = getLocalStorage("fontSize") ?? (isJetbrains ? 15 : 14);

View File

@ -20,6 +20,15 @@ export function HelpCenterSection() {
<div className="py-5">
<h3 className="mb-4 mt-0 text-xl">Help center</h3>
<div className="-mx-4 flex flex-col">
<MoreHelpRow
title="Continue Hub"
description="Visit hub.continue.dev to explore custom assistants and blocks"
Icon={ArrowTopRightOnSquareIcon}
onClick={() =>
ideMessenger.post("openUrl", "https://hub.continue.dev/")
}
/>
<MoreHelpRow
title="Documentation"
description="Learn how to configure and use Continue"

View File

@ -132,6 +132,8 @@ export function Chat() {
return isJetBrains();
}, []);
useAutoScroll(stepsDivRef, history);
useEffect(() => {
// Cmd + Backspace to delete current step
const listener = (e: any) => {
@ -282,8 +284,6 @@ export function Chat() {
const showScrollbar = showChatScrollbar ?? window.innerHeight > 5000;
useAutoScroll(stepsDivRef, history);
return (
<>
{widget}
@ -299,7 +299,7 @@ export function Chat() {
<div
key={item.message.id}
style={{
minHeight: index === history.length - 1 ? "25vh" : 0,
minHeight: index === history.length - 1 ? "200px" : 0,
}}
>
<ErrorBoundary
@ -318,6 +318,7 @@ export function Chat() {
isMainInput={false}
editorState={item.editorState}
contextItems={item.contextItems}
appliedRules={item.appliedRules}
inputId={item.message.id}
/>
</>

View File

@ -8,6 +8,10 @@ interface CreateFileToolCallProps {
}
export function CreateFile(props: CreateFileToolCallProps) {
if (!props.fileContents) {
return null;
}
const src = `\`\`\`${getMarkdownLanguageTagForFile(props.relativeFilepath ?? "output.txt")} ${props.relativeFilepath}\n${props.fileContents ?? ""}\n\`\`\``;
return props.relativeFilepath ? (

View File

@ -16,13 +16,12 @@ type EditToolCallProps = {
};
export function EditFile(props: EditToolCallProps) {
const src = `\`\`\`${getMarkdownLanguageTagForFile(props.relativeFilePath ?? "test.txt")} ${props.relativeFilePath}\n${props.changes ?? ""}\n\`\`\``;
const dispatch = useAppDispatch();
const isStreaming = useAppSelector((state) => state.session.isStreaming);
const applyState = useAppSelector((state) =>
selectApplyStateByToolCallId(state, props.toolCallId),
);
useEffect(() => {
if (!applyState) {
dispatch(
@ -35,10 +34,12 @@ export function EditFile(props: EditToolCallProps) {
}
}, [applyState, props.toolCallId]);
if (!props.relativeFilePath) {
if (!props.relativeFilePath || !props.changes) {
return null;
}
const src = `\`\`\`${getMarkdownLanguageTagForFile(props.relativeFilePath ?? "test.txt")} ${props.relativeFilePath}\n${props.changes}\n\`\`\``;
return (
<StyledMarkdownPreview
isRenderingInStepContainer

View File

@ -61,7 +61,7 @@ export function SimpleToolCallUI({
</div>
<span
className="ml-1 text-xs text-gray-400 transition-colors duration-200"
data-testid="toggle-div-title"
data-testid="tool-call-title"
>
<ToolCallStatusMessage tool={tool} toolCallState={toolCallState} />
</span>

View File

@ -1,6 +1,8 @@
import { Tool, ToolCallState } from "core";
import Mustache from "mustache";
import { ReactNode } from "react";
import { getFontSize } from "../../../util";
import { useFontSize } from "../../../components/ui/font";
interface ToolCallStatusMessageProps {
tool: Tool | undefined;
@ -11,6 +13,7 @@ export function ToolCallStatusMessage({
tool,
toolCallState,
}: ToolCallStatusMessageProps) {
const fontSize = useFontSize();
if (!tool) return "Agent tool use";
const defaultToolDescription = (
@ -65,7 +68,7 @@ export function ToolCallStatusMessage({
message = futureMessage;
}
return (
<div className="block">
<div className="block" style={{ fontSize }}>
<span>Continue</span> {intro} {message}
</div>
);

View File

@ -1,16 +1,24 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { ChatHistoryItemWithMessageId } from "../../redux/slices/sessionSlice";
/**
* Only reset scroll state when a new user message is added to the chat.
* We don't want to auto-scroll on new tool response messages.
*/
function getNumUserMsgs(history: ChatHistoryItemWithMessageId[]) {
return history.filter((msg) => msg.message.role === "user").length;
}
export const useAutoScroll = (
ref: React.RefObject<HTMLDivElement>,
history: unknown[],
history: ChatHistoryItemWithMessageId[],
) => {
const [userHasScrolled, setUserHasScrolled] = useState(false);
const numUserMsgs = useMemo(() => getNumUserMsgs(history), [history.length]);
useEffect(() => {
if (history.length) {
setUserHasScrolled(false);
}
}, [history.length]);
setUserHasScrolled(false);
}, [numUserMsgs]);
useEffect(() => {
if (!ref.current || history.length === 0) return;

View File

@ -1,21 +1,17 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import {
UpdateApplyStatePayload,
SetCodeToEditPayload,
MessageModes,
} from "core";
import { ApplyState, SetCodeToEditPayload, MessageModes } from "core";
import { EDIT_MODE_STREAM_ID } from "core/edit/constants";
export interface EditModeState {
// Array because of previous multi-file edit functionality
// Keeping array to not break persisted redux for now
codeToEdit: SetCodeToEditPayload[];
applyState: UpdateApplyStatePayload;
applyState: ApplyState;
returnToMode: MessageModes;
lastNonEditSessionWasEmpty: boolean;
}
export const INITIAL_EDIT_APPLY_STATE: UpdateApplyStatePayload = {
export const INITIAL_EDIT_APPLY_STATE: ApplyState = {
streamId: EDIT_MODE_STREAM_ID,
status: "not-started",
};
@ -39,7 +35,7 @@ export const editModeStateSlice = createSlice({
},
updateEditStateApplyState: (
state,
{ payload }: PayloadAction<UpdateApplyStatePayload>,
{ payload }: PayloadAction<ApplyState>,
) => {
state.applyState = {
...state.applyState,

View File

@ -7,7 +7,7 @@ import {
} from "@reduxjs/toolkit";
import { JSONContent } from "@tiptap/react";
import {
UpdateApplyStatePayload,
ApplyState,
ChatHistoryItem,
ChatMessage,
ContextItem,
@ -15,6 +15,7 @@ import {
FileSymbolMap,
MessageModes,
PromptLog,
RuleWithSource,
Session,
SessionMetadata,
ToolCallDelta,
@ -31,7 +32,7 @@ import { findCurrentToolCall, findToolCall } from "../util";
// We need this to handle reorderings (e.g. a mid-array deletion) of the messages array.
// The proper fix is adding a UUID to all chat messages, but this is the temp workaround.
type ChatHistoryItemWithMessageId = ChatHistoryItem & {
export type ChatHistoryItemWithMessageId = ChatHistoryItem & {
message: ChatMessage & { id: string };
};
@ -47,7 +48,7 @@ type SessionState = {
symbols: FileSymbolMap;
mode: MessageModes;
codeBlockApplyStates: {
states: UpdateApplyStatePayload[];
states: ApplyState[];
curIndex: number;
};
newestToolbarPreviewForInput: Record<string, string>;
@ -247,6 +248,19 @@ export const sessionSlice = createSlice({
...payload.contextItems,
];
},
setAppliedRulesAtIndex: (
state,
{
payload,
}: PayloadAction<{
index: number;
appliedRules: RuleWithSource[];
}>,
) => {
if (state.history[payload.index]) {
state.history[payload.index].appliedRules = payload.appliedRules;
}
},
setInactive: (state) => {
const curMessage = state.history.at(-1);
@ -521,10 +535,7 @@ export const sessionSlice = createSlice({
state.history[state.history.length - 1].contextItems = contextItems;
},
updateApplyState: (
state,
{ payload }: PayloadAction<UpdateApplyStatePayload>,
) => {
updateApplyState: (state, { payload }: PayloadAction<ApplyState>) => {
const applyState = state.codeBlockApplyStates.states.find(
(state) => state.streamId === payload.streamId,
);
@ -701,6 +712,7 @@ export const {
updateFileSymbols,
setContextItemsAtIndex,
addContextItemsAtIndex,
setAppliedRulesAtIndex,
setInactive,
streamUpdate,
newSession,

View File

@ -1,12 +1,14 @@
import { createAsyncThunk, unwrapResult } from "@reduxjs/toolkit";
import { JSONContent } from "@tiptap/core";
import { InputModifiers } from "core";
import { InputModifiers, ToolResultChatMessage, UserChatMessage } from "core";
import { constructMessages } from "core/llm/constructMessages";
import { getApplicableRules } from "core/llm/rules/getSystemMessageWithRules";
import posthog from "posthog-js";
import { v4 as uuidv4 } from "uuid";
import { getBaseSystemMessage } from "../../util";
import { selectSelectedChatModel } from "../slices/configSlice";
import {
setAppliedRulesAtIndex,
submitEditorAndInitAtIndex,
updateHistoryItemAtIndex,
} from "../slices/sessionSlice";
@ -84,17 +86,41 @@ export const streamResponseThunk = createAsyncThunk<
}),
);
// Construct messages from updated history
// Get updated history after the update
const updatedHistory = getState().session.history;
const messageMode = getState().session.mode
const baseChatOrAgentSystemMessage = getBaseSystemMessage(selectedChatModel, messageMode)
// Determine which rules apply to this message
const userMsg = updatedHistory[inputIndex].message;
const rules = getState().config.config.rules;
// Calculate applicable rules once
// We need to check the message type to match what getApplicableRules expects
const applicableRules = getApplicableRules(
userMsg.role === "user" || userMsg.role === "tool"
? (userMsg as UserChatMessage | ToolResultChatMessage)
: undefined,
rules,
);
// Store in history for UI display
dispatch(
setAppliedRulesAtIndex({
index: inputIndex,
appliedRules: applicableRules,
}),
);
const messageMode = getState().session.mode;
const baseChatOrAgentSystemMessage = getBaseSystemMessage(
selectedChatModel,
messageMode,
);
const messages = constructMessages(
messageMode,
[...updatedHistory],
baseChatOrAgentSystemMessage,
state.config.config.rules,
applicableRules,
);
posthog.capture("step run", {

View File

@ -3,6 +3,7 @@ import * as http from "node:http";
import { AddressInfo } from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import { pathToFileURL } from "url";
import { PackageIdentifier } from "./interfaces/slugs.js";
import { RegistryClient } from "./registryClient.js";
@ -134,6 +135,7 @@ describe("RegistryClient", () => {
describe("getContentFromFilePath", () => {
let absoluteFilePath: string;
let fileUrl: string;
let relativeFilePath: string;
beforeEach(() => {
@ -141,6 +143,11 @@ describe("RegistryClient", () => {
absoluteFilePath = path.join(tempDir, "absolute-path.yaml");
fs.writeFileSync(absoluteFilePath, "absolute file content", "utf8");
const urlFilePath = path.join(tempDir, "file-url-path.yaml");
fs.writeFileSync(urlFilePath, "file:// file content", "utf8");
const url = pathToFileURL(urlFilePath);
fileUrl = url.toString();
// Create a subdirectory and file in the temp directory
const subDir = path.join(tempDir, "sub");
fs.mkdirSync(subDir, { recursive: true });
@ -160,6 +167,14 @@ describe("RegistryClient", () => {
expect(result).toBe("absolute file content");
});
it("should read from local file url directly", () => {
const client = new RegistryClient();
const result = (client as any).getContentFromFilePath(fileUrl);
expect(result).toBe("file:// file content");
});
it("should use rootPath for relative paths when provided", () => {
const client = new RegistryClient({ rootPath: tempDir });

View File

@ -38,11 +38,9 @@ export class RegistryClient implements Registry {
private getContentFromFilePath(filepath: string): string {
if (filepath.startsWith("file://")) {
const pathWithoutProtocol = filepath.slice(7);
// For Windows file:///C:/path/to/file, we need to handle it properly
// On other systems, we might have file:///path/to/file
return fs.readFileSync(decodeURIComponent(pathWithoutProtocol), "utf8");
return fs.readFileSync(new URL(filepath), "utf8");
} else if (path.isAbsolute(filepath)) {
return fs.readFileSync(filepath, "utf8");
} else if (this.rootPath) {