mirror of
https://github.com/cline/cline.git
synced 2026-09-02 07:42:19 +08:00
Compare commits
17 Commits
saoudrizwan/cli
...
fixer
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d77a9a787 | |||
| 92ebfce0cb | |||
| 8a5042a8bc | |||
| 726853d679 | |||
| 0f52d80e55 | |||
| 71af56f493 | |||
| 1167b4f3a6 | |||
| e8d6370b0c | |||
| e243376a39 | |||
| 4f1be9d512 | |||
| 94d36ce719 | |||
| 5df7498f03 | |||
| c2b87252ac | |||
| be353bb3da | |||
| 0a7791de1f | |||
| 7cca102e14 | |||
| 4f591de6a9 |
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add endpoint configuration file support for on-premise deployments
|
||||
|
||||
Enterprise customers can now configure custom API endpoints by creating a `~/.cline/endpoints.json` file with custom URLs for `appBaseUrl`, `apiBaseUrl`, and `mcpBaseUrl`. When this file is present, Cline runs in on-premise mode with the custom endpoints.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: prevent infinite retry loops when replace_in_file fails repeatedly
|
||||
|
||||
Add safeguards to prevent the LLM from getting stuck in infinite retry loops when `replace_in_file` operations fail repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: skip diff error UI handling during streaming to prevent flickering
|
||||
|
||||
Suppress diff view error notifications while content is actively streaming to prevent visual flickering and improve user experience. Error handling is deferred until streaming completes.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix(extract-text): strip notebook outputs to reduce context size
|
||||
|
||||
Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing the amount of context sent to the LLM while preserving the essential code and markdown content.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: throttle diff view updates during streaming
|
||||
|
||||
Add throttling to diff view updates during content streaming to reduce UI flickering and improve performance. Updates are now batched at reasonable intervals instead of firing on every token received.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Disable PostHog telemetry, error tracking, and feature flags in self-hosted mode
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove deprecated zai-glm-4.6 model from Cerebras provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Make Sonnet 4.5 the default Amazon Bedrock model id
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Disable PostHog and build-time OpenTelemetry telemetry in self-hosted/on-premise mode. Enterprise customers running self-hosted deployments will no longer send any telemetry to Cline's collectors. Runtime environment OTEL and remote config OTEL remain available for enterprises to configure their own telemetry collection.
|
||||
@@ -16,13 +16,6 @@
|
||||
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
|
||||
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
|
||||
# ============================================================================
|
||||
# TELEMETRY PROVIDER CONTROL
|
||||
# ============================================================================
|
||||
# Control which telemetry providers are active
|
||||
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
|
||||
# Set to false to disable Telemetry completely
|
||||
|
||||
# ============================================================================
|
||||
# OPENTELEMETRY (Optional - for advanced telemetry)
|
||||
# ============================================================================
|
||||
|
||||
@@ -97,7 +97,6 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
|
||||
@@ -138,7 +138,6 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
|
||||
+29
-2
@@ -1,14 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## [3.55.0]
|
||||
|
||||
- Add new model: Arcee Trinity Large Preview
|
||||
- Add new model: Moonshot Kimi K2.5
|
||||
- Add MCP prompts support - prompts from connected MCP servers now appear in slash command autocomplete as `/mcp:<server>:<prompt>`
|
||||
|
||||
## [3.54.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Native tool calls support for Ollama provider
|
||||
- Sonnet 4.5 is now the default Amazon Bedrock model id
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent infinite retry loops when replace_in_file fails repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
|
||||
- Skip diff error UI handling during streaming to prevent flickering. Error handling is deferred until streaming completes.
|
||||
- Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing context size sent to the LLM.
|
||||
- Throttle diff view updates during streaming to reduce UI flickering and improve performance.
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed Mistral's Devstral-2512 free from the free models list
|
||||
- Removed deprecated zai-glm-4.6 model from Cerebras provider
|
||||
|
||||
## [3.53.1]
|
||||
|
||||
### Fixed
|
||||
- Bug in responses API
|
||||
|
||||
- Bug in responses API
|
||||
|
||||
## [3.53.0]
|
||||
|
||||
### Fixed
|
||||
- Removed grok model from free tier
|
||||
|
||||
- Removed grok model from free tier
|
||||
|
||||
## [3.52.0]
|
||||
|
||||
|
||||
@@ -104,14 +104,18 @@ func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cl
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *EnvService) isTelemetryEnabled() bool {
|
||||
// In CLI mode, check the CLINE_TELEMETRY_DISABLED environment variable
|
||||
return os.Getenv("CLINE_TELEMETRY_DISABLED") != "true"
|
||||
}
|
||||
|
||||
// GetTelemetrySettings returns the telemetry settings for CLI mode
|
||||
func (s *EnvService) GetTelemetrySettings(ctx context.Context, req *cline.EmptyRequest) (*host.GetTelemetrySettingsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetTelemetrySettings called")
|
||||
}
|
||||
|
||||
// In CLI mode, check the POSTHOG_TELEMETRY_ENABLED environment variable
|
||||
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
|
||||
telemetryEnabled := s.isTelemetryEnabled()
|
||||
|
||||
var setting host.Setting
|
||||
if telemetryEnabled {
|
||||
@@ -133,8 +137,7 @@ func (s *EnvService) SubscribeToTelemetrySettings(req *cline.EmptyRequest, strea
|
||||
log.Printf("SubscribeToTelemetrySettings called")
|
||||
}
|
||||
|
||||
// Send initial telemetry state
|
||||
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
|
||||
telemetryEnabled := s.isTelemetryEnabled()
|
||||
|
||||
var setting host.Setting
|
||||
if telemetryEnabled {
|
||||
|
||||
+15
-1
@@ -117,7 +117,13 @@
|
||||
"features/auto-compact",
|
||||
"features/background-edit",
|
||||
"features/checkpoints",
|
||||
"features/cline-rules",
|
||||
{
|
||||
"group": "Cline Rules",
|
||||
"pages": [
|
||||
"features/cline-rules/overview",
|
||||
"features/cline-rules/conditional-rules"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Commands & Shortcuts",
|
||||
"pages": [
|
||||
@@ -424,6 +430,14 @@
|
||||
{
|
||||
"source": "/enterprise-solutions/team-management/roles-and-permissions",
|
||||
"destination": "/enterprise-solutions/team-management/managing-members"
|
||||
},
|
||||
{
|
||||
"source": "/features/cline-rules",
|
||||
"destination": "/features/cline-rules/overview"
|
||||
},
|
||||
{
|
||||
"source": "/features/conditional-rules",
|
||||
"destination": "/features/cline-rules/conditional-rules"
|
||||
}
|
||||
],
|
||||
"search": {
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
Cline Rules allow you to provide Cline with system-level guidance. Think of them as a persistent way to include context and preferences for your projects or globally for every conversation.
|
||||
|
||||
## Creating a Rule
|
||||
|
||||
You can create a rule by clicking the `+` button in the Rules tab. This will open a new file in your IDE which you can use to write your rule.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/cline-rules.png" alt="Create a Rule" />
|
||||
</Frame>
|
||||
|
||||
Once you save the file:
|
||||
|
||||
- Your rule will be stored in the `.clinerules/` directory in your project (if it's a Workspace Rule)
|
||||
- Or in the Global Rules directory (if it's a Global Rule):
|
||||
|
||||
### Global Rules Directory Location
|
||||
|
||||
The location of your Global Rules directory depends on your operating system:
|
||||
|
||||
| Operating System | Default Location | Notes |
|
||||
|------------------|------------------|-------|
|
||||
| **Windows** | `Documents\Cline\Rules` | Uses system Documents folder |
|
||||
| **macOS** | `~/Documents/Cline/Rules` | Uses user Documents folder |
|
||||
| **Linux/WSL** | `~/Documents/Cline/Rules` | May fall back to `~/Cline/Rules` on some systems |
|
||||
|
||||
> **Note for Linux/WSL users**: If you don't find your global rules in `~/Documents/Cline/Rules`, check `~/Cline/Rules` as the location may vary depending on your system configuration and whether the Documents directory exists.
|
||||
|
||||
You can also have Cline create a rule for you by using the [`/newrule` slash command](/features/slash-commands/new-rule) in the chat.
|
||||
|
||||
```markdown Example Cline Rule Structure [expandable]
|
||||
# Project Guidelines
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
- Update relevant documentation in /docs when modifying features
|
||||
- Keep README.md in sync with new capabilities
|
||||
- Maintain changelog entries in CHANGELOG.md
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
Create ADRs in /docs/adr for:
|
||||
|
||||
- Major dependency changes
|
||||
- Architectural pattern changes
|
||||
- New integration patterns
|
||||
- Database schema changes
|
||||
Follow template in /docs/adr/template.md
|
||||
|
||||
## Code Style & Patterns
|
||||
|
||||
- Generate API clients using OpenAPI Generator
|
||||
- Use TypeScript axios template
|
||||
- Place generated code in /src/generated
|
||||
- Prefer composition over inheritance
|
||||
- Use repository pattern for data access
|
||||
- Follow error handling pattern in /src/utils/errors.ts
|
||||
|
||||
## Testing Standards
|
||||
|
||||
- Unit tests required for business logic
|
||||
- Integration tests for API endpoints
|
||||
- E2E tests for critical user flows
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
1. **Version Controlled**: The `.clinerules` file becomes part of your project's source code
|
||||
2. **Team Consistency**: Ensures consistent behavior across all team members
|
||||
3. **Project-Specific**: Rules and standards tailored to each project's needs
|
||||
4. **Institutional Knowledge**: Maintains project standards and practices in code
|
||||
|
||||
Place the `.clinerules` file in your project's root directory:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules
|
||||
├── src/
|
||||
├── docs/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cline's system prompt, on the other hand, is not user-editable ([here's where you can find it](https://github.com/cline/cline/blob/main/src/core/prompts/system.ts)). For a broader look at prompt engineering best practices, check out [this resource](https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview).
|
||||
|
||||
### AGENTS.md Standard Support
|
||||
|
||||
Cline also supports the [AGENTS.md](https://agents.md/) standard as a fallback
|
||||
(in addition to Cline Rules) by automatically detecting `AGENTS.md` files in
|
||||
your workspace root. This allows you to use the same rules file across different AI
|
||||
coding tools.
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── AGENTS.md
|
||||
├── src/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Tips for Writing Effective Cline Rules
|
||||
|
||||
- Be Clear and Concise: Use simple language and avoid ambiguity.
|
||||
- Focus on Desired Outcomes: Describe the results you want, not the specific steps.
|
||||
- Test and Iterate: Experiment to find what works best for your workflow.
|
||||
|
||||
### .clinerules/ Folder System
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/ # Folder containing active rules
|
||||
│ ├── 01-coding.md # Core coding standards
|
||||
│ ├── 02-documentation.md # Documentation requirements
|
||||
│ └── current-sprint.md # Rules specific to current work
|
||||
├── src/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cline automatically processes **all Markdown files** inside the `.clinerules/` directory, combining them into a unified set of rules. The numeric prefixes (optional) help organize files in a logical sequence.
|
||||
|
||||
#### Using a Rules Bank
|
||||
|
||||
For projects with multiple contexts or teams, maintain a rules bank directory:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/ # Active rules - automatically applied
|
||||
│ ├── 01-coding.md
|
||||
│ └── client-a.md
|
||||
│
|
||||
├── clinerules-bank/ # Repository of available but inactive rules
|
||||
│ ├── clients/ # Client-specific rule sets
|
||||
│ │ ├── client-a.md
|
||||
│ │ └── client-b.md
|
||||
│ ├── frameworks/ # Framework-specific rules
|
||||
│ │ ├── react.md
|
||||
│ │ └── vue.md
|
||||
│ └── project-types/ # Project type standards
|
||||
│ ├── api-service.md
|
||||
│ └── frontend-app.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
#### Benefits of the Folder Approach
|
||||
|
||||
1. **Contextual Activation**: Copy only relevant rules from the bank to the active folder
|
||||
2. **Easier Maintenance**: Update individual rule files without affecting others
|
||||
3. **Team Flexibility**: Different team members can activate rules specific to their current task
|
||||
4. **Reduced Noise**: Keep the active ruleset focused and relevant
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
Switch between client projects:
|
||||
|
||||
```bash
|
||||
# Switch to Client B project
|
||||
rm .clinerules/client-a.md
|
||||
cp clinerules-bank/clients/client-b.md .clinerules/
|
||||
```
|
||||
|
||||
Adapt to different tech stacks:
|
||||
|
||||
```bash
|
||||
# Frontend React project
|
||||
cp clinerules-bank/frameworks/react.md .clinerules/
|
||||
```
|
||||
|
||||
#### Implementation Tips
|
||||
|
||||
- Keep individual rule files focused on specific concerns
|
||||
- Use descriptive filenames that clearly indicate the rule's purpose
|
||||
- Consider git-ignoring the active `.clinerules/` folder while tracking the `clinerules-bank/`
|
||||
- Create team scripts to quickly activate common rule combinations
|
||||
|
||||
The folder system transforms your Cline rules from a static document into a dynamic knowledge system that adapts to your team's changing contexts and requirements.
|
||||
|
||||
### Managing Rules with the Toggleable Popover
|
||||
|
||||
To make managing both single `.clinerules` files and the folder system even easier, Cline v3.13 introduces a dedicated popover UI directly accessible from the chat interface.
|
||||
|
||||
Located conveniently under the chat input field, this popover allows you to:
|
||||
|
||||
- **Instantly See Active Rules:** View which global rules (from your user settings) and workspace rules (`.clinerules` file or folder contents) are currently active.
|
||||
- **Quickly Toggle Rules:** Enable or disable specific rule files within your workspace `.clinerules/` folder with a single click. This is perfect for activating context-specific rules (like `react-rules.md` or `memory-bank.md`) only when needed.
|
||||
- **Easily Add/Manage Rules:** Quickly create a workspace `.clinerules` file or folder if one doesn't exist, or add new rule files to an existing folder.
|
||||
|
||||
This UI significantly simplifies switching contexts and managing different sets of instructions without needing to manually edit files or configurations during a conversation.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/image%20(1).png" alt="Cline Logo" />
|
||||
</Frame>
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
title: "Conditional Rules"
|
||||
sidebarTitle: "Conditional Rules"
|
||||
description: "Activate rules automatically based on which files you're working with"
|
||||
---
|
||||
|
||||
Conditional rules let you scope rules to specific parts of your codebase. Rules activate only when you're working with matching files, keeping your context focused and relevant.
|
||||
|
||||
For an introduction to Cline Rules, see the [Overview](/features/cline-rules/overview).
|
||||
|
||||
- **Without conditionals**: every rule loads for every request.
|
||||
- **With conditionals**, rules activate only when your current files match their defined scope.
|
||||
|
||||
For example, React component rules should appear when you're working with React components, not when you're editing Python or documentation.
|
||||
|
||||
## How It Works
|
||||
|
||||
Conditional rules use YAML frontmatter at the top of your rule files. When Cline processes a request, it gathers context from your current work (open files, visible tabs, mentioned paths, edited files), evaluates each rule's conditions, and activates matching rules.
|
||||
|
||||
<Note>
|
||||
When a conditional rule activates, you'll see a notification: **"Conditional rules applied: workspace:frontend-rules.md"**
|
||||
</Note>
|
||||
|
||||
## Writing Conditional Rules
|
||||
|
||||
Add YAML frontmatter to the top of any rule file in your `.clinerules/` directory:
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "src/components/**"
|
||||
- "src/hooks/**"
|
||||
---
|
||||
|
||||
# React Component Guidelines
|
||||
|
||||
When creating or modifying React components:
|
||||
- Use functional components with React hooks
|
||||
- Extract reusable logic into custom React hooks
|
||||
- Keep components focused on a single responsibility
|
||||
```
|
||||
|
||||
The `---` markers delimit the frontmatter. Everything after the closing `---` is your rule content.
|
||||
|
||||
### The `paths` Conditional
|
||||
|
||||
Currently, `paths` is the supported conditional. It takes an array of glob patterns:
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "src/**" # All files under src/
|
||||
- "*.config.js" # Config files in root
|
||||
- "packages/*/src/" # Monorepo package sources
|
||||
---
|
||||
```
|
||||
|
||||
**Glob pattern syntax:**
|
||||
- `*` matches any characters except `/`
|
||||
- `**` matches any characters including `/` (recursive)
|
||||
- `?` matches a single character
|
||||
- `[abc]` matches any character in the brackets
|
||||
- `{a,b}` matches either pattern
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Pattern | Matches |
|
||||
|---------|---------|
|
||||
| `src/**/*.ts` | All TypeScript files under `src/` |
|
||||
| `*.md` | Markdown files in root only |
|
||||
| `**/*.test.ts` | Test files anywhere in the project |
|
||||
| `packages/{web,api}/**` | Files in web or api packages |
|
||||
| `src/components/*.tsx` | TSX files directly in components (not nested) |
|
||||
|
||||
### Behavior Details
|
||||
|
||||
**Multiple patterns**: A rule activates if any pattern matches any file in your context.
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- "mobile/**"
|
||||
---
|
||||
# Activates when working in frontend OR mobile
|
||||
```
|
||||
|
||||
**No frontmatter**: Rules without frontmatter are always active.
|
||||
|
||||
**Empty paths array**: `paths: []` means the rule never activates. Use this to temporarily disable a rule.
|
||||
|
||||
**Invalid YAML**: If frontmatter can't be parsed, Cline fails open: the rule activates with raw content visible to help debugging.
|
||||
|
||||
## What Counts as "Current Context"
|
||||
|
||||
Cline evaluates rules based on:
|
||||
|
||||
1. **Your message**: File paths mentioned in your prompt (e.g., "update `src/App.tsx`")
|
||||
2. **Open tabs**: Files currently open in your editor
|
||||
3. **Visible files**: Files visible in your active editor panes
|
||||
4. **Edited files**: Files Cline has created, modified, or deleted during the task
|
||||
5. **Pending operations**: Files Cline is about to edit
|
||||
|
||||
Conditional rules can activate on your first message, when relevant files are open, or mid-task when Cline starts working with matching files.
|
||||
|
||||
<Tip>
|
||||
Be explicit about file paths in your prompts. "Update `src/services/user.ts`" reliably triggers path-based rules; "update the user service" may not.
|
||||
</Tip>
|
||||
|
||||
## Practical Examples
|
||||
|
||||
Copy these patterns and adapt them to your project structure.
|
||||
|
||||
### Frontend vs Backend Rules
|
||||
|
||||
Keep frontend and backend rules separate to avoid noise. Frontend rules only load when working with UI code, backend rules only load when working with API or service code.
|
||||
|
||||
```yaml
|
||||
# .clinerules/frontend.md
|
||||
---
|
||||
paths:
|
||||
- "src/components/**"
|
||||
- "src/pages/**"
|
||||
- "src/hooks/**"
|
||||
---
|
||||
|
||||
# Frontend Guidelines
|
||||
|
||||
- Use Tailwind CSS for styling
|
||||
- Prefer server components where possible
|
||||
- Keep client components small and focused
|
||||
```
|
||||
|
||||
```yaml
|
||||
# .clinerules/backend.md
|
||||
---
|
||||
paths:
|
||||
- "src/api/**"
|
||||
- "src/services/**"
|
||||
- "src/db/**"
|
||||
---
|
||||
|
||||
# Backend Guidelines
|
||||
|
||||
- Use dependency injection for services
|
||||
- All database queries go through repositories
|
||||
- Return typed errors, not thrown exceptions
|
||||
```
|
||||
|
||||
### Test File Rules
|
||||
|
||||
Enforce testing standards automatically. This rule activates only when you're writing or modifying tests, so testing guidance appears exactly when you need it.
|
||||
|
||||
```yaml
|
||||
# .clinerules/testing.md
|
||||
---
|
||||
paths:
|
||||
- "**/*.test.ts"
|
||||
- "**/*.spec.ts"
|
||||
- "**/__tests__/**"
|
||||
---
|
||||
|
||||
# Testing Standards
|
||||
|
||||
- Use descriptive test names: "should [expected behavior] when [condition]"
|
||||
- One assertion per test when possible
|
||||
- Mock external dependencies, not internal modules
|
||||
- Use factories for test data, not fixtures
|
||||
```
|
||||
|
||||
### Documentation Rules
|
||||
|
||||
Apply documentation standards only when editing docs. Prevents style rules from cluttering your context when you're writing code.
|
||||
|
||||
```yaml
|
||||
# .clinerules/docs.md
|
||||
---
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
---
|
||||
|
||||
# Documentation Guidelines
|
||||
|
||||
- Use sentence case for headings
|
||||
- Include code examples for all features
|
||||
- Keep paragraphs short (3-4 sentences max)
|
||||
- Link to related documentation
|
||||
```
|
||||
|
||||
## Combining with Rule Toggles
|
||||
|
||||
Conditional rules work alongside the rule toggle UI. Toggle off a conditional rule to disable it entirely (it won't activate even if paths match). Toggle on to let it activate when conditions are met.
|
||||
|
||||
This provides two levels of control: manual toggles and automatic condition-based activation.
|
||||
|
||||
## Tips for Effective Conditional Rules
|
||||
|
||||
### Start Broad, Then Narrow
|
||||
|
||||
Begin with broader patterns and refine as you learn what works:
|
||||
|
||||
```yaml
|
||||
# Start here
|
||||
paths:
|
||||
- "src/**"
|
||||
|
||||
# Then narrow down
|
||||
paths:
|
||||
- "src/features/auth/**"
|
||||
```
|
||||
|
||||
### Use Descriptive Filenames
|
||||
|
||||
Name your rule files to indicate their scope:
|
||||
|
||||
```
|
||||
.clinerules/
|
||||
├── api-endpoints.md # Rules for API code
|
||||
├── database-models.md # Rules for DB layer
|
||||
├── react-components.md # Rules for React
|
||||
└── universal.md # No frontmatter = always active
|
||||
```
|
||||
|
||||
### Keep Universal Rules Separate
|
||||
|
||||
Put always-on rules (coding standards, project conventions) in files without frontmatter. Reserve conditional rules for context-specific guidance.
|
||||
|
||||
### Test Your Patterns
|
||||
|
||||
Not sure if a pattern matches? Create a simple test rule:
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "your/pattern/here/**"
|
||||
---
|
||||
|
||||
TEST: This rule should activate for your/pattern/here files.
|
||||
```
|
||||
|
||||
Then work with a file in that path and check if you see the activation notification.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Rule not activating:**
|
||||
- Check that file paths in your context match the glob pattern
|
||||
- Verify the rule is toggled on in the rules panel
|
||||
- Ensure YAML frontmatter has proper `---` delimiters
|
||||
|
||||
**Rule activating unexpectedly:**
|
||||
- Review glob patterns: `**` is recursive and may match more than intended
|
||||
- Check for open files that match the pattern
|
||||
- File paths mentioned in your message also count as context
|
||||
|
||||
**Frontmatter showing in output:**
|
||||
- YAML couldn't be parsed
|
||||
- Check for syntax errors (unquoted special characters, improper indentation)
|
||||
|
||||
## Related
|
||||
|
||||
- [Cline Rules Overview](/features/cline-rules/overview) - Complete rules system guide
|
||||
- [Skills](/features/skills) - Load instructions on demand with `/skill` command
|
||||
- [Workflows](/features/slash-commands/workflows/index) - Define explicit task automation
|
||||
- [@ Mentions](/features/at-mentions/overview) - Add files to context explicitly
|
||||
- [Understanding Context Management](/prompting/understanding-context-management) - How Cline manages context window
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: "Cline Rules"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Add persistent instructions and context to guide Cline's behavior"
|
||||
---
|
||||
|
||||
Cline Rules provide system-level guidance for your projects. Rules persist across conversations, ensuring consistent behavior without repeating instructions in every chat.
|
||||
|
||||
## How It Works
|
||||
|
||||
Rules are loaded when Cline starts a task. Here's what happens:
|
||||
|
||||
**Loading order**: Cline checks for rules in this sequence:
|
||||
1. `.clinerules/` folder (all `.md` files inside)
|
||||
2. Single `.clinerules` file
|
||||
3. `AGENTS.md` file
|
||||
|
||||
**Scope precedence**: Workspace rules override global rules when both define the same guidance.
|
||||
|
||||
**Multiple files**: When using a `.clinerules/` folder, all Markdown files are combined into one ruleset. Numeric prefixes (like `01-`, `02-`) control the order.
|
||||
|
||||
**Conditional activation**: Rules with YAML frontmatter activate only when you're working with matching files. See [Conditional Rules](/features/cline-rules/conditional-rules) for details.
|
||||
|
||||
## Supported Rule Files
|
||||
|
||||
Cline reads rules from multiple file formats in your workspace root, letting you share rules across different AI coding tools:
|
||||
|
||||
| File/Folder | Source | Notes |
|
||||
|-------------|--------|-------|
|
||||
| `.clinerules/` | Cline | Folder with `.md` files (recommended) |
|
||||
| `.cursor/rules/` | Cursor | Folder with `.mdc` files |
|
||||
| `.windsurf/rules` | Windsurf | Folder with multiple `md` files |
|
||||
| `AGENTS.md` | Universal | Follows [agents.md](https://agents.md/) standard, searched recursively |
|
||||
|
||||
Cline prioritizes `.clinerules` when present. Other formats load only if no `.clinerules` exists (except `AGENTS.md`, which always searches subdirectories). All rules appear in the Rules popover where you can toggle them.
|
||||
|
||||
## Creating Rules
|
||||
|
||||
Click the `+` button in the Rules tab to create a new rule. This opens a file in your editor where you write your guidance.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/cline-rules.png" alt="Create a Rule" />
|
||||
</Frame>
|
||||
|
||||
When you save the file, it's stored in:
|
||||
- **Workspace rules**: `.clinerules/` in your project root
|
||||
- **Global rules**: Platform-specific location (see table below)
|
||||
|
||||
You can also use the [`/newrule` slash command](/features/slash-commands/new-rule) to have Cline generate a rule based on your description.
|
||||
|
||||
### Global Rules Location
|
||||
|
||||
| Operating System | Default Location |
|
||||
|------------------|------------------|
|
||||
| **Windows** | `Documents\Cline\Rules` |
|
||||
| **macOS** | `~/Documents/Cline/Rules` |
|
||||
| **Linux/WSL** | `~/Documents/Cline/Rules` or `~/Cline/Rules` |
|
||||
|
||||
<Note>
|
||||
Linux/WSL users: Check both locations if you don't find global rules in `~/Documents/Cline/Rules`.
|
||||
</Note>
|
||||
|
||||
## Managing Rules
|
||||
|
||||
The Rules popover (below the chat input) shows active rules and lets you toggle them on or off.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/image%20(1).png" alt="Rules Popover" />
|
||||
</Frame>
|
||||
|
||||
The popover displays:
|
||||
- **Global rules**: From your user-level Rules directory
|
||||
- **Workspace rules**: From `.clinerules/` in your project
|
||||
|
||||
Toggle any rule to enable or disable it. Disabled rules won't load, even if they match conditions.
|
||||
|
||||
## When to Use Rules
|
||||
|
||||
Rules work best for persistent project context:
|
||||
|
||||
- **Code standards**: Formatting preferences, naming conventions, project-specific patterns
|
||||
- **Documentation requirements**: Where to add docs, what format to follow
|
||||
- **Architecture decisions**: Design patterns, dependency rules, module boundaries
|
||||
- **Team conventions**: PR processes, branch naming, commit message format
|
||||
- **Technology constraints**: Required libraries, banned APIs, version requirements
|
||||
|
||||
Rules are less effective for:
|
||||
- One-time instructions (just say it in the chat)
|
||||
- Complex multi-step workflows (use [Workflows](/features/slash-commands/workflows/index) instead)
|
||||
- Dynamic decisions that depend on runtime context
|
||||
|
||||
## Example Rule
|
||||
|
||||
```markdown
|
||||
# Backend API Guidelines
|
||||
|
||||
## Route Handlers
|
||||
|
||||
- Use async/await, not callbacks
|
||||
- Validate request bodies with Zod schemas
|
||||
- Return typed errors from `src/errors.ts`
|
||||
- All routes require authentication unless in `publicRoutes` array
|
||||
|
||||
## Database Access
|
||||
|
||||
- All queries go through repository classes in `src/repositories/`
|
||||
- Use transactions for multi-table updates
|
||||
- Never expose raw database errors to clients
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for business logic in `src/services/`
|
||||
- Integration tests for route handlers in `src/routes/`
|
||||
- Mock external APIs, not internal modules
|
||||
```
|
||||
|
||||
This rule provides clear, actionable guidance without explaining obvious concepts or using vague language.
|
||||
|
||||
## Using a Folder Structure
|
||||
|
||||
For projects with many rules, organize them in a `.clinerules/` folder:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/
|
||||
│ ├── 01-coding-standards.md
|
||||
│ ├── 02-documentation.md
|
||||
│ └── 03-testing.md
|
||||
├── src/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cline loads all Markdown files in `.clinerules/` automatically. The numeric prefixes help you control ordering, but they're optional.
|
||||
|
||||
### Organizing a Rules Bank
|
||||
|
||||
Maintain a separate folder for rules you might need but don't always want active:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/ # Active rules
|
||||
│ ├── 01-coding.md
|
||||
│ └── client-a.md
|
||||
│
|
||||
├── clinerules-bank/ # Available but inactive
|
||||
│ ├── clients/
|
||||
│ │ ├── client-a.md
|
||||
│ │ └── client-b.md
|
||||
│ └── frameworks/
|
||||
│ ├── react.md
|
||||
│ └── vue.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
Copy files from the bank to `.clinerules/` when you need them. This keeps your active context lean while maintaining a library of reusable guidance.
|
||||
|
||||
Switch contexts with simple file operations:
|
||||
|
||||
```bash
|
||||
# Switch to Client B
|
||||
rm .clinerules/client-a.md
|
||||
cp clinerules-bank/clients/client-b.md .clinerules/
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Consider git-ignoring `.clinerules/` while tracking `clinerules-bank/` so team members can activate the rules relevant to their current work.
|
||||
</Tip>
|
||||
|
||||
## Conditional Rules
|
||||
|
||||
Scope rules to specific file patterns using YAML frontmatter. This keeps React guidance out of Python code and backend rules away from frontend work.
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "src/components/**"
|
||||
- "src/hooks/**"
|
||||
---
|
||||
|
||||
# React Guidelines
|
||||
|
||||
Use functional components with hooks. Extract reusable logic into custom hooks.
|
||||
```
|
||||
|
||||
This rule activates only when working with files matching those patterns. Read the [Conditional Rules guide](/features/cline-rules/conditional-rules) for pattern syntax, behavior details, and more examples.
|
||||
|
||||
## Tips for Effective Rules
|
||||
|
||||
**Be specific**: "Use async/await for all database calls" beats "write good async code."
|
||||
|
||||
**Show patterns**: Include file paths and real examples. "Follow the error handling in `src/utils/errors.ts`" gives Cline a concrete reference.
|
||||
|
||||
**Focus on outcomes**: Describe what you want, not step-by-step instructions. Let Cline figure out how.
|
||||
|
||||
**Test and refine**: Start with core standards. Add rules when you find yourself repeating the same feedback.
|
||||
|
||||
**Use conditional rules**: Load guidance only when relevant. This keeps context efficient and reduces noise.
|
||||
|
||||
## Related
|
||||
|
||||
- [Conditional Rules](/features/cline-rules/conditional-rules) - Activate rules based on file patterns
|
||||
- [Skills](/features/skills) - Load instructions on demand with `/skill` command
|
||||
- [Workflows](/features/slash-commands/workflows/index) - Define explicit task automation
|
||||
- [New Rule Slash Command](/features/slash-commands/new-rule) - Generate rules with AI assistance
|
||||
- [Plan and Act Mode](/features/plan-and-act) - Use different rules for planning vs execution
|
||||
@@ -144,10 +144,6 @@ if (process.env.ERROR_SERVICE_API_KEY) {
|
||||
buildEnvVars["process.env.ERROR_SERVICE_API_KEY"] = JSON.stringify(process.env.ERROR_SERVICE_API_KEY)
|
||||
}
|
||||
|
||||
if (process.env.POSTHOG_TELEMETRY_ENABLED) {
|
||||
buildEnvVars["process.env.POSTHOG_TELEMETRY_ENABLED"] = JSON.stringify(process.env.POSTHOG_TELEMETRY_ENABLED)
|
||||
}
|
||||
|
||||
// OpenTelemetry configuration (injected at build time from GitHub secrets)
|
||||
// These provide production defaults that can be overridden at runtime via environment variables
|
||||
if (process.env.OTEL_TELEMETRY_ENABLED) {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.53.1",
|
||||
"version": "3.55.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.53.1",
|
||||
"version": "3.55.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.53.1",
|
||||
"version": "3.55.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
|
||||
@@ -75,6 +75,19 @@ message McpResourceTemplate {
|
||||
optional string description = 4;
|
||||
}
|
||||
|
||||
message McpPromptArgument {
|
||||
string name = 1;
|
||||
optional string description = 2;
|
||||
optional bool required = 3;
|
||||
}
|
||||
|
||||
message McpPrompt {
|
||||
string name = 1;
|
||||
optional string title = 2;
|
||||
optional string description = 3;
|
||||
repeated McpPromptArgument arguments = 4;
|
||||
}
|
||||
|
||||
enum McpServerStatus {
|
||||
// Protobuf enums (in proto3) must have a zero value defined, which serves as the default if the field isn't explicitly set.
|
||||
// To align with the required nature of the TypeScript type and avoid an unnecessary UNSPECIFIED state, we map one of the existing statuses to this zero value.
|
||||
@@ -95,6 +108,7 @@ message McpServer {
|
||||
optional int32 timeout = 9;
|
||||
optional bool oauth_required = 10;
|
||||
optional string oauth_auth_status = 11;
|
||||
repeated McpPrompt prompts = 12;
|
||||
}
|
||||
|
||||
message McpServers {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetch } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { addReasoningContent } from "../transform/r1-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
@@ -81,14 +81,10 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
|
||||
const isDeepseekReasoner = model.id.includes("deepseek-reasoner")
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
if (isDeepseekReasoner) {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
const convertedMessages = convertToOpenAiMessages(messages)
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepseekReasoner
|
||||
? [{ role: "system", content: systemPrompt }, ...addReasoningContent(convertedMessages, messages)]
|
||||
: [{ role: "system", content: systemPrompt }, ...convertedMessages]
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ModelInfo, openAiModelInfoSaneDefaults } from "@shared/api"
|
||||
import { type Config, type Message, Ollama } from "ollama"
|
||||
import type { ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -7,6 +8,7 @@ import type { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOllamaMessages } from "../transform/ollama-format"
|
||||
import type { ApiStream } from "../transform/stream"
|
||||
import { ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
interface OllamaHandlerOptions extends CommonApiHandlerOptions {
|
||||
ollamaBaseUrl?: string
|
||||
@@ -51,7 +53,7 @@ export class OllamaHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
@withRetry({ retryAllErrors: true })
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ChatCompletionTool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
|
||||
|
||||
@@ -70,20 +72,44 @@ export class OllamaHandler implements ApiHandler {
|
||||
options: {
|
||||
num_ctx: Number(this.options.ollamaApiOptionsCtxNum),
|
||||
},
|
||||
tools: tools as any,
|
||||
})
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
|
||||
// Race the API request against the timeout
|
||||
const stream = (await Promise.race([apiPromise, timeoutPromise])) as Awaited<typeof apiPromise>
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (typeof chunk.message.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.message.content,
|
||||
}
|
||||
Logger.debug("[OllamaHandler] Message Chunk" + JSON.stringify(chunk))
|
||||
|
||||
const delta = chunk.message
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
Logger.debug(`[OllamaHandler] Tool Calls Detected: ${JSON.stringify(delta.tool_calls)}`)
|
||||
yield* toolCallProcessor.processToolCallDeltas(
|
||||
delta.tool_calls?.map((tc, inx) => ({
|
||||
index: inx,
|
||||
id: `ollama-tool-${inx}`,
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments:
|
||||
typeof tc.function.arguments === "string"
|
||||
? tc.function.arguments
|
||||
: JSON.stringify(tc.function.arguments),
|
||||
},
|
||||
type: "function",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof delta.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
// Handle token usage if available
|
||||
if (chunk.eval_count !== undefined || chunk.prompt_eval_count !== undefined) {
|
||||
yield {
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ClineAssistantThinkingBlock, ClineStorageMessage } from "@/shared/messages/content"
|
||||
|
||||
/**
|
||||
* DeepSeek Reasoner message format with reasoning_content support.
|
||||
*/
|
||||
export type DeepSeekReasonerMessage = OpenAI.Chat.ChatCompletionMessageParam & {
|
||||
reasoning_content?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds reasoning_content to OpenAI messages for DeepSeek Reasoner.
|
||||
* Per DeepSeek API: reasoning_content should be passed back during tool calling in the same turn,
|
||||
* and omitted when starting a new turn.
|
||||
*/
|
||||
export function addReasoningContent(
|
||||
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
originalMessages: ClineStorageMessage[],
|
||||
): DeepSeekReasonerMessage[] {
|
||||
// Find last user message index (start of current turn)
|
||||
// If no user message exists (lastUserIndex = -1), all messages are in the "current turn",
|
||||
// so reasoning_content will be added to all assistant messages. This is intentional.
|
||||
let lastUserIndex = -1
|
||||
for (let i = openAiMessages.length - 1; i >= 0; i--) {
|
||||
if (openAiMessages[i].role === "user") {
|
||||
lastUserIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Extract thinking content from original messages, keyed by assistant index
|
||||
const thinkingByIndex = new Map<number, string>()
|
||||
let assistantIdx = 0
|
||||
for (const msg of originalMessages) {
|
||||
if (msg.role === "assistant") {
|
||||
if (Array.isArray(msg.content)) {
|
||||
const thinking = msg.content
|
||||
.filter((p): p is ClineAssistantThinkingBlock => p.type === "thinking")
|
||||
.map((p) => p.thinking)
|
||||
.join("\n")
|
||||
if (thinking) {
|
||||
thinkingByIndex.set(assistantIdx, thinking)
|
||||
}
|
||||
}
|
||||
assistantIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Add reasoning_content only to assistant messages in current turn
|
||||
let aiIdx = 0
|
||||
return openAiMessages.map((msg, i): DeepSeekReasonerMessage => {
|
||||
if (msg.role === "assistant") {
|
||||
const thinking = thinkingByIndex.get(aiIdx++)
|
||||
if (thinking && i >= lastUserIndex) {
|
||||
return { ...msg, reasoning_content: thinking }
|
||||
}
|
||||
}
|
||||
return msg
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Anthropic messages to OpenAI format and merges consecutive messages with the same role.
|
||||
|
||||
+3
-1
@@ -464,12 +464,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -430,12 +430,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -392,12 +392,14 @@ By waiting for and carefully considering the user's response after each tool use
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -495,12 +495,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -461,12 +461,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -419,12 +419,14 @@ By waiting for and carefully considering the user's response after each tool use
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -464,12 +464,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -430,12 +430,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -392,12 +392,14 @@ By waiting for and carefully considering the user's response after each tool use
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -464,12 +464,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -430,12 +430,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -392,12 +392,14 @@ By waiting for and carefully considering the user's response after each tool use
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -22,12 +22,14 @@ export function hasEnabledMcpServers(context: SystemPromptContext): boolean {
|
||||
|
||||
const MCP_TEMPLATE_TEXT = `MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the \`use_mcp_tool\` tool, and access the server's resources via the \`access_mcp_resource\` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
{{MCP_SERVERS_LIST}}`
|
||||
|
||||
export async function getMcp(variant: PromptVariant, context: SystemPromptContext): Promise<string | undefined> {
|
||||
@@ -71,6 +73,18 @@ function formatMcpServersList(servers: McpServer[]): string {
|
||||
?.map((resource) => `- ${resource.uri} (${resource.name}): ${resource.description}`)
|
||||
.join("\n")
|
||||
|
||||
const prompts = server.prompts
|
||||
?.map((prompt) => {
|
||||
const argsStr = prompt.arguments?.length
|
||||
? `\n Arguments: ${prompt.arguments
|
||||
.map((arg) => `${arg.name}${arg.required ? " (required)" : ""}${arg.description ? `: ${arg.description}` : ""}`)
|
||||
.join(", ")}`
|
||||
: ""
|
||||
const title = prompt.title ? ` (${prompt.title})` : ""
|
||||
return `- ${prompt.name}${title}: ${prompt.description || "No description"}${argsStr}`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
const config = JSON.parse(server.config)
|
||||
|
||||
return (
|
||||
@@ -80,7 +94,8 @@ function formatMcpServersList(servers: McpServer[]): string {
|
||||
: "") +
|
||||
(tools ? `\n\n### Available Tools\n${tools}` : "") +
|
||||
(templates ? `\n\n### Resource Templates\n${templates}` : "") +
|
||||
(resources ? `\n\n### Direct Resources\n${resources}` : "")
|
||||
(resources ? `\n\n### Direct Resources\n${resources}` : "") +
|
||||
(prompts ? `\n\n### Available Prompts\n${prompts}` : "")
|
||||
)
|
||||
})
|
||||
.join("\n\n")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import { isGPT5ModelFamily } from "@/utils/model-utils"
|
||||
import type { ClineToolSpec } from "../spec"
|
||||
import { TASK_PROGRESS_PARAMETER } from "../types"
|
||||
|
||||
@@ -80,7 +81,7 @@ const NATIVE_GPT_5: ClineToolSpec = {
|
||||
id: ClineDefaultTool.APPLY_PATCH,
|
||||
name: "apply_patch",
|
||||
description: APPLY_PATCH_TOOL_DESC,
|
||||
contextRequirements: (context) => context.providerInfo.model.id.includes("gpt-5"),
|
||||
contextRequirements: (context) => isGPT5ModelFamily(context.providerInfo.model.id),
|
||||
parameters: [
|
||||
{
|
||||
name: "input",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { McpPromptResponse } from "@shared/mcp"
|
||||
import { expect } from "chai"
|
||||
import { formatMcpPromptResponse, McpPromptFetcher, parseSlashCommands } from "../index"
|
||||
|
||||
describe("slash-commands", () => {
|
||||
describe("formatMcpPromptResponse", () => {
|
||||
it("should format text message", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello world" } }],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.equal("[User]\nHello world")
|
||||
})
|
||||
|
||||
it("should format assistant message", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [{ role: "assistant", content: { type: "text", text: "I can help" } }],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.equal("[Assistant]\nI can help")
|
||||
})
|
||||
|
||||
it("should include description when provided", () => {
|
||||
const response: McpPromptResponse = {
|
||||
description: "Test description",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.include("Description: Test description")
|
||||
expect(result).to.include("[User]\nHello")
|
||||
})
|
||||
|
||||
it("should format multiple messages", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [
|
||||
{ role: "user", content: { type: "text", text: "Question" } },
|
||||
{ role: "assistant", content: { type: "text", text: "Answer" } },
|
||||
],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.include("[User]\nQuestion")
|
||||
expect(result).to.include("[Assistant]\nAnswer")
|
||||
})
|
||||
|
||||
it("should format image content", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [{ role: "user", content: { type: "image", data: "base64data", mimeType: "image/png" } }],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.equal("[User]\n[Image: image/png]")
|
||||
})
|
||||
|
||||
it("should format audio content", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [{ role: "user", content: { type: "audio", data: "base64data", mimeType: "audio/mp3" } }],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.equal("[User]\n[Audio: audio/mp3]")
|
||||
})
|
||||
|
||||
it("should format resource with text", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "resource",
|
||||
resource: { uri: "file:///test.txt", text: "File content" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.include("[Resource: file:///test.txt]")
|
||||
expect(result).to.include("File content")
|
||||
})
|
||||
|
||||
it("should format resource without text", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "resource",
|
||||
resource: { uri: "file:///binary.bin" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.equal("[User]\n[Resource: file:///binary.bin]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseSlashCommands MCP handling", () => {
|
||||
const mockMcpPromptFetcher: McpPromptFetcher = async (serverName, promptName) => {
|
||||
if (serverName === "test-server" && promptName === "greet") {
|
||||
return {
|
||||
description: "A greeting prompt",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello from MCP!" } }],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
it("should process MCP prompt command in task tag", async () => {
|
||||
const text = "<task>/mcp:test-server:greet</task>"
|
||||
const result = await parseSlashCommands(text, {}, {}, "test-ulid", undefined, false, undefined, mockMcpPromptFetcher)
|
||||
|
||||
expect(result.processedText).to.include('<mcp_prompt server="test-server" prompt="greet">')
|
||||
expect(result.processedText).to.include("Hello from MCP!")
|
||||
expect(result.needsClinerulesFileCheck).to.equal(false)
|
||||
})
|
||||
|
||||
it("should process MCP prompt with additional text", async () => {
|
||||
const text = "<task>/mcp:test-server:greet Please expand on this</task>"
|
||||
const result = await parseSlashCommands(text, {}, {}, "test-ulid", undefined, false, undefined, mockMcpPromptFetcher)
|
||||
|
||||
expect(result.processedText).to.include('<mcp_prompt server="test-server" prompt="greet">')
|
||||
expect(result.processedText).to.include("Please expand on this")
|
||||
})
|
||||
|
||||
it("should handle MCP prompt with colons in prompt name", async () => {
|
||||
const fetcherWithColons: McpPromptFetcher = async (serverName, promptName) => {
|
||||
if (serverName === "server" && promptName === "prompt:with:colons") {
|
||||
return {
|
||||
messages: [{ role: "user", content: { type: "text", text: "Colon prompt" } }],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const text = "<task>/mcp:server:prompt:with:colons</task>"
|
||||
const result = await parseSlashCommands(text, {}, {}, "test-ulid", undefined, false, undefined, fetcherWithColons)
|
||||
|
||||
expect(result.processedText).to.include('prompt="prompt:with:colons"')
|
||||
expect(result.processedText).to.include("Colon prompt")
|
||||
})
|
||||
|
||||
// Note: Tests for "unknown MCP server", "no fetcher", and "fetcher errors"
|
||||
// are skipped because they require StateManager initialization when falling
|
||||
// through to workflow checking. The core MCP functionality is covered above.
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ApiProviderInfo } from "@core/api"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { McpPromptResponse } from "@shared/mcp"
|
||||
import fs from "fs/promises"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -15,6 +16,11 @@ import {
|
||||
} from "../prompts/commands"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
|
||||
/**
|
||||
* Callback type for fetching MCP prompts
|
||||
*/
|
||||
export type McpPromptFetcher = (serverName: string, promptName: string) => Promise<McpPromptResponse | null>
|
||||
|
||||
type FileBasedWorkflow = {
|
||||
fullPath: string
|
||||
fileName: string
|
||||
@@ -42,6 +48,7 @@ export async function parseSlashCommands(
|
||||
focusChainSettings?: { enabled: boolean },
|
||||
enableNativeToolCalls?: boolean,
|
||||
providerInfo?: ApiProviderInfo,
|
||||
mcpPromptFetcher?: McpPromptFetcher,
|
||||
): Promise<{ processedText: string; needsClinerulesFileCheck: boolean }> {
|
||||
const SUPPORTED_DEFAULT_COMMANDS = [
|
||||
"newtask",
|
||||
@@ -79,10 +86,10 @@ export async function parseSlashCommands(
|
||||
// Regex to find slash commands anywhere in text (not just at the beginning).
|
||||
// This mirrors how @ mentions work - they can appear anywhere in a message.
|
||||
//
|
||||
// Pattern breakdown: /(^|\s)\/([a-zA-Z0-9_.-]+)(?=\s|$)/
|
||||
// Pattern breakdown: /(^|\s)\/([a-zA-Z0-9_.:@-]+)(?=\s|$)/
|
||||
// - (^|\s) : Must be at start of string OR preceded by whitespace
|
||||
// - \/ : The literal slash character
|
||||
// - ([a-zA-Z0-9_.-]+) : The command name (letters, numbers, underscore, dot, hyphen)
|
||||
// - ([a-zA-Z0-9_.:@-]+) : The command name (letters, numbers, underscore, dot, hyphen, colon, @)
|
||||
// - (?=\s|$): Must be followed by whitespace or end of string (lookahead)
|
||||
//
|
||||
// This safely avoids false matches in:
|
||||
@@ -91,7 +98,8 @@ export async function parseSlashCommands(
|
||||
// - Partial words: "foo/bar" - same reason
|
||||
//
|
||||
// Only ONE slash command per message is processed (first match found).
|
||||
const slashCommandInTextRegex = /(^|\s)\/([a-zA-Z0-9_.-]+)(?=\s|$)/
|
||||
// Note: Colons are allowed to support MCP prompt commands like /mcp:server:prompt
|
||||
const slashCommandInTextRegex = /(^|\s)\/([a-zA-Z0-9_.:@-]+)(?=\s|$)/
|
||||
|
||||
// Helper function to calculate positions and remove slash command from text
|
||||
const removeSlashCommand = (
|
||||
@@ -144,6 +152,39 @@ export async function parseSlashCommands(
|
||||
return { processedText: processedText, needsClinerulesFileCheck: commandName === "newrule" }
|
||||
}
|
||||
|
||||
// Check for MCP prompt commands (format: mcp:<server>:<prompt>)
|
||||
if (commandName.startsWith("mcp:") && mcpPromptFetcher) {
|
||||
const parts = commandName.split(":")
|
||||
if (parts.length >= 3) {
|
||||
const serverName = parts[1]
|
||||
const promptName = parts.slice(2).join(":") // Allow colons in prompt name
|
||||
|
||||
try {
|
||||
const promptResponse = await mcpPromptFetcher(serverName, promptName)
|
||||
if (promptResponse) {
|
||||
// Format the prompt messages as text
|
||||
const promptContent = formatMcpPromptResponse(promptResponse)
|
||||
|
||||
// Remove the slash command and add the prompt content
|
||||
const textWithoutSlashCommand = removeSlashCommand(text, tagContent, contentStartIndex, slashMatch)
|
||||
const processedText =
|
||||
`<mcp_prompt server="${serverName}" prompt="${promptName}">\n${promptContent}\n</mcp_prompt>\n` +
|
||||
textWithoutSlashCommand
|
||||
|
||||
// Track telemetry for MCP prompt usage
|
||||
telemetryService.captureSlashCommandUsed(ulid, commandName, "mcp_prompt")
|
||||
|
||||
return { processedText, needsClinerulesFileCheck: false }
|
||||
} else {
|
||||
// Prompt not found - log for debugging and fall through to workflow checking
|
||||
Logger.debug(`MCP prompt not found: ${commandName} (server: ${serverName}, prompt: ${promptName})`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Error fetching MCP prompt ${commandName}: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const globalWorkflows: Workflow[] = Object.entries(globalWorkflowToggles)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([filePath, _]) => ({
|
||||
@@ -214,3 +255,35 @@ export async function parseSlashCommands(
|
||||
// if no supported commands are found, return the original text
|
||||
return { processedText: text, needsClinerulesFileCheck: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats MCP prompt response messages into a text format for injection
|
||||
*/
|
||||
export function formatMcpPromptResponse(response: McpPromptResponse): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (response.description) {
|
||||
parts.push(`Description: ${response.description}`)
|
||||
}
|
||||
|
||||
for (const message of response.messages) {
|
||||
const roleLabel = message.role === "user" ? "User" : "Assistant"
|
||||
|
||||
if (message.content.type === "text") {
|
||||
parts.push(`[${roleLabel}]\n${message.content.text}`)
|
||||
} else if (message.content.type === "image") {
|
||||
parts.push(`[${roleLabel}]\n[Image: ${message.content.mimeType}]`)
|
||||
} else if (message.content.type === "audio") {
|
||||
parts.push(`[${roleLabel}]\n[Audio: ${message.content.mimeType}]`)
|
||||
} else if (message.content.type === "resource") {
|
||||
const resource = message.content.resource
|
||||
if (resource.text) {
|
||||
parts.push(`[${roleLabel}]\n[Resource: ${resource.uri}]\n${resource.text}`)
|
||||
} else {
|
||||
parts.push(`[${roleLabel}]\n[Resource: ${resource.uri}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n\n")
|
||||
}
|
||||
|
||||
@@ -3044,6 +3044,15 @@ export class Task {
|
||||
this.workspaceManager,
|
||||
)
|
||||
|
||||
// Create MCP prompt fetcher callback that wraps mcpHub.getPrompt
|
||||
const mcpPromptFetcher = async (serverName: string, promptName: string) => {
|
||||
try {
|
||||
return await this.mcpHub.getPrompt(serverName, promptName)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const { processedText, needsClinerulesFileCheck: needsCheck } = await parseSlashCommands(
|
||||
parsedText,
|
||||
localWorkflowToggles,
|
||||
@@ -3052,6 +3061,7 @@ export class Task {
|
||||
focusChainSettings,
|
||||
useNativeToolCalls,
|
||||
providerInfo,
|
||||
mcpPromptFetcher,
|
||||
)
|
||||
|
||||
if (needsCheck) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotoc
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
GetPromptResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ListToolsResultSchema,
|
||||
@@ -16,6 +18,8 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import {
|
||||
DEFAULT_MCP_TIMEOUT_SECONDS,
|
||||
McpPrompt,
|
||||
McpPromptResponse,
|
||||
McpResource,
|
||||
McpResourceResponse,
|
||||
McpResourceTemplate,
|
||||
@@ -617,10 +621,11 @@ export class McpHub {
|
||||
Logger.error(`[MCP Debug] Error setting notification handlers for ${name}:`, error)
|
||||
}
|
||||
|
||||
// Initial fetch of tools and resources
|
||||
// Initial fetch of tools, resources, and prompts
|
||||
connection.server.tools = await this.fetchToolsList(name)
|
||||
connection.server.resources = await this.fetchResourcesList(name)
|
||||
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name)
|
||||
connection.server.prompts = await this.fetchPromptsList(name)
|
||||
} catch (error) {
|
||||
// Update status with error
|
||||
const connection = this.findConnection(name, source)
|
||||
@@ -716,6 +721,34 @@ export class McpHub {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPromptsList(serverName: string): Promise<McpPrompt[]> {
|
||||
try {
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
|
||||
// Disabled servers don't have clients, so return empty prompts list
|
||||
if (!connection || connection.server.disabled || !connection.client) {
|
||||
return []
|
||||
}
|
||||
|
||||
const response = await connection.client.request({ method: "prompts/list" }, ListPromptsResultSchema, {
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
return (response?.prompts || []).map((prompt) => ({
|
||||
name: prompt.name,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: prompt.arguments?.map((arg) => ({
|
||||
name: arg.name,
|
||||
description: arg.description,
|
||||
required: arg.required,
|
||||
})),
|
||||
}))
|
||||
} catch (_error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConnection(name: string): Promise<void> {
|
||||
const connection = this.connections.find((conn) => conn.server.name === name)
|
||||
if (connection) {
|
||||
@@ -1116,6 +1149,45 @@ export class McpHub {
|
||||
)
|
||||
}
|
||||
|
||||
async getPrompt(
|
||||
serverName: string,
|
||||
promptName: string,
|
||||
promptArguments?: Record<string, string>,
|
||||
): Promise<McpPromptResponse> {
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
if (!connection) {
|
||||
throw new Error(`No connection found for server: ${serverName}`)
|
||||
}
|
||||
if (connection.server.disabled) {
|
||||
throw new Error(`Server "${serverName}" is disabled`)
|
||||
}
|
||||
if (!connection.client) {
|
||||
throw new Error(`No client available for server: ${serverName}`)
|
||||
}
|
||||
|
||||
const response = await connection.client.request(
|
||||
{
|
||||
method: "prompts/get",
|
||||
params: {
|
||||
name: promptName,
|
||||
arguments: promptArguments,
|
||||
},
|
||||
},
|
||||
GetPromptResultSchema,
|
||||
{
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
description: response.description,
|
||||
messages: response.messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content as McpPromptResponse["messages"][0]["content"],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async callTool(
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
|
||||
@@ -1530,9 +1530,9 @@ export class TelemetryService {
|
||||
* Records when slash commands or workflows are activated
|
||||
* @param ulid Unique identifier for the task
|
||||
* @param commandName The name of the command (e.g., "newtask", "reportbug", or custom workflow name)
|
||||
* @param commandType Whether it's a built-in command or custom workflow
|
||||
* @param commandType Whether it's a built-in command, custom workflow, or MCP prompt
|
||||
*/
|
||||
public captureSlashCommandUsed(ulid: string, commandName: string, commandType: "builtin" | "workflow") {
|
||||
public captureSlashCommandUsed(ulid: string, commandName: string, commandType: "builtin" | "workflow" | "mcp_prompt") {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.SLASH_COMMAND_USED,
|
||||
properties: {
|
||||
|
||||
@@ -23,13 +23,13 @@ export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
|
||||
},
|
||||
{
|
||||
group: "free",
|
||||
id: "mistralai/devstral-2512:free",
|
||||
name: "Mistral: Devstral 2512",
|
||||
score: 85,
|
||||
id: "arcee-ai/trinity-large-preview:free",
|
||||
name: "Arcee AI: Trinity Large Preview",
|
||||
score: 88,
|
||||
latency: 2,
|
||||
badge: "",
|
||||
badge: "New",
|
||||
info: {
|
||||
contextWindow: 256_000,
|
||||
contextWindow: 131_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
|
||||
@@ -16,6 +16,7 @@ export type McpServer = {
|
||||
tools?: McpTool[]
|
||||
resources?: McpResource[]
|
||||
resourceTemplates?: McpResourceTemplate[]
|
||||
prompts?: McpPrompt[]
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
uid?: string
|
||||
@@ -46,6 +47,54 @@ export type McpResourceTemplate = {
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export type McpPromptArgument = {
|
||||
name: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export type McpPrompt = {
|
||||
name: string
|
||||
title?: string
|
||||
description?: string
|
||||
arguments?: McpPromptArgument[]
|
||||
}
|
||||
|
||||
export type McpPromptMessageContent =
|
||||
| {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: "image"
|
||||
data: string
|
||||
mimeType: string
|
||||
}
|
||||
| {
|
||||
type: "audio"
|
||||
data: string
|
||||
mimeType: string
|
||||
}
|
||||
| {
|
||||
type: "resource"
|
||||
resource: {
|
||||
uri: string
|
||||
mimeType?: string
|
||||
text?: string
|
||||
blob?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type McpPromptMessage = {
|
||||
role: "user" | "assistant"
|
||||
content: McpPromptMessageContent
|
||||
}
|
||||
|
||||
export type McpPromptResponse = {
|
||||
description?: string
|
||||
messages: McpPromptMessage[]
|
||||
}
|
||||
|
||||
export type McpResourceResponse = {
|
||||
_meta?: Record<string, any>
|
||||
contents: Array<{
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
McpServerStatus,
|
||||
McpPrompt as ProtoMcpPrompt,
|
||||
McpPromptArgument as ProtoMcpPromptArgument,
|
||||
McpResource as ProtoMcpResource,
|
||||
McpResourceTemplate as ProtoMcpResourceTemplate,
|
||||
McpServer as ProtoMcpServer,
|
||||
McpServerStatus,
|
||||
McpTool as ProtoMcpTool,
|
||||
} from "@shared/proto/cline/mcp"
|
||||
import { McpOAuthAuthStatus, McpResource, McpResourceTemplate, McpServer, McpTool } from "../../mcp"
|
||||
import { McpOAuthAuthStatus, McpPrompt, McpPromptArgument, McpResource, McpResourceTemplate, McpServer, McpTool } from "../../mcp"
|
||||
|
||||
// Helper to convert TS status to Proto enum
|
||||
function convertMcpStatusToProto(status: McpServer["status"]): McpServerStatus {
|
||||
@@ -30,6 +32,7 @@ export function convertMcpServersToProtoMcpServers(mcpServers: McpServer[]): Pro
|
||||
tools: (server.tools || []).map(convertTool),
|
||||
resources: (server.resources || []).map(convertResource),
|
||||
resourceTemplates: (server.resourceTemplates || []).map(convertResourceTemplate),
|
||||
prompts: (server.prompts || []).map(convertPrompt),
|
||||
|
||||
disabled: server.disabled,
|
||||
timeout: server.timeout,
|
||||
@@ -81,6 +84,29 @@ function convertResourceTemplate(template: McpResourceTemplate): ProtoMcpResourc
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts McpPromptArgument to ProtoMcpPromptArgument format
|
||||
*/
|
||||
function convertPromptArgument(arg: McpPromptArgument): ProtoMcpPromptArgument {
|
||||
return {
|
||||
name: arg.name,
|
||||
description: arg.description,
|
||||
required: arg.required,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts McpPrompt to ProtoMcpPrompt format
|
||||
*/
|
||||
function convertPrompt(prompt: McpPrompt): ProtoMcpPrompt {
|
||||
return {
|
||||
name: prompt.name,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: (prompt.arguments || []).map(convertPromptArgument),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to convert Proto enum to TS status
|
||||
function convertProtoStatusToMcp(status: McpServerStatus): McpServer["status"] {
|
||||
switch (status) {
|
||||
@@ -106,6 +132,7 @@ export function convertProtoMcpServersToMcpServers(protoServers: ProtoMcpServer[
|
||||
tools: protoServer.tools.map(convertProtoTool),
|
||||
resources: protoServer.resources.map(convertProtoResource),
|
||||
resourceTemplates: protoServer.resourceTemplates.map(convertProtoResourceTemplate),
|
||||
prompts: protoServer.prompts.map(convertProtoPrompt),
|
||||
|
||||
disabled: protoServer.disabled,
|
||||
timeout: protoServer.timeout,
|
||||
@@ -155,3 +182,26 @@ function convertProtoResourceTemplate(protoTemplate: ProtoMcpResourceTemplate):
|
||||
description: protoTemplate.description === "" ? undefined : protoTemplate.description,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts ProtoMcpPromptArgument to McpPromptArgument format
|
||||
*/
|
||||
function convertProtoPromptArgument(protoArg: ProtoMcpPromptArgument): McpPromptArgument {
|
||||
return {
|
||||
name: protoArg.name,
|
||||
description: protoArg.description === "" ? undefined : protoArg.description,
|
||||
required: protoArg.required,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts ProtoMcpPrompt to McpPrompt format
|
||||
*/
|
||||
function convertProtoPrompt(protoPrompt: ProtoMcpPrompt): McpPrompt {
|
||||
return {
|
||||
name: protoPrompt.name,
|
||||
title: protoPrompt.title === "" ? undefined : protoPrompt.title,
|
||||
description: protoPrompt.description === "" ? undefined : protoPrompt.description,
|
||||
arguments: protoPrompt.arguments.map(convertProtoPromptArgument),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface SlashCommand {
|
||||
name: string
|
||||
description?: string
|
||||
section?: "default" | "custom"
|
||||
section?: "default" | "custom" | "mcp"
|
||||
cliCompatible?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { isClaude4PlusModelFamily, shouldSkipReasoningForModel } from "../model-utils"
|
||||
import { isClaude4PlusModelFamily, isGPT5ModelFamily, shouldSkipReasoningForModel } from "../model-utils"
|
||||
|
||||
describe("shouldSkipReasoningForModel", () => {
|
||||
it("should return true for grok-4 models", () => {
|
||||
@@ -55,3 +55,30 @@ describe("isClaude4PlusModelFamily", () => {
|
||||
isClaude4PlusModelFamily("llama-3").should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGPT5ModelFamily", () => {
|
||||
it("should return true for GPT-5 model IDs with hyphen", () => {
|
||||
isGPT5ModelFamily("gpt-5").should.equal(true)
|
||||
isGPT5ModelFamily("gpt-5.1").should.equal(true)
|
||||
isGPT5ModelFamily("gpt-5.2-codex").should.equal(true)
|
||||
isGPT5ModelFamily("openai/gpt-5").should.equal(true)
|
||||
})
|
||||
|
||||
it("should return true for GPT-5 model IDs without hyphen (OCA format)", () => {
|
||||
isGPT5ModelFamily("gpt5").should.equal(true)
|
||||
isGPT5ModelFamily("oca/gpt5").should.equal(true)
|
||||
})
|
||||
|
||||
it("should be case insensitive", () => {
|
||||
isGPT5ModelFamily("GPT-5").should.equal(true)
|
||||
isGPT5ModelFamily("GPT5").should.equal(true)
|
||||
isGPT5ModelFamily("OCA/GPT5").should.equal(true)
|
||||
})
|
||||
|
||||
it("should return false for non-GPT-5 models", () => {
|
||||
isGPT5ModelFamily("gpt-4").should.equal(false)
|
||||
isGPT5ModelFamily("gpt-4o").should.equal(false)
|
||||
isGPT5ModelFamily("claude-3-sonnet").should.equal(false)
|
||||
isGPT5ModelFamily("gemini-pro").should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ export function isNextGenModelProvider(providerInfo: ApiProviderInfo): boolean {
|
||||
"openai-codex",
|
||||
"baseten",
|
||||
"vercel-ai-gateway",
|
||||
"deepseek",
|
||||
"oca",
|
||||
].some((id) => providerId === id)
|
||||
}
|
||||
@@ -140,6 +141,11 @@ function isDeepSeek32ModelFamily(id: string): boolean {
|
||||
return modelId.includes("deepseek") && modelId.includes("3.2") && !modelId.includes("speciale")
|
||||
}
|
||||
|
||||
export function isDeepSeekNativeModelFamily(id: string): boolean {
|
||||
const modelId = normalize(id)
|
||||
return modelId.includes("deepseek-chat") || modelId.includes("deepseek-reasoner")
|
||||
}
|
||||
|
||||
export function isNextGenModelFamily(id: string): boolean {
|
||||
const modelId = normalize(id)
|
||||
return (
|
||||
@@ -150,7 +156,8 @@ export function isNextGenModelFamily(id: string): boolean {
|
||||
isMinimaxModelFamily(modelId) ||
|
||||
isGemini3ModelFamily(modelId) ||
|
||||
isNextGenOpenSourceModelFamily(modelId) ||
|
||||
isDeepSeek32ModelFamily(modelId)
|
||||
isDeepSeek32ModelFamily(modelId) ||
|
||||
isDeepSeekNativeModelFamily(modelId)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BrowserAction, BrowserActionResult, ClineMessage, ClineSayBrowserAction
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSize } from "react-use"
|
||||
import styled from "styled-components"
|
||||
@@ -438,7 +439,7 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
|
||||
cursor: "pointer",
|
||||
padding: `9px 8px ${consoleLogsExpanded ? 0 : 8}px 8px`,
|
||||
}}>
|
||||
<span className={`codicon codicon-chevron-${consoleLogsExpanded ? "down" : "right"}`}></span>
|
||||
{consoleLogsExpanded ? <ChevronDownIcon size={16} /> : <ChevronRightIcon size={16} />}
|
||||
<span style={consoleLogsTextStyle}>Console Logs</span>
|
||||
</div>
|
||||
{consoleLogsExpanded && (
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
ArrowRightIcon,
|
||||
BellIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CircleSlashIcon,
|
||||
CircleXIcon,
|
||||
FileCode2Icon,
|
||||
@@ -62,9 +64,6 @@ import SearchResultsDisplay from "./SearchResultsDisplay"
|
||||
import { ThinkingRow } from "./ThinkingRow"
|
||||
import UserMessage from "./UserMessage"
|
||||
|
||||
// State type for api_req_started rendering
|
||||
type ApiReqState = "pre" | "thinking" | "error" | "final"
|
||||
|
||||
const HEADER_CLASSNAMES = "flex items-center gap-2.5 mb-3"
|
||||
|
||||
interface ChatRowProps {
|
||||
@@ -641,7 +640,7 @@ export const ChatRowContent = memo(
|
||||
<div className="flex items-center mb-2">
|
||||
<span className="font-bold mr-1">Summary:</span>
|
||||
<div className="grow" />
|
||||
<span className="codicon codicon-chevron-up my-0.5 shrink-0" />
|
||||
<ChevronDownIcon className="my-0.5 shrink-0 size-4" />
|
||||
</div>
|
||||
<span className="ph-no-capture break-words whitespace-pre-wrap">{tool.content}</span>
|
||||
</div>
|
||||
@@ -650,7 +649,7 @@ export const ChatRowContent = memo(
|
||||
<span className="ph-no-capture whitespace-nowrap overflow-hidden text-ellipsis text-left flex-1 mr-2 [direction:rtl]">
|
||||
{tool.content + "\u200E"}
|
||||
</span>
|
||||
<span className="codicon codicon-chevron-down my-0.5 shrink-0" />
|
||||
<ChevronRightIcon className="my-0.5 shrink-0 size-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -273,6 +273,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
showChatModelSelector: showModelSelector,
|
||||
setShowChatModelSelector: setShowModelSelector,
|
||||
dictationSettings,
|
||||
mcpServers,
|
||||
} = useExtensionState()
|
||||
const { clineUser } = useClineAuth()
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
@@ -527,6 +528,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
globalWorkflowToggles,
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
)
|
||||
|
||||
if (allCommands.length === 0) {
|
||||
@@ -551,6 +553,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
globalWorkflowToggles,
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
)
|
||||
if (commands.length > 0) {
|
||||
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
|
||||
@@ -1503,6 +1506,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
<SlashCommandMenu
|
||||
globalWorkflowToggles={globalWorkflowToggles}
|
||||
localWorkflowToggles={localWorkflowToggles}
|
||||
mcpServers={mcpServers}
|
||||
onMouseDown={handleMenuMouseDown}
|
||||
onSelect={handleSlashCommandsSelect}
|
||||
query={slashCommandsQuery}
|
||||
|
||||
@@ -104,7 +104,7 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
|
||||
}
|
||||
|
||||
// Regular error message
|
||||
return <p className="m-0 mt-4 whitespace-pre-wrap text-error wrap-anywhere">{message.text}</p>
|
||||
return <p className="m-0 mt-0 whitespace-pre-wrap text-error wrap-anywhere">{message.text}</p>
|
||||
|
||||
case "diff_error":
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import type React from "react"
|
||||
import { useMemo } from "react"
|
||||
import { cleanPathPrefix } from "../common/CodeAccordian"
|
||||
import { getIconByToolName } from "./chat-view"
|
||||
import { isApiReqAbsorbable, isLowStakesTool } from "./chat-view/utils/messageUtils"
|
||||
import ErrorRow from "./ErrorRow"
|
||||
import { ThinkingRow } from "./ThinkingRow"
|
||||
import { TypewriterText } from "./TypewriterText"
|
||||
@@ -62,12 +63,17 @@ const collectToolsInRange = (
|
||||
stopCondition?: (msg: ClineMessage) => boolean,
|
||||
): { icon: LucideIcon; text: string }[] => {
|
||||
const activities: { icon: LucideIcon; text: string }[] = []
|
||||
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const msg = messages[i]
|
||||
|
||||
if (stopCondition?.(msg)) {
|
||||
break
|
||||
}
|
||||
if (msg.say !== "tool" && msg.ask !== "tool") {
|
||||
|
||||
// Only collect tools that are currently executing (ask === "tool")
|
||||
// Skip completed tools (say === "tool") - they should be in the completed list
|
||||
if (msg.say === "tool" || msg.ask !== "tool") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -138,19 +144,27 @@ export const RequestStartRow: React.FC<RequestStartRowProps> = ({
|
||||
const hasError = !!(apiRequestFailedMessage || apiReqStreamingFailedMessage)
|
||||
const hasCost = cost != null
|
||||
const hasReasoning = !!reasoningContent
|
||||
const hasResponseStarted = !!responseStarted
|
||||
const hasCompletionResult = clineMessages.some(
|
||||
(msg) => msg.ask === "completion_result" || msg.say === "completion_result" || msg.ask === "plan_mode_respond",
|
||||
)
|
||||
|
||||
const apiReqState: ApiReqState = hasError ? "error" : hasCost ? "final" : hasReasoning ? "thinking" : "pre"
|
||||
|
||||
// While reasoning is streaming, keep the Brain ThinkingBlock exactly as-is.
|
||||
// Once response content starts (any text/tool/command), collapse into a compact
|
||||
// "🧠 Thinking" row that can be expanded to show the reasoning only.
|
||||
const showStreamingThinking = hasReasoning && !hasResponseStarted && !hasError && !hasCost
|
||||
const showCollapsedThinking = hasReasoning && !showStreamingThinking
|
||||
const showStreamingThinking = useMemo(
|
||||
() => hasReasoning && !hasError && !cost && !responseStarted,
|
||||
[hasReasoning, hasError, cost, responseStarted],
|
||||
)
|
||||
|
||||
// Check if this api_req will be absorbed into a tool group (reasoning will disappear)
|
||||
const willBeAbsorbed = useMemo(() => {
|
||||
return isApiReqAbsorbable(message.ts, clineMessages)
|
||||
}, [message.ts, clineMessages])
|
||||
|
||||
// Find all exploratory tool activities that are currently in flight.
|
||||
// Only show tools between the previous completed API request and the current incomplete one.
|
||||
// Once an API request completes (has cost), tool messages that follow belong to the next cycle.
|
||||
// Tools come AFTER the api_req_started message, so we look from currentApiReq forward.
|
||||
const currentActivities = useMemo(() => {
|
||||
const currentApiReq = findCurrentApiReq(clineMessages)
|
||||
if (!currentApiReq) {
|
||||
@@ -159,47 +173,76 @@ export const RequestStartRow: React.FC<RequestStartRowProps> = ({
|
||||
|
||||
if (!currentApiReq.hasCost) {
|
||||
// CASE A: Current api_req is INCOMPLETE
|
||||
const prevIdx = findPrevCompletedApiReq(clineMessages, currentApiReq.index)
|
||||
if (prevIdx === -1) {
|
||||
return []
|
||||
}
|
||||
return collectToolsInRange(clineMessages, prevIdx + 1, currentApiReq.index)
|
||||
// Look for ask === "tool" messages AFTER the current api_req_started
|
||||
return collectToolsInRange(clineMessages, currentApiReq.index + 1, clineMessages.length)
|
||||
}
|
||||
// CASE B: Current api_req is COMPLETE - no activities to show
|
||||
return []
|
||||
}, [clineMessages])
|
||||
|
||||
// Check if there are any completed tools in the tool group
|
||||
const hasCompletedTools = useMemo(() => {
|
||||
// Look for any completed low-stakes tool messages that would be in a tool group
|
||||
return clineMessages.some((msg, idx) => {
|
||||
if (msg.say === "tool" && isLowStakesTool(msg)) {
|
||||
// Check if this tool is from a completed API request
|
||||
// (looking backwards for an api_req with cost)
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
const prevMsg = clineMessages[i]
|
||||
if (prevMsg.say === "api_req_started" && prevMsg.text) {
|
||||
try {
|
||||
const info = JSON.parse(prevMsg.text)
|
||||
return info.cost != null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}, [clineMessages])
|
||||
|
||||
// Only show currentActivities if there are NO completed tools
|
||||
// (otherwise they'll be shown in the unified ToolGroupRenderer list)
|
||||
const shouldShowActivities = currentActivities.length > 0 && !hasCompletedTools
|
||||
|
||||
return (
|
||||
<div>
|
||||
{apiReqState === "pre" && (
|
||||
{apiReqState === "pre" && shouldShowActivities && (
|
||||
<div className="flex items-center text-description w-full text-sm">
|
||||
<div className="ml-1 flex-1 w-full h-full">
|
||||
{currentActivities.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5 w-full min-h-1">
|
||||
{currentActivities.map((activity, _) => (
|
||||
<div className="flex items-center gap-2 h-auto w-full overflow-hidden" key={activity.text}>
|
||||
<activity.icon className="size-2 text-foreground shrink-0" />
|
||||
<TypewriterText speed={15} text={activity.text} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TypewriterText
|
||||
text={message.partial !== false ? (mode === "plan" ? "Planning..." : "Thinking...") : ""}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5 w-full min-h-1">
|
||||
{currentActivities.map((activity, _) => (
|
||||
<div className="flex items-center gap-2 h-auto w-full overflow-hidden" key={activity.text}>
|
||||
<activity.icon className="size-2 text-foreground shrink-0" />
|
||||
<TypewriterText speed={15} text={activity.text} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reasoningContent && (
|
||||
<ThinkingRow
|
||||
isExpanded={isExpanded || showStreamingThinking || showCollapsedThinking}
|
||||
isVisible={true}
|
||||
onToggle={handleToggle}
|
||||
reasoningContent={reasoningContent}
|
||||
showTitle={false}
|
||||
/>
|
||||
)}
|
||||
{reasoningContent &&
|
||||
(!hasCost ? (
|
||||
// Still streaming - show "Thinking..." text with shimmer
|
||||
<div className="ml-1 pl-0 mb-1 -mt-1.25 pt-1">
|
||||
<div className="inline-flex justify-baseline gap-0.5 text-left select-none px-0 w-full">
|
||||
<span className="animate-shimmer bg-linear-90 from-foreground to-description bg-[length:200%_100%] bg-clip-text text-transparent">
|
||||
Thinking...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Complete - always show collapsible "Thoughts" section
|
||||
<ThinkingRow
|
||||
isExpanded={isExpanded}
|
||||
isVisible={true}
|
||||
onToggle={handleToggle}
|
||||
reasoningContent={reasoningContent}
|
||||
showTitle={true}
|
||||
/>
|
||||
))}
|
||||
|
||||
{apiReqState === "error" && (
|
||||
<ErrorRow
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
import React, { useMemo } from "react"
|
||||
import CodeAccordian from "../common/CodeAccordian"
|
||||
|
||||
@@ -121,12 +122,11 @@ const SearchResultsDisplay: React.FC<SearchResultsDisplayProps> = ({
|
||||
{path + (filePattern ? `/(${filePattern})` : "")}
|
||||
</span>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
<span
|
||||
className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}
|
||||
style={{
|
||||
fontSize: 13.5,
|
||||
margin: "1px 0",
|
||||
}}></span>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon size={16} style={{ margin: "1px 0" }} />
|
||||
) : (
|
||||
<ChevronRightIcon size={16} style={{ margin: "1px 0" }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type SlashCommand } from "@shared/slashCommands"
|
||||
import type { McpServer } from "@shared/mcp"
|
||||
import type { SlashCommand } from "@/utils/slash-commands"
|
||||
import React, { useCallback, useEffect, useRef } from "react"
|
||||
import ScreenReaderAnnounce from "@/components/common/ScreenReaderAnnounce"
|
||||
import { useMenuAnnouncement } from "@/hooks/useMenuAnnouncement"
|
||||
@@ -14,6 +15,7 @@ interface SlashCommandMenuProps {
|
||||
globalWorkflowToggles?: Record<string, boolean>
|
||||
remoteWorkflowToggles?: Record<string, boolean>
|
||||
remoteWorkflows?: any[]
|
||||
mcpServers?: McpServer[]
|
||||
}
|
||||
|
||||
const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
@@ -26,6 +28,7 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
globalWorkflowToggles = {},
|
||||
remoteWorkflowToggles,
|
||||
remoteWorkflows,
|
||||
mcpServers = [],
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -36,9 +39,11 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
globalWorkflowToggles,
|
||||
remoteWorkflowToggles,
|
||||
remoteWorkflows,
|
||||
mcpServers,
|
||||
)
|
||||
const defaultCommands = filteredCommands.filter((cmd) => cmd.section === "default" || !cmd.section)
|
||||
const workflowCommands = filteredCommands.filter((cmd) => cmd.section === "custom")
|
||||
const mcpCommands = filteredCommands.filter((cmd) => cmd.section === "mcp")
|
||||
|
||||
// Screen reader announcements
|
||||
const getCommandLabel = useCallback((command: SlashCommand) => {
|
||||
@@ -135,6 +140,12 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
<>
|
||||
{renderCommandSection(defaultCommands, "Default Commands", 0, true)}
|
||||
{renderCommandSection(workflowCommands, "Workflow Commands", defaultCommands.length, false)}
|
||||
{renderCommandSection(
|
||||
mcpCommands,
|
||||
"MCP Prompts",
|
||||
defaultCommands.length + workflowCommands.length,
|
||||
true,
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div aria-selected="false" className="py-2 px-3 cursor-default flex flex-col" role="option">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
import { memo, useEffect, useRef } from "react"
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -13,6 +13,14 @@ interface ThinkingRowProps {
|
||||
|
||||
export const ThinkingRow = memo(({ showTitle = false, reasoningContent, isVisible, isExpanded, onToggle }: ThinkingRowProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollDown, setCanScrollDown] = useState(false)
|
||||
|
||||
const checkScrollable = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
|
||||
setCanScrollDown(scrollTop + clientHeight < scrollHeight - 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only auto-scroll to bottom during streaming (showCursor=true)
|
||||
// For expanded collapsed thinking, start at top
|
||||
@@ -20,24 +28,31 @@ export const ThinkingRow = memo(({ showTitle = false, reasoningContent, isVisibl
|
||||
if (scrollRef.current && isVisible) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [reasoningContent, isVisible])
|
||||
checkScrollable()
|
||||
}, [reasoningContent, isVisible, checkScrollable])
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Don't render anything if collapsed and no title (nothing to show)
|
||||
if (!isExpanded && !showTitle) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ml-1">
|
||||
<div className="ml-1 pl-0 mb-1 -mt-1.25">
|
||||
{showTitle ? (
|
||||
<Button
|
||||
className="inline-flex justify-baseline gap-0.5 text-left select-none cursor-pointer text-description px-0 w-full"
|
||||
className="inline-flex justify-baseline gap-0.5 text-left select-none cursor-pointer px-0 w-full"
|
||||
onClick={onToggle}
|
||||
variant="icon">
|
||||
{isExpanded ? <ChevronDownIcon className="opacity-70" /> : <ChevronRightIcon className="opacity-70" />}
|
||||
<span className="font-semibold">Thinking:</span>
|
||||
<span className="italic break-words truncate [direction:rtl] w-full">
|
||||
{!isExpanded ? reasoningContent : ""}
|
||||
</span>
|
||||
<span>Thoughts</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="!size-1 text-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="!size-1 text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -55,15 +70,19 @@ export const ThinkingRow = memo(({ showTitle = false, reasoningContent, isVisibl
|
||||
disabled={!showTitle}
|
||||
onClick={onToggle}
|
||||
variant="text">
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[150px] overflow-y-auto text-description leading-normal truncated whitespace-pre-wrap break-words flex-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden [direction:ltr]",
|
||||
{
|
||||
"pl-2 border-l border-description/50": showTitle,
|
||||
},
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[150px] overflow-y-auto text-description leading-normal truncated whitespace-pre-wrap break-words [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden [direction:ltr]",
|
||||
"pl-2 border-l border-description/50",
|
||||
)}
|
||||
onScroll={checkScrollable}
|
||||
ref={scrollRef}>
|
||||
<span className="pb-2 block text-xs">{reasoningContent}</span>
|
||||
</div>
|
||||
{canScrollDown && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-6 pointer-events-none bg-gradient-to-t from-background to-transparent" />
|
||||
)}
|
||||
ref={scrollRef}>
|
||||
<span>{reasoningContent}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
import { useRef, useState } from "react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { getAsVar, VSC_TITLEBAR_INACTIVE_FOREGROUND } from "@/utils/vscStyles"
|
||||
@@ -161,11 +162,7 @@ const AutoApproveBar = ({ style }: AutoApproveBarProps) => {
|
||||
<span className="whitespace-nowrap">Auto-approve:</span>
|
||||
{getEnabledActionsText()}
|
||||
</div>
|
||||
{isModalVisible ? (
|
||||
<span className="codicon codicon-chevron-down" />
|
||||
) : (
|
||||
<span className="codicon codicon-chevron-up" />
|
||||
)}
|
||||
{isModalVisible ? <ChevronDownIcon size={16} /> : <ChevronRightIcon size={16} />}
|
||||
</div>
|
||||
|
||||
<AutoApproveModal
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* STASHED CODE - Idle Indicator with MutationObserver
|
||||
*
|
||||
* This code implements a "Thinking..."/"Working..." indicator that appears after 3 seconds
|
||||
* of DOM silence. It was removed from MessagesArea.tsx but preserved here for future use.
|
||||
*
|
||||
* To re-enable:
|
||||
* 1. Import this hook in MessagesArea.tsx
|
||||
* 2. Call useIdleIndicator() and get showIdleIndicator state
|
||||
* 3. Add the indicator to the Virtuoso Footer component
|
||||
*/
|
||||
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
// Idle timeout in milliseconds before showing indicator
|
||||
const IDLE_TIMEOUT_MS = 3000
|
||||
|
||||
/**
|
||||
* Hook that detects when the DOM has been idle for IDLE_TIMEOUT_MS
|
||||
* Uses MutationObserver to track actual content changes
|
||||
*/
|
||||
export function useIdleIndicator(scrollContainerRef: React.RefObject<HTMLDivElement>, clineMessages: ClineMessage[]): boolean {
|
||||
const [showIdleIndicator, setShowIdleIndicator] = useState(false)
|
||||
const idleTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const timerStartTimeRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if task is complete
|
||||
const isTaskComplete = clineMessages.some(
|
||||
(msg) => msg.ask === "completion_result" || msg.say === "completion_result" || msg.ask === "plan_mode_respond",
|
||||
)
|
||||
|
||||
if (isTaskComplete) {
|
||||
// Don't show indicator if task is complete
|
||||
setShowIdleIndicator(false)
|
||||
timerStartTimeRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[IdleIndicator] Setting up MutationObserver")
|
||||
|
||||
const resetIdleTimer = () => {
|
||||
// Clear existing timer
|
||||
if (idleTimerRef.current) {
|
||||
clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
|
||||
// Hide indicator immediately when new content arrives
|
||||
setShowIdleIndicator(false)
|
||||
|
||||
// Record start time if this is the first mutation
|
||||
if (!timerStartTimeRef.current) {
|
||||
timerStartTimeRef.current = Date.now()
|
||||
}
|
||||
|
||||
// Calculate elapsed and remaining time
|
||||
const elapsed = Date.now() - timerStartTimeRef.current
|
||||
const remaining = Math.max(0, IDLE_TIMEOUT_MS - elapsed)
|
||||
|
||||
console.log(
|
||||
`[IdleIndicator] DOM mutation detected, restarting timer. Elapsed: ${elapsed}ms, Remaining: ${remaining}ms`,
|
||||
)
|
||||
|
||||
// Start new timer for remaining duration
|
||||
idleTimerRef.current = setTimeout(() => {
|
||||
console.log("[IdleIndicator] DOM idle for 3s, showing indicator")
|
||||
setShowIdleIndicator(true)
|
||||
}, remaining)
|
||||
}
|
||||
|
||||
// Observe changes to the chat container
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
// Only reset timer if there are actual content changes
|
||||
const hasContentChange = mutations.some((mutation) => {
|
||||
return (
|
||||
mutation.type === "childList" ||
|
||||
mutation.type === "characterData" ||
|
||||
(mutation.type === "attributes" && mutation.attributeName !== "style")
|
||||
)
|
||||
})
|
||||
|
||||
if (hasContentChange) {
|
||||
resetIdleTimer()
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
})
|
||||
|
||||
// Start initial timer
|
||||
resetIdleTimer()
|
||||
|
||||
return () => {
|
||||
console.log("[IdleIndicator] Cleaning up MutationObserver")
|
||||
observer.disconnect()
|
||||
if (idleTimerRef.current) {
|
||||
clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
timerStartTimeRef.current = null
|
||||
}
|
||||
}, [scrollContainerRef, clineMessages])
|
||||
|
||||
return showIdleIndicator
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to render in Virtuoso Footer
|
||||
*
|
||||
* Usage:
|
||||
* <Virtuoso
|
||||
* components={{
|
||||
* Footer: () => (
|
||||
* <div>
|
||||
* <div className="min-h-1" />
|
||||
* {showIdleIndicator && (
|
||||
* <div className="flex items-center text-description text-sm px-4 pt-2.5 pb-2.5">
|
||||
* <div className="ml-1">
|
||||
* <TypewriterText text={mode === "plan" ? "Thinking..." : "Working..."} />
|
||||
* </div>
|
||||
* </div>
|
||||
* )}
|
||||
* </div>
|
||||
* ),
|
||||
* }}
|
||||
* />
|
||||
*/
|
||||
@@ -60,10 +60,22 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({
|
||||
}, [messageOrGroup, modifiedMessages])
|
||||
|
||||
// Tool group (low-stakes tools grouped together)
|
||||
// Always render - the loading state in ChatRow shows current activities,
|
||||
// while ToolGroupRenderer shows what's in context. Overlap is fine.
|
||||
// Determine if this is the last tool group to show active items
|
||||
const isLastToolGroup = useMemo(() => {
|
||||
if (!isToolGroup(messageOrGroup)) {
|
||||
return false
|
||||
}
|
||||
// Find the last tool group in groupedMessages
|
||||
for (let i = groupedMessages.length - 1; i >= 0; i--) {
|
||||
if (isToolGroup(groupedMessages[i])) {
|
||||
return i === index
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, [messageOrGroup, groupedMessages, index])
|
||||
|
||||
if (isToolGroup(messageOrGroup)) {
|
||||
return <ToolGroupRenderer allMessages={modifiedMessages} messages={messageOrGroup} />
|
||||
return <ToolGroupRenderer allMessages={modifiedMessages} isLastGroup={isLastToolGroup} messages={messageOrGroup} />
|
||||
}
|
||||
|
||||
// Browser session group
|
||||
|
||||
+167
-42
@@ -1,9 +1,9 @@
|
||||
import { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { memo, useCallback, useMemo, useState } from "react"
|
||||
import { TypewriterText } from "@/components/chat/TypewriterText"
|
||||
import { cleanPathPrefix } from "@/components/common/CodeAccordian"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
import { getIconByToolName, getToolsNotInCurrentActivities, isLowStakesTool } from "../../utils/messageUtils"
|
||||
@@ -11,28 +11,138 @@ import { getIconByToolName, getToolsNotInCurrentActivities, isLowStakesTool } fr
|
||||
interface ToolGroupRendererProps {
|
||||
messages: ClineMessage[]
|
||||
allMessages: ClineMessage[]
|
||||
isLastGroup: boolean
|
||||
}
|
||||
|
||||
interface ToolWithReasoning {
|
||||
tool: ClineMessage
|
||||
parsedTool: ClineSayTool
|
||||
reasoning?: string
|
||||
isActive?: boolean
|
||||
activityText?: string
|
||||
}
|
||||
|
||||
const EXPANDABLE_TOOLS = new Set(["listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"])
|
||||
|
||||
// Helper to format activity text for active items (from RequestStartRow logic)
|
||||
const getActivityText = (tool: ClineSayTool): string | null => {
|
||||
const cleanedPath = cleanPathPrefix(tool.path || "")
|
||||
const formatSearchRegex = (regex: string, path: string, filePattern?: string): string => {
|
||||
const cleanedPath = cleanPathPrefix(path)
|
||||
const terms = regex
|
||||
.split("|")
|
||||
.map((t) => t.trim().replace(/\\b/g, "").replace(/\\s\?/g, " "))
|
||||
.filter(Boolean)
|
||||
.join(" | ")
|
||||
return filePattern && filePattern !== "*"
|
||||
? `"${terms}" in ${cleanedPath}/ (${filePattern})`
|
||||
: `"${terms}" in ${cleanedPath}/`
|
||||
}
|
||||
|
||||
switch (tool.tool) {
|
||||
case "readFile":
|
||||
return tool.path ? `Reading ${cleanedPath}...` : null
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
return tool.path ? `Exploring ${cleanedPath}/...` : null
|
||||
case "searchFiles":
|
||||
return tool.regex && tool.path ? `Searching ${formatSearchRegex(tool.regex, tool.path, tool.filePattern)}...` : null
|
||||
case "listCodeDefinitionNames":
|
||||
return tool.path ? `Analyzing ${cleanedPath}/...` : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate current activities (from RequestStartRow logic)
|
||||
const getCurrentActivities = (allMessages: ClineMessage[]): ClineMessage[] => {
|
||||
// Find current api_req
|
||||
let currentApiReqIndex = -1
|
||||
for (let i = allMessages.length - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const info = JSON.parse(msg.text)
|
||||
const hasCost = info.cost != null
|
||||
if (!hasCost) {
|
||||
currentApiReqIndex = i
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentApiReqIndex === -1) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Collect tools AFTER the current api_req_started
|
||||
const activities: ClineMessage[] = []
|
||||
for (let i = currentApiReqIndex + 1; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i]
|
||||
// Only collect tools that are currently executing (ask === "tool")
|
||||
// Skip completed tools (say === "tool") - they should be in the completed list
|
||||
if (msg.say === "tool" || msg.ask !== "tool") {
|
||||
continue
|
||||
}
|
||||
if (isLowStakesTool(msg)) {
|
||||
activities.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return activities
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a collapsible group of low-stakes tool calls.
|
||||
* Only shows tools that are NOT in the "current activities" range (PAST tools only).
|
||||
* Shows both completed tools AND currently active tools in a unified list (only for last group).
|
||||
*/
|
||||
export const ToolGroupRenderer = memo(({ messages, allMessages }: ToolGroupRendererProps) => {
|
||||
export const ToolGroupRenderer = memo(({ messages, allMessages, isLastGroup }: ToolGroupRendererProps) => {
|
||||
const [expandedItems, setExpandedItems] = useState<Record<number, boolean>>({})
|
||||
|
||||
// Filter out tools in the "current activities" range (being shown in loading state)
|
||||
const filteredMessages = useMemo(() => getToolsNotInCurrentActivities(messages, allMessages), [messages, allMessages])
|
||||
|
||||
// Build tool items with associated reasoning (reasoning that comes BEFORE a tool)
|
||||
const toolsWithReasoning = useMemo(() => buildToolsWithReasoning(filteredMessages), [filteredMessages])
|
||||
// Get current activities (active reading/exploring) - only for last group
|
||||
const currentActivities = useMemo(() => {
|
||||
if (!isLastGroup) {
|
||||
return []
|
||||
}
|
||||
return getCurrentActivities(allMessages)
|
||||
}, [allMessages, isLastGroup])
|
||||
|
||||
// Build completed tool items
|
||||
const completedTools = useMemo(() => buildToolsWithReasoning(filteredMessages), [filteredMessages])
|
||||
|
||||
// Build active tool items
|
||||
const activeTools = useMemo(() => {
|
||||
return currentActivities
|
||||
.map((msg) => {
|
||||
const parsedTool = parseToolSafe(msg.text)
|
||||
return {
|
||||
tool: msg,
|
||||
parsedTool,
|
||||
reasoning: undefined,
|
||||
isActive: true,
|
||||
activityText: getActivityText(parsedTool),
|
||||
}
|
||||
})
|
||||
.filter((item) => item.activityText)
|
||||
}, [currentActivities])
|
||||
|
||||
// Merge: completed items first, then active items (active only added to last group)
|
||||
// Deduplicate - exclude completed items that match active items by path
|
||||
const allTools = useMemo(() => {
|
||||
// Get paths of active items
|
||||
const activePaths = new Set(activeTools.map((item) => item.parsedTool.path).filter(Boolean))
|
||||
|
||||
// Filter out completed items that are also being actively read
|
||||
const dedupedCompleted = completedTools.filter((item) => !activePaths.has(item.parsedTool.path))
|
||||
|
||||
return [...dedupedCompleted, ...activeTools]
|
||||
}, [completedTools, activeTools])
|
||||
|
||||
const summary = getToolGroupSummary(filteredMessages)
|
||||
|
||||
@@ -46,19 +156,19 @@ export const ToolGroupRenderer = memo(({ messages, allMessages }: ToolGroupRende
|
||||
setExpandedItems((prev) => ({ ...prev, [ts]: !prev[ts] }))
|
||||
}, [])
|
||||
|
||||
// Don't render if no PAST tools to show
|
||||
if (toolsWithReasoning.length === 0) {
|
||||
// Don't render if no tools to show
|
||||
if (allTools.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-2 text-description")}>
|
||||
<div className={cn("px-4 py-2 ml-1 text-description")}>
|
||||
{/* Header */}
|
||||
<div className="text-[13px] opacity-90 mb-1">{summary}:</div>
|
||||
<div className="text-[13px] text-foreground mb-1">{summary}:</div>
|
||||
|
||||
{/* Content - files/folders with reasoning in tooltip */}
|
||||
{/* Content - unified list of completed + active tools */}
|
||||
<div className="min-w-0">
|
||||
{toolsWithReasoning.map(({ tool, parsedTool, reasoning }) => {
|
||||
{allTools.map(({ tool, parsedTool, isActive, activityText }) => {
|
||||
const info = getToolDisplayInfo(parsedTool)
|
||||
if (!info) {
|
||||
return null
|
||||
@@ -67,32 +177,46 @@ export const ToolGroupRenderer = memo(({ messages, allMessages }: ToolGroupRende
|
||||
const isExpandable = EXPANDABLE_TOOLS.has(parsedTool.tool)
|
||||
const isItemExpanded = expandedItems[tool.ts] ?? false
|
||||
const content = parsedTool.content || null
|
||||
const hasReasoning = !!reasoning?.length
|
||||
|
||||
// Active items render with "Reading..." TypewriterText (match completed item structure exactly)
|
||||
if (isActive && activityText) {
|
||||
return (
|
||||
<div className="min-w-0" key={tool.ts}>
|
||||
{/* ACTIVE "READING..." ITEM STYLING - Modify vertical spacing here via py-0 and -my-0.5 */}
|
||||
<Button
|
||||
className="flex items-center gap-[3px] text-[13px] text-description py-[1px] min-w-0 max-w-full px-0 leading-tight -my-0.5"
|
||||
disabled
|
||||
size="icon"
|
||||
variant="text">
|
||||
<info.icon className="opacity-70 shrink-0 size-[12px]" />
|
||||
<span className="flex-1 min-w-0 whitespace-nowrap overflow-hidden text-ellipsis text-left text-[13px]">
|
||||
<TypewriterText speed={15} text={activityText} />
|
||||
</span>{" "}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Completed items render normally (clickable)
|
||||
return (
|
||||
<div className="min-w-0" key={tool.ts}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-1.5 cursor-pointer text-[13px] text-description py-0.5 hover:text-link min-w-0 max-w-full px-0"
|
||||
onClick={() => (isExpandable ? handleItemToggle(tool.ts) : handleOpenFile(info.path))}
|
||||
size="icon"
|
||||
variant="text">
|
||||
<info.icon className="opacity-70 shrink-0 size-[13px]" />
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 min-w-0 whitespace-nowrap overflow-hidden text-ellipsis text-left [direction:rtl] text-[13px]",
|
||||
{
|
||||
"[direction:ltr]": !!info.displayText,
|
||||
},
|
||||
)}>
|
||||
{(info.displayText || cleanPathPrefix(info.path)) + "\u200E"}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{hasReasoning && <TooltipContent side="bottom">{reasoning}</TooltipContent>}
|
||||
</Tooltip>
|
||||
{/* Expanded content for folders/search/definitions - raw text */}
|
||||
<Button
|
||||
className="flex items-center gap-[3px] cursor-pointer text-[13px] text-description py-[1px] hover:text-link min-w-0 max-w-full px-0 leading-tight -my-0.5"
|
||||
onClick={() => (isExpandable ? handleItemToggle(tool.ts) : handleOpenFile(info.path))}
|
||||
size="icon"
|
||||
variant="text">
|
||||
<info.icon className="opacity-70 shrink-0 size-[12px]" />
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 min-w-0 whitespace-nowrap overflow-hidden text-ellipsis text-left [direction:rtl] text-[13px]",
|
||||
{
|
||||
"[direction:ltr]": !!info.displayText,
|
||||
},
|
||||
)}>
|
||||
{(info.displayText || cleanPathPrefix(info.path)) + "\u200E"}
|
||||
</span>
|
||||
</Button>
|
||||
{/* Expanded content for folders/search/definitions - file lists only */}
|
||||
{isExpandable && isItemExpanded && content && (
|
||||
<pre className="m-1 ml-4 text-xs opacity-80 whitespace-pre-wrap break-words p-2 max-h-40 overflow-auto rounded-xs">
|
||||
{content}
|
||||
@@ -107,24 +231,25 @@ export const ToolGroupRenderer = memo(({ messages, allMessages }: ToolGroupRende
|
||||
})
|
||||
|
||||
/**
|
||||
* Build tool items with associated reasoning (reasoning that comes BEFORE a tool).
|
||||
* Only processes low-stakes tools, accumulating reasoning messages along the way.
|
||||
* Build tool items WITHOUT reasoning.
|
||||
* Reasoning should not be displayed in file lists - only file/folder content.
|
||||
*/
|
||||
function buildToolsWithReasoning(messages: ClineMessage[]): ToolWithReasoning[] {
|
||||
const result: ToolWithReasoning[] = []
|
||||
const reasoningBuffer: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.say === "reasoning" && msg.text) {
|
||||
reasoningBuffer.push(msg.text)
|
||||
} else if (isLowStakesTool(msg)) {
|
||||
// Skip reasoning messages - they should not be in file lists
|
||||
if (msg.say === "reasoning") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLowStakesTool(msg)) {
|
||||
const parsedTool = parseToolSafe(msg.text)
|
||||
result.push({
|
||||
tool: msg,
|
||||
parsedTool,
|
||||
reasoning: reasoningBuffer.length > 0 ? reasoningBuffer.join("\n\n") : undefined,
|
||||
reasoning: undefined, // Never show reasoning in file lists
|
||||
})
|
||||
reasoningBuffer.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ export function groupMessages(visibleMessages: ClineMessage[]): (ClineMessage |
|
||||
}
|
||||
}
|
||||
|
||||
visibleMessages.forEach((message) => {
|
||||
for (const message of visibleMessages) {
|
||||
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
|
||||
// complete existing browser session if any
|
||||
endBrowserSession()
|
||||
@@ -144,7 +144,7 @@ export function groupMessages(visibleMessages: ClineMessage[]): (ClineMessage |
|
||||
if (isCancelled) {
|
||||
endBrowserSession()
|
||||
result.push(message)
|
||||
return
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export function groupMessages(visibleMessages: ClineMessage[]): (ClineMessage |
|
||||
} else {
|
||||
result.push(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handle case where browser session is the last group
|
||||
if (currentGroup.length > 0) {
|
||||
@@ -614,14 +614,21 @@ export function isApiReqAbsorbable(apiReqTs: number, allMessages: ClineMessage[]
|
||||
}
|
||||
|
||||
let hasLowStakesTool = false
|
||||
let hasReasoning = false
|
||||
for (let i = apiReqIndex + 1; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started") {
|
||||
break
|
||||
}
|
||||
|
||||
// Reasoning and checkpoints do not affect absorbability
|
||||
if (msg.say === "reasoning" || msg.say === "checkpoint_created") {
|
||||
// Reasoning - mark it but don't absorb if present
|
||||
if (msg.say === "reasoning") {
|
||||
hasReasoning = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Checkpoints do not affect absorbability
|
||||
if (msg.say === "checkpoint_created") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -642,17 +649,19 @@ export function isApiReqAbsorbable(apiReqTs: number, allMessages: ClineMessage[]
|
||||
}
|
||||
}
|
||||
|
||||
return hasLowStakesTool
|
||||
// Don't absorb if there's reasoning - we want to show "Thoughts >"
|
||||
return hasLowStakesTool && !hasReasoning
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an api_req_started at a given index produces low-stakes tools
|
||||
* (regardless of whether it also produces text).
|
||||
* If so, it should be absorbed into the tool group rather than rendered separately.
|
||||
* The key is: no HIGH-stakes tools (write, edit, command, etc.)
|
||||
* The key is: no HIGH-stakes tools (write, edit, command, etc.) AND no reasoning
|
||||
*/
|
||||
function isApiReqFollowedOnlyByLowStakesTools(index: number, messages: (ClineMessage | ClineMessage[])[]): boolean {
|
||||
let hasLowStakesTool = false
|
||||
let hasReasoning = false
|
||||
for (let i = index + 1; i < messages.length; i++) {
|
||||
const item = messages[i]
|
||||
if (Array.isArray(item)) {
|
||||
@@ -664,8 +673,9 @@ function isApiReqFollowedOnlyByLowStakesTools(index: number, messages: (ClineMes
|
||||
if (msg.say === "api_req_started") {
|
||||
break
|
||||
}
|
||||
// Reasoning is allowed
|
||||
// Reasoning - mark it but don't absorb if present
|
||||
if (msg.say === "reasoning") {
|
||||
hasReasoning = true
|
||||
continue
|
||||
}
|
||||
// Low-stakes tool - mark it
|
||||
@@ -686,7 +696,8 @@ function isApiReqFollowedOnlyByLowStakesTools(index: number, messages: (ClineMes
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLowStakesTool
|
||||
// Don't absorb if there's reasoning - we want to show "Thoughts >"
|
||||
return hasLowStakesTool && !hasReasoning
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -111,6 +111,7 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("Checkpoint restore task error:", err)
|
||||
} finally {
|
||||
setRestoreTaskDisabled(false)
|
||||
}
|
||||
}
|
||||
@@ -127,6 +128,7 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("Checkpoint restore workspace error:", err)
|
||||
} finally {
|
||||
setRestoreWorkspaceDisabled(false)
|
||||
}
|
||||
}
|
||||
@@ -143,6 +145,7 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("Checkpoint restore both error:", err)
|
||||
} finally {
|
||||
setRestoreBothDisabled(false)
|
||||
}
|
||||
}
|
||||
@@ -295,7 +298,7 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
|
||||
const Container = styled.div<{ isMenuOpen?: boolean; $isCheckedOut?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
padding: 8px 0px 0px 0px;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -304,6 +307,11 @@ const Container = styled.div<{ isMenuOpen?: boolean; $isCheckedOut?: boolean }>`
|
||||
margin-bottom: 1px;
|
||||
opacity: ${(props) => (props.$isCheckedOut ? 1 : props.isMenuOpen ? 1 : 0.5)};
|
||||
height: 0.5rem;
|
||||
|
||||
&:first-of-type {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
import { memo, useMemo } from "react"
|
||||
import CodeBlock from "@/components/common/CodeBlock"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -88,7 +89,7 @@ const CodeAccordian = ({
|
||||
<span>{numberOfEdits}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`} />
|
||||
{isExpanded ? <ChevronDownIcon className="size-4" /> : <ChevronRightIcon className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{(!(path || isFeedback || isConsoleLogs) || isExpanded) && (
|
||||
|
||||
@@ -16,7 +16,13 @@ interface WhatsNewModalProps {
|
||||
|
||||
export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, version }) => {
|
||||
const { clineUser } = useClineAuth()
|
||||
const { openRouterModels, setShowChatModelSelector, refreshOpenRouterModels, navigateToSettings } = useExtensionState()
|
||||
const {
|
||||
openRouterModels,
|
||||
setShowChatModelSelector,
|
||||
refreshOpenRouterModels,
|
||||
navigateToSettings,
|
||||
navigateToSettingsModelPicker,
|
||||
} = useExtensionState()
|
||||
const { handleFieldsChange } = useApiConfigurationHandlers()
|
||||
|
||||
const clickedModelsRef = useRef<Set<string>>(new Set())
|
||||
@@ -42,6 +48,19 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
|
||||
[handleFieldsChange, openRouterModels, setShowChatModelSelector, onClose],
|
||||
)
|
||||
|
||||
const navigateToModelPicker = useCallback(
|
||||
(initialModelTab: "recommended" | "free") => {
|
||||
// Switch to Cline provider first so the model picker tab works
|
||||
handleFieldsChange({
|
||||
planModeApiProvider: "cline",
|
||||
actModeApiProvider: "cline",
|
||||
})
|
||||
onClose()
|
||||
navigateToSettingsModelPicker({ targetSection: "api-config", initialModelTab })
|
||||
},
|
||||
[handleFieldsChange, navigateToSettingsModelPicker, onClose],
|
||||
)
|
||||
|
||||
const setOpenAiCodexProvider = useCallback(() => {
|
||||
handleFieldsChange({
|
||||
planModeApiProvider: "openai-codex",
|
||||
@@ -79,6 +98,35 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
|
||||
</Button>
|
||||
)
|
||||
|
||||
type InlineModelLinkProps =
|
||||
| { type: "model"; modelId: string; label: string }
|
||||
| { type: "picker"; pickerTab: "recommended" | "free"; label: string }
|
||||
|
||||
const InlineModelLink: React.FC<InlineModelLinkProps> = (props) => {
|
||||
if (props.type === "picker") {
|
||||
return (
|
||||
<span
|
||||
onClick={() => navigateToModelPicker(props.pickerTab)}
|
||||
style={{ color: "var(--vscode-textLink-foreground)", cursor: "pointer" }}>
|
||||
{props.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const isClicked = clickedModelsRef.current.has(props.modelId)
|
||||
if (isClicked) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={() => setModel(props.modelId)}
|
||||
style={{ color: "var(--vscode-textLink-foreground)", cursor: "pointer" }}>
|
||||
{props.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(isOpen) => !isOpen && onClose()} open={open}>
|
||||
<DialogContent
|
||||
@@ -96,25 +144,28 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
|
||||
{/* Description */}
|
||||
<ul className="text-sm pl-3 list-disc" style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
<li className="mb-2">
|
||||
<strong>OpenAI ChatGPT Subscription Integration:</strong> Use your ChatGPT subscription directly in
|
||||
Cline with no additional token cost and no api keys to manage.{" "}
|
||||
<strong>New free model: Arcee Trinity Large:</strong> strong coding performance with an open-weight
|
||||
model.{" "}
|
||||
<InlineModelLink label="Try free" modelId="cline:arcee-ai/trinity-large-preview:free" type="model" />
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Try Kimi K2.5:</strong> Moonshot's latest with advanced reasoning for complex, multi-step
|
||||
coding tasks. Great for front-end tasks.{" "}
|
||||
<InlineModelLink label="Try now" modelId="cline:moonshotai/kimi-k2.5" type="model" />
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Bring your ChatGPT subscription to Cline!</strong> Use your existing plan directly with no per
|
||||
token costs or API keys to manage.{" "}
|
||||
<span
|
||||
onClick={setOpenAiCodexProvider}
|
||||
style={{ color: "var(--vscode-textLink-foreground)", cursor: "pointer" }}>
|
||||
Sign in
|
||||
Connect
|
||||
</span>
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Jupyter Notebooks:</strong> Comprehensive AI-assisted editing of <code>.ipynb</code> files
|
||||
with full cell-level context awareness.{" "}
|
||||
<a
|
||||
href="https://docs.cline.bot/features/jupyter-notebooks"
|
||||
style={{ color: "var(--vscode-textLink-foreground)" }}>
|
||||
Learn More
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Grok Code Fast 1: </strong> is no longer free to use.
|
||||
<strong>Grok Code Fast 1 & Devstral are saying goodbye (to free):</strong> free promotion is done but
|
||||
there are plenty models in our free tier.{" "}
|
||||
<InlineModelLink label="See alternatives" pickerTab="free" type="picker" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { McpDisplayMode } from "@shared/McpDisplayMode"
|
||||
import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import styled from "styled-components"
|
||||
import ChatErrorBoundary from "@/components/chat/ChatErrorBoundary"
|
||||
@@ -223,7 +224,11 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
marginBottom: isExpanded ? "8px" : "0px",
|
||||
}}>
|
||||
<div className="header-title">
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? "down" : "right"} header-icon`}></span>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="header-icon" size={16} />
|
||||
) : (
|
||||
<ChevronRightIcon className="header-icon" size={16} />
|
||||
)}
|
||||
Response
|
||||
</div>
|
||||
<DropdownContainer
|
||||
@@ -247,7 +252,11 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
<ResponseContainer>
|
||||
<ResponseHeader onClick={toggleExpand}>
|
||||
<div className="header-title">
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? "down" : "right"} header-icon`}></span>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="header-icon" size={16} />
|
||||
) : (
|
||||
<ChevronRightIcon className="header-icon" size={16} />
|
||||
)}
|
||||
Response (Error)
|
||||
</div>
|
||||
</ResponseHeader>
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { McpPrompt } from "@shared/mcp"
|
||||
|
||||
type McpPromptRowProps = {
|
||||
prompt: McpPrompt
|
||||
serverName?: string
|
||||
}
|
||||
|
||||
const McpPromptRow = ({ prompt, serverName }: McpPromptRowProps) => {
|
||||
return (
|
||||
<div
|
||||
key={prompt.name}
|
||||
style={{
|
||||
padding: "3px 0",
|
||||
}}>
|
||||
<div
|
||||
data-testid="prompt-row-container"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "4px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", minWidth: 0, flex: "1 1 auto" }}>
|
||||
<span className="codicon codicon-comment-discussion" style={{ marginRight: "6px", flexShrink: 0 }}></span>
|
||||
<span style={{ fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{prompt.title || prompt.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{prompt.description && (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "0px",
|
||||
marginTop: "4px",
|
||||
opacity: 0.8,
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
{prompt.description}
|
||||
</div>
|
||||
)}
|
||||
{prompt.arguments && prompt.arguments.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
fontSize: "12px",
|
||||
border: "1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 30%, transparent)",
|
||||
borderRadius: "3px",
|
||||
padding: "8px",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "4px",
|
||||
opacity: 0.8,
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Arguments
|
||||
</div>
|
||||
{prompt.arguments.map((arg) => (
|
||||
<div
|
||||
key={arg.name}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
marginTop: "4px",
|
||||
}}>
|
||||
<code
|
||||
style={{
|
||||
color: "var(--vscode-textPreformat-foreground)",
|
||||
marginRight: "8px",
|
||||
}}>
|
||||
{arg.name}
|
||||
{arg.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</code>
|
||||
<span
|
||||
style={{
|
||||
opacity: 0.8,
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{arg.description || "No description"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpPromptRow
|
||||
@@ -24,6 +24,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { McpServiceClient } from "@/services/grpc-client"
|
||||
import { getMcpServerDisplayName } from "@/utils/mcp"
|
||||
import McpPromptRow from "./McpPromptRow"
|
||||
import McpResourceRow from "./McpResourceRow"
|
||||
import McpToolRow from "./McpToolRow"
|
||||
|
||||
@@ -317,6 +318,7 @@ const ServerRow = ({
|
||||
<VSCodePanelTab id="resources">
|
||||
Resources ({[...(server.resourceTemplates || []), ...(server.resources || [])].length || 0})
|
||||
</VSCodePanelTab>
|
||||
<VSCodePanelTab id="prompts">Prompts ({server.prompts?.length || 0})</VSCodePanelTab>
|
||||
|
||||
<VSCodePanelView id="tools-view">
|
||||
{server.tools && server.tools.length > 0 ? (
|
||||
@@ -354,6 +356,31 @@ const ServerRow = ({
|
||||
<div className="py-2.5 text-description">No resources found</div>
|
||||
)}
|
||||
</VSCodePanelView>
|
||||
|
||||
<VSCodePanelView id="prompts-view">
|
||||
{server.prompts && server.prompts.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
width: "100%",
|
||||
paddingTop: "8px",
|
||||
}}>
|
||||
{server.prompts.map((prompt) => (
|
||||
<McpPromptRow key={prompt.name} prompt={prompt} serverName={server.name} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 0",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
No prompts found
|
||||
</div>
|
||||
)}
|
||||
</VSCodePanelView>
|
||||
</VSCodePanels>
|
||||
|
||||
<div className="my-2.5 mx-1.5">
|
||||
|
||||
@@ -61,6 +61,7 @@ interface ApiOptionsProps {
|
||||
modelIdErrorMessage?: string
|
||||
isPopup?: boolean
|
||||
currentMode: Mode
|
||||
initialModelTab?: "recommended" | "free"
|
||||
}
|
||||
|
||||
// This is necessary to ensure dropdown opens downward, important for when this is used in popup
|
||||
@@ -87,7 +88,14 @@ declare module "vscode" {
|
||||
}
|
||||
}
|
||||
|
||||
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, isPopup, currentMode }: ApiOptionsProps) => {
|
||||
const ApiOptions = ({
|
||||
showModelOptions,
|
||||
apiErrorMessage,
|
||||
modelIdErrorMessage,
|
||||
isPopup,
|
||||
currentMode,
|
||||
initialModelTab,
|
||||
}: ApiOptionsProps) => {
|
||||
// Use full context state for immediate save payload
|
||||
const { apiConfiguration, remoteConfigSettings } = useExtensionState()
|
||||
|
||||
@@ -349,7 +357,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
)}
|
||||
|
||||
{apiConfiguration && selectedProvider === "cline" && (
|
||||
<ClineProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
|
||||
<ClineProvider
|
||||
currentMode={currentMode}
|
||||
initialModelTab={initialModelTab}
|
||||
isPopup={isPopup}
|
||||
showModelOptions={showModelOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiConfiguration && selectedProvider === "asksage" && (
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface OpenRouterModelPickerProps {
|
||||
isPopup?: boolean
|
||||
currentMode: Mode
|
||||
showProviderRouting?: boolean
|
||||
initialTab?: "recommended" | "free"
|
||||
}
|
||||
|
||||
// Featured models for Cline provider organized by tabs
|
||||
@@ -81,15 +82,20 @@ export const freeModels = [
|
||||
label: "FREE",
|
||||
},
|
||||
{
|
||||
id: "mistralai/devstral-2512:free",
|
||||
description: "Mistral's latest model with strong coding abilities",
|
||||
id: "arcee-ai/trinity-large-preview:free",
|
||||
description: "Arcee AI's advanced large preview model in the Trinity series",
|
||||
label: "FREE",
|
||||
},
|
||||
]
|
||||
|
||||
const FREE_CLINE_MODELS = freeModels.map((m) => m.id)
|
||||
|
||||
const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup, currentMode, showProviderRouting }) => {
|
||||
const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
|
||||
isPopup,
|
||||
currentMode,
|
||||
showProviderRouting,
|
||||
initialTab,
|
||||
}) => {
|
||||
const { handleModeFieldChange, handleModeFieldsChange, handleFieldChange } = useApiConfigurationHandlers()
|
||||
const { apiConfiguration, favoritedModelIds, openRouterModels, refreshOpenRouterModels } = useExtensionState()
|
||||
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
|
||||
@@ -97,9 +103,19 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const [activeTab, setActiveTab] = useState<"recommended" | "free">(() => {
|
||||
if (initialTab) {
|
||||
return initialTab
|
||||
}
|
||||
const currentModelId = modeFields.openRouterModelId || openRouterDefaultModelId
|
||||
return freeModels.some((m) => m.id === currentModelId) ? "free" : "recommended"
|
||||
})
|
||||
|
||||
// If a caller wants to deep-link to the Free tab (or Recommended), honor that.
|
||||
useEffect(() => {
|
||||
if (initialTab) {
|
||||
setActiveTab(initialTab)
|
||||
}
|
||||
}, [initialTab])
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const dropdownListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -131,7 +131,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
[],
|
||||
) // Empty deps - these imports never change
|
||||
|
||||
const { version, environment } = useExtensionState()
|
||||
const { version, environment, settingsInitialModelTab } = useExtensionState()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(targetSection || SETTINGS_TABS[0].id)
|
||||
|
||||
@@ -233,10 +233,12 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
props.onResetState = handleResetState
|
||||
} else if (activeTab === "about") {
|
||||
props.version = version
|
||||
} else if (activeTab === "api-config") {
|
||||
props.initialModelTab = settingsInitialModelTab
|
||||
}
|
||||
|
||||
return <Component {...props} />
|
||||
}, [activeTab, handleResetState, version])
|
||||
}, [activeTab, handleResetState, settingsInitialModelTab, version])
|
||||
|
||||
const titleColor = getEnvironmentColor(environment)
|
||||
|
||||
|
||||
@@ -9,12 +9,13 @@ interface ClineProviderProps {
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
currentMode: Mode
|
||||
initialModelTab?: "recommended" | "free"
|
||||
}
|
||||
|
||||
/**
|
||||
* The Cline provider configuration component
|
||||
*/
|
||||
export const ClineProvider = ({ showModelOptions, isPopup, currentMode }: ClineProviderProps) => {
|
||||
export const ClineProvider = ({ showModelOptions, isPopup, currentMode, initialModelTab }: ClineProviderProps) => {
|
||||
return (
|
||||
<div>
|
||||
{/* Cline Account Info Card */}
|
||||
@@ -25,7 +26,12 @@ export const ClineProvider = ({ showModelOptions, isPopup, currentMode }: ClineP
|
||||
{showModelOptions && (
|
||||
<>
|
||||
{/* OpenRouter Model Picker - includes Provider Routing in Advanced section */}
|
||||
<OpenRouterModelPicker currentMode={currentMode} isPopup={isPopup} showProviderRouting={true} />
|
||||
<OpenRouterModelPicker
|
||||
currentMode={currentMode}
|
||||
initialTab={initialModelTab}
|
||||
isPopup={isPopup}
|
||||
showProviderRouting={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,9 +12,10 @@ import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandler
|
||||
|
||||
interface ApiConfigurationSectionProps {
|
||||
renderSectionHeader?: (tabId: string) => JSX.Element | null
|
||||
initialModelTab?: "recommended" | "free"
|
||||
}
|
||||
|
||||
const ApiConfigurationSection = ({ renderSectionHeader }: ApiConfigurationSectionProps) => {
|
||||
const ApiConfigurationSection = ({ renderSectionHeader, initialModelTab }: ApiConfigurationSectionProps) => {
|
||||
const { planActSeparateModelsSetting, mode, apiConfiguration } = useExtensionState()
|
||||
const [currentTab, setCurrentTab] = useState<Mode>(mode)
|
||||
const { handleFieldsChange } = useApiConfigurationHandlers()
|
||||
@@ -50,11 +51,11 @@ const ApiConfigurationSection = ({ renderSectionHeader }: ApiConfigurationSectio
|
||||
|
||||
{/* Content container */}
|
||||
<div className="-mb-3">
|
||||
<ApiOptions currentMode={currentTab} showModelOptions={true} />
|
||||
<ApiOptions currentMode={currentTab} initialModelTab={initialModelTab} showModelOptions={true} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ApiOptions currentMode={mode} showModelOptions={true} />
|
||||
<ApiOptions currentMode={mode} initialModelTab={initialModelTab} showModelOptions={true} />
|
||||
)}
|
||||
|
||||
<div className="mb-[5px]">
|
||||
|
||||
@@ -824,7 +824,7 @@ export function filterOpenRouterModelIds(modelIds: string[], provider: ApiProvid
|
||||
// For Cline provider: exclude :free models, but keep Minimax models
|
||||
return modelIds.filter((id) => {
|
||||
// Keep all Minimax and devstral models regardless of :free suffix
|
||||
if (id.toLowerCase().includes("minimax-m2") || id.toLowerCase().includes("devstral-2512")) {
|
||||
if (id.toLowerCase().includes("minimax-m2") || id.toLowerCase().includes("arcee-ai/trinity-large")) {
|
||||
return true
|
||||
}
|
||||
// Filter out other :free models
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
mcpTab?: McpViewTab
|
||||
showSettings: boolean
|
||||
settingsTargetSection?: string
|
||||
settingsInitialModelTab?: "recommended" | "free"
|
||||
showHistory: boolean
|
||||
showAccount: boolean
|
||||
showWorktrees: boolean
|
||||
@@ -102,6 +103,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
// Navigation functions
|
||||
navigateToMcp: (tab?: McpViewTab) => void
|
||||
navigateToSettings: (targetSection?: string) => void
|
||||
navigateToSettingsModelPicker: (opts: { targetSection?: string; initialModelTab?: "recommended" | "free" }) => void
|
||||
navigateToHistory: () => void
|
||||
navigateToAccount: () => void
|
||||
navigateToWorktrees: () => void
|
||||
@@ -130,6 +132,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const [mcpTab, setMcpTab] = useState<McpViewTab | undefined>(undefined)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [settingsTargetSection, setSettingsTargetSection] = useState<string | undefined>(undefined)
|
||||
const [settingsInitialModelTab, setSettingsInitialModelTab] = useState<"recommended" | "free" | undefined>(undefined)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showAccount, setShowAccount] = useState(false)
|
||||
const [showWorktrees, setShowWorktrees] = useState(false)
|
||||
@@ -146,6 +149,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const hideSettings = useCallback(() => {
|
||||
setShowSettings(false)
|
||||
setSettingsTargetSection(undefined)
|
||||
setSettingsInitialModelTab(undefined)
|
||||
}, [])
|
||||
const hideHistory = useCallback(() => setShowHistory(false), [setShowHistory])
|
||||
const hideAccount = useCallback(() => setShowAccount(false), [setShowAccount])
|
||||
@@ -175,6 +179,20 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
setShowAccount(false)
|
||||
setShowWorktrees(false)
|
||||
setSettingsTargetSection(targetSection)
|
||||
setSettingsInitialModelTab(undefined)
|
||||
setShowSettings(true)
|
||||
},
|
||||
[closeMcpView],
|
||||
)
|
||||
|
||||
const navigateToSettingsModelPicker = useCallback(
|
||||
(opts: { targetSection?: string; initialModelTab?: "recommended" | "free" }) => {
|
||||
setShowHistory(false)
|
||||
closeMcpView()
|
||||
setShowAccount(false)
|
||||
setShowWorktrees(false)
|
||||
setSettingsTargetSection(opts.targetSection)
|
||||
setSettingsInitialModelTab(opts.initialModelTab)
|
||||
setShowSettings(true)
|
||||
},
|
||||
[closeMcpView],
|
||||
@@ -773,6 +791,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
mcpTab,
|
||||
showSettings,
|
||||
settingsTargetSection,
|
||||
settingsInitialModelTab,
|
||||
showHistory,
|
||||
showAccount,
|
||||
showWorktrees,
|
||||
@@ -793,6 +812,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
// Navigation functions
|
||||
navigateToMcp,
|
||||
navigateToSettings,
|
||||
navigateToSettingsModelPicker,
|
||||
navigateToHistory,
|
||||
navigateToAccount,
|
||||
navigateToWorktrees,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { McpServer } from "@shared/mcp"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getMatchingSlashCommands, getMcpPromptCommands, slashCommandRegex, validateSlashCommand } from "../slash-commands"
|
||||
|
||||
// Helper to create a mock MCP server
|
||||
function createMockMcpServer(overrides: Partial<McpServer> = {}): McpServer {
|
||||
return {
|
||||
name: "test-server",
|
||||
status: "connected",
|
||||
config: "{}",
|
||||
prompts: [],
|
||||
tools: [],
|
||||
resources: [],
|
||||
resourceTemplates: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("slash-commands", () => {
|
||||
describe("getMcpPromptCommands", () => {
|
||||
it("should return empty array when no servers provided", () => {
|
||||
const result = getMcpPromptCommands([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("should return empty array when servers have no prompts", () => {
|
||||
const servers = [createMockMcpServer({ prompts: [] })]
|
||||
const result = getMcpPromptCommands(servers)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("should skip disconnected servers", () => {
|
||||
const servers = [
|
||||
createMockMcpServer({
|
||||
status: "disconnected",
|
||||
prompts: [{ name: "test-prompt", description: "A test prompt" }],
|
||||
}),
|
||||
]
|
||||
const result = getMcpPromptCommands(servers)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("should skip servers with connecting status", () => {
|
||||
const servers = [
|
||||
createMockMcpServer({
|
||||
status: "connecting",
|
||||
prompts: [{ name: "test-prompt", description: "A test prompt" }],
|
||||
}),
|
||||
]
|
||||
const result = getMcpPromptCommands(servers)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("should generate commands for connected servers with prompts", () => {
|
||||
const servers = [
|
||||
createMockMcpServer({
|
||||
name: "my-server",
|
||||
prompts: [{ name: "summarize", description: "Summarize text" }],
|
||||
}),
|
||||
]
|
||||
const result = getMcpPromptCommands(servers)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "mcp:my-server:summarize",
|
||||
description: "Summarize text",
|
||||
section: "mcp",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should use title as fallback description", () => {
|
||||
const servers = [
|
||||
createMockMcpServer({
|
||||
name: "server",
|
||||
prompts: [{ name: "prompt", title: "My Prompt Title" }],
|
||||
}),
|
||||
]
|
||||
const result = getMcpPromptCommands(servers)
|
||||
expect(result[0].description).toBe("My Prompt Title")
|
||||
})
|
||||
|
||||
it("should use default description when no description or title", () => {
|
||||
const servers = [
|
||||
createMockMcpServer({
|
||||
name: "server",
|
||||
prompts: [{ name: "prompt" }],
|
||||
}),
|
||||
]
|
||||
const result = getMcpPromptCommands(servers)
|
||||
expect(result[0].description).toBe("MCP prompt from server")
|
||||
})
|
||||
|
||||
it("should handle multiple prompts from single server", () => {
|
||||
const servers = [
|
||||
createMockMcpServer({
|
||||
name: "multi-server",
|
||||
prompts: [
|
||||
{ name: "prompt1", description: "First prompt" },
|
||||
{ name: "prompt2", description: "Second prompt" },
|
||||
{ name: "prompt3", description: "Third prompt" },
|
||||
],
|
||||
}),
|
||||
]
|
||||
const result = getMcpPromptCommands(servers)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((c) => c.name)).toEqual([
|
||||
"mcp:multi-server:prompt1",
|
||||
"mcp:multi-server:prompt2",
|
||||
"mcp:multi-server:prompt3",
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle multiple servers with prompts", () => {
|
||||
const servers = [
|
||||
createMockMcpServer({
|
||||
name: "server-a",
|
||||
prompts: [{ name: "promptA", description: "From A" }],
|
||||
}),
|
||||
createMockMcpServer({
|
||||
name: "server-b",
|
||||
prompts: [{ name: "promptB", description: "From B" }],
|
||||
}),
|
||||
]
|
||||
const result = getMcpPromptCommands(servers)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].name).toBe("mcp:server-a:promptA")
|
||||
expect(result[1].name).toBe("mcp:server-b:promptB")
|
||||
})
|
||||
|
||||
it("should skip servers with undefined prompts", () => {
|
||||
const servers = [
|
||||
createMockMcpServer({
|
||||
name: "server",
|
||||
prompts: undefined,
|
||||
}),
|
||||
]
|
||||
const result = getMcpPromptCommands(servers)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMatchingSlashCommands with MCP servers", () => {
|
||||
const mcpServers = [
|
||||
createMockMcpServer({
|
||||
name: "test-server",
|
||||
prompts: [
|
||||
{ name: "summarize", description: "Summarize content" },
|
||||
{ name: "translate", description: "Translate text" },
|
||||
],
|
||||
}),
|
||||
]
|
||||
|
||||
it("should include MCP commands in results when no query", () => {
|
||||
const result = getMatchingSlashCommands("", {}, {}, undefined, undefined, mcpServers)
|
||||
const mcpCommands = result.filter((cmd) => cmd.section === "mcp")
|
||||
expect(mcpCommands).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should filter MCP commands by query prefix", () => {
|
||||
const result = getMatchingSlashCommands("mcp:test", {}, {}, undefined, undefined, mcpServers)
|
||||
const mcpCommands = result.filter((cmd) => cmd.section === "mcp")
|
||||
expect(mcpCommands).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should filter to specific MCP prompt", () => {
|
||||
const result = getMatchingSlashCommands("mcp:test-server:sum", {}, {}, undefined, undefined, mcpServers)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe("mcp:test-server:summarize")
|
||||
})
|
||||
|
||||
it("should return empty for non-matching MCP query", () => {
|
||||
const result = getMatchingSlashCommands("mcp:nonexistent", {}, {}, undefined, undefined, mcpServers)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateSlashCommand with MCP servers", () => {
|
||||
const mcpServers = [
|
||||
createMockMcpServer({
|
||||
name: "server",
|
||||
prompts: [{ name: "prompt", description: "Test" }],
|
||||
}),
|
||||
]
|
||||
|
||||
it("should return full for exact MCP command match", () => {
|
||||
const result = validateSlashCommand("mcp:server:prompt", {}, {}, undefined, undefined, mcpServers)
|
||||
expect(result).toBe("full")
|
||||
})
|
||||
|
||||
it("should return partial for partial MCP command match", () => {
|
||||
const result = validateSlashCommand("mcp:server:pro", {}, {}, undefined, undefined, mcpServers)
|
||||
expect(result).toBe("partial")
|
||||
})
|
||||
|
||||
it("should return partial for server prefix only", () => {
|
||||
const result = validateSlashCommand("mcp:serv", {}, {}, undefined, undefined, mcpServers)
|
||||
expect(result).toBe("partial")
|
||||
})
|
||||
|
||||
it("should return null for non-matching MCP command", () => {
|
||||
const result = validateSlashCommand("mcp:unknown:cmd", {}, {}, undefined, undefined, mcpServers)
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("slashCommandRegex with MCP format", () => {
|
||||
it("should match MCP command format with colons", () => {
|
||||
const text = "/mcp:server:prompt"
|
||||
const match = text.match(slashCommandRegex)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![2]).toBe("/mcp:server:prompt")
|
||||
})
|
||||
|
||||
it("should match MCP command in middle of text", () => {
|
||||
const text = "Please run /mcp:server:prompt now"
|
||||
const match = text.match(slashCommandRegex)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![2]).toBe("/mcp:server:prompt")
|
||||
})
|
||||
|
||||
it("should not match MCP-like pattern in URL", () => {
|
||||
const text = "http://example.com/mcp:test"
|
||||
const match = text.match(slashCommandRegex)
|
||||
// Should not match because / is not preceded by whitespace or start
|
||||
expect(match).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { McpServer } from "@shared/mcp"
|
||||
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
|
||||
import { BASE_SLASH_COMMANDS, type SlashCommand, VSCODE_ONLY_COMMANDS } from "../../../src/shared/slashCommands.ts"
|
||||
|
||||
export type { SlashCommand }
|
||||
|
||||
export const DEFAULT_SLASH_COMMANDS: SlashCommand[] =
|
||||
PLATFORM_CONFIG.type === PlatformType.VSCODE ? [...BASE_SLASH_COMMANDS, ...VSCODE_ONLY_COMMANDS] : BASE_SLASH_COMMANDS
|
||||
|
||||
@@ -67,14 +70,39 @@ export function getWorkflowCommands(
|
||||
return workflows
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MCP prompt commands from connected MCP servers
|
||||
* Format: mcp:<server-name>:<prompt-name>
|
||||
*/
|
||||
export function getMcpPromptCommands(mcpServers: McpServer[] = []): SlashCommand[] {
|
||||
const commands: SlashCommand[] = []
|
||||
|
||||
for (const server of mcpServers) {
|
||||
if (server.status !== "connected" || !server.prompts) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const prompt of server.prompts) {
|
||||
commands.push({
|
||||
name: `mcp:${server.name}:${prompt.name}`,
|
||||
description: prompt.description || prompt.title || `MCP prompt from ${server.name}`,
|
||||
section: "mcp",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
// Regex for detecting slash commands in text
|
||||
// Must be at start of string OR preceded by whitespace to avoid matching URLs/paths
|
||||
// e.g., matches "/newtask" or "text /newtask" but not "http://example.com/newtask"
|
||||
export const slashCommandRegex = /(^|\s)(\/[a-zA-Z0-9_.-]+)(?=\s|$)/
|
||||
// Note: Colons are allowed to support MCP prompt commands like /mcp:server:prompt
|
||||
export const slashCommandRegex = /(^|\s)(\/[a-zA-Z0-9_.:@-]+)(?=\s|$)/
|
||||
export const slashCommandRegexGlobal = new RegExp(slashCommandRegex.source, "g")
|
||||
// Regex for detecting a slash command at the end of text (for deletion)
|
||||
// Must be at start OR preceded by whitespace, captures the whole command including slash
|
||||
export const slashCommandDeleteRegex = /(^|\s)(\/[a-zA-Z0-9_.-]+)$/
|
||||
export const slashCommandDeleteRegex = /(^|\s)(\/[a-zA-Z0-9_.:@-]+)$/
|
||||
|
||||
/**
|
||||
* Removes a slash command at the cursor position
|
||||
@@ -133,7 +161,8 @@ export function shouldShowSlashCommandsMenu(text: string, cursorPosition: number
|
||||
// Check if there's already a valid slash command earlier in the text.
|
||||
// A valid earlier slash command is one that: starts at beginning or after whitespace,
|
||||
// and is followed by whitespace (meaning it's complete).
|
||||
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/
|
||||
// Note: Colons are allowed to support MCP prompt commands like /mcp:server:prompt
|
||||
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.:@-]+\s/
|
||||
const textBeforeCurrentSlash = text.slice(0, slashIndex)
|
||||
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
|
||||
return false
|
||||
@@ -151,6 +180,7 @@ export function getMatchingSlashCommands(
|
||||
globalWorkflowToggles: Record<string, boolean> = {},
|
||||
remoteWorkflowToggles?: Record<string, boolean>,
|
||||
remoteWorkflows?: any[],
|
||||
mcpServers: McpServer[] = [],
|
||||
): SlashCommand[] {
|
||||
const workflowCommands = getWorkflowCommands(
|
||||
localWorkflowToggles,
|
||||
@@ -158,7 +188,8 @@ export function getMatchingSlashCommands(
|
||||
remoteWorkflowToggles,
|
||||
remoteWorkflows,
|
||||
)
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands]
|
||||
const mcpPromptCommands = getMcpPromptCommands(mcpServers)
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands, ...mcpPromptCommands]
|
||||
|
||||
if (!query) {
|
||||
return allCommands
|
||||
@@ -201,6 +232,7 @@ export function validateSlashCommand(
|
||||
globalWorkflowToggles: Record<string, boolean> = {},
|
||||
remoteWorkflowToggles?: Record<string, boolean>,
|
||||
remoteWorkflows?: any[],
|
||||
mcpServers: McpServer[] = [],
|
||||
): "full" | "partial" | null {
|
||||
if (!command) {
|
||||
return null
|
||||
@@ -212,7 +244,8 @@ export function validateSlashCommand(
|
||||
remoteWorkflowToggles,
|
||||
remoteWorkflows,
|
||||
)
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands]
|
||||
const mcpPromptCommands = getMcpPromptCommands(mcpServers)
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands, ...mcpPromptCommands]
|
||||
|
||||
// case insensitive matching
|
||||
const exactMatch = allCommands.some((cmd) => cmd.name.toLowerCase() === command.toLowerCase())
|
||||
|
||||
Reference in New Issue
Block a user